@pmelab/gtd 23.2.0 → 24.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +44 -11
- package/claude/hooks/access.ts +1 -1
- package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
- package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
- package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
- package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
- package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
- package/dist/flows.mjs +525 -0
- package/dist/gtd.bundle.mjs +43830 -56044
- package/dist/steering.mjs +2 -0
- package/dist/testkit.mjs +13006 -0
- package/dist/wire.mjs +2 -0
- package/dist/workflow-kit.mjs +352 -0
- package/dist/workflows/feature.mjs +315 -0
- package/dist/workflows/fix.mjs +40 -0
- package/dist/workflows/review.mjs +25 -0
- package/dist/workflows.mjs +1953 -1724
- package/package.json +70 -8
- package/skills/authoring/SKILL.md +5 -4
- package/src/cli/Cli.test.ts +1329 -0
- package/src/cli/Cli.ts +1348 -0
- package/src/cli/Program.test.ts +2262 -0
- package/src/cli/Program.ts +1331 -0
- package/src/cli/ToolCommands.ts +43 -0
- package/src/cli/index.ts +14 -0
- package/src/edge/Edge.test.ts +1016 -0
- package/src/edge/Edge.ts +1225 -0
- package/src/edge/index.ts +16 -0
- package/src/emit/Emit.test.ts +425 -0
- package/src/emit/Emit.ts +139 -0
- package/src/emit/GitScript.test.ts +260 -0
- package/src/emit/GitScript.ts +131 -0
- package/src/emit/ModeContradiction.test.ts +120 -0
- package/src/emit/ModeContradiction.ts +66 -0
- package/src/emit/OutcomeScript.test.ts +238 -0
- package/src/emit/OutcomeScript.ts +73 -0
- package/src/emit/SteeringMode.test.ts +274 -0
- package/src/emit/SteeringMode.ts +256 -0
- package/src/emit/index.ts +40 -0
- package/src/flows/factory.ts +5 -1
- package/src/flows/runtime.ts +3 -0
- package/src/install/ConfigScaffold.ts +19 -0
- package/src/install/Install.test.ts +426 -0
- package/src/install/Install.ts +634 -0
- package/src/install/Version.ts +27 -0
- package/src/install/index.ts +3 -0
- package/src/platform/CommandRunner.ts +74 -0
- package/src/platform/Commentary.test.ts +126 -0
- package/src/platform/Commentary.ts +92 -0
- package/src/platform/Git.test.ts +50 -0
- package/src/platform/Git.ts +552 -0
- package/src/platform/Host.ts +42 -0
- package/src/platform/RetainedHistory.test.ts +65 -0
- package/src/platform/RetainedHistory.ts +34 -0
- package/src/platform/Workspace.test.ts +669 -0
- package/src/platform/Workspace.ts +631 -0
- package/src/platform/WorktreeState.test.ts +162 -0
- package/src/platform/WorktreeState.ts +50 -0
- package/src/platform/index.ts +22 -0
- package/src/replay/Access.test.ts +53 -0
- package/src/replay/Access.ts +46 -0
- package/src/replay/Format.test.ts +20 -0
- package/src/replay/Format.ts +14 -0
- package/src/replay/Replay.test.ts +887 -0
- package/src/replay/Replay.ts +669 -0
- package/src/replay/Trailers.test.ts +117 -0
- package/src/replay/Trailers.ts +213 -0
- package/src/replay/Tree.test.ts +25 -0
- package/src/replay/Tree.ts +45 -0
- package/src/replay/index.ts +11 -0
- package/src/steering/Blocks.fixture.ts +6 -0
- package/src/steering/Blocks.test.ts +167 -0
- package/src/steering/Blocks.ts +321 -0
- package/src/steering/CodeThreads.test.ts +127 -0
- package/src/steering/CodeThreads.ts +197 -0
- package/src/steering/Edits.test.ts +19 -0
- package/src/steering/Edits.ts +20 -0
- package/src/steering/Eol.test.ts +16 -0
- package/src/steering/Eol.ts +2 -0
- package/src/steering/FORMATS.md +58 -0
- package/src/steering/Footnotes.test.ts +977 -0
- package/src/steering/Footnotes.ts +661 -0
- package/src/steering/Inline.fixture.ts +7 -0
- package/src/steering/Inline.test.ts +227 -0
- package/src/steering/Inline.ts +195 -0
- package/src/steering/MarkdownTree.test.ts +247 -0
- package/src/steering/MarkdownTree.ts +260 -0
- package/src/steering/Outline.ts +19 -0
- package/src/steering/SteeringFormat.test.ts +91 -0
- package/src/steering/SteeringFormat.ts +267 -0
- package/src/steering/SteeringFormats.test.ts +163 -0
- package/src/steering/Threads.ts +163 -0
- package/src/steering/contract.test.ts +264 -0
- package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
- package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
- package/src/steering/fixtures/free-form/notes.md +12 -0
- package/src/steering/fixtures/free-form/notes.read.json +42 -0
- package/src/steering/fixtures/qa/answered.md +23 -0
- package/src/steering/fixtures/qa/answered.read.json +119 -0
- package/src/steering/fixtures/qa/requirements.md +160 -0
- package/src/steering/fixtures/qa/requirements.read.json +475 -0
- package/src/steering/fixtures/review/review.md +76 -0
- package/src/steering/fixtures/review/review.read.json +237 -0
- package/src/steering/fixtures/review/ticked.md +23 -0
- package/src/steering/fixtures/review/ticked.read.json +99 -0
- package/src/steering/fixtures/review-notes/baseline.md +14 -0
- package/src/steering/fixtures/review-notes/noted.md +19 -0
- package/src/steering/freeform.test.ts +311 -0
- package/src/steering/freeform.ts +148 -0
- package/src/steering/index.test.ts +62 -0
- package/src/steering/index.ts +140 -0
- package/src/steering/qa.test.ts +2160 -0
- package/src/steering/qa.ts +1104 -0
- package/src/steering/review.test.ts +1829 -0
- package/src/steering/review.ts +1180 -0
- package/src/step/LandStep.ts +31 -0
- package/src/step/RepoSnapshot.ts +29 -0
- package/src/step/accessRefusal.test.ts +58 -0
- package/src/step/accessRefusal.ts +26 -0
- package/src/step/index.ts +5 -0
- package/src/step/planStart.test.ts +202 -0
- package/src/step/planStart.ts +167 -0
- package/src/step/planStep.test.ts +104 -0
- package/src/step/planStep.ts +89 -0
- package/src/step/snapshot.fixture.ts +28 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
- package/src/testkit/EmittedScriptRecognizer.ts +642 -0
- package/src/testkit/FakeGitOperations.ts +95 -0
- package/src/testkit/GitTiers.test.ts +11 -0
- package/src/testkit/GitTiers.ts +1052 -0
- package/src/testkit/InMemRepo.ts +467 -0
- package/src/testkit/Layers.test.ts +58 -0
- package/src/testkit/Layers.ts +241 -0
- package/src/testkit/StrictGitOperations.ts +31 -0
- package/src/testkit/TestRepo.test.ts +119 -0
- package/src/testkit/TestRepo.ts +274 -0
- package/src/testkit/cliIo.ts +36 -0
- package/src/testkit/index.ts +9 -0
- package/src/testkit/steps.ts +177 -0
- package/src/testkit/testkit.feature +65 -0
- package/src/wire/BeatDocument.test.ts +419 -0
- package/src/wire/BeatDocument.ts +114 -0
- package/src/wire/BeatPlain.test.ts +113 -0
- package/src/wire/BeatPlain.ts +127 -0
- package/src/wire/BeatStatus.test.ts +59 -0
- package/src/wire/BeatStatus.ts +81 -0
- package/src/wire/Demand.test.ts +142 -0
- package/src/wire/Demand.ts +108 -0
- package/src/wire/ExitCodes.test.ts +21 -0
- package/src/wire/ExitCodes.ts +16 -0
- package/src/wire/Glob.ts +29 -0
- package/src/wire/Judge.ts +22 -0
- package/src/wire/Land.test.ts +120 -0
- package/src/wire/Land.ts +75 -0
- package/src/wire/Select.test.ts +148 -0
- package/src/wire/Select.ts +78 -0
- package/src/wire/Sessions.test.ts +59 -0
- package/src/wire/Sessions.ts +39 -0
- package/src/wire/WireSchema.test.ts +162 -0
- package/src/wire/constants.ts +22 -0
- package/src/wire/golden/1/beat-capture.json +34 -0
- package/src/wire/golden/1/beat-message.json +34 -0
- package/src/wire/golden/1/beat-prompt.json +44 -0
- package/src/wire/golden/1/beat-script.json +34 -0
- package/src/wire/golden/1/beat-stalled.json +34 -0
- package/src/wire/golden/1/judge.json +14 -0
- package/src/wire/golden/1/land-noop.json +10 -0
- package/src/wire/golden/1/land.json +10 -0
- package/src/wire/index.ts +12 -0
- package/src/wire/types.ts +46 -0
- package/src/workflow/Diagnostic.test.ts +93 -0
- package/src/workflow/Diagnostic.ts +54 -0
- package/src/workflow/SteeringFormats.test.ts +27 -0
- package/src/workflow/SteeringFormats.ts +6 -0
- package/src/workflow/Workflow.ts +77 -0
- package/src/workflow/compile.test.ts +53 -0
- package/src/workflow/compile.ts +56 -0
- package/src/workflow/discovery.ts +122 -0
- package/src/workflow/fingerprint.test.ts +52 -0
- package/src/workflow/fingerprint.ts +53 -0
- package/src/workflow/index.ts +33 -0
- package/src/workflow/load.test.ts +1017 -0
- package/src/workflow/load.ts +836 -0
- package/src/workflow/skills.test.ts +109 -0
- package/src/workflow/skills.ts +90 -0
- package/src/workflow/vars.ts +33 -0
- package/src/workflow-kit/health.test.ts +30 -0
- package/src/workflow-kit/health.ts +86 -0
- package/src/workflow-kit/index.ts +7 -0
- package/src/workflow-kit/quality.test.ts +17 -0
- package/src/workflow-kit/quality.ts +45 -0
- package/src/workflow-kit/review.ts +324 -0
- package/src/{workflows → workflow-kit}/scenarios.ts +21 -28
- package/src/workflows/feature/feature.test.ts +32 -0
- package/src/workflows/{bundled.ts → feature/feature.ts} +11 -63
- package/src/workflows/feature/index.ts +10 -0
- package/src/workflows/{packages.test.ts → feature/packages.test.ts} +2 -2
- package/src/workflows/{packages.ts → feature/packages.ts} +3 -3
- package/src/workflows/{planning.ts → feature/planning.ts} +2 -3
- package/src/workflows/{review.test.ts → feature/review.test.ts} +9 -44
- package/src/workflows/feature/review.ts +106 -0
- package/src/workflows/feature/steps.test.ts +82 -0
- package/src/workflows/feature/steps.ts +92 -0
- package/src/workflows/fix/fix.feature +80 -0
- package/src/workflows/fix/fix.test.ts +74 -0
- package/src/workflows/fix/fix.ts +52 -0
- package/src/workflows/fix/index.ts +6 -0
- package/src/workflows/index.ts +36 -2
- package/src/workflows/review/index.ts +6 -0
- package/src/workflows/review/review.test.ts +99 -0
- package/src/workflows/review/review.ts +33 -0
- package/src/workflows/{access.test.ts → shared/access.test.ts} +1 -1
- package/src/workflows/{access.ts → shared/access.ts} +3 -5
- package/src/workflows/shared/config.fixture.ts +12 -0
- package/src/workflows/{health.test.ts → shared/health.test.ts} +1 -1
- package/src/workflows/{health.ts → shared/health.ts} +38 -55
- package/src/workflows/{options.test.ts → shared/options.test.ts} +2 -2
- package/src/workflows/{options.ts → shared/options.ts} +14 -2
- package/src/workflows/shared/reserved.ts +11 -0
- package/src/workflows/{scenarios.test.ts → shared/scenarios.test.ts} +1 -1
- package/src/workflows/shared/scenarios.ts +35 -0
- package/src/workflows/{skills.test.ts → shared/skills.test.ts} +3 -1
- package/src/workflows/{skills.ts → shared/skills.ts} +7 -6
- package/src/workflows/{steps.test.ts → shared/steps.test.ts} +1 -66
- package/src/workflows/{steps.ts → shared/steps.ts} +8 -98
- package/src/workflows/shared/tail.test.ts +205 -0
- package/src/workflows/shared/tail.ts +135 -0
- package/src/workflows/{text.fixture.ts → shared/text.fixture.ts} +2 -2
- package/src/workflows/{text.ts → shared/text.ts} +48 -7
- package/src/workflows/{threads.test.ts → shared/threads.test.ts} +1 -1
- package/src/workflows/shared/workflows.steps.ts +7 -0
- package/src/workflows/review.ts +0 -362
- /package/src/workflows/{prose.ts → shared/prose.ts} +0 -0
- /package/src/workflows/{text.test.ts → shared/text.test.ts} +0 -0
- /package/src/workflows/{vars.ts → shared/vars.ts} +0 -0
|
@@ -0,0 +1,1329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse-tier unit tests for src/cli/Cli.ts — `parseArgv` is pure and total, so
|
|
3
|
+
* these need no repo, no layers, and no `Effect.runPromise`. Runtime-tier
|
|
4
|
+
* tests (`runCli` through a capturing `CliIo`) live in the second `describe`
|
|
5
|
+
* block below.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from "node:fs"
|
|
8
|
+
import { resolve } from "node:path"
|
|
9
|
+
import * as fc from "fast-check"
|
|
10
|
+
import { Effect, Layer } from "effect"
|
|
11
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
12
|
+
import {
|
|
13
|
+
needsOf,
|
|
14
|
+
nodeCliIo,
|
|
15
|
+
normalizeTrailingNewline,
|
|
16
|
+
parseArgv,
|
|
17
|
+
renderHelp,
|
|
18
|
+
runCli,
|
|
19
|
+
standaloneKinds,
|
|
20
|
+
type CliIo,
|
|
21
|
+
type Command,
|
|
22
|
+
type CliPlan,
|
|
23
|
+
ToolCommands,
|
|
24
|
+
} from "./index.js"
|
|
25
|
+
import { EXIT_CODES, EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR } from "../wire/index.js"
|
|
26
|
+
import { type CommandRequirements } from "./Cli.js"
|
|
27
|
+
import { InMemRepo, testLayers } from "../testkit/index.js"
|
|
28
|
+
import { CONFIG_SCAFFOLD } from "../install/index.js"
|
|
29
|
+
|
|
30
|
+
const FLAG_NAMES = [
|
|
31
|
+
"--json",
|
|
32
|
+
"--port",
|
|
33
|
+
"--host",
|
|
34
|
+
"--self-signed",
|
|
35
|
+
"--dev",
|
|
36
|
+
"--cost",
|
|
37
|
+
"--model",
|
|
38
|
+
"--workflow",
|
|
39
|
+
"--var",
|
|
40
|
+
"--open-questions",
|
|
41
|
+
"--open-threads",
|
|
42
|
+
"--verbose",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
describe("parseArgv — unknown options", () => {
|
|
46
|
+
it("any `--` token not in the flag table forces kind === 'usage' (property)", () => {
|
|
47
|
+
fc.assert(
|
|
48
|
+
fc.property(
|
|
49
|
+
fc.array(
|
|
50
|
+
fc.string({ minLength: 1, maxLength: 12 }).filter((s) => !s.includes("=")),
|
|
51
|
+
{
|
|
52
|
+
maxLength: 6,
|
|
53
|
+
},
|
|
54
|
+
),
|
|
55
|
+
(words) => {
|
|
56
|
+
const bogus = "--totally-bogus-flag"
|
|
57
|
+
const argv = ["node", "gtd.js", ...words, bogus]
|
|
58
|
+
if (argv.includes("--help") || argv.includes("-h")) return
|
|
59
|
+
if (argv.includes("--version") || argv.includes("-V")) return
|
|
60
|
+
if (argv.includes("-v")) return
|
|
61
|
+
if (FLAG_NAMES.some((f) => argv.includes(f))) return
|
|
62
|
+
const plan: CliPlan = parseArgv(argv)
|
|
63
|
+
expect(plan.kind).toBe("usage")
|
|
64
|
+
},
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("parseArgv(['node','gtd.js','next','--jsn']) is the exact usage plan", () => {
|
|
70
|
+
const plan = parseArgv(["node", "gtd.js", "next", "--jsn"])
|
|
71
|
+
expect(plan).toEqual({
|
|
72
|
+
kind: "usage",
|
|
73
|
+
stdout: "",
|
|
74
|
+
message: "gtd: unknown option '--jsn' — see `gtd --help`",
|
|
75
|
+
json: false,
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe("parseArgv — arity", () => {
|
|
81
|
+
it("`gtd land human` is an arity error (land takes no positional)", () => {
|
|
82
|
+
const plan = parseArgv(["node", "gtd.js", "land", "human"])
|
|
83
|
+
expect(plan.kind).toBe("usage")
|
|
84
|
+
if (plan.kind === "usage") expect(plan.message).toContain("too many arguments")
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("`gtd next extra` is a usage error (next takes no argument)", () => {
|
|
88
|
+
const plan = parseArgv(["node", "gtd.js", "next", "extra"])
|
|
89
|
+
expect(plan.kind).toBe("usage")
|
|
90
|
+
if (plan.kind === "usage") expect(plan.message).toContain("too many arguments")
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("`gtd next --at` is an unknown-option usage error", () => {
|
|
94
|
+
const plan = parseArgv(["node", "gtd.js", "next", "--at"])
|
|
95
|
+
expect(plan.kind).toBe("usage")
|
|
96
|
+
if (plan.kind === "usage") expect(plan.message).toContain("unknown option '--at'")
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe("parseArgv — scope", () => {
|
|
101
|
+
it("--cost on an unknown command reports the flag's own scope error, not the unknown command", () => {
|
|
102
|
+
const plan = parseArgv(["node", "gtd.js", "frobnicate", "--cost=5"])
|
|
103
|
+
expect(plan.kind).toBe("usage")
|
|
104
|
+
if (plan.kind === "usage") expect(plan.message).toContain("only valid for `gtd land`")
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("--json is in scope for next, land, judge and judge answer — every other command usage-errors on it", () => {
|
|
108
|
+
for (const args of [
|
|
109
|
+
["next", "--json"],
|
|
110
|
+
["land", "--json"],
|
|
111
|
+
["judge", "--json"],
|
|
112
|
+
["judge", "answer", "--json"],
|
|
113
|
+
["doors", "--json"],
|
|
114
|
+
]) {
|
|
115
|
+
const ok = parseArgv(["node", "gtd.js", ...args])
|
|
116
|
+
expect(ok.kind).toBe("command")
|
|
117
|
+
if (ok.kind === "command") expect(ok.json).toEqual({ kind: "document" })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const args of [
|
|
121
|
+
["lsp", "--json"],
|
|
122
|
+
["validate", "--json"],
|
|
123
|
+
["check", "qa", "TODO.md", "--json"],
|
|
124
|
+
["install", "--json"],
|
|
125
|
+
["abandon", "--json"],
|
|
126
|
+
["restore", "--json"],
|
|
127
|
+
["--workflow", "some-state", "--json"],
|
|
128
|
+
]) {
|
|
129
|
+
const plan = parseArgv(["node", "gtd.js", ...args])
|
|
130
|
+
expect(plan.kind).toBe("usage")
|
|
131
|
+
if (plan.kind === "usage") {
|
|
132
|
+
expect(plan.message).toContain("only valid for `gtd next`/`gtd land`")
|
|
133
|
+
expect(plan.message).toContain("gtd install")
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("--sh is gone: a bare unknown-option usage error on next and land, exit 2", () => {
|
|
139
|
+
for (const args of [
|
|
140
|
+
["next", "--sh"],
|
|
141
|
+
["land", "--sh"],
|
|
142
|
+
]) {
|
|
143
|
+
const plan = parseArgv(["node", "gtd.js", ...args])
|
|
144
|
+
expect(plan.kind).toBe("usage")
|
|
145
|
+
if (plan.kind === "usage") {
|
|
146
|
+
expect(plan.message).toContain("unknown option '--sh'")
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("--port on land is rejected", () => {
|
|
152
|
+
const plan = parseArgv(["node", "gtd.js", "land", "--port=1234"])
|
|
153
|
+
expect(plan.kind).toBe("usage")
|
|
154
|
+
if (plan.kind === "usage") expect(plan.message).toContain("only valid for `gtd ui`")
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("--port is accepted by gtd ui", () => {
|
|
158
|
+
expect(parseArgv(["node", "gtd.js", "ui", "--port", "3000"]).kind).toBe("command")
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("--host on any other command (e.g. next) is a scope violation", () => {
|
|
162
|
+
const plan = parseArgv(["node", "gtd.js", "next", "--host", "x"])
|
|
163
|
+
expect(plan.kind).toBe("usage")
|
|
164
|
+
if (plan.kind === "usage") {
|
|
165
|
+
expect(plan.message).toBe("gtd: --host is only valid for `gtd ui`")
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it("--var without --workflow is rejected", () => {
|
|
170
|
+
const plan = parseArgv(["node", "gtd.js", "land", "--var", "a=1"])
|
|
171
|
+
expect(plan.kind).toBe("usage")
|
|
172
|
+
if (plan.kind === "usage") expect(plan.message).toBe("gtd: --var requires --workflow")
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it("--cost with --workflow is rejected (landing and starting are different verbs)", () => {
|
|
176
|
+
const plan = parseArgv(["node", "gtd.js", "--workflow", "foo", "--cost=5"])
|
|
177
|
+
expect(plan.kind).toBe("usage")
|
|
178
|
+
if (plan.kind === "usage") expect(plan.message).toContain("only valid for `gtd land`")
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it("--model with --workflow is rejected the same way", () => {
|
|
182
|
+
const plan = parseArgv(["node", "gtd.js", "--workflow", "foo", "--model=gpt"])
|
|
183
|
+
expect(plan.kind).toBe("usage")
|
|
184
|
+
if (plan.kind === "usage") expect(plan.message).toContain("only valid for `gtd land`")
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it("--workflow on `gtd land` (or any other command) is rejected — landing and starting are different verbs", () => {
|
|
188
|
+
for (const args of [
|
|
189
|
+
["validate", "--workflow", "e"],
|
|
190
|
+
["land", "--workflow", "e"],
|
|
191
|
+
]) {
|
|
192
|
+
const plan = parseArgv(["node", "gtd.js", ...args])
|
|
193
|
+
expect(plan.kind).toBe("usage")
|
|
194
|
+
if (plan.kind === "usage") {
|
|
195
|
+
expect(plan.message).toBe(
|
|
196
|
+
"gtd: --workflow is only valid with no other command — use the bare `gtd --workflow <name>` " +
|
|
197
|
+
"form; landing and starting are different verbs",
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it("--model without --cost is a usage error", () => {
|
|
204
|
+
const plan = parseArgv(["node", "gtd.js", "land", "--model=opus"])
|
|
205
|
+
expect(plan.kind).toBe("usage")
|
|
206
|
+
if (plan.kind === "usage") expect(plan.message).toContain("--model requires --cost")
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it("--dispatch is gone: an unknown-option usage error everywhere, `gtd next` included", () => {
|
|
210
|
+
for (const args of [
|
|
211
|
+
["land", "--dispatch"],
|
|
212
|
+
["validate", "--dispatch"],
|
|
213
|
+
["next", "--json", "--dispatch"],
|
|
214
|
+
]) {
|
|
215
|
+
const plan = parseArgv(["node", "gtd.js", ...args])
|
|
216
|
+
expect(plan.kind).toBe("usage")
|
|
217
|
+
if (plan.kind === "usage") {
|
|
218
|
+
expect(plan.message).toContain("unknown option '--dispatch'")
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
describe("parseArgv — gtd next", () => {
|
|
225
|
+
it("parses to a bare next command — there is no separate claiming form any more", () => {
|
|
226
|
+
const plan = parseArgv(["node", "gtd.js", "next"])
|
|
227
|
+
expect(plan.kind).toBe("command")
|
|
228
|
+
if (plan.kind === "command") {
|
|
229
|
+
expect(plan.command).toEqual({ kind: "next" })
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it("gtd next --json parses to a next command with json set", () => {
|
|
234
|
+
const plan = parseArgv(["node", "gtd.js", "next", "--json"])
|
|
235
|
+
expect(plan.kind).toBe("command")
|
|
236
|
+
if (plan.kind === "command") {
|
|
237
|
+
expect(plan.command).toEqual({ kind: "next" })
|
|
238
|
+
expect(plan.json).toEqual({ kind: "document" })
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it("gtd next --json=kind parses to JsonMode select with path 'kind'", () => {
|
|
243
|
+
const plan = parseArgv(["node", "gtd.js", "next", "--json=kind"])
|
|
244
|
+
expect(plan.kind).toBe("command")
|
|
245
|
+
if (plan.kind === "command") {
|
|
246
|
+
expect(plan.command).toEqual({ kind: "next" })
|
|
247
|
+
expect(plan.json).toEqual({ kind: "select", path: "kind" })
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it("gtd next with no --json parses to JsonMode off", () => {
|
|
252
|
+
const plan = parseArgv(["node", "gtd.js", "next"])
|
|
253
|
+
expect(plan.kind).toBe("command")
|
|
254
|
+
if (plan.kind === "command") expect(plan.json).toEqual({ kind: "off" })
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it("gtd next --json=session.id parses to a dotted selector path verbatim", () => {
|
|
258
|
+
const plan = parseArgv(["node", "gtd.js", "next", "--json=session.id"])
|
|
259
|
+
expect(plan.kind).toBe("command")
|
|
260
|
+
if (plan.kind === "command") expect(plan.json).toEqual({ kind: "select", path: "session.id" })
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it("gtd next --json= is a usage error naming only the legal --json=<path> form — never the rejected empty form or the illegal space form", () => {
|
|
264
|
+
const plan = parseArgv(["node", "gtd.js", "next", "--json="])
|
|
265
|
+
expect(plan.kind).toBe("usage")
|
|
266
|
+
if (plan.kind === "usage") {
|
|
267
|
+
expect(plan.message).toBe("gtd: --json requires a value — use --json=<path>")
|
|
268
|
+
expect(plan.json).toBe(true)
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it("gtd next --json kind (space form) leaves kind as a positional — next takes none, so this is a usage error", () => {
|
|
273
|
+
const plan = parseArgv(["node", "gtd.js", "next", "--json", "kind"])
|
|
274
|
+
expect(plan.kind).toBe("usage")
|
|
275
|
+
if (plan.kind === "usage") expect(plan.message).toContain("too many arguments")
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it("--json is non-repeatable: a second bare occurrence is a usage error", () => {
|
|
279
|
+
const plan = parseArgv(["node", "gtd.js", "next", "--json", "--json"])
|
|
280
|
+
expect(plan.kind).toBe("usage")
|
|
281
|
+
if (plan.kind === "usage") expect(plan.message).toContain("--json may be given at most once")
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it("--json is non-repeatable: two selector occurrences are a usage error", () => {
|
|
285
|
+
const plan = parseArgv(["node", "gtd.js", "next", "--json=a", "--json=b"])
|
|
286
|
+
expect(plan.kind).toBe("usage")
|
|
287
|
+
if (plan.kind === "usage") expect(plan.message).toContain("--json may be given at most once")
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it("--json is non-repeatable across mixed bare/selector forms", () => {
|
|
291
|
+
const plan = parseArgv(["node", "gtd.js", "next", "--json", "--json=b"])
|
|
292
|
+
expect(plan.kind).toBe("usage")
|
|
293
|
+
if (plan.kind === "usage") expect(plan.message).toContain("--json may be given at most once")
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
describe("parseArgv — gtd land", () => {
|
|
298
|
+
it("gtd land parses to a bare land command", () => {
|
|
299
|
+
const plan = parseArgv(["node", "gtd.js", "land"])
|
|
300
|
+
expect(plan.kind).toBe("command")
|
|
301
|
+
if (plan.kind === "command") {
|
|
302
|
+
expect(plan.command).toEqual({ kind: "land" })
|
|
303
|
+
}
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it("gtd land --cost=5 --model=opus carries both, omitting neither key", () => {
|
|
307
|
+
const plan = parseArgv(["node", "gtd.js", "land", "--cost=5", "--model=opus"])
|
|
308
|
+
expect(plan.kind).toBe("command")
|
|
309
|
+
if (plan.kind === "command") {
|
|
310
|
+
expect(plan.command).toEqual({ kind: "land", cost: 5, model: "opus" })
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it("gtd land --json parses to a land command with json set", () => {
|
|
315
|
+
const plan = parseArgv(["node", "gtd.js", "land", "--json"])
|
|
316
|
+
expect(plan.kind).toBe("command")
|
|
317
|
+
if (plan.kind === "command") {
|
|
318
|
+
expect(plan.command).toEqual({ kind: "land" })
|
|
319
|
+
expect(plan.json).toEqual({ kind: "document" })
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it("gtd land --json=script parses to JsonMode select with path 'script'", () => {
|
|
324
|
+
const plan = parseArgv(["node", "gtd.js", "land", "--json=script"])
|
|
325
|
+
expect(plan.kind).toBe("command")
|
|
326
|
+
if (plan.kind === "command") expect(plan.json).toEqual({ kind: "select", path: "script" })
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it("gtd land --json=model and gtd land --model=<name> do not collide — different tokens", () => {
|
|
330
|
+
const readBack = parseArgv(["node", "gtd.js", "land", "--json=model"])
|
|
331
|
+
expect(readBack.kind).toBe("command")
|
|
332
|
+
if (readBack.kind === "command") {
|
|
333
|
+
expect(readBack.json).toEqual({ kind: "select", path: "model" })
|
|
334
|
+
expect(readBack.command).toEqual({ kind: "land" })
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const record = parseArgv(["node", "gtd.js", "land", "--model=opus", "--cost=1"])
|
|
338
|
+
expect(record.kind).toBe("command")
|
|
339
|
+
if (record.kind === "command") {
|
|
340
|
+
expect(record.json).toEqual({ kind: "off" })
|
|
341
|
+
expect(record.command).toEqual({ kind: "land", cost: 1, model: "opus" })
|
|
342
|
+
}
|
|
343
|
+
})
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
describe("parseArgv — flag orthogonality", () => {
|
|
347
|
+
it("--version with --json still produces the version output (flags are independent)", () => {
|
|
348
|
+
const plan = parseArgv(["node", "gtd.js", "--version", "--json"])
|
|
349
|
+
expect(plan.kind).toBe("output")
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it("--help with extra args still prints help", () => {
|
|
353
|
+
const plan = parseArgv(["node", "gtd.js", "--help", "--json"])
|
|
354
|
+
expect(plan.kind).toBe("output")
|
|
355
|
+
})
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
describe("parseArgv — bare/unknown command under --json", () => {
|
|
359
|
+
// With no command resolved, `kind` is `undefined` — `--json`'s scope is
|
|
360
|
+
// violated just like any other scoped flag used standalone, so its scope
|
|
361
|
+
// error takes priority over "missing command"/"unknown command" here.
|
|
362
|
+
it("bare gtd --json is a usage error with json:true, naming --json's own scope error", () => {
|
|
363
|
+
const plan = parseArgv(["node", "gtd.js", "--json"])
|
|
364
|
+
expect(plan.kind).toBe("usage")
|
|
365
|
+
if (plan.kind === "usage") {
|
|
366
|
+
expect(plan.json).toBe(true)
|
|
367
|
+
expect(plan.message).toContain("only valid for `gtd next`")
|
|
368
|
+
}
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it("bare gtd's missing-command message (no --json) points at gtd install, not a bundled loop", () => {
|
|
372
|
+
const plan = parseArgv(["node", "gtd.js"])
|
|
373
|
+
expect(plan.kind).toBe("usage")
|
|
374
|
+
if (plan.kind === "usage") {
|
|
375
|
+
expect(plan.message).toContain("missing command")
|
|
376
|
+
expect(plan.message).toContain("gtd decides and prints")
|
|
377
|
+
expect(plan.message).toContain("gtd install")
|
|
378
|
+
expect(plan.message).toContain("A complete minimal driver")
|
|
379
|
+
}
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it("an unknown command under --json carries json:true, naming --json's own scope error over the subcommand", () => {
|
|
383
|
+
const plan = parseArgv(["node", "gtd.js", "bogus", "--json"])
|
|
384
|
+
expect(plan.kind).toBe("usage")
|
|
385
|
+
if (plan.kind === "usage") {
|
|
386
|
+
expect(plan.json).toBe(true)
|
|
387
|
+
expect(plan.message).toContain("only valid for `gtd next`")
|
|
388
|
+
}
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it("an unknown command without --json still names the subcommand", () => {
|
|
392
|
+
const plan = parseArgv(["node", "gtd.js", "bogus"])
|
|
393
|
+
expect(plan.kind).toBe("usage")
|
|
394
|
+
if (plan.kind === "usage") {
|
|
395
|
+
expect(plan.json).toBe(false)
|
|
396
|
+
expect(plan.message).toContain("bogus")
|
|
397
|
+
}
|
|
398
|
+
})
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
describe("parseArgv — entry/var flag details (formerly parseEntryFlags/takeFlagValues)", () => {
|
|
402
|
+
it("accepts --workflow=<state> (= form)", () => {
|
|
403
|
+
const plan = parseArgv(["node", "gtd.js", "--workflow=side-entry"])
|
|
404
|
+
expect(plan.kind).toBe("command")
|
|
405
|
+
if (plan.kind === "command") {
|
|
406
|
+
expect(plan.command).toEqual({
|
|
407
|
+
kind: "start",
|
|
408
|
+
actor: "human",
|
|
409
|
+
workflow: "side-entry",
|
|
410
|
+
vars: {},
|
|
411
|
+
label: "gtd --workflow side-entry",
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it("a bare --workflow with no value is a usage error", () => {
|
|
417
|
+
const plan = parseArgv(["node", "gtd.js", "--workflow"])
|
|
418
|
+
expect(plan.kind).toBe("usage")
|
|
419
|
+
if (plan.kind === "usage") expect(plan.message).toContain("--workflow requires a value")
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it("a second --workflow occurrence is a usage error (not last-wins)", () => {
|
|
423
|
+
const plan = parseArgv(["node", "gtd.js", "--workflow", "a", "--workflow", "b"])
|
|
424
|
+
expect(plan.kind).toBe("usage")
|
|
425
|
+
if (plan.kind === "usage")
|
|
426
|
+
expect(plan.message).toContain("--workflow may be given at most once")
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it("a duplicate --var NAME is a usage error", () => {
|
|
430
|
+
const plan = parseArgv(["node", "gtd.js", "--workflow", "e", "--var", "a=1", "--var", "a=2"])
|
|
431
|
+
expect(plan.kind).toBe("usage")
|
|
432
|
+
if (plan.kind === "usage") expect(plan.message).toContain("--var a")
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it("a --var name outside the setting-name rule is a usage error naming it", () => {
|
|
436
|
+
for (const raw of ["a b=1", "a-b=1", "1a=1", "a\nb=1"]) {
|
|
437
|
+
const plan = parseArgv(["node", "gtd.js", "--workflow", "e", "--var", raw])
|
|
438
|
+
expect(plan.kind).toBe("usage")
|
|
439
|
+
if (plan.kind === "usage") {
|
|
440
|
+
expect(plan.message).toContain(
|
|
441
|
+
`--var ${JSON.stringify(raw.slice(0, raw.indexOf("=")))} is not a valid setting name`,
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it("a multiline --var value is a usage error", () => {
|
|
448
|
+
const plan = parseArgv(["node", "gtd.js", "--workflow", "e", "--var", "a=1\n2"])
|
|
449
|
+
expect(plan.kind).toBe("usage")
|
|
450
|
+
})
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
describe("parseArgv — --entry is removed", () => {
|
|
454
|
+
it("is an ordinary unknown option, with no hint at --workflow", () => {
|
|
455
|
+
const plan = parseArgv(["node", "gtd.js", "--entry", "fix-precheck"])
|
|
456
|
+
expect(plan.kind).toBe("usage")
|
|
457
|
+
if (plan.kind === "usage") {
|
|
458
|
+
expect(plan.message).toContain("--entry")
|
|
459
|
+
expect(plan.message).not.toContain("--workflow")
|
|
460
|
+
}
|
|
461
|
+
})
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
describe("parseArgv — the --workflow selector", () => {
|
|
465
|
+
it("gtd --workflow version parses to { kind: start, workflow: version } — the regression this RFC fixes", () => {
|
|
466
|
+
const plan = parseArgv(["node", "gtd.js", "--workflow", "version"])
|
|
467
|
+
expect(plan.kind).toBe("command")
|
|
468
|
+
if (plan.kind === "command") {
|
|
469
|
+
expect(plan.command).toMatchObject({ kind: "start", workflow: "version" })
|
|
470
|
+
}
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
it("gtd --workflow help likewise parses to a start command, not the help text", () => {
|
|
474
|
+
const plan = parseArgv(["node", "gtd.js", "--workflow", "help"])
|
|
475
|
+
expect(plan.kind).toBe("command")
|
|
476
|
+
if (plan.kind === "command") {
|
|
477
|
+
expect(plan.command).toMatchObject({ kind: "start", workflow: "help" })
|
|
478
|
+
}
|
|
479
|
+
})
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
describe("parseArgv — gtd check <mode> <file>", () => {
|
|
483
|
+
it("parses to a check command carrying mode and file", () => {
|
|
484
|
+
const plan = parseArgv(["node", "gtd.js", "check", "qa", ".gtd/TODO.md"])
|
|
485
|
+
expect(plan.kind).toBe("command")
|
|
486
|
+
if (plan.kind === "command") {
|
|
487
|
+
expect(plan.command).toEqual({ kind: "check", mode: "qa", file: ".gtd/TODO.md" })
|
|
488
|
+
expect(plan.json).toEqual({ kind: "off" })
|
|
489
|
+
}
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
it("--json is out of scope for check — next/land are the only structured surfaces", () => {
|
|
493
|
+
const plan = parseArgv(["node", "gtd.js", "check", "review", "REVIEW.md", "--json"])
|
|
494
|
+
expect(plan.kind).toBe("usage")
|
|
495
|
+
if (plan.kind === "usage") expect(plan.message).toContain("only valid for `gtd next`")
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it("bare --open-threads (no mode, no file) parses to a check command", () => {
|
|
499
|
+
const plan = parseArgv(["node", "gtd.js", "check", "--open-threads"])
|
|
500
|
+
expect(plan.kind).toBe("command")
|
|
501
|
+
if (plan.kind === "command") {
|
|
502
|
+
expect(plan.command).toEqual({ kind: "check", openThreads: true })
|
|
503
|
+
}
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it("the <mode> <file> --open-threads form is unchanged", () => {
|
|
507
|
+
const plan = parseArgv(["node", "gtd.js", "check", "qa", "N.md", "--open-threads"])
|
|
508
|
+
expect(plan.kind === "command" && plan.command).toEqual({
|
|
509
|
+
kind: "check",
|
|
510
|
+
mode: "qa",
|
|
511
|
+
file: "N.md",
|
|
512
|
+
openThreads: true,
|
|
513
|
+
})
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it("missing both arguments is a usage error naming mode and file", () => {
|
|
517
|
+
const plan = parseArgv(["node", "gtd.js", "check"])
|
|
518
|
+
expect(plan.kind).toBe("usage")
|
|
519
|
+
if (plan.kind === "usage") {
|
|
520
|
+
expect(plan.message).toContain("missing mode and file arguments")
|
|
521
|
+
}
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
it("missing the file argument (mode only) is a usage error naming file", () => {
|
|
525
|
+
const plan = parseArgv(["node", "gtd.js", "check", "qa"])
|
|
526
|
+
expect(plan.kind).toBe("usage")
|
|
527
|
+
if (plan.kind === "usage") {
|
|
528
|
+
expect(plan.message).toContain("missing file argument")
|
|
529
|
+
}
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
it("too many arguments is a usage error", () => {
|
|
533
|
+
const plan = parseArgv(["node", "gtd.js", "check", "qa", "TODO.md", "extra"])
|
|
534
|
+
expect(plan.kind).toBe("usage")
|
|
535
|
+
if (plan.kind === "usage") {
|
|
536
|
+
expect(plan.message).toContain("too many arguments")
|
|
537
|
+
expect(plan.message).toContain("extra")
|
|
538
|
+
}
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it("a scoped-out flag (e.g. --cost) is rejected on check", () => {
|
|
542
|
+
const plan = parseArgv(["node", "gtd.js", "check", "qa", "TODO.md", "--cost=5"])
|
|
543
|
+
expect(plan.kind).toBe("usage")
|
|
544
|
+
if (plan.kind === "usage") expect(plan.message).toContain("only valid for `gtd land`")
|
|
545
|
+
})
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
describe("parseArgv — gtd uncheck <file>", () => {
|
|
549
|
+
it("parses to an uncheck command carrying file", () => {
|
|
550
|
+
const plan = parseArgv(["node", "gtd.js", "uncheck", ".gtd/REVIEW.md"])
|
|
551
|
+
expect(plan.kind).toBe("command")
|
|
552
|
+
if (plan.kind === "command") {
|
|
553
|
+
expect(plan.command).toEqual({ kind: "uncheck", file: ".gtd/REVIEW.md" })
|
|
554
|
+
expect(plan.json).toEqual({ kind: "off" })
|
|
555
|
+
}
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
it("missing the file argument is a usage error", () => {
|
|
559
|
+
const plan = parseArgv(["node", "gtd.js", "uncheck"])
|
|
560
|
+
expect(plan.kind).toBe("usage")
|
|
561
|
+
if (plan.kind === "usage") expect(plan.message).toContain("missing file argument")
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
it("too many arguments is a usage error", () => {
|
|
565
|
+
const plan = parseArgv(["node", "gtd.js", "uncheck", "REVIEW.md", "extra"])
|
|
566
|
+
expect(plan.kind).toBe("usage")
|
|
567
|
+
if (plan.kind === "usage") {
|
|
568
|
+
expect(plan.message).toContain("too many arguments")
|
|
569
|
+
expect(plan.message).toContain("extra")
|
|
570
|
+
}
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
it("takes no mode argument — a second positional is rejected as extra, never accepted as mode", () => {
|
|
574
|
+
const plan = parseArgv(["node", "gtd.js", "uncheck", "qa", "REVIEW.md"])
|
|
575
|
+
expect(plan.kind).toBe("usage")
|
|
576
|
+
if (plan.kind === "usage") expect(plan.message).toContain("too many arguments")
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
it("a scoped-out flag (e.g. --cost) is rejected on uncheck", () => {
|
|
580
|
+
const plan = parseArgv(["node", "gtd.js", "uncheck", "REVIEW.md", "--cost=5"])
|
|
581
|
+
expect(plan.kind).toBe("usage")
|
|
582
|
+
if (plan.kind === "usage") expect(plan.message).toContain("only valid for `gtd land`")
|
|
583
|
+
})
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
describe("parseArgv — gtd check --open-questions", () => {
|
|
587
|
+
it("parses to a check command carrying openQuestions: true", () => {
|
|
588
|
+
const plan = parseArgv(["node", "gtd.js", "check", "qa", ".gtd/TODO.md", "--open-questions"])
|
|
589
|
+
expect(plan.kind).toBe("command")
|
|
590
|
+
if (plan.kind === "command") {
|
|
591
|
+
expect(plan.command).toEqual({
|
|
592
|
+
kind: "check",
|
|
593
|
+
mode: "qa",
|
|
594
|
+
file: ".gtd/TODO.md",
|
|
595
|
+
openQuestions: true,
|
|
596
|
+
})
|
|
597
|
+
}
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
it("omits openQuestions entirely when the flag is absent (unchanged shape)", () => {
|
|
601
|
+
const plan = parseArgv(["node", "gtd.js", "check", "qa", ".gtd/TODO.md"])
|
|
602
|
+
expect(plan.kind).toBe("command")
|
|
603
|
+
if (plan.kind === "command") {
|
|
604
|
+
expect(plan.command).toEqual({ kind: "check", mode: "qa", file: ".gtd/TODO.md" })
|
|
605
|
+
expect("openQuestions" in plan.command).toBe(false)
|
|
606
|
+
}
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
it("--open-questions on any other command is a usage error carrying its own scopeError", () => {
|
|
610
|
+
const plan = parseArgv(["node", "gtd.js", "land", "--open-questions"])
|
|
611
|
+
expect(plan.kind).toBe("usage")
|
|
612
|
+
if (plan.kind === "usage") {
|
|
613
|
+
expect(plan.message).toBe("gtd: --open-questions is only valid for `gtd check`")
|
|
614
|
+
}
|
|
615
|
+
})
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
describe("parseArgv — help/version short-circuits", () => {
|
|
619
|
+
it("--help produces an output plan", () => {
|
|
620
|
+
expect(parseArgv(["node", "gtd.js", "--help"]).kind).toBe("output")
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
it("--version produces an output plan containing a semver", () => {
|
|
624
|
+
const plan = parseArgv(["node", "gtd.js", "--version"])
|
|
625
|
+
expect(plan.kind).toBe("output")
|
|
626
|
+
if (plan.kind === "output") expect(plan.stdout).toMatch(/\d+\.\d+\.\d+/)
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
it("the `version` subcommand produces the same output as --version", () => {
|
|
630
|
+
const a = parseArgv(["node", "gtd.js", "--version"])
|
|
631
|
+
const b = parseArgv(["node", "gtd.js", "version"])
|
|
632
|
+
expect(a).toEqual(b)
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
it("the `help` subcommand produces the same output as --help", () => {
|
|
636
|
+
const a = parseArgv(["node", "gtd.js", "--help"])
|
|
637
|
+
const b = parseArgv(["node", "gtd.js", "help"])
|
|
638
|
+
expect(a).toEqual(b)
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
it("gtd bogus --help still prints help — help wins over an unknown command", () => {
|
|
642
|
+
const plan = parseArgv(["node", "gtd.js", "bogus", "--help"])
|
|
643
|
+
expect(plan.kind).toBe("output")
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
it("-V short-circuits to the version output, exactly like --version", () => {
|
|
647
|
+
const a = parseArgv(["node", "gtd.js", "-V"])
|
|
648
|
+
const b = parseArgv(["node", "gtd.js", "--version"])
|
|
649
|
+
expect(a).toEqual(b)
|
|
650
|
+
})
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
describe("parseArgv — --verbose / -v (the -v/-V swap)", () => {
|
|
654
|
+
it("bare `gtd -v` no longer prints a version — it is the missing-command usage error", () => {
|
|
655
|
+
const plan = parseArgv(["node", "gtd.js", "-v"])
|
|
656
|
+
expect(plan.kind).toBe("usage")
|
|
657
|
+
if (plan.kind === "usage") expect(plan.message).toContain("missing command")
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
it("-v resolves to --verbose: `gtd -v next` carries verbose: true", () => {
|
|
661
|
+
const plan = parseArgv(["node", "gtd.js", "-v", "next"])
|
|
662
|
+
expect(plan.kind).toBe("command")
|
|
663
|
+
if (plan.kind === "command") {
|
|
664
|
+
expect(plan.command).toEqual({ kind: "next" })
|
|
665
|
+
expect(plan.verbose).toBe(true)
|
|
666
|
+
}
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
it("--verbose is equivalent to -v", () => {
|
|
670
|
+
const a = parseArgv(["node", "gtd.js", "next", "--verbose"])
|
|
671
|
+
const b = parseArgv(["node", "gtd.js", "next", "-v"])
|
|
672
|
+
expect(a).toEqual(b)
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
it("every other command's plan carries verbose: false when the flag is absent", () => {
|
|
676
|
+
const plan = parseArgv(["node", "gtd.js", "next"])
|
|
677
|
+
expect(plan.kind).toBe("command")
|
|
678
|
+
if (plan.kind === "command") expect(plan.verbose).toBe(false)
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
it("--verbose is valid alongside every command kind — never a scope violation", () => {
|
|
682
|
+
for (const args of [
|
|
683
|
+
["land", "--verbose"],
|
|
684
|
+
["next", "--verbose", "--json"],
|
|
685
|
+
["check", "qa", "TODO.md", "--verbose"],
|
|
686
|
+
["--workflow", "some-state", "--verbose"],
|
|
687
|
+
]) {
|
|
688
|
+
const plan = parseArgv(["node", "gtd.js", ...args])
|
|
689
|
+
expect(plan.kind).toBe("command")
|
|
690
|
+
if (plan.kind === "command") expect(plan.verbose).toBe(true)
|
|
691
|
+
}
|
|
692
|
+
})
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
describe("parseArgv — gtd install", () => {
|
|
696
|
+
it("parses to an install command", () => {
|
|
697
|
+
const plan = parseArgv(["node", "gtd.js", "install"])
|
|
698
|
+
expect(plan.kind).toBe("command")
|
|
699
|
+
if (plan.kind === "command") {
|
|
700
|
+
expect(plan.command).toEqual({ kind: "install" })
|
|
701
|
+
expect(plan.json).toEqual({ kind: "off" })
|
|
702
|
+
}
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
it("--json is out of scope for install — the briefing is plain text only", () => {
|
|
706
|
+
const plan = parseArgv(["node", "gtd.js", "install", "--json"])
|
|
707
|
+
expect(plan.kind).toBe("usage")
|
|
708
|
+
if (plan.kind === "usage") expect(plan.message).toContain("only valid for `gtd next`")
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
it("gtd install extra is a usage error (install takes no argument)", () => {
|
|
712
|
+
const plan = parseArgv(["node", "gtd.js", "install", "extra"])
|
|
713
|
+
expect(plan.kind).toBe("usage")
|
|
714
|
+
if (plan.kind === "usage") expect(plan.message).toContain("too many arguments")
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
it("a scoped-out flag (e.g. --port) is rejected on install", () => {
|
|
718
|
+
const plan = parseArgv(["node", "gtd.js", "install", "--port=3"])
|
|
719
|
+
expect(plan.kind).toBe("usage")
|
|
720
|
+
if (plan.kind === "usage") expect(plan.message).toContain("only valid for `gtd ui`")
|
|
721
|
+
})
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
describe("parseArgv — gtd ui", () => {
|
|
725
|
+
it("parses --host/--port/--self-signed/--dev into one ui command", () => {
|
|
726
|
+
const plan = parseArgv([
|
|
727
|
+
"node",
|
|
728
|
+
"gtd.js",
|
|
729
|
+
"ui",
|
|
730
|
+
"--host",
|
|
731
|
+
"h",
|
|
732
|
+
"--port",
|
|
733
|
+
"8443",
|
|
734
|
+
"--self-signed",
|
|
735
|
+
"--dev",
|
|
736
|
+
])
|
|
737
|
+
expect(plan.kind).toBe("command")
|
|
738
|
+
if (plan.kind === "command") {
|
|
739
|
+
expect(plan.command).toEqual({
|
|
740
|
+
kind: "ui",
|
|
741
|
+
host: "h",
|
|
742
|
+
port: 8443,
|
|
743
|
+
selfSigned: true,
|
|
744
|
+
dev: true,
|
|
745
|
+
})
|
|
746
|
+
}
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
it("bare `gtd ui` omits host/port and defaults selfSigned/dev to false", () => {
|
|
750
|
+
const plan = parseArgv(["node", "gtd.js", "ui"])
|
|
751
|
+
expect(plan.kind).toBe("command")
|
|
752
|
+
if (plan.kind === "command") {
|
|
753
|
+
expect(plan.command).toEqual({ kind: "ui", selfSigned: false, dev: false })
|
|
754
|
+
}
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
it("gtd ui --bogus is an unknown-option usage error", () => {
|
|
758
|
+
const plan = parseArgv(["node", "gtd.js", "ui", "--bogus"])
|
|
759
|
+
expect(plan.kind).toBe("usage")
|
|
760
|
+
if (plan.kind === "usage") expect(plan.message).toContain("unknown option '--bogus'")
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
it("gtd ui extra is a usage error — ui takes no positional argument", () => {
|
|
764
|
+
const plan = parseArgv(["node", "gtd.js", "ui", "extra"])
|
|
765
|
+
expect(plan.kind).toBe("usage")
|
|
766
|
+
if (plan.kind === "usage") expect(plan.message).toContain("too many arguments")
|
|
767
|
+
})
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
describe("parseArgv — gtd judge / gtd judge answer", () => {
|
|
771
|
+
it("gtd judge parses to a bare judge command", () => {
|
|
772
|
+
const plan = parseArgv(["node", "gtd.js", "judge"])
|
|
773
|
+
expect(plan.kind).toBe("command")
|
|
774
|
+
if (plan.kind === "command") {
|
|
775
|
+
expect(plan.command).toEqual({ kind: "judge" })
|
|
776
|
+
expect(plan.json).toEqual({ kind: "off" })
|
|
777
|
+
}
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
it("gtd judge answer parses to a judgeAnswer command — the CLI's one two-level verb", () => {
|
|
781
|
+
const plan = parseArgv(["node", "gtd.js", "judge", "answer"])
|
|
782
|
+
expect(plan.kind).toBe("command")
|
|
783
|
+
if (plan.kind === "command") {
|
|
784
|
+
expect(plan.command).toEqual({ kind: "judgeAnswer" })
|
|
785
|
+
}
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
it("gtd judge --json / gtd judge answer --json both parse with json set", () => {
|
|
789
|
+
for (const args of [
|
|
790
|
+
["judge", "--json"],
|
|
791
|
+
["judge", "answer", "--json"],
|
|
792
|
+
]) {
|
|
793
|
+
const plan = parseArgv(["node", "gtd.js", ...args])
|
|
794
|
+
expect(plan.kind).toBe("command")
|
|
795
|
+
if (plan.kind === "command") expect(plan.json).toEqual({ kind: "document" })
|
|
796
|
+
}
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
it("gtd judge extra (not 'answer') is a too-many-arguments usage error, not judgeAnswer", () => {
|
|
800
|
+
const plan = parseArgv(["node", "gtd.js", "judge", "extra"])
|
|
801
|
+
expect(plan.kind).toBe("usage")
|
|
802
|
+
if (plan.kind === "usage") expect(plan.message).toContain("too many arguments")
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
it("gtd judge answer extra is a too-many-arguments usage error", () => {
|
|
806
|
+
const plan = parseArgv(["node", "gtd.js", "judge", "answer", "extra"])
|
|
807
|
+
expect(plan.kind).toBe("usage")
|
|
808
|
+
if (plan.kind === "usage") expect(plan.message).toContain("too many arguments")
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
it("a scoped-out flag (e.g. --cost) is rejected on judge and judge answer", () => {
|
|
812
|
+
for (const args of [
|
|
813
|
+
["judge", "--cost=5"],
|
|
814
|
+
["judge", "answer", "--cost=5"],
|
|
815
|
+
]) {
|
|
816
|
+
const plan = parseArgv(["node", "gtd.js", ...args])
|
|
817
|
+
expect(plan.kind).toBe("usage")
|
|
818
|
+
if (plan.kind === "usage") expect(plan.message).toContain("only valid for `gtd land`")
|
|
819
|
+
}
|
|
820
|
+
})
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
describe("parseArgv — gtd judge run", () => {
|
|
824
|
+
const parse = (...args: string[]) => parseArgv(["node", "gtd.js", ...args])
|
|
825
|
+
|
|
826
|
+
it("parses --provider fixed with --answers", () => {
|
|
827
|
+
const plan = parse("judge", "run", "--provider", "fixed", "--answers", "a.json")
|
|
828
|
+
expect(plan.kind === "command" && plan.command).toEqual({
|
|
829
|
+
kind: "judgeRun",
|
|
830
|
+
provider: "fixed",
|
|
831
|
+
answers: "a.json",
|
|
832
|
+
})
|
|
833
|
+
})
|
|
834
|
+
|
|
835
|
+
it("parses --provider jev alone", () => {
|
|
836
|
+
const plan = parse("judge", "run", "--provider=jev")
|
|
837
|
+
expect(plan.kind === "command" && plan.command).toEqual({ kind: "judgeRun", provider: "jev" })
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
it("parses --provider llm with --model", () => {
|
|
841
|
+
const plan = parse("judge", "run", "--provider=llm", "--model", "sonnet")
|
|
842
|
+
expect(plan.kind === "command" && plan.command).toEqual({
|
|
843
|
+
kind: "judgeRun",
|
|
844
|
+
provider: "llm",
|
|
845
|
+
model: "sonnet",
|
|
846
|
+
})
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
it("parses no --provider (auto), with or without --model", () => {
|
|
850
|
+
const bare = parse("judge", "run")
|
|
851
|
+
expect(bare.kind === "command" && bare.command).toEqual({ kind: "judgeRun" })
|
|
852
|
+
const model = parse("judge", "run", "--model=x")
|
|
853
|
+
expect(model.kind === "command" && model.command).toEqual({ kind: "judgeRun", model: "x" })
|
|
854
|
+
})
|
|
855
|
+
|
|
856
|
+
it.each([
|
|
857
|
+
[["judge", "run", "--provider", "nope"], "must be one of fixed, jev, llm"],
|
|
858
|
+
[["judge", "run", "--answers", "a.json"], "--answers is only valid with --provider fixed"],
|
|
859
|
+
[["judge", "run", "--provider", "jev", "--model", "x"], "--model"],
|
|
860
|
+
[["judge", "run", "--provider", "fixed", "--model", "x"], "--model"],
|
|
861
|
+
[["next", "--model", "x"], "`gtd land` (with --cost) or `gtd judge run`"],
|
|
862
|
+
[["judge", "run", "--provider", "fixed", "--json"], "--json is only valid"],
|
|
863
|
+
[["judge", "run", "--provider", "fixed", "--answers="], "requires a value"],
|
|
864
|
+
[["judge", "--provider", "fixed"], "only valid for `gtd judge run`"],
|
|
865
|
+
[["judge", "answer", "--answers", "a.json"], "only valid for `gtd judge run`"],
|
|
866
|
+
[["next", "--provider", "fixed"], "only valid for `gtd judge run`"],
|
|
867
|
+
])("%j is a usage error", (args, text) => {
|
|
868
|
+
const plan = parse(...args)
|
|
869
|
+
expect(plan.kind).toBe("usage")
|
|
870
|
+
if (plan.kind === "usage") expect(plan.message).toContain(text)
|
|
871
|
+
})
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
describe("parseArgv — doors", () => {
|
|
875
|
+
const parse = (...args: string[]): CliPlan => parseArgv(["node", "gtd.js", ...args])
|
|
876
|
+
|
|
877
|
+
it("`gtd door <name>` carries the name and no args", () => {
|
|
878
|
+
const plan = parse("door", "fix")
|
|
879
|
+
expect(plan).toMatchObject({
|
|
880
|
+
kind: "command",
|
|
881
|
+
command: { kind: "door", name: "fix", args: [] },
|
|
882
|
+
})
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
it("`gtd door <name> a b` carries every positional after the name, in order", () => {
|
|
886
|
+
const plan = parse("door", "review", "v1.0", "extra")
|
|
887
|
+
expect(plan).toMatchObject({
|
|
888
|
+
kind: "command",
|
|
889
|
+
command: { kind: "door", name: "review", args: ["v1.0", "extra"] },
|
|
890
|
+
})
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
it("`gtd door` with no name is a usage error", () => {
|
|
894
|
+
const plan = parse("door")
|
|
895
|
+
expect(plan.kind).toBe("usage")
|
|
896
|
+
if (plan.kind === "usage") expect(plan.message).toContain("gtd door: missing name argument")
|
|
897
|
+
})
|
|
898
|
+
|
|
899
|
+
it("`gtd doors` parses with every --json shape", () => {
|
|
900
|
+
expect(parse("doors")).toMatchObject({
|
|
901
|
+
kind: "command",
|
|
902
|
+
command: { kind: "doors" },
|
|
903
|
+
json: { kind: "off" },
|
|
904
|
+
})
|
|
905
|
+
expect(parse("doors", "--json")).toMatchObject({ json: { kind: "document" } })
|
|
906
|
+
expect(parse("doors", "--json=0.name")).toMatchObject({
|
|
907
|
+
json: { kind: "select", path: "0.name" },
|
|
908
|
+
})
|
|
909
|
+
})
|
|
910
|
+
|
|
911
|
+
it("`gtd doors` takes no argument", () => {
|
|
912
|
+
const plan = parse("doors", "extra")
|
|
913
|
+
expect(plan.kind).toBe("usage")
|
|
914
|
+
if (plan.kind === "usage") expect(plan.message).toContain("too many arguments")
|
|
915
|
+
})
|
|
916
|
+
|
|
917
|
+
it("`gtd door` rejects --json and --var — a door's vars come from its args", () => {
|
|
918
|
+
expect(parse("door", "fix", "--json").kind).toBe("usage")
|
|
919
|
+
expect(parse("door", "fix", "--var", "a=1").kind).toBe("usage")
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
it("help lists both commands", () => {
|
|
923
|
+
const help = renderHelp()
|
|
924
|
+
expect(help).toContain("door <name> [args...]")
|
|
925
|
+
expect(help).toMatch(/^ {2}doors\b/m)
|
|
926
|
+
})
|
|
927
|
+
})
|
|
928
|
+
|
|
929
|
+
describe("standaloneKinds / needsOf", () => {
|
|
930
|
+
it("pins the standalone kinds", () => {
|
|
931
|
+
expect(standaloneKinds()).toEqual(["lsp", "check", "uncheck", "install", "loop", "judgeRun"])
|
|
932
|
+
})
|
|
933
|
+
|
|
934
|
+
it("needsOf matches none/fs for the standalone kinds and state for everything else", () => {
|
|
935
|
+
expect(needsOf("lsp")).toBe("none")
|
|
936
|
+
expect(needsOf("check")).toBe("fs")
|
|
937
|
+
expect(needsOf("uncheck")).toBe("fs")
|
|
938
|
+
expect(needsOf("install")).toBe("none")
|
|
939
|
+
expect(needsOf("loop")).toBe("none")
|
|
940
|
+
expect(needsOf("judgeRun")).toBe("none")
|
|
941
|
+
for (const kind of [
|
|
942
|
+
"land",
|
|
943
|
+
"start",
|
|
944
|
+
"abandon",
|
|
945
|
+
"restore",
|
|
946
|
+
"next",
|
|
947
|
+
"validate",
|
|
948
|
+
"ui",
|
|
949
|
+
"judge",
|
|
950
|
+
"judgeAnswer",
|
|
951
|
+
"door",
|
|
952
|
+
"doors",
|
|
953
|
+
] as const) {
|
|
954
|
+
expect(needsOf(kind)).toBe("state")
|
|
955
|
+
}
|
|
956
|
+
})
|
|
957
|
+
})
|
|
958
|
+
|
|
959
|
+
describe("renderHelp", () => {
|
|
960
|
+
it("mentions every command and flag, and no removed surface", () => {
|
|
961
|
+
const help = renderHelp()
|
|
962
|
+
expect(help).toContain("Usage")
|
|
963
|
+
expect(help).toContain("land")
|
|
964
|
+
expect(help).toContain("--workflow <name>")
|
|
965
|
+
expect(help).toContain("--var")
|
|
966
|
+
expect(help).toContain("abandon")
|
|
967
|
+
expect(help).toContain("restore")
|
|
968
|
+
expect(help).toContain("next")
|
|
969
|
+
expect(help).toContain("validate")
|
|
970
|
+
expect(help).toContain("lsp")
|
|
971
|
+
expect(help).not.toContain("visualize")
|
|
972
|
+
expect(help).not.toMatch(/^ {2}init\b/m)
|
|
973
|
+
expect(help).not.toContain(".gtdrc")
|
|
974
|
+
expect(help).toMatch(/^ {2}ui\b/m)
|
|
975
|
+
expect(help).toContain("check <mode> <file>")
|
|
976
|
+
expect(help).toContain("install")
|
|
977
|
+
expect(help).toContain("base ")
|
|
978
|
+
expect(help).toMatch(/^ {2}judge\b/m)
|
|
979
|
+
expect(help).toContain("judge answer")
|
|
980
|
+
expect(help).toContain("version")
|
|
981
|
+
expect(help).toContain("help")
|
|
982
|
+
expect(help).toContain("--json")
|
|
983
|
+
expect(help).toContain("--port")
|
|
984
|
+
expect(help).not.toContain("--no-open")
|
|
985
|
+
expect(help).toContain("--host")
|
|
986
|
+
expect(help).toContain("--self-signed")
|
|
987
|
+
expect(help).toContain("--dev")
|
|
988
|
+
expect(help).toContain("--cost")
|
|
989
|
+
expect(help).toContain("--model")
|
|
990
|
+
expect(help).toContain("--open-questions")
|
|
991
|
+
expect(help).toContain("--verbose")
|
|
992
|
+
expect(help).toContain("--version, -V")
|
|
993
|
+
expect(help).toContain("--help, -h")
|
|
994
|
+
expect(help).not.toContain("review <commitish>")
|
|
995
|
+
expect(help).not.toContain("format <file>")
|
|
996
|
+
expect(help).not.toContain("--debug")
|
|
997
|
+
expect(help).not.toContain("bin/gtd")
|
|
998
|
+
expect(help).not.toContain("(no command), loop")
|
|
999
|
+
expect(help).not.toContain("--dispatch")
|
|
1000
|
+
expect(help).not.toContain("--if-resting")
|
|
1001
|
+
expect(help).not.toMatch(/^ {2}serve\b/m)
|
|
1002
|
+
expect(help).toMatch(/\n$/)
|
|
1003
|
+
})
|
|
1004
|
+
|
|
1005
|
+
it("every flag row's name appears in the Options: block and vice versa", () => {
|
|
1006
|
+
const help = renderHelp()
|
|
1007
|
+
const optionsBlock = help.slice(help.indexOf("Options:"))
|
|
1008
|
+
for (const name of FLAG_NAMES) expect(optionsBlock).toContain(name)
|
|
1009
|
+
})
|
|
1010
|
+
|
|
1011
|
+
it("the docs/cli.md Commands fenced block equals renderHelp()", () => {
|
|
1012
|
+
const doc = readFileSync(resolve(import.meta.dirname, "../../docs/cli.md"), "utf8")
|
|
1013
|
+
const match = doc.match(/## Commands\n\n```\n([\s\S]*?)\n```/)
|
|
1014
|
+
expect(match).not.toBeNull()
|
|
1015
|
+
expect(match![1] + "\n").toBe(renderHelp())
|
|
1016
|
+
})
|
|
1017
|
+
|
|
1018
|
+
it("README.md and COMPATIBILITY.md list exactly the commands", () => {
|
|
1019
|
+
const commands = new Set(
|
|
1020
|
+
[...renderHelp().matchAll(/^ {2}([a-z]+(?: [a-z]+)?)(?= )/gm)].map(([, name]) => name),
|
|
1021
|
+
)
|
|
1022
|
+
expect(commands).toContain("judge answer")
|
|
1023
|
+
const listed = (file: string, list: RegExp): Set<string> => {
|
|
1024
|
+
const doc = readFileSync(resolve(import.meta.dirname, file), "utf8")
|
|
1025
|
+
const match = doc.match(list)
|
|
1026
|
+
expect(match, file).not.toBeNull()
|
|
1027
|
+
return new Set([...match![1]!.matchAll(/`([a-z]+(?: [a-z]+)?)`/g)].map(([, name]) => name!))
|
|
1028
|
+
}
|
|
1029
|
+
expect(listed("../../README.md", /The `gtd` bin's commands — ([\s\S]*?) — are listed/)).toEqual(
|
|
1030
|
+
commands,
|
|
1031
|
+
)
|
|
1032
|
+
expect(listed("../../COMPATIBILITY.md", /every command \(([\s\S]*?)\), option/)).toEqual(
|
|
1033
|
+
commands,
|
|
1034
|
+
)
|
|
1035
|
+
})
|
|
1036
|
+
|
|
1037
|
+
it("docs/cli.md's Exit codes table, pinned beside the rendered help output, is exactly wire's frozen EXIT_CODES", () => {
|
|
1038
|
+
const doc = readFileSync(resolve(import.meta.dirname, "../../docs/cli.md"), "utf8")
|
|
1039
|
+
const match = doc.match(/### Exit codes\n\n[^\n]*\n[^\n]*\n\n((?:\|.*\n)+)/)
|
|
1040
|
+
expect(match).not.toBeNull()
|
|
1041
|
+
const table = match![1] ?? ""
|
|
1042
|
+
const codes = [...table.matchAll(/\|\s*(\d+)(?:\s*\/\s*(\d+))?\s*\|/g)].flatMap(([, a, b]) =>
|
|
1043
|
+
b !== undefined ? [Number(a), Number(b)] : [Number(a)],
|
|
1044
|
+
)
|
|
1045
|
+
expect(new Set(codes)).toEqual(EXIT_CODES)
|
|
1046
|
+
})
|
|
1047
|
+
})
|
|
1048
|
+
|
|
1049
|
+
interface Captured {
|
|
1050
|
+
readonly stdout: string
|
|
1051
|
+
readonly stderr: string
|
|
1052
|
+
readonly exitCode: number | undefined
|
|
1053
|
+
readonly layersBuilt: number
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
const capturingIo = (
|
|
1057
|
+
layers: (verbose: boolean) => Layer.Layer<CommandRequirements>,
|
|
1058
|
+
): { io: CliIo; captured: () => Captured } => {
|
|
1059
|
+
let stdout = ""
|
|
1060
|
+
let stderr = ""
|
|
1061
|
+
let exitCode: number | undefined
|
|
1062
|
+
let layersBuilt = 0
|
|
1063
|
+
const io: CliIo = {
|
|
1064
|
+
stdout: (chunk) => {
|
|
1065
|
+
stdout += chunk
|
|
1066
|
+
},
|
|
1067
|
+
stderr: (chunk) => {
|
|
1068
|
+
stderr += chunk
|
|
1069
|
+
},
|
|
1070
|
+
exit: (code) => {
|
|
1071
|
+
exitCode = code
|
|
1072
|
+
},
|
|
1073
|
+
layers: (verbose) => {
|
|
1074
|
+
layersBuilt++
|
|
1075
|
+
return layers(verbose)
|
|
1076
|
+
},
|
|
1077
|
+
}
|
|
1078
|
+
return { io, captured: () => ({ stdout, stderr, exitCode, layersBuilt }) }
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// Fails as soon as any property is accessed — proves `layers()` is never
|
|
1082
|
+
// asked to build a real service for --version/--help/a usage error.
|
|
1083
|
+
const throwingLayers = (): Layer.Layer<CommandRequirements> =>
|
|
1084
|
+
Layer.effectDiscard(
|
|
1085
|
+
Effect.sync(() => {
|
|
1086
|
+
throw new Error("layers() must not be called")
|
|
1087
|
+
}),
|
|
1088
|
+
) as Layer.Layer<CommandRequirements>
|
|
1089
|
+
|
|
1090
|
+
describe("runCli — layers() is never invoked for output/usage plans", () => {
|
|
1091
|
+
it("--version", async () => {
|
|
1092
|
+
const { io, captured } = capturingIo(throwingLayers)
|
|
1093
|
+
await Effect.runPromise(runCli(["node", "gtd.js", "--version"], io))
|
|
1094
|
+
expect(captured().layersBuilt).toBe(0)
|
|
1095
|
+
})
|
|
1096
|
+
|
|
1097
|
+
it("--help", async () => {
|
|
1098
|
+
const { io, captured } = capturingIo(throwingLayers)
|
|
1099
|
+
await Effect.runPromise(runCli(["node", "gtd.js", "--help"], io))
|
|
1100
|
+
expect(captured().layersBuilt).toBe(0)
|
|
1101
|
+
})
|
|
1102
|
+
|
|
1103
|
+
it("a usage error", async () => {
|
|
1104
|
+
const { io, captured } = capturingIo(throwingLayers)
|
|
1105
|
+
await Effect.runPromise(runCli(["node", "gtd.js", "bogus"], io))
|
|
1106
|
+
expect(captured().layersBuilt).toBe(0)
|
|
1107
|
+
})
|
|
1108
|
+
})
|
|
1109
|
+
|
|
1110
|
+
describe("runCli — exit codes", () => {
|
|
1111
|
+
it("output plan: exit is never called (success) — --help and --version still exit 0", async () => {
|
|
1112
|
+
const { io, captured } = capturingIo(throwingLayers)
|
|
1113
|
+
await Effect.runPromise(runCli(["node", "gtd.js", "--version"], io))
|
|
1114
|
+
expect(captured().exitCode).toBeUndefined()
|
|
1115
|
+
const { io: helpIo, captured: helpCaptured } = capturingIo(throwingLayers)
|
|
1116
|
+
await Effect.runPromise(runCli(["node", "gtd.js", "--help"], helpIo))
|
|
1117
|
+
expect(helpCaptured().exitCode).toBeUndefined()
|
|
1118
|
+
})
|
|
1119
|
+
|
|
1120
|
+
it("usage plan: exit(EXIT_USAGE_ERROR) — an unknown/missing/malformed invocation, never EXIT_RUNTIME_ERROR", async () => {
|
|
1121
|
+
const { io, captured } = capturingIo(throwingLayers)
|
|
1122
|
+
await Effect.runPromise(runCli(["node", "gtd.js", "bogus"], io))
|
|
1123
|
+
expect(captured().exitCode).toBe(EXIT_USAGE_ERROR)
|
|
1124
|
+
})
|
|
1125
|
+
|
|
1126
|
+
it("gtd ui --bogus exits EXIT_USAGE_ERROR (unknown flag)", async () => {
|
|
1127
|
+
const { io, captured } = capturingIo(throwingLayers)
|
|
1128
|
+
await Effect.runPromise(runCli(["node", "gtd.js", "ui", "--bogus"], io))
|
|
1129
|
+
expect(captured().exitCode).toBe(EXIT_USAGE_ERROR)
|
|
1130
|
+
})
|
|
1131
|
+
|
|
1132
|
+
it("--host on a non-ui command exits EXIT_USAGE_ERROR with a clear scopeError message", async () => {
|
|
1133
|
+
const { io, captured } = capturingIo(throwingLayers)
|
|
1134
|
+
await Effect.runPromise(runCli(["node", "gtd.js", "next", "--host", "x"], io))
|
|
1135
|
+
const result = captured()
|
|
1136
|
+
expect(result.exitCode).toBe(EXIT_USAGE_ERROR)
|
|
1137
|
+
expect(result.stderr).toContain("only valid for `gtd ui`")
|
|
1138
|
+
})
|
|
1139
|
+
|
|
1140
|
+
it("a command failure that is NOT a usage error still exits EXIT_RUNTIME_ERROR", async () => {
|
|
1141
|
+
// Unlike a usage error (never builds a layer), a resolved command
|
|
1142
|
+
// always does, so this exercises `report`'s own exit code.
|
|
1143
|
+
const { io, captured } = capturingIo(throwingLayers)
|
|
1144
|
+
await Effect.runPromise(runCli(["node", "gtd.js", "next"], io))
|
|
1145
|
+
expect(captured().exitCode).toBe(EXIT_RUNTIME_ERROR)
|
|
1146
|
+
})
|
|
1147
|
+
|
|
1148
|
+
it("an unknown --json=<path> selector exits EXIT_USAGE_ERROR, not EXIT_RUNTIME_ERROR — Program.ts's SelectorUsageError mapped by report()", async () => {
|
|
1149
|
+
const repo = new InMemRepo()
|
|
1150
|
+
repo.writeFile("NOTE.md", "a note\n")
|
|
1151
|
+
repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
|
|
1152
|
+
repo.commitAllWithPrefix("gtd: init")
|
|
1153
|
+
const { io, captured } = capturingIo(() => testLayers(repo))
|
|
1154
|
+
await Effect.runPromise(runCli(["node", "gtd.js", "next", "--json=does.not.exist"], io))
|
|
1155
|
+
const { stdout, stderr, exitCode } = captured()
|
|
1156
|
+
expect(exitCode).toBe(EXIT_USAGE_ERROR)
|
|
1157
|
+
expect(stdout).toBe("")
|
|
1158
|
+
expect(stderr).toContain("does.not.exist")
|
|
1159
|
+
})
|
|
1160
|
+
})
|
|
1161
|
+
|
|
1162
|
+
describe("runCli — --json envelopes", () => {
|
|
1163
|
+
it("a usage error under --json writes the envelope on stderr, leaving stdout byte-empty", async () => {
|
|
1164
|
+
const { io, captured } = capturingIo(throwingLayers)
|
|
1165
|
+
await Effect.runPromise(runCli(["node", "gtd.js", "bogus", "--json"], io))
|
|
1166
|
+
const { stdout, stderr } = captured()
|
|
1167
|
+
expect(stdout).toBe("")
|
|
1168
|
+
const [envelopeLine] = stderr.split("\n")
|
|
1169
|
+
const parsed = JSON.parse(envelopeLine!) as { state: string; prompt: string }
|
|
1170
|
+
expect(parsed.state).toBe("error")
|
|
1171
|
+
expect(stderr).toMatch(/gtd: [^\n]*\n$/)
|
|
1172
|
+
expect(stderr).not.toContain("gtd: gtd:")
|
|
1173
|
+
})
|
|
1174
|
+
|
|
1175
|
+
it("a runtime error under --json writes the envelope on stderr, leaving stdout byte-empty", async () => {
|
|
1176
|
+
const { io, captured } = capturingIo(throwingLayers)
|
|
1177
|
+
await Effect.runPromise(runCli(["node", "gtd.js", "next", "--json"], io))
|
|
1178
|
+
const { stdout, stderr } = captured()
|
|
1179
|
+
expect(stdout).toBe("")
|
|
1180
|
+
const [envelopeLine] = stderr.split("\n")
|
|
1181
|
+
const parsed = JSON.parse(envelopeLine!) as { state: string; prompt: string }
|
|
1182
|
+
expect(parsed.state).toBe("error")
|
|
1183
|
+
})
|
|
1184
|
+
})
|
|
1185
|
+
|
|
1186
|
+
describe("runCli — stdout stays byte-empty on every failing surface", () => {
|
|
1187
|
+
// A handler may write through `out` and still fail — `bufferedArtifactOut`'s
|
|
1188
|
+
// buffer is simply never flushed, so `io.stdout` is never called at all.
|
|
1189
|
+
|
|
1190
|
+
it("usage error: stdout is byte-empty, the message lands on stderr", async () => {
|
|
1191
|
+
const { io, captured } = capturingIo(throwingLayers)
|
|
1192
|
+
await Effect.runPromise(runCli(["node", "gtd.js", "bogus"], io))
|
|
1193
|
+
const { stdout, stderr } = captured()
|
|
1194
|
+
expect(stdout).toBe("")
|
|
1195
|
+
expect(stderr).toContain("unknown command")
|
|
1196
|
+
})
|
|
1197
|
+
|
|
1198
|
+
it("refusal: a real command's own typed Effect.fail leaves stdout byte-empty", async () => {
|
|
1199
|
+
const repo = new InMemRepo() // no commits yet — assertRepositoryHasCommits refuses
|
|
1200
|
+
const { io, captured } = capturingIo(() => testLayers(repo))
|
|
1201
|
+
await Effect.runPromise(runCli(["node", "gtd.js", "next"], io))
|
|
1202
|
+
const { stdout, stderr, exitCode } = captured()
|
|
1203
|
+
expect(exitCode).toBe(EXIT_RUNTIME_ERROR)
|
|
1204
|
+
expect(stdout).toBe("")
|
|
1205
|
+
expect(stderr).toContain("gtd requires a repository with at least one commit")
|
|
1206
|
+
})
|
|
1207
|
+
|
|
1208
|
+
it("runtime error: a handler's own Effect.fail (not the shared repo-root/commit guard) leaves stdout byte-empty", async () => {
|
|
1209
|
+
// `gtd check` needs neither a repository nor a commit — this exercises a
|
|
1210
|
+
// plain `FileSystem`-only failure inside the handler itself, distinct
|
|
1211
|
+
// from the "refusal" test above (which fails in the shared guard).
|
|
1212
|
+
const repo = new InMemRepo()
|
|
1213
|
+
repo.writeFile(".gtd/TODO.md", "## Open Questions\n\n###\n\nno question text.\n")
|
|
1214
|
+
const { io, captured } = capturingIo(() => testLayers(repo))
|
|
1215
|
+
await Effect.runPromise(runCli(["node", "gtd.js", "check", "qa", ".gtd/TODO.md"], io))
|
|
1216
|
+
const { stdout, stderr, exitCode } = captured()
|
|
1217
|
+
expect(exitCode).toBe(EXIT_RUNTIME_ERROR)
|
|
1218
|
+
expect(stdout).toBe("")
|
|
1219
|
+
expect(stderr).toContain("has no question text")
|
|
1220
|
+
})
|
|
1221
|
+
|
|
1222
|
+
it("defect: an unchecked throw building the layer still leaves stdout byte-empty", async () => {
|
|
1223
|
+
const { io, captured } = capturingIo(throwingLayers)
|
|
1224
|
+
await Effect.runPromise(runCli(["node", "gtd.js", "next"], io))
|
|
1225
|
+
const { stdout, stderr, exitCode } = captured()
|
|
1226
|
+
expect(exitCode).toBe(EXIT_RUNTIME_ERROR)
|
|
1227
|
+
expect(stdout).toBe("")
|
|
1228
|
+
expect(stderr).toContain("layers() must not be called")
|
|
1229
|
+
})
|
|
1230
|
+
|
|
1231
|
+
it("unit: a failing command's collected buffer is never handed to io.stdout", async () => {
|
|
1232
|
+
// Same failure as the "runtime error" case above, observed directly
|
|
1233
|
+
// through `io.stdout` (a raw call-recording array) — a failing run must
|
|
1234
|
+
// produce zero calls, not merely an empty joined string.
|
|
1235
|
+
const repo = new InMemRepo()
|
|
1236
|
+
repo.writeFile("README.md", "x\n")
|
|
1237
|
+
repo.commitAllWithPrefix("chore: init gtd workflow")
|
|
1238
|
+
repo.writeFile(".gtd/TODO.md", "## Open Questions\n\n###\n\nno question text.\n")
|
|
1239
|
+
const stdoutCalls: string[] = []
|
|
1240
|
+
const io: CliIo = {
|
|
1241
|
+
stdout: (chunk) => stdoutCalls.push(chunk),
|
|
1242
|
+
stderr: () => {},
|
|
1243
|
+
exit: () => {},
|
|
1244
|
+
layers: () => testLayers(repo),
|
|
1245
|
+
}
|
|
1246
|
+
await Effect.runPromise(runCli(["node", "gtd.js", "check", "qa", ".gtd/TODO.md"], io))
|
|
1247
|
+
expect(stdoutCalls).toEqual([])
|
|
1248
|
+
})
|
|
1249
|
+
})
|
|
1250
|
+
|
|
1251
|
+
describe("nodeCliIo", () => {
|
|
1252
|
+
const io = nodeCliIo(Layer.succeed(ToolCommands, {} as ToolCommands["Type"]))
|
|
1253
|
+
|
|
1254
|
+
it("exposes stdout/stderr/exit/layers", () => {
|
|
1255
|
+
expect(typeof io.stdout).toBe("function")
|
|
1256
|
+
expect(typeof io.stderr).toBe("function")
|
|
1257
|
+
expect(typeof io.exit).toBe("function")
|
|
1258
|
+
expect(typeof io.layers).toBe("function")
|
|
1259
|
+
})
|
|
1260
|
+
|
|
1261
|
+
afterEach(() => {
|
|
1262
|
+
vi.restoreAllMocks()
|
|
1263
|
+
})
|
|
1264
|
+
|
|
1265
|
+
it("stdout writes through process.stdout.write with a completion callback, even when the write is queued (backpressure)", () => {
|
|
1266
|
+
const write = vi
|
|
1267
|
+
.spyOn(process.stdout, "write")
|
|
1268
|
+
.mockImplementation((_chunk: unknown, cb?: unknown) => {
|
|
1269
|
+
if (typeof cb === "function") cb()
|
|
1270
|
+
return false // the queued-data case
|
|
1271
|
+
})
|
|
1272
|
+
io.stdout("a large chunk")
|
|
1273
|
+
expect(write).toHaveBeenCalledTimes(1)
|
|
1274
|
+
const [chunk, cb] = write.mock.calls[0]!
|
|
1275
|
+
expect(chunk).toBe("a large chunk")
|
|
1276
|
+
expect(typeof cb).toBe("function")
|
|
1277
|
+
})
|
|
1278
|
+
|
|
1279
|
+
it("exit sets process.exitCode rather than calling process.exit — an undrained stdout.write must not be torn down", () => {
|
|
1280
|
+
const exit = vi.spyOn(process, "exit").mockImplementation(() => {
|
|
1281
|
+
throw new Error("process.exit must never be called on the normal path")
|
|
1282
|
+
})
|
|
1283
|
+
const before = process.exitCode
|
|
1284
|
+
try {
|
|
1285
|
+
io.exit(10)
|
|
1286
|
+
expect(process.exitCode).toBe(10)
|
|
1287
|
+
expect(exit).not.toHaveBeenCalled()
|
|
1288
|
+
} finally {
|
|
1289
|
+
process.exitCode = before
|
|
1290
|
+
}
|
|
1291
|
+
})
|
|
1292
|
+
})
|
|
1293
|
+
|
|
1294
|
+
describe("normalizeTrailingNewline", () => {
|
|
1295
|
+
it("leaves a byte-empty artifact byte-empty", () => {
|
|
1296
|
+
expect(normalizeTrailingNewline("")).toBe("")
|
|
1297
|
+
})
|
|
1298
|
+
|
|
1299
|
+
it("appends exactly one trailing newline to a non-empty artifact with none", () => {
|
|
1300
|
+
expect(normalizeTrailingNewline("no newline yet")).toBe("no newline yet\n")
|
|
1301
|
+
})
|
|
1302
|
+
|
|
1303
|
+
it("collapses several trailing newlines to exactly one", () => {
|
|
1304
|
+
expect(normalizeTrailingNewline("content\n\n\n\n")).toBe("content\n")
|
|
1305
|
+
})
|
|
1306
|
+
|
|
1307
|
+
it("leaves an artifact already ending in exactly one newline unchanged", () => {
|
|
1308
|
+
expect(normalizeTrailingNewline("content\n")).toBe("content\n")
|
|
1309
|
+
})
|
|
1310
|
+
})
|
|
1311
|
+
|
|
1312
|
+
describe("runCli — flush normalizes the artifact's trailing newline", () => {
|
|
1313
|
+
it("a plain-text write command's script (no trailing newline) reaches io.stdout with exactly one", async () => {
|
|
1314
|
+
const repo = new InMemRepo()
|
|
1315
|
+
repo.writeFile(".gitignore", "node_modules\n")
|
|
1316
|
+
repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
|
|
1317
|
+
repo.commitAllWithPrefix("chore: initial commit")
|
|
1318
|
+
const { io, captured } = capturingIo(() => testLayers(repo))
|
|
1319
|
+
await Effect.runPromise(runCli(["node", "gtd.js", "land"], io))
|
|
1320
|
+
const { stdout } = captured()
|
|
1321
|
+
expect(stdout.length).toBeGreaterThan(0)
|
|
1322
|
+
expect(stdout.endsWith("\n")).toBe(true)
|
|
1323
|
+
expect(stdout.endsWith("\n\n")).toBe(false)
|
|
1324
|
+
})
|
|
1325
|
+
})
|
|
1326
|
+
|
|
1327
|
+
// Re-exported so downstream test files can build Command values without
|
|
1328
|
+
// importing from Program.js — keeps the type import exercised here too.
|
|
1329
|
+
export type { Command }
|