@pmelab/gtd 29.2.1 → 29.2.3

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.
@@ -1,7 +1,7 @@
1
1
  import { answered, changes, changesSince, glob, hasThreadFor, head, human, judge, judgeDiffShape, kept, mark, matches, numeric, quote, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restoreScript, revertScript, run, scope, slice, start, vars } from "../flows.mjs";
2
2
  import { lensesOf, review as review$1 } from "../workflow-kit.mjs";
3
- import { $ as refactorFixPrompt, A as architectureDecomposePrompt, B as designSystem, C as planner, D as agentWithSkills, E as reviewing, F as buildReviewFixNitsPrompt, G as idleMessage, I as buildReviewTriageMessage, J as naivePassPrompt, K as implementBuildingPrompt, L as builderSystem, M as buildReviewAnswerQuestionsPrompt, N as buildReviewAwaitAfterDeferMessage, O as architectSystem, P as buildReviewCollectingPrompt, Q as refactorDroppedRefusal, R as deferNotesScript, S as fixRisks, V as designTriagePrompt, X as packagesItemFixSuitePrompt, Y as packagesItemBuildingPrompt, Z as refactorBuildingPrompt, _ as REVIEW, at as reviewerSystem, b as fixCheck, ct as startGateBlockedMessage, d as healthy, dt as defaults, et as refactorRules, f as ARCHITECTURE, ft as envDefaults, g as REQUIREMENTS, h as REFACTOR, i as skills, it as reviewNotesCapture, j as architectureGateAnswerMessage, k as architectureAuthorPrompt, l as gate, lt as unwindFailedMessage, n as summary, nt as reviewEditsCapture, o as shipped, ot as seamByHandMessage, p as FEEDBACK, pt as testFileGlobs, q as implementFixPrompt, r as access, st as seamSteerMessage, t as steering, tt as reviewEditNotesCapture, u as guarded, ut as unwindFailure, v as awaitReview, w as reviewMissing, y as coder, z as designGateAnswerMessage } from "../chunks/reserved-BzeofpoC.mjs";
4
- import { a as tail, i as refusePendingHandOff, n as closeReview } from "../chunks/tail-eS_ZSImR.mjs";
3
+ import { $ as refactorFixPrompt, A as architectureDecomposePrompt, B as designSystem, C as planner, D as agentWithSkills, E as reviewing, F as buildReviewFixNitsPrompt, G as idleMessage, I as buildReviewTriageMessage, J as naivePassPrompt, K as implementBuildingPrompt, L as builderSystem, M as buildReviewAnswerQuestionsPrompt, N as buildReviewAwaitAfterDeferMessage, O as architectSystem, P as buildReviewCollectingPrompt, Q as refactorDroppedRefusal, R as deferNotesScript, S as fixRisks, V as designTriagePrompt, X as packagesItemFixSuitePrompt, Y as packagesItemBuildingPrompt, Z as refactorBuildingPrompt, _ as REVIEW, at as reviewerSystem, b as fixCheck, ct as startGateBlockedMessage, d as healthy, dt as defaults, et as refactorRules, f as ARCHITECTURE, ft as envDefaults, g as REQUIREMENTS, h as REFACTOR, i as skills, it as reviewNotesCapture, j as architectureGateAnswerMessage, k as architectureAuthorPrompt, l as gate, lt as unwindFailedMessage, n as summary, nt as reviewEditsCapture, o as shipped, ot as seamByHandMessage, p as FEEDBACK, pt as testFileGlobs, q as implementFixPrompt, r as access, st as seamSteerMessage, t as steering, tt as reviewEditNotesCapture, u as guarded, ut as unwindFailure, v as awaitReview, w as reviewMissing, y as coder, z as designGateAnswerMessage } from "../chunks/reserved-BduvUQxQ.mjs";
4
+ import { a as tail, i as refusePendingHandOff, n as closeReview } from "../chunks/tail-pgq1Fbbv.mjs";
5
5
  //#region src/workflows/shared/fences.ts
