@pmelab/gtd 24.0.0 → 24.1.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 +31 -5
- package/claude/hooks/access.ts +1 -1
- package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
- package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
- package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
- package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
- package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
- package/dist/flows.mjs +525 -0
- package/dist/gtd.bundle.mjs +43339 -55575
- package/dist/steering.mjs +2 -0
- package/dist/testkit.mjs +13006 -0
- package/dist/wire.mjs +2 -0
- package/dist/workflow-kit.mjs +352 -0
- package/dist/workflows/feature.mjs +315 -0
- package/dist/workflows/fix.mjs +40 -0
- package/dist/workflows/review.mjs +25 -0
- package/dist/workflows.mjs +8 -6
- package/package.json +69 -8
- package/src/cli/Cli.test.ts +1329 -0
- package/src/cli/Cli.ts +1348 -0
- package/src/cli/Program.test.ts +2262 -0
- package/src/cli/Program.ts +1331 -0
- package/src/cli/ToolCommands.ts +43 -0
- package/src/cli/index.ts +14 -0
- package/src/edge/Edge.test.ts +1016 -0
- package/src/edge/Edge.ts +1225 -0
- package/src/edge/index.ts +16 -0
- package/src/emit/Emit.test.ts +425 -0
- package/src/emit/Emit.ts +139 -0
- package/src/emit/GitScript.test.ts +260 -0
- package/src/emit/GitScript.ts +131 -0
- package/src/emit/ModeContradiction.test.ts +120 -0
- package/src/emit/ModeContradiction.ts +66 -0
- package/src/emit/OutcomeScript.test.ts +238 -0
- package/src/emit/OutcomeScript.ts +73 -0
- package/src/emit/SteeringMode.test.ts +274 -0
- package/src/emit/SteeringMode.ts +256 -0
- package/src/emit/index.ts +40 -0
- package/src/install/ConfigScaffold.ts +19 -0
- package/src/install/Install.test.ts +426 -0
- package/src/install/Install.ts +634 -0
- package/src/install/Version.ts +27 -0
- package/src/install/index.ts +3 -0
- package/src/platform/CommandRunner.ts +74 -0
- package/src/platform/Commentary.test.ts +126 -0
- package/src/platform/Commentary.ts +92 -0
- package/src/platform/Git.test.ts +50 -0
- package/src/platform/Git.ts +552 -0
- package/src/platform/Host.ts +42 -0
- package/src/platform/RetainedHistory.test.ts +65 -0
- package/src/platform/RetainedHistory.ts +34 -0
- package/src/platform/Workspace.test.ts +669 -0
- package/src/platform/Workspace.ts +631 -0
- package/src/platform/WorktreeState.test.ts +162 -0
- package/src/platform/WorktreeState.ts +50 -0
- package/src/platform/index.ts +22 -0
- package/src/replay/Access.test.ts +53 -0
- package/src/replay/Access.ts +46 -0
- package/src/replay/Format.test.ts +20 -0
- package/src/replay/Format.ts +14 -0
- package/src/replay/Replay.test.ts +887 -0
- package/src/replay/Replay.ts +669 -0
- package/src/replay/Trailers.test.ts +117 -0
- package/src/replay/Trailers.ts +213 -0
- package/src/replay/Tree.test.ts +25 -0
- package/src/replay/Tree.ts +45 -0
- package/src/replay/index.ts +11 -0
- package/src/steering/Blocks.fixture.ts +6 -0
- package/src/steering/Blocks.test.ts +167 -0
- package/src/steering/Blocks.ts +321 -0
- package/src/steering/CodeThreads.test.ts +127 -0
- package/src/steering/CodeThreads.ts +197 -0
- package/src/steering/Edits.test.ts +19 -0
- package/src/steering/Edits.ts +20 -0
- package/src/steering/Eol.test.ts +16 -0
- package/src/steering/Eol.ts +2 -0
- package/src/steering/FORMATS.md +58 -0
- package/src/steering/Footnotes.test.ts +977 -0
- package/src/steering/Footnotes.ts +661 -0
- package/src/steering/Inline.fixture.ts +7 -0
- package/src/steering/Inline.test.ts +227 -0
- package/src/steering/Inline.ts +195 -0
- package/src/steering/MarkdownTree.test.ts +247 -0
- package/src/steering/MarkdownTree.ts +260 -0
- package/src/steering/Outline.ts +19 -0
- package/src/steering/SteeringFormat.test.ts +91 -0
- package/src/steering/SteeringFormat.ts +267 -0
- package/src/steering/SteeringFormats.test.ts +163 -0
- package/src/steering/Threads.ts +163 -0
- package/src/steering/contract.test.ts +264 -0
- package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
- package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
- package/src/steering/fixtures/free-form/notes.md +12 -0
- package/src/steering/fixtures/free-form/notes.read.json +42 -0
- package/src/steering/fixtures/qa/answered.md +23 -0
- package/src/steering/fixtures/qa/answered.read.json +119 -0
- package/src/steering/fixtures/qa/requirements.md +160 -0
- package/src/steering/fixtures/qa/requirements.read.json +475 -0
- package/src/steering/fixtures/review/review.md +76 -0
- package/src/steering/fixtures/review/review.read.json +237 -0
- package/src/steering/fixtures/review/ticked.md +23 -0
- package/src/steering/fixtures/review/ticked.read.json +99 -0
- package/src/steering/fixtures/review-notes/baseline.md +14 -0
- package/src/steering/fixtures/review-notes/noted.md +19 -0
- package/src/steering/freeform.test.ts +311 -0
- package/src/steering/freeform.ts +148 -0
- package/src/steering/index.test.ts +62 -0
- package/src/steering/index.ts +140 -0
- package/src/steering/qa.test.ts +2160 -0
- package/src/steering/qa.ts +1104 -0
- package/src/steering/review.test.ts +1829 -0
- package/src/steering/review.ts +1180 -0
- package/src/step/LandStep.ts +31 -0
- package/src/step/RepoSnapshot.ts +29 -0
- package/src/step/accessRefusal.test.ts +58 -0
- package/src/step/accessRefusal.ts +26 -0
- package/src/step/index.ts +5 -0
- package/src/step/planStart.test.ts +202 -0
- package/src/step/planStart.ts +167 -0
- package/src/step/planStep.test.ts +104 -0
- package/src/step/planStep.ts +89 -0
- package/src/step/snapshot.fixture.ts +28 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
- package/src/testkit/EmittedScriptRecognizer.ts +642 -0
- package/src/testkit/FakeGitOperations.ts +95 -0
- package/src/testkit/GitTiers.test.ts +11 -0
- package/src/testkit/GitTiers.ts +1052 -0
- package/src/testkit/InMemRepo.ts +467 -0
- package/src/testkit/Layers.test.ts +58 -0
- package/src/testkit/Layers.ts +241 -0
- package/src/testkit/StrictGitOperations.ts +31 -0
- package/src/testkit/TestRepo.test.ts +119 -0
- package/src/testkit/TestRepo.ts +274 -0
- package/src/testkit/cliIo.ts +36 -0
- package/src/testkit/index.ts +9 -0
- package/src/testkit/steps.ts +177 -0
- package/src/testkit/testkit.feature +65 -0
- package/src/wire/BeatDocument.test.ts +419 -0
- package/src/wire/BeatDocument.ts +114 -0
- package/src/wire/BeatPlain.test.ts +113 -0
- package/src/wire/BeatPlain.ts +127 -0
- package/src/wire/BeatStatus.test.ts +59 -0
- package/src/wire/BeatStatus.ts +81 -0
- package/src/wire/Demand.test.ts +142 -0
- package/src/wire/Demand.ts +108 -0
- package/src/wire/ExitCodes.test.ts +21 -0
- package/src/wire/ExitCodes.ts +16 -0
- package/src/wire/Glob.ts +29 -0
- package/src/wire/Judge.ts +22 -0
- package/src/wire/Land.test.ts +120 -0
- package/src/wire/Land.ts +75 -0
- package/src/wire/Select.test.ts +148 -0
- package/src/wire/Select.ts +78 -0
- package/src/wire/Sessions.test.ts +59 -0
- package/src/wire/Sessions.ts +39 -0
- package/src/wire/WireSchema.test.ts +162 -0
- package/src/wire/constants.ts +22 -0
- package/src/wire/golden/1/beat-capture.json +34 -0
- package/src/wire/golden/1/beat-message.json +34 -0
- package/src/wire/golden/1/beat-prompt.json +44 -0
- package/src/wire/golden/1/beat-script.json +34 -0
- package/src/wire/golden/1/beat-stalled.json +34 -0
- package/src/wire/golden/1/judge.json +14 -0
- package/src/wire/golden/1/land-noop.json +10 -0
- package/src/wire/golden/1/land.json +10 -0
- package/src/wire/index.ts +12 -0
- package/src/wire/types.ts +46 -0
- package/src/workflow/Diagnostic.test.ts +93 -0
- package/src/workflow/Diagnostic.ts +54 -0
- package/src/workflow/SteeringFormats.test.ts +27 -0
- package/src/workflow/SteeringFormats.ts +6 -0
- package/src/workflow/Workflow.ts +77 -0
- package/src/workflow/compile.test.ts +53 -0
- package/src/workflow/compile.ts +56 -0
- package/src/workflow/discovery.ts +122 -0
- package/src/workflow/fingerprint.test.ts +52 -0
- package/src/workflow/fingerprint.ts +53 -0
- package/src/workflow/index.ts +33 -0
- package/src/workflow/load.test.ts +1017 -0
- package/src/workflow/load.ts +836 -0
- package/src/workflow/skills.test.ts +109 -0
- package/src/workflow/skills.ts +90 -0
- package/src/workflow/vars.ts +33 -0
- package/src/workflows/feature/feature.test.ts +27 -33
- package/src/workflows/feature/index.ts +10 -0
- package/src/workflows/fix/fix.feature +80 -0
- package/src/workflows/fix/fix.test.ts +58 -78
- package/src/workflows/fix/index.ts +6 -0
- package/src/workflows/index.ts +2 -10
- package/src/workflows/review/index.ts +6 -0
- package/src/workflows/review/review.test.ts +85 -29
- package/src/workflows/review/review.ts +3 -1
- package/src/workflows/shared/config.fixture.ts +12 -0
- package/src/workflows/shared/reserved.ts +11 -0
- package/src/workflows/shared/workflows.steps.ts +7 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
// Each block is recognized by RE-RUNNING the same `GitScript.ts`/`Emit.ts`
|
|
2
|
+
// builder against values extracted from it and comparing strings — never by
|
|
3
|
+
// duplicating a builder's template as a regex — so an unrecognized block
|
|
4
|
+
// fails loudly instead of silently passing through.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
commitAll,
|
|
8
|
+
commitAsIs,
|
|
9
|
+
deleteRef,
|
|
10
|
+
discardPending,
|
|
11
|
+
hardResetTo,
|
|
12
|
+
mixedResetTo,
|
|
13
|
+
shellQuote,
|
|
14
|
+
softResetTo,
|
|
15
|
+
updateRef,
|
|
16
|
+
binaryGuard,
|
|
17
|
+
DID_NOT_RUN_COMMENT,
|
|
18
|
+
failurePromptWrapper,
|
|
19
|
+
fileExistsGuard,
|
|
20
|
+
PRESENTATION_FAILURE_WARNING,
|
|
21
|
+
PRESENTATION_ONLY_COMMENT,
|
|
22
|
+
buildModeContradictionCheck,
|
|
23
|
+
contradictionMessage,
|
|
24
|
+
modeContradictionSkipNotice,
|
|
25
|
+
OUTCOME_MARKER,
|
|
26
|
+
} from "../emit/index.js"
|
|
27
|
+
import { steeringFormatFor, type SteeringFormat } from "../steering/index.js"
|
|
28
|
+
import type { InMemRepo } from "./InMemRepo.js"
|
|
29
|
+
import type { ScriptedCommand } from "./Layers.js"
|
|
30
|
+
|
|
31
|
+
export interface AppliedScriptResult {
|
|
32
|
+
readonly ok: boolean
|
|
33
|
+
/** Present exactly when `ok` is `false` — names the block/line that stopped the script. */
|
|
34
|
+
readonly error?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reverses `GitScript.ts`'s `shellQuote`: extracts every `'...'` token from
|
|
39
|
+
* `text`, in order, unescaping the `'\''` (close-literal-open) sequence back
|
|
40
|
+
* to a plain `'`. Position-agnostic on purpose — a builder's block may carry
|
|
41
|
+
* the same value quoted more than once (`commitAllowEmpty`'s retry line
|
|
42
|
+
* repeats the message), so callers take whichever index they need rather
|
|
43
|
+
* than relying on a fixed token count.
|
|
44
|
+
*/
|
|
45
|
+
const extractQuotedTokens = (text: string): string[] => {
|
|
46
|
+
const tokens: string[] = []
|
|
47
|
+
let i = 0
|
|
48
|
+
while (i < text.length) {
|
|
49
|
+
if (text[i] !== "'") {
|
|
50
|
+
i += 1
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
i += 1
|
|
54
|
+
let value = ""
|
|
55
|
+
while (i < text.length) {
|
|
56
|
+
if (text[i] === "'") {
|
|
57
|
+
if (text.slice(i, i + 4) === "'\\''") {
|
|
58
|
+
value += "'"
|
|
59
|
+
i += 4
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
i += 1
|
|
63
|
+
break
|
|
64
|
+
}
|
|
65
|
+
value += text[i]
|
|
66
|
+
i += 1
|
|
67
|
+
}
|
|
68
|
+
tokens.push(value)
|
|
69
|
+
}
|
|
70
|
+
return tokens
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type BlockOutcome =
|
|
74
|
+
| { readonly kind: "noop" }
|
|
75
|
+
| { readonly kind: "applied" }
|
|
76
|
+
| { readonly kind: "failed"; readonly error: string }
|
|
77
|
+
/**
|
|
78
|
+
* `fileExistsGuard` tripping on an absent file. The one shape that succeeds
|
|
79
|
+
* EARLY, skipping every remaining block — every other guard here either
|
|
80
|
+
* no-ops or fails the script.
|
|
81
|
+
*/
|
|
82
|
+
| { readonly kind: "stopped" }
|
|
83
|
+
|
|
84
|
+
const SET_FLAGS_RE = /^set\s+-\S+(\s+\S+)*$/
|
|
85
|
+
|
|
86
|
+
const recognizeSetFlags = (block: string): BlockOutcome | undefined =>
|
|
87
|
+
SET_FLAGS_RE.test(block) ? { kind: "noop" } : undefined
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* This module's own invented precondition-assertion shape, not a real gtd
|
|
91
|
+
* convention: a `[ ... ] || { ...; exit 1; }` guard on the current `HEAD`,
|
|
92
|
+
* matching `set -euo pipefail` semantics — a tripped precondition must stop
|
|
93
|
+
* the script, exactly like a failed builder line would.
|
|
94
|
+
*/
|
|
95
|
+
export const preconditionHeadEquals = (hash: string): string =>
|
|
96
|
+
`[ "$(git rev-parse HEAD)" = '${hash}' ] || { echo "precondition failed: HEAD moved" >&2; exit 1; }`
|
|
97
|
+
|
|
98
|
+
const PRECONDITION_RE =
|
|
99
|
+
/^\[ "\$\(git rev-parse HEAD\)" = '([0-9a-f]{40})' \] \|\| \{ echo "precondition failed: HEAD moved" >&2; exit 1; \}$/
|
|
100
|
+
|
|
101
|
+
const recognizePrecondition = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
|
|
102
|
+
const match = PRECONDITION_RE.exec(block)
|
|
103
|
+
if (!match) return undefined
|
|
104
|
+
const expected = match[1]!
|
|
105
|
+
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
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The 8 `GitScript.ts` builders. Each branch checks a cheap literal PREFIX
|
|
115
|
+
* first (to pick which builder to try), then re-derives the builder's
|
|
116
|
+
* arguments from the block's quoted tokens and confirms the match by calling
|
|
117
|
+
* the SAME builder and comparing strings — so a block is only ever "applied"
|
|
118
|
+
* when it is byte-for-byte what that builder would have produced.
|
|
119
|
+
*/
|
|
120
|
+
/** The `if ! out=$(<git command> 2>&1); then` head of an if/case/fi builder — everything from its opening up to the terminator, however many lines its quoted argument spans. */
|
|
121
|
+
const CONDITION_TERMINATOR = " 2>&1); then"
|
|
122
|
+
|
|
123
|
+
const conditionStatement = (block: string): string => {
|
|
124
|
+
const start = block.indexOf("if ! out=$(")
|
|
125
|
+
if (start === -1) return block
|
|
126
|
+
const end = block.indexOf(CONDITION_TERMINATOR, start)
|
|
127
|
+
return end === -1 ? block.slice(start) : block.slice(start, end)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// fallow-ignore-next-line complexity
|
|
131
|
+
const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
|
|
132
|
+
if (block === discardPending()) {
|
|
133
|
+
repo.discardPending()
|
|
134
|
+
return { kind: "applied" }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The two multi-line if/case/fi builders below re-quote their message past
|
|
138
|
+
// their opening `if ! out=$(...` statement — tokens are extracted from THAT
|
|
139
|
+
// statement only, or a quoted fragment further down would pollute the
|
|
140
|
+
// extracted args. Sliced by its `2>&1); then` terminator rather than taken
|
|
141
|
+
// as one LINE, because a commit message is routinely multi-line (every
|
|
142
|
+
// trailer-carrying subject is) and its later lines are part of the same
|
|
143
|
+
// quoted argument.
|
|
144
|
+
const conditionLine = conditionStatement(block)
|
|
145
|
+
|
|
146
|
+
if (block.startsWith("git add -A &&\n")) {
|
|
147
|
+
const [message] = extractQuotedTokens(conditionLine)
|
|
148
|
+
if (message !== undefined && commitAll(message) === block) {
|
|
149
|
+
repo.commitAllWithPrefix(message)
|
|
150
|
+
return { kind: "applied" }
|
|
151
|
+
}
|
|
152
|
+
return undefined
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (block.startsWith("if ! out=$(git commit --allow-empty -m ")) {
|
|
156
|
+
const [message] = extractQuotedTokens(conditionLine)
|
|
157
|
+
if (message !== undefined && commitAsIs(message) === block) {
|
|
158
|
+
repo.commitAsIs(message)
|
|
159
|
+
return { kind: "applied" }
|
|
160
|
+
}
|
|
161
|
+
return undefined
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (block.startsWith("git reset --soft ")) {
|
|
165
|
+
const [ref] = extractQuotedTokens(block)
|
|
166
|
+
if (ref !== undefined && softResetTo(ref) === block) {
|
|
167
|
+
repo.softResetTo(ref)
|
|
168
|
+
return { kind: "applied" }
|
|
169
|
+
}
|
|
170
|
+
return undefined
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (block.startsWith("git reset --mixed ")) {
|
|
174
|
+
const [ref] = extractQuotedTokens(block)
|
|
175
|
+
if (ref !== undefined && mixedResetTo(ref) === block) {
|
|
176
|
+
repo.mixedResetTo(ref)
|
|
177
|
+
return { kind: "applied" }
|
|
178
|
+
}
|
|
179
|
+
return undefined
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (block.startsWith("git reset --hard ")) {
|
|
183
|
+
const [ref] = extractQuotedTokens(block)
|
|
184
|
+
if (ref !== undefined && hardResetTo(ref) === block) {
|
|
185
|
+
repo.hardResetTo(ref)
|
|
186
|
+
return { kind: "applied" }
|
|
187
|
+
}
|
|
188
|
+
return undefined
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (block.startsWith("git update-ref -d ")) {
|
|
192
|
+
const [ref] = extractQuotedTokens(block)
|
|
193
|
+
if (ref !== undefined && deleteRef(ref) === block) {
|
|
194
|
+
repo.deleteRef(ref)
|
|
195
|
+
return { kind: "applied" }
|
|
196
|
+
}
|
|
197
|
+
return undefined
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (block.startsWith("git update-ref ")) {
|
|
201
|
+
const [ref, hash] = extractQuotedTokens(block)
|
|
202
|
+
if (ref !== undefined && hash !== undefined && updateRef(ref, hash) === block) {
|
|
203
|
+
repo.updateRef(ref, hash)
|
|
204
|
+
return { kind: "applied" }
|
|
205
|
+
}
|
|
206
|
+
return undefined
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return undefined
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The REAL `fileExistsGuard` block, re-derived from the extracted path and string-compared. The one recognizer that can report `{ kind: "stopped" }`: a real `exit 0` there ends the script successfully. */
|
|
213
|
+
const recognizeFileExistsGuard = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
|
|
214
|
+
const [file] = extractQuotedTokens(block)
|
|
215
|
+
if (file === undefined || fileExistsGuard(file) !== block) return undefined
|
|
216
|
+
return repo.readFile(file) !== undefined ? { kind: "noop" } : { kind: "stopped" }
|
|
217
|
+
}
|
|
218
|
+
|
|
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. */
|
|
220
|
+
const BINARY_GUARD_MESSAGE_RE = /^gtd: mode "([^"]+)": "(format|validate)" command not found: (.+)$/
|
|
221
|
+
|
|
222
|
+
const recognizeBinaryGuard = (block: string): BlockOutcome | undefined => {
|
|
223
|
+
const [binary, , message] = extractQuotedTokens(block)
|
|
224
|
+
if (binary === undefined || message === undefined) return undefined
|
|
225
|
+
const match = BINARY_GUARD_MESSAGE_RE.exec(message)
|
|
226
|
+
if (!match) return undefined
|
|
227
|
+
const [, mode, key, messageBinary] = match
|
|
228
|
+
if (messageBinary !== binary) return undefined
|
|
229
|
+
if (binaryGuard(binary, mode!, key as "format" | "validate") !== block) return undefined
|
|
230
|
+
return { kind: "noop" }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 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
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* `failurePromptWrapper`. The constants below bracket the template's one
|
|
239
|
+
* variable part; the extracted pair is confirmed by RE-RUNNING the real
|
|
240
|
+
* builder and string-comparing. On a failing inner outcome it prefixes the
|
|
241
|
+
* prompt onto the error — the fake's stand-in for what the real wrapper
|
|
242
|
+
* prints before exiting non-zero.
|
|
243
|
+
*/
|
|
244
|
+
const FILE_VAR_PREFIX_RE = /^export GTD_FILE=('(?:[^']|'\\'')*')\n/
|
|
245
|
+
|
|
246
|
+
/** The command after `withFileVar`'s `export GTD_FILE=…` line, `$GTD_FILE` expanded the way the shell would. */
|
|
247
|
+
const expandFileVar = (text: string): string | undefined => {
|
|
248
|
+
const match = FILE_VAR_PREFIX_RE.exec(text)
|
|
249
|
+
if (match === null) return undefined
|
|
250
|
+
const [file] = extractQuotedTokens(match[1]!)
|
|
251
|
+
if (file === undefined) return undefined
|
|
252
|
+
return text
|
|
253
|
+
.slice(match[0].length)
|
|
254
|
+
.replace(/"\$GTD_FILE"|"\$\{GTD_FILE\}"/g, shellQuote(file))
|
|
255
|
+
.replace(/\$\{GTD_FILE\}|\$GTD_FILE\b/g, file)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A mode's `format:`/`validate:` command: `gtd check`, or a scripted command. */
|
|
259
|
+
const recognizeModeCommand = (
|
|
260
|
+
repo: InMemRepo,
|
|
261
|
+
commands: ReadonlyMap<string, ScriptedCommand>,
|
|
262
|
+
command: string,
|
|
263
|
+
): BlockOutcome | undefined =>
|
|
264
|
+
recognizeGtdCheck(repo, command) ?? recognizeScriptedCommand(repo, commands, command)
|
|
265
|
+
|
|
266
|
+
const FAILURE_PROMPT_HEADER = 'gtd_validate_status=0\ngtd_validate_out="$( {\n'
|
|
267
|
+
const FAILURE_PROMPT_MIDDLE =
|
|
268
|
+
'\n} 2>&1 )" || gtd_validate_status=$?\n' +
|
|
269
|
+
'if [ "$gtd_validate_status" -ne 0 ]; then\n' +
|
|
270
|
+
" printf '%s\\n\\n%s\\n' "
|
|
271
|
+
const FAILURE_PROMPT_FOOTER = ' "$gtd_validate_out"\n exit "$gtd_validate_status"\nfi'
|
|
272
|
+
|
|
273
|
+
const recognizeFailurePromptWrapper = (
|
|
274
|
+
repo: InMemRepo,
|
|
275
|
+
commands: ReadonlyMap<string, ScriptedCommand>,
|
|
276
|
+
block: string,
|
|
277
|
+
): BlockOutcome | undefined => {
|
|
278
|
+
if (!block.startsWith(FAILURE_PROMPT_HEADER) || !block.endsWith(FAILURE_PROMPT_FOOTER)) {
|
|
279
|
+
return undefined
|
|
280
|
+
}
|
|
281
|
+
const middleIndex = block.indexOf(FAILURE_PROMPT_MIDDLE, FAILURE_PROMPT_HEADER.length)
|
|
282
|
+
if (middleIndex === -1) return undefined
|
|
283
|
+
|
|
284
|
+
const inner = block.slice(FAILURE_PROMPT_HEADER.length, middleIndex)
|
|
285
|
+
const promptQuoted = block.slice(
|
|
286
|
+
middleIndex + FAILURE_PROMPT_MIDDLE.length,
|
|
287
|
+
block.length - FAILURE_PROMPT_FOOTER.length,
|
|
288
|
+
)
|
|
289
|
+
const [prompt] = extractQuotedTokens(promptQuoted)
|
|
290
|
+
if (prompt === undefined || failurePromptWrapper(inner, prompt) !== block) return undefined
|
|
291
|
+
|
|
292
|
+
const innerOutcome = recognizeModeCommand(repo, commands, expandFileVar(inner) ?? inner)
|
|
293
|
+
if (innerOutcome === undefined) return undefined
|
|
294
|
+
if (innerOutcome.kind === "failed") {
|
|
295
|
+
return { kind: "failed", error: `${prompt}\n\n${innerOutcome.error}` }
|
|
296
|
+
}
|
|
297
|
+
return innerOutcome
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** `modeContradictionSkipNotice` — a mode whose validator is EXTERNAL. Re-derived from the mode name embedded in its own decoded message, keeping the "call the real builder, never hand-copy its template" discipline. */
|
|
301
|
+
const SKIP_NOTICE_MODE_RE = /mode "([^"]+)" has an external validate:/
|
|
302
|
+
|
|
303
|
+
const recognizeModeContradictionSkipNotice = (block: string): BlockOutcome | undefined => {
|
|
304
|
+
// Two quoted tokens: the literal `printf` format string `'%s\n'` first,
|
|
305
|
+
// then the message itself.
|
|
306
|
+
const [, message] = extractQuotedTokens(block)
|
|
307
|
+
if (message === undefined) return undefined
|
|
308
|
+
const match = SKIP_NOTICE_MODE_RE.exec(message)
|
|
309
|
+
if (!match) return undefined
|
|
310
|
+
const mode = match[1]!
|
|
311
|
+
if (modeContradictionSkipNotice(mode) !== block) return undefined
|
|
312
|
+
return { kind: "noop" }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Everything `parseModeContradictionCheck` recovers from a matched block — enough for `simulateModeContradictionCheck` to run it, with no further parsing. */
|
|
316
|
+
interface ParsedModeContradictionCheck {
|
|
317
|
+
readonly mode: string
|
|
318
|
+
readonly samplePath: string
|
|
319
|
+
readonly sample: string
|
|
320
|
+
readonly formatCommand: string
|
|
321
|
+
readonly format: SteeringFormat
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The printf line's own pieces — the sample bytes, the scratch path, and the literal PREFIX text (up to and including that path) — or `undefined` when `block` doesn't open with `printf '%s' ...`. */
|
|
325
|
+
const parsePrintfPrefix = (
|
|
326
|
+
block: string,
|
|
327
|
+
):
|
|
328
|
+
| { readonly sample: string; readonly samplePath: string; readonly prefix: string }
|
|
329
|
+
| undefined => {
|
|
330
|
+
if (!block.startsWith("printf '%s' ")) return undefined
|
|
331
|
+
// Three leading quoted tokens: the literal printf format string `'%s'`
|
|
332
|
+
// first, then the sample, then the scratch path.
|
|
333
|
+
const [, sample, samplePath] = extractQuotedTokens(block)
|
|
334
|
+
if (sample === undefined || samplePath === undefined) return undefined
|
|
335
|
+
const prefix = `printf '%s' ${shellQuote(sample)} > ${shellQuote(samplePath)}`
|
|
336
|
+
if (!block.startsWith(`${prefix}\n`)) return undefined
|
|
337
|
+
return { sample, samplePath, prefix }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** The mode name and `format:` command sandwiched between the printf `prefix` and the fixed `gtd check <mode> <pathQ> >/dev/null ...` line, or `undefined` when that line isn't there right after it. */
|
|
341
|
+
const parseModeAndFormatCommand = (
|
|
342
|
+
block: string,
|
|
343
|
+
prefix: string,
|
|
344
|
+
pathQ: string,
|
|
345
|
+
): { readonly mode: string; readonly formatCommand: string } | undefined => {
|
|
346
|
+
const suffix = ` ${pathQ} >/dev/null 2>&1 || {\n`
|
|
347
|
+
const suffixIndex = block.indexOf(suffix, prefix.length + 1)
|
|
348
|
+
if (suffixIndex === -1) return undefined
|
|
349
|
+
const lineStart = block.lastIndexOf("\ngtd check ", suffixIndex)
|
|
350
|
+
if (lineStart === -1) return undefined
|
|
351
|
+
const mode = block.slice(lineStart + "\ngtd check ".length, suffixIndex)
|
|
352
|
+
if (mode.length === 0 || /\s/.test(mode)) return undefined
|
|
353
|
+
return { mode, formatCommand: block.slice(prefix.length + 1, lineStart) }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Parses `block` back into the real builder's inputs. Split from the simulation so extraction (many guards, no effects) and simulation (few guards, all effects) stay separate. A wrong guess fails to parse rather than mis-simulating. */
|
|
357
|
+
const parseModeContradictionCheck = (block: string): ParsedModeContradictionCheck | undefined => {
|
|
358
|
+
const prefixParts = parsePrintfPrefix(block)
|
|
359
|
+
if (prefixParts === undefined) return undefined
|
|
360
|
+
const { sample, samplePath, prefix } = prefixParts
|
|
361
|
+
|
|
362
|
+
const modeParts = parseModeAndFormatCommand(block, prefix, shellQuote(samplePath))
|
|
363
|
+
if (modeParts === undefined) return undefined
|
|
364
|
+
const { mode, formatCommand } = modeParts
|
|
365
|
+
|
|
366
|
+
const format = steeringFormatFor(mode)
|
|
367
|
+
if (format === undefined) return undefined
|
|
368
|
+
if (buildModeContradictionCheck({ mode, samplePath, sample, formatCommand }) !== block) {
|
|
369
|
+
return undefined
|
|
370
|
+
}
|
|
371
|
+
return { mode, samplePath, sample, formatCommand, format }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* One parsed round-trip: write the sample, run its `format:` through the
|
|
376
|
+
* scripted-command table (real bash is unreachable in this tier), re-validate,
|
|
377
|
+
* and clean up on every path out, mirroring the real script's `rm -f` on both
|
|
378
|
+
* branches. An unscripted `format:` fails loudly rather than succeeding.
|
|
379
|
+
*/
|
|
380
|
+
const simulateModeContradictionCheck = (
|
|
381
|
+
repo: InMemRepo,
|
|
382
|
+
commands: ReadonlyMap<string, ScriptedCommand>,
|
|
383
|
+
parsed: ParsedModeContradictionCheck,
|
|
384
|
+
): BlockOutcome => {
|
|
385
|
+
const { mode, samplePath, sample, formatCommand, format } = parsed
|
|
386
|
+
repo.writeFile(samplePath, sample)
|
|
387
|
+
const command = expandFileVar(formatCommand) ?? formatCommand
|
|
388
|
+
const formatOutcome = recognizeScriptedCommand(repo, commands, command)
|
|
389
|
+
if (formatOutcome === undefined) {
|
|
390
|
+
repo.deleteFile(samplePath)
|
|
391
|
+
return {
|
|
392
|
+
kind: "failed",
|
|
393
|
+
error: `unscripted command "${command}" — declare it with a Given step`,
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (formatOutcome.kind === "failed") {
|
|
397
|
+
repo.deleteFile(samplePath)
|
|
398
|
+
return formatOutcome
|
|
399
|
+
}
|
|
400
|
+
const formatted = repo.readFile(samplePath) ?? sample
|
|
401
|
+
const findings = format.validate(formatted)
|
|
402
|
+
repo.deleteFile(samplePath)
|
|
403
|
+
if (findings.length === 0) return { kind: "noop" }
|
|
404
|
+
return {
|
|
405
|
+
kind: "failed",
|
|
406
|
+
error: `${contradictionMessage(mode, formatCommand)}\n${formatted}`,
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* `src/emit/ModeContradiction.ts`'s `buildModeContradictionCheck` — the
|
|
412
|
+
* contradiction round-trip block `resolveValidateScript` (`src/cli/Program.ts`)
|
|
413
|
+
* emits ahead of `fileExistsGuard` for a mode with a live built-in validator
|
|
414
|
+
* and a declared `format:`. Carries no blank line of its own, so it always
|
|
415
|
+
* arrives here as ONE block.
|
|
416
|
+
*/
|
|
417
|
+
const recognizeModeContradictionCheck = (
|
|
418
|
+
repo: InMemRepo,
|
|
419
|
+
commands: ReadonlyMap<string, ScriptedCommand>,
|
|
420
|
+
block: string,
|
|
421
|
+
): BlockOutcome | undefined => {
|
|
422
|
+
const parsed = parseModeContradictionCheck(block)
|
|
423
|
+
return parsed === undefined ? undefined : simulateModeContradictionCheck(repo, commands, parsed)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const GTD_CHECK_RE = /^gtd check (\S+) (.+)$/
|
|
427
|
+
|
|
428
|
+
/** `gtd check <mode> <file>` — a non-empty findings array fails the script, mirroring a real invocation's non-zero exit under `set -e`. */
|
|
429
|
+
const recognizeGtdCheck = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
|
|
430
|
+
const match = GTD_CHECK_RE.exec(block)
|
|
431
|
+
if (!match) return undefined
|
|
432
|
+
const mode = match[1]!
|
|
433
|
+
const [file] = extractQuotedTokens(match[2]!)
|
|
434
|
+
const format = steeringFormatFor(mode)
|
|
435
|
+
if (format === undefined || file === undefined) {
|
|
436
|
+
return { kind: "failed", error: `gtd check: no built-in steering format named "${mode}"` }
|
|
437
|
+
}
|
|
438
|
+
// An ABSENT file has nothing to check and exits 0 — `runCheckCommand`'s own
|
|
439
|
+
// documented behavior. Validating `""` instead would report every "missing
|
|
440
|
+
// header" finding the format has, turning "the reviewer deleted the file"
|
|
441
|
+
// (a case the review-doc guard owns) into a script failure.
|
|
442
|
+
const content = repo.readFile(file)
|
|
443
|
+
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("; ")}` }
|
|
450
|
+
}
|
|
451
|
+
return { kind: "noop" }
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const GTD_UNCHECK_RE = /^gtd uncheck (.+)$/
|
|
455
|
+
|
|
456
|
+
/** `gtd uncheck <file>` — the review-gate reset prepended ahead of the human's commit, so no tick ever reaches it. Re-runs the REAL `clearTicks`, writing back only on an actual change. */
|
|
457
|
+
const recognizeGtdUncheck = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
|
|
458
|
+
const match = GTD_UNCHECK_RE.exec(block)
|
|
459
|
+
if (!match) return undefined
|
|
460
|
+
const [file] = extractQuotedTokens(match[1]!)
|
|
461
|
+
if (file === undefined) return undefined
|
|
462
|
+
const content = repo.readFile(file)
|
|
463
|
+
if (content === undefined) return { kind: "noop" }
|
|
464
|
+
const format = steeringFormatFor("review")
|
|
465
|
+
const cleared = format === undefined ? content : format.clearTicks(content)
|
|
466
|
+
if (cleared !== content) repo.writeFile(file, cleared)
|
|
467
|
+
return { kind: "noop" }
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* `Emit.ts`'s `combinedScript` leading comment — `gtd land`/`gtd --workflow`'s
|
|
472
|
+
* whole plain-text artifact opens with this line ahead of the required
|
|
473
|
+
* script, so recognizing it as a no-op (like the retry-helper function
|
|
474
|
+
* definition) keeps the `@inmem` tier green.
|
|
475
|
+
*/
|
|
476
|
+
const recognizeDidNotRunComment = (block: string): BlockOutcome | undefined =>
|
|
477
|
+
block === DID_NOT_RUN_COMMENT ? { kind: "noop" } : undefined
|
|
478
|
+
|
|
479
|
+
/** `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. */
|
|
480
|
+
const PRESENTATION_SUBSHELL_PREFIX = `${PRESENTATION_ONLY_COMMENT}\n(\n`
|
|
481
|
+
const PRESENTATION_SUBSHELL_SUFFIX = `\n) || ${PRESENTATION_FAILURE_WARNING}`
|
|
482
|
+
|
|
483
|
+
const recognizePresentationSubshell = (
|
|
484
|
+
repo: InMemRepo,
|
|
485
|
+
commands: ReadonlyMap<string, ScriptedCommand>,
|
|
486
|
+
block: string,
|
|
487
|
+
): BlockOutcome | undefined => {
|
|
488
|
+
if (
|
|
489
|
+
!block.startsWith(PRESENTATION_SUBSHELL_PREFIX) ||
|
|
490
|
+
!block.endsWith(PRESENTATION_SUBSHELL_SUFFIX)
|
|
491
|
+
) {
|
|
492
|
+
return undefined
|
|
493
|
+
}
|
|
494
|
+
const inner = block.slice(
|
|
495
|
+
PRESENTATION_SUBSHELL_PREFIX.length,
|
|
496
|
+
block.length - PRESENTATION_SUBSHELL_SUFFIX.length,
|
|
497
|
+
)
|
|
498
|
+
applyEmittedScript(repo, commands, inner)
|
|
499
|
+
return { kind: "noop" }
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Anything left over must be an EXACT hit in the scripted-command table — mirrors `ScriptedCommand`'s two `kind`s (`Layers.ts`). */
|
|
503
|
+
const recognizeScriptedCommand = (
|
|
504
|
+
repo: InMemRepo,
|
|
505
|
+
commands: ReadonlyMap<string, ScriptedCommand>,
|
|
506
|
+
block: string,
|
|
507
|
+
): BlockOutcome | undefined => {
|
|
508
|
+
const scripted = commands.get(block)
|
|
509
|
+
if (scripted === undefined) return undefined
|
|
510
|
+
if (scripted.kind === "rewrite") {
|
|
511
|
+
repo.writeFile(scripted.file, scripted.content)
|
|
512
|
+
return { kind: "applied" }
|
|
513
|
+
}
|
|
514
|
+
if (scripted.status !== 0) {
|
|
515
|
+
return {
|
|
516
|
+
kind: "failed",
|
|
517
|
+
error: `scripted command "${block}" exited ${scripted.status}: ${scripted.output}`,
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return { kind: "noop" }
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Splits on blank lines, IGNORING those inside a single-quoted string: every
|
|
525
|
+
* trailer-carrying commit subject spans one, so a naive `split(/\n{2,}/)`
|
|
526
|
+
* tears a `commitAll` block into fragments that recognize as nothing.
|
|
527
|
+
*
|
|
528
|
+
* Quote depth toggles on every `'`, with one load-bearing correction: the `\`
|
|
529
|
+
* in `'\''` escapes its following `'` OUTSIDE any quote, so that middle quote
|
|
530
|
+
* must not toggle. Deliberately not a bash lexer — gtd's emission vocabulary
|
|
531
|
+
* is closed, and no double-quoted string in it ever contains a `'`.
|
|
532
|
+
*/
|
|
533
|
+
const trackQuoteState = (line: string, quoted: boolean): boolean => {
|
|
534
|
+
let inQuote = quoted
|
|
535
|
+
let index = 0
|
|
536
|
+
while (index < line.length) {
|
|
537
|
+
const char = line[index]
|
|
538
|
+
if (!inQuote && char === "\\") {
|
|
539
|
+
index += 2
|
|
540
|
+
continue
|
|
541
|
+
}
|
|
542
|
+
if (char === "'") inQuote = !inQuote
|
|
543
|
+
index += 1
|
|
544
|
+
}
|
|
545
|
+
return inQuote
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* The optional half is a bare `(\n...\n) || <warning>` whose inner script has
|
|
550
|
+
* its OWN blank-line-separated sections, so `splitBlocks` needs this depth to
|
|
551
|
+
* keep them from tearing it apart. Safe to key on a bare `(`/`)` line because
|
|
552
|
+
* no other shape in this closed vocabulary produces one.
|
|
553
|
+
*/
|
|
554
|
+
const nextSubshellDepth = (trimmedLine: string, depth: number): number => {
|
|
555
|
+
if (trimmedLine === "(") return depth + 1
|
|
556
|
+
if (depth > 0 && trimmedLine.startsWith(")")) return depth - 1
|
|
557
|
+
return depth
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const splitBlocks = (script: string): readonly string[] => {
|
|
561
|
+
const blocks: string[] = []
|
|
562
|
+
let current = ""
|
|
563
|
+
let quoted = false
|
|
564
|
+
let subshellDepth = 0
|
|
565
|
+
for (const line of script.trim().split("\n")) {
|
|
566
|
+
const trimmedLine = line.trim()
|
|
567
|
+
if (!quoted && subshellDepth === 0 && trimmedLine.length === 0) {
|
|
568
|
+
if (current.trim().length > 0) blocks.push(current.trim())
|
|
569
|
+
current = ""
|
|
570
|
+
continue
|
|
571
|
+
}
|
|
572
|
+
current += (current.length > 0 ? "\n" : "") + line
|
|
573
|
+
quoted = trackQuoteState(line, quoted)
|
|
574
|
+
if (!quoted) subshellDepth = nextSubshellDepth(trimmedLine, subshellDepth)
|
|
575
|
+
}
|
|
576
|
+
if (current.trim().length > 0) blocks.push(current.trim())
|
|
577
|
+
return blocks
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** 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
|
+
const recognizersFor = (
|
|
582
|
+
repo: InMemRepo,
|
|
583
|
+
commands: ReadonlyMap<string, ScriptedCommand>,
|
|
584
|
+
): ReadonlyArray<(block: string) => BlockOutcome | undefined> => [
|
|
585
|
+
(block) => recognizeSetFlags(block),
|
|
586
|
+
(block) => recognizeDidNotRunComment(block),
|
|
587
|
+
(block) => recognizePrecondition(repo, block),
|
|
588
|
+
(block) => recognizeFileExistsGuard(repo, block),
|
|
589
|
+
(block) => recognizeBinaryGuard(block),
|
|
590
|
+
(block) => recognizeGitBuilders(repo, block),
|
|
591
|
+
(block) => recognizeFailurePromptWrapper(repo, commands, block),
|
|
592
|
+
(block) => recognizePresentationSubshell(repo, commands, block),
|
|
593
|
+
(block) => recognizeModeContradictionSkipNotice(block),
|
|
594
|
+
(block) => recognizeModeContradictionCheck(repo, commands, block),
|
|
595
|
+
(block) => recognizeGtdCheck(repo, block),
|
|
596
|
+
(block) => recognizeGtdUncheck(repo, block),
|
|
597
|
+
(block) => {
|
|
598
|
+
const command = expandFileVar(block)
|
|
599
|
+
return command === undefined ? undefined : recognizeModeCommand(repo, commands, command)
|
|
600
|
+
},
|
|
601
|
+
(block) => recognizeOutcome(block),
|
|
602
|
+
(block) => recognizeScriptedCommand(repo, commands, block),
|
|
603
|
+
]
|
|
604
|
+
|
|
605
|
+
const recognizeBlock = (
|
|
606
|
+
recognizers: ReadonlyArray<(block: string) => BlockOutcome | undefined>,
|
|
607
|
+
block: string,
|
|
608
|
+
): BlockOutcome | undefined => {
|
|
609
|
+
for (const recognize of recognizers) {
|
|
610
|
+
const outcome = recognize(block)
|
|
611
|
+
if (outcome !== undefined) return outcome
|
|
612
|
+
}
|
|
613
|
+
return undefined
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Applies `script` to `repo` block by block, stopping at the first failure —
|
|
618
|
+
* an unrecognized block, a tripped precondition, a failing `gtd check`, or a
|
|
619
|
+
* non-zero scripted-command exit — exactly like `set -euo pipefail` stops a
|
|
620
|
+
* real script. No block after the failing one is applied.
|
|
621
|
+
*/
|
|
622
|
+
export const applyEmittedScript = (
|
|
623
|
+
repo: InMemRepo,
|
|
624
|
+
commands: ReadonlyMap<string, ScriptedCommand>,
|
|
625
|
+
script: string,
|
|
626
|
+
): AppliedScriptResult => {
|
|
627
|
+
const recognizers = recognizersFor(repo, commands)
|
|
628
|
+
|
|
629
|
+
for (const block of splitBlocks(script)) {
|
|
630
|
+
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
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return { ok: true }
|
|
642
|
+
}
|