@pmelab/gtd 24.3.0 → 24.4.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 (60) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +15 -4
  3. package/dist/chunks/{reserved-DJwU5C4z.mjs → reserved-B9pG93Fc.mjs} +52 -12
  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 +779 -812
  7. package/dist/steering.mjs +2 -2
  8. package/dist/testkit.mjs +663 -158
  9. package/dist/workflow-kit.mjs +9 -7
  10. package/dist/workflows/feature.mjs +152 -35
  11. package/dist/workflows/fix.mjs +2 -2
  12. package/dist/workflows/review.mjs +1 -1
  13. package/dist/workflows.mjs +223 -54
  14. package/package.json +1 -1
  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/steering/FORMATS.md +1 -1
  23. package/src/steering/SteeringFormat.test.ts +9 -1
  24. package/src/steering/SteeringFormat.ts +7 -0
  25. package/src/steering/contract.test.ts +3 -0
  26. package/src/steering/index.ts +1 -0
  27. package/src/steering/review.test.ts +30 -0
  28. package/src/steering/review.ts +8 -2
  29. package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
  30. package/src/testkit/EmittedScriptRecognizer.ts +148 -51
  31. package/src/testkit/EmittedScripts.test.ts +166 -0
  32. package/src/testkit/FlowScripts.test.ts +242 -0
  33. package/src/testkit/FlowScripts.ts +326 -0
  34. package/src/testkit/GitTiers.ts +140 -49
  35. package/src/testkit/InMemRepo.ts +35 -2
  36. package/src/testkit/Layers.ts +10 -2
  37. package/src/testkit/TestRepo.ts +4 -1
  38. package/src/testkit/index.ts +1 -1
  39. package/src/workflow/discovery.test.ts +147 -0
  40. package/src/workflow/discovery.ts +5 -0
  41. package/src/workflow/fingerprint.test.ts +20 -4
  42. package/src/workflow/fingerprint.ts +8 -9
  43. package/src/workflow/index.ts +1 -6
  44. package/src/workflow/load.test.ts +5 -19
  45. package/src/workflow/load.ts +94 -10
  46. package/src/workflow-kit/review.test.ts +84 -0
  47. package/src/workflow-kit/review.ts +9 -6
  48. package/src/workflows/feature/review.test.ts +238 -12
  49. package/src/workflows/feature/review.ts +172 -31
  50. package/src/workflows/feature/steps.test.ts +12 -0
  51. package/src/workflows/feature/steps.ts +8 -1
  52. package/src/workflows/shared/access.test.ts +4 -2
  53. package/src/workflows/shared/access.ts +1 -0
  54. package/src/workflows/shared/options.test.ts +8 -0
  55. package/src/workflows/shared/options.ts +1 -0
  56. package/src/workflows/shared/steps.ts +2 -0
  57. package/src/workflows/shared/tail.ts +3 -11
  58. package/src/workflows/shared/text.test.ts +106 -0
  59. package/src/workflows/shared/text.ts +57 -1
  60. package/src/workflows/shared/vars.ts +1 -0
@@ -24,12 +24,23 @@ import {
24
24
  modeContradictionSkipNotice,
25
25
  OUTCOME_MARKER,
26
26
  } from "../emit/index.js"
27
- import { steeringFormatFor, type SteeringFormat } from "../steering/index.js"
27
+ import {
28
+ BUILT_IN_MODE_NAMES,
29
+ formatFinding,
30
+ steeringFormatFor,
31
+ type SteeringFormat,
32
+ } from "../steering/index.js"
33
+ import { renderFormat } from "../wire/index.js"
34
+ import { applyFlowScript, rewritten } from "./FlowScripts.js"
28
35
  import type { InMemRepo } from "./InMemRepo.js"
29
36
  import type { ScriptedCommand } from "./Layers.js"
30
37
 