6
6
  const markFenced = (text) => {
7
7
  let fence;
@@ -516,6 +516,35 @@ const requireNoTestFiles = () => {
516
516
  if (tests.length === 0) return;
517
517
  refuse(`gtd land: refactor-tests: the refactor may not change a test file — revert these:\n${tests.map((path) => ` - ${path}`).join("\n")}`);
518
518
  };
519
+ const holdsCallSites = (path) => !path.endsWith(".feature") && !path.endsWith(".snap");
520
+ const COMMENT = /(?<![*/])\/\*[\s\S]*?\*\/|(?<!:)\/\/.*$/gm;
521
+ const ASSERTION = /\b(?:expect|assert\w*)\s*[.(]/g;
522
+ const TEST_CASE = /(?<![.\w])x?(?:it|test|describe)\s*[.(]/g;
523
+ const MODIFIER = /(?<![.\w])(?:it|test|describe)\.(skip|only|todo|fails|skipIf|runIf)\b|(?<![.\w])(x)(?:it|test|describe)\b/g;
524
+ const counts = (text) => {
525
+ const live = text.replace(COMMENT, "");
526
+ const modifiers = [...live.matchAll(MODIFIER)].map((m) => m[1] ?? m[2]).sort();
527
+ return JSON.stringify([
528
+ [...live.matchAll(ASSERTION)].length,
529
+ [...live.matchAll(TEST_CASE)].length,
530
+ modifiers
531
+ ]);
532
+ };
533
+ const tested = (text) => {
534
+ const live = text.replace(COMMENT, "");
535
+ return live.search(ASSERTION) >= 0 || live.search(TEST_CASE) >= 0;
536
+ };
537
+ const callSitesOnly = ({ path, status, before, after }) => status === "modified" && holdsCallSites(path) && before !== void 0 && after !== void 0 && tested(before) && counts(before) === counts(after);
538
+ /**
539
+ * The test-file half of the refactor guard: an existing test may follow a changed call shape,
540
+ * nothing more. Returns the test files it admitted, which the scope check then allows.
541
+ */
542
+ const requireTestCallSitesOnly = () => {
543
+ const tests = changes().filter((c) => isCode(c.path) && isTestFile(c.path));
544
+ const refused = tests.filter((c) => !callSitesOnly(c)).map((c) => c.path);
545
+ if (refused.length > 0) refuse(`gtd land: refactor-tests: the refactor may update an existing test file's call sites only — never add, delete, skip or comment out a test or an assertion, or touch a snapshot, a .feature file or test data; revert these:\n${refused.map((path) => ` - ${path}`).join("\n")}`);
546
+ return tests.map((c) => c.path);
547
+ };
519
548
  const flagExpression = (pattern) => {
520
549
  if (pattern.trim() === "") throw new Error("gtd: refactorFlagPattern must not be blank");
521
550
  let expression;
@@ -571,12 +600,16 @@ const packageLines = (strays, pattern, reference) => [
571
600
  "## Rules",
572
601
  "",
573
602
  "- The suite's outcome must not change",
574
- "- Change only the paths above, and never a test file",
603
+ "- Change only the paths above, plus an existing test file where a changed call shape reaches it: never add or delete a test file, and never add, remove or alter a test or an assertion",
575
604
  `- Introduce no flag read and add no second read of an existing one (\`${pattern}\`)`,
576
605
  "",
577
606
  "## Reference",
578
607
  "",
579
- reference === void 0 ? "None." : `Reading material only, never applied: \`git show ${reference} -- ${strays.map(quote).join(" ")}\``,
608
+ ...reference === void 0 ? ["None."] : [
609
+ `Reading material only, never applied: \`git show ${reference} -- ${strays.map(quote).join(" ")}\``,
610
+ "",
611
+ "Its changes to the existing code above are the seam this refactor exists to land, minus the feature's own behaviour."
612
+ ],
580
613
  ""
581
614
  ];
582
615
  const demoteHeadings = (text) => {
@@ -609,8 +642,7 @@ const plan = (shape, options) => {
609
642
  };
610
643
  };
611
644
  const guardOf = ({ pattern, strays }, entry) => () => {
612
- requireNoTestFiles();
613
- requireWithin(strays);
645
+ requireWithin([...strays, ...requireTestCallSitesOnly()]);
614
646
  requireNoNewFlags(pattern, entry);
615
647
  };
616
648
  /** Derive, build, check and close one refactor lap; `feedback` is the collected review text of the lap before. */
@@ -651,7 +683,11 @@ const refactorLap = async (p, guard, feedback) => {
651
683
  });
652
684
  await run("closing", removeScript([REFACTOR_PACKAGE]), { label: "Closing out the refactor" });
653
685
  };
654
- const strayAccess = (strays) => ({ write: [...strays, ".gtd/**"] });
686
+ const strayAccess = (strays) => ({ write: [
687
+ ...strays,
688
+ ...testFileGlobs,
689
+ ".gtd/**"
690
+ ] });
655
691
  const refactorFirst = async (shape, options = {}) => {
656
692
  const p = plan(shape, options);
657
693
  if (p === void 0) return false;
@@ -886,4 +922,4 @@ const feature = shipped(ordinaryStart);
886
922
  //#region src/workflows/feature/index.ts
887
923
  var feature_default = feature;
888
924
  //#endregion
889
- export { REFACTOR_PACKAGE, access, afterTail, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, build, buildTail, collecting, declaredTests, decompose, feature_default as default, defaults, deferNotes, design, envDefaults, feature, fixNits, fixSuite, implement, judgeSeam, naivePass, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, reUnwind, refactorCollecting, refactorFirst, refactorStrays, requireDeclaredTests, requireNoNewFlags, requireNoTestFiles, requireWithin, reviewedRefactor, seamBuild, skills, steering, summary, triage, unwind };
925
+ export { REFACTOR_PACKAGE, access, afterTail, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, build, buildTail, collecting, declaredTests, decompose, feature_default as default, defaults, deferNotes, design, envDefaults, feature, fixNits, fixSuite, implement, judgeSeam, naivePass, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, reUnwind, refactorCollecting, refactorFirst, refactorStrays, requireDeclaredTests, requireNoNewFlags, requireNoTestFiles, requireTestCallSitesOnly, requireWithin, reviewedRefactor, seamBuild, skills, steering, summary, triage, unwind };
@@ -1,7 +1,7 @@
1
1
  import { read, refuse, removeScript, run, scope, start } from "../flows.mjs";
2
2
  import { d as reviewNotes, f as reviewRisks } from "../chunks/steering-DVILanVf.mjs";
3
- import { D as agentWithSkills, H as finisherSystem, U as fixReviewNotesPrompt, _ as REVIEW, dt as defaults, ft as envDefaults, i as skills, n as summary, o as shipped, r as access, s as baseline, t as steering, y as coder } from "../chunks/reserved-BzeofpoC.mjs";
4
- import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-eS_ZSImR.mjs";
3
+ import { D as agentWithSkills, H as finisherSystem, U as fixReviewNotesPrompt, _ as REVIEW, dt as defaults, ft as envDefaults, i as skills, n as summary, o as shipped, r as access, s as baseline, t as steering, y as coder } from "../chunks/reserved-BduvUQxQ.mjs";
4
+ import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-pgq1Fbbv.mjs";
5
5
  //#region src/workflows/fix/fix.ts
6
6
  /**
7
7
  * Fix every note a human left in a handed-off `.gtd/REVIEW.md` and every
@@ -1,6 +1,6 @@
1
1
  import { start } from "../flows.mjs";
2
- import { dt as defaults, ft as envDefaults, i as skills, l as gate, n as summary, o as shipped, r as access, rt as reviewGateBlockedMessage, t as steering } from "../chunks/reserved-BzeofpoC.mjs";
3
- import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-eS_ZSImR.mjs";
2
+ import { dt as defaults, ft as envDefaults, i as skills, l as gate, n as summary, o as shipped, r as access, rt as reviewGateBlockedMessage, t as steering } from "../chunks/reserved-BduvUQxQ.mjs";
3
+ import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-pgq1Fbbv.mjs";
4
4
  //#region src/workflows/review/review.ts
5
5
  /**
6
6
  * Pure review of everything since `reviewBase`: a green gate, the quality
@@ -1,5 +1,5 @@
1
1
  import { agent, changesSince, glob, head, mark, read, refuse, removeScript, run, scope, vars } from "../flows.mjs";
2
- import { a as flagShipped, dt as defaults, ft as envDefaults, gt as flipProblems, i as skills, l as gate, mt as flagMentions, n as summary, p as FEEDBACK, r as access, t as steering, vt as requireFlag, y as coder } from "../chunks/reserved-BzeofpoC.mjs";
2
+ import { a as flagShipped, dt as defaults, ft as envDefaults, gt as flipProblems, i as skills, l as gate, mt as flagMentions, n as summary, p as FEEDBACK, r as access, t as steering, vt as requireFlag, y as coder } from "../chunks/reserved-BduvUQxQ.mjs";
3
3
  //#region src/workflows/rollout/rollout.ts
4
4
  const flippingPrompt = (name, registry) => `Roll out the release flag "${name}": in ${registry}, set \`defaultOn: true\` on its entry. Change nothing else — no other entry, no other field, no other file, no comments.`;
5
5
  const suiteBlockedMessage = () => "The suite is red after the rollout. Fix it by hand — a rollout may only touch the flag's registry entry — then land.";
@@ -17554,9 +17554,13 @@ ${fixFeedbackPrompt}
17554
17554
  - Leave everything uncommitted and finish your turn — do not commit
17555
17555
  `;
17556
17556
  const refactorRules = (strays, pattern) => `- Behaviour-neutral: the suite's outcome must not change
17557
- - Change only these paths, nothing else, and never a test file even
17558
- when it is one of them:
17557
+ - Change only these paths, nothing else:
17559
17558
  ${strays.map((path) => ` - \`${displayPath(path)}\``).join("\n")}
17559
+ - An existing test file only where a changed call shape reaches it:
17560
+ update its call sites, inside an assertion too;
17561
+ never add or delete a test file,
17562
+ never add, remove or alter a test or an assertion, and never
17563
+ change what an assertion checks, an expected value or a snapshot
17560
17564
  - Introduce no new flag read and add no second read of an existing one:
17561
17565
  every match of \`${pattern}\` may move, never increase in number`;
17562
17566
  const withRules = (rules) => rules === void 0 ? "" : `${rules}\n`;
@@ -17566,8 +17570,13 @@ ${feedback ? `- The package carries a \`## Review feedback\` section: a human's
17566
17570
  last refactor. Act on it, within the rules below
17567
17571
  ` : ""}${refactorRules(strays, pattern)}
17568
17572
  ${reference === void 0 ? "" : `- Reference only: \`git show ${reference} -- ${strays.map(quote).join(" ")}\` shows an
17569
- earlier attempt at this change. Read it for ideas; never apply,
17570
- cherry-pick or check it out — write a fresh refactor
17573
+ earlier attempt at this change, and \`git show --stat ${reference}\`
17574
+ lists every file it touched, the test call sites included.
17575
+ Read it; never apply, cherry-pick or check it out
17576
+ - Its changes to these paths' existing code are the seam: write that
17577
+ restructuring afresh here, minus the feature's own behaviour. A
17578
+ refactor that touches none of it is wrong, however clean — never
17579
+ swap in an unrelated tidy-up
17571
17580
  `}- Leave everything uncommitted and finish your turn — do not commit
17572
17581
  `;
17573
17582
  const refactorFixPrompt = (strays, pattern) => `${stateFileRules}
@@ -17883,11 +17892,11 @@ const closedFold = (round, target) => round === "edits" ? `- Fold every hand-edi
17883
17892
  /** `refactor` retargets the prompt at `.gtd/REFACTOR.md` and fences it to the strays. A concern outside the fence is not folded; if nothing at all is written the landing is refused, but a partly buildable round drops the rest silently. */
17884
17893
  const buildReviewCollectingPrompt = (capture, refactor) => {
17885
17894
  const target = refactor ? ".gtd/REFACTOR.md" : ".gtd/REQUIREMENTS.md";
17886
- const fence = refactor ? `- The next refactor may change only these paths, and never a test file
17887
- or a flag read:
17895
+ const fence = refactor ? `- The next refactor may change only these paths, plus an existing test
17896
+ file's call sites, and never a flag read:
17888
17897
  ${refactor.strays.map((path) => ` - \`${displayPath(path)}\``).join("\n")}
17889
- A concern needing any other path, a test file, a new or added flag
17890
- read, or a change in behaviour (that belongs to the feature, not this
17898
+ A concern needing any other path, a new or changed test or assertion,
17899
+ a new or added flag read, or a change in behaviour (that belongs to the feature, not this
17891
17900
  refactor) cannot be built: do not fold it
17892
17901
  ` : "";
17893
17902
  const actionable = refactor ? `The round is actionable if the human left a note on
@@ -17991,7 +18000,7 @@ ${removed.map(({ path, lines }) => lines.map((a) => ` ${path} — ${a.scenari
17991
18000
  const lapRange = {
17992
18001
  first: "it covers everything since the base.",
17993
18002
  feedback: "it starts at the previous review's boundary, so it covers only what is new since the previous review.",
17994
- refactor: "it covers the whole refactor since it started, on every round."
18003
+ refactor: "it covers the whole refactor since it started, on every round. A refactor may update an existing test's call sites only: give any test-file hunk its own chunk, and say so if it changes a test, an assertion or an expected value."
17995
18004
  };
17996
18005
  const buildReviewReviewingPrompt = (base, lap, carry, removed = []) => `${styleBlock}
17997
18006
 
@@ -18108,7 +18117,12 @@ ${slice === void 0 ? `- Write the closing message for the process HEAD closes or
18108
18117
  the exact escape or rewrite a committed config needs. A \`!\` on the
18109
18118
  type prefix alone (\`feat!:\`/\`refactor!:\`) is NOT enough: this repo's
18110
18119
  own commit-analyzer has missed that marker before, cutting no release
18111
- until a follow-up commit carried the literal footer instead
18120
+ until a follow-up commit carried the literal footer instead${slice === void 0 || slice.index === slice.count ? `
18121
+ - If the process was started for an issue — its entry commit or
18122
+ \`.gtd/REQUIREMENTS.md\` names one as \`#N\`, \`owner/repo#N\` or an issue
18123
+ URL — end the message with a literal \`Closes #N\` footer per issue
18124
+ (\`Closes owner/repo#N\` for another repository). Name only an issue the
18125
+ work resolves, never one it merely mentions` : ""}
18112
18126
 
18113
18127
  ${slice !== void 0 && slice.index > 1 ? `The process's entry commit is \`${it.entryCommit}\` — outside this slice's range, context
18114
18128
  that states the motivation. ` : `The process's entry commit is \`${it.entryCommit}\`. `}${human}Inspect the range: \`git log ${it.processBase}..${it.processTip}\`
@@ -38339,6 +38353,35 @@ const requireNoTestFiles = () => {
38339
38353
  if (tests.length === 0) return;
38340
38354
  refuse(`gtd land: refactor-tests: the refactor may not change a test file — revert these:\n${tests.map((path) => ` - ${path}`).join("\n")}`);
38341
38355
  };
38356
+ const holdsCallSites = (path) => !path.endsWith(".feature") && !path.endsWith(".snap");
38357
+ const COMMENT = /(?<![*/])\/\*[\s\S]*?\*\/|(?<!:)\/\/.*$/gm;
38358
+ const ASSERTION = /\b(?:expect|assert\w*)\s*[.(]/g;
38359
+ const TEST_CASE = /(?<![.\w])x?(?:it|test|describe)\s*[.(]/g;
38360
+ const MODIFIER = /(?<![.\w])(?:it|test|describe)\.(skip|only|todo|fails|skipIf|runIf)\b|(?<![.\w])(x)(?:it|test|describe)\b/g;
38361
+ const counts = (text) => {
38362
+ const live = text.replace(COMMENT, "");
38363
+ const modifiers = [...live.matchAll(MODIFIER)].map((m) => m[1] ?? m[2]).sort();
38364
+ return JSON.stringify([
38365
+ [...live.matchAll(ASSERTION)].length,
38366
+ [...live.matchAll(TEST_CASE)].length,
38367
+ modifiers
38368
+ ]);
38369
+ };
38370
+ const tested = (text) => {
38371
+ const live = text.replace(COMMENT, "");
38372
+ return live.search(ASSERTION) >= 0 || live.search(TEST_CASE) >= 0;
38373
+ };
38374
+ const callSitesOnly = ({ path, status, before, after }) => status === "modified" && holdsCallSites(path) && before !== void 0 && after !== void 0 && tested(before) && counts(before) === counts(after);
38375
+ /**
38376
+ * The test-file half of the refactor guard: an existing test may follow a changed call shape,
38377
+ * nothing more. Returns the test files it admitted, which the scope check then allows.
38378
+ */
38379
+ const requireTestCallSitesOnly = () => {
38380
+ const tests = changes().filter((c) => isCode(c.path) && isTestFile(c.path));
38381
+ const refused = tests.filter((c) => !callSitesOnly(c)).map((c) => c.path);
38382
+ if (refused.length > 0) refuse(`gtd land: refactor-tests: the refactor may update an existing test file's call sites only — never add, delete, skip or comment out a test or an assertion, or touch a snapshot, a .feature file or test data; revert these:\n${refused.map((path) => ` - ${path}`).join("\n")}`);
38383
+ return tests.map((c) => c.path);
38384
+ };
38342
38385
  const flagExpression = (pattern) => {
38343
38386
  if (pattern.trim() === "") throw new Error("gtd: refactorFlagPattern must not be blank");
38344
38387
  let expression;
@@ -38394,12 +38437,16 @@ const packageLines = (strays, pattern, reference) => [
38394
38437
  "## Rules",
38395
38438
  "",
38396
38439
  "- The suite's outcome must not change",
38397
- "- Change only the paths above, and never a test file",
38440
+ "- Change only the paths above, plus an existing test file where a changed call shape reaches it: never add or delete a test file, and never add, remove or alter a test or an assertion",
38398
38441
  `- Introduce no flag read and add no second read of an existing one (\`${pattern}\`)`,
38399
38442
  "",
38400
38443
  "## Reference",
38401
38444
  "",
38402
- reference === void 0 ? "None." : `Reading material only, never applied: \`git show ${reference} -- ${strays.map(quote).join(" ")}\``,
38445
+ ...reference === void 0 ? ["None."] : [
38446
+ `Reading material only, never applied: \`git show ${reference} -- ${strays.map(quote).join(" ")}\``,
38447
+ "",
38448
+ "Its changes to the existing code above are the seam this refactor exists to land, minus the feature's own behaviour."
38449
+ ],
38403
38450
  ""
38404
38451
  ];
38405
38452
  const demoteHeadings = (text) => {
@@ -38432,8 +38479,7 @@ const plan = (shape, options) => {
38432
38479
  };
38433
38480
  };
38434
38481
  const guardOf = ({ pattern, strays }, entry) => () => {
38435
- requireNoTestFiles();
38436
- requireWithin(strays);
38482
+ requireWithin([...strays, ...requireTestCallSitesOnly()]);
38437
38483
  requireNoNewFlags(pattern, entry);
38438
38484
  };
38439
38485
  /** Derive, build, check and close one refactor lap; `feedback` is the collected review text of the lap before. */
@@ -38474,7 +38520,11 @@ const refactorLap = async (p, guard, feedback) => {
38474
38520
  });
38475
38521
  await run("closing", removeScript([REFACTOR_PACKAGE]), { label: "Closing out the refactor" });
38476
38522
  };
38477
- const strayAccess = (strays) => ({ write: [...strays, ".gtd/**"] });
38523
+ const strayAccess = (strays) => ({ write: [
38524
+ ...strays,
38525
+ ...testFileGlobs,
38526
+ ".gtd/**"
38527
+ ] });
38478
38528
  const refactorFirst = async (shape, options = {}) => {
38479
38529
  const p = plan(shape, options);
38480
38530
  if (p === void 0) return false;
@@ -38900,4 +38950,4 @@ var workflows_default = feature;
38900
38950
  */
38901
38951
  const doors = {};
38902
38952
  //#endregion
38903
- export { ARCHITECTURE, ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, QUALITY, REFACTOR, REFACTOR_PACKAGE, REQUIREMENTS, REVIEW, REVIEW_ORIG, ShippedOptions, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, awaitReview, base, baseline, build, buildTail, builtInLenses, cleanup, cleanupFlow, closeReview, coder, collecting, declaredTests, decompose, workflows_default as default, defaults, deferNotes, describeEscalation, design, doors, envDefaults, escalate, escalation, escalationExhausted, feature, fix, fixCheck, fixFlow, fixNits, fixQuality, fixQualityFindings, fixReviewNotes, fixRisks, fixSuite, gate, guarded, handOffReview, handOffTail, healthy, implement, judgeSeam, lensesOf, naivePass, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, planner, qualityLap, reUnwind, refactorCollecting, refactorFirst, refactorStrays, requireDeclaredTests, requireNoNewFlags, requireNoTestFiles, requireWithin, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewedRefactor, reviewing, rollout, rolloutFlow, seamBuild, skills, steering, summary, tail, threads, triage, unwind };
38953
+ export { ARCHITECTURE, ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, QUALITY, REFACTOR, REFACTOR_PACKAGE, REQUIREMENTS, REVIEW, REVIEW_ORIG, ShippedOptions, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, awaitReview, base, baseline, build, buildTail, builtInLenses, cleanup, cleanupFlow, closeReview, coder, collecting, declaredTests, decompose, workflows_default as default, defaults, deferNotes, describeEscalation, design, doors, envDefaults, escalate, escalation, escalationExhausted, feature, fix, fixCheck, fixFlow, fixNits, fixQuality, fixQualityFindings, fixReviewNotes, fixRisks, fixSuite, gate, guarded, handOffReview, handOffTail, healthy, implement, judgeSeam, lensesOf, naivePass, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, planner, qualityLap, reUnwind, refactorCollecting, refactorFirst, refactorStrays, requireDeclaredTests, requireNoNewFlags, requireNoTestFiles, requireTestCallSitesOnly, requireWithin, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewedRefactor, reviewing, rollout, rolloutFlow, seamBuild, skills, steering, summary, tail, threads, triage, unwind };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "29.2.1",
3
+ "version": "29.2.3",
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": {
@@ -13,7 +13,7 @@ Feature: the refactor slice is reviewed on its own before the feature is impleme
13
13
  And an environment variable "GTD_FLAG_NAIVE_PASS" set to "1"
14
14
  And an environment variable "GTD_FLAG_RISK_TIER" set to "0"
15
15
 
16
- Scenario: with a narrowed seamAllow a test-named file written by the refactor is refused
16
+ Scenario: with a narrowed seamAllow a test file added by the refactor is refused
17
17
  Given an environment variable "GTD_REFACTORQUALITYREVIEWS" set to ""
18
18
  And an environment variable "GTD_SEAMALLOW" set to "docs/**"
19
19
  And a file "src/calc.ts" with:
@@ -85,12 +85,12 @@ Feature: the refactor slice is reviewed on its own before the feature is impleme
85
85
  import { expect, it } from "vitest"
86
86
  it("adds", () => expect(2 + 2).toBe(4))
87
87
  """
88
- And step "refactor.building" writes "src/calc.test.ts" with:
88
+ And step "refactor.building" writes "src/calc.spec.ts" with:
89
89
  """
90
90
  import { expect, it } from "vitest"
91
91
  it("adds", () => expect(3 + 3).toBe(6))
92
92
  """
93
- Then driving the process is refused with "outside its write access"
93
+ Then driving the process is refused with "refactor-tests"
94
94
 
95
95
 
96
96
  Scenario: refactorQualityReviews picks the refactor's lenses and qualityReviews does not reach them
@@ -1607,6 +1607,40 @@ Feature: the refactor slice is reviewed on its own before the feature is impleme
1607
1607
  And ".gtd/REFACTOR.md" contains "src/calc.ts"
1608
1608
  And ".gtd/REFACTOR.md" does not contain "src/calc.test.ts"
1609
1609
 
1610
+ Scenario: the refactor updates an existing test's call sites to the seam's new shape
1611
+ Given step "seam.naive" writes "src/calc.ts" with:
1612
+ """
1613
+ export const add = ({ a, b }: { a: number; b: number }) => a + b
1614
+ export const subtract = ({ a, b }: { a: number; b: number }) => a - b
1615
+ """
1616
+ And step "refactor.deriving" runs its script
1617
+ And step "refactor.building" writes "src/calc.ts" with:
1618
+ """
1619
+ export const add = ({ a, b }: { a: number; b: number }) => a + b
1620
+ """
1621
+ And step "refactor.building" writes "src/calc.test.ts" with:
1622
+ """
1623
+ import { expect, it } from "vitest"
1624
+ import { add } from "./calc"
1625
+ it("adds", () => expect(add({ a: 1, b: 1 })).toBe(2))
1626
+ """
1627
+ When the process is driven
1628
+ Then the process rests at "refactor.health.check"
1629
+
1630
+ Scenario: the refactor may not drop an assertion from an existing test
1631
+ Given step "seam.naive" writes "src/calc.ts" with:
1632
+ """
1633
+ export const add = (a: number, b: number) => a + b
1634
+ export const subtract = (a: number, b: number) => a - b
1635
+ """
1636
+ And step "refactor.deriving" runs its script
1637
+ And step "refactor.building" writes "src/calc.test.ts" with:
1638
+ """
1639
+ import { it } from "vitest"
1640
+ it("adds", () => {})
1641
+ """
1642
+ Then driving the process is refused with "refactor-tests"
1643
+
1610
1644
  Scenario: a refactor-first whose strays are all test files goes straight to implement
1611
1645
  Given step "seam.naive" writes "src/calc.test.ts" with:
1612
1646
  """
@@ -13,6 +13,7 @@ import {
13
13
  refactorStrays,
14
14
  requireNoNewFlags,
15
15
  requireNoTestFiles,
16
+ requireTestCallSitesOnly,
16
17
  requireWithin,
17
18
  reviewedRefactor,
18
19
  REFACTOR_PACKAGE,
@@ -20,6 +21,15 @@ import {
20
21
 
21
22
  afterEach(() => installContext(undefined))
22
23
 
24
+ const testFileGlobs = [
25
+ "**/*.test.*",
26
+ "**/*.spec.*",
27
+ "**/*.feature",
28
+ "**/tests/**",
29
+ "**/test/**",
30
+ "**/__tests__/**",
31
+ ]
32
+
23
33
  const change = (
24
34
  path: string,
25
35
  before: string | undefined,
@@ -131,6 +141,137 @@ describe("requireNoTestFiles", () => {
131
141
  })
132
142
  })
133
143
 
144
+ describe("requireTestCallSitesOnly", () => {
145
+ it("refuses an added or deleted test path, naming it", () => {
146
+ const list = [
147
+ change("tests/unit/b.ts", undefined, "x", "added"),
148
+ change("src/__tests__/c.ts", "a", undefined, "deleted"),
149
+ change("src/a.test.ts", "expect(a()).toBe(1)", "expect(a(1)).toBe(1)"),
150
+ ]
151
+ const message = withChanges(list, () => requireTestCallSitesOnly())
152
+ expect(message).toMatch(/^gtd land: refactor-tests: /)
153
+ expect(message).toContain(" - tests/unit/b.ts")
154
+ expect(message).toContain(" - src/__tests__/c.ts")
155
+ expect(message).not.toContain("src/a.test.ts")
156
+ })
157
+
158
+ it("passes a modified test file's call site, non-test paths and .gtd/ paths", () => {
159
+ const list = [
160
+ change("src/a.test.ts", "expect(a()).toBe(1)", "expect(a(1)).toBe(1)"),
161
+ change("src/new.ts", undefined, "x", "added"),
162
+ change(".gtd/tests/x.md", "", "x", "added"),
163
+ ]
164
+ expect(withChanges(list, () => requireTestCallSitesOnly())).toBeUndefined()
165
+ })
166
+
167
+ it("refuses a snapshot change and a dropped test or assertion, naming each", () => {
168
+ const list = [
169
+ change("src/__snapshots__/a.test.ts.snap", "a", "b"),
170
+ change("src/b.test.ts", 'it("a", () => {})\nit("b", () => {})', 'it("a", () => {})'),
171
+ change("src/c.test.ts", "expect(c()).toBe(1)\nexpect(c()).toBe(1)", "expect(c()).toBe(1)"),
172
+ change(
173
+ "d.feature",
174
+ "Feature: d\n Scenario: d\n Then it is\n",
175
+ "Feature: d\n Scenario: d\n",
176
+ ),
177
+ ]
178
+ const message = withChanges(list, () => requireTestCallSitesOnly())
179
+ for (const { path } of list) expect(message).toContain(` - ${path}`)
180
+ })
181
+
182
+ it.each([
183
+ [
184
+ "a skipped test",
185
+ 'it("a", () => expect(a()).toBe(1))',
186
+ 'it.skip("a", () => expect(a()).toBe(1))',
187
+ ],
188
+ [
189
+ "an x-prefixed test",
190
+ 'it("a", () => expect(a()).toBe(1))',
191
+ 'xit("a", () => expect(a()).toBe(1))',
192
+ ],
193
+ [
194
+ "a line-commented assertion",
195
+ 'it("a", () => {\n expect(a()).toBe(1)\n})',
196
+ 'it("a", () => {\n // expect(a()).toBe(1)\n})',
197
+ ],
198
+ [
199
+ "a block-commented test",
200
+ 'it("a", () => expect(a()).toBe(1))',
201
+ '/* it("a", () => expect(a()).toBe(1)) */',
202
+ ],
203
+ [
204
+ "a .skip swapped for an .only",
205
+ 'it.skip("a", () => expect(a()).toBe(1))\nit("b", () => expect(b()).toBe(1))',
206
+ 'it.only("a", () => expect(a()).toBe(1))\nit("b", () => expect(b()).toBe(1))',
207
+ ],
208
+ [
209
+ "an assertion dropped after a URL in a test name",
210
+ 'it("GET http://x", () => expect(get()).toBe(1))',
211
+ 'it("GET http://x", () => {})',
212
+ ],
213
+ ["fixture data with no test", '{"sum": 2}', '{"sum": 3}'],
214
+ ])("refuses %s", (_label, before, after) => {
215
+ const message = withChanges([change("tests/a.test.ts", before, after)], () =>
216
+ requireTestCallSitesOnly(),
217
+ )
218
+ expect(message).toContain(" - tests/a.test.ts")
219
+ })
220
+
221
+ it("refuses a modified test file whose content cannot be read", () => {
222
+ const message = withChanges([change("tests/a.test.ts", undefined, "expect(a()).toBe(1)")], () =>
223
+ requireTestCallSitesOnly(),
224
+ )
225
+ expect(message).toContain(" - tests/a.test.ts")
226
+ })
227
+
228
+ it("passes a regex .test( call rewritten at a call site", () => {
229
+ const list = [
230
+ change(
231
+ "src/a.test.ts",
232
+ 'it("a", () => expect(/x/.test(s)).toBe(true))',
233
+ 'it("a", () => expect(has(s)).toBe(true))',
234
+ ),
235
+ ]
236
+ expect(withChanges(list, () => requireTestCallSitesOnly())).toBeUndefined()
237
+ })
238
+
239
+ it("passes a call site rewritten inside an assertion", () => {
240
+ const list = [
241
+ change(
242
+ "src/a.test.ts",
243
+ 'it("adds", () => expect(add(1, 1)).toBe(2))',
244
+ 'it("adds", async () => expect(await add({ a: 1, b: 1 })).toBe(2))',
245
+ ),
246
+ ]
247
+ expect(withChanges(list, () => requireTestCallSitesOnly())).toBeUndefined()
248
+ })
249
+
250
+ it("is not narrowed by a narrowed seamAllow", () => {
251
+ installContext(
252
+ fixtureContext(
253
+ { vars: { seamAllow: "docs/**" } },
254
+ {
255
+ changes: () => [change("src/a.test.ts", undefined, "b", "added")],
256
+ matches: globMatches,
257
+ refuse: (message) => {
258
+ throw new Error(message)
259
+ },
260
+ },
261
+ ),
262
+ )
263
+ expect(refusal(() => requireTestCallSitesOnly())).toContain("src/a.test.ts")
264
+ })
265
+
266
+ it("does not refuse other naming such as _test.go or test_*.py", () => {
267
+ const list = [
268
+ change("pkg/a_test.go", undefined, "b", "added"),
269
+ change("pkg/test_a.py", undefined, "b", "added"),
270
+ ]
271
+ expect(withChanges(list, () => requireTestCallSitesOnly())).toBeUndefined()
272
+ })
273
+ })
274
+
134
275
  describe("refactorStrays", () => {
135
276
  it("refactorStrays drops test files, keeps the rest and ignores a narrowed seamAllow", () => {
136
277
  installContext(fixtureContext({ vars: { seamAllow: "docs/**" } }, { matches: globMatches }))
@@ -359,7 +500,7 @@ describe("refactorFirst", () => {
359
500
  const body = derive?.kind === "run" ? derive.body : ""
360
501
  expect(body).toContain("`src/a.ts`") // gtd-path-exempt: fixture paths
361
502
  expect(body).not.toContain("a.test.ts")
362
- expect(h.scopes[0]?.access).toEqual({ write: ["src/a.ts", ".gtd/**"] })
503
+ expect(h.scopes[0]?.access).toEqual({ write: ["src/a.ts", ...testFileGlobs, ".gtd/**"] })
363
504
  })
364
505
 
365
506
  it("throws before any step when a stray holds a flag read the pattern matches zero-length", async () => {
@@ -403,10 +544,13 @@ describe("refactorFirst", () => {
403
544
  expect(lines).toEqual(["- `src/z.ts`", "- `src/a.ts`", "- `it's.ts`"]) // gtd-path-exempt: fixture paths
404
545
  })
405
546
 
406
- it("runs inside a refactor scope whose write access is the strays plus .gtd/**", async () => {
547
+ it("runs inside a refactor scope whose write access is the strays, test files and .gtd/**", async () => {
407
548
  const h = await drive(shape())
408
549
  expect(h.scopes).toEqual([
409
- { name: "refactor", access: { write: ["src/a.ts", "src/b.ts", ".gtd/**"] } },
550
+ {
551
+ name: "refactor",
552
+ access: { write: ["src/a.ts", "src/b.ts", ...testFileGlobs, ".gtd/**"] },
553
+ },
410
554
  ])
411
555
  })
412
556
 
@@ -439,19 +583,38 @@ describe("refactorFirst", () => {
439
583
  expect(names(h)).not.toContain("health.check")
440
584
  })
441
585
 
442
- it("refuses a build turn changing a test file even when it is a stray", async () => {
443
- const h = await drive(shape({ strays: ["src/a.ts", "src/a.test.ts"] }), {
444
- build: [change("src/a.test.ts", "a", "b")],
586
+ it("lets a build turn update an existing test file's call sites", async () => {
587
+ const h = await drive(shape(), {
588
+ build: [
589
+ change("src/a.ts", "a", "b"),
590
+ change("tests/a.test.ts", "expect(a()).toBe(1)", "expect(await a()).toBe(1)"),
591
+ ],
592
+ })
593
+ expect(h.result).toBe(true)
594
+ expect(names(h)).toContain("health.check")
595
+ })
596
+
597
+ it("refuses a build turn adding a test file", async () => {
598
+ const h = await drive(shape(), {
599
+ build: [change("src/a.test.ts", undefined, "b", "added")],
445
600
  })
446
601
  expect((h.result as Error).message).toContain("refactor-tests")
447
602
  expect((h.result as Error).message).toContain("src/a.test.ts")
448
603
  expect(names(h)).not.toContain("health.check")
449
604
  })
450
605
 
451
- it("says never a test file in the package rules", async () => {
606
+ it("lets the package rules admit only an existing test file's call sites", async () => {
452
607
  const h = await drive(shape())
453
- expect(bodyOf(h, 0)).toContain("never a test file")
454
- expect(bodyOf(h, 0)).not.toContain("only when it is one of them")
608
+ expect(bodyOf(h, 0)).toContain("never add or delete a test file")
609
+ expect(bodyOf(h, 0)).toContain("never add, remove or alter a test or an assertion")
610
+ })
611
+
612
+ it("names the reference's seam as the change to carry, when there is a reference", async () => {
613
+ const withRef = await drive(shape(), { reference: "abc1234" })
614
+ expect(bodyOf(withRef, 0)).toMatch(/seam/)
615
+ expect(buildingPrompt(withRef)).toMatch(/touches none of it/)
616
+ const bare = await drive(shape())
617
+ expect(buildingPrompt(bare)).not.toMatch(/touches none of it/)
455
618
  })
456
619
 
457
620
  it("refuses a build turn adding a flag read", async () => {
@@ -734,7 +897,7 @@ describe("reviewedRefactor", () => {
734
897
  const body = derive?.kind === "run" ? derive.body : ""
735
898
  expect(body).toContain("`src/a.ts`") // gtd-path-exempt: fixture paths
736
899
  expect(body).not.toContain("a.test.ts")
737
- expect(h.scopes[0]?.access).toEqual({ write: ["src/a.ts", ".gtd/**"] })
900
+ expect(h.scopes[0]?.access).toEqual({ write: ["src/a.ts", ...testFileGlobs, ".gtd/**"] })
738
901
  const collecting = h.steps.find((s) => s.kind === "agent" && s.name === "review.collecting")
739
902
  const prompt = collecting?.kind === "agent" ? collecting.prompt : ""
740
903
  expect(prompt).toContain("`src/a.ts`") // gtd-path-exempt: fixture paths
@@ -958,14 +1121,18 @@ describe("reviewedRefactor", () => {
958
1121
  const h = await driveReviewed(shape({ strays: ["src/a.ts", "src/a.test.ts"] }), {
959
1122
  notes: ["add — typo"],
960
1123
  answers: { "note-1": { answer: "nit", p: 0.9 } },
961
- turns: { "refactor.build.review.fix.nits.fixing": [change("src/a.test.ts", "a", "b")] },
1124
+ turns: {
1125
+ "refactor.build.review.fix.nits.fixing": [
1126
+ change("src/a.test.ts", undefined, "b", "added"),
1127
+ ],
1128
+ },
962
1129
  })
963
1130
  expect((h.result as Error).message).toContain("refactor-tests")
964
1131
  expect(h.names.at(-1)).toBe("refactor.build.review.fix.nits.fixing")
965
1132
  })
966
1133
 
967
- it("runs the nit-fix turn under the strays plus .gtd/** as its write access", async () => {
968
- const write = ["src/a.ts", ".gtd/**"]
1134
+ it("runs the nit-fix turn under the strays, test files and .gtd/** as its write access", async () => {
1135
+ const write = ["src/a.ts", ...testFileGlobs, ".gtd/**"]
969
1136
  const h = await driveReviewed(shape({ strays: ["src/a.ts", "src/a.test.ts"] }), {
970
1137
  notes: ["add — typo"],
971
1138
  answers: { "note-1": { answer: "nit", p: 0.9 } },
@@ -979,7 +1146,7 @@ describe("reviewedRefactor", () => {
979
1146
  notes: ["add — typo"],
980
1147
  answers: { "note-1": { answer: "nit", p: 0.9 } },
981
1148
  reds: 2,
982
- turns: { "refactor.build.fix": [change("src/a.test.ts", "x", "y")] },
1149
+ turns: { "refactor.build.fix": [change("src/a.test.ts", "x", undefined, "deleted")] },
983
1150
  })
984
1151
  expect(h.names).toContain("refactor.build.fix")
985
1152
  expect((h.result as Error).message).toContain("refactor-tests")
@@ -1031,11 +1198,11 @@ describe("reviewedRefactor's tail prompts", () => {
1031
1198
  ],
1032
1199
  ["refactor.build.health.describe", { findings: true, reds: 6 }],
1033
1200
  ] as const)(
1034
- "%s names every stray and bans test files and new flag reads",
1201
+ "%s names every stray and bans new test files and new flag reads",
1035
1202
  async (name, script) => {
1036
1203
  const prompt = await promptAt(name, script as ReviewedScript)
1037
1204
  for (const stray of strays) expect(prompt).toContain(`\`${stray}\``)
1038
- expect(prompt).toContain("never a test file")
1205
+ expect(prompt).toContain("never add or delete a test file")
1039
1206
  expect(prompt).toContain("no new flag read")
1040
1207
  },
1041
1208
  )