@pmelab/gtd 59.0.1 → 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 +37 -33
- 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-07Z_ENch.mjs → testkit-BvecVjQ0.mjs} +570 -390
- package/dist/chunks/{wire-BTUhRGXj.mjs → wire-DZ8R1RU9.mjs} +8 -1
- package/dist/flows.mjs +2 -676
- package/dist/gtd.bundle.mjs +751 -645
- package/dist/steering.mjs +2 -2
- package/dist/testkit/gherkin.mjs +245 -36
- 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 +66 -60
- package/src/cli/Cli.test.ts +183 -182
- package/src/cli/Cli.ts +164 -186
- 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 +65 -79
- package/src/cli/Program.ts +157 -118
- package/src/edge/Edge.test.ts +64 -56
- package/src/edge/Edge.ts +3 -3
- package/src/flows/factory.test.ts +14 -0
- package/src/flows/factory.ts +22 -5
- 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 +370 -51
- package/src/gherkin/messages.test.ts +38 -4
- package/src/gherkin/messages.ts +21 -4
- 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 +245 -7
- package/src/testkit/TestRepo.ts +271 -40
- 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 +2 -6
- package/src/twins/EmittedScripts.test.ts +4 -2
- package/src/twins/InMemRepo.ts +1 -0
- 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 +169 -146
- 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("'\\''", "'"))
|
|
@@ -228,39 +351,112 @@ const expectedVerdict = (doc: BeatDocument): string => {
|
|
|
228
351
|
].join("\n")
|
|
229
352
|
}
|
|
230
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
|
+
|
|
231
405
|
export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
232
406
|
const repo = new InMemRepo()
|
|
233
|
-
const env = { ...options.env }
|
|
407
|
+
const env: Record<string, string | undefined> = { ...options.env }
|
|
234
408
|
const { workflow } = options
|
|
235
409
|
const id = `module-${++registered}`
|
|
410
|
+
const modules = Object.entries(options.modules ?? {})
|
|
411
|
+
const moduleKey = (specifier: string): string => `${id}:${specifier}`
|
|
236
412
|
repo.writeFile(
|
|
237
413
|
"gtd.config.ts",
|
|
238
|
-
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))),
|
|
239
419
|
)
|
|
240
420
|
for (const [path, content] of Object.entries(options.files ?? {})) repo.writeFile(path, content)
|
|
241
421
|
repo.seedCommit("chore: initial commit")
|
|
242
422
|
|
|
243
423
|
const canned = [...(options.commands ?? [])]
|
|
244
|
-
const
|
|
424
|
+
const table = (first: readonly CannedCommand[] = []): CommandTable => ({
|
|
245
425
|
get: (command) => {
|
|
246
|
-
const hit = canned.find(({ run }) =>
|
|
426
|
+
const hit = [...first, ...canned].find(({ run }) =>
|
|
247
427
|
typeof run === "string" ? run === command : run.test(command),
|
|
248
428
|
)
|
|
249
429
|
if (hit === undefined) return undefined
|
|
250
430
|
const { exit = 0, output = "", effect } = hit
|
|
251
431
|
return { kind: "exit", status: exit, output, effect: () => effect?.(self) }
|
|
252
432
|
},
|
|
253
|
-
}
|
|
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
|
|
254
448
|
|
|
255
|
-
// The
|
|
256
|
-
// 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.
|
|
257
451
|
const gtd = async (...args: string[]): Promise<CapturedCliResult> => {
|
|
258
|
-
const { io, result } = makeCapturingCliIo(repo,
|
|
452
|
+
const { io, result } = makeCapturingCliIo(repo, seenEnv())
|
|
259
453
|
if (typeof workflow !== "string") registry().set(id, workflow)
|
|
454
|
+
for (const [specifier, module] of modules) registry().set(moduleKey(specifier), module)
|
|
260
455
|
try {
|
|
261
456
|
await Effect.runPromise(runCli(["node", "gtd", ...args], io))
|
|
262
457
|
} finally {
|
|
263
458
|
registry().delete(id)
|
|
459
|
+
for (const [specifier] of modules) registry().delete(moduleKey(specifier))
|
|
264
460
|
}
|
|
265
461
|
return result()
|
|
266
462
|
}
|
|
@@ -299,7 +495,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
299
495
|
return reported
|
|
300
496
|
}
|
|
301
497
|
|
|
302
|
-
// A start prints its script plainly
|
|
498
|
+
// A start prints its script plainly.
|
|
303
499
|
const begin = async (args: readonly string[]): Promise<CapturedCliResult> => {
|
|
304
500
|
const result = await gtd(...args)
|
|
305
501
|
if (result.exitCode !== EXIT_OK) throw failed(`gtd ${args.join(" ")}`, result)
|
|
@@ -314,35 +510,67 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
314
510
|
}
|
|
315
511
|
|
|
316
512
|
/** Lands what an actor did: a judge's verdict answered, anything else validated and landed. */
|
|
317
|
-
const act = async (doc: BeatDocument,
|
|
513
|
+
const act = async (doc: BeatDocument, returned: unknown): Promise<void> => {
|
|
318
514
|
if (doc.actor !== "judge") {
|
|
319
515
|
if (doc.validate !== undefined) apply(`validating ${doc.file ?? doc.state}`, doc.validate)
|
|
320
516
|
await runWrite(["land"])
|
|
321
517
|
return
|
|
322
518
|
}
|
|
323
519
|
const actor = `the actor for judge step "${doc.state}"`
|
|
324
|
-
if (
|
|
520
|
+
if (returned === undefined) {
|
|
325
521
|
throw new Error(`${actor} returned no verdict\n${expectedVerdict(doc)}`)
|
|
326
522
|
}
|
|
327
|
-
|
|
523
|
+
const sent = isVerdict(returned) ? JSON.stringify(returned.answers) : returned
|
|
524
|
+
if (typeof sent !== "string") {
|
|
328
525
|
throw new Error(
|
|
329
|
-
`${actor} returned ${Array.isArray(
|
|
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)}`,
|
|
330
527
|
)
|
|
331
528
|
}
|
|
332
529
|
await runWrite(
|
|
333
530
|
["judge", "answer"],
|
|
334
|
-
|
|
531
|
+
sent,
|
|
335
532
|
(error) =>
|
|
336
533
|
new Error(
|
|
337
|
-
`${actor} sent a verdict gtd refused:\n${
|
|
534
|
+
`${actor} sent a verdict gtd refused:\n${sent}\n${expectedVerdict(doc)}\n${error.message}`,
|
|
338
535
|
),
|
|
339
536
|
)
|
|
340
537
|
}
|
|
341
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
|
|
564
|
+
}
|
|
565
|
+
|
|
342
566
|
const self: TestRepo = {
|
|
343
567
|
command: (command) => {
|
|
344
568
|
canned.push(command)
|
|
345
569
|
},
|
|
570
|
+
setEnv: (name, value) => {
|
|
571
|
+
if (value === undefined) delete env[name]
|
|
572
|
+
else env[name] = value
|
|
573
|
+
},
|
|
346
574
|
branch: (name, ref = "HEAD") => repo.createBranch(name, ref),
|
|
347
575
|
checkout: (name) => repo.checkoutBranch(name),
|
|
348
576
|
ref: (name, ref = "HEAD") => {
|
|
@@ -357,29 +585,25 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
357
585
|
remove: (path) => repo.deleteFile(path),
|
|
358
586
|
commit: (message) => repo.seedCommit(message),
|
|
359
587
|
show: (ref, path) => repo.fileAtRef(ref, path) ?? undefined,
|
|
360
|
-
runScript: (script) => {
|
|
361
|
-
const
|
|
362
|
-
|
|
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)
|
|
363
592
|
else if (!flow.ok) throw new Error(`testkit: ${flow.error ?? ""}`)
|
|
364
593
|
},
|
|
365
594
|
gtd,
|
|
366
|
-
start: (
|
|
367
|
-
door: (name, ...args) => begin(["
|
|
595
|
+
start: (name, ...args) => begin(["start", name, ...args]),
|
|
596
|
+
door: (name, ...args) => begin(["start", name, ...args]),
|
|
368
597
|
land: () => runWrite(["land"]),
|
|
369
598
|
next,
|
|
370
|
-
drive: async (actors = {},
|
|
599
|
+
drive: async (actors = {}, options = {}) => {
|
|
600
|
+
const { maxBeats = 50 } = options
|
|
371
601
|
const passed: string[] = []
|
|
372
|
-
for (let
|
|
602
|
+
for (let n = 0; n < maxBeats; n++) {
|
|
373
603
|
const doc = await next()
|
|
374
604
|
passed.push(doc.state)
|
|
375
|
-
|
|
376
|
-
if (
|
|
377
|
-
await runWrite(["land"])
|
|
378
|
-
continue
|
|
379
|
-
}
|
|
380
|
-
const actor = actors[doc.state]
|
|
381
|
-
if (actor === undefined) return doc
|
|
382
|
-
await act(doc, await actor(doc, self))
|
|
605
|
+
const rest = await beat(doc, actors, options)
|
|
606
|
+
if (rest !== undefined) return rest
|
|
383
607
|
}
|
|
384
608
|
throw new Error(
|
|
385
609
|
`the process did not rest within ${maxBeats} beats; it passed through:\n${passed.join(" → ")}`,
|
|
@@ -388,6 +612,13 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
388
612
|
subjects: () => repo.commitHistory().map(({ message }) => message.split("\n")[0] ?? ""),
|
|
389
613
|
messages: () => repo.commitHistory().map(({ message }) => message),
|
|
390
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
|
+
})),
|
|
391
622
|
}
|
|
392
623
|
return self
|
|
393
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
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
2
|
+
import { agent, read } from "../flows/index.js"
|
|
3
|
+
import { installContext, type FlowContext, type StepRequest } from "./index.js"
|
|
4
|
+
|
|
5
|
+
afterEach(() => installContext(undefined))
|
|
6
|
+
|
|
7
|
+
describe("the flow-context harness", () => {
|
|
8
|
+
it("renders a prompt and records a step against an installed context", async () => {
|
|
9
|
+
const requests: StepRequest[] = []
|
|
10
|
+
installContext({
|
|
11
|
+
read: (path: string) => (path === "PLAN.md" ? "add a button" : undefined),
|
|
12
|
+
step: async (request: StepRequest) => {
|
|
13
|
+
requests.push(request)
|
|
14
|
+
},
|
|
15
|
+
} as Partial<FlowContext> as FlowContext)
|
|
16
|
+
|
|
17
|
+
await agent("build", `Build: ${read("PLAN.md")}`)
|
|
18
|
+
|
|
19
|
+
expect(requests).toEqual([
|
|
20
|
+
{ kind: "agent", name: "build", prompt: "Build: add a button", options: {} },
|
|
21
|
+
])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("leaves a flow failing outside a replay once uninstalled", () => {
|
|
25
|
+
installContext(undefined)
|
|
26
|
+
expect(() => read("PLAN.md")).toThrow("called outside a gtd replay")
|
|
27
|
+
})
|
|
28
|
+
})
|
package/src/testkit/index.ts
CHANGED
|
@@ -2,15 +2,27 @@
|
|
|
2
2
|
// repository. It imports no test runner, so any runner can use it; the
|
|
3
3
|
// gherkin steps are the opt-in \`@pmelab/gtd/testkit/gherkin\`.
|
|
4
4
|
export {
|
|
5
|
+
inTurn,
|
|
5
6
|
runsItsScript,
|
|
6
7
|
testRepo,
|
|
7
|
-
|
|
8
|
+
verdict,
|
|
9
|
+
verdictWhen,
|
|
10
|
+
whenPrompted,
|
|
8
11
|
type Actors,
|
|
9
12
|
type CannedCommand,
|
|
10
13
|
type DriveOptions,
|
|
14
|
+
type RunScriptOptions,
|
|
15
|
+
type ScriptedActor,
|
|
16
|
+
type TestCommit,
|
|
11
17
|
type TestRepo,
|
|
12
18
|
type TestRepoOptions,
|
|
19
|
+
type Verdict,
|
|
20
|
+
type VerdictEntry,
|
|
13
21
|
type WorkflowModule,
|
|
14
22
|
} from "./TestRepo.js"
|
|
15
|
-
export {
|
|
23
|
+
export type { BeatDocument } from "../wire/index.js"
|
|
24
|
+
export type { CapturedCliResult } from "../twins/index.js"
|
|
16
25
|
export type { TestkitWorld } from "../gherkin/index.js"
|
|
26
|
+
// The flow-context harness: render a prompt or stub a step synchronously,
|
|
27
|
+
// against a replay context the test installs instead of the engine.
|
|
28
|
+
export { installContext, type FlowContext, type StepRequest } from "../flows/internal.js"
|