31
38
  export interface AppliedScriptResult {
32
39
  readonly ok: boolean
40
+ /** The exit status `sh` would report. */
41
+ readonly status: number
42
+ /** What the script printed, stdout and stderr together, as `sh` would. */
43
+ readonly output: string
33
44
  /** Present exactly when `ok` is `false` — names the block/line that stopped the script. */
34
45
  readonly error?: string
35
46
  }
@@ -70,10 +81,14 @@ const extractQuotedTokens = (text: string): string[] => {
70
81
  return tokens
71
82
  }
72
83
 
84
+ /** What the in-memory tier prints for `$PATH` — there is none to name. */
85
+ const IN_MEMORY_PATH = "(in-memory: no PATH)"
86
+
73
87
  type BlockOutcome =
74
- | { readonly kind: "noop" }
88
+ | { readonly kind: "noop"; readonly output?: string }
75
89
  | { readonly kind: "applied" }
76
- | { readonly kind: "failed"; readonly error: string }
90
+ /** `error` is what the block printed on its way out; `status` defaults to 1. */
91
+ | { readonly kind: "failed"; readonly error: string; readonly status?: number }
77
92
  /**
78
93
  * `fileExistsGuard` tripping on an absent file. The one shape that succeeds
79
94
  * EARLY, skipping every remaining block — every other guard here either
@@ -103,11 +118,9 @@ const recognizePrecondition = (repo: InMemRepo, block: string): BlockOutcome | u
103
118
  if (!match) return undefined
104
119
  const expected = match[1]!
105
120
  const actual = repo.resolveRef("HEAD")
106
- if (actual === expected) return { kind: "noop" }
107
- return {
108
- kind: "failed",
109
- error: `precondition failed: HEAD is ${actual ?? "(no commits)"}, expected ${expected}`,
110
- }
121
+ return actual === expected
122
+ ? { kind: "noop" }
123
+ : { kind: "failed", error: "precondition failed: HEAD moved" }
111
124
  }
112
125
 
113
126
  /**
@@ -216,10 +229,13 @@ const recognizeFileExistsGuard = (repo: InMemRepo, block: string): BlockOutcome
216
229
  return repo.readFile(file) !== undefined ? { kind: "noop" } : { kind: "stopped" }
217
230
  }
218
231
 
219
- /** `binaryGuard`. The in-memory tier has no real `$PATH` to probe — a scripted command IS the stand-in for "this binary exists" — so this always no-ops; the guard actually tripping is `@live`-only coverage. */
232
+ /** `binaryGuard`. The in-memory tier has no `$PATH`: a binary is present unless declared `missing`. */
220
233
  const BINARY_GUARD_MESSAGE_RE = /^gtd: mode "([^"]+)": "(format|validate)" command not found: (.+)$/
221
234
 
222
- const recognizeBinaryGuard = (block: string): BlockOutcome | undefined => {
235
+ const recognizeBinaryGuard = (
236
+ commands: ReadonlyMap<string, ScriptedCommand>,
237
+ block: string,
238
+ ): BlockOutcome | undefined => {
223
239
  const [binary, , message] = extractQuotedTokens(block)
224
240
  if (binary === undefined || message === undefined) return undefined
225
241
  const match = BINARY_GUARD_MESSAGE_RE.exec(message)
@@ -227,12 +243,29 @@ const recognizeBinaryGuard = (block: string): BlockOutcome | undefined => {
227
243
  const [, mode, key, messageBinary] = match
228
244
  if (messageBinary !== binary) return undefined
229
245
  if (binaryGuard(binary, mode!, key as "format" | "validate") !== block) return undefined
230
- return { kind: "noop" }
246
+ if (commands.get(binary)?.kind !== "missing") return { kind: "noop" }
247
+ return { kind: "failed", error: `${message}\n$PATH: ${IN_MEMORY_PATH}`, status: 127 }
231
248
  }
232
249
 
233
250
  /** Recognized by `OUTCOME_MARKER` rather than re-derived and string-compared: an outcome only prints, so no git effect a loose match could miss — unlike `recognizeGitBuilders`, where a near-miss would silently skip a real mutation. */
234
- const recognizeOutcome = (block: string): BlockOutcome | undefined =>
235
- block.startsWith(OUTCOME_MARKER) ? { kind: "noop" } : undefined
251
+ const recognizeOutcome = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
252
+ if (!block.startsWith(`${OUTCOME_MARKER}\nprintf `)) return undefined
253
+ const line = block.slice(OUTCOME_MARKER.length + 1)
254
+ const [format, ...args] = shellWords(repo, line.slice("printf ".length))
255
+ return { kind: "noop", output: renderFormat(format!.replace(/\\n/g, "\n"), ...args) }
256
+ }
257
+
258
+ /** The word list of `printf`'s arguments: quoted literals, and the `"$(git …)"` lookups outcome lines carry. */
259
+ const shellWords = (repo: InMemRepo, text: string): string[] =>
260
+ [
261
+ ...text.matchAll(
262
+ /'((?:[^']|'\\'')*)'|"\$\(git (rev-parse --short|log -1 --format=%s) '([^']*)'\)"/g,
263
+ ),
264
+ ].map((m) => {
265
+ if (m[1] !== undefined) return m[1].replaceAll("'\\''", "'")
266
+ const hash = repo.resolveRef(m[3]!) ?? ""
267
+ return m[2] === "rev-parse --short" ? hash.slice(0, 7) : (repo.lastCommitSubject(hash) ?? "")
268
+ })
236
269
 
