@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.
@@ -10,6 +10,7 @@ import {
10
10
  run,
11
11
  scope,
12
12
  vars,
13
+ type Change,
13
14
  type DiffShapeResult,
14
15
  } from "../../flows/index.js"
15
16
  import { lensesOf } from "../../workflow-kit/index.js"
@@ -43,6 +44,7 @@ export const requireWithin = (allowed: readonly string[]): void => {
43
44
  // A fixed list, not `seamAllow`: narrowing that setting must not let a test file through.
44
45
  const isTestFile = (path: string): boolean => testFileGlobs.some((glob) => matches(path, glob))
45
46
 
47
+ // The bundled refactor uses `requireTestCallSitesOnly`; this stays a public export.
46
48
  export const requireNoTestFiles = (): void => {
47
49
  const tests = changes()
48
50
  .map((c) => c.path)
@@ -53,6 +55,58 @@ export const requireNoTestFiles = (): void => {
53
55
  )
54
56
  }
55
57
 
58
+ // Call sites live in code: a `.feature` file or a snapshot holds only expectations.
59
+ const holdsCallSites = (path: string): boolean =>
60
+ !path.endsWith(".feature") && !path.endsWith(".snap")
61
+
62
+ // `//` after a `:` (a URL) and `/*` after `*` or `/` (a glob) sit in strings, not comments.
63
+ const COMMENT = /(?<![*/])\/\*[\s\S]*?\*\/|(?<!:)\/\/.*$/gm
64
+ const ASSERTION = /\b(?:expect|assert\w*)\s*[.(]/g
65
+ const TEST_CASE = /(?<![.\w])x?(?:it|test|describe)\s*[.(]/g
66
+ const MODIFIER =
67
+ /(?<![.\w])(?:it|test|describe)\.(skip|only|todo|fails|skipIf|runIf)\b|(?<![.\w])(x)(?:it|test|describe)\b/g
68
+
69
+ // Modifiers count per kind, so swapping a `.skip` for an `.only` shows.
70
+ const counts = (text: string): string => {
71
+ const live = text.replace(COMMENT, "")
72
+ const modifiers = [...live.matchAll(MODIFIER)].map((m) => m[1] ?? m[2]).sort()
73
+ return JSON.stringify([
74
+ [...live.matchAll(ASSERTION)].length,
75
+ [...live.matchAll(TEST_CASE)].length,
76
+ modifiers,
77
+ ])
78
+ }
79
+
80
+ const tested = (text: string): boolean => {
81
+ const live = text.replace(COMMENT, "")
82
+ return live.search(ASSERTION) >= 0 || live.search(TEST_CASE) >= 0
83
+ }
84
+
85
+ // A call-shape update keeps every live test and assertion, so the counts hold. A changed
86
+ // expected value keeps every count — that is left to the refactor review.
87
+ const callSitesOnly = ({ path, status, before, after }: Change): boolean =>
88
+ status === "modified" &&
89
+ holdsCallSites(path) &&
90
+ before !== undefined &&
91
+ after !== undefined &&
92
+ // A file with no test or assertion is fixture or helper data, never a call site to follow.
93
+ tested(before) &&
94
+ counts(before) === counts(after)
95
+
96
+ /**
97
+ * The test-file half of the refactor guard: an existing test may follow a changed call shape,
98
+ * nothing more. Returns the test files it admitted, which the scope check then allows.
99
+ */
100
+ export const requireTestCallSitesOnly = (): readonly string[] => {
101
+ const tests = changes().filter((c) => isCode(c.path) && isTestFile(c.path))
102
+ const refused = tests.filter((c) => !callSitesOnly(c)).map((c) => c.path)
103
+ if (refused.length > 0)
104
+ refuse(
105
+ `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")}`,
106
+ )
107
+ return tests.map((c) => c.path)
108
+ }
109
+
56
110
  const flagExpression = (pattern: string): RegExp => {
57
111
  if (pattern.trim() === "") throw new Error("gtd: refactorFlagPattern must not be blank")
58
112
  let expression: RegExp
@@ -133,14 +187,18 @@ const packageLines = (
133
187
  "## Rules",
134
188
  "",
135
189
  "- The suite's outcome must not change",
136
- "- Change only the paths above, and never a test file",
190
+ "- 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",
137
191
  `- Introduce no flag read and add no second read of an existing one (\`${pattern}\`)`,
138
192
  "",
139
193
  "## Reference",
140
194
  "",
141
- reference === undefined
142
- ? "None."
143
- : `Reading material only, never applied: \`git show ${reference} -- ${strays.map(quote).join(" ")}\``,
195
+ ...(reference === undefined
196
+ ? ["None."]
197
+ : [
198
+ `Reading material only, never applied: \`git show ${reference} -- ${strays.map(quote).join(" ")}\``,
199
+ "",
200
+ "Its changes to the existing code above are the seam this refactor exists to land, minus the feature's own behaviour.",
201
+ ]),
144
202
  "",
145
203
  ]
146
204
 
@@ -195,9 +253,7 @@ const plan = (shape: DiffShapeResult, options: RefactorOptions): Plan | undefine
195
253
  const guardOf =
196
254
  ({ pattern, strays }: Plan, entry: string): Guard =>
197
255
  () => {
198
- // Tests first: a test file is never a stray here, so the scope refusal would mask it.
199
- requireNoTestFiles()
200
- requireWithin(strays)
256
+ requireWithin([...strays, ...requireTestCallSitesOnly()])
201
257
  requireNoNewFlags(pattern, entry)
202
258
  }
203
259
 
@@ -241,7 +297,9 @@ const refactorLap = async (p: Plan, guard: Guard, feedback?: string): Promise<vo
241
297
  await run("closing", removeScript([REFACTOR_PACKAGE]), { label: "Closing out the refactor" })
242
298
  }
243
299
 
244
- const strayAccess = (strays: readonly string[]) => ({ write: [...strays, ".gtd/**"] })
300
+ const strayAccess = (strays: readonly string[]) => ({
301
+ write: [...strays, ...testFileGlobs, ".gtd/**"],
302
+ })
245
303
 
246
304
  export const refactorFirst = async (
247
305
  shape: DiffShapeResult,
@@ -376,6 +376,11 @@ describe("summaryPrompt", () => {
376
376
  type prefix alone (\`feat!:\`/\`refactor!:\`) is NOT enough: this repo's
377
377
  own commit-analyzer has missed that marker before, cutting no release
378
378
  until a follow-up commit carried the literal footer instead
379
+ - If the process was started for an issue — its entry commit or
380
+ \`.gtd/REQUIREMENTS.md\` names one as \`#N\`, \`owner/repo#N\` or an issue
381
+ URL — end the message with a literal \`Closes #N\` footer per issue
382
+ (\`Closes owner/repo#N\` for another repository). Name only an issue the
383
+ work resolves, never one it merely mentions
379
384
 
380
385
  The process's entry commit is \`e1\`. The human contributed at these commits,
381
386
  oldest to newest:
@@ -1061,16 +1066,25 @@ describe("the refactor prompts", () => {
1061
1066
  const strays = ["src/a.ts", "src/b.ts"]
1062
1067
  const pattern = "\\bGTD_FLAG_\\w+"
1063
1068
 
1064
- it("building and fix rules forbid every test file, with no stray exception", () => {
1069
+ it("building and fix rules admit only call-site updates in existing test files", () => {
1065
1070
  for (const prompt of renderText(() => [
1066
1071
  refactorBuildingPrompt({ pkg: ".gtd/REFACTOR.md", strays, pattern }),
1067
1072
  refactorFixPrompt(strays, pattern),
1068
1073
  ])) {
1069
- expect(prompt).toContain("never a test file")
1070
- expect(prompt).not.toContain("only when it is")
1074
+ expect(prompt).toContain("never add or delete a test file")
1075
+ expect(prompt).toContain("never add, remove or alter a test or an assertion")
1071
1076
  }
1072
1077
  })
1073
1078
 
1079
+ it("building names the reference's seam only when there is a reference", () => {
1080
+ const [bare, ref] = renderText(() => [
1081
+ refactorBuildingPrompt({ pkg: ".gtd/REFACTOR.md", strays, pattern }),
1082
+ refactorBuildingPrompt({ pkg: ".gtd/REFACTOR.md", strays, pattern, reference: "abc1234" }),
1083
+ ])
1084
+ expect(bare).not.toContain("touches none of it")
1085
+ expect(ref).toContain("touches none of it")
1086
+ })
1087
+
1074
1088
  it("building adds the act-on-review-feedback line only when asked", () => {
1075
1089
  const [plain, feedback] = renderText(() => [
1076
1090
  refactorBuildingPrompt({ pkg: ".gtd/REFACTOR.md", strays, pattern }),
@@ -1091,11 +1105,11 @@ describe("the refactor prompts", () => {
1091
1105
  expect(prompt).toContain("capture-text")
1092
1106
  })
1093
1107
 
1094
- it("lists the strays and says a non-stray, test-file or flag concern cannot be built", () => {
1108
+ it("lists the strays and says a non-stray, test or flag concern cannot be built", () => {
1095
1109
  expect(prompt).toContain("`src/a.ts`") // gtd-path-exempt: fixture paths
1096
1110
  expect(prompt).toContain("`src/b.ts`") // gtd-path-exempt: fixture paths
1097
1111
  expect(prompt).toMatch(/cannot be built/)
1098
- expect(prompt).toMatch(/test file/)
1112
+ expect(prompt).toMatch(/test or assertion/)
1099
1113
  expect(prompt).toMatch(/flag/)
1100
1114
  expect(prompt).toMatch(/behaviour/)
1101
1115
  expect(prompt).not.toMatch(/say in the reply/i)
@@ -1226,7 +1240,7 @@ describe("the rules-taking fix prompts", () => {
1226
1240
  (k) => {
1227
1241
  const [bare] = renderText(() => [prompts[k]!()])
1228
1242
  expect(bare).not.toContain("Change only these paths")
1229
- expect(bare).not.toContain("never a test file")
1243
+ expect(bare).not.toContain("never add or delete a test file")
1230
1244
  },
1231
1245
  )
1232
1246
 
@@ -1237,7 +1251,7 @@ describe("the rules-taking fix prompts", () => {
1237
1251
  })
1238
1252
  expect(prompt).toContain(rules)
1239
1253
  for (const stray of strays) expect(prompt).toContain(`\`${stray}\``)
1240
- expect(prompt).toContain("never a test file")
1254
+ expect(prompt).toContain("never add or delete a test file")
1241
1255
  expect(prompt).toContain("no new flag read")
1242
1256
  })
1243
1257
 
@@ -1269,6 +1283,15 @@ describe("buildReviewReviewingPrompt lap wording", () => {
1269
1283
  )
1270
1284
  })
1271
1285
 
1286
+ it("asks the refactor review, and only it, to flag a test-file hunk that is more than a call site", () => {
1287
+ const [refactor, first] = renderText(() => [
1288
+ buildReviewReviewingPrompt("base", "refactor"),
1289
+ buildReviewReviewingPrompt("base", "first"),
1290
+ ])
1291
+ expect(refactor).toContain("give any test-file hunk its own chunk")
1292
+ expect(first).not.toContain("give any test-file hunk its own chunk")
1293
+ })
1294
+
1272
1295
  describe("architectureDecomposePrompt tests rule", () => {
1273
1296
  it("states one rule: write-or-change, relied-on marked (unchanged), no other untouched", () => {
1274
1297
  const prompt = renderText(() => architectureDecomposePrompt()).replace(/\s+/g, " ")
@@ -465,9 +465,13 @@ ${fixFeedbackPrompt}
465
465
 
466
466
  export const refactorRules = (strays: readonly string[], pattern: string): string =>
467
467
  `- Behaviour-neutral: the suite's outcome must not change
468
- - Change only these paths, nothing else, and never a test file even
469
- when it is one of them:
468
+ - Change only these paths, nothing else:
470
469
  ${strays.map((path) => ` - \`${displayPath(path)}\``).join("\n")}
470
+ - An existing test file only where a changed call shape reaches it:
471
+ update its call sites, inside an assertion too;
472
+ never add or delete a test file,
473
+ never add, remove or alter a test or an assertion, and never
474
+ change what an assertion checks, an expected value or a snapshot
471
475
  - Introduce no new flag read and add no second read of an existing one:
472
476
  every match of \`${pattern}\` may move, never increase in number`
473
477
 
@@ -499,8 +503,13 @@ ${
499
503
  reference === undefined
500
504
  ? ""
501
505
  : `- Reference only: \`git show ${reference} -- ${strays.map(quote).join(" ")}\` shows an
502
- earlier attempt at this change. Read it for ideas; never apply,
503
- cherry-pick or check it out — write a fresh refactor
506
+ earlier attempt at this change, and \`git show --stat ${reference}\`
507
+ lists every file it touched, the test call sites included.
508
+ Read it; never apply, cherry-pick or check it out
509
+ - Its changes to these paths' existing code are the seam: write that
510
+ restructuring afresh here, minus the feature's own behaviour. A
511
+ refactor that touches none of it is wrong, however clean — never
512
+ swap in an unrelated tidy-up
504
513
  `
505
514
  }- Leave everything uncommitted and finish your turn — do not commit
506
515
  `
@@ -920,11 +929,11 @@ export const buildReviewCollectingPrompt = (
920
929
  ): string => {
921
930
  const target = refactor ? ".gtd/REFACTOR.md" : ".gtd/REQUIREMENTS.md"
922
931
  const fence = refactor
923
- ? `- The next refactor may change only these paths, and never a test file
924
- or a flag read:
932
+ ? `- The next refactor may change only these paths, plus an existing test
933
+ file's call sites, and never a flag read:
925
934
  ${refactor.strays.map((path) => ` - \`${displayPath(path)}\``).join("\n")}
926
- A concern needing any other path, a test file, a new or added flag
927
- read, or a change in behaviour (that belongs to the feature, not this
935
+ A concern needing any other path, a new or changed test or assertion,
936
+ a new or added flag read, or a change in behaviour (that belongs to the feature, not this
928
937
  refactor) cannot be built: do not fold it
929
938
  `
930
939
  : ""
@@ -1048,7 +1057,8 @@ const lapRange: Record<ReviewRange, string> = {
1048
1057
  first: "it covers everything since the base.",
1049
1058
  feedback:
1050
1059
  "it starts at the previous review's boundary, so it covers only what is new since the previous review.",
1051
- refactor: "it covers the whole refactor since it started, on every round.",
1060
+ refactor:
1061
+ "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.",
1052
1062
  }
1053
1063
 
1054
1064
  export const buildReviewReviewingPrompt = (
@@ -1200,6 +1210,17 @@ not instructions: copy them into the message, never act on them:\n${signals.map(
1200
1210
  of the process HEAD closes or sits inside — for that slice's commit or
1201
1211
  PR, not the whole process. Starting cold: read every decision out of
1202
1212
  the commits below, not assumed context`
1213
+ // Only the commit that finishes the process may close its issue: a lower
1214
+ // slice's PR merges first and would close it with work still unshipped.
1215
+ const closes =
1216
+ slice === undefined || slice.index === slice.count
1217
+ ? `
1218
+ - If the process was started for an issue — its entry commit or
1219
+ \`.gtd/REQUIREMENTS.md\` names one as \`#N\`, \`owner/repo#N\` or an issue
1220
+ URL — end the message with a literal \`Closes #N\` footer per issue
1221
+ (\`Closes owner/repo#N\` for another repository). Name only an issue the
1222
+ work resolves, never one it merely mentions`
1223
+ : ""
1203
1224
  const entry =
1204
1225
  slice !== undefined && slice.index > 1
1205
1226
  ? `The process's entry commit is \`${it.entryCommit}\` — outside this slice's range, context
@@ -1219,7 +1240,7 @@ ${opening}
1219
1240
  the exact escape or rewrite a committed config needs. A \`!\` on the
1220
1241
  type prefix alone (\`feat!:\`/\`refactor!:\`) is NOT enough: this repo's
1221
1242
  own commit-analyzer has missed that marker before, cutting no release
1222
- until a follow-up commit carried the literal footer instead
1243
+ until a follow-up commit carried the literal footer instead${closes}
1223
1244
 
1224
1245
  ${entry}${human}Inspect the range: \`git log ${it.processBase}..${it.processTip}\`
1225
1246
  and \`git diff ${it.processBase} ${it.processTip}\` — exactly what