@pmelab/gtd 24.3.0 → 25.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.
Files changed (74) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +28 -22
  3. package/dist/chunks/{reserved-DJwU5C4z.mjs → reserved-6pAgFGgB.mjs} +119 -135
  4. package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
  5. package/dist/flows.mjs +9 -1
  6. package/dist/gtd.bundle.mjs +780 -813
  7. package/dist/steering.mjs +2 -2
  8. package/dist/testkit.mjs +666 -161
  9. package/dist/workflow-kit.mjs +55 -96
  10. package/dist/workflows/feature.mjs +171 -76
  11. package/dist/workflows/fix.mjs +2 -2
  12. package/dist/workflows/review.mjs +1 -1
  13. package/dist/workflows.mjs +353 -310
  14. package/package.json +1 -2
  15. package/src/cli/Program.test.ts +3 -8
  16. package/src/cli/Program.ts +1 -8
  17. package/src/edge/Edge.test.ts +5 -2
  18. package/src/edge/Edge.ts +3 -4
  19. package/src/emit/index.ts +1 -0
  20. package/src/flows/scripts.ts +6 -0
  21. package/src/platform/Git.test.ts +6 -9
  22. package/src/replay/Replay.ts +2 -1
  23. package/src/steering/FORMATS.md +1 -1
  24. package/src/steering/SteeringFormat.test.ts +9 -1
  25. package/src/steering/SteeringFormat.ts +7 -0
  26. package/src/steering/contract.test.ts +3 -0
  27. package/src/steering/index.ts +1 -0
  28. package/src/steering/review.test.ts +30 -0
  29. package/src/steering/review.ts +8 -2
  30. package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
  31. package/src/testkit/EmittedScriptRecognizer.ts +148 -51
  32. package/src/testkit/EmittedScripts.test.ts +166 -0
  33. package/src/testkit/FlowScripts.test.ts +242 -0
  34. package/src/testkit/FlowScripts.ts +326 -0
  35. package/src/testkit/GitTiers.ts +140 -49
  36. package/src/testkit/InMemRepo.ts +35 -2
  37. package/src/testkit/Layers.ts +10 -2
  38. package/src/testkit/TestRepo.ts +4 -1
  39. package/src/testkit/index.ts +1 -1
  40. package/src/workflow/discovery.test.ts +147 -0
  41. package/src/workflow/discovery.ts +5 -0
  42. package/src/workflow/fingerprint.test.ts +20 -4
  43. package/src/workflow/fingerprint.ts +8 -9
  44. package/src/workflow/index.ts +1 -6
  45. package/src/workflow/load.test.ts +5 -19
  46. package/src/workflow/load.ts +94 -10
  47. package/src/workflow-kit/review.test.ts +84 -0
  48. package/src/workflow-kit/review.ts +9 -6
  49. package/src/workflow-kit/scenarios.test.ts +116 -0
  50. package/src/workflow-kit/scenarios.ts +53 -113
  51. package/src/workflows/feature/feature.ts +2 -5
  52. package/src/workflows/feature/packages.test.ts +17 -34
  53. package/src/workflows/feature/packages.ts +13 -52
  54. package/src/workflows/feature/planning.ts +1 -1
  55. package/src/workflows/feature/review.test.ts +233 -54
  56. package/src/workflows/feature/review.ts +178 -41
  57. package/src/workflows/feature/steps.test.ts +12 -0
  58. package/src/workflows/feature/steps.ts +8 -1
  59. package/src/workflows/index.ts +0 -2
  60. package/src/workflows/shared/access.test.ts +4 -2
  61. package/src/workflows/shared/access.ts +1 -0
  62. package/src/workflows/shared/health.test.ts +1 -21
  63. package/src/workflows/shared/health.ts +4 -39
  64. package/src/workflows/shared/options.test.ts +8 -0
  65. package/src/workflows/shared/options.ts +1 -1
  66. package/src/workflows/shared/steps.ts +18 -11
  67. package/src/workflows/shared/tail.test.ts +1 -41
  68. package/src/workflows/shared/tail.ts +17 -41
  69. package/src/workflows/shared/text.fixture.ts +1 -0
  70. package/src/workflows/shared/text.test.ts +129 -41
  71. package/src/workflows/shared/text.ts +111 -75
  72. package/src/workflows/shared/vars.ts +1 -1
  73. package/src/workflows/shared/scenarios.test.ts +0 -256
  74. package/src/workflows/shared/scenarios.ts +0 -35
