@pmelab/gtd 37.0.0 → 38.0.0
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 +9 -0
- package/dist/chunks/{reserved-zAfoJtR7.mjs → reserved-sfMdy7v7.mjs} +33 -9
- package/dist/chunks/{tail-BRC4DGdV.mjs → tail-Cgca5Gp-.mjs} +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +168 -42
- 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 +198 -48
- package/package.json +1 -1
- package/src/testkit/steps.ts +35 -0
- package/src/workflows/feature/coverage.ts +14 -6
- package/src/workflows/feature/criteria.test.ts +17 -1
- package/src/workflows/feature/criteria.ts +26 -3
- package/src/workflows/feature/feature.ts +64 -15
- package/src/workflows/feature/index.ts +1 -0
- package/src/workflows/feature/seam.ts +63 -26
- package/src/workflows/feature/slicing-cap.feature +14 -0
- package/src/workflows/feature/slicing.feature +225 -0
- package/src/workflows/feature/slicing.test.ts +49 -0
- package/src/workflows/feature/slicing.ts +105 -0
- package/src/workflows/index.ts +1 -0
- package/src/workflows/shared/fences.ts +5 -0
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/steps.ts +1 -0
- package/src/workflows/shared/text.ts +50 -15
- package/src/workflows/shared/vars.ts +1 -0
- package/src/workflows/shared/workflows.steps.ts +13 -0
package/dist/workflows.mjs
CHANGED
|
@@ -16934,6 +16934,7 @@ const defaults = {
|
|
|
16934
16934
|
refactorFlagPattern: "\\bGTD_FLAG_\\w+",
|
|
16935
16935
|
seamAllow: [...testFileGlobs, ".gtd/**"].join(", "),
|
|
16936
16936
|
flagMode: "true",
|
|
16937
|
+
sliceMaxCriteria: "8",
|
|
16937
16938
|
flag_risk_tier: "0",
|
|
16938
16939
|
qualityReviews: "correctness, owasp-security, ponytail-review, test-audit, conventions, spec-challenge",
|
|
16939
16940
|
refactorQualityReviews: "correctness, ponytail-review, conventions"
|
|
@@ -17393,6 +17394,24 @@ printf '%s\\n' ${[
|
|
|
17393
17394
|
].map(quote).join(" ")} > ${quote(".gtd/REQUIREMENTS.md")}
|
|
17394
17395
|
`;
|
|
17395
17396
|
};
|
|
17397
|
+
const slicingPrompt = (cap) => `${stateFileRules}
|
|
17398
|
+
- Read \`.gtd/REQUIREMENTS.md\`: its \`## Acceptance Criteria\` are settled
|
|
17399
|
+
and more than ${cap}, too many to build in one slice
|
|
17400
|
+
- Group them into slices in \`.gtd/SLICES.md\`, one \`## <title>\` heading per
|
|
17401
|
+
slice over a \`- AC-<n>\` list item per criterion it builds. Put criteria of
|
|
17402
|
+
the same flow or subsystem together; every criterion in exactly one slice,
|
|
17403
|
+
at most ${cap} per slice
|
|
17404
|
+
- Keep it coarse: as few slices as the cap allows, never one per criterion
|
|
17405
|
+
- Order the slices so each builds on the ones before: they run in that order,
|
|
17406
|
+
each from where the previous one landed
|
|
17407
|
+
- Change nothing but \`.gtd/SLICES.md\` — leave the criteria as they are
|
|
17408
|
+
- Leave everything uncommitted and finish your turn — do not commit
|
|
17409
|
+
`;
|
|
17410
|
+
/** The line telling a slice's turn which criteria are its own; empty for a single slice. */
|
|
17411
|
+
const sliceLine = (ids) => ids === void 0 ? "" : `- This slice builds ${ids.join(", ")} only: every rule here about the criteria
|
|
17412
|
+
means these. The others are for other slices, before or after it — leave
|
|
17413
|
+
them unbuilt and untested
|
|
17414
|
+
`;
|
|
17396
17415
|
const designSystem = () => `${designPersona}
|
|
17397
17416
|
|
|
17398
17417
|
${agentConduct}`;
|
|
@@ -18039,9 +18058,9 @@ re-implement. It changed these files outside the expected set:
|
|
|
18039
18058
|
${listed(retry.strays)}
|
|
18040
18059
|
Start over and choose a different approach; do not rebuild the same diff.
|
|
18041
18060
|
`;
|
|
18042
|
-
const naivePassPrompt = (retry) => `${stateFileRules}
|
|
18061
|
+
const naivePassPrompt = (retry, slice) => `${stateFileRules}
|
|
18043
18062
|
- Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
|
|
18044
|
-
${criteriaBinding(false)}
|
|
18063
|
+
${sliceLine(slice)}${criteriaBinding(false)}
|
|
18045
18064
|
- Implement them the quickest way that works — the naive version. Do not
|
|
18046
18065
|
restructure, abstract or refactor around the change; it may land exactly as
|
|
18047
18066
|
you leave it, and a reviewer makes it fit
|
|
@@ -18091,9 +18110,13 @@ const afterRefactorLine = (rebuild) => `- A reviewed refactor has just landed an
|
|
|
18091
18110
|
though never a release flag. Build only what is still missing, held to every
|
|
18092
18111
|
rule below; if nothing is, ${carries(rebuild) ? "carry over the acceptance tests below and change nothing else" : "change nothing and finish your turn"}
|
|
18093
18112
|
`;
|
|
18094
|
-
const
|
|
18113
|
+
const flagLine = (flagMode, tier, amend, sliced) => !flagMode ? "- Add no release flag — this project ships without release flags" : tier === "low" ? "- Add no release flag — this change is low risk tier" : amend === true ? "- Keep the release flag the earlier lap added, if it added one; otherwise put\n the behaviour behind a new release flag" : sliced ? `- Put the new behaviour behind the release flag an earlier slice of this
|
|
18114
|
+
change added; only when none did, behind a new one, following the
|
|
18115
|
+
repository's flag conventions` : `- Put the new behaviour behind a new release flag, following the
|
|
18116
|
+
repository's flag conventions`;
|
|
18117
|
+
const implementBuildingPrompt = (reference, tier, amend, afterRefactor, flagMode = true, rebuild = {}, slice) => `${stateFileRules}
|
|
18095
18118
|
- Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
|
|
18096
|
-
${criteriaBinding(true)}
|
|
18119
|
+
${sliceLine(slice)}${criteriaBinding(true)}
|
|
18097
18120
|
${afterRefactor === true ? afterRefactorLine(rebuild) : ""}${amend === true ? `- The change already exists in the tree from an earlier lap; amend it to meet
|
|
18098
18121
|
the re-derived requirements instead of rewriting it
|
|
18099
18122
|
` : flagMode ? `- Implement them properly, fitting the codebase's seams and conventions
|
|
@@ -18102,8 +18125,7 @@ ${afterRefactor === true ? afterRefactorLine(rebuild) : ""}${amend === true ? `-
|
|
|
18102
18125
|
`}${reference === void 0 || amend === true ? "" : `- Reference only: \`git show ${reference}\` shows an earlier naive attempt.
|
|
18103
18126
|
Read it for ideas; never apply, cherry-pick or check it out — write
|
|
18104
18127
|
the change fresh${carries(rebuild) ? ", its acceptance tests excepted (below)" : ""}
|
|
18105
|
-
`}${rebuildLines(rebuild)}${
|
|
18106
|
-
repository's flag conventions`}
|
|
18128
|
+
`}${rebuildLines(rebuild)}${flagLine(flagMode, tier, amend, slice !== void 0)}
|
|
18107
18129
|
- Write the tests first, then the code that makes them pass. Name each
|
|
18108
18130
|
criterion's ID in the test that covers it (\`[AC-3] …\`): once the suite is
|
|
18109
18131
|
green a judge checks every ID has a passing test, and sends uncovered ones back
|
|
@@ -18184,9 +18206,9 @@ const coverageMessage = () => `Judging acceptance-criteria coverage. Run \`gtd j
|
|
|
18184
18206
|
\`yes\`/\`no\` verdict per criterion ID — or land without a verdict, which
|
|
18185
18207
|
counts every criterion as uncovered.
|
|
18186
18208
|
`;
|
|
18187
|
-
const coveringPrompt = (uncovered) => `${stateFileRules}
|
|
18209
|
+
const coveringPrompt = (uncovered, slice) => `${stateFileRules}
|
|
18188
18210
|
- Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
|
|
18189
|
-
${criteriaBinding(true)}
|
|
18211
|
+
${sliceLine(slice)}${criteriaBinding(true)}
|
|
18190
18212
|
- A judge found no passing test in this change that covers ${uncovered.join(", ")}.
|
|
18191
18213
|
Write a behaviour-level test for each, naming its ID in the test or scenario
|
|
18192
18214
|
name (\`[${uncovered[0]}] …\`), and fix the code if the test shows it falls short
|
|
@@ -18213,6 +18235,7 @@ const REVIEW = ".gtd/REVIEW.md";
|
|
|
18213
18235
|
const REFACTOR = ".gtd/REFACTOR.md";
|
|
18214
18236
|
const REFACTOR_NOTE = ".gtd/REFACTOR-NOTE.md";
|
|
18215
18237
|
const QUALITY = ".gtd/QUALITY.md";
|
|
18238
|
+
const SLICES = ".gtd/SLICES.md";
|
|
18216
18239
|
const planner = () => env.plannerModel ?? "";
|
|
18217
18240
|
const coder = () => env.coderModel ?? "";
|
|
18218
18241
|
const fixCheck = (rules) => agentWithSkills("fix", buildFixPrompt(rules), {
|
|
@@ -37724,6 +37747,7 @@ const ShippedOptions = Struct({
|
|
|
37724
37747
|
reviewSeverityMinP: setting,
|
|
37725
37748
|
refactorFlagPattern: setting,
|
|
37726
37749
|
seamAllow: setting,
|
|
37750
|
+
sliceMaxCriteria: setting,
|
|
37727
37751
|
qualityReviews: setting,
|
|
37728
37752
|
refactorQualityReviews: setting,
|
|
37729
37753
|
testCommand: setting,
|
|
@@ -37904,13 +37928,15 @@ const markFenced = (text) => {
|
|
|
37904
37928
|
};
|
|
37905
37929
|
});
|
|
37906
37930
|
};
|
|
37931
|
+
/** A markdown heading: its `#` run, then its text. */
|
|
37932
|
+
const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
|
|
37933
|
+
/** A list item, its checkbox dropped: its text. */
|
|
37934
|
+
const ITEM = /^ ?(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.*)$/;
|
|
37907
37935
|
//#endregion
|
|
37908
37936
|
//#region src/workflows/feature/criteria.ts
|
|
37909
37937
|
/** Below this the judge's `yes` is not trusted: a wrong skip builds against criteria nobody can check. */
|
|
37910
37938
|
const CLEAR_MIN_P = .8;
|
|
37911
|
-
const
|
|
37912
|
-
const ITEM = /^ ?(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.*)$/;
|
|
37913
|
-
const ID = /^(AC-\d+):\s*(.*)$/;
|
|
37939
|
+
const ID$1 = /^(AC-\d+):\s*(.*)$/;
|
|
37914
37940
|
/** The list items under the first heading `title` matches (at `level` when given), up to the next heading; wrapped lines joined, fenced lines ignored. */
|
|
37915
37941
|
const listUnder = (text, title, level) => {
|
|
37916
37942
|
const lines = markFenced(text);
|
|
@@ -37938,7 +37964,7 @@ const listUnder = (text, title, level) => {
|
|
|
37938
37964
|
};
|
|
37939
37965
|
/** The `- AC-<n>: …` list of `## Acceptance Criteria`, or `undefined` when the section is missing. */
|
|
37940
37966
|
const criteriaOf = (text) => listUnder(text, /^acceptance criteria$/i, 2)?.map((item) => {
|
|
37941
|
-
const id = ID.exec(item);
|
|
37967
|
+
const id = ID$1.exec(item);
|
|
37942
37968
|
return id === null ? {
|
|
37943
37969
|
id: "",
|
|
37944
37970
|
text: item
|
|
@@ -37947,16 +37973,20 @@ const criteriaOf = (text) => listUnder(text, /^acceptance criteria$/i, 2)?.map((
|
|
|
37947
37973
|
text: id[2]
|
|
37948
37974
|
};
|
|
37949
37975
|
});
|
|
37976
|
+
/** The ID'd criteria of `text`, only `slice`'s when given. */
|
|
37977
|
+
const criterionIds = (text, slice) => (criteriaOf(text) ?? []).map((c) => c.id).filter((id) => id !== "" && (slice?.only === void 0 || slice.only.includes(id)) && !(slice?.except ?? []).includes(id));
|
|
37978
|
+
/** `slice`'s IDs in `.gtd/REQUIREMENTS.md` as it is now; `undefined` for an unsliced change. */
|
|
37979
|
+
const sliceIds = (slice) => slice === void 0 ? void 0 : criterionIds(read(".gtd/REQUIREMENTS.md") ?? "", slice);
|
|
37950
37980
|
/** Items numbered `AC-1`…, unless every one already carries its own distinct ID. */
|
|
37951
37981
|
const numbered = (items) => {
|
|
37952
|
-
const ids = items.map((item) => ID.exec(item));
|
|
37982
|
+
const ids = items.map((item) => ID$1.exec(item));
|
|
37953
37983
|
const own = ids.every((id) => id !== null) && new Set(ids.map((id) => id[1])).size === ids.length;
|
|
37954
37984
|
return items.map((item, i) => own ? {
|
|
37955
37985
|
id: ids[i][1],
|
|
37956
37986
|
text: ids[i][2]
|
|
37957
37987
|
} : {
|
|
37958
37988
|
id: `AC-${i + 1}`,
|
|
37959
|
-
text: item.replace(ID, "$2")
|
|
37989
|
+
text: item.replace(ID$1, "$2")
|
|
37960
37990
|
});
|
|
37961
37991
|
};
|
|
37962
37992
|
const STATED = /^acceptance criteria:?$/i;
|
|
@@ -38646,10 +38676,12 @@ const green = (guard) => healthy(() => agentWithSkills("fix.suite.fixing", imple
|
|
|
38646
38676
|
const testsSince = (base) => changesSince(base).filter((c) => c.after !== void 0 && isCode$1(c.path) && isTestFile(c.path)).sort((a, b) => Number(/\bAC-\d/.test(a.after)) - Number(/\bAC-\d/.test(b.after))).map((c) => `=== ${c.path} (${c.status})\n${c.after}`).join("\n");
|
|
38647
38677
|
/**
|
|
38648
38678
|
* The criterion IDs of `.gtd/REQUIREMENTS.md` — read afresh, never carried
|
|
38649
|
-
* over — no passing test since `base` covers, by the judge.
|
|
38679
|
+
* over, only `slice`'s when given — no passing test since `base` covers, by the judge.
|
|
38650
38680
|
*/
|
|
38651
|
-
const uncoveredSince = async (base) => {
|
|
38652
|
-
const
|
|
38681
|
+
const uncoveredSince = async (base, slice) => {
|
|
38682
|
+
const requirements = read(".gtd/REQUIREMENTS.md") ?? "";
|
|
38683
|
+
const own = criterionIds(requirements, slice);
|
|
38684
|
+
const criteria = (criteriaOf(requirements) ?? []).filter((c) => own.includes(c.id));
|
|
38653
38685
|
if (criteria.length === 0) return {
|
|
38654
38686
|
covered: [],
|
|
38655
38687
|
uncovered: []
|
|
@@ -38676,14 +38708,14 @@ const record = (covered, uncovered = []) => {
|
|
|
38676
38708
|
* sending uncovered IDs back to a covering turn; marks the IDs covered on the
|
|
38677
38709
|
* landing that ends it. A human may overrule once the retries are spent.
|
|
38678
38710
|
*/
|
|
38679
|
-
const requireCoverage = async (base = start()) => {
|
|
38711
|
+
const requireCoverage = async (base = start(), slice) => {
|
|
38680
38712
|
let retries = 0;
|
|
38681
38713
|
for (;;) {
|
|
38682
|
-
const { covered, uncovered } = await uncoveredSince(base);
|
|
38714
|
+
const { covered, uncovered } = await uncoveredSince(base, slice);
|
|
38683
38715
|
if (uncovered.length === 0) return record(covered);
|
|
38684
38716
|
if (retries < COVERING_RETRIES) {
|
|
38685
38717
|
retries++;
|
|
38686
|
-
await agentWithSkills("covering", coveringPrompt(uncovered), {
|
|
38718
|
+
await agentWithSkills("covering", coveringPrompt(uncovered, sliceIds(slice)), {
|
|
38687
38719
|
label: "Covering the acceptance criteria",
|
|
38688
38720
|
model: coder(),
|
|
38689
38721
|
system: builderSystem(),
|
|
@@ -38714,7 +38746,7 @@ const judgeSeam = (tip) => judgeDiffShape("judge", {
|
|
|
38714
38746
|
allow: (vars.seamAllow ?? defaults.seamAllow).split(",").map((glob) => glob.trim()).filter((glob) => glob !== ""),
|
|
38715
38747
|
exempt: tip.changes.filter((c) => c.status === "added").map((c) => c.path)
|
|
38716
38748
|
});
|
|
38717
|
-
const naivePass = (retry) => agentWithSkills("naive", naivePassPrompt(retry), {
|
|
38749
|
+
const naivePass = (retry, slice) => agentWithSkills("naive", naivePassPrompt(retry, sliceIds(slice)), {
|
|
38718
38750
|
label: retry === void 0 ? "Writing the naive pass" : "Rewriting the naive pass",
|
|
38719
38751
|
keep: true,
|
|
38720
38752
|
model: coder(),
|
|
@@ -38755,7 +38787,7 @@ const addedLines = (change) => {
|
|
|
38755
38787
|
return change.after.split("\n").filter((line) => !before.has(line)).join("\n");
|
|
38756
38788
|
};
|
|
38757
38789
|
const acceptanceTests = (changes) => changes.filter((c) => c.after !== void 0 && isCode$1(c.path) && isTestFile(c.path));
|
|
38758
|
-
const idsOf = (file) => (
|
|
38790
|
+
const idsOf = (file, slice) => criterionIds(read(file) ?? "", slice);
|
|
38759
38791
|
const unnamed = (ids, tests) => {
|
|
38760
38792
|
const lines = tests.map(addedLines);
|
|
38761
38793
|
return ids.filter((id) => !lines.some((text) => named(id, text)));
|
|
@@ -38765,9 +38797,9 @@ const unnamed = (ids, tests) => {
|
|
|
38765
38797
|
* test line of the pass names, or a test importing a file the pass added —
|
|
38766
38798
|
* naive structure a reset and refactor replaces, which the tests must survive.
|
|
38767
38799
|
*/
|
|
38768
|
-
const acceptanceFault = (file, tip) => {
|
|
38800
|
+
const acceptanceFault = (file, tip, slice) => {
|
|
38769
38801
|
const tests = acceptanceTests(tip.changes);
|
|
38770
|
-
const untested = unnamed(idsOf(file), tests);
|
|
38802
|
+
const untested = unnamed(idsOf(file, slice), tests);
|
|
38771
38803
|
if (untested.length > 0) return `acceptance-tests: no test of the naive pass names ${untested.join(", ")} — write a behaviour-level test for each criterion with its ID in the test or scenario name, e.g. "[${untested[0]}] …"`;
|
|
38772
38804
|
const added = tip.changes.filter((c) => c.status === "added" && isCode$1(c.path) && !isTestFile(c.path)).map((c) => c.path);
|
|
38773
38805
|
const reaching = tests.filter((test) => holdsCallSites(test.path)).flatMap((test) => {
|
|
@@ -38777,8 +38809,8 @@ const acceptanceFault = (file, tip) => {
|
|
|
38777
38809
|
if (reaching.length > 0) return `acceptance-internals: these tests import files the naive pass added — test through code that existed before it (a public entry point, an existing module's exports):\n${reaching.join("\n")}`;
|
|
38778
38810
|
};
|
|
38779
38811
|
/** What a rebuild carries over from the naive pass `tip`: the IDs its tests name, and those test files. */
|
|
38780
|
-
const naiveAcceptance = (tip) => {
|
|
38781
|
-
const ids = idsOf(REQUIREMENTS);
|
|
38812
|
+
const naiveAcceptance = (tip, slice) => {
|
|
38813
|
+
const ids = idsOf(REQUIREMENTS, slice);
|
|
38782
38814
|
const perTest = acceptanceTests(tip.changes).map((c) => {
|
|
38783
38815
|
const lines = addedLines(c);
|
|
38784
38816
|
return {
|
|
@@ -38857,7 +38889,7 @@ function implement(first, tier, amend) {
|
|
|
38857
38889
|
const since = head$1();
|
|
38858
38890
|
const acceptance = options.rebuild?.acceptance;
|
|
38859
38891
|
const built = options.naive === void 0 || !await applied(options.naive);
|
|
38860
|
-
if (built) await agentWithSkills("building", implementBuildingPrompt(options.reference, options.tier, options.amend, options.afterRefactor, flagModeOn(), options.rebuild), {
|
|
38892
|
+
if (built) await agentWithSkills("building", implementBuildingPrompt(options.reference, options.tier, options.amend, options.afterRefactor, flagModeOn(), options.rebuild, sliceIds(options.slice)), {
|
|
38861
38893
|
label: "Implementing",
|
|
38862
38894
|
model: coder(),
|
|
38863
38895
|
system: builderSystem(),
|
|
@@ -38866,10 +38898,18 @@ function implement(first, tier, amend) {
|
|
|
38866
38898
|
requireCriteriaFrozen(REQUIREMENTS);
|
|
38867
38899
|
if (built && acceptance !== void 0) requireAcceptance(acceptance, since);
|
|
38868
38900
|
await green(built && acceptance !== void 0 ? () => requireAcceptanceKept(acceptance) : void 0);
|
|
38869
|
-
await requireCoverage();
|
|
38870
|
-
await
|
|
38901
|
+
await requireCoverage(options.base, options.slice);
|
|
38902
|
+
await closing(options.more === true);
|
|
38871
38903
|
});
|
|
38872
38904
|
}
|
|
38905
|
+
/** The last slice's closing, for a last slice a steer left with no criterion to build. */
|
|
38906
|
+
const closeOut = () => scope("implement", () => closing(false));
|
|
38907
|
+
/** A slice with `more` after it keeps the criteria and the slicing for the next one. */
|
|
38908
|
+
const closing = (more) => run("closing", removeScript(more ? [REFACTOR_NOTE] : [
|
|
38909
|
+
REQUIREMENTS,
|
|
38910
|
+
REFACTOR_NOTE,
|
|
38911
|
+
SLICES
|
|
38912
|
+
]), { label: "Closing out the implementation" });
|
|
38873
38913
|
/** Resolves `true` when the human steered (edited), `false` on a clean overrule. */
|
|
38874
38914
|
const steer = async (retry) => {
|
|
38875
38915
|
await human("steer", {
|
|
@@ -38900,8 +38940,8 @@ const budget = () => {
|
|
|
38900
38940
|
};
|
|
38901
38941
|
};
|
|
38902
38942
|
/** Sends a pass whose tests fall short or run red back (`undefined`: it goes on, an overruled one as it is). */
|
|
38903
|
-
const vetted = async (tip, vetting) => {
|
|
38904
|
-
const fault = vetting.waived ? void 0 : acceptanceFault(REQUIREMENTS, tip);
|
|
38943
|
+
const vetted = async (tip, vetting, slice) => {
|
|
38944
|
+
const fault = vetting.waived ? void 0 : acceptanceFault(REQUIREMENTS, tip, slice);
|
|
38905
38945
|
if (fault !== void 0) {
|
|
38906
38946
|
const verdict = await vetting.again({
|
|
38907
38947
|
fault,
|
|
@@ -38935,16 +38975,16 @@ const ruled = async (tip, again) => {
|
|
|
38935
38975
|
tip
|
|
38936
38976
|
} : next;
|
|
38937
38977
|
};
|
|
38938
|
-
const decide = () => scope("seam", async () => {
|
|
38978
|
+
const decide = (slice) => scope("seam", async () => {
|
|
38939
38979
|
const vetting = {
|
|
38940
38980
|
again: budget(),
|
|
38941
38981
|
waived: false
|
|
38942
38982
|
};
|
|
38943
38983
|
let retry;
|
|
38944
38984
|
for (;;) {
|
|
38945
|
-
await naivePass(retry);
|
|
38985
|
+
await naivePass(retry, slice);
|
|
38946
38986
|
const tip = kept("naive");
|
|
38947
|
-
const outcome = await vetted(tip, vetting) ?? (flagModeOn() ? await ruled(tip, vetting.again) : {
|
|
38987
|
+
const outcome = await vetted(tip, vetting, slice) ?? (flagModeOn() ? await ruled(tip, vetting.again) : {
|
|
38948
38988
|
reference: tip.hash,
|
|
38949
38989
|
tip
|
|
38950
38990
|
});
|
|
@@ -38965,8 +39005,8 @@ const refactorNote = async (reference, strays) => {
|
|
|
38965
39005
|
if (others.length > 0 || note.trim() === "") refuse(`gtd land: refactor-note: write ${REFACTOR_NOTE} and change nothing else${others.length > 0 ? ` — revert these:\n${others.map((path) => ` - ${path}`).join("\n")}` : ""}`);
|
|
38966
39006
|
return note;
|
|
38967
39007
|
};
|
|
38968
|
-
const seamBuild = async (base) => {
|
|
38969
|
-
const { reference, tip, shape } = await decide();
|
|
39008
|
+
const seamBuild = async (base, slice$1 = {}) => {
|
|
39009
|
+
const { reference, tip, shape } = await decide(slice$1.criteria);
|
|
38970
39010
|
if (shape === void 0) return {
|
|
38971
39011
|
reference,
|
|
38972
39012
|
base,
|
|
@@ -38981,17 +39021,17 @@ const seamBuild = async (base) => {
|
|
|
38981
39021
|
return {
|
|
38982
39022
|
reference,
|
|
38983
39023
|
base,
|
|
38984
|
-
rebuild: { acceptance: naiveAcceptance(tip) }
|
|
39024
|
+
rebuild: { acceptance: naiveAcceptance(tip, slice$1.criteria) }
|
|
38985
39025
|
};
|
|
38986
39026
|
}
|
|
38987
39027
|
const strays = refactorStrays(shape);
|
|
38988
39028
|
if (strays.length === 0) return {
|
|
38989
39029
|
reference,
|
|
38990
39030
|
base,
|
|
38991
|
-
rebuild: { acceptance: naiveAcceptance(tip) }
|
|
39031
|
+
rebuild: { acceptance: naiveAcceptance(tip, slice$1.criteria) }
|
|
38992
39032
|
};
|
|
38993
39033
|
const note = await scope("seam", () => refactorNote(reference, strays));
|
|
38994
|
-
const refactored = await slice("refactor", () => reviewedRefactor(shape, {
|
|
39034
|
+
const refactored = await slice(slice$1.refactor ?? "refactor", () => reviewedRefactor(shape, {
|
|
38995
39035
|
reference,
|
|
38996
39036
|
note
|
|
38997
39037
|
}));
|
|
@@ -39001,34 +39041,143 @@ const seamBuild = async (base) => {
|
|
|
39001
39041
|
refactored,
|
|
39002
39042
|
rebuild: {
|
|
39003
39043
|
note,
|
|
39004
|
-
acceptance: naiveAcceptance(tip)
|
|
39044
|
+
acceptance: naiveAcceptance(tip, slice$1.criteria)
|
|
39005
39045
|
}
|
|
39006
39046
|
};
|
|
39007
39047
|
};
|
|
39008
39048
|
//#endregion
|
|
39049
|
+
//#region src/workflows/feature/slicing.ts
|
|
39050
|
+
const ID = /^(AC-\d+)(?![\w-])/;
|
|
39051
|
+
/** The `sliceMaxCriteria` setting. Anything but a positive integer fails rather than guess a cap. */
|
|
39052
|
+
const sliceCap = () => {
|
|
39053
|
+
const value = vars.sliceMaxCriteria ?? defaults.sliceMaxCriteria;
|
|
39054
|
+
if (!/^[1-9]\d*$/.test(value)) refuse(`gtd: sliceMaxCriteria must be a positive integer, got ${JSON.stringify(value)}`);
|
|
39055
|
+
return Number(value);
|
|
39056
|
+
};
|
|
39057
|
+
/** Each `## ` heading of `text` with the list items under it; an item not led by an ID keeps an empty one. */
|
|
39058
|
+
const slicesOf = (text) => {
|
|
39059
|
+
const slices = [];
|
|
39060
|
+
let open = false;
|
|
39061
|
+
for (const { line, fenced } of markFenced(text)) {
|
|
39062
|
+
if (fenced) continue;
|
|
39063
|
+
const heading = HEADING.exec(line);
|
|
39064
|
+
if (heading !== null) {
|
|
39065
|
+
open = heading[1] === "##";
|
|
39066
|
+
if (open) slices.push({
|
|
39067
|
+
title: heading[2],
|
|
39068
|
+
ids: []
|
|
39069
|
+
});
|
|
39070
|
+
continue;
|
|
39071
|
+
}
|
|
39072
|
+
const item = ITEM.exec(line);
|
|
39073
|
+
if (item !== null && open) slices.at(-1).ids.push(ID.exec(item[1])?.[1] ?? "");
|
|
39074
|
+
}
|
|
39075
|
+
return slices;
|
|
39076
|
+
};
|
|
39077
|
+
const twice = (list) => [...new Set(list.filter((id, i) => list.indexOf(id) !== i))];
|
|
39078
|
+
/** Why `slices` fail to put each of `ids` in exactly one slice of at most `cap`, or `undefined`. */
|
|
39079
|
+
const sliceFault = (slices, ids, cap) => {
|
|
39080
|
+
const listed = slices.flatMap((s) => s.ids);
|
|
39081
|
+
if (slices.length === 0) return `no "## " slice`;
|
|
39082
|
+
const empty = slices.filter((s) => s.ids.length === 0).map((s) => s.title);
|
|
39083
|
+
if (empty.length > 0) return `a slice with no criterion: ${empty.join(", ")}`;
|
|
39084
|
+
if (listed.includes("")) return "a list item not led by a criterion ID";
|
|
39085
|
+
const over = slices.filter((s) => s.ids.length > cap).map((s) => s.title);
|
|
39086
|
+
if (over.length > 0) return `more than ${cap} criteria in: ${over.join(", ")}`;
|
|
39087
|
+
const repeated = twice(listed);
|
|
39088
|
+
if (repeated.length > 0) return `an ID in more than one slice: ${repeated.join(", ")}`;
|
|
39089
|
+
const unknown = listed.filter((id) => !ids.includes(id));
|
|
39090
|
+
if (unknown.length > 0) return `an ID ${REQUIREMENTS} does not list: ${unknown.join(", ")}`;
|
|
39091
|
+
const missing = ids.filter((id) => !listed.includes(id));
|
|
39092
|
+
if (missing.length > 0) return `no slice for: ${missing.join(", ")}`;
|
|
39093
|
+
};
|
|
39094
|
+
/** Refuse a turn whose `.gtd/SLICES.md` does not put each of `ids` in exactly one slice of at most `cap`. */
|
|
39095
|
+
const requireSlices = (ids, cap) => {
|
|
39096
|
+
const slices = slicesOf(read(".gtd/SLICES.md") ?? "");
|
|
39097
|
+
const why = sliceFault(slices, ids, cap);
|
|
39098
|
+
if (why !== void 0) refuse(`gtd land: slices: ${SLICES} has ${why} — list each slice as a "## <title>" heading over "- AC-<n>" items, every criterion of ${REQUIREMENTS} in exactly one slice, at most ${cap} per slice`);
|
|
39099
|
+
return slices;
|
|
39100
|
+
};
|
|
39101
|
+
/**
|
|
39102
|
+
* The slices to build, in order. Criteria within `sliceMaxCriteria` are one
|
|
39103
|
+
* slice and no turn; above it `design.slicing` groups them.
|
|
39104
|
+
*/
|
|
39105
|
+
const slicing = async () => {
|
|
39106
|
+
const all = criterionIds(read(".gtd/REQUIREMENTS.md") ?? "");
|
|
39107
|
+
const cap = sliceCap();
|
|
39108
|
+
if (all.length <= cap) return [{
|
|
39109
|
+
title: "",
|
|
39110
|
+
ids: all
|
|
39111
|
+
}];
|
|
39112
|
+
return scope("design", async () => {
|
|
39113
|
+
await agentWithSkills("slicing", slicingPrompt(cap), {
|
|
39114
|
+
label: "Slicing the change",
|
|
39115
|
+
file: SLICES,
|
|
39116
|
+
model: planner(),
|
|
39117
|
+
system: designSystem()
|
|
39118
|
+
});
|
|
39119
|
+
return requireSlices(all, cap);
|
|
39120
|
+
});
|
|
39121
|
+
};
|
|
39122
|
+
/** Slice `i`'s criteria; `undefined` when `slices` is the one slice of an unsliced change. */
|
|
39123
|
+
const sliceCriteria = (slices, i) => slices.length === 1 ? void 0 : i < slices.length - 1 ? { only: slices[i].ids } : { except: slices.slice(0, -1).flatMap((s) => s.ids) };
|
|
39124
|
+
//#endregion
|
|
39009
39125
|
//#region src/workflows/feature/feature.ts
|
|
39010
39126
|
/** Revert the sketch that started the process out of the working tree; its intent survives in history. */
|
|
39011
39127
|
const unwind = () => {
|
|
39012
39128
|
const commit = head$1();
|
|
39013
39129
|
return run("unwind", revertScript(commit, FEEDBACK, unwindFailure(commit)), { label: "Unwinding your input" });
|
|
39014
39130
|
};
|
|
39015
|
-
/**
|
|
39131
|
+
/**
|
|
39132
|
+
* The slices `design.slicing` grouped the criteria into, each built in turn
|
|
39133
|
+
* from where the previous one landed; design must have run since `firstBase`.
|
|
39134
|
+
*/
|
|
39016
39135
|
const buildFeature = async (firstBase) => {
|
|
39017
39136
|
const tier = riskTierOn() ? recordRisk() : void 0;
|
|
39018
|
-
const
|
|
39019
|
-
|
|
39137
|
+
const slices = await slicing();
|
|
39138
|
+
const one = slices.length === 1;
|
|
39139
|
+
for (const i of slices.keys()) {
|
|
39140
|
+
const more = i < slices.length - 1;
|
|
39141
|
+
const criteria = sliceCriteria(slices, i);
|
|
39142
|
+
if (sliceIds(criteria)?.length === 0) {
|
|
39143
|
+
if (!more) await closeOut();
|
|
39144
|
+
continue;
|
|
39145
|
+
}
|
|
39146
|
+
const n = one ? "" : `-${i + 1}`;
|
|
39147
|
+
await buildSlice(i === 0 ? firstBase : head$1(), {
|
|
39148
|
+
tier,
|
|
39149
|
+
criteria,
|
|
39150
|
+
refactor: `refactor${n}`,
|
|
39151
|
+
feature: `feature${n}`,
|
|
39152
|
+
more
|
|
39153
|
+
});
|
|
39154
|
+
}
|
|
39155
|
+
};
|
|
39156
|
+
/** A naive pass (judged only with `flagMode` on), then implement and the quality lap. */
|
|
39157
|
+
const buildSlice = async (sliceBase, build) => {
|
|
39158
|
+
const seam = await seamBuild(sliceBase, {
|
|
39159
|
+
criteria: build.criteria,
|
|
39160
|
+
refactor: build.refactor
|
|
39161
|
+
});
|
|
39162
|
+
await slice(build.feature, async () => {
|
|
39020
39163
|
await implement({
|
|
39021
39164
|
reference: seam.reference,
|
|
39022
|
-
tier,
|
|
39165
|
+
tier: build.tier,
|
|
39023
39166
|
afterRefactor: seam.refactored,
|
|
39024
39167
|
naive: seam.land === true ? seam.reference : void 0,
|
|
39025
|
-
rebuild: seam.rebuild
|
|
39168
|
+
rebuild: seam.rebuild,
|
|
39169
|
+
slice: build.criteria,
|
|
39170
|
+
base: build.criteria === void 0 ? void 0 : sliceBase,
|
|
39171
|
+
more: build.more
|
|
39026
39172
|
});
|
|
39027
39173
|
const carried = seam.rebuild?.acceptance?.tests ?? [];
|
|
39028
|
-
const base = seam.refactored === true && !changesSince(seam.base).some((c) => isCode$1(c.path) && !carried.includes(c.path)) ?
|
|
39174
|
+
const base = seam.refactored === true && !changesSince(seam.base).some((c) => isCode$1(c.path) && !carried.includes(c.path)) ? sliceBase : seam.base;
|
|
39175
|
+
const guard = build.more ? () => requireCriteriaFrozen(REQUIREMENTS) : void 0;
|
|
39029
39176
|
await tail(settleQuality, {
|
|
39030
39177
|
green: true,
|
|
39031
|
-
lensBase: base
|
|
39178
|
+
lensBase: base,
|
|
39179
|
+
guard,
|
|
39180
|
+
guardRests: false
|
|
39032
39181
|
});
|
|
39033
39182
|
});
|
|
39034
39183
|
};
|
|
@@ -39074,6 +39223,7 @@ const ordinaryStart = async () => {
|
|
|
39074
39223
|
});
|
|
39075
39224
|
refusePendingHandOff();
|
|
39076
39225
|
flagModeOn();
|
|
39226
|
+
sliceCap();
|
|
39077
39227
|
const stated = statedCriteria(changes(), head$1());
|
|
39078
39228
|
await unwind();
|
|
39079
39229
|
if (changes(".gtd/FEEDBACK.md").some((c) => c.status !== "deleted")) await human("unwind-failed", {
|
|
@@ -39268,4 +39418,4 @@ var workflows_default = feature;
|
|
|
39268
39418
|
*/
|
|
39269
39419
|
const doors = {};
|
|
39270
39420
|
//#endregion
|
|
39271
|
-
export { COMMENT, ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, QUALITY, REFACTOR, REFACTOR_NOTE, REFACTOR_PACKAGE, REQUIREMENTS, REVIEW, REVIEW_ORIG, ShippedOptions, acceptanceFault, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, awaitReview, base, baseline, buildFeature, buildTail, builtInLenses, cleanup, cleanupFlow, closeReview, coder, collecting, criteriaOf, workflows_default as default, defaults, deferNotes, describeEscalation, design, doors, envDefaults, escalate, escalation, escalationExhausted, feature, fix, fixCheck, fixFlow, fixNits, fixQuality, fixQualityFindings, fixReviewNotes, fixRisks, gate, guarded, handOffReview, handOffTail, healthy, holdsCallSites, implement, isTestFile, judgeSeam, lensEntriesOf, lensOf, lensesOf, naiveAcceptance, naivePass, ordinaryStart, planAndBuild, planner, qualityLap, reUnwind, refactorCollecting, refactorFirst, refactorNote, refactorStrays, requireAcceptance, requireAcceptanceKept, requireCriteria, requireCriteriaFrozen, requireNoNewFlags, requireNoTestFiles, requireTestCallSitesOnly, requireWithin, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewedRefactor, reviewing, rollout, rolloutFlow, seamBuild, skills, skipDesign, statedCriteria, steering, summary, tail, threads, triage, unwind };
|
|
39421
|
+
export { COMMENT, ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, QUALITY, REFACTOR, REFACTOR_NOTE, REFACTOR_PACKAGE, REQUIREMENTS, REVIEW, REVIEW_ORIG, SLICES, ShippedOptions, acceptanceFault, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, awaitReview, base, baseline, buildFeature, buildTail, builtInLenses, cleanup, cleanupFlow, closeOut, closeReview, coder, collecting, criteriaOf, criterionIds, workflows_default as default, defaults, deferNotes, describeEscalation, design, doors, envDefaults, escalate, escalation, escalationExhausted, feature, fix, fixCheck, fixFlow, fixNits, fixQuality, fixQualityFindings, fixReviewNotes, fixRisks, gate, guarded, handOffReview, handOffTail, healthy, holdsCallSites, implement, isTestFile, judgeSeam, lensEntriesOf, lensOf, lensesOf, naiveAcceptance, naivePass, ordinaryStart, planAndBuild, planner, qualityLap, reUnwind, refactorCollecting, refactorFirst, refactorNote, refactorStrays, requireAcceptance, requireAcceptanceKept, requireCriteria, requireCriteriaFrozen, requireNoNewFlags, requireNoTestFiles, requireSlices, requireTestCallSitesOnly, requireWithin, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewedRefactor, reviewing, rollout, rolloutFlow, seamBuild, skills, skipDesign, sliceCap, sliceCriteria, sliceIds, slicesOf, slicing, statedCriteria, steering, summary, tail, threads, triage, unwind };
|
package/package.json
CHANGED
package/src/testkit/steps.ts
CHANGED
|
@@ -26,6 +26,8 @@ export class TestkitWorld extends QuickPickleWorld {
|
|
|
26
26
|
/** Per step, what its scripted actor does, in the order the scenario declared it. */
|
|
27
27
|
actions = new Map<string, Actor[]>()
|
|
28
28
|
verdicts = new Map<string, string>()
|
|
29
|
+
/** Per judge step, a verdict for each text its questions may contain; the first match answers. */
|
|
30
|
+
conditionalVerdicts = new Map<string, [text: string, verdict: string][]>()
|
|
29
31
|
private created: TestRepo | undefined = undefined
|
|
30
32
|
|
|
31
33
|
// The repository is created on first use and keeps the config and
|
|
@@ -79,6 +81,19 @@ export class TestkitWorld extends QuickPickleWorld {
|
|
|
79
81
|
}
|
|
80
82
|
actors[step] = () => verdict
|
|
81
83
|
}
|
|
84
|
+
for (const [step, choices] of this.conditionalVerdicts) {
|
|
85
|
+
if (this.actions.has(step) || this.verdicts.has(step)) {
|
|
86
|
+
throw new Error(`step "${step}" has a conditional verdict and another script`)
|
|
87
|
+
}
|
|
88
|
+
// Matched against the pending questions, which `gtd judge` prints and the beat does not.
|
|
89
|
+
actors[step] = async (_, repo) => {
|
|
90
|
+
const { stdout } = await repo.gtd("judge")
|
|
91
|
+
const hit = choices.find(([text]) => stdout.includes(text))
|
|
92
|
+
if (hit === undefined)
|
|
93
|
+
throw new Error(`judge step "${step}" matched no verdict:\n${stdout}`)
|
|
94
|
+
return hit[1]
|
|
95
|
+
}
|
|
96
|
+
}
|
|
82
97
|
await this.repo().drive(actors)
|
|
83
98
|
}
|
|
84
99
|
}
|
|
@@ -137,6 +152,16 @@ Given(
|
|
|
137
152
|
},
|
|
138
153
|
)
|
|
139
154
|
|
|
155
|
+
// Lets one step write differently per visit, told apart by what it is prompted with.
|
|
156
|
+
Given(
|
|
157
|
+
"step {string}, when prompted with {string}, writes {string} with:",
|
|
158
|
+
(world: TestkitWorld, step: string, text: string, path: string, content: string) => {
|
|
159
|
+
world.act(step, (beat, repo) => {
|
|
160
|
+
if (beat.content.includes(text)) repo.write(path, withNewline(content))
|
|
161
|
+
})
|
|
162
|
+
},
|
|
163
|
+
)
|
|
164
|
+
|
|
140
165
|
// A standing assertion like any queued action: it applies on every later visit
|
|
141
166
|
// to the step until `from now on` clears the list.
|
|
142
167
|
Given(
|
|
@@ -169,6 +194,16 @@ Given("judge step {string} answers:", (world: TestkitWorld, step: string, verdic
|
|
|
169
194
|
world.verdicts.set(step, String(verdict))
|
|
170
195
|
})
|
|
171
196
|
|
|
197
|
+
Given(
|
|
198
|
+
"judge step {string}, when its questions contain {string}, answers:",
|
|
199
|
+
(world: TestkitWorld, step: string, text: string, verdict: string) => {
|
|
200
|
+
world.conditionalVerdicts.set(step, [
|
|
201
|
+
...(world.conditionalVerdicts.get(step) ?? []),
|
|
202
|
+
[text, String(verdict)],
|
|
203
|
+
])
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
|
|
172
207
|
When("gtd starts workflow {string}", async (world: TestkitWorld, workflow: string) => {
|
|
173
208
|
await world.repo().start(workflow)
|
|
174
209
|
})
|
|
@@ -11,7 +11,13 @@ import {
|
|
|
11
11
|
import { healthy } from "../shared/health.js"
|
|
12
12
|
import { coder, FEEDBACK, REQUIREMENTS } from "../shared/steps.js"
|
|
13
13
|
import * as t from "../shared/text.js"
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
criteriaOf,
|
|
16
|
+
criterionIds,
|
|
17
|
+
requireCriteriaFrozen,
|
|
18
|
+
sliceIds,
|
|
19
|
+
type CriteriaSlice,
|
|
20
|
+
} from "./criteria.js"
|
|
15
21
|
import { isTestFile } from "./refactor.js"
|
|
16
22
|
import { isCode } from "./review.js"
|
|
17
23
|
|
|
@@ -51,13 +57,15 @@ const testsSince = (base: string): string =>
|
|
|
51
57
|
|
|
52
58
|
/**
|
|
53
59
|
* The criterion IDs of `.gtd/REQUIREMENTS.md` — read afresh, never carried
|
|
54
|
-
* over — no passing test since `base` covers, by the judge.
|
|
60
|
+
* over, only `slice`'s when given — no passing test since `base` covers, by the judge.
|
|
55
61
|
*/
|
|
56
62
|
const uncoveredSince = async (
|
|
57
63
|
base: string,
|
|
64
|
+
slice: CriteriaSlice | undefined,
|
|
58
65
|
): Promise<{ readonly covered: string[]; readonly uncovered: string[] }> => {
|
|
59
66
|
const requirements = read(REQUIREMENTS) ?? ""
|
|
60
|
-
const
|
|
67
|
+
const own = criterionIds(requirements, slice)
|
|
68
|
+
const criteria = (criteriaOf(requirements) ?? []).filter((c) => own.includes(c.id))
|
|
61
69
|
if (criteria.length === 0) return { covered: [], uncovered: [] }
|
|
62
70
|
const { answers } = await judge("coverage", {
|
|
63
71
|
questions: criteria.map(t.coverageQuestion),
|
|
@@ -81,14 +89,14 @@ const record = (covered: readonly string[], uncovered: readonly string[] = []):
|
|
|
81
89
|
* sending uncovered IDs back to a covering turn; marks the IDs covered on the
|
|
82
90
|
* landing that ends it. A human may overrule once the retries are spent.
|
|
83
91
|
*/
|
|
84
|
-
export const requireCoverage = async (base = start()): Promise<void> => {
|
|
92
|
+
export const requireCoverage = async (base = start(), slice?: CriteriaSlice): Promise<void> => {
|
|
85
93
|
let retries = 0
|
|
86
94
|
for (;;) {
|
|
87
|
-
const { covered, uncovered } = await uncoveredSince(base)
|
|
95
|
+
const { covered, uncovered } = await uncoveredSince(base, slice)
|
|
88
96
|
if (uncovered.length === 0) return record(covered)
|
|
89
97
|
if (retries < COVERING_RETRIES) {
|
|
90
98
|
retries++
|
|
91
|
-
await t.agentWithSkills("covering", t.coveringPrompt(uncovered), {
|
|
99
|
+
await t.agentWithSkills("covering", t.coveringPrompt(uncovered, sliceIds(slice)), {
|
|
92
100
|
label: "Covering the acceptance criteria",
|
|
93
101
|
model: coder(),
|
|
94
102
|
system: t.builderSystem(),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
2
|
import type { Change } from "../../flows/index.js"
|
|
3
|
-
import { criteriaOf, statedCriteria } from "./criteria.js"
|
|
3
|
+
import { criteriaOf, criterionIds, statedCriteria } from "./criteria.js"
|
|
4
4
|
|
|
5
5
|
const added = (path: string, after: string): Change => ({
|
|
6
6
|
path,
|
|
@@ -104,3 +104,19 @@ describe("statedCriteria", () => {
|
|
|
104
104
|
).toBeUndefined()
|
|
105
105
|
})
|
|
106
106
|
})
|
|
107
|
+
|
|
108
|
+
describe("criterionIds", () => {
|
|
109
|
+
const requirements = "## Acceptance Criteria\n\n- AC-1: a\n- AC-2: b\n- AC-4: added\n- unnamed\n"
|
|
110
|
+
|
|
111
|
+
it("lists every ID'd criterion of an unsliced change", () => {
|
|
112
|
+
expect(criterionIds(requirements)).toEqual(["AC-1", "AC-2", "AC-4"])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("keeps `only` to the IDs the file still lists", () => {
|
|
116
|
+
expect(criterionIds(requirements, { only: ["AC-1", "AC-3"] })).toEqual(["AC-1"])
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it("gives `except` every other ID, one added since included", () => {
|
|
120
|
+
expect(criterionIds(requirements, { except: ["AC-1", "AC-3"] })).toEqual(["AC-2", "AC-4"])
|
|
121
|
+
})
|
|
122
|
+
})
|