237
270
  /**
238
271
  * `failurePromptWrapper`. The constants below bracket the template's one
@@ -255,13 +288,30 @@ const expandFileVar = (text: string): string | undefined => {
255
288
  .replace(/\$\{GTD_FILE\}|\$GTD_FILE\b/g, file)
256
289
  }
257
290
 
258
- /** A mode's `format:`/`validate:` command: `gtd check`, or a scripted command. */
291
+ /**
292
+ * A mode's `format:`/`validate:` command, under `withFileVar` or not:
293
+ * `gtd check`, or a scripted command — keyed by the command as expanded, or
294
+ * as written in the config (`fmt $GTD_FILE`), which is the only key a command
295
+ * run on a scratch path can have.
296
+ */
259
297
  const recognizeModeCommand = (
260
298
  repo: InMemRepo,
261
299
  commands: ReadonlyMap<string, ScriptedCommand>,
262
- command: string,
263
- ): BlockOutcome | undefined =>
264
- recognizeGtdCheck(repo, command) ?? recognizeScriptedCommand(repo, commands, command)
300
+ block: string,
301
+ ): BlockOutcome | undefined => {
302
+ const command = expandFileVar(block) ?? block
303
+ const exact =
304
+ recognizeGtdCheck(repo, command) ?? recognizeScriptedCommand(repo, commands, command)
305
+ if (exact !== undefined) return exact
306
+ const match = FILE_VAR_PREFIX_RE.exec(block)
307
+ if (match === null) return undefined
308
+ const scripted = commands.get(block.slice(match[0].length))
309
+ const [file] = extractQuotedTokens(match[1]!)
310
+ if (scripted === undefined || file === undefined) return undefined
311
+ const bound =
312
+ scripted.kind === "rewrite" && scripted.file === "$GTD_FILE" ? { ...scripted, file } : scripted
313
+ return recognizeScriptedCommand(repo, new Map([[command, bound]]), command)
314
+ }
265
315
 
266
316
  const FAILURE_PROMPT_HEADER = 'gtd_validate_status=0\ngtd_validate_out="$( {\n'
267
317
  const FAILURE_PROMPT_MIDDLE =
