@pmelab/gtd 29.0.0 → 29.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17475,7 +17475,12 @@ ${stateFileRules}
17475
17475
  - Every package with tests has a \`## Tests\` section, one line per
17476
17476
  declared test, copied from the architecture entries:
17477
17477
  \`- unit: \\\`<path>\\\`\` or \`- e2e: \\\`<path>\\\`\`. Chore packages have
17478
- none
17478
+ none. List every test the package writes or changes; also list an
17479
+ existing test a criterion relies on, marked \`(unchanged)\` —
17480
+ \`- unit: \\\`<path>\\\` (unchanged)\` or
17481
+ \`- e2e: \\\`<path>\\\` (unchanged)\`; list no other untouched test;
17482
+ never mark a file the package also writes or changes — list it once,
17483
+ unmarked; only decompose places the marker
17479
17484
  - Leave everything uncommitted and finish
17480
17485
  `;
17481
17486
  const architectureGateAnswerMessage = () => `This gate stops on every process, even with no open question, so
@@ -17508,15 +17513,26 @@ const packagesItemBuildingPrompt = (pkg) => `${stateFileRules}
17508
17513
  never delete the package file (the declared-tests guard reads it
17509
17514
  after you)
17510
17515
  - The package to implement is \`${pkg}\`
17511
- - First check its acceptance criteria against the current tree —
17512
- an earlier fix turn may already satisfy them. If **every**
17513
- criterion is met, implement nothing: write \`.gtd/SATISFIED.md\`
17514
- with each criterion's concrete evidence (commit, file, or
17515
- symbol), change nothing else, and finish. Otherwise implement
17516
- normally and skip that file
17516
+ - First check the acceptance criteria against the current tree —
17517
+ an earlier fix turn may already meet them
17518
+ - **Every criterion already met in the tree**: write
17519
+ \`.gtd/SATISFIED.md\` with evidence per criterion — commit, file,
17520
+ symbol, issue URL, or command output — and change nothing else
17521
+ - **No \`## Tests\` and work outside the tree**: first check it was
17522
+ not already done by an earlier attempt (e.g. an existing issue
17523
+ with the same title via \`gh\`) and reuse that as evidence, or do
17524
+ the work; then write \`.gtd/SATISFIED.md\` with that evidence and
17525
+ change nothing else in the tree
17526
+ - **Any other package**: implement normally, write no
17527
+ \`.gtd/SATISFIED.md\`
17517
17528
  - Write the tests the package declares under \`## Tests\` first,
17518
17529
  then the code that makes them pass; the turn is refused unless
17519
- every declared test is in your diff
17530
+ every declared test is in your diff — except a line marked
17531
+ \`(unchanged)\`, which must exist before the turn and stay
17532
+ untouched — creating, editing or deleting it refuses the turn. If
17533
+ it does not exist yet, or the marker is wrong because the package
17534
+ must change that test, stop without creating it and leave it to
17535
+ a person
17520
17536
  - A declared e2e test is a scenario in the repository's own e2e
17521
17537
  framework for the behaviour its \`## E2E Scenarios\` entry names.
17522
17538
  Word it to fit the existing steps — reuse and generalise them
@@ -17544,7 +17560,16 @@ ${strays.map((path) => ` - \`${displayPath(path)}\``).join("\n")}
17544
17560
  - Introduce no new flag read and add no second read of an existing one:
17545
17561
  every match of \`${pattern}\` may move, never increase in number`;
17546
17562
  const withRules = (rules) => rules === void 0 ? "" : `${rules}\n`;
