From c05b05a007c213e921adde7c3382c7edf0730c60 Mon Sep 17 00:00:00 2001 From: rafaelscosta Date: Fri, 8 May 2026 00:25:18 -0300 Subject: [PATCH] feat(core): add fast path execution gate --- .../schemas/framework-config.schema.json | 28 ++ .../core/orchestration/fast-path-gate.js | 356 ++++++++++++++++++ .aiox-core/core/orchestration/index.js | 3 + .../development/tasks/fast-path-gate.md | 57 +++ .aiox-core/framework-config.yaml | 5 + .aiox-core/install-manifest.yaml | 20 +- tests/config/schema-validation.test.js | 4 + .../core/orchestration/fast-path-gate.test.js | 171 +++++++++ 8 files changed, 638 insertions(+), 6 deletions(-) create mode 100644 .aiox-core/core/orchestration/fast-path-gate.js create mode 100644 .aiox-core/development/tasks/fast-path-gate.md create mode 100644 tests/core/orchestration/fast-path-gate.test.js diff --git a/.aiox-core/core/config/schemas/framework-config.schema.json b/.aiox-core/core/config/schemas/framework-config.schema.json index d9a15f7d7a..e1c169eea2 100644 --- a/.aiox-core/core/config/schemas/framework-config.schema.json +++ b/.aiox-core/core/config/schemas/framework-config.schema.json @@ -97,6 +97,34 @@ "type": "boolean", "description": "Require orchestrator review before story state mutation", "default": true + }, + "fast_path": { + "type": "object", + "description": "Fast-path gate defaults for mechanical, batchable, or parallelizable tasks", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "min_confidence": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.58 + }, + "min_batch_items": { + "type": "integer", + "minimum": 1, + "default": 3 + }, + "external_executor_threshold": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.78 + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/.aiox-core/core/orchestration/fast-path-gate.js b/.aiox-core/core/orchestration/fast-path-gate.js new file mode 100644 index 0000000000..cfa97a3008 --- /dev/null +++ b/.aiox-core/core/orchestration/fast-path-gate.js @@ -0,0 +1,356 @@ +'use strict'; + +const DEFAULT_FAST_PATH_CONFIG = Object.freeze({ + enabled: true, + externalExecutorsEnabled: false, + minConfidence: 0.58, + minBatchItems: 3, + externalExecutorThreshold: 0.78, +}); + +const STRUCTURED_FILE_EXTENSIONS_FROZEN = Object.freeze([ + '.csv', + '.json', + '.jsonl', + '.md', + '.toml', + '.tsv', + '.txt', + '.yaml', + '.yml', +]); + +const STRUCTURED_FILE_EXTENSION_SET = new Set(STRUCTURED_FILE_EXTENSIONS_FROZEN); + +const AUTOMATION_PATTERNS_FROZEN = Object.freeze([ + { + id: 'bulk-edit', + weight: 3, + pattern: /\b(batch|bulk|many files|multiple files|all files|in one shot|one shot)\b/i, + }, + { + id: 'structured-transform', + weight: 3, + pattern: /\b(yaml|json|csv|markdown|frontmatter|schema|variable|variables|field|fields)\b/i, + }, + { + id: 'mechanical-edit', + weight: 3, + pattern: /\b(replace|rename|populate|fill|complete|update|convert|transform|normalize|format)\b/i, + }, + { + id: 'map-then-apply', + weight: 2, + pattern: /\b(map|extract|derive|template|codemod|script)\b/i, + }, + { + id: 'repetition', + weight: 2, + pattern: /\b(repeated|repetitive|same change|similar change|dumb task|tedious)\b/i, + }, + { + id: 'parallelizable', + weight: 2, + pattern: /\b(parallel|independent|per file|per item|per record)\b/i, + }, +].map(Object.freeze)); + +const RISK_PATTERNS_FROZEN = Object.freeze([ + { + id: 'architecture', + weight: 3, + pattern: /\b(architecture|architectural|design decision|adr|contract)\b/i, + }, + { + id: 'security', + weight: 3, + pattern: /\b(security|secret|token|credential|auth|permission|pii|rls)\b/i, + }, + { + id: 'destructive', + weight: 3, + pattern: /\b(delete|remove data|drop|reset|rewrite history|destructive)\b/i, + }, + { + id: 'production', + weight: 2, + pattern: /\b(production|prod|release|billing|payment|customer)\b/i, + }, + { + id: 'migration', + weight: 2, + pattern: /\b(migration|migrate|schema change|breaking change)\b/i, + }, +].map(Object.freeze)); + +function clonePatternDefinition(patternDefinition) { + return { + id: patternDefinition.id, + weight: patternDefinition.weight, + pattern: new RegExp(patternDefinition.pattern.source, patternDefinition.pattern.flags), + }; +} + +function getStructuredFileExtensions() { + return new Set(STRUCTURED_FILE_EXTENSIONS_FROZEN); +} + +function getAutomationPatterns() { + return AUTOMATION_PATTERNS_FROZEN.map(clonePatternDefinition); +} + +function getRiskPatterns() { + return RISK_PATTERNS_FROZEN.map(clonePatternDefinition); +} + +function parseBoolean(value, fallback) { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (normalized === 'true') { + return true; + } + if (normalized === 'false') { + return false; + } + } + return fallback; +} + +function normalizeConfig(config = {}) { + const clamp01 = (value, fallback) => { + const numericValue = Number(value); + if (!Number.isFinite(numericValue)) { + return fallback; + } + return Math.min(1, Math.max(0, numericValue)); + }; + const positiveInteger = (value, fallback) => { + const numericValue = Number(value); + if (!Number.isFinite(numericValue)) { + return fallback; + } + return Math.max(1, Math.floor(numericValue)); + }; + + return { + enabled: parseBoolean(config.enabled, DEFAULT_FAST_PATH_CONFIG.enabled), + externalExecutorsEnabled: parseBoolean( + config.externalExecutorsEnabled ?? config.external_executors_enabled, + DEFAULT_FAST_PATH_CONFIG.externalExecutorsEnabled, + ), + minConfidence: clamp01( + config.minConfidence ?? config.min_confidence, + DEFAULT_FAST_PATH_CONFIG.minConfidence, + ), + minBatchItems: positiveInteger( + config.minBatchItems ?? config.min_batch_items, + DEFAULT_FAST_PATH_CONFIG.minBatchItems, + ), + externalExecutorThreshold: clamp01( + config.externalExecutorThreshold ?? config.external_executor_threshold, + DEFAULT_FAST_PATH_CONFIG.externalExecutorThreshold, + ), + }; +} + +function normalizeTask(input = {}) { + const task = input.task || input; + return { + description: String(task.description || task.summary || task.title || ''), + files: Array.isArray(task.files) ? task.files : [], + acceptanceCriteria: Array.isArray(task.acceptanceCriteria) + ? task.acceptanceCriteria + : Array.isArray(task.acceptance_criteria) + ? task.acceptance_criteria + : [], + itemCount: Number.isFinite(task.itemCount) + ? task.itemCount + : Number.isFinite(task.item_count) + ? task.item_count + : null, + }; +} + +function normalizePathExtension(filePath) { + const match = String(filePath || '').toLowerCase().match(/(\.[a-z0-9]+)$/); + return match ? match[1] : ''; +} + +function collectSignals(patterns, text) { + return patterns + .filter(({ pattern }) => pattern.test(text)) + .map(({ id, weight }) => ({ id, weight })); +} + +function getTaskText(task) { + return [ + task.description, + ...task.acceptanceCriteria.map((criterion) => String(criterion || '')), + ...task.files.map((file) => String(file || '')), + ].join('\n'); +} + +function scoreFastPath({ automationSignals, riskSignals, files, structuredFileCount, batchSize }) { + const automationWeight = automationSignals.reduce((sum, signal) => sum + signal.weight, 0); + const riskWeight = riskSignals.reduce((sum, signal) => sum + signal.weight, 0); + const fileWeight = Math.min(files.length, 8) * 0.45; + const structuredWeight = Math.min(structuredFileCount, 6) * 0.55; + const batchWeight = Math.min(batchSize, 10) * 0.35; + + const rawScore = automationWeight + fileWeight + structuredWeight + batchWeight - riskWeight * 1.35; + return Math.max(0, Math.min(1, rawScore / 13)); +} + +function chooseMode({ + confidence, + config, + externalExecutorsEnabled, + parallelizable, + structuredFileCount, + batchSize, +}) { + if (externalExecutorsEnabled && confidence >= config.externalExecutorThreshold) { + return 'external_executor'; + } + + if (parallelizable && batchSize >= config.minBatchItems) { + return 'parallel_batch'; + } + + if (structuredFileCount > 0 || batchSize >= config.minBatchItems) { + return 'deterministic_batch'; + } + + return 'standard'; +} + +function buildActions(mode) { + if (mode === 'external_executor') { + return [ + 'Prepare a bounded prompt with target files, schema, acceptance criteria, and validation commands.', + 'Run a dry-run plan first, then delegate with the configured external executor sandbox.', + 'Review the executor diff before mutating story or issue state.', + ]; + } + + if (mode === 'parallel_batch') { + return [ + 'Map target files or records once before editing.', + 'Group independent changes by file or record and apply them as a batch.', + 'Run targeted validation on the changed surface before broader checks.', + ]; + } + + if (mode === 'deterministic_batch') { + return [ + 'Extract the data shape and replacement rules before editing.', + 'Use a deterministic transform or structured parser instead of conversational one-by-one edits.', + 'Validate output syntax and diff size before continuing.', + ]; + } + + return [ + 'Use the standard story/task workflow.', + 'Keep changes sequential when risk or ambiguity is higher than the automation signal.', + ]; +} + +function evaluateFastPath(input = {}) { + const config = normalizeConfig(input.config || input.fastPath || {}); + const task = normalizeTask(input); + const text = getTaskText(task); + const automationSignals = collectSignals(AUTOMATION_PATTERNS_FROZEN, text); + const riskSignals = collectSignals(RISK_PATTERNS_FROZEN, text); + const structuredFileCount = task.files.filter((file) => ( + STRUCTURED_FILE_EXTENSION_SET.has(normalizePathExtension(file)) + )).length; + const batchSize = task.itemCount ?? Math.max(task.files.length, structuredFileCount); + const parallelizable = ( + task.files.length >= config.minBatchItems || + automationSignals.some((signal) => signal.id === 'parallelizable') + ); + + if (!config.enabled) { + return { + gate: 'fast_path', + enabled: false, + passed: false, + mode: 'standard', + confidence: 0, + parallelizable: false, + riskLevel: 'unknown', + reasons: ['fast path gate disabled by configuration'], + evidence: { automationSignals: [], riskSignals: [], fileCount: task.files.length, structuredFileCount, batchSize }, + actions: buildActions('standard'), + }; + } + + const confidence = scoreFastPath({ + automationSignals, + riskSignals, + files: task.files, + structuredFileCount, + batchSize, + }); + const passed = confidence >= config.minConfidence && riskSignals.length === 0; + const mode = passed + ? chooseMode({ + confidence, + config, + externalExecutorsEnabled: parseBoolean( + input.externalExecutorsEnabled ?? input.external_executors_enabled, + config.externalExecutorsEnabled, + ), + parallelizable, + structuredFileCount, + batchSize, + }) + : 'standard'; + const riskLevel = riskSignals.length >= 2 ? 'high' : riskSignals.length === 1 ? 'medium' : 'low'; + const reasons = [ + ...automationSignals.map((signal) => `automation signal: ${signal.id}`), + ...riskSignals.map((signal) => `risk signal: ${signal.id}`), + ]; + + if (structuredFileCount > 0) { + reasons.push(`structured files detected: ${structuredFileCount}`); + } + if (batchSize >= config.minBatchItems) { + reasons.push(`batch size meets threshold: ${batchSize}`); + } + if (!passed && reasons.length === 0) { + reasons.push('insufficient automation signal for fast path'); + } + + return { + gate: 'fast_path', + enabled: true, + passed, + mode, + confidence, + parallelizable: passed && parallelizable, + riskLevel, + reasons, + evidence: { + automationSignals, + riskSignals, + fileCount: task.files.length, + structuredFileCount, + batchSize, + }, + actions: buildActions(mode), + }; +} + +module.exports = { + DEFAULT_FAST_PATH_CONFIG, + evaluateFastPath, + getAutomationPatterns, + getRiskPatterns, + getStructuredFileExtensions, + normalizeConfig, + normalizeTask, +}; diff --git a/.aiox-core/core/orchestration/index.js b/.aiox-core/core/orchestration/index.js index c333975cbb..5fb6610dfb 100644 --- a/.aiox-core/core/orchestration/index.js +++ b/.aiox-core/core/orchestration/index.js @@ -23,6 +23,7 @@ const TechStackDetector = require('./tech-stack-detector'); const ConditionEvaluator = require('./condition-evaluator'); const SkillDispatcher = require('./skill-dispatcher'); const executionProfileResolver = require('./execution-profile-resolver'); +const fastPathGate = require('./fast-path-gate'); // Epic 0: Master Orchestrator (ADE) const MasterOrchestrator = require('./master-orchestrator'); @@ -162,6 +163,8 @@ module.exports = { SkillDispatcher, ExecutionProfileResolver: executionProfileResolver, resolveExecutionProfile: executionProfileResolver.resolveExecutionProfile, + FastPathGate: fastPathGate, + evaluateFastPath: fastPathGate.evaluateFastPath, // Epic 0: Orchestrator constants OrchestratorState: MasterOrchestrator.OrchestratorState, diff --git a/.aiox-core/development/tasks/fast-path-gate.md b/.aiox-core/development/tasks/fast-path-gate.md new file mode 100644 index 0000000000..d7b5f4b4da --- /dev/null +++ b/.aiox-core/development/tasks/fast-path-gate.md @@ -0,0 +1,57 @@ +# Fast Path Gate + +## Purpose + +Choose the fastest safe execution path before starting a task. This gate prevents slow one-by-one conversational edits for mechanical work such as YAML population, bulk replacements, structured data normalization, and repeated per-file updates. + +## Elicitation + +Both checkpoints below must be answered before proceeding to mode selection or invoking external delegation. + +### Before mode selection + +- Clarify any ambiguous `description` details, including the exact repeated operation and expected output shape. +- Validate the intended `files` list and `itemCount`; record whether targets are independent and complete. +- Confirm `acceptanceCriteria`, including syntax checks, diff review, and targeted validation commands. +- Confirm whether the task includes security, production, destructive, migration, architectural, or credential risk. + +### Before external delegation + +- Explicitly request user permission to use `externalExecutorsEnabled`. +- Confirm the allowed executor, sandbox, writable paths, timeout, retry limit, and failure fallback. +- Confirm that no secrets, production credentials, or protected customer data will be sent to the executor. +- Record any delegation constraints before running `aiox-delegate`. + +## Inputs + +- `description` — task summary or user request +- `files` — known target files +- `acceptanceCriteria` — expected outcomes +- `itemCount` — optional number of records, fields, or files to process +- `externalExecutorsEnabled` — whether delegation through `aiox-delegate` is allowed + +## Execution + +1. Run the "Before mode selection" elicitation checkpoint and record scope, intended files, security/production risk, acceptance criteria, and sandbox constraints. +2. Evaluate the task with `evaluateFastPath()` from `.aiox-core/core/orchestration/fast-path-gate.js`. +3. If `evaluateFastPath()` throws, returns invalid output, or returns an unknown mode, set `mode: standard`, record the error and fallback decision in evidence/logs, and continue with the normal story/task workflow. +4. If the gate returns `mode: standard`, continue with the normal story/task workflow. +5. If the gate returns `mode: deterministic_batch`, extract the schema and write one deterministic transform or structured edit plan before changing files. +6. If the gate returns `mode: parallel_batch`, map all independent targets first, then apply grouped edits in parallel batches. +7. If the gate returns `mode: external_executor`, run the "Before external delegation" elicitation checkpoint, create a bounded executor prompt, and run `aiox-delegate` with the configured sandbox, timeout, and retry limit. +8. If `aiox-delegate` times out, exhausts retries, or fails, record the executor error, fall back to `mode: standard`, resume the normal workflow, and surface the failure in targeted validation. +9. Always review the resulting diff and run targeted validation before story or issue closure. + +## Acceptance Criteria + +- Mechanical repeated tasks are not executed as long sequential conversational edits. +- Security, production, destructive, migration, and architectural tasks fall back to the standard workflow unless explicitly re-scoped. +- Fast-path decisions include confidence, reasons, evidence, and next actions. +- External executor usage remains opt-in and sandboxed by configuration. + +## Anti-Patterns + +- Do not use fast path for ambiguous architecture or security decisions. +- Do not skip validation because a task is mechanical. +- Do not delegate externally when the task contains secrets or production risk. +- Do not mutate story or issue state until the diff has been reviewed. diff --git a/.aiox-core/framework-config.yaml b/.aiox-core/framework-config.yaml index 8162408029..3f5195d5f7 100644 --- a/.aiox-core/framework-config.yaml +++ b/.aiox-core/framework-config.yaml @@ -51,6 +51,11 @@ dev: execution_mode: native # native | delegate delegate_to: codex auto_review: true + fast_path: + enabled: true + min_confidence: 0.58 + min_batch_items: 3 + external_executor_threshold: 0.78 external_executors: enabled: false diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index 729287cba3..c9b12f96b9 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,9 +8,9 @@ # - File types for categorization # version: 5.1.15 -generated_at: "2026-05-08T02:58:22.301Z" +generated_at: "2026-05-08T03:56:46.901Z" generator: scripts/generate-install-manifest.js -file_count: 1109 +file_count: 1111 files: - path: cli/commands/config/index.js hash: sha256:25c4b9bf4e0241abf7754b55153f49f1a214f1fb5fe904a576675634cb7b3da9 @@ -265,9 +265,9 @@ files: type: core size: 8156 - path: core/config/schemas/framework-config.schema.json - hash: sha256:1becd882f8077e0f8c95ce1ca395b05bf03fa7a1726f67c0bc5fed0ae59c3f27 + hash: sha256:f1cfea4db66373ce7624584e56558f35885407eec48ee93f9c14cc1fc9131af2 type: core - size: 7463 + size: 8288 - path: core/config/schemas/local-config.schema.json hash: sha256:0349e44aea7b3dfe051ce586eb201f25edab8a7ea6d054704034f877848aa43f type: core @@ -892,6 +892,10 @@ files: hash: sha256:21f66b6d59c67079bfd6f30dcb675bab60d8a3b6283c24246497d641beed1f57 type: core size: 1945 + - path: core/orchestration/fast-path-gate.js + hash: sha256:ecd04d15600bcbf1c717cfe5f1ac3ce38bdde30275fd38f7f48f96cf0757b249 + type: core + size: 10211 - path: core/orchestration/gate-evaluator.js hash: sha256:4e9e78745f937ee78b13704f3acdb45b19d4aab9bc0f17a5adaf5eecfc58e653 type: core @@ -905,9 +909,9 @@ files: type: core size: 35723 - path: core/orchestration/index.js - hash: sha256:8baea00058c1dcae86328fff06b04c52cfad8ad9a77e3f49458f062609aa478a + hash: sha256:eaebc2df86ad8fdd91082c4b1f007f967b2de89eb1ac4e5902e3b46b2eb5b169 type: core - size: 8835 + size: 8966 - path: core/orchestration/lock-manager.js hash: sha256:30af501b40da2a7bcfd591c62e367c5667eefd40af91d265c986521c93e7d1f2 type: core @@ -2112,6 +2116,10 @@ files: hash: sha256:c351428e7aa1af079046bbf357af98668675943fd13920b98b7ecfd9f87a6081 type: task size: 13901 + - path: development/tasks/fast-path-gate.md + hash: sha256:d28e2def9134ffe79e04ebcfa25de651d659471eab07367fa4c7992245f79fe2 + type: task + size: 3632 - path: development/tasks/generate-ai-frontend-prompt.md hash: sha256:d4c2abf28b065922f1e67c95fa2a69dd792c9828c6dd31d2fc173a5361b021aa type: task diff --git a/tests/config/schema-validation.test.js b/tests/config/schema-validation.test.js index 3af1f4da6a..4a7c7a90a3 100644 --- a/tests/config/schema-validation.test.js +++ b/tests/config/schema-validation.test.js @@ -119,6 +119,10 @@ describe('schema-validation — enriched schemas', () => { expect(schema.properties.external_executors.properties.default_sandbox.enum).toContain('full-auto'); expect(frameworkConfig.dev.execution_mode).toBe('native'); + expect(frameworkConfig.dev.fast_path.enabled).toBe(true); + expect(frameworkConfig.dev.fast_path.min_confidence).toBe(0.58); + expect(frameworkConfig.dev.fast_path.min_batch_items).toBe(3); + expect(frameworkConfig.dev.fast_path.external_executor_threshold).toBe(0.78); expect(frameworkConfig.external_executors.enabled).toBe(false); expect(frameworkConfig.external_executors.default_sandbox).toBe('workspace-write'); }); diff --git a/tests/core/orchestration/fast-path-gate.test.js b/tests/core/orchestration/fast-path-gate.test.js new file mode 100644 index 0000000000..d594366f48 --- /dev/null +++ b/tests/core/orchestration/fast-path-gate.test.js @@ -0,0 +1,171 @@ +'use strict'; + +const { + DEFAULT_FAST_PATH_CONFIG, + evaluateFastPath, + getAutomationPatterns, + getStructuredFileExtensions, + normalizeConfig, +} = require('@aiox-core/core/orchestration/fast-path-gate'); +const orchestration = require('@aiox-core/core/orchestration'); + +describe('fast path gate', () => { + it('exports through the orchestration module', () => { + expect(orchestration.FastPathGate.evaluateFastPath).toBe(evaluateFastPath); + expect(orchestration.evaluateFastPath).toBe(evaluateFastPath); + }); + + it('recommends a parallel batch for mechanical YAML population', () => { + const result = evaluateFastPath({ + description: 'Populate YAML variables from markdown context across multiple files in one shot', + files: [ + 'pro/a.yaml', + 'pro/b.yaml', + 'pro/c.yaml', + 'pro/d.yaml', + ], + acceptanceCriteria: ['All YAML fields are completed', 'YAML syntax validates'], + itemCount: 12, + }); + + expect(result.passed).toBe(true); + expect(result.mode).toBe('parallel_batch'); + expect(result.parallelizable).toBe(true); + expect(result.confidence).toBeGreaterThanOrEqual(DEFAULT_FAST_PATH_CONFIG.minConfidence); + expect(result.reasons).toContain('automation signal: structured-transform'); + expect(result.reasons).toContain('batch size meets threshold: 12'); + expect(result.actions.join(' ')).toMatch(/Map target files/i); + }); + + it('falls back to standard mode when risk signals are present', () => { + const result = evaluateFastPath({ + description: 'Bulk update production auth permissions and token handling', + files: ['auth.yaml', 'permissions.yaml', 'secrets.yaml'], + itemCount: 6, + }); + + expect(result.passed).toBe(false); + expect(result.mode).toBe('standard'); + expect(result.parallelizable).toBe(false); + expect(result.riskLevel).toBe('high'); + expect(result.reasons).toContain('risk signal: security'); + expect(result.reasons).toContain('risk signal: production'); + }); + + it('can recommend an external executor when enabled and confidence is high', () => { + const result = evaluateFastPath({ + description: [ + 'Batch replace and normalize variables in many YAML and JSON files', + 'Use a script to map fields per file and apply independent changes in parallel', + ].join('. '), + files: [ + 'data/a.yaml', + 'data/b.yaml', + 'data/c.json', + 'data/d.json', + 'data/e.md', + 'data/f.yml', + ], + itemCount: 20, + externalExecutorsEnabled: true, + }); + + expect(result.passed).toBe(true); + expect(result.mode).toBe('external_executor'); + expect(result.confidence).toBeGreaterThanOrEqual(DEFAULT_FAST_PATH_CONFIG.externalExecutorThreshold); + expect(result.actions.join(' ')).toMatch(/delegate/i); + }); + + it('keeps the standard workflow when disabled by configuration', () => { + const result = evaluateFastPath({ + description: 'Replace variables in multiple YAML files', + files: ['a.yaml', 'b.yaml', 'c.yaml'], + config: { enabled: false }, + }); + + expect(result.enabled).toBe(false); + expect(result.passed).toBe(false); + expect(result.mode).toBe('standard'); + expect(result.reasons).toEqual(['fast path gate disabled by configuration']); + }); + + it('normalizes snake_case config from YAML defaults', () => { + const config = normalizeConfig({ + min_confidence: 0.6, + min_batch_items: 4, + external_executor_threshold: 0.85, + }); + + expect(config.minConfidence).toBe(0.6); + expect(config.minBatchItems).toBe(4); + expect(config.externalExecutorThreshold).toBe(0.85); + }); + + it('clamps unsafe config overrides and normalizes explicit booleans', () => { + const config = normalizeConfig({ + enabled: 'false', + minConfidence: 2, + minBatchItems: 0, + externalExecutorThreshold: -1, + externalExecutorsEnabled: 'true', + }); + + expect(config.enabled).toBe(false); + expect(config.minConfidence).toBe(1); + expect(config.minBatchItems).toBe(1); + expect(config.externalExecutorThreshold).toBe(0); + expect(config.externalExecutorsEnabled).toBe(true); + + expect(normalizeConfig({ enabled: 'not-a-boolean' }).enabled).toBe(DEFAULT_FAST_PATH_CONFIG.enabled); + expect(normalizeConfig({ externalExecutorsEnabled: 'not-a-boolean' }).externalExecutorsEnabled) + .toBe(DEFAULT_FAST_PATH_CONFIG.externalExecutorsEnabled); + }); + + it('does not treat truthy boolean-like strings as external executor enablement', () => { + const baseInput = { + description: [ + 'Batch replace and normalize variables in many YAML and JSON files', + 'Use a script to map fields per file and apply independent changes in parallel', + ].join('. '), + files: [ + 'data/a.yaml', + 'data/b.yaml', + 'data/c.json', + 'data/d.json', + 'data/e.md', + 'data/f.yml', + ], + itemCount: 20, + }; + + expect(evaluateFastPath({ ...baseInput, externalExecutorsEnabled: 'false' }).mode).toBe('parallel_batch'); + expect(evaluateFastPath({ ...baseInput, externalExecutorsEnabled: 'yes' }).mode).toBe('parallel_batch'); + }); + + it('preserves itemCount zero', () => { + const result = evaluateFastPath({ + description: 'Replace variables in multiple YAML files', + files: ['a.yaml', 'b.yaml', 'c.yaml'], + itemCount: 0, + }); + + expect(result.evidence.batchSize).toBe(0); + }); + + it('exports defensive copies of fast path signal metadata', () => { + const extensions = getStructuredFileExtensions(); + extensions.add('.mutated'); + + expect(getStructuredFileExtensions().has('.mutated')).toBe(false); + + const patterns = getAutomationPatterns(); + patterns[0].id = 'mutated'; + patterns[0].pattern = /mutated/; + patterns.push({ id: 'new-pattern', weight: 99, pattern: /new-pattern/ }); + + const nextPatterns = getAutomationPatterns(); + expect(nextPatterns[0].id).toBe('bulk-edit'); + expect(nextPatterns[0].pattern.test('batch')).toBe(true); + expect(nextPatterns).toHaveLength(6); + }); +});