@@ -289,10 +339,12 @@ const recognizeFailurePromptWrapper = (
289
339
  const [prompt] = extractQuotedTokens(promptQuoted)
290
340
  if (prompt === undefined || failurePromptWrapper(inner, prompt) !== block) return undefined
291
341
 
292
- const innerOutcome = recognizeModeCommand(repo, commands, expandFileVar(inner) ?? inner)
342
+ const innerOutcome = recognizeModeCommand(repo, commands, inner)
293
343
  if (innerOutcome === undefined) return undefined
294
344
  if (innerOutcome.kind === "failed") {
295
- return { kind: "failed", error: `${prompt}\n\n${innerOutcome.error}` }
345
+ // `"$( … )"` strips the captured output's trailing newlines.
346
+ const captured = innerOutcome.error.replace(/\n+$/, "")
347
+ return { kind: "failed", error: `${prompt}\n\n${captured}`, status: innerOutcome.status ?? 1 }
296
348
  }
297
349
  return innerOutcome
298
350
  }
@@ -309,7 +361,7 @@ const recognizeModeContradictionSkipNotice = (block: string): BlockOutcome | und
309
361
  if (!match) return undefined
310
362
  const mode = match[1]!
311
363
  if (modeContradictionSkipNotice(mode) !== block) return undefined
312
- return { kind: "noop" }
364
+ return { kind: "noop", output: `${message}\n` }
313
365
  }
314
366
 
315
367
  /** Everything `parseModeContradictionCheck` recovers from a matched block — enough for `simulateModeContradictionCheck` to run it, with no further parsing. */
