@pmelab/gtd 24.2.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +41 -6
- package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
- package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-B9pG93Fc.mjs} +53 -13
- package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
- package/dist/flows.mjs +9 -1
- package/dist/gtd.bundle.mjs +1114 -865
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +1135 -218
- package/dist/workflow-kit.mjs +9 -7
- package/dist/workflows/feature.mjs +152 -35
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +1 -1
- package/dist/workflows.mjs +223 -54
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +57 -0
- package/src/cli/Cli.ts +89 -9
- package/src/cli/History.test.ts +225 -0
- package/src/cli/History.ts +143 -0
- package/src/cli/Output.ts +68 -0
- package/src/cli/Program.test.ts +10 -9
- package/src/cli/Program.ts +24 -72
- package/src/edge/Edge.test.ts +261 -3
- package/src/edge/Edge.ts +218 -41
- package/src/edge/index.ts +1 -0
- package/src/emit/GitScript.test.ts +27 -0
- package/src/emit/GitScript.ts +18 -2
- package/src/emit/index.ts +2 -0
- package/src/flows/scripts.ts +6 -0
- package/src/platform/Git.test.ts +6 -9
- package/src/platform/Git.ts +48 -10
- package/src/platform/RetainedHistory.test.ts +14 -0
- package/src/platform/RetainedHistory.ts +5 -0
- package/src/platform/index.ts +7 -1
- package/src/replay/Trailers.test.ts +32 -0
- package/src/replay/Trailers.ts +17 -0
- package/src/steering/FORMATS.md +1 -1
- package/src/steering/SteeringFormat.test.ts +9 -1
- package/src/steering/SteeringFormat.ts +7 -0
- package/src/steering/contract.test.ts +3 -0
- package/src/steering/index.ts +1 -0
- package/src/steering/review.test.ts +30 -0
- package/src/steering/review.ts +8 -2
- package/src/step/LandStep.ts +3 -1
- package/src/step/RepoSnapshot.ts +7 -1
- package/src/step/planStart.test.ts +6 -3
- package/src/step/planStep.test.ts +29 -1
- package/src/step/planStep.ts +8 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
- package/src/testkit/EmittedScriptRecognizer.ts +149 -52
- package/src/testkit/EmittedScripts.test.ts +166 -0
- package/src/testkit/FakeGitOperations.ts +6 -2
- package/src/testkit/FlowScripts.test.ts +242 -0
- package/src/testkit/FlowScripts.ts +326 -0
- package/src/testkit/GitTiers.ts +269 -47
- package/src/testkit/InMemRepo.ts +61 -8
- package/src/testkit/Layers.ts +10 -2
- package/src/testkit/TestRepo.ts +4 -1
- package/src/testkit/index.ts +1 -1
- package/src/workflow/discovery.test.ts +147 -0
- package/src/workflow/discovery.ts +5 -0
- package/src/workflow/fingerprint.test.ts +20 -4
- package/src/workflow/fingerprint.ts +8 -9
- package/src/workflow/index.ts +1 -6
- package/src/workflow/load.test.ts +5 -19
- package/src/workflow/load.ts +94 -10
- package/src/workflow-kit/review.test.ts +84 -0
- package/src/workflow-kit/review.ts +9 -6
- package/src/workflows/feature/review.test.ts +238 -12
- package/src/workflows/feature/review.ts +172 -31
- package/src/workflows/feature/steps.test.ts +12 -0
- package/src/workflows/feature/steps.ts +8 -1
- package/src/workflows/shared/access.test.ts +4 -2
- package/src/workflows/shared/access.ts +1 -0
- package/src/workflows/shared/options.test.ts +8 -0
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/steps.ts +2 -0
- package/src/workflows/shared/tail.ts +3 -11
- package/src/workflows/shared/text.test.ts +106 -0
- package/src/workflows/shared/text.ts +57 -1
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
/**
|
|
@@ -143,7 +156,7 @@ const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | un
|
|
|
143
156
|
// quoted argument.
|
|
144
157
|
const conditionLine = conditionStatement(block)
|
|
145
158
|
|
|
146
|
-
if (block.startsWith("git add -A
|
|
159
|
+
if (block.startsWith("git add -A || exit 1\n")) {
|
|
147
160
|
const [message] = extractQuotedTokens(conditionLine)
|
|
148
161
|
if (message !== undefined && commitAll(message) === block) {
|
|
149
162
|
repo.commitAllWithPrefix(message)
|
|
@@ -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
|
|
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 = (
|
|
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)
|
|
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
|
-
/**
|
|
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
|
-
|
|
263
|
-
): BlockOutcome | undefined =>
|
|
264
|
-
|
|
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,
|
|
342
|
+
const innerOutcome = recognizeModeCommand(repo, commands, inner)
|
|
293
343
|
if (innerOutcome === undefined) return undefined
|
|
294
344
|
if (innerOutcome.kind === "failed") {
|
|
295
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 ===
|
|
633
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -16,11 +16,15 @@ const makeGitReaderOps = (repo: InMemRepo, root: string): GitReaderOperations =>
|
|
|
16
16
|
return subject !== null ? Effect.succeed(subject) : Effect.fail(new Error("No commits"))
|
|
17
17
|
},
|
|
18
18
|
|
|
19
|
-
lastCommitMessage: () => {
|
|
20
|
-
const message = repo.lastCommitMessage()
|
|
19
|
+
lastCommitMessage: (ref?: string) => {
|
|
20
|
+
const message = repo.lastCommitMessage(ref)
|
|
21
21
|
return message !== null ? Effect.succeed(message) : Effect.fail(new Error("No commits"))
|
|
22
22
|
},
|
|
23
23
|
|
|
24
|
+
currentBranch: () => Effect.succeed(Option.some(repo.branchName())),
|
|
25
|
+
|
|
26
|
+
listRefs: (prefix: string) => Effect.succeed(repo.listRefs(prefix)),
|
|
27
|
+
|
|
24
28
|
resolveRef: (ref: string) => {
|
|
25
29
|
const hash = repo.resolveRef(ref)
|
|
26
30
|
return hash !== null
|