Skip to content

Commit 3c017c6

Browse files
maxgfrclaude
andcommitted
fix: enforce non-empty FR in check, drop stale SRD.md, note tech cap
Found and fixed by exercising the skill end-to-end against real product ideas (privacy analytics, meal-planning, password manager) plus hostile and degenerate briefs: - check: an SRD with zero functional requirements now fails the hard structural gate instead of only warning. This matches the gate already failing when a single FR is missing acceptance criteria, and stops the SKILL.md "loop until check passes" flow from terminating on an empty scaffold ("nothing to build" is not a complete, buildable SRD). - render: remove a stale SRD.md left behind by a prior `--merge` run when re-rendering without `--merge`, so `check` no longer validates outdated merged content (e.g. an already-resolved decision callout). Same hygiene the decisions/ directory already gets. - research/tech: surface an honest note when candidateTech is capped to the first 3 technologies, instead of silently dropping the rest. Adds a regression test per fix and rebuilds the zero-dep bundle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5f02a30 commit 3c017c6

8 files changed

Lines changed: 63 additions & 7 deletions

File tree

SKILL.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,9 @@ loop to completion; only pause to ask the user a real decision.
7979

8080
5. **Validate (two layers).**
8181
- *Structural (hard):* `node scripts/construct.mjs check --out <run>`. It
82-
fails on any unresolved `🧠`/TODO, an FR with no acceptance criteria, a
83-
dangling entity/interface/NFR reference, a missing required NFR category, or
84-
a malformed ADR. Fix until it passes.
82+
fails on any unresolved `🧠`, no functional requirements at all, an FR with
83+
no acceptance criteria, a dangling entity/interface/NFR reference, a missing
84+
required NFR category, or a malformed ADR. Fix until it passes.
8585
- *Grounding (advisory):* the same command prints coverage — what fraction of
8686
requirements/decisions cite evidence. Raise it where it matters (the load-
8787
bearing decisions); see `references/grounding-coverage.md`. It never fails