@@ -385,7 +437,7 @@ const simulateModeContradictionCheck = (
385
437
  const { mode, samplePath, sample, formatCommand, format } = parsed
386
438
  repo.writeFile(samplePath, sample)
387
439
  const command = expandFileVar(formatCommand) ?? formatCommand
388
- const formatOutcome = recognizeScriptedCommand(repo, commands, command)
440
+ const formatOutcome = recognizeModeCommand(repo, commands, formatCommand)
389
441
  if (formatOutcome === undefined) {
390
442
  repo.deleteFile(samplePath)
391
443
  return {
@@ -433,7 +485,8 @@ const recognizeGtdCheck = (repo: InMemRepo, block: string): BlockOutcome | undef
433
485
  const [file] = extractQuotedTokens(match[2]!)
434
486
  const format = steeringFormatFor(mode)
435
487
  if (format === undefined || file === undefined) {
436
- return { kind: "failed", error: `gtd check: no built-in steering format named "${mode}"` }
488
+ const known = BUILT_IN_MODE_NAMES.join(", ")
489
+ return { kind: "failed", error: `gtd check: unknown mode "${mode}" — known modes: ${known}` }
437
490
  }
438
491
  // An ABSENT file has nothing to check and exits 0 — `runCheckCommand`'s own
439
492
  // documented behavior. Validating `""` instead would report every "missing
@@ -441,14 +494,14 @@ const recognizeGtdCheck = (repo: InMemRepo, block: string): BlockOutcome | undef
441
494
  // (a case the review-doc guard owns) into a script failure.
442
495
  const content = repo.readFile(file)
443
496
  if (content === undefined) return { kind: "noop" }
444
- const findings = format.validate(content)
445
- if (findings.length > 0) {
446
- const formatted = findings.map((f) =>
447
- f.line !== undefined ? `${file}:${f.line + 1}: ${f.message}` : f.message,
448
- )
449
- return { kind: "failed", error: `gtd check ${mode} ${file}: ${formatted.join("; ")}` }
497
+ const findings = format.validate(content).map((finding) => formatFinding(file, finding))
498
+ if (findings.length === 0) return { kind: "noop" }
499
+ return {
500
+ kind: "failed",
501
+ error:
502
+ `gtd check: ${file} is not valid under mode "${mode}" (${findings.length} finding(s)):\n` +
503
+ findings.join("\n"),
450
504
  }
451
- return { kind: "noop" }
452
505
  }
453
506
 
454
507
  const GTD_UNCHECK_RE = /^gtd uncheck (.+)$/
@@ -477,6 +530,7 @@ const recognizeDidNotRunComment = (block: string): BlockOutcome | undefined =>
477
530
  block === DID_NOT_RUN_COMMENT ? { kind: "noop" } : undefined
478
531
 
479
532
  /** `combinedScript`'s optional half. ALWAYS reports `noop` whatever the inner result, mirroring bash's `( … ) || <warning>`, where the subshell's exit status is swallowed and must never fail the outer script. */
533
+ const PRESENTATION_FAILURE_TEXT = `${extractQuotedTokens(PRESENTATION_FAILURE_WARNING)[0]!.replace(/\\n$/, "")}\n`
480
534
  const PRESENTATION_SUBSHELL_PREFIX = `${PRESENTATION_ONLY_COMMENT}\n(\n`
481
535
  const PRESENTATION_SUBSHELL_SUFFIX = `\n) || ${PRESENTATION_FAILURE_WARNING}`
482
536
 
@@ -495,8 +549,9 @@ const recognizePresentationSubshell = (
495
549
  PRESENTATION_SUBSHELL_PREFIX.length,
496
550
  block.length - PRESENTATION_SUBSHELL_SUFFIX.length,
497
551
  )
498
- applyEmittedScript(repo, commands, inner)
499
- return { kind: "noop" }
552
+ const result = applyEmittedScript(repo, commands, inner)
553
+ if (result.ok) return { kind: "noop", output: result.output }
554
+ return { kind: "noop", output: `${result.output}${PRESENTATION_FAILURE_TEXT}` }
500
555
  }
501
556
 
502
557
  /** Anything left over must be an EXACT hit in the scripted-command table — mirrors `ScriptedCommand`'s two `kind`s (`Layers.ts`). */
@@ -508,16 +563,16 @@ const recognizeScriptedCommand = (
508
563
  const scripted = commands.get(block)
509
564
  if (scripted === undefined) return undefined
510
565
  if (scripted.kind === "rewrite") {
511
- repo.writeFile(scripted.file, scripted.content)
566
+ repo.writeFile(scripted.file, rewritten(repo, scripted))
512
567
  return { kind: "applied" }
513
568
  }
569
+ if (scripted.kind === "missing") {
570
+ return { kind: "failed", error: `sh: ${block.split(" ")[0]}: not found`, status: 127 }
571
+ }
514
572
  if (scripted.status !== 0) {
515
- return {
516
- kind: "failed",
517
- error: `scripted command "${block}" exited ${scripted.status}: ${scripted.output}`,
518
- }
573
+ return { kind: "failed", error: scripted.output, status: scripted.status }
519
574
  }
520
- return { kind: "noop" }
575
+ return { kind: "noop", output: scripted.output }
521
576
  }
522
577
 
523
578
  /**
@@ -577,6 +632,19 @@ const splitBlocks = (script: string): readonly string[] => {
577
632
  return blocks
578
633
  }
579
634
 
635
+ /** `deferNotesScript`: append its one section to `.gtd/TODO.md`, reading the section's lines off the final `printf`. */
636
+ const DEFER_HEAD = "mkdir -p .gtd\nif [ -s .gtd/TODO.md ]; then"
637
+ const recognizeDeferNotes = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
638
+ if (!block.startsWith(DEFER_HEAD)) return undefined
639
+ const at = block.lastIndexOf("\nprintf '%s\\n' ")
640
+ if (at === -1) return undefined
641
+ const lines = extractQuotedTokens(block.slice(at + "\nprintf '%s\\n' ".length))
642
+ const old = repo.readFile(".gtd/TODO.md") ?? ""
643
+ const sep = old === "" ? "" : `${old.endsWith("\n") ? "" : "\n"}\n`
644
+ repo.writeFile(".gtd/TODO.md", `${old}${sep}${lines.join("\n")}\n`)
645
+ return { kind: "applied" }
646
+ }
647
+
580
648
  /** Every recognizer `applyEmittedScript` tries, first match wins — `recognizeScriptedCommand` stays last since it's the exact-hit fallback for anything the rest don't claim. */
581
649
  const recognizersFor = (
582
650
  repo: InMemRepo,
@@ -586,8 +654,9 @@ const recognizersFor = (
586
654
  (block) => recognizeDidNotRunComment(block),
587
655
  (block) => recognizePrecondition(repo, block),
588
656
  (block) => recognizeFileExistsGuard(repo, block),
589
- (block) => recognizeBinaryGuard(block),
657
+ (block) => recognizeBinaryGuard(commands, block),
590
658
  (block) => recognizeGitBuilders(repo, block),
659
+ (block) => recognizeDeferNotes(repo, block),
591
660
  (block) => recognizeFailurePromptWrapper(repo, commands, block),
592
661
  (block) => recognizePresentationSubshell(repo, commands, block),
593
662
  (block) => recognizeModeContradictionSkipNotice(block),
@@ -595,10 +664,9 @@ const recognizersFor = (
595
664
  (block) => recognizeGtdCheck(repo, block),
596
665
  (block) => recognizeGtdUncheck(repo, block),
597
666
  (block) => {
598
- const command = expandFileVar(block)
599
- return command === undefined ? undefined : recognizeModeCommand(repo, commands, command)
667
+ return FILE_VAR_PREFIX_RE.test(block) ? recognizeModeCommand(repo, commands, block) : undefined
600
668
  },
601
- (block) => recognizeOutcome(block),
669
+ (block) => recognizeOutcome(repo, block),
602
670
  (block) => recognizeScriptedCommand(repo, commands, block),
603
671
  ]
604
672
 
@@ -624,19 +692,48 @@ export const applyEmittedScript = (
624
692
  commands: ReadonlyMap<string, ScriptedCommand>,
625
693
  script: string,
626
694
  ): AppliedScriptResult => {
627
- const recognizers = recognizersFor(repo, commands)
695
+ const flow = applyFlowScript(repo, commands, script)
696
+ if (flow !== undefined) {
697
+ return flow.ok
698
+ ? { ok: true, status: 0, output: flow.output }
699
+ : { ok: false, status: 1, output: flow.output, error: flow.error ?? "" }
700
+ }
701
+ return script.startsWith("#!") ? unrecognized(script) : applyBlocks(repo, commands, script)
702
+ }
628
703
 
704
+ const applyBlocks = (
705
+ repo: InMemRepo,
706
+ commands: ReadonlyMap<string, ScriptedCommand>,
707
+ script: string,
708
+ ): AppliedScriptResult => {
709
+ const recognizers = recognizersFor(repo, commands)
710
+ let output = ""
629
711
  for (const block of splitBlocks(script)) {
630
712
  const outcome = recognizeBlock(recognizers, block)
631
-
632
- if (outcome === undefined) {
633
- return { ok: false, error: `unrecognized script block: ${block.split("\n")[0]}` }
634
- }
635
- if (outcome.kind === "stopped") return { ok: true }
636
- if (outcome.kind === "failed") {
637
- return { ok: false, error: outcome.error }
638
- }
713
+ if (outcome === undefined) return unrecognized(block, output)
714
+ if (outcome.kind === "noop") output += outcome.output ?? ""
715
+ if (outcome.kind === "stopped") break
716
+ if (outcome.kind === "failed") return failedAt(outcome, output)
639
717
  }
718
+ return { ok: true, status: 0, output }
719
+ }
640
720
 
641
- return { ok: true }
721
+ const failedAt = (
722
+ outcome: { readonly error: string; readonly status?: number },
723
+ output: string,
724
+ ): AppliedScriptResult => {
725
+ const printed = outcome.error.length === 0 || outcome.error.endsWith("\n") ? "" : "\n"
726
+ return {
727
+ ok: false,
728
+ status: outcome.status ?? 1,
729
+ output: `${output}${outcome.error}${printed}`,
730
+ error: outcome.error,
731
+ }
642
732
  }
733
+
734
+ const unrecognized = (block: string, output = ""): AppliedScriptResult => ({
735
+ ok: false,
736
+ status: 1,
737
+ output,
738
+ error: `unrecognized script block: ${block.split("\n")[0]}`,
739
+ })
@@ -0,0 +1,166 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest"
2
+ import {
3
+ abandonedOutcome,
4
+ binaryGuard,
5
+ combinedScript,
6
+ emitScripts,
7
+ failurePromptWrapper,
8
+ fileExistsGuard,
9
+ modeContradictionSkipNotice,
10
+ transitionOutcome,
11
+ withFileVar,
12
+ type EmitStep,
13
+ } from "../emit/index.js"
14
+ import { gitTiers, type GitTier, type ScriptedCommand } from "./index.js"
15
+
16
+ // Every case runs through real `sh` — with this build's `gtd` on PATH — and
17
+ // through `applyEmittedScript`. A command a case runs is declared for the
18
+ // in-memory twin; Live runs it for real, so a declaration that lies fails here.
19
+ const FILE = ".gtd/a.md"
20
+ const COPY = 'cp "$GTD_FILE" copy.md'
21
+ const FAILING = 'printf "bad: %s\\n" "$GTD_FILE"; exit 2'
22
+ const MISSING = "gtd-test-no-such-binary"
23
+
24
+ const commands = new Map<string, ScriptedCommand>([
25
+ [`cp '${FILE}' copy.md`, { kind: "rewrite", file: "copy.md", content: "a\n" }],
26
+ [`printf "bad: %s\\n" '${FILE}'; exit 2`, { kind: "exit", status: 2, output: `bad: ${FILE}\n` }],
27
+ ["false", { kind: "exit", status: 1, output: "" }],
28
+ [MISSING, { kind: "missing" }],
29
+ ])
30
+
31
+ const commandSteps = (...cmds: string[]): EmitStep[] =>
32
+ cmds.map((command) => ({ kind: "command", command }))
33
+
34
+ const script = (...cmds: string[]): string => emitScripts(commandSteps(...cmds)).required
35
+
36
+ for (const makeTier of gitTiers) {
37
+ describe(`emitted scripts [${makeTier().name}]`, () => {
38
+ let t: GitTier
39
+ const run = (text: string) => t.runScript(text, commands)
40
+ const read = (path: string) =>
41
+ t.observe.existsPath(path) ? t.observe.readWorktreeFile(path) : undefined
42
+
43
+ beforeEach(() => {
44
+ t = makeTier()
45
+ })
46
+
47
+ afterEach(() => {
48
+ t.dispose()
49
+ })
50
+
51
+ describe("fileExistsGuard", () => {
52
+ it("ends the script successfully when the file is absent", () => {
53
+ expect(run(script(fileExistsGuard(FILE), withFileVar(COPY, FILE)))).toMatchObject({
54
+ ok: true,
55
+ })
56
+ expect(read("copy.md")).toBeUndefined()
57
+ })
58
+
59
+ it("runs on, with $GTD_FILE naming the file, when it is present", () => {
60
+ t.seed.writeFile(FILE, "a\n")
61
+ expect(run(script(fileExistsGuard(FILE), withFileVar(COPY, FILE))).ok).toBe(true)
62
+ expect(read("copy.md")).toBe("a\n")
63
+ })
64
+ })
65
+
66
+ it("stops at the first failing step, with its status", () => {
67
+ t.seed.writeFile(FILE, "a\n")
68
+ const result = run(script("false", withFileVar(COPY, FILE)))
69
+ expect(result).toMatchObject({ ok: false, status: 1 })
70
+ expect(read("copy.md")).toBeUndefined()
71
+ })
72
+
73
+ describe("failurePromptWrapper", () => {
74
+ it("prints the prompt and the captured output, and exits with the command's status", () => {
75
+ const wrapped = failurePromptWrapper(withFileVar(FAILING, FILE), "Fix the file.")
76
+ expect(run(script(wrapped))).toEqual({
77
+ ok: false,
78
+ status: 2,
79
+ output: `Fix the file.\n\nbad: ${FILE}\n`,
80
+ })
81
+ })
82
+
83
+ it("is silent when the command passes", () => {
84
+ t.seed.writeFile(FILE, "a\n")
85
+ const wrapped = failurePromptWrapper(withFileVar(COPY, FILE), "Fix the file.")
86
+ expect(run(script(wrapped))).toEqual({ ok: true, status: 0, output: "" })
87
+ })
88
+ })
89
+
90
+ describe("binaryGuard", () => {
91
+ it("passes a binary on PATH", () => {
92
+ expect(run(script(binaryGuard("sh", "adr", "validate"))).ok).toBe(true)
93
+ })
94
+
95
+ it("names a missing binary and exits 127", () => {
96
+ const result = run(script(binaryGuard(MISSING, "adr", "validate")))
97
+ expect(result).toMatchObject({ ok: false, status: 127 })
98
+ expect(result.output).toMatch(
99
+ new RegExp(`^gtd: mode "adr": "validate" command not found: ${MISSING}\\n\\$PATH: `),
100
+ )
101
+ })
102
+ })
103
+
104
+ it("prints an outcome line, looking up the hash and subject it names", () => {
105
+ const head = t.observe.resolveRef("HEAD")
106
+ const outcome = emitScripts([
107
+ { kind: "outcome", command: transitionOutcome("idle", "working") },
108
+ { kind: "outcome", command: abandonedOutcome("working", head, "idle") },
109
+ ]).required
110
+ const result = run(outcome)
111
+ expect(result.ok).toBe(true)
112
+ expect(result.output).toBe(
113
+ "-> idle → working\n" +
114
+ `abandoned the process resting at "working" — HEAD is back at ${head.slice(0, 7)} ("init: first commit"), resting at "idle".\n` +
115
+ "Everything the process produced is kept as uncommitted changes (`git status`); discard them with `git checkout -- . && git clean -fd .gtd` for a clean tree.\n",
116
+ )
117
+ })
118
+
119
+ it("swallows a failing presentation-only half with a warning", () => {
120
+ const { required, optional } = emitScripts(
121
+ [{ kind: "outcome", command: transitionOutcome("a", "b") }],
122
+ commandSteps("false"),
123
+ )
124
+ expect(run(combinedScript(required, optional))).toEqual({
125
+ ok: true,
126
+ status: 0,
127
+ output: "-> a → b\ngtd: presentation-only follow-up failed — continuing\n",
128
+ })
129
+ })
130
+
131
+ it("prints the contradiction-check skip notice", () => {
132
+ expect(run(script(modeContradictionSkipNotice("adr"))).output).toBe(
133
+ 'gtd: mode "adr" has an external validate: command — skipping the format/validate contradiction check\n',
134
+ )
135
+ })
136
+
137
+ describe("gtd check", () => {
138
+ it("passes a valid file, and an absent one", () => {
139
+ expect(run(script(`gtd check qa '${FILE}'`)).ok).toBe(true)
140
+ t.seed.writeFile(FILE, "## Open Questions\n")
141
+ expect(run(script(`gtd check qa '${FILE}'`))).toEqual({ ok: true, status: 0, output: "" })
142
+ })
143
+
144
+ it("prints each finding and fails", () => {
145
+ t.seed.writeFile(FILE, "## Open Questions\n\n### \n")
146
+ const result = run(script(`gtd check qa '${FILE}'`))
147
+ expect(result).toMatchObject({ ok: false, status: 1 })
148
+ expect(result.output).toMatch(
149
+ new RegExp(
150
+ `^gtd check: ${FILE} is not valid under mode "qa" \\(1 finding\\(s\\)\\):\\n${FILE}:3:1: `,
151
+ ),
152
+ )
153
+ })
154
+ })
155
+
156
+ it("gtd uncheck clears a review file's ticks", () => {
157
+ const review = ".gtd/REVIEW.md"
158
+ t.seed.writeFile(
159
+ review,
160
+ "# Review: abc1234\n\n## calc\n- [x] ./src/calc.ts#1-1\nnew add function\n",
161
+ )
162
+ expect(run(script(`gtd uncheck '${review}'`)).ok).toBe(true)
163
+ expect(read(review)).toContain("- [ ] ./src/calc.ts#1-1")
164
+ })
165
+ })
166
+ }