diff --git a/sdk/tests/backlog-picker-flow.test.ts b/sdk/tests/backlog-picker-flow.test.ts index a7ad70648..2cb8e41de 100644 --- a/sdk/tests/backlog-picker-flow.test.ts +++ b/sdk/tests/backlog-picker-flow.test.ts @@ -92,3 +92,52 @@ describe('backlog-picker flow', () => { } }); }); + +describe('backlog-picker canonical spec', () => { + it('stays in sync with the flow yaml', () => { + // The canonical spec is what the kernel consumes. PR #22 fixed the path + // matcher in the yaml and left the canonical spec carrying the old + // permissive one, so the fix did not reach the thing that runs — review + // caught it. A divergence between the two is silent by nature: both files + // are valid, the tests over the yaml pass, and the kernel keeps executing + // the stale command. + const root = join(__dirname, '..', '..'); + const flow = load(readFileSync(join(root, 'testdata', 'backlog-picker.flow.yaml'), 'utf8')) as { + steps: Array<{ id: string; command?: string }>; + }; + const canonical = JSON.parse( + readFileSync(join(root, 'testdata', 'backlog-picker.spec.canonical.json'), 'utf8'), + ) as { steps: Array<{ id: string; command?: string }> }; + + const canonicalById = new Map(canonical.steps.map((s) => [s.id, s.command])); + for (const step of flow.steps) { + if (step.command === undefined) continue; + expect(canonicalById.get(step.id), `step "${step.id}" diverges from the canonical spec`).toBe( + step.command, + ); + } + }); + + it('keeps directories and extensionless paths, and rejects prose', () => { + const steps = stepCommands(); + const dir = mkdtempSync(join(tmpdir(), 'backlog-scope-')); + try { + mkdirSync(join(dir, 'ops'), { recursive: true }); + writeFileSync( + join(dir, 'ops', 'BACKLOG.md'), + '# Backlog\n\n- **Scope entry** touches `regressions/` and `src/Dockerfile` and `ops/BACKLOG.md`,\n' + + ' but a path that merely contains `/` is prose, not a file.\n', + ); + run(steps['read-backlog'], dir); + run(steps['select-entry'], dir); + const emitted = JSON.parse(run(steps['emit-package'], dir)) as { files_in_scope: string[] }; + + expect(emitted.files_in_scope).toContain('regressions/'); + expect(emitted.files_in_scope).toContain('src/Dockerfile'); + expect(emitted.files_in_scope).toContain('ops/BACKLOG.md'); + expect(emitted.files_in_scope).not.toContain('/'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/sdk/tests/backlog-picker.test.ts b/sdk/tests/backlog-picker.test.ts index 160364ca1..05133f4cd 100644 --- a/sdk/tests/backlog-picker.test.ts +++ b/sdk/tests/backlog-picker.test.ts @@ -1,3 +1,8 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { load } from 'js-yaml'; import { describe, expect, it } from 'vitest'; import { renderWorkPackage, selectBacklogEntry } from '../src/backlog-picker.js'; @@ -44,4 +49,31 @@ describe('backlog picker', () => { const md = 'Some **bold prose** in a paragraph.\n\n- **Real entry** yes\n'; expect(selectBacklogEntry(md)?.title).toBe('Real entry'); }); + + it('does not treat backticked prose containing a slash as a file in scope', () => { + const flowPath = join(__dirname, '..', '..', 'testdata', 'backlog-picker.flow.yaml'); + const flow = load(readFileSync(flowPath, 'utf8')) as { + steps: Array<{ id: string; command: string }>; + }; + const command = flow.steps.find((step) => step.id === 'emit-package')?.command; + expect(command).toBeDefined(); + + const dir = mkdtempSync(join(tmpdir(), 'backlog-picker-paths-')); + try { + mkdirSync(join(dir, '.relayflow')); + writeFileSync( + join(dir, '.relayflow', 'backlog-picker-entry.json'), + JSON.stringify({ + title: 'Fix gate 3', + body: 'Update `src/file.ts`; prose that contains `/` is not a path.', + }), + ); + + const output = execFileSync('sh', ['-c', command!], { cwd: dir, encoding: 'utf8' }); + const workPackage = JSON.parse(output) as { files_in_scope: string[] }; + expect(workPackage.files_in_scope).toEqual(['src/file.ts']); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); diff --git a/testdata/backlog-picker.flow.yaml b/testdata/backlog-picker.flow.yaml index 030f216b1..612aebb22 100644 --- a/testdata/backlog-picker.flow.yaml +++ b/testdata/backlog-picker.flow.yaml @@ -16,4 +16,4 @@ steps: type: deterministic dependsOn: [select-entry] command: >- - node -e 'const fs=require("node:fs");const entry=JSON.parse(fs.readFileSync(".relayflow/backlog-picker-entry.json","utf8"));const text=entry.title+" "+entry.body;const files=[...new Set([...text.matchAll(/`([^`\s]*\/[^`]*)`/g)].map(match=>match[1]))];const gate=text.match(/\bgate[ -]?(\d+)\b/i);process.stdout.write(JSON.stringify({title:entry.title,description:entry.body,files_in_scope:files,gate:gate?Number(gate[1]):null}))' + node -e 'const fs=require("node:fs");const entry=JSON.parse(fs.readFileSync(".relayflow/backlog-picker-entry.json","utf8"));const text=entry.title+" "+entry.body;const files=[...new Set([...text.matchAll(/`([A-Za-z_][A-Za-z0-9._-]*(?:\/[A-Za-z0-9._-]*)+)`/g)].map(match=>match[1]).filter(candidate=>!/^\/|\/\//.test(candidate)))];const gate=text.match(/\bgate[ -]?(\d+)\b/i);process.stdout.write(JSON.stringify({title:entry.title,description:entry.body,files_in_scope:files,gate:gate?Number(gate[1]):null}))' diff --git a/testdata/backlog-picker.spec.canonical.json b/testdata/backlog-picker.spec.canonical.json index 874d6c1aa..67cb2d148 100644 --- a/testdata/backlog-picker.spec.canonical.json +++ b/testdata/backlog-picker.spec.canonical.json @@ -1 +1 @@ -{"description":"Read ops/BACKLOG.md and deterministically emit its first work package.","name":"backlog-picker","steps":[{"command":"mkdir -p .relayflow && cat ops/BACKLOG.md > .relayflow/backlog-picker-source.md","depends_on":[],"id":"read-backlog","max_iterations":1,"retry":{"initial_backoff_ms":100,"jitter_percent":20,"max_backoff_ms":60000,"multiplier":2},"type":"deterministic","verification":{}},{"command":"node -e 'const fs=require(\"node:fs\");const text=fs.readFileSync(\".relayflow/backlog-picker-source.md\",\"utf8\");const match=text.match(/^- \\*\\*(.+?)\\*\\*\\s*(.*(?:\\n .*)*)/m);if(!match)process.exit(1);const entry={title:match[1],body:match[2].replace(/\\s+/g,\" \").trim()};fs.writeFileSync(\".relayflow/backlog-picker-entry.json\",JSON.stringify(entry));process.stdout.write(JSON.stringify(entry))'","depends_on":["read-backlog"],"id":"select-entry","max_iterations":1,"retry":{"initial_backoff_ms":100,"jitter_percent":20,"max_backoff_ms":60000,"multiplier":2},"type":"deterministic","verification":{}},{"command":"node -e 'const fs=require(\"node:fs\");const entry=JSON.parse(fs.readFileSync(\".relayflow/backlog-picker-entry.json\",\"utf8\"));const text=entry.title+\" \"+entry.body;const files=[...new Set([...text.matchAll(/`([^`\\s]*\\/[^`]*)`/g)].map(match=>match[1]))];const gate=text.match(/\\bgate[ -]?(\\d+)\\b/i);process.stdout.write(JSON.stringify({title:entry.title,description:entry.body,files_in_scope:files,gate:gate?Number(gate[1]):null}))'","depends_on":["select-entry"],"id":"emit-package","max_iterations":1,"retry":{"initial_backoff_ms":100,"jitter_percent":20,"max_backoff_ms":60000,"multiplier":2},"type":"deterministic","verification":{}}],"version":"0.1.0"} +{"description":"Read ops/BACKLOG.md and deterministically emit its first work package.","name":"backlog-picker","steps":[{"command":"mkdir -p .relayflow && cat ops/BACKLOG.md > .relayflow/backlog-picker-source.md","depends_on":[],"id":"read-backlog","max_iterations":1,"retry":{"initial_backoff_ms":100,"jitter_percent":20,"max_backoff_ms":60000,"multiplier":2},"type":"deterministic","verification":{}},{"command":"node -e 'const fs=require(\"node:fs\");fs.rmSync(\".relayflow/backlog-picker-entry.json\",{force:true});const text=fs.readFileSync(\".relayflow/backlog-picker-source.md\",\"utf8\");const match=text.match(/^- \\*\\*(.+?)\\*\\*\\s*(.*(?:\\n .*)*)/m);if(!match)process.exit(1);const entry={title:match[1],body:match[2].replace(/\\s+/g,\" \").trim()};fs.writeFileSync(\".relayflow/backlog-picker-entry.json\",JSON.stringify(entry));process.stdout.write(JSON.stringify(entry))'","depends_on":["read-backlog"],"id":"select-entry","max_iterations":1,"retry":{"initial_backoff_ms":100,"jitter_percent":20,"max_backoff_ms":60000,"multiplier":2},"type":"deterministic","verification":{}},{"command":"node -e 'const fs=require(\"node:fs\");const entry=JSON.parse(fs.readFileSync(\".relayflow/backlog-picker-entry.json\",\"utf8\"));const text=entry.title+\" \"+entry.body;const files=[...new Set([...text.matchAll(/`([A-Za-z_][A-Za-z0-9._-]*(?:\\/[A-Za-z0-9._-]*)+)`/g)].map(match=>match[1]).filter(candidate=>!/^\\/|\\/\\//.test(candidate)))];const gate=text.match(/\\bgate[ -]?(\\d+)\\b/i);process.stdout.write(JSON.stringify({title:entry.title,description:entry.body,files_in_scope:files,gate:gate?Number(gate[1]):null}))'","depends_on":["select-entry"],"id":"emit-package","max_iterations":1,"retry":{"initial_backoff_ms":100,"jitter_percent":20,"max_backoff_ms":60000,"multiplier":2},"type":"deterministic","verification":{}}],"version":"0.1.0"} \ No newline at end of file