@pmelab/gtd 59.0.0 → 59.0.1
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 +5 -5
- package/dist/chunks/{testkit-CMBAAyRK.mjs → testkit-07Z_ENch.mjs} +431 -446
- package/dist/chunks/{wire-C12dahrH.mjs → wire-BTUhRGXj.mjs} +1 -1
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +410 -440
- package/dist/testkit/gherkin.mjs +145 -65
- package/dist/testkit.mjs +1 -1
- package/dist/wire.mjs +1 -1
- package/package.json +1 -1
- package/skills/authoring/SKILL.md +3 -2
- package/src/cli/Cli.test.ts +133 -52
- package/src/cli/Cli.ts +384 -461
- package/src/cli/Help.test.ts +47 -0
- package/src/cli/Help.ts +70 -0
- package/src/cli/Program.test.ts +67 -4
- package/src/cli/Program.ts +65 -18
- package/src/edge/Edge.ts +1 -1
- package/src/emit/Emit.test.ts +1 -1
- package/src/emit/Emit.ts +1 -1
- package/src/flows/factory.ts +3 -1
- package/src/gherkin/index.ts +135 -71
- package/src/gherkin/messages.test.ts +90 -0
- package/src/gherkin/messages.ts +84 -0
- package/src/testkit/TestRepo.test.ts +47 -2
- package/src/testkit/TestRepo.ts +51 -6
- package/src/twins/EmittedScriptRecognizer.ts +5 -8
- package/src/wire/Land.ts +1 -1
- package/src/workflow/load.ts +6 -6
- package/src/workflow/skills.test.ts +5 -5
- package/src/workflow/skills.ts +1 -1
package/dist/testkit/gherkin.mjs
CHANGED
|
@@ -1,9 +1,66 @@
|
|
|
1
|
-
import { n as testRepo, t as runsItsScript } from "../chunks/testkit-
|
|
1
|
+
import { n as testRepo, t as runsItsScript } from "../chunks/testkit-07Z_ENch.mjs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import assert from "node:assert";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
import { Given, QuickPickleWorld, Then, When, setWorldConstructor } from "quickpickle";
|
|
6
|
+
//#region src/gherkin/messages.ts
|
|
7
|
+
const CONTENT_LINES = 5;
|
|
8
|
+
const reason = (error) => error instanceof Error ? error.message : String(error);
|
|
9
|
+
const beatLines = async (repo) => {
|
|
10
|
+
try {
|
|
11
|
+
const beat = await repo.next();
|
|
12
|
+
const content = beat.content.trimEnd().split("\n");
|
|
13
|
+
const more = content.length - CONTENT_LINES;
|
|
14
|
+
return [
|
|
15
|
+
`step "${beat.state}" (${beat.kind}, ${beat.actor}${beat.idle ? ", idle" : ""})`,
|
|
16
|
+
"content:",
|
|
17
|
+
...content.slice(0, CONTENT_LINES).map((line) => ` | ${line}`),
|
|
18
|
+
...more > 0 ? [` | … ${more} more lines`] : []
|
|
19
|
+
];
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return [`unknown — gtd next failed: ${reason(error)}`];
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
/** Where the process rests, for a failed outcome to end with. Never throws: it describes a failure. */
|
|
25
|
+
const restDescription = async (repo, neverUsed) => {
|
|
26
|
+
let lines;
|
|
27
|
+
try {
|
|
28
|
+
const r = repo();
|
|
29
|
+
lines = [
|
|
30
|
+
...await beatLines(r),
|
|
31
|
+
"last commits:",
|
|
32
|
+
...r.subjects().slice(-5).map((subject) => ` ${subject}`)
|
|
33
|
+
];
|
|
34
|
+
} catch (error) {
|
|
35
|
+
lines = [`unknown — ${reason(error)}`];
|
|
36
|
+
}
|
|
37
|
+
if (neverUsed.length > 0) lines.push(`actors never used: ${neverUsed.map((step) => `"${step}"`).join(", ")}`);
|
|
38
|
+
return ["where the process rests:", ...lines.map((line) => ` ${line}`)].join("\n");
|
|
39
|
+
};
|
|
40
|
+
/** Both sides of a failed `contains`: the text looked for, and what was there. */
|
|
41
|
+
const containsFailure = (where, text, content, expected) => content === void 0 ? `${where} does not exist${expected ? `, so it cannot contain "${text}"` : ""}` : `${where} ${expected ? "does not contain" : "contains"} "${text}"; it holds:\n${content}`;
|
|
42
|
+
/** What `attempt` was refused with; fails when it was not refused at all. */
|
|
43
|
+
const refusal = async (attempt, expectation) => {
|
|
44
|
+
try {
|
|
45
|
+
await attempt;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return error instanceof Error ? error.message : String(error);
|
|
48
|
+
}
|
|
49
|
+
assert.fail(`expected a refusal ${expectation}, but nothing was refused`);
|
|
50
|
+
};
|
|
51
|
+
const refusedWith = async (attempt, message) => {
|
|
52
|
+
const got = await refusal(attempt, `with "${message}"`);
|
|
53
|
+
if (!got.includes(message)) assert.fail(`expected a refusal with "${message}", but it was refused with:\n${got}`);
|
|
54
|
+
};
|
|
55
|
+
const assertContains = (where, text, content) => {
|
|
56
|
+
if (content === void 0 || !content.includes(text)) assert.fail(containsFailure(where, text, content, true));
|
|
57
|
+
};
|
|
58
|
+
const assertLacks = (where, text, content) => {
|
|
59
|
+
if (content === void 0 || content.includes(text)) assert.fail(containsFailure(where, text, content, false));
|
|
60
|
+
};
|
|
61
|
+
//#endregion
|
|
6
62
|
//#region src/gherkin/index.ts
|
|
63
|
+
const usedActors = /* @__PURE__ */ new WeakMap();
|
|
7
64
|
var TestkitWorld = class extends QuickPickleWorld {
|
|
8
65
|
constructor(context, info) {
|
|
9
66
|
super(context, info);
|
|
@@ -51,6 +108,8 @@ var TestkitWorld = class extends QuickPickleWorld {
|
|
|
51
108
|
}
|
|
52
109
|
async drive() {
|
|
53
110
|
const actors = {};
|
|
111
|
+
const used = usedActors.get(this) ?? /* @__PURE__ */ new Set();
|
|
112
|
+
usedActors.set(this, used);
|
|
54
113
|
for (const [step, actions] of this.actions) actors[step] = async (beat, repo) => {
|
|
55
114
|
for (const action of actions) await action(beat, repo);
|
|
56
115
|
};
|
|
@@ -67,12 +126,35 @@ var TestkitWorld = class extends QuickPickleWorld {
|
|
|
67
126
|
return hit[1];
|
|
68
127
|
};
|
|
69
128
|
}
|
|
70
|
-
|
|
129
|
+
const tracked = {};
|
|
130
|
+
for (const [step, actor] of Object.entries(actors)) tracked[step] = (beat, repo) => {
|
|
131
|
+
used.add(step);
|
|
132
|
+
return actor(beat, repo);
|
|
133
|
+
};
|
|
134
|
+
await this.repo().drive(tracked);
|
|
71
135
|
}
|
|
72
136
|
};
|
|
73
137
|
setWorldConstructor(TestkitWorld);
|
|
74
138
|
const withNewline = (content) => content.endsWith("\n") ? content : `${content}\n`;
|
|
75
139
|
const args = (list) => list.split(" ").filter((arg) => arg !== "");
|
|
140
|
+
const neverUsed = (world) => {
|
|
141
|
+
const used = usedActors.get(world) ?? /* @__PURE__ */ new Set();
|
|
142
|
+
return [.../* @__PURE__ */ new Set([
|
|
143
|
+
...world.actions.keys(),
|
|
144
|
+
...world.verdicts.keys(),
|
|
145
|
+
...world.conditionalVerdicts.keys()
|
|
146
|
+
])].filter((step) => !used.has(step));
|
|
147
|
+
};
|
|
148
|
+
/** An outcome step: a failure ends with where the process rests. */
|
|
149
|
+
const outcome = (check) => async (world, ...rest) => {
|
|
150
|
+
try {
|
|
151
|
+
await check(world, ...rest);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
154
|
+
failure.message += `\n\n${await restDescription(() => world.repo(), neverUsed(world))}`;
|
|
155
|
+
throw failure;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
76
158
|
Given("a gtd config with:", (world, source) => {
|
|
77
159
|
world.configure(withNewline(source));
|
|
78
160
|
});
|
|
@@ -197,87 +279,85 @@ When("gtd lands", async (world) => {
|
|
|
197
279
|
When("the process is driven", async (world) => {
|
|
198
280
|
await world.drive();
|
|
199
281
|
});
|
|
200
|
-
Then("opening door {string} is refused with {string}", async (world, door, message) => {
|
|
201
|
-
await
|
|
202
|
-
});
|
|
203
|
-
Then("starting workflow {string} with {string} is refused with {string}", async (world, workflow, list, message) => {
|
|
204
|
-
await
|
|
205
|
-
});
|
|
206
|
-
Then("landing is refused with {string}", async (world, message) => {
|
|
207
|
-
await
|
|
208
|
-
});
|
|
209
|
-
Then("driving the process is refused with {string}", async (world, message) => {
|
|
210
|
-
await
|
|
211
|
-
});
|
|
212
|
-
Then("driving the process is refused without {string}", async (world, message) => {
|
|
213
|
-
await
|
|
214
|
-
});
|
|
215
|
-
|
|
282
|
+
Then("opening door {string} is refused with {string}", outcome(async (world, door, message) => {
|
|
283
|
+
await refusedWith(world.repo().door(door), message);
|
|
284
|
+
}));
|
|
285
|
+
Then("starting workflow {string} with {string} is refused with {string}", outcome(async (world, workflow, list, message) => {
|
|
286
|
+
await refusedWith(world.repo().start(workflow, ...args(list)), message);
|
|
287
|
+
}));
|
|
288
|
+
Then("landing is refused with {string}", outcome(async (world, message) => {
|
|
289
|
+
await refusedWith(world.repo().land(), message);
|
|
290
|
+
}));
|
|
291
|
+
Then("driving the process is refused with {string}", outcome(async (world, message) => {
|
|
292
|
+
await refusedWith(world.drive(), message);
|
|
293
|
+
}));
|
|
294
|
+
Then("driving the process is refused without {string}", outcome(async (world, message) => {
|
|
295
|
+
const got = await refusal(world.drive(), `without "${message}"`);
|
|
296
|
+
if (got.includes(message)) assert.fail(`expected a refusal without "${message}", but it was refused with:\n${got}`);
|
|
297
|
+
}));
|
|
298
|
+
Then("the process rests at {string}", outcome(async (world, step) => {
|
|
216
299
|
assert.strictEqual((await world.repo().next()).state, step);
|
|
217
|
-
});
|
|
218
|
-
Then("the resting step's content contains {string}", async (world, text) => {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
})
|
|
222
|
-
Then("the resting step's content contains the hash of the commit {string}", async (world, subject) => {
|
|
300
|
+
}));
|
|
301
|
+
Then("the resting step's content contains {string}", outcome(async (world, text) => {
|
|
302
|
+
assertContains("the resting step's content", text, (await world.repo().next()).content);
|
|
303
|
+
}));
|
|
304
|
+
Then("the resting step's content contains the hash of the commit {string}", outcome(async (world, subject) => {
|
|
223
305
|
const repo = world.repo();
|
|
224
306
|
const hash = repo.hashes()[repo.subjects().lastIndexOf(subject)];
|
|
225
307
|
assert.ok(hash !== void 0, `no commit "${subject}"`);
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
})
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
});
|
|
233
|
-
Then("the last commit subject is {string}", (world, subject) => {
|
|
308
|
+
assertContains("the resting step's content", hash, (await repo.next()).content);
|
|
309
|
+
}));
|
|
310
|
+
Then("the resting step's content does not contain {string}", outcome(async (world, text) => {
|
|
311
|
+
assertLacks("the resting step's content", text, (await world.repo().next()).content);
|
|
312
|
+
}));
|
|
313
|
+
Then("the last commit subject is {string}", outcome((world, subject) => {
|
|
234
314
|
assert.strictEqual(world.repo().subjects().at(-1), subject);
|
|
235
|
-
});
|
|
236
|
-
Then("the commit subjects end with:", (world, list) => {
|
|
315
|
+
}));
|
|
316
|
+
Then("the commit subjects end with:", outcome((world, list) => {
|
|
237
317
|
const expected = list.split("\n").filter((line) => line.trim() !== "");
|
|
238
318
|
assert.deepStrictEqual(world.repo().subjects().slice(-expected.length), expected);
|
|
239
|
-
});
|
|
319
|
+
}));
|
|
240
320
|
const outputOf = async (world, list) => {
|
|
241
321
|
const result = await world.repo().gtd(...args(list));
|
|
242
322
|
assert.strictEqual(result.exitCode, 0, result.stderr);
|
|
243
323
|
return result.stdout;
|
|
244
324
|
};
|
|
245
|
-
Then("the output of gtd {string} contains {string}", async (world, list, text) => {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
})
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
});
|
|
253
|
-
Then("the output lines of gtd {string} start with:", async (world, list, prefixes) => {
|
|
325
|
+
Then("the output of gtd {string} contains {string}", outcome(async (world, list, text) => {
|
|
326
|
+
assertContains(`the output of gtd ${list}`, text, await outputOf(world, list));
|
|
327
|
+
}));
|
|
328
|
+
Then("the output of gtd {string} does not contain {string}", outcome(async (world, list, text) => {
|
|
329
|
+
assertLacks(`the output of gtd ${list}`, text, await outputOf(world, list));
|
|
330
|
+
}));
|
|
331
|
+
Then("the output lines of gtd {string} start with:", outcome(async (world, list, prefixes) => {
|
|
254
332
|
const out = (await outputOf(world, list)).split("\n").filter((line) => line !== "");
|
|
255
333
|
const expected = prefixes.split("\n").filter((line) => line.trim() !== "");
|
|
256
|
-
|
|
257
|
-
out.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
});
|
|
265
|
-
|
|
334
|
+
const sides = `expected lines starting with:\n${expected.join("\n")}\ngot:\n${out.join("\n")}`;
|
|
335
|
+
assert.strictEqual(out.length, expected.length, sides);
|
|
336
|
+
out.forEach((line, i) => assert.ok(line.startsWith(expected[i]), sides));
|
|
337
|
+
}));
|
|
338
|
+
Then("{string} contains {string}", outcome((world, path, text) => {
|
|
339
|
+
assertContains(`"${path}"`, text, world.repo().read(path));
|
|
340
|
+
}));
|
|
341
|
+
Then("{string} contains:", outcome((world, path, text) => {
|
|
342
|
+
assertContains(`"${path}"`, text, world.repo().read(path));
|
|
343
|
+
}));
|
|
344
|
+
Then("{string} contains the short hash of the commit {string}", outcome((world, path, subject) => {
|
|
266
345
|
const repo = world.repo();
|
|
267
346
|
const hash = repo.hashes()[repo.subjects().lastIndexOf(subject)];
|
|
268
347
|
assert.ok(hash !== void 0, `no commit "${subject}"`);
|
|
269
|
-
|
|
270
|
-
});
|
|
271
|
-
Then("{string} contains {string} exactly once", (world, path, text) => {
|
|
348
|
+
assertContains(`"${path}"`, hash.slice(0, 7), repo.read(path));
|
|
349
|
+
}));
|
|
350
|
+
Then("{string} contains {string} exactly once", outcome((world, path, text) => {
|
|
272
351
|
const content = world.repo().read(path) ?? "";
|
|
273
|
-
|
|
274
|
-
});
|
|
275
|
-
|
|
352
|
+
const times = content.split(text).length - 1;
|
|
353
|
+
assert.strictEqual(times, 1, `"${path}" contains "${text}" ${times} times; it holds:\n${content}`);
|
|
354
|
+
}));
|
|
355
|
+
Then("{string} does not contain {string}", outcome((world, path, text) => {
|
|
356
|
+
assertLacks(`"${path}"`, text, world.repo().read(path));
|
|
357
|
+
}));
|
|
358
|
+
Then("{string} does not exist", outcome((world, path) => {
|
|
276
359
|
const content = world.repo().read(path);
|
|
277
|
-
assert.
|
|
278
|
-
});
|
|
279
|
-
Then("{string} does not exist", (world, path) => {
|
|
280
|
-
assert.strictEqual(world.repo().read(path), void 0);
|
|
281
|
-
});
|
|
360
|
+
assert.strictEqual(content, void 0, `"${path}" exists; it holds:\n${content}`);
|
|
361
|
+
}));
|
|
282
362
|
//#endregion
|
|
283
363
|
export { TestkitWorld };
|
package/dist/testkit.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as testRepo, r as InMemRepo, t as runsItsScript } from "./chunks/testkit-
|
|
1
|
+
import { n as testRepo, r as InMemRepo, t as runsItsScript } from "./chunks/testkit-07Z_ENch.mjs";
|
|
2
2
|
export { InMemRepo, runsItsScript, testRepo };
|
package/dist/wire.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { C as EXIT_SIGTERM, D as UNATTRIBUTED_MODEL, E as ACCESS_REFUSAL, O as WIRE_SCHEMA, S as EXIT_SIGINT, T as EXIT_USAGE_ERROR, _ as demandOf, a as landFields, b as EXIT_OK, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, h as BEAT_KINDS, i as judgeJson, k as renderFormat, l as renderLandPlain, m as statusOf, n as resolveSession, o as landProseText, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches, u as DEMAND_BRIEFING, v as stallDiagnosis, w as EXIT_STRAYS_FOUND, x as EXIT_RUNTIME_ERROR, y as EXIT_CODES } from "./chunks/wire-
|
|
1
|
+
import { C as EXIT_SIGTERM, D as UNATTRIBUTED_MODEL, E as ACCESS_REFUSAL, O as WIRE_SCHEMA, S as EXIT_SIGINT, T as EXIT_USAGE_ERROR, _ as demandOf, a as landFields, b as EXIT_OK, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, h as BEAT_KINDS, i as judgeJson, k as renderFormat, l as renderLandPlain, m as statusOf, n as resolveSession, o as landProseText, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches, u as DEMAND_BRIEFING, v as stallDiagnosis, w as EXIT_STRAYS_FOUND, x as EXIT_RUNTIME_ERROR, y as EXIT_CODES } from "./chunks/wire-BTUhRGXj.mjs";
|
|
2
2
|
export { ACCESS_REFUSAL, BEAT_KINDS, DEMAND_BRIEFING, EXIT_CODES, EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_SIGINT, EXIT_SIGTERM, EXIT_STRAYS_FOUND, EXIT_USAGE_ERROR, UNATTRIBUTED_MODEL, WIRE_SCHEMA, beatDocument, beatKindOf, demandOf, globMatches, judgeJson, landFields, landProseText, noopText, renderBeatJson, renderBeatPlain, renderFormat, renderLandJson, renderLandPlain, resolveSession, selectPath, stallDiagnosis, statusOf };
|
package/package.json
CHANGED
|
@@ -139,7 +139,7 @@ inside. Skills are declared per scope, never on `agent()`, and a driver
|
|
|
139
139
|
restricts each turn to its scope's list where it can. `refuse(message)` refuses
|
|
140
140
|
the pending landing — call it right after the step whose turn you reject.
|
|
141
141
|
`@pmelab/gtd/flows` exports `requireProgress(file)`, `requireAnswers(file)` and
|
|
142
|
-
`requireRevert(edited, base)
|
|
142
|
+
`requireRevert(edited, base)` among its ready-made checks.
|
|
143
143
|
|
|
144
144
|
## Names, commits and history
|
|
145
145
|
|
|
@@ -206,7 +206,8 @@ divergence later.
|
|
|
206
206
|
network or filesystem. Read the tree through the helpers; do IO inside a
|
|
207
207
|
`run()` body.
|
|
208
208
|
- Await only a step, `scope()`, or a function that steps. Anything else fails
|
|
209
|
-
with `the flow awaited something that is not a step`.
|
|
209
|
+
with `the flow awaited something that is not a step`. gtd does not flag a step
|
|
210
|
+
you forget to await — turn on a `no-floating-promises` lint rule.
|
|
210
211
|
- One call site per step name — wrap a reused helper in two different
|
|
211
212
|
`scope()`s.
|
|
212
213
|
- No `try`/`catch` around a step: `restart()` and refusals travel as exceptions.
|
package/src/cli/Cli.test.ts
CHANGED
|
@@ -71,7 +71,8 @@ describe("parseArgv — unknown options", () => {
|
|
|
71
71
|
expect(plan).toEqual({
|
|
72
72
|
kind: "usage",
|
|
73
73
|
stdout: "",
|
|
74
|
-
message:
|
|
74
|
+
message:
|
|
75
|
+
"gtd: unknown option '--jsn' — did you mean `--json`? Run `gtd help <command>` for its options",
|
|
75
76
|
json: false,
|
|
76
77
|
})
|
|
77
78
|
})
|
|
@@ -369,17 +370,52 @@ describe("parseArgv — bare/unknown command under --json", () => {
|
|
|
369
370
|
}
|
|
370
371
|
})
|
|
371
372
|
|
|
372
|
-
it("bare gtd
|
|
373
|
+
it("bare gtd prints a short usage, not the overview, and points at gtd help and gtd install", () => {
|
|
373
374
|
const plan = parseArgv(["node", "gtd.js"])
|
|
374
375
|
expect(plan.kind).toBe("usage")
|
|
375
376
|
if (plan.kind === "usage") {
|
|
376
377
|
expect(plan.message).toContain("missing command")
|
|
377
|
-
expect(plan.message).toContain("gtd
|
|
378
|
+
expect(plan.message).toContain("`gtd help`")
|
|
378
379
|
expect(plan.message).toContain("gtd install")
|
|
379
|
-
expect(plan.
|
|
380
|
+
expect(plan.stdout).toMatch(/^Usage: gtd <command>/)
|
|
381
|
+
expect(plan.stdout.split("\n").length).toBeLessThan(10)
|
|
380
382
|
}
|
|
381
383
|
})
|
|
382
384
|
|
|
385
|
+
it("an unknown command that looks like a typo names the nearest command", () => {
|
|
386
|
+
expect(parseArgv(["node", "gtd.js", "nxt"])).toEqual({
|
|
387
|
+
kind: "usage",
|
|
388
|
+
stdout: "",
|
|
389
|
+
message:
|
|
390
|
+
"gtd: unknown command 'nxt' — did you mean `gtd next`? Run `gtd help` for every command",
|
|
391
|
+
json: false,
|
|
392
|
+
})
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it("an unknown command nothing resembles points at gtd help only", () => {
|
|
396
|
+
const plan = parseArgv(["node", "gtd.js", "frobnicate"])
|
|
397
|
+
expect(plan.kind === "usage" && plan.message).toBe(
|
|
398
|
+
"gtd: unknown command 'frobnicate' — run `gtd help` for every command",
|
|
399
|
+
)
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it("a mistyped history subcommand names the nearest one", () => {
|
|
403
|
+
const plan = parseArgv(["node", "gtd.js", "history", "shwo"])
|
|
404
|
+
expect(plan.kind === "usage" && plan.message).toContain("did you mean `gtd history show`?")
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it("a bare --json followed by a word suggests the = form", () => {
|
|
408
|
+
const plan = parseArgv(["node", "gtd.js", "next", "--json", "kind"])
|
|
409
|
+
expect(plan.kind === "usage" && plan.message).toBe(
|
|
410
|
+
"gtd next: too many arguments — expected none, got: kind — to print one field, write --json=kind",
|
|
411
|
+
)
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it("an unknown single-dash option is an unknown option, not an extra argument", () => {
|
|
415
|
+
const plan = parseArgv(["node", "gtd.js", "-j", "next"])
|
|
416
|
+
expect(plan.kind === "usage" && plan.message).toContain("unknown option '-j'")
|
|
417
|
+
})
|
|
418
|
+
|
|
383
419
|
it("an unknown command under --json carries json:true, naming --json's own scope error over the subcommand", () => {
|
|
384
420
|
const plan = parseArgv(["node", "gtd.js", "bogus", "--json"])
|
|
385
421
|
expect(plan.kind).toBe("usage")
|
|
@@ -891,10 +927,8 @@ describe("parseArgv — doors", () => {
|
|
|
891
927
|
})
|
|
892
928
|
})
|
|
893
929
|
|
|
894
|
-
it("`gtd door` with no name
|
|
895
|
-
|
|
896
|
-
expect(plan.kind).toBe("usage")
|
|
897
|
-
if (plan.kind === "usage") expect(plan.message).toContain("gtd door: missing name argument")
|
|
930
|
+
it("`gtd door` with no name parses, so the loaded config can list its doors", () => {
|
|
931
|
+
expect(parse("door")).toMatchObject({ kind: "command", command: { kind: "door", args: [] } })
|
|
898
932
|
})
|
|
899
933
|
|
|
900
934
|
it("`gtd doors` parses with every --json shape", () => {
|
|
@@ -1061,7 +1095,8 @@ describe("parseArgv — gtd strays", () => {
|
|
|
1061
1095
|
const plan = parseArgv(["node", "gtd.js", "lsp", "--json"])
|
|
1062
1096
|
if (plan.kind === "usage") expect(plan.message).toContain("`gtd strays`")
|
|
1063
1097
|
expect(renderHelp()).toContain("strays <range> [glob...]")
|
|
1064
|
-
|
|
1098
|
+
const help = parseArgv(["node", "gtd.js", "help", "strays"])
|
|
1099
|
+
expect(help.kind === "output" && help.stdout).toContain("--json[=<path>]")
|
|
1065
1100
|
})
|
|
1066
1101
|
})
|
|
1067
1102
|
|
|
@@ -1100,56 +1135,93 @@ describe("standaloneKinds / needsOf", () => {
|
|
|
1100
1135
|
})
|
|
1101
1136
|
})
|
|
1102
1137
|
|
|
1138
|
+
const helpFor = (...args: string[]): string => {
|
|
1139
|
+
const plan = parseArgv(["node", "gtd.js", ...args])
|
|
1140
|
+
if (plan.kind !== "output") throw new Error(`no help for ${args.join(" ")}`)
|
|
1141
|
+
return plan.stdout
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/** The command names the overview lists — ` name ` lines, as the docs pins read them. */
|
|
1145
|
+
const listedCommands = (): string[] =>
|
|
1146
|
+
[...renderHelp().matchAll(/^ {2}([a-z]+(?:-[a-z]+)?(?: [a-z]+)?)(?= )/gm)].map(([, n]) => n!)
|
|
1147
|
+
|
|
1103
1148
|
describe("renderHelp", () => {
|
|
1104
|
-
it("
|
|
1149
|
+
it("groups the commands and mentions no removed surface", () => {
|
|
1105
1150
|
const help = renderHelp()
|
|
1106
|
-
|
|
1107
|
-
|
|
1151
|
+
for (const heading of ["Core loop:", "Start and stop:", "Inspect:", "Driver plumbing:"]) {
|
|
1152
|
+
expect(help).toContain(`\n${heading}\n`)
|
|
1153
|
+
}
|
|
1108
1154
|
expect(help).toContain("--workflow <name>")
|
|
1109
1155
|
expect(help).toContain("--var")
|
|
1110
|
-
expect(help).toContain("abandon")
|
|
1111
|
-
expect(help).toContain("restore")
|
|
1112
|
-
expect(help).toContain("next")
|
|
1113
|
-
expect(help).toContain("validate")
|
|
1114
|
-
expect(help).toContain("lsp")
|
|
1115
|
-
expect(help).not.toContain("visualize")
|
|
1116
|
-
expect(help).not.toMatch(/^ {2}init\b/m)
|
|
1117
|
-
expect(help).not.toContain(".gtdrc")
|
|
1118
|
-
expect(help).toMatch(/^ {2}ui\b/m)
|
|
1119
1156
|
expect(help).toContain("check <mode> <file>")
|
|
1120
|
-
expect(help).toContain("
|
|
1121
|
-
expect(help).toContain("
|
|
1122
|
-
expect(help).
|
|
1123
|
-
expect(help).toContain("
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
expect(help).
|
|
1135
|
-
expect(help).toContain("--verbose")
|
|
1136
|
-
expect(help).toContain("--version, -V")
|
|
1137
|
-
expect(help).toContain("--help, -h")
|
|
1138
|
-
expect(help).not.toContain("review <commitish>")
|
|
1139
|
-
expect(help).not.toContain("format <file>")
|
|
1140
|
-
expect(help).not.toContain("--debug")
|
|
1141
|
-
expect(help).not.toContain("bin/gtd")
|
|
1142
|
-
expect(help).not.toContain("(no command), loop")
|
|
1143
|
-
expect(help).not.toContain("--dispatch")
|
|
1144
|
-
expect(help).not.toContain("--if-resting")
|
|
1145
|
-
expect(help).not.toMatch(/^ {2}serve\b/m)
|
|
1157
|
+
expect(help).toContain("door <name> [args...]")
|
|
1158
|
+
expect(help).toContain("-V, --version")
|
|
1159
|
+
expect(help).toContain("-h, --help")
|
|
1160
|
+
expect(help).toContain("Examples:")
|
|
1161
|
+
for (const removed of [
|
|
1162
|
+
"visualize",
|
|
1163
|
+
".gtdrc",
|
|
1164
|
+
"--no-open",
|
|
1165
|
+
"--debug",
|
|
1166
|
+
"--dispatch",
|
|
1167
|
+
"bin/gtd",
|
|
1168
|
+
]) {
|
|
1169
|
+
expect(help).not.toContain(removed)
|
|
1170
|
+
}
|
|
1171
|
+
expect(help).not.toMatch(/^ {2}(init|serve)\b/m)
|
|
1146
1172
|
expect(help).toMatch(/\n$/)
|
|
1147
1173
|
})
|
|
1148
1174
|
|
|
1149
|
-
it("every
|
|
1150
|
-
const
|
|
1151
|
-
|
|
1152
|
-
|
|
1175
|
+
it("keeps every line of every help text within 80 columns", () => {
|
|
1176
|
+
for (const text of [renderHelp(), ...listedCommands().map((name) => helpFor("help", name))]) {
|
|
1177
|
+
for (const line of text.split("\n")) expect(line.length, line).toBeLessThanOrEqual(80)
|
|
1178
|
+
}
|
|
1179
|
+
})
|
|
1180
|
+
|
|
1181
|
+
it("names no internals", () => {
|
|
1182
|
+
const all = [renderHelp(), ...listedCommands().map((name) => helpFor("help", name))].join("")
|
|
1183
|
+
for (const internal of ["it.processCost", "refs/worktree/gtd/history", "Effect Schema"]) {
|
|
1184
|
+
expect(all).not.toContain(internal)
|
|
1185
|
+
}
|
|
1186
|
+
})
|
|
1187
|
+
|
|
1188
|
+
it("every flag is in the help of a command it is valid for", () => {
|
|
1189
|
+
const all = [renderHelp(), ...listedCommands().map((name) => helpFor("help", name))].join("")
|
|
1190
|
+
for (const name of FLAG_NAMES) expect(all).toContain(name)
|
|
1191
|
+
})
|
|
1192
|
+
|
|
1193
|
+
it("gtd <cmd> --help, gtd <cmd> -h and gtd help <cmd> print that command's own help", () => {
|
|
1194
|
+
const help = helpFor("help", "land")
|
|
1195
|
+
expect(help).toMatch(/^Usage: gtd land \[options\]\n/)
|
|
1196
|
+
expect(help).toContain("--cost=<n>")
|
|
1197
|
+
expect(help).not.toContain("--port")
|
|
1198
|
+
expect(helpFor("land", "--help")).toBe(help)
|
|
1199
|
+
expect(helpFor("land", "-h")).toBe(help)
|
|
1200
|
+
})
|
|
1201
|
+
|
|
1202
|
+
it("a two-word command's help is its own", () => {
|
|
1203
|
+
expect(helpFor("judge", "run", "--help")).toMatch(/^Usage: gtd judge run \[options\]/)
|
|
1204
|
+
expect(helpFor("help", "history", "show")).toMatch(/^Usage: gtd history show <ref>/)
|
|
1205
|
+
})
|
|
1206
|
+
|
|
1207
|
+
it("a flag that means something else per command is described per command", () => {
|
|
1208
|
+
expect(helpFor("help", "land")).toContain("With --cost: the model")
|
|
1209
|
+
expect(helpFor("help", "judge", "run")).toContain("The claude model")
|
|
1210
|
+
})
|
|
1211
|
+
|
|
1212
|
+
it("--help after an unknown command still prints the overview", () => {
|
|
1213
|
+
expect(helpFor("bogus", "--help")).toBe(renderHelp())
|
|
1214
|
+
})
|
|
1215
|
+
|
|
1216
|
+
it("gtd help <unknown> is a usage error naming the nearest command", () => {
|
|
1217
|
+
const plan = parseArgv(["node", "gtd.js", "help", "lnad"])
|
|
1218
|
+
expect(plan).toEqual({
|
|
1219
|
+
kind: "usage",
|
|
1220
|
+
stdout: "",
|
|
1221
|
+
message:
|
|
1222
|
+
"gtd: help: no command 'lnad' — did you mean `gtd help land`? Run `gtd help` for every command",
|
|
1223
|
+
json: false,
|
|
1224
|
+
})
|
|
1153
1225
|
})
|
|
1154
1226
|
|
|
1155
1227
|
it("the docs/cli.md Commands fenced block equals renderHelp()", () => {
|
|
@@ -1159,6 +1231,15 @@ describe("renderHelp", () => {
|
|
|
1159
1231
|
expect(match![1] + "\n").toBe(renderHelp())
|
|
1160
1232
|
})
|
|
1161
1233
|
|
|
1234
|
+
it("docs/cli.md's Every command section is every command's help, in the overview's order", () => {
|
|
1235
|
+
const doc = readFileSync(resolve(import.meta.dirname, "../../docs/cli.md"), "utf8")
|
|
1236
|
+
const section = doc.slice(doc.indexOf("\n## Every command\n"))
|
|
1237
|
+
const documented = [...section.matchAll(/^### `gtd ([^`]+)`\n\n```\n([\s\S]*?)```\n/gm)].map(
|
|
1238
|
+
([, name, help]) => [name, help],
|
|
1239
|
+
)
|
|
1240
|
+
expect(documented).toEqual(listedCommands().map((name) => [name, helpFor("help", name)]))
|
|
1241
|
+
})
|
|
1242
|
+
|
|
1162
1243
|
it("README.md and COMPATIBILITY.md list exactly the commands", () => {
|
|
1163
1244
|
const commands = new Set(
|
|
1164
1245
|
[...renderHelp().matchAll(/^ {2}([a-z]+(?:-[a-z]+)?(?: [a-z]+)?)(?= )/gm)].map(
|
|
@@ -1419,7 +1500,7 @@ describe("runCli — stdout stays byte-empty on every failing surface", () => {
|
|
|
1419
1500
|
const { stdout, stderr, exitCode } = captured()
|
|
1420
1501
|
expect(exitCode).toBe(EXIT_RUNTIME_ERROR)
|
|
1421
1502
|
expect(stdout).toBe("")
|
|
1422
|
-
expect(stderr).toContain("gtd
|
|
1503
|
+
expect(stderr).toContain("gtd: the repository has no commits yet")
|
|
1423
1504
|
})
|
|
1424
1505
|
|
|
1425
1506
|
it("runtime error: a handler's own Effect.fail (not the shared repo-root/commit guard) leaves stdout byte-empty", async () => {
|