@@ -1,9 +1,24 @@
1
- import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"
1
+ import {
2
+ mkdirSync,
3
+ mkdtempSync,
4
+ readFileSync,
5
+ realpathSync,
6
+ symlinkSync,
7
+ writeFileSync,
8
+ } from "node:fs"
2
9
  import { tmpdir } from "node:os"
3
10
  import { join } from "node:path"
4
11
  import { describe, expect, it } from "vitest"
5
12
  import { changedConfigFiles, configFingerprint } from "./fingerprint.js"
6
13
 
14
+ const read = (path: string): string | undefined => {
15
+ try {
16
+ return readFileSync(path, "utf8")
17
+ } catch {
18
+ return undefined
19
+ }
20
+ }
21
+
7
22
  describe("the config fingerprint", () => {
8
23
  it("hashes each file under its root-relative path, skipping one that cannot be read", () => {
9
24
  const root = mkdtempSync(join(tmpdir(), "gtd-fingerprint-"))
@@ -14,6 +29,7 @@ describe("the config fingerprint", () => {
14
29
  const fingerprint = configFingerprint(
15
30
  [join(root, "gtd.config.ts"), join(root, "lib", "flow.ts"), join(root, "gone.ts")],
16
31
  root,
32
+ read,
17
33
  )
18
34
 
19
35
  expect(Object.keys(fingerprint).sort()).toEqual(["gtd.config.ts", "lib/flow.ts"])
@@ -22,7 +38,7 @@ describe("the config fingerprint", () => {
22
38
  expect(
23
39
  changedConfigFiles(
24
40
  fingerprint,
25
- configFingerprint([join(root, "gtd.config.ts"), join(root, "lib", "flow.ts")], root),
41
+ configFingerprint([join(root, "gtd.config.ts"), join(root, "lib", "flow.ts")], root, read),
26
42
  ),
27
43
  ).toEqual(["lib/flow.ts"])
28
44
  })
@@ -34,7 +50,7 @@ describe("the config fingerprint", () => {
34
50
  symlinkSync(real, link)
35
51
 
36
52
  expect(
37
- Object.keys(configFingerprint([join(realpathSync(real), "gtd.config.ts")], link)),
53
+ Object.keys(configFingerprint([join(realpathSync(real), "gtd.config.ts")], link, read)),
38
54
  ).toEqual(["gtd.config.ts"])
39
55
  })
40
56
 
@@ -42,7 +58,7 @@ describe("the config fingerprint", () => {
42
58
  const root = mkdtempSync(join(tmpdir(), "gtd-fingerprint-"))
43
59
  writeFileSync(join(root, "a\x1b.ts"), "export const a = 1\n")
44
60
 
45
- expect(configFingerprint([join(root, "a\x1b.ts")], root)).toEqual({})
61
+ expect(configFingerprint([join(root, "a\x1b.ts")], root, read)).toEqual({})
46
62
  })
47
63
 
48
64
  it("names a file only one side has as changed", () => {
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto"
2
- import { readFileSync, realpathSync } from "node:fs"
2
+ import { realpathSync } from "node:fs"
3
3
  import { relative, sep } from "node:path"
4
4
 
5
5
  /** Path (relative to `root`) → content hash of every file a config load touched. */
@@ -27,19 +27,18 @@ const realOr = (path: string): string => {
27
27
  // feeds a warning, and the load that listed the file already read it.
28
28
  // Paths are real paths: a loader may report `/private/var/…` for a root
29
29
  // reached through the `/var` symlink, and the two must still relate.
30
- export const configFingerprint = (files: readonly string[], root: string): ConfigFingerprint => {
30
+ export const configFingerprint = (
31
+ files: readonly string[],
32
+ root: string,
33
+ read: (path: string) => string | undefined,
34
+ ): ConfigFingerprint => {
31
35
  const fingerprint: Record<string, string> = {}
32
36
  const realRoot = realOr(root)
33
37
  for (const file of files) {
34
38
  const path = relative(realRoot, realOr(file)).split(sep).join("/")
35
39
  if (CONTROL_CHARACTER.test(path)) continue
36
- let content: Buffer
37
- try {
38
- content = readFileSync(file)
39
- } catch {
40
- continue
41
- }
42
- fingerprint[path] = contentHash(content)
40
+ const content = read(file)
41
+ if (content !== undefined) fingerprint[path] = contentHash(content)
43
42
  }
44
43
  return fingerprint
45
44
  }
@@ -25,9 +25,4 @@ export {
25
25
  type StateMode,
26
26
  type StateName,
27
27
  } from "./Workflow.js"
28
- export {
29
- changedConfigFiles,
30
- configFingerprint,
31
- contentHash,
32
- type ConfigFingerprint,
33
- } from "./fingerprint.js"
28
+ export { changedConfigFiles, contentHash, type ConfigFingerprint } from "./fingerprint.js"
@@ -1,13 +1,5 @@
1
1
  import { execSync } from "node:child_process"
2
- import {
3
- existsSync,
4
- readFileSync,
5
- mkdtempSync,
6
- realpathSync,
7
- rmSync,
8
- writeFileSync,
9
- mkdirSync,
10
- } from "node:fs"
2
+ import { existsSync, readFileSync, mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"
11
3
  import { join } from "node:path"
12
4
  import { tmpdir } from "node:os"
13
5
  import { afterEach, beforeEach, describe, expect, it } from "vitest"
@@ -310,7 +302,7 @@ describe("ConfigService", () => {
310
302
  expect(cfg.workflow.steering).toEqual({ ".gtd/PLAN.md": "qa" })
311
303
  })
312
304
 
313
- it("workflowFiles names gtd.config.ts AND a sibling module it imports from — a caller watching only gtd.config.ts's own path misses that module's edits", async () => {
305
+ it("the fingerprint covers gtd.config.ts AND a sibling module it imports from", async () => {
314
306
  writeFileSync(
315
307
  join(projectDir, "steps.ts"),
316
308
  [`export const steering = { ".gtd/PLAN.md": "qa" }`, ``].join("\n"),
@@ -323,19 +315,13 @@ describe("ConfigService", () => {
323
315
  const cfg = await getConfig()
324
316
 
325
317
  expect(cfg.workflow.steering[".gtd/PLAN.md"]).toBe("qa")
326
- expect(cfg.workflowFiles.map((f) => realpathSync(f))).toEqual(
327
- expect.arrayContaining([
328
- realpathSync(join(projectDir, "gtd.config.ts")),
329
- realpathSync(join(projectDir, "steps.ts")),
330
- ]),
331
- )
318
+ expect(Object.keys(cfg.fingerprint).sort()).toEqual(["gtd.config.ts", "steps.ts"])
332
319
  })
333
320
 
334
- it("workflowFiles names gtd.config.ts but no shipped module it imports", async () => {
321
+ it("the fingerprint covers gtd.config.ts but no shipped module it imports", async () => {
335
322
  const cfg = await getConfig()
336
323
 
337
- expect(cfg.workflowFiles).toHaveLength(1)
338
- expect(cfg.workflowFiles[0]).toMatch(/\/gtd\.config\.ts$/)
324
+ expect(Object.keys(cfg.fingerprint)).toEqual(["gtd.config.ts"])
339
325
  })
340
326
 
341
327
  it.each([
@@ -12,6 +12,7 @@ import { Host, Workspace, type WorkspaceOps } from "../platform/index.js"
12
12
  import { compileConfig, type CompiledConfig } from "./compile.js"
13
13
  import { SETTING_NAME_RULE, isSettingName } from "./vars.js"
14
14
  import { ConfigDiscovery, WORKFLOW_MODULE, type WorkflowModule } from "./discovery.js"
15
+ import { configFingerprint, type ConfigFingerprint } from "./fingerprint.js"
15
16
  import {
16
17
  dedupeDiagnostics,
17
18
  formatDiagnostic,
@@ -41,8 +42,8 @@ export interface ConfigOperations {
41
42
  readonly ui?: flows.UiSettings
42
43
  /** Non-fatal findings, formatted through `formatDiagnostic` like an error. */
43
44
  readonly warnings: readonly Diagnostic[]
44
- /** Every real file `gtd.config.ts`'s evaluation touched (itself plus any relative import). A caller that must know when `workflow` might have changed (the LSP's steering-map memo) watches all of them; watching `gtd.config.ts`'s own path alone misses an edit to a module it imports. */
45
- readonly workflowFiles: readonly string[]
45
+ /** The content hash of every file `gtd.config.ts`'s evaluation touched — itself plus any relative import — by path relative to the root. */
46
+ readonly fingerprint: ConfigFingerprint
46
47
  }
47
48
 
48
49
  // Everything lives in `message` (not `GtdError.detail`) — `renderFailure`
@@ -175,10 +176,22 @@ export const load: Effect.Effect<
175
176
  },
176
177
  ...(compiled.ui !== undefined ? { ui: compiled.ui } : {}),
177
178
  warnings: diagnostics.filter((d) => d.severity === "warning"),
178
- workflowFiles: loaded.file.dependencyFiles,
179
+ fingerprint: configFingerprint(
180
+ loaded.file.dependencyFiles,
181
+ host.root,
182
+ module.read ?? readFromDisk,
183
+ ),
179
184
  }
180
185
  })
181
186
 
187
+ const readFromDisk = (path: string): string | undefined => {
188
+ try {
189
+ return readFileSync(path, "utf8")
190
+ } catch {
191
+ return undefined
192
+ }
193
+ }
194
+
182
195
  interface Settings {
183
196
  readonly defaults: Readonly<Record<string, string>>
184
197
  readonly envDefaults: Readonly<Record<string, string>>
@@ -253,7 +266,14 @@ const memoized = (transform: Transform): Transform => {
253
266
 
254
267
  let jitiModule: JitiModule | undefined
255
268
  let transform: Transform | undefined
256
- const jiti = (shipped: ShippedModules): JitiInstance => {
269
+ /** Relative specifiers a module imports, served from somewhere other than the disk. */
270
+ interface LocalModules {
271
+ /** The path a specifier names, or nothing when it names no module. */
272
+ readonly resolve: (specifier: string) => string | undefined
273
+ readonly load: (path: string) => unknown
274
+ }
275
+
276
+ const jiti = (shipped: ShippedModules, local?: LocalModules): JitiInstance => {
257
277
  const require = createRequire(import.meta.url)
258
278
  jitiModule ??= require("jiti") as JitiModule
259
279
  // jiti's own Babel transform, which its package exports don't name.
@@ -261,7 +281,16 @@ const jiti = (shipped: ShippedModules): JitiInstance => {
261
281
  require(join(dirname(require.resolve("jiti/package.json")), "dist", "babel.cjs")) as Transform,
262
282
  )
263
283
  return jitiModule.createJiti(import.meta.url, {
264
- virtualModules: shipped.modules,
284
+ // Consulted when a module is required, so a local module is evaluated
285
+ // only once an import actually runs.
286
+ virtualModules: new Proxy(shipped.modules, {
287
+ has: (modules, id) =>
288
+ id in modules || (typeof id === "string" && local?.resolve(id) !== undefined),
289
+ get: (modules, id) => {
290
+ const path = typeof id === "string" ? local?.resolve(id) : undefined
291
+ return path === undefined ? modules[id as string] : local!.load(path)
292
+ },
293
+ }),
265
294
  alias: shipped.missing,
266
295
  // No transpile cache on disk, and a fresh module every load — nothing a
267
296
  // later command could read back instead of the source.
@@ -572,11 +601,12 @@ const namespaceOf = (exported: unknown): Record<string, unknown> =>
572
601
  * pulled in, transitively.
573
602
  */
574
603
  const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded => {
575
- const cache: Record<string, unknown> = {}
576
- const exports = namespaceOf(
577
- jiti(shipped).evalModule(module.source, { filename: module.filepath, cache }),
578
- )
579
- const file = fileFrom(exports, module.filepath, Object.keys(cache))
604
+ const { exports: evaluated, files } =
605
+ module.read === undefined
606
+ ? evaluateFromDisk(module, shipped)
607
+ : evaluateRead(module, module.read, shipped)
608
+ const exports = namespaceOf(evaluated)
609
+ const file = fileFrom(exports, module.filepath, files)
580
610
  const exported = Object.entries(exports).filter(
581
611
  ([name, value]) => !RESERVED_EXPORTS.has(name) && isWorkflow(value),
582
612
  )
@@ -611,6 +641,60 @@ const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded
611
641
  }
612
642
  }
613
643
 
644
+ const evaluateFromDisk = (
645
+ module: WorkflowModule,
646
+ shipped: ShippedModules,
647
+ ): { readonly exports: unknown; readonly files: readonly string[] } => {
648
+ const cache: Record<string, unknown> = {}
649
+ const exports = jiti(shipped).evalModule(module.source, { filename: module.filepath, cache })
650
+ return { exports, files: Object.keys(cache) }
651
+ }
652
+
653
+ // How jiti finds a TypeScript module behind a specifier: as written, its
654
+ // `.js` spelling as `.ts`, with `.ts` appended, or as a directory index.
655
+ const moduleCandidates = (path: string): readonly string[] => [
656
+ path,
657
+ path.replace(/\.js$/, ".ts"),
658
+ path.replace(/\.mjs$/, ".mts"),
659
+ `${path}.ts`,
660
+ join(path, "index.ts"),
661
+ ]
662
+
663
+ /**
664
+ * Evaluates the config with every local module it imports taken from `read`
665
+ * instead of the disk. jiti matches a virtual module on the specifier as
666
+ * written, so each module gets its own instance resolving against its own
667
+ * directory. A specifier `read` cannot serve falls through to the disk, and
668
+ * fails the way a missing file does.
669
+ */
670
+ const evaluateRead = (
671
+ module: WorkflowModule,
672
+ read: (path: string) => string | undefined,
673
+ shipped: ShippedModules,
674
+ ): { readonly exports: unknown; readonly files: readonly string[] } => {
675
+ const files = [module.filepath]
676
+ const evaluated = new Map<string, unknown>()
677
+ const evaluate = (filepath: string, source: string): unknown =>
678
+ jiti(shipped, {
679
+ resolve: (specifier) =>
680
+ /^\.\.?\//.test(specifier)
681
+ ? moduleCandidates(join(dirname(filepath), specifier)).find(
682
+ (candidate) => read(candidate) !== undefined,
683
+ )
684
+ : undefined,
685
+ load: (path) => {
686
+ if (!evaluated.has(path)) {
687
+ // A placeholder first, so an import cycle ends here.
688
+ evaluated.set(path, {})
689
+ files.push(path)
690
+ evaluated.set(path, evaluate(path, read(path)!))
691
+ }
692
+ return evaluated.get(path)
693
+ },
694
+ }).evalModule(source, { filename: filepath })
695
+ return { exports: evaluate(module.filepath, module.source), files }
696
+ }
697
+
614
698
  const isWorkflow = (value: unknown): boolean =>
615
699
  typeof value === "function" || flows.isFactoryWorkflow(value)
616
700
 
@@ -0,0 +1,84 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest"
2
+
3
+ const state = vi.hoisted(() => ({ feedback: undefined as string | undefined, head: "h1" }))
4
+
5
+ vi.mock("../flows/index.js", () => ({
6
+ changes: () => [],
7
+ changesSince: () => [],
8
+ hasThreadFor: () => false,
9
+ head: () => state.head,
10
+ read: () => state.feedback,
11
+ refuse: (m: string) => {
12
+ throw new Error(m)
13
+ },
14
+ requireReplies: () => {},
15
+ requireThreadsClosed: () => {},
16
+ }))
17
+
18
+ import { routeNotes, type ReviewPolicy, type Verdict } from "./review.js"
19
+ import type { ReviewNote } from "../steering/index.js"
20
+
21
+ const note = (id: string) => ({ id }) as unknown as ReviewNote
22
+
23
+ const setup = (verdicts: Record<string, Verdict>) => {
24
+ const calls: string[] = []
25
+ const deferred: string[][] = []
26
+ const collected: ReviewNote[][] = []
27
+ const p = {
28
+ file: "r.md",
29
+ feedback: "f.md",
30
+ triage: async () => new Map(Object.entries(verdicts)),
31
+ answerQuestions: async () => void calls.push("answer"),
32
+ fixNits: async () => void calls.push("nits"),
33
+ keepGreen: async () => void calls.push("green"),
34
+ collecting: async () => {
35
+ calls.push("collect")
36
+ state.feedback = "x"
37
+ },
38
+ captures: {
39
+ editNotes: (_r: string, edits: readonly ReviewNote[]) => {
40
+ collected.push([...edits])
41
+ return "cap"
42
+ },
43
+ },
44
+ defer: async (ns: readonly ReviewNote[]) => {
45
+ calls.push("defer")
46
+ deferred.push(ns.map((n) => n.id))
47
+ },
48
+ } as unknown as ReviewPolicy
49
+ const r = {
50
+ round: "r1",
51
+ close: async () => void calls.push("close"),
52
+ outcome: (verdict: "signoff" | "feedback") => ({ verdict }) as never,
53
+ unfolded: async () => ({ verdict: "signoff" }) as never,
54
+ }
55
+ return { p, r, calls, deferred, collected }
56
+ }
57
+
58
+ beforeEach(() => {
59
+ state.feedback = undefined
60
+ })
61
+
62
+ describe("routeNotes defer", () => {
63
+ it("defer-only round awaits again, no collecting turn", async () => {
64
+ const t = setup({ a: "defer", b: "defer" })
65
+ const res = await routeNotes(t.p, [note("a"), note("b")], t.r)
66
+ expect(res).toEqual({ next: "await", reviewed: "r1" })
67
+ expect(t.deferred).toEqual([["a", "b"]])
68
+ expect(t.calls).toEqual(["defer"])
69
+ })
70
+
71
+ it("defer + edit collects only the edits and ends feedback", async () => {
72
+ const t = setup({ a: "defer", b: "edit" })
73
+ const res = await routeNotes(t.p, [note("a"), note("b")], t.r)
74
+ expect(res).toEqual({ verdict: "feedback" })
75
+ expect(t.calls).toEqual(["defer", "close", "collect"])
76
+ expect(t.collected[0]!.map((n) => n.id)).toEqual(["b"])
77
+ })
78
+
79
+ it("runs after nit fixes and before the collecting turn", async () => {
80
+ const t = setup({ a: "nit", b: "defer", c: "edit" })
81
+ await routeNotes(t.p, [note("a"), note("b"), note("c")], t.r)
82
+ expect(t.calls).toEqual(["nits", "green", "defer", "close", "collect"])
83
+ })
84
+ })
@@ -24,7 +24,7 @@ export type ReviewOutcome =
24
24
  readonly edited: readonly Change[]
25
25
  }
26
26
 
27
- export type Verdict = "edit" | "question" | "nit" | "praise"
27
+ export type Verdict = "edit" | "question" | "nit" | "praise" | "defer"
28
28
 
29
29
  export type ReviewRisk = ReturnType<typeof reviewRisks>[number]
30
30
 
@@ -49,6 +49,8 @@ export interface ReviewPolicy {
49
49
  readonly triage: (notes: readonly ReviewNote[]) => Promise<ReadonlyMap<string, Verdict>>
50
50
  readonly answerQuestions: (notes: readonly ReviewNote[]) => Promise<void>
51
51
  readonly fixNits: (notes: readonly ReviewNote[]) => Promise<void>
52
+ /** Leave `defer` notes out of the round. */
53
+ readonly defer: (notes: readonly ReviewNote[]) => Promise<void>
52
54
  /** Remove `file` at the end of a round at commit `round`. */
53
55
  readonly close: (round: string) => Promise<void>
54
56
  /** What a collecting turn reads. */
@@ -127,16 +129,16 @@ interface Round {
127
129
  readonly unfolded: () => Promise<Finish>
128
130
  }
129
131
 
130
- /** Judge every note, then run each verdict's route: answers, then nit fixes, then the edits' lap. */
131
- const routeNotes = async (
132
+ /** Judge every note, then run each verdict's route: answers, then nit fixes, then deferrals, then the edits' lap. */
133
+ export const routeNotes = async (
132
134
  p: ReviewPolicy,
133
135
  notes: readonly ReviewNote[],
134
136
  r: Round,
135
137
  ): Promise<Finish> => {
136
138
  const verdicts = await p.triage(notes)
137
139
  const of = (v: Verdict): ReviewNote[] => notes.filter((n) => (verdicts.get(n.id) ?? "edit") === v)
138
- const [edits, questions, nits] = [of("edit"), of("question"), of("nit")]
139
- if (edits.length + questions.length + nits.length === 0) return r.unfolded()
140
+ const [edits, questions, nits, deferred] = [of("edit"), of("question"), of("nit"), of("defer")]
141
+ if (edits.length + questions.length + nits.length + deferred.length === 0) return r.unfolded()
140
142
 
141
143
  let answeredAt: string | undefined
142
144
  if (questions.length > 0) {
@@ -147,6 +149,7 @@ const routeNotes = async (
147
149
  await p.fixNits(nits)
148
150
  await p.keepGreen()
149
151
  }
152
+ if (deferred.length > 0) await p.defer(deferred)
150
153
  if (edits.length > 0) {
151
154
  await r.close()
152
155
  return r.outcome(await collect(p, p.captures.editNotes(r.round, edits, answeredAt)))
@@ -155,7 +158,7 @@ const routeNotes = async (
155
158
  await r.close()
156
159
  return { next: "rereview", carry: answeredAt }
157
160
  }
158
- return { next: "await", reviewed: answeredAt! }
161
+ return { next: "await", reviewed: answeredAt ?? r.round }
159
162
  }
160
163
 
161
164
  /** A code edit, not a thread-only change: lines that only add, answer or remove code threads don't count. */
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import type { Change } from "../flows/index.js"
3
+ import { assertionLines, removedAssertions } from "./scenarios.js"
4
+
5
+ const scenario = (name: string, ...steps: string[]): string[] => [
6
+ ` Scenario: ${name}`,
7
+ ...steps.map((s) => ` ${s}`),
8
+ ]
9
+
10
+ const feature = (...steps: string[]): string =>
11
+ ["Feature: x", ...scenario("y", ...steps)].join("\n")
12
+
13
+ const modified = (path: string, before: string, after: string | undefined): Change => ({
14
+ path,
15
+ status: after === undefined ? "deleted" : "modified",
16
+ before,
17
+ after,
18
+ })
19
+
20
+ const lost = (path: string, name: string, ...lines: string[]) => ({
21
+ path,
22
+ lines: lines.map((line) => ({ scenario: `Scenario: ${name}`, line })),
23
+ })
24
+
25
+ describe("assertionLines", () => {
26
+ it("keeps Then lines and their And/But continuations, not Given/When ones", () => {
27
+ expect(
28
+ assertionLines(
29
+ feature(
30
+ "Given a",
31
+ "And b",
32
+ "When c",
33
+ "Then d",
34
+ "And e",
35
+ "| row |",
36
+ "But f",
37
+ "When g",
38
+ "And h",
39
+ ),
40
+ ).map((a) => a.line),
41
+ ).toEqual(["Then d", "And e", "But f"])
42
+ })
43
+
44
+ it("skips doc string content", () => {
45
+ expect(
46
+ assertionLines(feature("Then a file with:", ' """', " Then not a step", ' """')).map(
47
+ (a) => a.line,
48
+ ),
49
+ ).toEqual(["Then a file with:"])
50
+ })
51
+
52
+ it("names the scenario each assertion sits in, through an Examples table", () => {
53
+ const source = [
54
+ "Feature: x",
55
+ ...scenario("a", "Then one"),
56
+ " Scenario Outline: b",
57
+ " Then <two>",
58
+ " Examples:",
59
+ " | two |",
60
+ ].join("\n")
61
+ expect(assertionLines(source)).toEqual([
62
+ { scenario: "Scenario: a", line: "Then one" },
63
+ { scenario: "Scenario Outline: b", line: "Then <two>" },
64
+ ])
65
+ })
66
+ })
67
+
68
+ describe("removedAssertions", () => {
69
+ it("reports a reworded assertion of an existing scenario", () => {
70
+ expect(
71
+ removedAssertions([
72
+ modified(
73
+ "a.feature",
74
+ feature("Given a", "Then it is shown"),
75
+ feature("Given a", "Then it is displayed"),
76
+ ),
77
+ ]),
78
+ ).toEqual([lost("a.feature", "y", "Then it is shown")])
79
+ })
80
+
81
+ it("ignores reworded Given and When steps and added assertions", () => {
82
+ expect(
83
+ removedAssertions([
84
+ modified(
85
+ "a.feature",
86
+ feature("Given a", "Then b"),
87
+ feature("Given z", "When y", "Then b", "And c"),
88
+ ),
89
+ ]),
90
+ ).toEqual([])
91
+ })
92
+
93
+ it("counts duplicates, so dropping one of two identical lines shows", () => {
94
+ expect(
95
+ removedAssertions([modified("a.feature", feature("Then b", "Then b"), feature("Then b"))]),
96
+ ).toEqual([lost("a.feature", "y", "Then b")])
97
+ })
98
+
99
+ it("reports an assertion that moved to another scenario", () => {
100
+ const before = ["Feature: x", ...scenario("a", "Then ok"), ...scenario("b", "When c")]
101
+ const after = ["Feature: x", ...scenario("a", "When d"), ...scenario("b", "When c", "Then ok")]
102
+ expect(removedAssertions([modified("a.feature", before.join("\n"), after.join("\n"))])).toEqual(
103
+ [lost("a.feature", "a", "Then ok")],
104
+ )
105
+ })
106
+
107
+ it("reports every assertion of a deleted file, none of an added one or a non-feature", () => {
108
+ expect(
109
+ removedAssertions([
110
+ modified("gone.feature", feature("Then b"), undefined),
111
+ { path: "new.feature", status: "added", before: undefined, after: feature("Then c") },
112
+ modified("a.ts", "Then b", ""),
113
+ ]),
114
+ ).toEqual([lost("gone.feature", "y", "Then b")])
115
+ })
116
+ })