@pmelab/gtd 59.0.0 → 60.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +42 -38
- package/claude/hooks/doors.ts +8 -4
- package/dist/chunks/flows-DENt0uWV.mjs +707 -0
- package/dist/chunks/{steering-CCNvngSH.mjs → steering-DF_BMadH.mjs} +292 -289
- package/dist/chunks/{testkit-CMBAAyRK.mjs → testkit-BvecVjQ0.mjs} +937 -772
- package/dist/chunks/{wire-C12dahrH.mjs → wire-DZ8R1RU9.mjs} +9 -2
- package/dist/flows.mjs +2 -676
- package/dist/gtd.bundle.mjs +1101 -1025
- package/dist/steering.mjs +2 -2
- package/dist/testkit/gherkin.mjs +371 -82
- package/dist/testkit.mjs +3 -2
- package/dist/web/main.js +234 -232
- package/dist/wire.mjs +2 -2
- package/dist/workflow-kit.mjs +3 -3
- package/package.json +1 -1
- package/skills/authoring/SKILL.md +68 -61
- package/src/cli/Cli.test.ts +311 -229
- package/src/cli/Cli.ts +486 -585
- package/src/cli/Help.test.ts +47 -0
- package/src/cli/Help.ts +70 -0
- package/src/cli/History.ts +9 -4
- package/src/cli/Judges.ts +1 -1
- package/src/cli/Output.test.ts +35 -0
- package/src/cli/Output.ts +31 -1
- package/src/cli/Program.test.ts +131 -82
- package/src/cli/Program.ts +197 -111
- package/src/edge/Edge.test.ts +64 -56
- package/src/edge/Edge.ts +4 -4
- package/src/emit/Emit.test.ts +1 -1
- package/src/emit/Emit.ts +1 -1
- package/src/flows/factory.test.ts +14 -0
- package/src/flows/factory.ts +25 -6
- package/src/flows/helpers.test.ts +35 -0
- package/src/flows/helpers.ts +19 -10
- package/src/flows/index.test.ts +66 -0
- package/src/flows/index.ts +109 -5
- package/src/flows/internal.ts +7 -0
- package/src/flows/runtime.test.ts +192 -1
- package/src/flows/runtime.ts +188 -54
- package/src/gherkin/git-control.feature +6 -6
- package/src/gherkin/index.test.ts +127 -0
- package/src/gherkin/index.ts +481 -98
- package/src/gherkin/messages.test.ts +124 -0
- package/src/gherkin/messages.ts +101 -0
- package/src/gherkin/more-steps.feature +170 -0
- package/src/gherkin/scenario-end.feature +101 -0
- package/src/gherkin/testkit.feature +83 -7
- package/src/install/Install.test.ts +7 -7
- package/src/install/Install.ts +8 -8
- package/src/replay/Replay.test.ts +13 -2
- package/src/replay/Replay.ts +19 -30
- package/src/replay/judgeDiffShape.test.ts +1 -1
- package/src/steering/Footnotes.test.ts +1 -1
- package/src/steering/SteeringFormats.test.ts +1 -1
- package/src/steering/freeform.test.ts +1 -1
- package/src/steering/index.test.ts +27 -1
- package/src/steering/index.ts +6 -104
- package/src/steering/internal.ts +11 -0
- package/src/steering/qa.test.ts +7 -2
- package/src/steering/reads.test.ts +42 -0
- package/src/steering/reads.ts +43 -0
- package/src/steering/registry.ts +93 -0
- package/src/steering/review.test.ts +2 -2
- package/src/step/planStart.test.ts +18 -16
- package/src/step/planStart.ts +9 -9
- package/src/testkit/TestRepo.test.ts +289 -6
- package/src/testkit/TestRepo.ts +317 -41
- package/src/testkit/imports.test.ts +58 -0
- package/src/testkit/imports.ts +102 -0
- package/src/testkit/index.test.ts +28 -0
- package/src/testkit/index.ts +14 -2
- package/src/twins/EmittedScriptRecognizer.ts +7 -14
- package/src/twins/EmittedScripts.test.ts +4 -2
- package/src/twins/InMemRepo.ts +1 -0
- package/src/wire/Land.ts +1 -1
- package/src/wire/Script.ts +11 -0
- package/src/wire/WireSchema.test.ts +3 -0
- package/src/wire/golden/1/script.json +4 -0
- package/src/wire/index.ts +1 -0
- package/src/workflow/SteeringFormats.test.ts +1 -1
- package/src/workflow/compile.ts +2 -2
- package/src/workflow/discovery.test.ts +3 -3
- package/src/workflow/load.test.ts +256 -116
- package/src/workflow/load.ts +175 -152
- package/src/workflow/skills.test.ts +5 -5
- package/src/workflow/skills.ts +1 -1
- package/src/workflow-kit/index.test.ts +25 -0
- package/src/workflow-kit/index.ts +37 -4
- package/src/workflow-kit/review.ts +2 -2
package/src/testkit/TestRepo.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type CapturedCliResult,
|
|
11
11
|
type CommandTable,
|
|
12
12
|
} from "../twins/index.js"
|
|
13
|
+
import { importsFrom } from "./imports.js"
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* The repository's `gtd.config.ts`: its source, or the module itself. A module
|
|
@@ -40,26 +41,81 @@ export interface TestRepoOptions {
|
|
|
40
41
|
readonly files?: Readonly<Record<string, string>>
|
|
41
42
|
/** The environment gtd sees; the real `process.env` is never read, except ambient `GTD_FLAG_*` (explicit env wins). */
|
|
42
43
|
readonly env?: Readonly<Record<string, string>>
|
|
44
|
+
/**
|
|
45
|
+
* Modules a config given as source imports by specifier, e.g.
|
|
46
|
+
* `{ "#workflow": await import("./index.ts") }`: its `import`/`export … from
|
|
47
|
+
* "#workflow"` reads the module the test imported.
|
|
48
|
+
*/
|
|
49
|
+
readonly modules?: Readonly<Record<string, Readonly<Record<string, unknown>>>>
|
|
50
|
+
/** `false` keeps the runner's `GTD_FLAG_*` out too, so gtd sees `env` alone. Defaults to `true`. */
|
|
51
|
+
readonly ambientFlags?: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One answer of a {@link Verdict}. */
|
|
55
|
+
export interface VerdictEntry {
|
|
56
|
+
readonly id: string
|
|
57
|
+
readonly answer: boolean | string | number
|
|
58
|
+
readonly p: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A judge step's verdict, as {@link verdict} builds it; an actor may return it in place of a JSON string. */
|
|
62
|
+
export interface Verdict {
|
|
63
|
+
readonly answers: readonly VerdictEntry[]
|
|
43
64
|
}
|
|
44
65
|
|
|
45
66
|
/** What a scripted actor does at the step it is registered for, before the beat lands. */
|
|
46
|
-
export type
|
|
67
|
+
export type ScriptedActor = (
|
|
68
|
+
beat: BeatDocument,
|
|
69
|
+
repo: TestRepo,
|
|
70
|
+
) => void | string | Verdict | Promise<void | string | Verdict>
|
|
47
71
|
|
|
48
72
|
/**
|
|
49
73
|
* Scripted actors, keyed by full step name. At a judge step the actor returns
|
|
50
74
|
* the verdict, which is answered through `gtd judge answer`; anywhere else it
|
|
51
75
|
* edits the tree and the beat is landed.
|
|
52
76
|
*/
|
|
53
|
-
export type Actors = Readonly<Record<string,
|
|
77
|
+
export type Actors = Readonly<Record<string, ScriptedActor>>
|
|
54
78
|
|
|
55
79
|
export interface DriveOptions {
|
|
56
80
|
/** Fail instead of looping forever on a workflow that never rests. */
|
|
57
81
|
readonly maxBeats?: number
|
|
82
|
+
/**
|
|
83
|
+
* Defaults to `true`: fail on an actor whose step no drive of this
|
|
84
|
+
* repository has reached, a check step without an actor, a human step whose
|
|
85
|
+
* actor changes nothing (the drive would repeat it), and an actor returning a
|
|
86
|
+
* verdict anywhere but at a judge step. `false` lets each of them pass.
|
|
87
|
+
*/
|
|
88
|
+
readonly strict?: boolean
|
|
89
|
+
/**
|
|
90
|
+
* `"pass"`: a strict drive skips only its check for actors no drive has
|
|
91
|
+
* reached, for a harness that checks that once at its own end, as the
|
|
92
|
+
* gherkin world does at scenario end.
|
|
93
|
+
*/
|
|
94
|
+
readonly unreachedActors?: "fail" | "pass"
|
|
95
|
+
/** `"run"`: a check step without an actor runs its own script, as `runsItsScript` would. */
|
|
96
|
+
readonly checks?: "run"
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A commit, as `TestRepo.commits` lists it. */
|
|
100
|
+
export interface TestCommit {
|
|
101
|
+
readonly hash: string
|
|
102
|
+
readonly subject: string
|
|
103
|
+
/** The full message: subject, body and trailers. */
|
|
104
|
+
readonly message: string
|
|
105
|
+
/** The trailers of the message's last paragraph, each key's values in order. */
|
|
106
|
+
readonly trailers: Readonly<Record<string, readonly string[]>>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface RunScriptOptions {
|
|
110
|
+
/** Tried before the repository's own canned commands, for this run only. */
|
|
111
|
+
readonly commands?: readonly CannedCommand[]
|
|
58
112
|
}
|
|
59
113
|
|
|
60
114
|
export interface TestRepo {
|
|
61
115
|
/** Adds a command a step's script may run, after those `testRepo` was given. */
|
|
62
116
|
command(command: CannedCommand): void
|
|
117
|
+
/** Sets (or, with `undefined`, unsets) a variable gtd sees from the next call on. */
|
|
118
|
+
setEnv(name: string, value: string | undefined): void
|
|
63
119
|
/** Creates branch `name` at `ref` (HEAD by default). Outside a process only. */
|
|
64
120
|
branch(name: string, ref?: string): void
|
|
65
121
|
/** Checks branch `name` out; refuses with uncommitted changes. Outside a process only. */
|
|
@@ -79,12 +135,12 @@ export interface TestRepo {
|
|
|
79
135
|
* script helpers emit are understood; anything else (a test suite, a git
|
|
80
136
|
* write) throws, and the step needs an actor that stands in for it.
|
|
81
137
|
*/
|
|
82
|
-
runScript(script: string): void
|
|
138
|
+
runScript(script: string, options?: RunScriptOptions): void
|
|
83
139
|
/** One in-process invocation; nothing it prints is run. */
|
|
84
140
|
gtd(...args: string[]): Promise<CapturedCliResult>
|
|
85
|
-
/** `gtd
|
|
86
|
-
start(
|
|
87
|
-
/**
|
|
141
|
+
/** `gtd start <name> [...args]`, its script applied: a door of that name, or else the workflow. */
|
|
142
|
+
start(name: string, ...args: string[]): Promise<CapturedCliResult>
|
|
143
|
+
/** The same as {@link TestRepo.start}. */
|
|
88
144
|
door(name: string, ...args: string[]): Promise<CapturedCliResult>
|
|
89
145
|
land(): Promise<CapturedCliResult>
|
|
90
146
|
next(): Promise<BeatDocument>
|
|
@@ -102,6 +158,8 @@ export interface TestRepo {
|
|
|
102
158
|
messages(): readonly string[]
|
|
103
159
|
/** Every commit's full hash, oldest first. */
|
|
104
160
|
hashes(): readonly string[]
|
|
161
|
+
/** Every commit, oldest first. */
|
|
162
|
+
commits(): readonly TestCommit[]
|
|
105
163
|
}
|
|
106
164
|
|
|
107
165
|
const REGISTRY = Symbol.for("@pmelab/gtd/testkit:modules")
|
|
@@ -114,11 +172,12 @@ const registry = (): Registry => {
|
|
|
114
172
|
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/
|
|
115
173
|
let registered = 0
|
|
116
174
|
|
|
175
|
+
const lookup = (key: string): string =>
|
|
176
|
+
`globalThis[Symbol.for(${JSON.stringify(REGISTRY.description)})].get(${JSON.stringify(key)})`
|
|
177
|
+
|
|
117
178
|
/** A config whose exports are module `id`'s, read off the registry when the engine loads it. */
|
|
118
179
|
const moduleSource = (id: string, module: Readonly<Record<string, unknown>>): string => {
|
|
119
|
-
const lines = [
|
|
120
|
-
`const m = globalThis[Symbol.for(${JSON.stringify(REGISTRY.description)})].get(${JSON.stringify(id)})`,
|
|
121
|
-
]
|
|
180
|
+
const lines = [`const m = ${lookup(id)}`]
|
|
122
181
|
for (const name of Object.keys(module)) {
|
|
123
182
|
if (name === "default") lines.push(`export default m.default`)
|
|
124
183
|
else if (IDENTIFIER.test(name)) lines.push(`export const ${name} = m[${JSON.stringify(name)}]`)
|
|
@@ -128,7 +187,71 @@ const moduleSource = (id: string, module: Readonly<Record<string, unknown>>): st
|
|
|
128
187
|
}
|
|
129
188
|
|
|
130
189
|
/** An actor that runs the step's own script, for steps whose script `runScript` understands. */
|
|
131
|
-
export const runsItsScript:
|
|
190
|
+
export const runsItsScript: ScriptedActor = (beat, repo) => repo.runScript(beat.content)
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A judge's verdict, keyed by question id: `verdict({ ready: true })`, or with
|
|
194
|
+
* a confidence, `verdict({ ready: { answer: true, p: 0.6 } })`. `p` defaults to 0.9.
|
|
195
|
+
*/
|
|
196
|
+
export const verdict = (
|
|
197
|
+
answers: Readonly<
|
|
198
|
+
Record<
|
|
199
|
+
string,
|
|
200
|
+
VerdictEntry["answer"] | { readonly answer: VerdictEntry["answer"]; readonly p?: number }
|
|
201
|
+
>
|
|
202
|
+
>,
|
|
203
|
+
): Verdict => ({
|
|
204
|
+
answers: Object.entries(answers).map(([id, entry]) =>
|
|
205
|
+
typeof entry === "object"
|
|
206
|
+
? { id, answer: entry.answer, p: entry.p ?? 0.9 }
|
|
207
|
+
: { id, answer: entry, p: 0.9 },
|
|
208
|
+
),
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
/** `actor`, at a visit whose beat content contains `text`; any other visit changes nothing. */
|
|
212
|
+
export const whenPrompted =
|
|
213
|
+
(text: string, actor: ScriptedActor): ScriptedActor =>
|
|
214
|
+
(beat, repo) =>
|
|
215
|
+
beat.content.includes(text) ? actor(beat, repo) : undefined
|
|
216
|
+
|
|
217
|
+
/** Each actor in order, at every visit; the first verdict any of them returns is the result. */
|
|
218
|
+
export const inTurn =
|
|
219
|
+
(actors: readonly ScriptedActor[]): ScriptedActor =>
|
|
220
|
+
async (beat, repo) => {
|
|
221
|
+
let result: string | Verdict | undefined
|
|
222
|
+
for (const actor of actors) {
|
|
223
|
+
const returned = await actor(beat, repo)
|
|
224
|
+
if (result === undefined && returned !== undefined) result = returned as string | Verdict
|
|
225
|
+
}
|
|
226
|
+
return result
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** At a judge step, `answer` when the pending questions (as `gtd judge` prints them) contain `text`. */
|
|
230
|
+
export const verdictWhen =
|
|
231
|
+
(text: string, answer: string | Verdict): ScriptedActor =>
|
|
232
|
+
async (_, repo) => {
|
|
233
|
+
const { stdout } = await repo.gtd("judge")
|
|
234
|
+
return stdout.includes(text) ? answer : undefined
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const isVerdict = (value: unknown): value is Verdict =>
|
|
238
|
+
typeof value === "object" &&
|
|
239
|
+
value !== null &&
|
|
240
|
+
Array.isArray((value as { answers?: unknown }).answers)
|
|
241
|
+
|
|
242
|
+
const TRAILER = /^([A-Za-z0-9][A-Za-z0-9-]*): ?(.*)$/
|
|
243
|
+
|
|
244
|
+
const trailersOf = (message: string): Record<string, string[]> => {
|
|
245
|
+
const paragraphs = message.trimEnd().split(/\n\s*\n/)
|
|
246
|
+
const last = paragraphs.length > 1 ? paragraphs.at(-1)!.split("\n") : []
|
|
247
|
+
const trailers: Record<string, string[]> = {}
|
|
248
|
+
if (last.length === 0 || !last.every((line) => TRAILER.test(line))) return trailers
|
|
249
|
+
for (const line of last) {
|
|
250
|
+
const [, key, value] = TRAILER.exec(line)!
|
|
251
|
+
;(trailers[key!] ??= []).push(value!)
|
|
252
|
+
}
|
|
253
|
+
return trailers
|
|
254
|
+
}
|
|
132
255
|
|
|
133
256
|
const words = (text: string): string[] =>
|
|
134
257
|
[...text.matchAll(/'((?:[^']|'\\'')*)'/g)].map((m) => m[1]!.replaceAll("'\\''", "'"))
|
|
@@ -204,39 +327,136 @@ const runLines = (repo: InMemRepo, commands: CommandTable, script: string): void
|
|
|
204
327
|
const failed = (what: string, result: CapturedCliResult): Error =>
|
|
205
328
|
new Error(`${what} exited ${result.exitCode}\n${result.stderr}${result.stdout}`)
|
|
206
329
|
|
|
330
|
+
const ANSWER: Readonly<Record<string, string>> = {
|
|
331
|
+
noul: "true",
|
|
332
|
+
choice: '"<option>"',
|
|
333
|
+
score: "0.5",
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The verdict a judge beat's questions take, as a template to copy. */
|
|
337
|
+
const expectedVerdict = (doc: BeatDocument): string => {
|
|
338
|
+
let questions: readonly { id: string; primitive: string }[] = []
|
|
339
|
+
try {
|
|
340
|
+
questions = (JSON.parse(doc.judge ?? "") as { questions: typeof questions }).questions
|
|
341
|
+
} catch {
|
|
342
|
+
// An unreadable judge document leaves the template without entries.
|
|
343
|
+
}
|
|
344
|
+
const entries = questions.map(
|
|
345
|
+
({ id, primitive }) =>
|
|
346
|
+
`{ "id": ${JSON.stringify(id)}, "answer": ${ANSWER[primitive] ?? '"<answer>"'}, "p": 0.9 }`,
|
|
347
|
+
)
|
|
348
|
+
return [
|
|
349
|
+
"expected a JSON array, one entry per question — answer a boolean for a noul, a string for a choice, a number for a score; p between 0 and 1:",
|
|
350
|
+
`[${entries.join(", ")}]`,
|
|
351
|
+
].join("\n")
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const quoted = (names: readonly string[]): string => names.map((name) => `"${name}"`).join(", ")
|
|
355
|
+
|
|
356
|
+
/** Who acts at `doc`: its scripted actor, or a check's own script under `checks: "run"`. A stall has none. */
|
|
357
|
+
const actorAt = (
|
|
358
|
+
doc: BeatDocument,
|
|
359
|
+
actors: Actors,
|
|
360
|
+
checks: DriveOptions["checks"],
|
|
361
|
+
): { readonly scripted: boolean; readonly actor: ScriptedActor | undefined } => {
|
|
362
|
+
if (doc.kind === "stalled") return { scripted: false, actor: undefined }
|
|
363
|
+
if (Object.hasOwn(actors, doc.state)) return { scripted: true, actor: actors[doc.state] }
|
|
364
|
+
const runs = checks === "run" && doc.actor === "check"
|
|
365
|
+
return { scripted: false, actor: runs ? runsItsScript : undefined }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** A strict drive's rest: refused at an unscripted check step, or with an actor no drive reached. */
|
|
369
|
+
const strictRest = (
|
|
370
|
+
doc: BeatDocument,
|
|
371
|
+
actors: Actors,
|
|
372
|
+
reached: ReadonlySet<string> | undefined,
|
|
373
|
+
): BeatDocument => {
|
|
374
|
+
if (doc.kind !== "stalled" && doc.actor === "check") {
|
|
375
|
+
throw new Error(
|
|
376
|
+
`strict drive: check step "${doc.state}" has no actor — script it, or drive with checks: "run"`,
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
const never =
|
|
380
|
+
reached === undefined ? [] : Object.keys(actors).filter((step) => !reached.has(step))
|
|
381
|
+
if (never.length > 0) {
|
|
382
|
+
throw new Error(
|
|
383
|
+
`strict drive: the process rests at "${doc.state}", and never reached the actors for ${quoted(never)} — script a step only for the drive that reaches it`,
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
return doc
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const refuseVerdict = (doc: BeatDocument, returned: unknown): void => {
|
|
390
|
+
if (doc.actor !== "judge" && returned !== undefined) {
|
|
391
|
+
throw new Error(
|
|
392
|
+
`strict drive: the actor for ${doc.actor} step "${doc.state}" returned a verdict; only a judge step takes one`,
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const refuseNothingLanded = (doc: BeatDocument, nothing: boolean): void => {
|
|
398
|
+
if (doc.actor === "human" && nothing) {
|
|
399
|
+
throw new Error(
|
|
400
|
+
`strict drive: the actor for human step "${doc.state}" changed nothing, so nothing landed and the drive would repeat the step`,
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
207
405
|
export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
208
406
|
const repo = new InMemRepo()
|
|
209
|
-
const env = { ...options.env }
|
|
407
|
+
const env: Record<string, string | undefined> = { ...options.env }
|
|
210
408
|
const { workflow } = options
|
|
211
409
|
const id = `module-${++registered}`
|
|
410
|
+
const modules = Object.entries(options.modules ?? {})
|
|
411
|
+
const moduleKey = (specifier: string): string => `${id}:${specifier}`
|
|
212
412
|
repo.writeFile(
|
|
213
413
|
"gtd.config.ts",
|
|
214
|
-
typeof workflow
|
|
414
|
+
typeof workflow !== "string"
|
|
415
|
+
? moduleSource(id, workflow)
|
|
416
|
+
: modules.length === 0
|
|
417
|
+
? workflow
|
|
418
|
+
: importsFrom(workflow, options.modules!, (specifier) => lookup(moduleKey(specifier))),
|
|
215
419
|
)
|
|
216
420
|
for (const [path, content] of Object.entries(options.files ?? {})) repo.writeFile(path, content)
|
|
217
421
|
repo.seedCommit("chore: initial commit")
|
|
218
422
|
|
|
219
423
|
const canned = [...(options.commands ?? [])]
|
|
220
|
-
const
|
|
424
|
+
const table = (first: readonly CannedCommand[] = []): CommandTable => ({
|
|
221
425
|
get: (command) => {
|
|
222
|
-
const hit = canned.find(({ run }) =>
|
|
426
|
+
const hit = [...first, ...canned].find(({ run }) =>
|
|
223
427
|
typeof run === "string" ? run === command : run.test(command),
|
|
224
428
|
)
|
|
225
429
|
if (hit === undefined) return undefined
|
|
226
430
|
const { exit = 0, output = "", effect } = hit
|
|
227
431
|
return { kind: "exit", status: exit, output, effect: () => effect?.(self) }
|
|
228
432
|
},
|
|
229
|
-
}
|
|
433
|
+
})
|
|
434
|
+
const commands = table()
|
|
435
|
+
|
|
436
|
+
// An `undefined` entry masks the ambient flag the in-memory host would add.
|
|
437
|
+
const seenEnv = (): Readonly<Record<string, string | undefined>> =>
|
|
438
|
+
options.ambientFlags === false
|
|
439
|
+
? {
|
|
440
|
+
...Object.fromEntries(
|
|
441
|
+
Object.keys(process.env)
|
|
442
|
+
.filter((name) => name.startsWith("GTD_FLAG_"))
|
|
443
|
+
.map((name) => [name, undefined]),
|
|
444
|
+
),
|
|
445
|
+
...env,
|
|
446
|
+
}
|
|
447
|
+
: env
|
|
230
448
|
|
|
231
|
-
// The
|
|
232
|
-
// call that loads
|
|
449
|
+
// The modules are registered only while gtd runs, so nothing outlives the
|
|
450
|
+
// call that loads them and no test can reach another's module.
|
|
233
451
|
const gtd = async (...args: string[]): Promise<CapturedCliResult> => {
|
|
234
|
-
const { io, result } = makeCapturingCliIo(repo,
|
|
452
|
+
const { io, result } = makeCapturingCliIo(repo, seenEnv())
|
|
235
453
|
if (typeof workflow !== "string") registry().set(id, workflow)
|
|
454
|
+
for (const [specifier, module] of modules) registry().set(moduleKey(specifier), module)
|
|
236
455
|
try {
|
|
237
456
|
await Effect.runPromise(runCli(["node", "gtd", ...args], io))
|
|
238
457
|
} finally {
|
|
239
458
|
registry().delete(id)
|
|
459
|
+
for (const [specifier] of modules) registry().delete(moduleKey(specifier))
|
|
240
460
|
}
|
|
241
461
|
return result()
|
|
242
462
|
}
|
|
@@ -261,17 +481,21 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
261
481
|
}
|
|
262
482
|
|
|
263
483
|
/** Runs a write command, then the script its `--json=script` twin prints. */
|
|
264
|
-
const runWrite = async (
|
|
484
|
+
const runWrite = async (
|
|
485
|
+
args: readonly string[],
|
|
486
|
+
stdin?: string,
|
|
487
|
+
refused: (error: Error) => Error = (error) => error,
|
|
488
|
+
): Promise<CapturedCliResult> => {
|
|
265
489
|
const call = (...extra: string[]) =>
|
|
266
490
|
stdin === undefined ? gtd(...args, ...extra) : withStdin(stdin, () => gtd(...args, ...extra))
|
|
267
491
|
const reported = await call()
|
|
268
|
-
if (reported.exitCode !== EXIT_OK) throw failed(`gtd ${args.join(" ")}`, reported)
|
|
492
|
+
if (reported.exitCode !== EXIT_OK) throw refused(failed(`gtd ${args.join(" ")}`, reported))
|
|
269
493
|
const script = await call("--json=script")
|
|
270
494
|
apply(`gtd ${args.join(" ")}`, script.stdout)
|
|
271
495
|
return reported
|
|
272
496
|
}
|
|
273
497
|
|
|
274
|
-
// A start prints its script plainly
|
|
498
|
+
// A start prints its script plainly.
|
|
275
499
|
const begin = async (args: readonly string[]): Promise<CapturedCliResult> => {
|
|
276
500
|
const result = await gtd(...args)
|
|
277
501
|
if (result.exitCode !== EXIT_OK) throw failed(`gtd ${args.join(" ")}`, result)
|
|
@@ -286,22 +510,67 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
286
510
|
}
|
|
287
511
|
|
|
288
512
|
/** Lands what an actor did: a judge's verdict answered, anything else validated and landed. */
|
|
289
|
-
const act = async (doc: BeatDocument,
|
|
513
|
+
const act = async (doc: BeatDocument, returned: unknown): Promise<void> => {
|
|
290
514
|
if (doc.actor !== "judge") {
|
|
291
515
|
if (doc.validate !== undefined) apply(`validating ${doc.file ?? doc.state}`, doc.validate)
|
|
292
516
|
await runWrite(["land"])
|
|
293
517
|
return
|
|
294
518
|
}
|
|
295
|
-
|
|
296
|
-
|
|
519
|
+
const actor = `the actor for judge step "${doc.state}"`
|
|
520
|
+
if (returned === undefined) {
|
|
521
|
+
throw new Error(`${actor} returned no verdict\n${expectedVerdict(doc)}`)
|
|
522
|
+
}
|
|
523
|
+
const sent = isVerdict(returned) ? JSON.stringify(returned.answers) : returned
|
|
524
|
+
if (typeof sent !== "string") {
|
|
525
|
+
throw new Error(
|
|
526
|
+
`${actor} returned ${Array.isArray(sent) ? "an array" : typeof sent === "object" ? "an object" : `a ${typeof sent}`}, not a JSON string — JSON.stringify it\n${expectedVerdict(doc)}`,
|
|
527
|
+
)
|
|
297
528
|
}
|
|
298
|
-
await runWrite(
|
|
529
|
+
await runWrite(
|
|
530
|
+
["judge", "answer"],
|
|
531
|
+
sent,
|
|
532
|
+
(error) =>
|
|
533
|
+
new Error(
|
|
534
|
+
`${actor} sent a verdict gtd refused:\n${sent}\n${expectedVerdict(doc)}\n${error.message}`,
|
|
535
|
+
),
|
|
536
|
+
)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Across drives, so a later drive need not revisit a step an earlier one passed.
|
|
540
|
+
const reached = new Set<string>()
|
|
541
|
+
|
|
542
|
+
/** One beat of a drive: the beat it rests at, or `undefined` to read the next. */
|
|
543
|
+
const beat = async (
|
|
544
|
+
doc: BeatDocument,
|
|
545
|
+
actors: Actors,
|
|
546
|
+
{ strict = true, checks, unreachedActors = "fail" }: DriveOptions,
|
|
547
|
+
): Promise<BeatDocument | undefined> => {
|
|
548
|
+
if (doc.kind === "capture") {
|
|
549
|
+
await runWrite(["land"])
|
|
550
|
+
return undefined
|
|
551
|
+
}
|
|
552
|
+
const { scripted, actor } = actorAt(doc, actors, checks)
|
|
553
|
+
if (actor === undefined)
|
|
554
|
+
return strict
|
|
555
|
+
? strictRest(doc, actors, unreachedActors === "fail" ? reached : undefined)
|
|
556
|
+
: doc
|
|
557
|
+
if (scripted) reached.add(doc.state)
|
|
558
|
+
const returned = await actor(doc, self)
|
|
559
|
+
if (strict) refuseVerdict(doc, returned)
|
|
560
|
+
const before = repo.commitHistory().length
|
|
561
|
+
await act(doc, returned)
|
|
562
|
+
if (strict) refuseNothingLanded(doc, repo.commitHistory().length === before)
|
|
563
|
+
return undefined
|
|
299
564
|
}
|
|
300
565
|
|
|
301
566
|
const self: TestRepo = {
|
|
302
567
|
command: (command) => {
|
|
303
568
|
canned.push(command)
|
|
304
569
|
},
|
|
570
|
+
setEnv: (name, value) => {
|
|
571
|
+
if (value === undefined) delete env[name]
|
|
572
|
+
else env[name] = value
|
|
573
|
+
},
|
|
305
574
|
branch: (name, ref = "HEAD") => repo.createBranch(name, ref),
|
|
306
575
|
checkout: (name) => repo.checkoutBranch(name),
|
|
307
576
|
ref: (name, ref = "HEAD") => {
|
|
@@ -316,33 +585,40 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
316
585
|
remove: (path) => repo.deleteFile(path),
|
|
317
586
|
commit: (message) => repo.seedCommit(message),
|
|
318
587
|
show: (ref, path) => repo.fileAtRef(ref, path) ?? undefined,
|
|
319
|
-
runScript: (script) => {
|
|
320
|
-
const
|
|
321
|
-
|
|
588
|
+
runScript: (script, { commands: first = [] } = {}) => {
|
|
589
|
+
const scriptCommands = first.length === 0 ? commands : table(first)
|
|
590
|
+
const flow = applyFlowScript(repo, scriptCommands, script)
|
|
591
|
+
if (flow === undefined) runLines(repo, scriptCommands, script)
|
|
322
592
|
else if (!flow.ok) throw new Error(`testkit: ${flow.error ?? ""}`)
|
|
323
593
|
},
|
|
324
594
|
gtd,
|
|
325
|
-
start: (
|
|
326
|
-
door: (name, ...args) => begin(["
|
|
595
|
+
start: (name, ...args) => begin(["start", name, ...args]),
|
|
596
|
+
door: (name, ...args) => begin(["start", name, ...args]),
|
|
327
597
|
land: () => runWrite(["land"]),
|
|
328
598
|
next,
|
|
329
|
-
drive: async (actors = {},
|
|
330
|
-
|
|
599
|
+
drive: async (actors = {}, options = {}) => {
|
|
600
|
+
const { maxBeats = 50 } = options
|
|
601
|
+
const passed: string[] = []
|
|
602
|
+
for (let n = 0; n < maxBeats; n++) {
|
|
331
603
|
const doc = await next()
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
continue
|
|
336
|
-
}
|
|
337
|
-
const actor = actors[doc.state]
|
|
338
|
-
if (actor === undefined) return doc
|
|
339
|
-
await act(doc, await actor(doc, self))
|
|
604
|
+
passed.push(doc.state)
|
|
605
|
+
const rest = await beat(doc, actors, options)
|
|
606
|
+
if (rest !== undefined) return rest
|
|
340
607
|
}
|
|
341
|
-
throw new Error(
|
|
608
|
+
throw new Error(
|
|
609
|
+
`the process did not rest within ${maxBeats} beats; it passed through:\n${passed.join(" → ")}`,
|
|
610
|
+
)
|
|
342
611
|
},
|
|
343
612
|
subjects: () => repo.commitHistory().map(({ message }) => message.split("\n")[0] ?? ""),
|
|
344
613
|
messages: () => repo.commitHistory().map(({ message }) => message),
|
|
345
614
|
hashes: () => repo.commitHistory().map(({ hash }) => hash),
|
|
615
|
+
commits: () =>
|
|
616
|
+
repo.commitHistory().map(({ hash, message }) => ({
|
|
617
|
+
hash,
|
|
618
|
+
subject: message.split("\n")[0] ?? "",
|
|
619
|
+
message,
|
|
620
|
+
trailers: trailersOf(message),
|
|
621
|
+
})),
|
|
346
622
|
}
|
|
347
623
|
return self
|
|
348
624
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { importsFrom } from "./imports.js"
|
|
3
|
+
|
|
4
|
+
const lookup = (specifier: string): string => `REG(${JSON.stringify(specifier)})`
|
|
5
|
+
const mod = { default: 1, a: 2, b: 3 }
|
|
6
|
+
const rewrite = (source: string): string => importsFrom(source, { "#w": mod }, lookup)
|
|
7
|
+
|
|
8
|
+
describe("importsFrom", () => {
|
|
9
|
+
it("binds named, renamed, default and namespace imports", () => {
|
|
10
|
+
expect(rewrite(`import d, { a, b as c, type T } from "#w"\nimport * as ns from '#w';`)).toBe(
|
|
11
|
+
[
|
|
12
|
+
`const __testkit_module_1 = REG("#w")`,
|
|
13
|
+
`const d = __testkit_module_1["default"]`,
|
|
14
|
+
`const a = __testkit_module_1["a"]`,
|
|
15
|
+
`const c = __testkit_module_1["b"]`,
|
|
16
|
+
`const __testkit_module_2 = REG("#w")`,
|
|
17
|
+
`const ns = __testkit_module_2`,
|
|
18
|
+
].join("\n"),
|
|
19
|
+
)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("re-exports by name, as default, as a namespace and wholesale", () => {
|
|
23
|
+
expect(
|
|
24
|
+
rewrite(
|
|
25
|
+
`export {\n a as default,\n b,\n} from "#w"\nexport * as ns from "#w"\nexport * from "#w"`,
|
|
26
|
+
),
|
|
27
|
+
).toBe(
|
|
28
|
+
[
|
|
29
|
+
`const __testkit_module_1 = REG("#w")`,
|
|
30
|
+
`export default __testkit_module_1["a"]`,
|
|
31
|
+
`export const b = __testkit_module_1["b"]`,
|
|
32
|
+
`const __testkit_module_2 = REG("#w")`,
|
|
33
|
+
`export const ns = __testkit_module_2`,
|
|
34
|
+
`const __testkit_module_3 = REG("#w")`,
|
|
35
|
+
`export const a = __testkit_module_3["a"]`,
|
|
36
|
+
`export const b = __testkit_module_3["b"]`,
|
|
37
|
+
].join("\n"),
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("drops type-only and bare imports, and leaves every other specifier alone", () => {
|
|
42
|
+
const other = `import { agent } from "@pmelab/gtd/flows"\nexport default agent`
|
|
43
|
+
expect(rewrite(`import type { T } from "#w"\nimport "#w"\n${other}`)).toBe(`\n\n${other}`)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("never reads a statement across the code before it", () => {
|
|
47
|
+
const source = `export default tiny\nimport { a } from "#w"`
|
|
48
|
+
expect(rewrite(source)).toBe(
|
|
49
|
+
`export default tiny\nconst __testkit_module_1 = REG("#w")\nconst a = __testkit_module_1["a"]`,
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("refuses a name the module does not export", () => {
|
|
54
|
+
expect(() => rewrite(`import { nope } from "#w"`)).toThrow(
|
|
55
|
+
'testkit: module "#w" has no export "nope"',
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
type Module = Readonly<Record<string, unknown>>
|
|
2
|
+
|
|
3
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/
|
|
4
|
+
|
|
5
|
+
// Static `import`/`export … from` and bare `import "…"` statements, each
|
|
6
|
+
// starting a line. A clause holds names, `*`, commas and one brace group, and
|
|
7
|
+
// breaks lines only inside the braces, so a match cannot run past its statement.
|
|
8
|
+
const STATEMENT =
|
|
9
|
+
/^[ \t]*(import|export)([ \t]+type)?[ \t]+([\w$* \t,]*(?:\{[^}]*\})?[\w$* \t,]*?)\s*\bfrom\s*(["'])([^"'\n]+)\4[ \t]*;?|^[ \t]*import\s*(["'])([^"'\n]+)\6[ \t]*;?/gm
|
|
10
|
+
|
|
11
|
+
interface Binding {
|
|
12
|
+
readonly name: string
|
|
13
|
+
readonly as: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const bindings = (list: string): Binding[] =>
|
|
17
|
+
list
|
|
18
|
+
.split(",")
|
|
19
|
+
.map((entry) => entry.trim())
|
|
20
|
+
.filter((entry) => entry !== "" && !entry.startsWith("type "))
|
|
21
|
+
.map((entry) => {
|
|
22
|
+
const [name, as] = entry.split(/\s+as\s+/)
|
|
23
|
+
return { name: name!, as: as ?? name! }
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const exportLine = (as: string, value: string): string =>
|
|
27
|
+
as === "default" ? `export default ${value}` : `export const ${as} = ${value}`
|
|
28
|
+
|
|
29
|
+
type Read = (name: string) => string
|
|
30
|
+
|
|
31
|
+
const exportLines = (module: Module, m: string, read: Read, head: string, braces?: string) => {
|
|
32
|
+
if (braces !== undefined) return bindings(braces).map((b) => exportLine(b.as, read(b.name)))
|
|
33
|
+
if (/^\*\s+as\s+/.test(head)) return [exportLine(head.replace(/^\*\s+as\s+/, ""), m)]
|
|
34
|
+
return Object.keys(module)
|
|
35
|
+
.filter((name) => name !== "default" && IDENTIFIER.test(name))
|
|
36
|
+
.map((name) => exportLine(name, read(name)))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const importLines = (m: string, read: Read, head: string, braces?: string): string[] => [
|
|
40
|
+
...head
|
|
41
|
+
.split(",")
|
|
42
|
+
.map((part) => part.trim())
|
|
43
|
+
.filter((part) => part !== "")
|
|
44
|
+
.map((part) =>
|
|
45
|
+
part.startsWith("*")
|
|
46
|
+
? `const ${part.replace(/^\*\s+as\s+/, "")} = ${m}`
|
|
47
|
+
: `const ${part} = ${read("default")}`,
|
|
48
|
+
),
|
|
49
|
+
...(braces === undefined ? [] : bindings(braces).map((b) => `const ${b.as} = ${read(b.name)}`)),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `source` with each import or re-export of a specifier in `modules` reading
|
|
54
|
+
* that module off `lookup(specifier)`, an expression evaluated when the
|
|
55
|
+
* config runs. A string config is evaluated in memory, where no resolver
|
|
56
|
+
* knows the specifier, so the binding is made by hand.
|
|
57
|
+
*/
|
|
58
|
+
export const importsFrom = (
|
|
59
|
+
source: string,
|
|
60
|
+
modules: Readonly<Record<string, Module>>,
|
|
61
|
+
lookup: (specifier: string) => string,
|
|
62
|
+
): string => {
|
|
63
|
+
let n = 0
|
|
64
|
+
const rewrite = (kind: string, clause: string, specifier: string, module: Module): string => {
|
|
65
|
+
const m = `__testkit_module_${++n}`
|
|
66
|
+
const read: Read = (name) => {
|
|
67
|
+
if (!(name in module))
|
|
68
|
+
throw new Error(`testkit: module "${specifier}" has no export "${name}"`)
|
|
69
|
+
return `${m}[${JSON.stringify(name)}]`
|
|
70
|
+
}
|
|
71
|
+
const text = clause.trim()
|
|
72
|
+
const braces = /\{([^}]*)\}/.exec(text)?.[1]
|
|
73
|
+
const head = text
|
|
74
|
+
.replace(/\{[^}]*\}/, "")
|
|
75
|
+
.replace(/,\s*$/, "")
|
|
76
|
+
.trim()
|
|
77
|
+
const lines =
|
|
78
|
+
kind === "export"
|
|
79
|
+
? exportLines(module, m, read, head, braces)
|
|
80
|
+
: importLines(m, read, head, braces)
|
|
81
|
+
return [`const ${m} = ${lookup(specifier)}`, ...lines].join("\n")
|
|
82
|
+
}
|
|
83
|
+
return source.replace(
|
|
84
|
+
STATEMENT,
|
|
85
|
+
(
|
|
86
|
+
statement,
|
|
87
|
+
kind: string | undefined,
|
|
88
|
+
typeOnly: string | undefined,
|
|
89
|
+
clause: string | undefined,
|
|
90
|
+
_q: string | undefined,
|
|
91
|
+
from: string | undefined,
|
|
92
|
+
_bq: string | undefined,
|
|
93
|
+
bare: string | undefined,
|
|
94
|
+
) => {
|
|
95
|
+
const specifier = from ?? bare!
|
|
96
|
+
const module = modules[specifier]
|
|
97
|
+
if (module === undefined) return statement
|
|
98
|
+
if (typeOnly !== undefined || bare !== undefined) return ""
|
|
99
|
+
return rewrite(kind!, clause!, specifier, module)
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
}
|