scripts/construct.mjs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,10 +1092,16 @@ ${body || "(no body)"}`,
10921092

10931093
// src/research/tech.ts
10941094
async function techAngle(ctx) {
1095-
const techs = ctx.brief.candidateTech.slice(0, 3);
1095+
const allTechs = ctx.brief.candidateTech;
1096+
const techs = allTechs.slice(0, 3);
10961097
const ideaKw = ctx.query || ctx.brief.idea;
10971098
const docItems = [];
10981099
const docNotes = [];
1100+
if (allTechs.length > techs.length) {
1101+
docNotes.push(
1102+
`Only the first ${techs.length} of ${allTechs.length} candidate technologies were grounded; skipped: ${allTechs.slice(techs.length).join(", ")}. Drill them with \`construct tech --out <run> --q "<tech>"\`.`
1103+
);
1104+
}
10991105
for (const tech of techs) {
11001106
const q = `${tech} official documentation`;
11011107
const { urls, via, notes } = await discover(q, ctx.webEngine, ctx.perSource);
@@ -2029,6 +2035,8 @@ function renderSRD(brief, evidence, opts) {
20292035
files.push("SRD.json");
20302036
if (opts.merge) {
20312037
writeFile(out, "SRD.md", renderMergeBundle(srd), files);
2038+
} else {
2039+
rmSync2(join8(out, "SRD.md"), { force: true });
20322040
}
20332041
return { dir: out, files, srd };
20342042
}
@@ -2177,7 +2185,9 @@ function checkRun(runDir) {
21772185
for (const i of fr.interfaces) if (!interfaceNames.has(i)) errors.push(`${fr.id} references unknown interface "${i}".`);
21782186
for (const n of fr.nfrs) if (!nfrIds.has(n)) errors.push(`${fr.id} references unknown NFR "${n}".`);
21792187
}
2180-
if (srd.functional.length === 0) warnings.push("No functional requirements \u2014 the SRD has nothing to build.");
2188+
if (srd.functional.length === 0) {
2189+
errors.push("No functional requirements \u2014 an SRD must specify at least one. Capture features in the brief (featureWishlist) and re-render.");
2190+
}
21812191
const noTrace = srd.functional.filter((fr) => fr.entities.length === 0 && fr.interfaces.length === 0).length;
21822192
if (noTrace) {
21832193
warnings.push(`${noTrace} functional requirement(s) have no data/interface traceability \u2014 fill DATA-MODEL.md / INTERFACES.md and set FR.entities/interfaces.`);

src/check.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,14 @@ export function checkRun(runDir: string): CheckResult {
170170
for (const i of fr.interfaces) if (!interfaceNames.has(i)) errors.push(`${fr.id} references unknown interface "${i}".`);
171171
for (const n of fr.nfrs) if (!nfrIds.has(n)) errors.push(`${fr.id} references unknown NFR "${n}".`);
172172
}
173-
if (srd.functional.length === 0) warnings.push("No functional requirements — the SRD has nothing to build.");
173+
// Zero FRs is a HARD failure, not a nudge: the gate certifies a *buildable*
174+
// SRD, and a document with nothing to build is incomplete by definition. It
175+
// also keeps the gate consistent — one FR missing acceptance criteria fails,
176+
// so an SRD missing every FR must fail too (else the SKILL.md "loop until
177+
// check passes" terminates on an empty scaffold).
178+
if (srd.functional.length === 0) {
179+
errors.push("No functional requirements — an SRD must specify at least one. Capture features in the brief (featureWishlist) and re-render.");
180+
}
174181

175182
// Advisory enrichment nudges (never fail the gate): point the author at the
176183
// parts a deterministic render leaves generic.

src/render.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@ export function renderSRD(brief: Brief, evidence: EvidenceItem[], opts: RenderOp
7272

7373
if (opts.merge) {
7474
writeFile(out, "SRD.md", renderMergeBundle(srd), files);
75+
} else {
76+
// A prior `--merge` run may have left an SRD.md behind. Drop it on a
77+
// non-merge re-render — same hygiene as the decisions dir above — so the
78+
// tree stays the single source of truth and `check` never validates a stale
79+
// bundle (e.g. an old 🧠 the current render already resolved).
80+
rmSync(join(out, "SRD.md"), { force: true });
7581
}
7682

7783
return { dir: out, files, srd };

src/research/tech.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,20 @@ import { stackoverflow } from "./stackoverflow.js";
88
// (b) mines StackOverflow for the known pitfalls of that technology.
99
// Emits `docs` + `so` evidence the ADRs and NFRs can cite.
1010
export async function techAngle(ctx: ResearchContext): Promise<SourceResult[]> {
11-
const techs = ctx.brief.candidateTech.slice(0, 3);
11+
// Bound the run to the first few technologies; surface the cap honestly rather
12+
// than silently dropping the rest of the user's candidateTech list.
13+
const allTechs = ctx.brief.candidateTech;
14+
const techs = allTechs.slice(0, 3);
1215
const ideaKw = ctx.query || ctx.brief.idea;
1316

1417
// --- docs: official documentation of each candidate technology. ----------
1518
const docItems: RawItem[] = [];
1619
const docNotes: string[] = [];
20+
if (allTechs.length > techs.length) {
21+
docNotes.push(
22+
`Only the first ${techs.length} of ${allTechs.length} candidate technologies were grounded; skipped: ${allTechs.slice(techs.length).join(", ")}. Drill them with \`construct tech --out <run> --q "<tech>"\`.`,
23+
);
24+
}
1725
for (const tech of techs) {
1826
const q = `${tech} official documentation`;
1927
const { urls, via, notes } = await discover(q, ctx.webEngine, ctx.perSource);

tests/check.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ describe("checkRun — hard structural gate", () => {
8080
expect(r.ok).toBe(false);
8181
expect(r.structural.errors.join(" ")).toMatch(/Missing required NFR category.*usability/);
8282
});
83+
84+
it("fails when the SRD has no functional requirements (nothing to build)", () => {
85+
const r = checkRun(renderRun({ briefOverride: { featureWishlist: [] } }));
86+
expect(r.ok).toBe(false);
87+
expect(r.structural.errors.join(" ")).toMatch(/no functional requirements/i);
88+
});
8389
});
8490

8591
describe("checkRun — advisory grounding (never fails the build)", () => {

tests/render.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,17 @@ describe("renderSRD", () => {
8787
expect(existsSync(stale)).toBe(false);
8888
});
8989

90+
it("drops a stale SRD.md from a prior --merge run when re-rendering without --merge", () => {
91+
const out = freshDir();
92+
// First render WITH merge (and an open question, so SRD.md carries a 🧠).
93+
renderSRD({ ...brief, openQuestions: ["Pick a license model"] }, evidence, { level: "complex", out, merge: true, generatedAt: "T" });
94+
expect(existsSync(join(out, "SRD.md"))).toBe(true);
95+
// Re-render WITHOUT merge and with the decision resolved — the stale bundle
96+
// must not linger for `check` to flag.
97+
renderSRD(brief, evidence, { level: "light", out, merge: false, generatedAt: "T" });
98+
expect(existsSync(join(out, "SRD.md"))).toBe(false);
99+
});
100+
90101
it("is byte-deterministic for the same inputs and generatedAt", () => {
91102
const a = freshDir();
92103
const b = freshDir();

tests/tech.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,12 @@ describe("techAngle StackOverflow", () => {
4343
expect(so!.items.length).toBe(3); // distinct question ids, deduped by ref
4444
expect(docs!.source).toBe("docs");
4545
});
46+
47+
it("notes honestly when candidateTech is capped beyond the first three", async () => {
48+
vi.stubGlobal("fetch", vi.fn(async () => fail())); // no docs/SO results
49+
const [docs] = await techAngle(ctx(["Next.js", "PostgreSQL", "Prisma", "Redis", "Kafka"]));
50+
const notes = docs!.notes.join(" ");
51+
expect(notes).toMatch(/Only the first 3 of 5 candidate technologies/);
52+
expect(notes).toMatch(/Redis, Kafka/);
53+
});
4654
});

0 commit comments

Comments
 (0)