@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +29 -19
- package/claude/hooks/handoff.ts +2 -3
- package/claude/hooks/ship.ts +762 -62
- package/dist/chunks/{reserved-DoHUII2D.mjs → reserved-fqf-c9dc.mjs} +34 -9
- package/dist/chunks/{tail-C6YBG5zh.mjs → tail-Do11DVcw.mjs} +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +127 -41
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +159 -48
- package/package.json +1 -1
- package/src/workflows/feature/feature.ts +6 -5
- package/src/workflows/feature/packages.test.ts +299 -5
- package/src/workflows/feature/packages.ts +109 -26
- package/src/workflows/feature/refactor.ts +9 -2
- package/src/workflows/feature/review.ts +25 -7
- package/src/workflows/feature/seam.ts +28 -8
- package/src/workflows/shared/text.test.ts +44 -0
- package/src/workflows/shared/text.ts +57 -10
package/dist/workflows.mjs
CHANGED
|
@@ -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
|
|
17512
|
-
an earlier fix turn may already
|
|
17513
|
-
|
|
17514
|
-
with
|
|
17515
|
-
symbol
|
|
17516
|
-
|
|
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
|
-
|
|
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 =
|
|
37959
|
-
|
|
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
|
-
|
|
37964
|
-
|
|
37965
|
-
const match = TEST_LINE.exec(
|
|
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;
|
|
37977
|
-
* written,
|
|
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
|
|
37984
|
-
|
|
37985
|
-
|
|
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
|
-
|
|
38234
|
-
const
|
|
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(
|
|
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(
|
|
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
|
-
|
|
38465
|
-
|
|
38466
|
-
|
|
38467
|
-
|
|
38468
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
38565
|
-
|
|
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(
|
|
38574
|
-
|
|
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
|
@@ -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(
|
|
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(
|
|
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(
|
|
72
|
-
outcome = await buildTail(
|
|
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([
|
|
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
|
+
})
|