17547
- const refactorBuildingPrompt = (pkg, strays, pattern, reference, feedback = false) => `${stateFileRules}
17563
+ function refactorBuildingPrompt(first, strays, pattern, reference, feedback) {
17564
+ return refactorBuildingPromptOf(typeof first === "string" ? {
17565
+ pkg: first,
17566
+ strays: strays ?? [],
17567
+ pattern: pattern ?? "",
17568
+ reference,
17569
+ feedback
17570
+ } : first);
17571
+ }
17572
+ const refactorBuildingPromptOf = ({ pkg, strays, pattern, reference, feedback = false }) => `${stateFileRules}
17548
17573
  - The package to implement is \`${pkg}\`; never delete it
17549
17574
  ${feedback ? `- The package carries a \`## Review feedback\` section: a human's review of the
17550
17575
  last refactor. Act on it, within the rules below
@@ -37955,34 +37980,86 @@ const deferNotes = (notes, round) => run("review.defer", deferNotesScript(notes,
37955
37980
  });
37956
37981
  //#endregion
37957
37982
  //#region src/workflows/feature/packages.ts
37958
- const TEST_LINE = /^\s*-\s+(unit|e2e):\s+`([^`]+)`/;
37959
- /** The `## Tests` entries of a package file; malformed lines are ignored. */
37983
+ const TEST_LINE = /^-\s+(unit|e2e):\s+`([^`]+)`(.*)$/;
37984
+ const MARKER = /^[ \t]+\(unchanged\)(?=\s|$)/i;
37985
+ const ITEM = /^\s*-\s/;
37986
+ /** The `## Tests` entries of a package file; malformed entries are ignored. */
37960
37987
  const declaredTests = (packageText) => {
37961
37988
  const found = [];
37962
37989
  let inTests = false;
37963
- for (const line of packageText.split("\n")) if (/^##\s/.test(line)) inTests = /^##\s+Tests\s*$/.test(line);
37964
- else if (inTests) {
37965
- const match = TEST_LINE.exec(line);
37990
+ let entry;
37991
+ const flush = () => {
37992
+ const match = entry === void 0 ? null : TEST_LINE.exec(entry);
37966
37993
  if (match) found.push({
37967
37994
  level: match[1],
37968
- path: match[2]
37995
+ path: match[2],
37996
+ unchanged: MARKER.test(match[3])
37969
37997
  });
37970
- }
37998
+ entry = void 0;
37999
+ };
38000
+ for (const line of packageText.replace(/\r\n?/g, "\n").split("\n")) if (/^##\s/.test(line)) {
38001
+ flush();
38002
+ inTests = /^##\s+Tests\s*$/.test(line);
38003
+ } else if (!inTests) continue;
38004
+ else if (ITEM.test(line)) {
38005
+ flush();
38006
+ entry = line.trim();
38007
+ } else if (entry !== void 0 && /^\s/.test(line) && line.trim() !== "") entry = `${entry} ${line.trim()}`;
38008
+ else flush();
38009
+ flush();
37971
38010
  return found;
37972
38011
  };
38012
+ const diffBucket = (test, status, turn, satisfied) => {
38013
+ if (test.unchanged) {
38014
+ if (turn !== "build") return void 0;
38015
+ if (status === "modified") return "modifiedMarked";
38016
+ return status === "added" ? "addedMarked" : void 0;
38017
+ }
38018
+ return !satisfied && (status === void 0 || status === "deleted") ? "notInDiff" : void 0;
38019
+ };
38020
+ const treeBucket = (test, status) => {
38021
+ if (read(test.path) !== void 0) return void 0;
38022
+ return test.unchanged && status === "deleted" ? "deletedMarked" : "notInTree";
38023
+ };
38024
+ const unmetBucket = (test, change, turn, satisfied) => diffBucket(test, change?.status, turn, satisfied) ?? treeBucket(test, change?.status);
37973
38025
  /**
37974
38026
  * Refuse a build or fix turn unless every test `pkg` declares exists and is in the
37975
38027
  * diff since `since`. The package text is read as of `since`, so a builder
37976
- * editing its own `## Tests` cannot dodge the guard; with `.gtd/SATISFIED.md`
37977
- * written, existing in the tree suffices.
38028
+ * editing its own `## Tests` (or adding `(unchanged)`) cannot dodge the guard;
38029
+ * with `.gtd/SATISFIED.md` written, or the line marked, existing suffices. The
38030
+ * build turn must neither modify nor add a marked test; a fix turn may edit it.
37978
38031
  */
37979
- const requireDeclaredTests = (pkg, since) => {
38032
+ const requireDeclaredTests = (pkg, since, turn = "fix") => {
37980
38033
  const range = changesSince(since);
37981
38034
  const text = range.get(pkg)?.before ?? read(pkg) ?? "";
37982
38035
  const satisfied = read(".gtd/SATISFIED.md") !== void 0;
37983
- const missing = declaredTests(text).map((test) => test.path).filter((path) => satisfied ? read(path) === void 0 : range.get(path) === void 0 || range.get(path)?.status === "deleted" || read(path) === void 0);
37984
- if (missing.length === 0) return;
37985
- refuse(`gtd land: declared-tests: ${pkg} declares tests that are ${satisfied ? "missing from the tree" : "not in the package's diff"} — write them first:\n${missing.map((path) => ` - ${path}`).join("\n")}`);
38036
+ const buckets = {
38037
+ notInDiff: [],
38038
+ modifiedMarked: [],
38039
+ addedMarked: [],
38040
+ deletedMarked: [],
38041
+ notInTree: []
38042
+ };
38043
+ const entries = declaredTests(text);
38044
+ const seen = /* @__PURE__ */ new Set();
38045
+ for (const test of entries) {
38046
+ if (test.unchanged && entries.some((e) => !e.unchanged && e.path === test.path)) continue;
38047
+ const bucket = unmetBucket(test, range.get(test.path), turn, satisfied);
38048
+ if (bucket && !seen.has(test.path)) {
38049
+ seen.add(test.path);
38050
+ buckets[bucket].push(test.path);
38051
+ }
38052
+ }
38053
+ const { notInDiff, modifiedMarked, addedMarked, deletedMarked, notInTree } = buckets;
38054
+ const reasons = [
38055
+ ["not in the package's diff — write them", notInDiff],
38056
+ ["marked (unchanged) but modified — revert them", modifiedMarked],
38057
+ ["marked (unchanged) but added by this turn — the marker promises an existing test; stop and leave it to a person", addedMarked],
38058
+ ["marked (unchanged) but deleted — restore them", deletedMarked],
38059
+ ["missing from the tree", notInTree]
38060
+ ].filter(([, paths]) => paths.length > 0);
38061
+ if (reasons.length === 0) return;
38062
+ refuse(`gtd land: declared-tests: ${pkg} declares tests this turn left unmet:\n${reasons.map(([reason, paths]) => `${reason}:\n${paths.map((path) => ` - ${path}`).join("\n")}`).join("\n")}`);
37986
38063
  };
37987
38064
  /**
37988
38065
  * Build `pkg`, check its declared tests, keep the full suite green, and close
@@ -37990,12 +38067,12 @@ const requireDeclaredTests = (pkg, since) => {
37990
38067
  */
37991
38068
  const packageItem = async (pkg) => {
37992
38069
  const from = head$1();
37993
- const declared = (turn) => async () => {
38070
+ const declared = (turn, kind) => async () => {
37994
38071
  await turn();
37995
- requireDeclaredTests(pkg, from);
38072
+ requireDeclaredTests(pkg, from, kind);
37996
38073
  };
37997
- await declared(() => build(pkg))();
37998
- await healthy(declared(fixSuite));
38074
+ await declared(() => build(pkg), "build")();
38075
+ await healthy(declared(fixSuite, "fix"));
37999
38076
  await run("closing", removeScript([
38000
38077
  pkg,
38001
38078
  ".gtd/SATISFIED.md",
@@ -38230,8 +38307,15 @@ const review$1 = (base, escalations = { rounds: 0 }, options = {}) => {
38230
38307
  }
38231
38308
  });
38232
38309
  };
38233
- /** The build tail: fix (when entered red), keep green unless `green`, the quality lap, then human review since `base`. */
38234
- const buildTail = (fixFirst, base, green = false, options = {}) => tail(fixFirst, (escalations) => review$1(base, escalations, options), green, options.guard, options.lenses, options.guardRests, options.lensBase, options.rules);
38310
+ function buildTail(first, second, third = false, fourth = {}) {
38311
+ const base = typeof first === "string" ? first : second;
38312
+ const options = typeof first === "string" ? second ?? {} : {
38313
+ ...fourth,
38314
+ fixFirst: first,
38315
+ green: third
38316
+ };
38317
+ return tail(options.fixFirst ?? false, (escalations) => review$1(base, escalations, options), options.green ?? false, options.guard, options.lenses, options.guardRests, options.lensBase, options.rules);
38318
+ }
38235
38319
  /**
38236
38320
  * Undo the human's review-round code edits. A path changed since the human
38237
38321
  * left it is not overwritten; requireRevert names it instead.
@@ -38372,7 +38456,13 @@ const refactorLap = async (p, guard, feedback) => {
38372
38456
  label: "Deriving the refactor package",
38373
38457
  file: REFACTOR_PACKAGE
38374
38458
  });
38375
- await agentWithSkills("building", refactorBuildingPrompt(REFACTOR_PACKAGE, strays, pattern, reference, feedback !== void 0), {
38459
+ await agentWithSkills("building", refactorBuildingPrompt({
38460
+ pkg: REFACTOR_PACKAGE,
38461
+ strays,
38462
+ pattern,
38463
+ reference,
38464
+ feedback: feedback !== void 0
38465
+ }), {
38376
38466
  label: "Refactoring",
38377
38467
  model: coder(),
38378
38468
  system: builderSystem()
@@ -38428,7 +38518,8 @@ const reviewedRefactor = async (shape, options = {}) => {
38428
38518
  let collected;
38429
38519
  for (;;) {
38430
38520
  await refactorLap(p, guard, collected);
38431
- const outcome = await buildTail(false, entry, true, {
38521
+ const outcome = await buildTail(entry, {
38522
+ green: true,
38432
38523
  guard,
38433
38524
  guardRests: false,
38434
38525
  lenses: lensesOf(vars.refactorQualityReviews ?? ""),
@@ -38461,20 +38552,27 @@ const naivePass = (rejection) => agentWithSkills("naive", naivePassPrompt(reject
38461
38552
  model: coder(),
38462
38553
  system: builderSystem()
38463
38554
  });
38464
- const implement = (reference, tier, amend) => scope("implement", async () => {
38465
- await agentWithSkills("building", implementBuildingPrompt(reference, tier, amend), {
38466
- label: "Implementing",
38467
- model: coder(),
38468
- system: builderSystem()
38555
+ function implement(first, tier, amend) {
38556
+ const options = typeof first === "object" && first !== null ? first : {
38557
+ reference: first,
38558
+ tier,
38559
+ amend
38560
+ };
38561
+ return scope("implement", async () => {
38562
+ await agentWithSkills("building", implementBuildingPrompt(options.reference, options.tier, options.amend), {
38563
+ label: "Implementing",
38564
+ model: coder(),
38565
+ system: builderSystem()
38566
+ });
38567
+ await healthy(() => agentWithSkills("fix.suite.fixing", implementFixPrompt(), {
38568
+ label: "Fixing the check",
38569
+ file: FEEDBACK,
38570
+ model: coder(),
38571
+ system: builderSystem()
38572
+ }));
38573
+ await run("closing", removeScript([REQUIREMENTS]), { label: "Closing out the implementation" });
38469
38574
  });
38470
- await healthy(() => agentWithSkills("fix.suite.fixing", implementFixPrompt(), {
38471
- label: "Fixing the check",
38472
- file: FEEDBACK,
38473
- model: coder(),
38474
- system: builderSystem()
38475
- }));
38476
- await run("closing", removeScript([REQUIREMENTS]), { label: "Closing out the implementation" });
38477
- });
38575
+ }
38478
38576
  const decide = () => scope("seam", async () => {
38479
38577
  let rejection;
38480
38578
  let retries = 0;
@@ -38549,7 +38647,10 @@ const planAndBuild = async (firstBase, firstLap = "first") => {
38549
38647
  await design(base);
38550
38648
  await architecture();
38551
38649
  await packages();
38552
- const outcome = await buildTail(false, base, true, lap === "first" ? {} : { lap });
38650
+ const outcome = await buildTail(base, {
38651
+ green: true,
38652
+ ...lap === "first" ? {} : { lap }
38653
+ });
38553
38654
  if (outcome.verdict === "signoff") return;
38554
38655
  await reUnwind(outcome);
38555
38656
  base = outcome.base;
@@ -38561,8 +38662,14 @@ const naivePlanAndBuild = async (firstBase) => {
38561
38662
  const tier = riskTierOn() ? recordRisk() : void 0;
38562
38663
  const seam = await seamBuild(firstBase);
38563
38664
  await slice("feature", async () => {
38564
- await implement(seam.reference, tier);
38565
- await featureFeedback(await buildTail(false, seam.base, true, { lensBase: seam.base }), seam.base);
38665
+ await implement({
38666
+ reference: seam.reference,
38667
+ tier
38668
+ });
38669
+ await featureFeedback(await buildTail(seam.base, {
38670
+ green: true,
38671
+ lensBase: seam.base
38672
+ }), seam.base);
38566
38673
  });
38567
38674
  };
38568
38675
  const featureFeedback = async (first, lensBase) => {
@@ -38570,8 +38677,12 @@ const featureFeedback = async (first, lensBase) => {
38570
38677
  while (outcome.verdict === "feedback") {
38571
38678
  await reUnwind(outcome);
38572
38679
  await design(outcome.base);
38573
- await implement(void 0, riskTierOn() ? recordRisk() : void 0, true);
38574
- outcome = await buildTail(false, outcome.base, true, {
38680
+ await implement({
38681
+ tier: riskTierOn() ? recordRisk() : void 0,
38682
+ amend: true
38683
+ });
38684
+ outcome = await buildTail(outcome.base, {
38685
+ green: true,
38575
38686
  lap: "feedback",
38576
38687
  ...lensBase === void 0 ? {} : { lensBase }
38577
38688
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "29.0.0",
3
+ "version": "29.1.1",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -39,7 +39,7 @@ export const planAndBuild = async (
39
39
  await design(base)
40
40
  await architecture()
41
41
  await packages()
42
- const outcome = await buildTail(false, base, true, lap === "first" ? {} : { lap })
42
+ const outcome = await buildTail(base, { green: true, ...(lap === "first" ? {} : { lap }) })
43
43
  if (outcome.verdict === "signoff") return
44
44
  await reUnwind(outcome)
45
45
  base = outcome.base
@@ -54,9 +54,9 @@ const naivePlanAndBuild = async (firstBase: string): Promise<void> => {
54
54
  const tier = riskTierOn() ? recordRisk() : undefined
55
55
  const seam = await seamBuild(firstBase)
56
56
  await slice("feature", async () => {
57
- await implement(seam.reference, tier)
57
+ await implement({ reference: seam.reference, tier })
58
58
  await featureFeedback(
59
- await buildTail(false, seam.base, true, { lensBase: seam.base }),
59
+ await buildTail(seam.base, { green: true, lensBase: seam.base }),
60
60
  seam.base,
61
61
  )
62
62
  })
@@ -68,8 +68,9 @@ const featureFeedback = async (first: ReviewOutcome, lensBase?: string): Promise
68
68
  await reUnwind(outcome)
69
69
  await design(outcome.base)
70
70
  const tier = riskTierOn() ? recordRisk() : undefined
71
- await implement(undefined, tier, true)
72
- outcome = await buildTail(false, outcome.base, true, {
71
+ await implement({ tier, amend: true })
72
+ outcome = await buildTail(outcome.base, {
73
+ green: true,
73
74
  lap: "feedback",
74
75
  ...(lensBase === undefined ? {} : { lensBase }),
75
76
  })
@@ -1,6 +1,6 @@
1
1
  import { afterEach, describe, expect, it } from "vitest"
2
- import { installContext, type Change, type StepRequest } from "../../flows/index.js"
3
- import { declaredTests, packages } from "./packages.js"
2
+ import { installContext, type Change, type Changes, type StepRequest } from "../../flows/index.js"
3
+ import { declaredTests, packages, requireDeclaredTests } from "./packages.js"
4
4
  import { fixtureContext } from "../shared/text.fixture.js"
5
5
 
6
6
  afterEach(() => installContext(undefined))
@@ -16,8 +16,8 @@ describe("declaredTests", () => {
16
16
  "- e2e: `spec/a.feature`",
17
17
  ].join("\n")
18
18
  expect(declaredTests(text)).toEqual([
19
- { level: "unit", path: "lib/a.test.ts" },
20
- { level: "e2e", path: "spec/a.feature" },
19
+ { level: "unit", path: "lib/a.test.ts", unchanged: false },
20
+ { level: "e2e", path: "spec/a.feature", unchanged: false },
21
21
  ])
22
22
  })
23
23
 
@@ -33,7 +33,88 @@ describe("declaredTests", () => {
33
33
  "## Notes",
34
34
  "- e2e: `spec/other.feature`",
35
35
  ].join("\n")
36
- expect(declaredTests(text)).toEqual([{ level: "unit", path: "lib/ok.test.ts" }])
36
+ expect(declaredTests(text)).toEqual([
37
+ { level: "unit", path: "lib/ok.test.ts", unchanged: false },
38
+ ])
39
+ })
40
+
41
+ it("marks a whitespace-led, case-insensitive `(unchanged)` ending at a boundary", () => {
42
+ const text = [
43
+ "## Tests",
44
+ "- unit: `a.test.ts` (unchanged)",
45
+ "- unit: `b.test.ts` extra",
46
+ "- unit: `c.test.ts` (unchanged)x",
47
+ "- unit: `f.test.ts` (unchanged) — kept as is",
48
+ "- unit: `d.test.ts` (Unchanged)",
49
+ "- e2e: `e.feature` (unchanged)",
50
+ "- unit: `g.test.ts`\t(unchanged)",
51
+ "- unit: `h.test.ts` (unchanged)",
52
+ "- unit: `i.test.ts`(unchanged)",
53
+ "- Unit: `j.test.ts` (unchanged)",
54
+ ].join("\n")
55
+ expect(declaredTests(text).map((t) => [t.path, t.unchanged])).toEqual([
56
+ ["a.test.ts", true],
57
+ ["b.test.ts", false],
58
+ ["c.test.ts", false],
59
+ ["f.test.ts", true],
60
+ ["d.test.ts", true],
61
+ ["e.feature", true],
62
+ ["g.test.ts", true],
63
+ ["h.test.ts", true],
64
+ ["i.test.ts", false],
65
+ ])
66
+ })
67
+
68
+ it("never joins a marker across an empty or whitespace-only line", () => {
69
+ for (const gap of ["\n\n", "\n \n", "\n\t\n"]) {
70
+ const text = `## Tests\n- unit: \`a.test.ts\`${gap} (unchanged)\n`
71
+ expect(declaredTests(text).map((t) => t.unchanged)).toEqual([false])
72
+ }
73
+ })
74
+
75
+ it("reads an entry whose path or marker is wrapped, in any line-end style", () => {
76
+ const forms = [
77
+ "- unit: `a/long.test.ts` (unchanged)",
78
+ "- unit:\n `a/long.test.ts` (unchanged)",
79
+ "- unit: `a/long.test.ts`\n (unchanged)",
80
+ "- unit:\n `a/long.test.ts`\n (unchanged)",
81
+ ]
82
+ for (const form of forms) {
83
+ const lf = `## Tests\n${form}\n- e2e: \`b.feature\`\n`
84
+ const want = [
85
+ { level: "unit", path: "a/long.test.ts", unchanged: true },
86
+ { level: "e2e", path: "b.feature", unchanged: false },
87
+ ]
88
+ expect(declaredTests(lf)).toEqual(want)
89
+ expect(declaredTests(lf.replace(/\n/g, "\r\n"))).toEqual(want)
90
+ expect(declaredTests(lf.replace(/\n/g, "\r"))).toEqual(want)
91
+ }
92
+ })
93
+
94
+ it("keeps the heading and its entries when an indented marker follows `## Tests`", () => {
95
+ const text = "## Tests\n (unchanged)\n- unit: `a.test.ts`\n"
96
+ expect(declaredTests(text)).toEqual([{ level: "unit", path: "a.test.ts", unchanged: false }])
97
+ })
98
+
99
+ it("never extends an entry across a new item or a heading", () => {
100
+ const text = [
101
+ "## Tests",
102
+ "- unit: `a.test.ts`",
103
+ " - unit: `b.test.ts` (unchanged)",
104
+ "- unit: `c.test.ts`",
105
+ "## Tests",
106
+ " (unchanged)",
107
+ ].join("\n")
108
+ expect(declaredTests(text).map((t) => [t.path, t.unchanged])).toEqual([
109
+ ["a.test.ts", false],
110
+ ["b.test.ts", true],
111
+ ["c.test.ts", false],
112
+ ])
113
+ })
114
+
115
+ it("closes an entry on an unindented line", () => {
116
+ const text = "## Tests\n- unit: `a.test.ts`\n(unchanged)\n"
117
+ expect(declaredTests(text).map((t) => t.unchanged)).toEqual([false])
37
118
  })
38
119
 
39
120
  it("returns nothing without a Tests section", () => {
@@ -72,3 +153,216 @@ describe("packages", () => {
72
153
  expect(closings[1]).toContain(".gtd/ARCHITECTURE.md")
73
154
  })
74
155
  })
156
+
157
+ describe("requireDeclaredTests", () => {
158
+ const run = (
159
+ text: string,
160
+ tree: readonly string[],
161
+ changes: readonly Change[],
162
+ satisfied = false,
163
+ turn: "build" | "fix" | "default" = "build",
164
+ ) => {
165
+ const pkg = ".gtd/packages/01-a.md"
166
+ installContext(
167
+ fixtureContext(
168
+ {},
169
+ {
170
+ read: (path: string) =>
171
+ path === pkg
172
+ ? text
173
+ : path === ".gtd/SATISFIED.md"
174
+ ? satisfied
175
+ ? "x"
176
+ : undefined
177
+ : tree.includes(path)
178
+ ? "x"
179
+ : undefined,
180
+ refuse: (message: string): never => {
181
+ throw new Error(message)
182
+ },
183
+ changesSince: (): Changes => {
184
+ const list: Change[] = [
185
+ { path: pkg, status: "modified", before: text, after: text },
186
+ ...changes,
187
+ ]
188
+ return Object.assign(list, {
189
+ paths: list.map((c) => c.path),
190
+ get: (path: string) => list.find((c) => c.path === path),
191
+ })
192
+ },
193
+ },
194
+ ),
195
+ )
196
+ let message = ""
197
+ try {
198
+ if (turn === "default") requireDeclaredTests(pkg, "c")
199
+ else requireDeclaredTests(pkg, "c", turn)
200
+ } catch (error) {
201
+ message = String(error instanceof Error ? error.message : error)
202
+ }
203
+ return { message }
204
+ }
205
+ const deleted = (path: string): Change => ({
206
+ path,
207
+ status: "deleted",
208
+ before: "x",
209
+ after: undefined,
210
+ })
211
+
212
+ it("groups an unwritten unmarked test and a missing marked test in one refusal", () => {
213
+ const text = [
214
+ "## Tests",
215
+ "- unit: `lib/unwritten.test.ts`",
216
+ "- unit: `lib/gone.test.ts` (unchanged)",
217
+ "- unit: `lib/kept.test.ts` (unchanged)",
218
+ ].join("\n")
219
+ const { message } = run(text, ["lib/kept.test.ts"], [])
220
+ expect(message).toContain("gtd land: declared-tests:")
221
+ expect(message).not.toMatch(/satisf/i)
222
+ const diffAt = message.indexOf("not in the package's diff")
223
+ const treeAt = message.indexOf("missing from the tree")
224
+ expect(diffAt).toBeGreaterThanOrEqual(0)
225
+ expect(treeAt).toBeGreaterThan(diffAt)
226
+ expect(message.indexOf(" - lib/unwritten.test.ts")).toBeGreaterThan(diffAt)
227
+ expect(message.indexOf(" - lib/unwritten.test.ts")).toBeLessThan(treeAt)
228
+ expect(message.indexOf(" - lib/gone.test.ts")).toBeGreaterThan(treeAt)
229
+ expect(message).not.toContain("kept.test.ts")
230
+ })
231
+
232
+ it("sends a marked deleted test to restore only", () => {
233
+ const { message } = run("## Tests\n- unit: `a.test.ts` (unchanged)", [], [deleted("a.test.ts")])
234
+ expect(message).toContain("declares tests this turn left unmet:")
235
+ expect(message).toContain("marked (unchanged) but deleted — restore them:\n - a.test.ts")
236
+ expect(message).not.toContain("missing from the tree")
237
+ expect(message).not.toContain("write them")
238
+ })
239
+
240
+ it("keeps a marked absent test with no diff change under missing", () => {
241
+ const { message } = run("## Tests\n- unit: `a.test.ts` (unchanged)", [], [])
242
+ expect(message).toContain("missing from the tree:\n - a.test.ts")
243
+ expect(message).not.toContain("restore")
244
+ })
245
+
246
+ it("keeps an unmarked deleted test under write, or missing once satisfied", () => {
247
+ const text = "## Tests\n- unit: `a.test.ts`"
248
+ expect(run(text, [], [deleted("a.test.ts")]).message).toContain(
249
+ "not in the package's diff — write them:\n - a.test.ts",
250
+ )
251
+ const done = run(text, [], [deleted("a.test.ts")], true).message
252
+ expect(done).toContain("missing from the tree:\n - a.test.ts")
253
+ expect(done).not.toContain("restore")
254
+ })
255
+
256
+ const modified = (path: string): Change => ({
257
+ path,
258
+ status: "modified",
259
+ before: "x",
260
+ after: "y",
261
+ })
262
+
263
+ it("refuses a marked test the build modified, even with SATISFIED.md", () => {
264
+ const text = "## Tests\n- unit: `a.test.ts` (unchanged)"
265
+ for (const satisfied of [false, true]) {
266
+ const { message } = run(text, ["a.test.ts"], [modified("a.test.ts")], satisfied)
267
+ expect(message).toContain("marked (unchanged) but modified — revert them:\n - a.test.ts")
268
+ }
269
+ })
270
+
271
+ it("lets a fix turn modify a marked test but not delete it", () => {
272
+ const text = "## Tests\n- unit: `a.test.ts` (unchanged)\n- unit: `b.test.ts` (unchanged)"
273
+ expect(run(text, ["a.test.ts"], [modified("a.test.ts")], false, "fix").message).toContain(
274
+ "missing from the tree:\n - b.test.ts",
275
+ )
276
+ expect(run(text, ["a.test.ts"], [modified("a.test.ts")], false, "fix").message).not.toContain(
277
+ "revert",
278
+ )
279
+ const gone = run(text, [], [deleted("a.test.ts"), deleted("b.test.ts")], false, "fix").message
280
+ expect(gone).toContain("restore them:\n - a.test.ts\n - b.test.ts")
281
+ const clean = "## Tests\n- unit: `a.test.ts` (unchanged)"
282
+ expect(run(clean, ["a.test.ts"], [modified("a.test.ts")], false, "fix").message).toBe("")
283
+ })
284
+
285
+ it("lets an unmarked entry for the same file win over a marked one", () => {
286
+ const text = "## Tests\n- e2e: `x.feature`\n- e2e: `x.feature` (unchanged)"
287
+ expect(run(text, ["x.feature"], [modified("x.feature")]).message).toBe("")
288
+ })
289
+
290
+ const added = (path: string): Change => ({
291
+ path,
292
+ status: "added",
293
+ before: undefined,
294
+ after: "x",
295
+ })
296
+ const addedGroup =
297
+ "marked (unchanged) but added by this turn — the marker promises an existing test; stop and leave it to a person"
298
+
299
+ it("refuses a marked test the build added, as added and never as modified", () => {
300
+ const { message } = run(
301
+ "## Tests\n- unit: `a.test.ts` (unchanged)",
302
+ ["a.test.ts"],
303
+ [added("a.test.ts")],
304
+ )
305
+ expect(message).toContain(`${addedGroup}:\n - a.test.ts`)
306
+ expect(message).not.toContain("revert")
307
+ expect(message).not.toMatch(/delete|restore/i)
308
+ })
309
+
310
+ it("only the build turn refuses an added marked test (turn split, not a fix-turn flow)", () => {
311
+ const text = "## Tests\n- unit: `a.test.ts` (unchanged)"
312
+ expect(run(text, ["a.test.ts"], [added("a.test.ts")], false, "fix").message).toBe("")
313
+ })
314
+
315
+ it.each([
316
+ ["modified", modified("a.test.ts")],
317
+ ["added", added("a.test.ts")],
318
+ ] as [string, Change][])("defaults to the fix turn with two arguments (%s)", (_label, change) => {
319
+ const text = "## Tests\n- unit: `a.test.ts` (unchanged)"
320
+ expect(run(text, ["a.test.ts"], [change], false, "default").message).toBe("")
321
+ expect(run(text, ["a.test.ts"], [change], false, "build").message).not.toBe("")
322
+ })
323
+
324
+ it("lets an unmarked entry win over a marked one the build added", () => {
325
+ const text = "## Tests\n- unit: `a.test.ts`\n- unit: `a.test.ts` (unchanged)"
326
+ expect(run(text, ["a.test.ts"], [added("a.test.ts")]).message).toBe("")
327
+ })
328
+
329
+ it("lists a duplicated unwritten line once", () => {
330
+ const text = "## Tests\n- unit: `a.test.ts`\n- unit: `a.test.ts`"
331
+ const { message } = run(text, [], [])
332
+ expect(message.split("a.test.ts").length - 1).toBe(1)
333
+ })
334
+
335
+ it("lists a duplicated marked line missing from the tree once", () => {
336
+ const text = "## Tests\n- unit: `a.test.ts` (unchanged)\n- unit: `a.test.ts` (unchanged)"
337
+ const { message } = run(text, [], [])
338
+ expect(message.split("a.test.ts").length - 1).toBe(1)
339
+ })
340
+
341
+ it("orders write, revert, added, restore, missing and never says satisfied", () => {
342
+ const text = [
343
+ "## Tests",
344
+ "- unit: `w.test.ts`",
345
+ "- unit: `r.test.ts` (unchanged)",
346
+ "- unit: `m.test.ts` (unchanged)",
347
+ "- unit: `v.test.ts` (unchanged)",
348
+ "- unit: `a.test.ts` (unchanged)",
349
+ ].join("\n")
350
+ const { message } = run(
351
+ text,
352
+ ["v.test.ts", "a.test.ts"],
353
+ [deleted("r.test.ts"), modified("v.test.ts"), added("a.test.ts")],
354
+ )
355
+ const w = message.indexOf("write them")
356
+ const v = message.indexOf("revert them")
357
+ expect(v).toBeGreaterThan(w)
358
+ const ad = message.indexOf("added by this turn")
359
+ expect(ad).toBeGreaterThan(v)
360
+ const r = message.indexOf("restore them")
361
+ expect(r).toBeGreaterThan(ad)
362
+ const m = message.indexOf("missing from the tree")
363
+ expect(w).toBeGreaterThan(0)
364
+ expect(r).toBeGreaterThan(w)
365
+ expect(m).toBeGreaterThan(r)
366
+ expect(message).not.toMatch(/satisf/i)
367
+ })
368
+ })