Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions sdk/tests/backlog-picker-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
});
32 changes: 32 additions & 0 deletions sdk/tests/backlog-picker.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 });
}
});
});
2 changes: 1 addition & 1 deletion testdata/backlog-picker.flow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}))'
2 changes: 1 addition & 1 deletion testdata/backlog-picker.spec.canonical.json
Original file line number Diff line number Diff line change
@@ -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"}