@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,130 +1,70 @@
1
- import { head, read } from "../flows/index.js"
1
+ import type { Change } from "../flows/index.js"
2
2
 
3
- /** Mutable: an accepted change moves the snapshot forward. */
4
- export interface FrozenScenarios {
5
- at: string
6
- texts: Record<string, string>
7
- }
8
-
9
- const DOC_STRING = /^(\s*)("""|```)/
10
-
11
- /**
12
- * Gherkin wording only: lines trimmed; blank, `#` comment and `@` tag lines
13
- * dropped — except inside a doc string, whose content is test input: kept
14
- * whole, indented relative to its opening delimiter (as Gherkin reads it).
15
- */
16
- export const scenarioText = (source: string): string => {
17
- const out: string[] = []
18
- let doc: { readonly indent: number; readonly fence: string } | undefined
19
- for (const line of source.split("\n")) {
20
- const delimiter = DOC_STRING.exec(line)
21
- if (doc === undefined && delimiter !== null) {
22
- doc = { indent: delimiter[1]!.length, fence: delimiter[2]! }
23
- out.push(line.trim())
24
- } else if (doc !== undefined && line.trim().startsWith(doc.fence)) {
25
- doc = undefined
26
- out.push(line.trim())
27
- } else if (doc !== undefined) {
28
- const lead = line.length - line.trimStart().length
29
- out.push(line.slice(Math.min(lead, doc.indent)).trimEnd())
30
- } else {
31
- const trimmed = line.trim()
32
- if (trimmed !== "" && !trimmed.startsWith("#") && !trimmed.startsWith("@")) out.push(trimmed)
33
- }
34
- }
35
- return out.join("\n")
36
- }
3
+ const DOC_STRING = /^("""|```)/
4
+ const THEN = /^Then\s/
5
+ const CONTINUATION = /^(And|But|\*)\s/
6
+ const HEADING = /^(Feature|Rule|Background|Scenario|Scenario Outline|Scenario Template|Example):/
37
7
 
38
- const textOf = (path: string): string | undefined => {
39
- const source = read(path)
40
- return source === undefined ? undefined : scenarioText(source)
8
+ export interface Assertion {
9
+ /** The trimmed heading line of the scenario (or background) it sits in. */
10
+ readonly scenario: string
11
+ readonly line: string
41
12
  }
42
13
 
43
- /** Snapshot `.feature` paths at `head()`, merged over `previous`; `undefined` when nothing is frozen. */
44
- export const freezeScenarios = (
45
- paths: readonly string[],
46
- previous?: FrozenScenarios,
47
- ): FrozenScenarios | undefined => {
48
- if (paths.length === 0) return previous
49
- const texts: Record<string, string> = { ...previous?.texts }
50
- for (const path of paths) {
51
- const text = textOf(path)
52
- if (text !== undefined) texts[path] = text
14
+ /** Trimmed lines outside doc strings, whose content is test input, not steps. */
15
+ const stepLines = (source: string): string[] => {
16
+ const out: string[] = []
17
+ let fence: string | undefined
18
+ for (const trimmed of source.split("\n").map((l) => l.trim())) {
19
+ const opening = fence === undefined ? DOC_STRING.exec(trimmed)?.[1] : undefined
20
+ if (opening !== undefined) fence = opening
21
+ else if (fence === undefined) out.push(trimmed)
22
+ else if (trimmed.startsWith(fence)) fence = undefined
53
23
  }
54
- return Object.keys(texts).length === 0 ? previous : { at: head(), texts }
24
+ return out
55
25
  }
56
26
 
57
- /** Paths whose wording differs from the snapshot (a deleted file counts). */
58
- export const driftedScenarios = (frozen: FrozenScenarios): readonly string[] =>
59
- Object.entries(frozen.texts)
60
- .filter(([path, text]) => textOf(path) !== text)
61
- .map(([path]) => path)
62
-
63
- // Blank lines survive only inside doc strings, where they are wording.
64
- const lines = (text: string | undefined): string[] =>
65
- text === undefined || text === "" ? [] : text.split("\n")
66
-
67
- /** `table[i][j]`: the longest common subsequence of `a[i..]` and `b[j..]`. */
68
- const lcsTable = (a: readonly string[], b: readonly string[]): number[][] => {
69
- const table = Array.from({ length: a.length + 1 }, () => Array<number>(b.length + 1).fill(0))
70
- for (let i = a.length - 1; i >= 0; i--) {
71
- for (let j = b.length - 1; j >= 0; j--) {
72
- table[i]![j] =
73
- a[i] === b[j] ? table[i + 1]![j + 1]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!)
74
- }
75
- }
76
- return table
77
- }
27
+ const neutral = (line: string): boolean =>
28
+ line === "" || line.startsWith("#") || line.startsWith("|")
78
29
 
79
- /** Changed lines in file order, `- ` / `+ `-prefixed, so a moved or repeated step still shows. */
80
- const lineDiff = (a: readonly string[], b: readonly string[]): string[] => {
81
- const table = lcsTable(a, b)
82
- const out: string[] = []
83
- let i = 0
84
- let j = 0
85
- while (i < a.length && j < b.length) {
86
- if (a[i] === b[j]) {
87
- i++
88
- j++
89
- } else if (table[i + 1]![j]! >= table[i]![j + 1]!) out.push(`- ${a[i++]}`)
90
- else out.push(`+ ${b[j++]}`)
30
+ /** Trimmed `Then` step lines, an `And`/`But`/`*` continuing one included; doc strings skipped. */
31
+ export const assertionLines = (source: string): readonly Assertion[] => {
32
+ const out: Assertion[] = []
33
+ let inThen = false
34
+ let scenario = ""
35
+ for (const line of stepLines(source)) {
36
+ if (HEADING.test(line)) scenario = line
37
+ if (THEN.test(line)) inThen = true
38
+ else if (!CONTINUATION.test(line) && !neutral(line)) inThen = false
39
+ if (inThen && !neutral(line)) out.push({ scenario, line })
91
40
  }
92
- return [...out, ...a.slice(i).map((l) => `- ${l}`), ...b.slice(j).map((l) => `+ ${l}`)]
41
+ return out
93
42
  }
94
43
 
95
- export interface WordingDrift {
44
+ export interface RemovedAssertions {
96
45
  readonly path: string
97
- /** `- `/`+ `-prefixed, in file order. */
98
- readonly lines: readonly string[]
46
+ readonly lines: readonly Assertion[]
99
47
  }
100
48
 
101
- export const wordingDrift = (frozen: FrozenScenarios, path: string): WordingDrift => ({
102
- path,
103
- lines: lineDiff(lines(frozen.texts[path]), lines(textOf(path))),
104
- })
49
+ const key = (a: Assertion): string => `${a.scenario}\n${a.line}`
105
50
 
106
51
  /**
107
- * After a turn: rest at `stop` while wording drifted from `frozen`. Whatever
108
- * still differs after it is accepted and moves the snapshot; what was
109
- * restored stays frozen as it was.
52
+ * Assertions a `.feature` file that existed before `changes` lost — the one
53
+ * way rewording a scenario can weaken it. Matched within each scenario and
54
+ * counted, so a line moving to another scenario, or one of two identical
55
+ * `Then`s dropped, still shows. A renamed scenario reports all of its own.
110
56
  */
111
- export const holdWording = async (
112
- frozen: FrozenScenarios | undefined,
113
- stop: (at: string, drifted: readonly WordingDrift[]) => Promise<void>,
114
- ): Promise<void> => {
115
- if (frozen === undefined) return
116
- const drifted = driftedScenarios(frozen)
117
- if (drifted.length === 0) return
118
- await stop(
119
- frozen.at,
120
- drifted.map((path) => wordingDrift(frozen, path)),
121
- )
122
- const accepted = driftedScenarios(frozen)
123
- if (accepted.length === 0) return
124
- for (const path of accepted) {
125
- const text = textOf(path)
126
- if (text === undefined) delete frozen.texts[path]
127
- else frozen.texts[path] = text
128
- }
129
- frozen.at = head()
130
- }
57
+ export const removedAssertions = (changes: readonly Change[]): readonly RemovedAssertions[] =>
58
+ changes
59
+ .filter((c) => c.path.endsWith(".feature") && c.before !== undefined)
60
+ .map((c) => {
61
+ const left = new Map<string, number>()
62
+ for (const a of assertionLines(c.after ?? "")) left.set(key(a), (left.get(key(a)) ?? 0) + 1)
63
+ const lines = assertionLines(c.before ?? "").filter((a) => {
64
+ const n = left.get(key(a)) ?? 0
65
+ left.set(key(a), n - 1)
66
+ return n <= 0
67
+ })
68
+ return { path: c.path, lines }
69
+ })
70
+ .filter((r) => r.lines.length > 0)
@@ -10,7 +10,6 @@ import {
10
10
  } from "../../flows/index.js"
11
11
  import { gate } from "../shared/health.js"
12
12
  import { shipped } from "../shared/options.js"
13
- import type { FrozenScenarios } from "../shared/scenarios.js"
14
13
  import { FEEDBACK, REVIEW } from "../shared/steps.js"
15
14
  import { refusePendingHandOff } from "../shared/tail.js"
16
15
  import * as t from "../shared/text.js"
@@ -53,13 +52,11 @@ export const reUnwind = async (
53
52
  /** Plan, build and review until a review round signs off; feedback re-plans from scratch. */
54
53
  export const planAndBuild = async (firstBase: string): Promise<void> => {
55
54
  let base = firstBase
56
- let carried: FrozenScenarios | undefined
57
55
  for (;;) {
58
56
  await design(base)
59
57
  await architecture()
60
- const built = await packages(carried)
61
- carried = built.frozen
62
- const outcome = await buildTail(false, base, built)
58
+ await packages()
59
+ const outcome = await buildTail(false, base, true)
63
60
  if (outcome.verdict === "signoff") return
64
61
  await reUnwind(outcome)
65
62
  base = outcome.base
@@ -1,6 +1,6 @@
1
1
  import { afterEach, describe, expect, it } from "vitest"
2
2
  import { installContext, type Change, type StepRequest } from "../../flows/index.js"
3
- import { declaredTests, packages, SCENARIO_PACKAGE } from "./packages.js"
3
+ import { declaredTests, packages } from "./packages.js"
4
4
  import { fixtureContext } from "../shared/text.fixture.js"
5
5
 
6
6
  afterEach(() => installContext(undefined))
@@ -41,51 +41,34 @@ describe("declaredTests", () => {
41
41
  })
42
42
  })
43
43
 
44
- describe("packages scenarios", () => {
45
- it("holds only package 0's own paths, not what later packages add", async () => {
46
- const queue = [SCENARIO_PACKAGE, ".gtd/packages/01-impl.md"]
47
- const touches: { at: number; change: Change }[] = []
48
- let steps = 0
49
- const touch = (path: string, status: Change["status"]): void => {
50
- touches.push({ at: steps, change: { path, status, before: undefined, after: "x" } })
51
- }
52
- const effects: Record<string, () => void> = {
53
- building: () => {
54
- if (queue[0] === SCENARIO_PACKAGE) {
55
- touch("tests/e2e/a.feature", "added")
56
- touch(".gtd/packages/00-e2e-scenarios.md", "modified")
57
- } else {
58
- touch("src/impl.ts", "added")
59
- touch("tests/e2e/b.feature", "added")
60
- touch("tests/e2e/a.feature", "modified")
61
- }
62
- },
63
- closing: () => void queue.shift(),
64
- }
44
+ describe("packages", () => {
45
+ it("closes every queued package in name order, the plan with the last", async () => {
46
+ const queue = [".gtd/packages/02-b.md", ".gtd/packages/01-a.md"]
47
+ const closings: string[] = []
65
48
  installContext(
66
49
  fixtureContext(
67
50
  {},
68
51
  {
69
52
  step: (request: StepRequest) => {
70
- steps++
71
- if (request.kind !== "restart") effects[request.name]?.()
53
+ if (request.kind === "run" && request.name === "closing") {
54
+ closings.push(request.body)
55
+ queue.splice(queue.indexOf([...queue].sort()[0]!), 1)
56
+ }
72
57
  return Promise.resolve()
73
58
  },
74
59
  pushScope: () => undefined,
75
60
  popScope: () => undefined,
76
- refuse: (message: string): never => {
77
- throw new Error(message)
78
- },
79
61
  glob: () => [...queue],
80
- changesSince: (hash: string): readonly Change[] =>
81
- touches.filter((t) => t.at >= Number(hash.slice(1))).map((t) => t.change),
82
- head: () => `c${steps}`,
62
+ changesSince: (): readonly Change[] => [],
63
+ head: () => "c",
83
64
  },
84
65
  ),
85
66
  )
86
- const plan = await packages()
87
- expect(plan.ranges.map((r) => r.pkg)).toEqual([SCENARIO_PACKAGE, ".gtd/packages/01-impl.md"])
88
- expect(plan.scenarios.added).toEqual(["tests/e2e/a.feature"])
89
- expect(plan.scenarios.changed).toEqual([])
67
+ await packages()
68
+ expect(closings).toHaveLength(2)
69
+ expect(closings[0]).toContain("01-a.md")
70
+ expect(closings[0]).not.toContain("ARCHITECTURE")
71
+ expect(closings[1]).toContain("02-b.md")
72
+ expect(closings[1]).toContain(".gtd/ARCHITECTURE.md")
90
73
  })
91
74
  })
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  changesSince,
3
- type Change,
4
3
  glob,
5
4
  head,
6
5
  read,
@@ -10,12 +9,9 @@ import {
10
9
  scope,
11
10
  } from "../../flows/index.js"
12
11
  import { healthy } from "../shared/health.js"
13
- import { freezeScenarios, guarded, type FrozenScenarios } from "../shared/scenarios.js"
12
+ import { ARCHITECTURE } from "../shared/steps.js"
14
13
  import { build, fixSuite } from "./steps.js"
15
14
 
16
- /** Package 0: the e2e scenarios, written red. */
17
- export const SCENARIO_PACKAGE = ".gtd/packages/00-e2e-scenarios.md"
18
-
19
15
  export interface DeclaredTest {
20
16
  readonly level: "unit" | "e2e"
21
17
  readonly path: string
@@ -62,68 +58,33 @@ export const requireDeclaredTests = (pkg: string, since: string): void => {
62
58
  )
63
59
  }
64
60
 
65
- export interface PackageRange {
66
- readonly pkg: string
67
- readonly from: string
68
- readonly to: string
69
- }
70
-
71
- export interface BuiltPlan {
72
- readonly ranges: readonly PackageRange[]
73
- /** Paths package 0 added / modified outside `.gtd/`. */
74
- readonly scenarios: { readonly added: readonly string[]; readonly changed: readonly string[] }
75
- /** The wording snapshot after this run, to guard the tail and later laps. */
76
- readonly frozen: FrozenScenarios | undefined
77
- }
78
-
79
- /** Build `pkg`, check its declared tests, keep the fast suite green, and close it out, which removes it. */
80
- export const packageItem = async (pkg: string, frozen?: FrozenScenarios): Promise<PackageRange> => {
61
+ /**
62
+ * Build `pkg`, check its declared tests, keep the full suite green, and close
63
+ * it out, which removes it — and `.gtd/ARCHITECTURE.md` with the last one.
64
+ */
65
+ export const packageItem = async (pkg: string): Promise<void> => {
81
66
  const from = head()
82
67
  // Rechecked after every fix too: a fix deleting a red declared test would
83
- // otherwise green the suite. Before the wording gate, so it refuses the turn.
68
+ // otherwise green the suite.
84
69
  const declared = (turn: () => Promise<void>) => async () => {
85
70
  await turn()
86
71
  requireDeclaredTests(pkg, from)
87
72
  }
88
- await guarded(
89
- frozen,
90
- declared(() => build(pkg)),
91
- )()
92
- await healthy(guarded(frozen, declared(fixSuite)), { suite: "fast" })
93
- await run("closing", removeScript([pkg, ".gtd/SATISFIED.md"]), {
73
+ await declared(() => build(pkg))()
74
+ await healthy(declared(fixSuite))
75
+ const last = glob(".gtd/packages/*.md").length === 1
76
+ await run("closing", removeScript([pkg, ".gtd/SATISFIED.md", ...(last ? [ARCHITECTURE] : [])]), {
94
77
  label: "Closing out the package",
95
78
  })
96
- return { pkg, from, to: head() }
97
79
  }
98
80
 
99
81
  /** The first queued package — the one a package step works on. */
100
82
  export const nextPackage = (): string | undefined => [...glob(".gtd/packages/*.md")].sort()[0]
101
83
 
102
- const outsideGtd = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
103
-
104
84
  /** Build every package file under `.gtd/packages/`, in name order. */
105
- export const packages = (carried?: FrozenScenarios): Promise<BuiltPlan> =>
85
+ export const packages = (): Promise<void> =>
106
86
  scope("packages", async () => {
107
- let frozen = carried
108
- const ranges: PackageRange[] = []
109
- // Captured right after package 0: a later diff to the tree would also hold what 01…N touch.
110
- let touched: readonly Change[] = []
111
87
  for (let pkg = nextPackage(); pkg !== undefined; pkg = nextPackage()) {
112
- const range = await scope("item", () =>
113
- packageItem(pkg, pkg === SCENARIO_PACKAGE ? undefined : frozen),
114
- )
115
- ranges.push(range)
116
- if (pkg === SCENARIO_PACKAGE) {
117
- touched = changesSince(range.from).filter(
118
- (c) => outsideGtd(c.path) && c.status !== "deleted",
119
- )
120
- frozen = freezeScenarios(
121
- touched.filter((c) => c.path.endsWith(".feature")).map((c) => c.path),
122
- frozen,
123
- )
124
- }
88
+ await scope("item", () => packageItem(pkg))
125
89
  }
126
- const paths = (status: string): string[] =>
127
- touched.filter((c) => c.status === status).map((c) => c.path)
128
- return { ranges, scenarios: { added: paths("added"), changed: paths("modified") }, frozen }
129
90
  })
@@ -54,7 +54,7 @@ export const architecture = (): Promise<void> =>
54
54
  }
55
55
  if (changes(ARCHITECTURE).some((c) => c.status === "deleted")) {
56
56
  refuse(
57
- "gtd land: decompose: keep .gtd/ARCHITECTURE.md — the build tail's full run needs it; it is removed only once that run is green",
57
+ "gtd land: decompose: keep .gtd/ARCHITECTURE.md — the packages are built from it; it is removed with the last package",
58
58
  )
59
59
  }
60
60
  })