diff --git a/.changeset/tidy-ravens-sing.md b/.changeset/tidy-ravens-sing.md new file mode 100644 index 000000000..579d6800f --- /dev/null +++ b/.changeset/tidy-ravens-sing.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3475 +--- +phase-plan-index silently drops short-form depends_on references (e.g. ["01"]), collapsing every plan into wave 1. The planner template's two worked dependency examples taught exactly that broken short form; they now teach the full-form plan id (e.g. ["01-01"]) the file's own frontmatter comment and other examples already document, so newly authored plans keep resolvable dependency edges. Resolver-side short-form handling is tracked separately in #3473. diff --git a/gsd-core/templates/phase-prompt.md b/gsd-core/templates/phase-prompt.md index d4427e1dd..064729fb5 100644 --- a/gsd-core/templates/phase-prompt.md +++ b/gsd-core/templates/phase-prompt.md @@ -187,7 +187,7 @@ autonomous: true # Plan 02 - Protected features (needs auth) wave: 2 -depends_on: ["01"] +depends_on: ["01-01"] files_modified: [src/features/dashboard.ts] autonomous: true ``` @@ -199,7 +199,7 @@ Plan 02 in Wave 2 waits for Plan 01 in Wave 1 - genuine dependency on auth types ```yaml # Plan 03 - UI with verification wave: 3 -depends_on: ["01", "02"] +depends_on: ["01-01", "01-02"] files_modified: [src/components/Dashboard.tsx] autonomous: false # Has checkpoint:human-verify ``` diff --git a/scripts/lint-allow-test-rule-refs.ceiling.json b/scripts/lint-allow-test-rule-refs.ceiling.json index d364ad0f2..d9ef39660 100644 --- a/scripts/lint-allow-test-rule-refs.ceiling.json +++ b/scripts/lint-allow-test-rule-refs.ceiling.json @@ -1,4 +1,4 @@ { - "maxFiles": 304, + "maxFiles": 305, "grace": 3 } diff --git a/tests/template-phase-prompt-dependson-3427.test.cjs b/tests/template-phase-prompt-dependson-3427.test.cjs new file mode 100644 index 000000000..427100757 --- /dev/null +++ b/tests/template-phase-prompt-dependson-3427.test.cjs @@ -0,0 +1,111 @@ +// allow-test-rule: source-text-is-the-product (see #3427) +// Reads gsd-core/templates/phase-prompt.md — the template's deployed text IS +// what the planner loads, so testing its worked `depends_on` examples tests the +// contract the wave DAG depends on. + +/** + * Regression guard for #3427 / #3473 criterion B8. + * + * `resolveDependencyId` (src/phase.cts) resolves a `depends_on` token only + * when it is a FULL plan id (`planMap` exact match) or a canonical id + * (`canonicalToId` prefix match). A bare short-form suffix (`"01"`) resolves + * to nothing and every caller silently drops the edge, collapsing the phase + * into wave 1 (#3427; the resolver half is rewritten by #3473 B4/B5). + * + * Until short-form resolution exists — and regardless, per the template's own + * documented convention (`depends_on: [] # e.g., ["01-01"]`) — the planner + * template must only ever TEACH full-form ids in its worked `depends_on` + * examples. This suite pins that: every non-empty `depends_on:` example in + * templates/phase-prompt.md lists `NN-NN` full-form plan ids only. + */ + +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const TEMPLATE = path.join(__dirname, '..', 'gsd-core', 'templates', 'phase-prompt.md'); + +/** Full-form plan id: two-digit phase + two-digit plan, e.g. "03-01". */ +const FULL_FORM_ID = /^\d{2}-\d{2}$/; + +/** + * Extract every `depends_on:` example line from a template body. + * Returns [{ line, tokens }] where tokens is the parsed string array + * ([] for `depends_on: []`, null for a non-literal/unparsable value — the + * caller treats null as a failure so an example can never opt out by + * obfuscating its array). + */ +function extractDependsOnExamples(body) { + const out = []; + const lines = body.split('\n'); + lines.forEach((line, i) => { + const match = line.match(/^\s*depends_on:\s*(\[[^\]]*\])\s*(?:#.*)?$/); + if (!match) return; + const raw = match[1].trim(); + if (raw === '[]') { + out.push({ line: i + 1, text: line, tokens: [] }); + return; + } + const inner = raw.slice(1, -1).trim(); + const tokens = inner + .split(',') + .map((piece) => piece.trim().replace(/^["']|["']$/g, '')); + out.push({ line: i + 1, text: line, tokens }); + }); + return out; +} + +/** Every token in every non-empty example is a full-form plan id. */ +function shortFormTokens(examples) { + const bad = []; + for (const example of examples) { + for (const token of example.tokens) { + if (!FULL_FORM_ID.test(token)) bad.push({ ...example, token }); + } + } + return bad; +} + +describe('phase-prompt template teaches only full-form depends_on ids (#3427 / #3473 B8)', () => { + const body = fs.readFileSync(TEMPLATE, 'utf8'); + const examples = extractDependsOnExamples(body); + + test('template carries depends_on examples to check (non-vacuous)', () => { + assert.ok(examples.length >= 8, `expected >= 8 depends_on examples, found ${examples.length}`); + assert.ok( + examples.some((e) => e.tokens.length > 0), + 'expected at least one non-empty depends_on example' + ); + }); + + test('every non-empty depends_on example lists full-form plan ids only', () => { + const bad = shortFormTokens(examples); + assert.deepEqual( + bad.map((b) => `line ${b.line}: token "${b.token}" in ${b.text.trim()}`), + [], + 'depends_on examples must use full-form plan ids (e.g. ["03-01"]), never ' + + 'bare short-form suffixes like ["01"] — the resolver drops those edges ' + + 'silently (#3427)' + ); + }); + + test('checker: known good/bad samples classify correctly', () => { + const good = extractDependsOnExamples( + [ + 'depends_on: []', + 'depends_on: ["03-01"]', + 'depends_on: ["03-01", "03-02"] # trailing comment', + 'depends_on: ["01-01"]', + ].join('\n') + ); + assert.deepEqual(shortFormTokens(good), []); + + const bad = extractDependsOnExamples( + ['depends_on: ["01"]', 'depends_on: ["01", "02"]'].join('\n') + ); + assert.equal(shortFormTokens(bad).length, 3); // both tokens of line 2 + line 1 + }); +});