@junghanacs/entwurf 0.45.0 → 0.50.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/AGENTS.md +3 -2
- package/BASELINE.md +110 -1
- package/CHANGELOG.md +169 -0
- package/CONTRIBUTING.md +1 -1
- package/ENTWURF-CLI.json +1229 -0
- package/ENTWURF.json +63 -15
- package/FAQ.md +4 -4
- package/README.md +124 -188
- package/VERIFY.md +2 -2
- package/docs/external-mcp-host.md +3 -3
- package/docs/setup-clean-host.md +37 -12
- package/mcp/entwurf-bridge/dist/ENTWURF.json +63 -15
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +19 -8
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +483 -18
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +613 -33
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-resume-call.js +497 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +50 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-location.js +347 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-owner-fence.js +1132 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +18 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +30 -15
- package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +40 -7
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +1 -27
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +1 -1
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
- package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +1 -1
- package/mcp/entwurf-bridge/src/index.ts +28 -8
- package/mcp/entwurf-bridge/tsconfig.build.json +7 -1
- package/native/win32-x64/manifest.json +1 -1
- package/package.json +6 -3
- package/pi/pi-durable/bootstrap.mjs +179 -6
- package/pi/pi-durable/carrier/runtime.js +108 -9
- package/pi/pi-durable/carrier/session-name.js +11 -0
- package/pi/pi-durable/carrier/sessions.js +96 -20
- package/pi/pi-durable/overlay/upstream-pin.json +11 -5
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +710 -38
- package/pi-extensions/lib/fresh-call-dispatch.ts +64 -12
- package/pi-extensions/lib/herdr-fresh-call.ts +919 -40
- package/pi-extensions/lib/herdr-resume-call.ts +810 -0
- package/pi-extensions/lib/meta-session.ts +13 -8
- package/pi-extensions/lib/mux-resume-call.ts +77 -4
- package/pi-extensions/lib/pi-durable-fresh-preflight.ts +9 -2
- package/pi-extensions/lib/pi-durable-location.ts +446 -0
- package/pi-extensions/lib/pi-durable-owner-fence.ts +1461 -0
- package/pi-extensions/lib/pi-durable-runtime.ts +4 -0
- package/pi-extensions/lib/resume-launch-identity.ts +31 -12
- package/pi-extensions/meta-bridge-pi-durable.ts +29 -15
- package/run.sh +411 -657
- package/scripts/check-claude-launch.ts +341 -0
- package/scripts/check-entwurf-v2-surface.ts +2 -2
- package/scripts/check-fresh-call-dispatch.ts +70 -9
- package/scripts/check-fresh-cut-gate.sh +23 -9
- package/scripts/check-gate-qualification.ts +18 -13
- package/scripts/check-herdr-activation.ts +301 -23
- package/scripts/check-herdr-fresh-call.ts +161 -25
- package/scripts/check-herdr-plugin-build.ts +173 -1
- package/scripts/check-herdr-plugin.ts +28 -2
- package/scripts/check-herdr-runtime-bootstrap.ts +136 -0
- package/scripts/check-install-surface.ts +1236 -6
- package/scripts/check-meta-doctor-oracle.sh +1 -1
- package/scripts/check-meta-facts.ts +20 -1
- package/scripts/check-mux-resume-call.ts +28 -0
- package/scripts/check-peer-facts.ts +39 -2
- package/scripts/cli-help.mjs +222 -0
- package/scripts/dev-bin.sh +39 -2
- package/scripts/herdr-activation.mjs +28 -6
- package/scripts/herdr-operator-link.mjs +389 -0
- package/scripts/herdr-plugin-activate.mjs +5 -0
- package/scripts/herdr-plugin-deactivate.mjs +18 -5
- package/scripts/herdr-rails.mjs +43 -7
- package/scripts/herdr-runtime.mjs +27 -0
- package/scripts/meta-bridge-fresh-cut.ts +1 -27
- package/scripts/meta-bridge-prune.ts +1 -1
- package/scripts/meta-facts.ts +1 -1
- package/scripts/mutants/agent-guidance.json +4 -4
- package/scripts/mutants/claude-launch.json +142 -0
- package/scripts/mutants/cli-help-source.json +420 -0
- package/scripts/mutants/dev-gate-dispatch.json +109 -0
- package/scripts/mutants/fresh-call-dispatch.json +38 -2
- package/scripts/mutants/fresh-cut.json +15 -0
- package/scripts/mutants/herdr-activation.json +128 -0
- package/scripts/mutants/herdr-fresh-call.json +278 -5
- package/scripts/mutants/herdr-operator-link.json +318 -0
- package/scripts/mutants/herdr-plugin-build.json +55 -0
- package/scripts/mutants/herdr-plugin.json +17 -0
- package/scripts/mutants/herdr-resume-call.json +288 -0
- package/scripts/mutants/herdr-runtime-bootstrap.json +48 -0
- package/scripts/mutants/meta-facts.json +15 -0
- package/scripts/mutants/mux-resume-call.json +63 -2
- package/scripts/mutants/peer-facts.json +15 -0
- package/scripts/mutants/pi-durable.json +979 -1
- package/scripts/mutants/source-install.json +35 -0
- package/scripts/mutants/v2-surface.json +2 -2
- package/scripts/mutants/v2-visible-resume.json +255 -6
- package/scripts/peer-facts.ts +1 -1
- package/scripts/smoke-agy-install-state.sh +66 -0
- package/scripts/smoke-meta-prune.sh +23 -0
- package/scripts/smoke-omp-fresh-live.ts +12 -3
- package/scripts/smoke-omp-receive-live.ts +7 -1
- package/scripts/smoke-pi-durable-fresh-live.ts +12 -2
- package/team.json.example +39 -0
|
@@ -17,7 +17,10 @@
|
|
|
17
17
|
* compiled twin declared in tsconfig.build.json. The exact miss that killed the three.
|
|
18
18
|
* S3a no npm bin points at a raw .ts at all; S3b every .sh bin that execs one branches.
|
|
19
19
|
* S4 dev-only gates are NOT emitted — the tarball ships operator surfaces, not 70+ gate
|
|
20
|
-
* leaves (a build bloat regression is as real as a missing artifact).
|
|
20
|
+
* leaves (a build bloat regression is as real as a missing artifact). A gate with no
|
|
21
|
+
* explicit arm is dispatched by its own name (`check-x` → `check_x`, run.sh's default
|
|
22
|
+
* arm), so S4 reads those from the function definitions; a case-label reader alone would
|
|
23
|
+
* lose every one of them and stay green on what it no longer sees.
|
|
21
24
|
* S5 no offline smoke carries an obvious write to the operator's real $HOME, and any
|
|
22
25
|
* smoke that swaps HOME into a sandbox swaps XDG_DATA_HOME with it — moving HOME
|
|
23
26
|
* alone still writes real install-state below the inherited XDG root, which is
|
|
@@ -39,6 +42,41 @@
|
|
|
39
42
|
* absent. S7c also pins that the tracked settings carry NO packages key — entwurf's own
|
|
40
43
|
* checkout self-registers through user scope alone (#110).
|
|
41
44
|
* S6 tracked first-party text sources are NUL-free (a stray NUL makes git show nothing).
|
|
45
|
+
* S8 the by-name dispatch answers only for a gate function run.sh itself defined: a run.sh
|
|
46
|
+
* function that is not a gate (`usage`), a gate name nothing defines, one only an
|
|
47
|
+
* exported BASH_FUNC_* defines, and one only a comment/heredoc spells all stay the
|
|
48
|
+
* Unknown command; an admitted gate runs once, with no argv, its streams and exact exit
|
|
49
|
+
* status intact, and stops at its first failure.
|
|
50
|
+
* S9 `entwurf --help`, `entwurf help all` and `entwurf help <command>` render root ENTWURF-CLI.json
|
|
51
|
+
* through scripts/cli-help.mjs. The root forms and the Unknown path print the short root page,
|
|
52
|
+
* byte for byte a reviewed literal fixture (pinned, written before the data and never generated
|
|
53
|
+
* from it); `entwurf help all` prints the full catalog — the ce92dc7 usage bytes (a fixture
|
|
54
|
+
* outside the tarball, pinned) with explicit amendments only, the `entwurf` prefix and the tool
|
|
55
|
+
* count EXPECTED_TOOLS holds — and starts only node on the renderer with `--all`. Every command
|
|
56
|
+
* the root page names is described, dispatchable and outside the debt, and an example naming
|
|
57
|
+
* anything else fails the source by name. The source is read beside the renderer; of the PATH tools
|
|
58
|
+
* it watches, a help run starts only node on that renderer (a PATH watch, not an exec audit —
|
|
59
|
+
* the renderer's node:fs-only reach is read directly); a missing or malformed source fails by
|
|
60
|
+
* name with no fallback text, exit 1 on every form, apart from a refused help request's 2; an
|
|
61
|
+
* early-closed reader ends the page quietly, both pipe sides 0 under pipefail; the committed
|
|
62
|
+
* bytes are canonical — the only guard against a duplicated JSON property, which JSON.parse
|
|
63
|
+
* accepts. A help preflight between `cmd=${1:-}`
|
|
64
|
+
* and the dispatch case answers `help <command>` and `<command> -h|--help` before any arm: on a
|
|
65
|
+
* byte copy that stops at the case, every command run.sh has is asked — described ones print
|
|
66
|
+
* their page, undescribed ones are refused (exit 2) and never run, the five vendor launchers
|
|
67
|
+
* pass through — and extra argv, the `--help <command>` alias and odd names are refused on one
|
|
68
|
+
* line. fresh-cut's longer text matches its ce92dc7 source literal minus the old invocation.
|
|
69
|
+
* The root page is the #153 literal with three explicit #157 amendments (pinned). `entwurf help
|
|
70
|
+
* team` / `help remote-pair` print guide topics byte for byte reviewed literal fixtures (pinned,
|
|
71
|
+
* written before the data; remote-pair with one explicit review amendment, pinned too); only
|
|
72
|
+
* `help <name>` reaches them, through node on the renderer with
|
|
73
|
+
* `--name`; asked as a command (`team --help`) they are refused, their names are no command's,
|
|
74
|
+
* `all` or each other's, and the package files they name are shipped by exact files entries.
|
|
75
|
+
* Coverage reads owners independently of the data: operator commands are the labels of a case
|
|
76
|
+
* byte-pinned to the reviewed ce92dc7 one (evidence for this change, not a ban on later work),
|
|
77
|
+
* gate commands are the functions bash itself defines from run.sh — so a definition left only
|
|
78
|
+
* as heredoc text is no command — and every description names one of them while the
|
|
79
|
+
* undescribed are exactly the ce92dc7 debt.
|
|
42
80
|
*
|
|
43
81
|
* HONEST SCOPE — what a green run does and does not mean. S1-S4 are structural: they read the
|
|
44
82
|
* dispatch graph and the build manifest, so they hold for any entrypoint written in this repo's
|
|
@@ -50,13 +88,34 @@
|
|
|
50
88
|
* and not claimed here. Every S was mutation-checked, including bypasses found in
|
|
51
89
|
* review — do not add an S without proving it fails on the bug it names.
|
|
52
90
|
*
|
|
53
|
-
* Read-only: parses sources
|
|
91
|
+
* Read-only for the repo: parses sources; spawns git, and S8's bounded bash probes — the
|
|
92
|
+
* checkout's run.sh on the Unknown path, a byte copy of run.sh in a tmp dir with one fixture gate
|
|
93
|
+
* defined ahead of the unchanged dispatch, and `bash -c` witnesses that an exported function
|
|
94
|
+
* really imports; S9's root-help runs of the checkout's run.sh and of tmp copies carrying a broken
|
|
95
|
+
* help source, under a PATH whose leading shims record what a help run starts, byte copies that
|
|
96
|
+
* stop before the dispatch to list the functions bash defined (scratch HOME/XDG, the same refusing
|
|
97
|
+
* shims), and a byte copy whose dispatch case is preceded by a sentinel exit, on which every command
|
|
98
|
+
* is asked for its help without any arm being reachable. Each probe gets PATH plus at most the BASH_FUNC_* or scratch HOME/XDG its cell names,
|
|
99
|
+
* never the host environment.
|
|
54
100
|
*/
|
|
55
101
|
|
|
56
|
-
import { execFileSync } from "node:child_process";
|
|
57
|
-
import {
|
|
102
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
103
|
+
import { createHash } from "node:crypto";
|
|
104
|
+
import {
|
|
105
|
+
copyFileSync,
|
|
106
|
+
existsSync,
|
|
107
|
+
mkdirSync,
|
|
108
|
+
mkdtempSync,
|
|
109
|
+
readdirSync,
|
|
110
|
+
readFileSync,
|
|
111
|
+
rmSync,
|
|
112
|
+
writeFileSync,
|
|
113
|
+
} from "node:fs";
|
|
114
|
+
import os from "node:os";
|
|
58
115
|
import path from "node:path";
|
|
59
116
|
import { fileURLToPath } from "node:url";
|
|
117
|
+
import { FRESH_CUT_EXIT } from "../pi-extensions/lib/meta-session.ts";
|
|
118
|
+
import { EXPECTED_TOOLS } from "./probe-bridge-command.ts";
|
|
60
119
|
|
|
61
120
|
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
62
121
|
const RUN_SH = readFileSync(path.join(REPO, "run.sh"), "utf8");
|
|
@@ -64,6 +123,18 @@ const BUILD_TSCONFIG = readFileSync(path.join(REPO, "mcp/entwurf-bridge/tsconfig
|
|
|
64
123
|
const PACKAGE_JSON = JSON.parse(readFileSync(path.join(REPO, "package.json"), "utf8")) as {
|
|
65
124
|
bin?: Record<string, string>;
|
|
66
125
|
};
|
|
126
|
+
// Bash parameter expansion, escaped so the string holds the literal `${1:-}` run.sh carries: the
|
|
127
|
+
// line the top-level dispatch starts at.
|
|
128
|
+
const DISPATCH = `\ncmd=\${1:-}\n`;
|
|
129
|
+
const CLI_JSON = "ENTWURF-CLI.json";
|
|
130
|
+
const CLI_RENDERER = "scripts/cli-help.mjs";
|
|
131
|
+
|
|
132
|
+
/** Copy the help source and its renderer beside a fixture run.sh, where its usage() reads them. */
|
|
133
|
+
function stageHelp(dir: string, json: string = readFileSync(path.join(REPO, CLI_JSON), "utf8")): void {
|
|
134
|
+
mkdirSync(path.join(dir, "scripts"), { recursive: true });
|
|
135
|
+
copyFileSync(path.join(REPO, CLI_RENDERER), path.join(dir, CLI_RENDERER));
|
|
136
|
+
writeFileSync(path.join(dir, CLI_JSON), json);
|
|
137
|
+
}
|
|
67
138
|
|
|
68
139
|
let failed = 0;
|
|
69
140
|
function ok(label: string, cond: boolean, detail?: string): void {
|
|
@@ -171,6 +242,13 @@ function subcommandTargets(): Map<string, string[]> {
|
|
|
171
242
|
|
|
172
243
|
const targets = subcommandTargets();
|
|
173
244
|
const isDevGate = (cmd: string): boolean => cmd.startsWith("check-") || cmd.startsWith("smoke-");
|
|
245
|
+
|
|
246
|
+
/** Gates no explicit arm claims: run.sh's default arm reaches them by name, check-x → check_x. */
|
|
247
|
+
const byNameGates = new Map<string, string[]>();
|
|
248
|
+
for (const [fn, ts] of functionTargets()) {
|
|
249
|
+
const cmd = fn.replace(/_/g, "-");
|
|
250
|
+
if (/^(?:check|smoke)_[a-z0-9_]+$/.test(fn) && !targets.has(cmd)) byNameGates.set(cmd, ts);
|
|
251
|
+
}
|
|
174
252
|
const operatorCmds = [...targets].filter(([cmd, ts]) => !isDevGate(cmd) && ts.length > 0);
|
|
175
253
|
|
|
176
254
|
{
|
|
@@ -188,16 +266,1168 @@ const operatorCmds = [...targets].filter(([cmd, ts]) => !isDevGate(cmd) && ts.le
|
|
|
188
266
|
.join("\n\n"),
|
|
189
267
|
);
|
|
190
268
|
|
|
191
|
-
|
|
269
|
+
// Counted from the raw text, not through functionTargets, so a reader that stopped seeing gate
|
|
270
|
+
// functions shrinks this set against an independent number instead of passing on fewer rows.
|
|
271
|
+
const gateFnDefs = [...RUN_SH.matchAll(/^((?:check|smoke)_[a-z0-9_]+)\(\)\s*\{/gm)].map((m) => m[1] as string);
|
|
272
|
+
const unaccounted = gateFnDefs.filter((fn) => {
|
|
273
|
+
const cmd = fn.replace(/_/g, "-");
|
|
274
|
+
return !targets.has(cmd) && !byNameGates.has(cmd);
|
|
275
|
+
});
|
|
276
|
+
ok(
|
|
277
|
+
`[QK:DEV-GATE-BY-NAME-READ] S4 reads every gate function: ${byNameGates.size} dispatched by name + the rest under an explicit arm`,
|
|
278
|
+
byNameGates.size > 0 && unaccounted.length === 0,
|
|
279
|
+
unaccounted.length ? `gate functions S4 cannot see: ${unaccounted.join(", ")}` : "no gate is dispatched by name",
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
// Names alone are not coverage: a reader that kept every name but lost its leaves would pass
|
|
283
|
+
// the bloat check on empty rows. Two gates the by-name arm reaches, with the leaf each one's
|
|
284
|
+
// run.sh function runs (read off run.sh by hand, not off this reader).
|
|
285
|
+
const knownLeaves: [cmd: string, leaf: string][] = [
|
|
286
|
+
["check-peer-facts", "check-peer-facts"],
|
|
287
|
+
["smoke-herdr-plugin-build-live", "smoke-herdr-plugin-build-live"],
|
|
288
|
+
];
|
|
289
|
+
const lostLeaves = knownLeaves.filter(([cmd, leaf]) => !(byNameGates.get(cmd) ?? []).includes(leaf));
|
|
290
|
+
ok(
|
|
291
|
+
"[QK:DEV-GATE-BY-NAME-LEAVES] S4 reads the run_ts leaf of a by-name gate, not only its name",
|
|
292
|
+
lostLeaves.length === 0,
|
|
293
|
+
lostLeaves.map(([cmd, leaf]) => `'${cmd}' should reach scripts/${leaf}.ts`).join("\n"),
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
const bloat = [...targets, ...byNameGates].flatMap(([cmd, tss]) =>
|
|
192
297
|
isDevGate(cmd) ? tss.filter((ts) => emitted.has(ts)).map((ts) => [cmd, ts]) : [],
|
|
193
298
|
);
|
|
194
299
|
ok(
|
|
195
|
-
"S4: dev-only gates are not emitted into the tarball",
|
|
300
|
+
"[QK:DEV-GATE-BY-NAME-NOT-EMITTED] S4: dev-only gates are not emitted into the tarball (explicit arms and by-name gates)",
|
|
196
301
|
bloat.length === 0,
|
|
197
302
|
bloat.map(([cmd, ts]) => `'${cmd}' is a dev gate but scripts/${ts}.ts is in the build include`).join("\n"),
|
|
198
303
|
);
|
|
199
304
|
}
|
|
200
305
|
|
|
306
|
+
// ─── S8 — the by-name dispatch answers only for a defined gate function ──────────────
|
|
307
|
+
{
|
|
308
|
+
// Only PATH crosses: the probes end on the Unknown path, and no host identity or secret has a
|
|
309
|
+
// reason to reach them.
|
|
310
|
+
const probe = (cmd: string) =>
|
|
311
|
+
spawnSync("bash", [path.join(REPO, "run.sh"), cmd], {
|
|
312
|
+
cwd: REPO,
|
|
313
|
+
encoding: "utf8",
|
|
314
|
+
env: { PATH: process.env.PATH ?? "/usr/bin:/bin" },
|
|
315
|
+
});
|
|
316
|
+
// The Unknown path prints the root page: its first line, with its LF, read from the reviewed
|
|
317
|
+
// literal (S9 owns the whole-page comparison), marks that page here.
|
|
318
|
+
const UNKNOWN_PAGE_START = `${readFileSync(path.join(REPO, "test/fixtures/cli-help/root-compact-153.txt"), "utf8").split("\n", 1)[0]}\n`;
|
|
319
|
+
const isUnknown = (cmd: string): boolean => {
|
|
320
|
+
const r = probe(cmd);
|
|
321
|
+
return r.status === 1 && r.stderr === `Unknown command: ${cmd}\n` && r.stdout.startsWith(UNKNOWN_PAGE_START);
|
|
322
|
+
};
|
|
323
|
+
ok(
|
|
324
|
+
"[QK:BY-NAME-GATE-PREFIX] S8a a run.sh function that is not a gate (`usage`) is still the Unknown command, never run by name",
|
|
325
|
+
isUnknown("usage"),
|
|
326
|
+
);
|
|
327
|
+
ok(
|
|
328
|
+
"[QK:BY-NAME-GATE-DEFINED] S8b a gate name no function defines is still the Unknown command (rc 1 + usage), never a bash command-not-found",
|
|
329
|
+
isUnknown("check-no-such-gate"),
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
// A byte copy of run.sh with one fixture gate defined ahead of the dispatch, which stays
|
|
333
|
+
// exactly the production text. The gate fails at its third line, so a dispatcher that loses
|
|
334
|
+
// errexit runs the marker after it; a comment and a heredoc spell two more gate names that
|
|
335
|
+
// nothing defines.
|
|
336
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "entwurf-by-name-"));
|
|
337
|
+
try {
|
|
338
|
+
if (RUN_SH.split(DISPATCH).length !== 2)
|
|
339
|
+
throw new Error(`S8: run.sh no longer has exactly one \`cmd=\${1:-}\` line`);
|
|
340
|
+
const fixture = [
|
|
341
|
+
"check_dispatch_probe() {",
|
|
342
|
+
" printf 'argc=%s\\n' \"$#\"",
|
|
343
|
+
" echo probe-stderr >&2",
|
|
344
|
+
" (exit 7)",
|
|
345
|
+
" echo MARK-AFTER-FAILURE",
|
|
346
|
+
"}",
|
|
347
|
+
"# check_comment_only() { :; }",
|
|
348
|
+
": <<'TEXT_ONLY'",
|
|
349
|
+
"check_heredoc_only() { :; }",
|
|
350
|
+
"TEXT_ONLY",
|
|
351
|
+
].join("\n");
|
|
352
|
+
const copy = path.join(dir, "run.sh");
|
|
353
|
+
writeFileSync(copy, RUN_SH.replace(DISPATCH, `\n${fixture}${DISPATCH}`));
|
|
354
|
+
stageHelp(dir); // the Unknown path renders the usage, which reads beside run.sh
|
|
355
|
+
const run = (argv: string[], extraEnv: Record<string, string> = {}) =>
|
|
356
|
+
spawnSync("bash", [copy, ...argv], {
|
|
357
|
+
cwd: dir,
|
|
358
|
+
encoding: "utf8",
|
|
359
|
+
env: { PATH: process.env.PATH ?? "/usr/bin:/bin", ...extraEnv },
|
|
360
|
+
});
|
|
361
|
+
const unknownIn = (cmd: string, extraEnv: Record<string, string> = {}): boolean => {
|
|
362
|
+
const r = run([cmd], extraEnv);
|
|
363
|
+
return r.status === 1 && r.stderr === `Unknown command: ${cmd}\n` && r.stdout.startsWith(UNKNOWN_PAGE_START);
|
|
364
|
+
};
|
|
365
|
+
const probe = run(["check-dispatch-probe", "extra-a", "extra-b"]);
|
|
366
|
+
ok(
|
|
367
|
+
"[QK:BY-NAME-GATE-RUNS] S8c an admitted gate runs exactly once: its stdout/stderr pass through and its exact nonzero status (7) is run.sh's",
|
|
368
|
+
probe.status === 7 && probe.stdout.split("argc=").length === 2 && probe.stderr === "probe-stderr\n",
|
|
369
|
+
`rc=${probe.status} stdout=${JSON.stringify(probe.stdout)} stderr=${JSON.stringify(probe.stderr)}`,
|
|
370
|
+
);
|
|
371
|
+
ok(
|
|
372
|
+
"[QK:BY-NAME-GATE-NO-ARGV] S8d the gate is called with no arguments, as the explicit arm it replaced did",
|
|
373
|
+
probe.stdout.startsWith("argc=0\n"),
|
|
374
|
+
`stdout=${JSON.stringify(probe.stdout)}`,
|
|
375
|
+
);
|
|
376
|
+
ok(
|
|
377
|
+
"[QK:BY-NAME-GATE-STOPS] S8e the gate stops at its first failure: the line after it never runs",
|
|
378
|
+
!probe.stdout.includes("MARK-AFTER-FAILURE") && probe.status === 7,
|
|
379
|
+
`rc=${probe.status} stdout=${JSON.stringify(probe.stdout)}`,
|
|
380
|
+
);
|
|
381
|
+
// An exported function for `fn`, plus the witness that bash really imports that spelling —
|
|
382
|
+
// without it, a malformed variable would leave no function to refuse and a cell would pass
|
|
383
|
+
// on nothing.
|
|
384
|
+
const exportFn = (fn: string): { env: Record<string, string>; imported: boolean } => {
|
|
385
|
+
const env = { [`BASH_FUNC_${fn}%%`]: "() { echo env-function-ran\n}" };
|
|
386
|
+
const w = spawnSync("bash", ["-c", fn], {
|
|
387
|
+
encoding: "utf8",
|
|
388
|
+
env: { PATH: process.env.PATH ?? "/usr/bin:/bin", ...env },
|
|
389
|
+
});
|
|
390
|
+
return { env, imported: w.stdout === "env-function-ran\n" };
|
|
391
|
+
};
|
|
392
|
+
const envOnly = exportFn("check_env_only");
|
|
393
|
+
ok(
|
|
394
|
+
"[QK:BY-NAME-GATE-DEFINED-HERE] S8f a gate only an exported environment function defines is the Unknown command",
|
|
395
|
+
envOnly.imported && unknownIn("check-env-only", envOnly.env),
|
|
396
|
+
`import witness: ${envOnly.imported}`,
|
|
397
|
+
);
|
|
398
|
+
// The same refusal for a name run.sh spells only in a comment or a heredoc, with a real
|
|
399
|
+
// exported function of that name present — the text must not vouch for it.
|
|
400
|
+
const textOnly = ["check_comment_only", "check_heredoc_only"].map((fn) => {
|
|
401
|
+
const e = exportFn(fn);
|
|
402
|
+
return { fn, imported: e.imported, unknown: unknownIn(fn.replace(/_/g, "-"), e.env) };
|
|
403
|
+
});
|
|
404
|
+
ok(
|
|
405
|
+
"S8g an exported function whose name run.sh spells only in a comment or a heredoc is the Unknown command",
|
|
406
|
+
textOnly.every((c) => c.imported && c.unknown),
|
|
407
|
+
JSON.stringify(textOnly),
|
|
408
|
+
);
|
|
409
|
+
const shadowed = run(["check-dispatch-probe"], { "BASH_FUNC_check_dispatch_probe%%": "() { exit 0\n}" });
|
|
410
|
+
ok(
|
|
411
|
+
"S8h an environment function with the name of a gate run.sh defines does not replace it",
|
|
412
|
+
shadowed.status === 7 && shadowed.stdout === "argc=0\n",
|
|
413
|
+
`rc=${shadowed.status} stdout=${JSON.stringify(shadowed.stdout)}`,
|
|
414
|
+
);
|
|
415
|
+
} finally {
|
|
416
|
+
rmSync(dir, { recursive: true, force: true });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ─── S9 — `entwurf --help` renders ENTWURF-CLI.json, which describes only real commands ─
|
|
421
|
+
{
|
|
422
|
+
// The independent oracle is what ce92dc7's usage() heredoc actually printed, kept byte for byte
|
|
423
|
+
// outside the tarball; the pin fences the fixture itself so neither can drift alone. It is the
|
|
424
|
+
// CONTENT oracle of the full catalog — the page `entwurf help all` prints, which was the root page
|
|
425
|
+
// until the root became the short page below — and no expected catalog text or inventory is
|
|
426
|
+
// derived from the description data. The command pages further down are checked against a page
|
|
427
|
+
// built from the data entry — a routing and format oracle; their content is bound by this catalog
|
|
428
|
+
// fixture (every synopsis and summary also prints in the catalog) and by fresh-cut's own fixture.
|
|
429
|
+
const OLD_USAGE_FIXTURE = "test/fixtures/cli-help/usage-ce92dc7.txt";
|
|
430
|
+
const OLD_USAGE_SHA256 = "e67f1cb97e5022b865f525d8fb37e3cd0fd0adac927323efd2f27beb9c9e23e6";
|
|
431
|
+
const oldUsage = readFileSync(path.join(REPO, OLD_USAGE_FIXTURE), "utf8");
|
|
432
|
+
const sha256 = (s: string): string => createHash("sha256").update(s).digest("hex");
|
|
433
|
+
if (sha256(oldUsage) !== OLD_USAGE_SHA256) throw new Error(`S9: ${OLD_USAGE_FIXTURE} is not the ce92dc7 usage`);
|
|
434
|
+
// The root page's own oracle: a reviewed literal, pinned, written before the data that renders it
|
|
435
|
+
// and never generated from it. Moving the root forms from the catalog bytes to these is the
|
|
436
|
+
// intended change; the catalog keeps its own oracle above, observed through `help all`.
|
|
437
|
+
const COMPACT_FIXTURE = "test/fixtures/cli-help/root-compact-153.txt";
|
|
438
|
+
const COMPACT_SHA256 = "30176fa0a2e8598a2ffca98c0e2c4ac773f57dc3428042e15286c87e2b8832ae";
|
|
439
|
+
const compact153 = readFileSync(path.join(REPO, COMPACT_FIXTURE), "utf8");
|
|
440
|
+
if (sha256(compact153) !== COMPACT_SHA256) {
|
|
441
|
+
throw new Error(`S9: ${COMPACT_FIXTURE} is not the reviewed root page`);
|
|
442
|
+
}
|
|
443
|
+
// #157 (main 0922fd, approved for GLG): the help row names a command or a topic, and one footer
|
|
444
|
+
// line points at the two guides; #157 stage 4 adds the Claude Code launch row. Exactly these three
|
|
445
|
+
// literal edits of the #153 page, each exactly once, and the result pinned too — the sha below was
|
|
446
|
+
// taken from the amended literal before the data changed.
|
|
447
|
+
const ROOT_AMENDMENTS_157: { old: string; next: string }[] = [
|
|
448
|
+
{
|
|
449
|
+
old: " entwurf help [<command>] this overview, or one command's full description\n",
|
|
450
|
+
next: " entwurf help [<name>] this overview, or command/topic help\n",
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
old: "not commands here. Advanced maintenance and development gates: entwurf help all\n",
|
|
454
|
+
next: "not commands here. Advanced maintenance and development gates: entwurf help all\nGuides: entwurf help team | entwurf help remote-pair\n",
|
|
455
|
+
},
|
|
456
|
+
// #157 stage 4 (GLG, relayed by main 07697d): the Claude Code launch row, right after
|
|
457
|
+
// pi-durable's — receipt: the run.sh `claude)` arm. Pinned below with the other two.
|
|
458
|
+
{
|
|
459
|
+
old: " entwurf pi-durable [args...] the packaged durable pi launcher\n",
|
|
460
|
+
next: " entwurf pi-durable [args...] the packaged durable pi launcher\n entwurf claude [args...] Claude Code itself; args go to claude unchanged\n",
|
|
461
|
+
},
|
|
462
|
+
];
|
|
463
|
+
let expectedCompact = compact153;
|
|
464
|
+
for (const a of ROOT_AMENDMENTS_157) {
|
|
465
|
+
if (expectedCompact.split(a.old).length !== 2) throw new Error(`S9: root amendment not exactly once: ${a.old}`);
|
|
466
|
+
expectedCompact = expectedCompact.replace(a.old, a.next);
|
|
467
|
+
}
|
|
468
|
+
if (sha256(expectedCompact) !== "24d41fb481b910d2c60a9c14d92dc75f200b7d1214c104b6f26e61a85a8b0949") {
|
|
469
|
+
throw new Error("S9: the amended root page is not the reviewed #157 page");
|
|
470
|
+
}
|
|
471
|
+
// The guide topics' oracles: reviewed literals, pinned, written before the data and never
|
|
472
|
+
// generated from it. Each names the package files it sends the reader to (S9w checks they ship).
|
|
473
|
+
const TOPIC_FIXTURES: { name: string; file: string; sha: string; ships: string[] }[] = [
|
|
474
|
+
{
|
|
475
|
+
name: "team",
|
|
476
|
+
file: "test/fixtures/cli-help/topic-team-157.txt",
|
|
477
|
+
sha: "c5691a0db1cb2dcccac0042fb1cf0439dd472e87623ea6af8419825062b2f672",
|
|
478
|
+
ships: ["team.json.example"],
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
name: "remote-pair",
|
|
482
|
+
file: "test/fixtures/cli-help/topic-remote-pair-157.txt",
|
|
483
|
+
sha: "eb60224e5b588b9c6cfed1cd43d816ec0f9a0d10abc2c6da8decc6f3e0808052",
|
|
484
|
+
ships: ["docs/setup-clean-host.md"],
|
|
485
|
+
},
|
|
486
|
+
];
|
|
487
|
+
const topicPage = new Map<string, string>();
|
|
488
|
+
for (const t of TOPIC_FIXTURES) {
|
|
489
|
+
const text = readFileSync(path.join(REPO, t.file), "utf8");
|
|
490
|
+
if (sha256(text) !== t.sha) throw new Error(`S9: ${t.file} is not the reviewed ${t.name} guide`);
|
|
491
|
+
topicPage.set(t.name, text);
|
|
492
|
+
}
|
|
493
|
+
// #157 review amendment (H1-AMEND-A1): the remote-pair guide's path rule is per host. Exactly this
|
|
494
|
+
// one literal edit of its baseline fixture, exactly once, and the result pinned — the sha below
|
|
495
|
+
// was taken by the coordinator before the data changed. The baseline fixture itself is unchanged.
|
|
496
|
+
const REMOTE_PAIR_AMENDMENT = {
|
|
497
|
+
old: " moves the store; the file follows the store. The sending bridge and the\n SSH receive side must resolve the same path.\n",
|
|
498
|
+
next: " moves the store; the file follows the store. On each host, its bridge and\n its incoming SSH receive process must resolve the same local opt-in path.\n The paths on the two hosts need not be identical.\n",
|
|
499
|
+
};
|
|
500
|
+
const remoteBaseline = topicPage.get("remote-pair") ?? "";
|
|
501
|
+
if (remoteBaseline.split(REMOTE_PAIR_AMENDMENT.old).length !== 2) {
|
|
502
|
+
throw new Error("S9: the remote-pair amendment is not exactly once in its baseline fixture");
|
|
503
|
+
}
|
|
504
|
+
const remoteAmended = remoteBaseline.replace(REMOTE_PAIR_AMENDMENT.old, REMOTE_PAIR_AMENDMENT.next);
|
|
505
|
+
if (sha256(remoteAmended) !== "2c53d9050a3c76fdc73c43ac1339b82808dc0f45548ef73903b7e105a7b6780e") {
|
|
506
|
+
throw new Error("S9: the amended remote-pair guide is not the reviewed #157 page");
|
|
507
|
+
}
|
|
508
|
+
topicPage.set("remote-pair", remoteAmended);
|
|
509
|
+
|
|
510
|
+
// The catalog today is that ce92dc7 fixture with exactly these explicit amendments applied, each naming
|
|
511
|
+
// the source that justifies it — never a text regenerated from the new data. The prefix: a command
|
|
512
|
+
// line names the product, not the checkout's script; columns are absolute, so the character the
|
|
513
|
+
// prefix loses becomes one more space before `#`. The literals: an old phrase that is no longer
|
|
514
|
+
// true, replaced where it stands.
|
|
515
|
+
const respell = (l: string): string =>
|
|
516
|
+
l.replace(/^ {2}\.\/run\.sh (\S.*?)( +)# /, (_m, syn, gap) => ` entwurf ${syn}${gap} # `);
|
|
517
|
+
const toolCountWord = ({ 9: "nine" } as Record<number, string>)[EXPECTED_TOOLS.length];
|
|
518
|
+
const literalAmendments: { old: string; next: string; receipt: string }[] = [
|
|
519
|
+
{
|
|
520
|
+
old: "EXACTLY the eight shipped garden verbs",
|
|
521
|
+
next: `EXACTLY the ${toolCountWord} shipped garden verbs`,
|
|
522
|
+
receipt:
|
|
523
|
+
"scripts/probe-bridge-command.ts EXPECTED_TOOLS; check-entwurf-bridge-boot G1f checks the live tools/list",
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
old: "roots and BOTH component inverses read-only first; mutates only when every preflight is green; then Claude -> Pi user scope -> runtime -> ledger LAST. A component failure stops with the runtime and ledger intact as retry authority. With NO ledger it removes nothing: a clean host exits 0, and anything found at the plugin's journal/runtime/cache addresses is named",
|
|
527
|
+
next: "roots, BOTH component inverses and the operator link (#153) read-only first; mutates only when every preflight is green; then the operator link -> Claude -> Pi user scope -> runtime -> ledger LAST. A component failure stops with the runtime and ledger intact as retry authority. With NO ledger it removes nothing: a clean host exits 0, and anything found at the plugin's journal/runtime/cache addresses or its operator-link receipt is named",
|
|
528
|
+
receipt:
|
|
529
|
+
"scripts/herdr-plugin-deactivate.mjs: preflightOperatorLink beside the component preflights, removeOperatorLink before the components, operatorLinkLeftovers with no ledger (#153)",
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
old: "--continue (this directory's newest session) and --resume (choose one of its sessions) take none of them.",
|
|
533
|
+
next: "--continue (this directory's newest session), --resume (choose one of its sessions), and --session <native id> (reopen exactly that session of this directory) take none of them and are mutually exclusive.",
|
|
534
|
+
receipt:
|
|
535
|
+
"pi/pi-durable/bootstrap.mjs parseArgs: --session in VALUE_FLAGS; session-continue-exclusive, session-resume-exclusive and fresh-only-argument-with-session beside resume-continue-exclusive and the --resume/--continue fresh-only refusals (#157 G3 B-L1)",
|
|
536
|
+
},
|
|
537
|
+
];
|
|
538
|
+
const respelled = oldUsage.split("\n").filter((l) => respell(l) !== l).length;
|
|
539
|
+
let expectedCatalog = oldUsage.split("\n").map(respell).join("\n");
|
|
540
|
+
const amendmentProblems: string[] = [];
|
|
541
|
+
if (toolCountWord === undefined)
|
|
542
|
+
amendmentProblems.push(`EXPECTED_TOOLS has ${EXPECTED_TOOLS.length} tools, no reviewed word`);
|
|
543
|
+
for (const a of literalAmendments) {
|
|
544
|
+
if (expectedCatalog.split(a.old).length !== 2) amendmentProblems.push(`not exactly once in the fixture: ${a.old}`);
|
|
545
|
+
expectedCatalog = expectedCatalog.replace(a.old, a.next);
|
|
546
|
+
}
|
|
547
|
+
// A line the old usage never had: a debt name described since, inserted right after the fixture
|
|
548
|
+
// line it belongs beside. The text is a reviewed literal whose receipt is the arm it describes.
|
|
549
|
+
const insertedLines: { after: string; line: string; receipt: string }[] = [
|
|
550
|
+
{
|
|
551
|
+
after: " entwurf pi-durable [args...] # ",
|
|
552
|
+
line: " entwurf pi [args...] # #118: the MANAGED pi launch. exec()s the pi that `type -P` finds on PATH (a file, never a shell function or alias) in THIS terminal, with --entwurf-control added ahead of your argv, which passes through untouched. Refuses by name when ENTWURF_PI_LAUNCH_ACTIVE is non-empty (it exports that to the pi it starts), when no pi is on PATH (it launches the pi coding agent, it does not install one), or when that path is not an executable regular file",
|
|
553
|
+
receipt:
|
|
554
|
+
"run.sh `pi)` arm: type -P pi, the non-empty ENTWURF_PI_LAUNCH_ACTIVE refusal, the missing and non-regular refusals, exec with --entwurf-control",
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
after: " entwurf pi [args...] # ",
|
|
558
|
+
line: " entwurf claude [args...] # #157: the Claude Code launch. exec()s the claude that `type -P` finds on PATH (a file, never a shell function or alias) in THIS terminal and directory; your argv, --help included, reaches it untouched with nothing added, and its exit status and signals are its own. It sets ENTWURF_CLAUDE_LAUNCH_ACTIVE and removes an inherited PI_SESSION_ID/PI_AGENT_ID pair (another citizen's identity); no other variable is decided here. A garden citizen comes from the meta-bridge plugin and hooks setup installed, which this launch neither checks nor supplies: without them it is plain Claude Code. Refuses by name when ENTWURF_CLAUDE_LAUNCH_ACTIVE is non-empty, when no claude is on PATH (it launches Claude Code, it does not install one), when that path is not an executable regular file, or when it resolves to this run.sh",
|
|
559
|
+
receipt:
|
|
560
|
+
"run.sh `claude)` arm: type -P claude, the non-empty ENTWURF_CLAUDE_LAUNCH_ACTIVE refusal, the missing, non-regular and resolves-to-run.sh refusals, unset PI_SESSION_ID PI_AGENT_ID, export of the sentinel, exec with the operator argv alone (#157 stage 4)",
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
after: " entwurf check-codex-app-server-launch # ",
|
|
564
|
+
line: " entwurf check-claude-launch # #157 stage 4 gate: the Claude Code launch `entwurf claude`, driven through its public address against a FAKE VENDOR on a sandbox PATH. The verb dropped, the argv byte-identical with nothing injected, --help/-h reaching the vendor, the caller's cwd, the sentinel exported, an inherited PI_SESSION_ID/PI_AGENT_ID pair removed and no other environment change, the exit status, pid (exec) and signal passed through, and named refusals for an absent vendor, a non-regular PATH hit, the sentinel and a PATH claude that resolves to run.sh. Hermetic: no real Claude Code, no network, no model turn",
|
|
565
|
+
receipt: "run.sh check_claude_launch -> scripts/check-claude-launch.ts (#157 stage 4)",
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
after: " entwurf herdr-plugin-deactivate # ",
|
|
569
|
+
line: " entwurf herdr-plugin-expose-operator --bin-dir <abs> # #153 the ONE explicit operator link: creates exactly <abs>/entwurf -> the Herdr runtime's own node_modules/.bin/entwurf, recorded by a receipt beside the activation ledger (raised to schema 3 first, so an artifact that cannot undo the link refuses the host). Runs only as the active runtime's package, only from a settled runtime with an active ledger, only into a physical directory that is an entry of the caller's PATH with no earlier entwurf on it; an existing file there without a receipt is someone else's and is refused. Never edits a profile or picks a directory",
|
|
570
|
+
receipt: "run.sh `herdr-plugin-expose-operator)` arm -> scripts/herdr-operator-link.mjs expose (#153)",
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
after: " entwurf herdr-plugin-expose-operator --bin-dir <abs> # ",
|
|
574
|
+
line: " entwurf herdr-plugin-withdraw-operator # #153 the opt-out of herdr-plugin-expose-operator: removes exactly the receipt's link — only while it is still that link — and then the receipt; the ledger stays at schema 3. Runs only as the active runtime's package. herdr-plugin-deactivate removes the same link first, before any component",
|
|
575
|
+
receipt: "run.sh `herdr-plugin-withdraw-operator)` arm -> scripts/herdr-operator-link.mjs withdraw (#153)",
|
|
576
|
+
},
|
|
577
|
+
];
|
|
578
|
+
for (const ins of insertedLines) {
|
|
579
|
+
const rows = expectedCatalog.split("\n");
|
|
580
|
+
const at = rows.flatMap((l, i) => (l.startsWith(ins.after) ? [i] : []));
|
|
581
|
+
if (at.length !== 1) amendmentProblems.push(`insertion anchor not exactly once: ${ins.after}`);
|
|
582
|
+
else rows.splice(at[0] + 1, 0, ins.line);
|
|
583
|
+
expectedCatalog = rows.join("\n");
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const helpEnv = { PATH: process.env.PATH ?? "/usr/bin:/bin" };
|
|
587
|
+
const runHelp = (runSh: string, argv: string[], cwd: string, env: Record<string, string> = helpEnv) =>
|
|
588
|
+
spawnSync("bash", [runSh, ...argv], { cwd, encoding: "utf8", env });
|
|
589
|
+
const rootForms: string[][] = [[], ["-h"], ["--help"], ["help"]];
|
|
590
|
+
/** Recording shims for the tools a help run must not start; `node` gets `nodeTail` instead. */
|
|
591
|
+
const makeShims = (dir: string, nodeTail: string): { bin: string; trace: string } => {
|
|
592
|
+
const bin = path.join(dir, "shims");
|
|
593
|
+
const trace = path.join(dir, "trace");
|
|
594
|
+
mkdirSync(bin);
|
|
595
|
+
writeFileSync(trace, "");
|
|
596
|
+
const watched = [
|
|
597
|
+
"python3",
|
|
598
|
+
"pnpm",
|
|
599
|
+
"npm",
|
|
600
|
+
"git",
|
|
601
|
+
"tmux",
|
|
602
|
+
"herdr",
|
|
603
|
+
"pi",
|
|
604
|
+
"claude",
|
|
605
|
+
"codex",
|
|
606
|
+
"copilot",
|
|
607
|
+
"ssh",
|
|
608
|
+
"curl",
|
|
609
|
+
];
|
|
610
|
+
for (const name of ["node", ...watched]) {
|
|
611
|
+
writeFileSync(
|
|
612
|
+
path.join(bin, name),
|
|
613
|
+
`#!/bin/sh\nprintf '%s' "${name}" >>'${trace}'; printf ' %s' "$@" >>'${trace}'; echo >>'${trace}'\n${name === "node" ? nodeTail : "exit 99"}\n`,
|
|
614
|
+
{ mode: 0o755 },
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
return { bin, trace };
|
|
618
|
+
};
|
|
619
|
+
const rendersRoot = (runSh: string, cwd: string): string[] => {
|
|
620
|
+
const bad: string[] = [];
|
|
621
|
+
for (const argv of rootForms) {
|
|
622
|
+
const r = runHelp(runSh, argv, cwd);
|
|
623
|
+
if (r.status !== 0 || r.stderr !== "" || r.stdout !== expectedCompact) {
|
|
624
|
+
bad.push(`${JSON.stringify(argv)}: rc=${r.status} stdout-sha=${sha256(r.stdout)} stderr=${r.stderr.trim()}`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const u = runHelp(runSh, ["no-such-command"], cwd);
|
|
628
|
+
if (u.status !== 1 || u.stderr !== "Unknown command: no-such-command\n" || u.stdout !== expectedCompact) {
|
|
629
|
+
bad.push(`unknown: rc=${u.status} stdout-sha=${sha256(u.stdout)} stderr=${u.stderr.trim()}`);
|
|
630
|
+
}
|
|
631
|
+
return bad;
|
|
632
|
+
};
|
|
633
|
+
const rootBad = rendersRoot(path.join(REPO, "run.sh"), REPO);
|
|
634
|
+
ok(
|
|
635
|
+
`[QK:CLI-HELP-ROOT-COMPACT] S9a the root help (no argv, -h, --help, help) and the Unknown path print exactly the reviewed root page (${COMPACT_FIXTURE} with the three #157 amendments)`,
|
|
636
|
+
rootBad.length === 0,
|
|
637
|
+
rootBad.join("\n"),
|
|
638
|
+
);
|
|
639
|
+
// The catalog the root page used to be: `entwurf help all`, against the ce92dc7 oracle.
|
|
640
|
+
const allPage = runHelp(path.join(REPO, "run.sh"), ["help", "all"], REPO);
|
|
641
|
+
ok(
|
|
642
|
+
"[QK:CLI-HELP-ALL-BYTES] S9a* `entwurf help all` prints exactly the ce92dc7 usage bytes with the reviewed amendments, on stdout, exit 0, nothing on stderr",
|
|
643
|
+
allPage.status === 0 && allPage.stderr === "" && allPage.stdout === expectedCatalog,
|
|
644
|
+
`rc=${allPage.status} stdout-sha=${sha256(allPage.stdout)} stderr=${allPage.stderr.trim()}`,
|
|
645
|
+
);
|
|
646
|
+
const catalogNow = allPage.stdout;
|
|
647
|
+
ok(
|
|
648
|
+
"[QK:CLI-HELP-AMENDMENTS] S9a' each amendment is exact in the catalog `help all` prints: every command line is spelled `entwurf`, the tool count is the one EXPECTED_TOOLS holds, and no replaced phrase survives",
|
|
649
|
+
amendmentProblems.length === 0 &&
|
|
650
|
+
respelled === 189 &&
|
|
651
|
+
!/^ {2}\.\/run\.sh /m.test(catalogNow) &&
|
|
652
|
+
literalAmendments.every((a) => catalogNow.split(a.next).length === 2 && !catalogNow.includes(a.old)),
|
|
653
|
+
[...amendmentProblems, `respelled lines: ${respelled}`].join("\n"),
|
|
654
|
+
);
|
|
655
|
+
// pi left the description debt: its catalog line is the reviewed literal, once, beside
|
|
656
|
+
// pi-durable's, and `entwurf help pi` prints that same summary as its page.
|
|
657
|
+
const piRows = catalogNow.split("\n");
|
|
658
|
+
const piLine = insertedLines[0].line;
|
|
659
|
+
const piAt = piRows.flatMap((l, i) => (l === piLine ? [i] : []));
|
|
660
|
+
const piPage = runHelp(path.join(REPO, "run.sh"), ["help", "pi"], REPO);
|
|
661
|
+
const piSummary = piLine.slice(piLine.indexOf("# ") + 2);
|
|
662
|
+
ok(
|
|
663
|
+
"[QK:CLI-HELP-PI-DESCRIBED] S9a'' `pi` is described by the reviewed literal: one catalog line, right after pi-durable's, and `entwurf help pi` prints that summary",
|
|
664
|
+
piAt.length === 1 &&
|
|
665
|
+
piRows[piAt[0] - 1].startsWith(insertedLines[0].after) &&
|
|
666
|
+
piPage.status === 0 &&
|
|
667
|
+
piPage.stderr === "" &&
|
|
668
|
+
piPage.stdout === `Usage: entwurf pi [args...]\n\n${piSummary}\n`,
|
|
669
|
+
JSON.stringify({ lines: piAt.length, rc: piPage.status, stderr: piPage.stderr, page: piPage.stdout.slice(0, 200) }),
|
|
670
|
+
);
|
|
671
|
+
// #157 — each guide topic, asked `entwurf help <name>` on the real run.sh, prints its reviewed
|
|
672
|
+
// literal: stdout only, exit 0, nothing on stderr.
|
|
673
|
+
const topicBad = TOPIC_FIXTURES.flatMap((t) => {
|
|
674
|
+
const r = runHelp(path.join(REPO, "run.sh"), ["help", t.name], REPO);
|
|
675
|
+
return r.status === 0 && r.stderr === "" && r.stdout === topicPage.get(t.name)
|
|
676
|
+
? []
|
|
677
|
+
: [`help ${t.name}: rc=${r.status} stdout-sha=${sha256(r.stdout)} stderr=${r.stderr.trim()}`];
|
|
678
|
+
});
|
|
679
|
+
ok(
|
|
680
|
+
`[QK:CLI-HELP-TOPIC-BYTES] S9a''' \`entwurf help team\` and \`entwurf help remote-pair\` print exactly their reviewed guide pages (team: its fixture; remote-pair: its fixture with the one pinned review amendment), on stdout, exit 0, nothing on stderr`,
|
|
681
|
+
topicBad.length === 0,
|
|
682
|
+
topicBad.join("\n"),
|
|
683
|
+
);
|
|
684
|
+
// The files a guide sends its reader to must reach every install: each is an EXACT entry of the
|
|
685
|
+
// package.json files array (no glob reading here — the tarball proof is check-pack's), exists in
|
|
686
|
+
// this checkout, and is named by its guide's reviewed literal.
|
|
687
|
+
const packageFiles = (JSON.parse(readFileSync(path.join(REPO, "package.json"), "utf8")) as { files: string[] }).files;
|
|
688
|
+
const unshipped = TOPIC_FIXTURES.flatMap((t) =>
|
|
689
|
+
t.ships.flatMap((f) =>
|
|
690
|
+
packageFiles.includes(f) && existsSync(path.join(REPO, f)) && (topicPage.get(t.name) ?? "").includes(f)
|
|
691
|
+
? []
|
|
692
|
+
: [`${t.name} -> ${f}: files entry=${packageFiles.includes(f)} exists=${existsSync(path.join(REPO, f))}`],
|
|
693
|
+
),
|
|
694
|
+
);
|
|
695
|
+
ok(
|
|
696
|
+
"[QK:CLI-HELP-TOPIC-SHIPPED] S9w every package file a guide topic names (team.json.example, docs/setup-clean-host.md) is an exact package.json files entry, exists, and is named by that guide",
|
|
697
|
+
unshipped.length === 0,
|
|
698
|
+
unshipped.join("\n"),
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
const scratch = mkdtempSync(path.join(os.tmpdir(), "entwurf-cli-help-"));
|
|
702
|
+
try {
|
|
703
|
+
// A decoy source in the caller's cwd and HOME: the renderer must read the bytes beside itself.
|
|
704
|
+
const decoy = path.join(scratch, "decoy");
|
|
705
|
+
mkdirSync(path.join(decoy, "scripts"), { recursive: true });
|
|
706
|
+
writeFileSync(path.join(decoy, CLI_JSON), "{");
|
|
707
|
+
const r = runHelp(path.join(REPO, "run.sh"), ["--help"], decoy, { ...helpEnv, HOME: decoy });
|
|
708
|
+
const rAll = runHelp(path.join(REPO, "run.sh"), ["help", "all"], decoy, { ...helpEnv, HOME: decoy });
|
|
709
|
+
ok(
|
|
710
|
+
"[QK:CLI-HELP-MODULE-RELATIVE] S9b the help source is read beside the renderer, never from the caller's cwd or HOME",
|
|
711
|
+
r.status === 0 && r.stdout === expectedCompact && rAll.status === 0 && rAll.stdout === expectedCatalog,
|
|
712
|
+
`root rc=${r.status} stderr=${r.stderr.trim()}; help all rc=${rAll.status} stderr=${rAll.stderr.trim()}`,
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
// Production run.sh under a PATH whose first entries record what a help run starts, among the
|
|
716
|
+
// tools shimmed here: node is wrapped and then really runs, the rest refuse. This is a PATH
|
|
717
|
+
// watch, not a full exec audit — an absolute-path exec or a module node imports is not seen. The
|
|
718
|
+
// renderer's own reach is read directly: it imports node:fs alone and reads one module-relative
|
|
719
|
+
// file. Within that scope a help run starts one thing: node on the shipped renderer, no argument.
|
|
720
|
+
const { bin: shims, trace } = makeShims(scratch, `exec '${process.execPath}' "$@"`);
|
|
721
|
+
const traced = { PATH: `${shims}:${helpEnv.PATH}` };
|
|
722
|
+
for (const argv of [...rootForms, ["no-such-command"]]) runHelp(path.join(REPO, "run.sh"), argv, REPO, traced);
|
|
723
|
+
const want = `node ${path.join(REPO, CLI_RENDERER)}\n`.repeat(rootForms.length + 1);
|
|
724
|
+
const got = readFileSync(trace, "utf8");
|
|
725
|
+
ok(
|
|
726
|
+
"[QK:CLI-HELP-ONLY-RENDERER] S9c of the PATH tools watched (node, python3, pnpm, npm, git, tmux, herdr, pi, claude, codex, copilot, ssh, curl), a help run starts only node on scripts/cli-help.mjs",
|
|
727
|
+
got === want,
|
|
728
|
+
`trace:\n${got}`,
|
|
729
|
+
);
|
|
730
|
+
// The catalog's own route, watched the same way: `help all` starts node on the renderer with
|
|
731
|
+
// `--all`, the one argv that selects the catalog, and nothing else.
|
|
732
|
+
const allRouteDir = path.join(scratch, "all-route");
|
|
733
|
+
mkdirSync(allRouteDir);
|
|
734
|
+
const allShims = makeShims(allRouteDir, `exec '${process.execPath}' "$@"`);
|
|
735
|
+
runHelp(path.join(REPO, "run.sh"), ["help", "all"], REPO, { PATH: `${allShims.bin}:${helpEnv.PATH}` });
|
|
736
|
+
const allTrace = readFileSync(allShims.trace, "utf8");
|
|
737
|
+
ok(
|
|
738
|
+
"[QK:CLI-HELP-ALL-ROUTE] S9c' of the same watched PATH tools, `entwurf help all` starts exactly node on scripts/cli-help.mjs --all",
|
|
739
|
+
allTrace === `node ${path.join(REPO, CLI_RENDERER)} --all\n`,
|
|
740
|
+
`trace:\n${allTrace}`,
|
|
741
|
+
);
|
|
742
|
+
// A guide's own route, watched the same way: `help <topic>` starts node on the renderer with
|
|
743
|
+
// `--name <topic>` — the one mode that may answer with a topic — and nothing else.
|
|
744
|
+
const topicRouteDir = path.join(scratch, "topic-route");
|
|
745
|
+
mkdirSync(topicRouteDir);
|
|
746
|
+
const topicShims = makeShims(topicRouteDir, `exec '${process.execPath}' "$@"`);
|
|
747
|
+
for (const t of TOPIC_FIXTURES)
|
|
748
|
+
runHelp(path.join(REPO, "run.sh"), ["help", t.name], REPO, { PATH: `${topicShims.bin}:${helpEnv.PATH}` });
|
|
749
|
+
const topicTrace = readFileSync(topicShims.trace, "utf8");
|
|
750
|
+
ok(
|
|
751
|
+
"[QK:CLI-HELP-TOPIC-ROUTE] S9c'' of the same watched PATH tools, `entwurf help <topic>` starts exactly node on scripts/cli-help.mjs --name <topic>",
|
|
752
|
+
topicTrace === TOPIC_FIXTURES.map((t) => `node ${path.join(REPO, CLI_RENDERER)} --name ${t.name}\n`).join(""),
|
|
753
|
+
`trace:\n${topicTrace}`,
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
// A fixture copy whose description source is missing or malformed. Each must fail by name with
|
|
757
|
+
// nothing on stdout — the root forms and the Unknown path alike, no older text behind them.
|
|
758
|
+
// These are the RUNTIME refusals. A duplicated JSON property is not one of them: JSON.parse
|
|
759
|
+
// keeps the last value silently, so only S9g's check-time canonical comparison sees it.
|
|
760
|
+
const good = JSON.parse(readFileSync(path.join(REPO, CLI_JSON), "utf8")) as {
|
|
761
|
+
schemaVersion: number;
|
|
762
|
+
root: Record<string, unknown>;
|
|
763
|
+
topics: Record<string, unknown>[];
|
|
764
|
+
usage: { commandGroups: Record<string, unknown>[][] };
|
|
765
|
+
};
|
|
766
|
+
const variant = (edit: (d: typeof good) => void): string => {
|
|
767
|
+
const d = structuredClone(good);
|
|
768
|
+
edit(d);
|
|
769
|
+
return JSON.stringify(d);
|
|
770
|
+
};
|
|
771
|
+
const first = good.usage.commandGroups[0]?.[0];
|
|
772
|
+
const broken: [label: string, json: string | null][] = [
|
|
773
|
+
["missing", null],
|
|
774
|
+
["not JSON", "{"],
|
|
775
|
+
[
|
|
776
|
+
"unsupported schemaVersion",
|
|
777
|
+
variant((d) => {
|
|
778
|
+
d.schemaVersion = 1;
|
|
779
|
+
}),
|
|
780
|
+
],
|
|
781
|
+
[
|
|
782
|
+
"the previous schemaVersion (2, before the root page)",
|
|
783
|
+
variant((d) => {
|
|
784
|
+
d.schemaVersion = 2;
|
|
785
|
+
}),
|
|
786
|
+
],
|
|
787
|
+
[
|
|
788
|
+
"the previous schemaVersion (3, before the guide topics)",
|
|
789
|
+
variant((d) => {
|
|
790
|
+
d.schemaVersion = 3;
|
|
791
|
+
}),
|
|
792
|
+
],
|
|
793
|
+
[
|
|
794
|
+
"a topic with a field the schema does not know",
|
|
795
|
+
variant((d) => {
|
|
796
|
+
d.topics[0]!.run = "x";
|
|
797
|
+
}),
|
|
798
|
+
],
|
|
799
|
+
[
|
|
800
|
+
"a topic named like a described command",
|
|
801
|
+
variant((d) => {
|
|
802
|
+
d.topics[0]!.name = "setup";
|
|
803
|
+
}),
|
|
804
|
+
],
|
|
805
|
+
[
|
|
806
|
+
"a topic named all",
|
|
807
|
+
variant((d) => {
|
|
808
|
+
d.topics[0]!.name = "all";
|
|
809
|
+
}),
|
|
810
|
+
],
|
|
811
|
+
[
|
|
812
|
+
"one topic name twice",
|
|
813
|
+
variant((d) => {
|
|
814
|
+
d.topics[1]!.name = d.topics[0]!.name;
|
|
815
|
+
}),
|
|
816
|
+
],
|
|
817
|
+
[
|
|
818
|
+
"a root page with a field the schema does not know",
|
|
819
|
+
variant((d) => {
|
|
820
|
+
d.root.run = "x";
|
|
821
|
+
}),
|
|
822
|
+
],
|
|
823
|
+
[
|
|
824
|
+
"an entry with a field the schema does not know",
|
|
825
|
+
variant((d) => {
|
|
826
|
+
d.usage.commandGroups[0]![0]!.run = "x";
|
|
827
|
+
}),
|
|
828
|
+
],
|
|
829
|
+
[
|
|
830
|
+
"one command described twice",
|
|
831
|
+
variant((d) => {
|
|
832
|
+
d.usage.commandGroups[1]!.push({ ...first });
|
|
833
|
+
}),
|
|
834
|
+
],
|
|
835
|
+
[
|
|
836
|
+
"details that start on an empty line",
|
|
837
|
+
variant((d) => {
|
|
838
|
+
d.usage.commandGroups[0]![0]!.details = ["", "text"];
|
|
839
|
+
}),
|
|
840
|
+
],
|
|
841
|
+
[
|
|
842
|
+
"details that are not single-line strings",
|
|
843
|
+
variant((d) => {
|
|
844
|
+
d.usage.commandGroups[0]![0]!.details = ["one\ntwo"];
|
|
845
|
+
}),
|
|
846
|
+
],
|
|
847
|
+
];
|
|
848
|
+
const failures: string[] = [];
|
|
849
|
+
const strictness: Record<string, boolean> = {};
|
|
850
|
+
for (const [label, json] of broken) {
|
|
851
|
+
const dir = path.join(scratch, `broken-${Object.keys(strictness).length}`);
|
|
852
|
+
mkdirSync(dir);
|
|
853
|
+
writeFileSync(path.join(dir, "run.sh"), RUN_SH);
|
|
854
|
+
stageHelp(dir, json ?? "");
|
|
855
|
+
if (json === null) rmSync(path.join(dir, CLI_JSON));
|
|
856
|
+
let refused = true;
|
|
857
|
+
// The root forms, the Unknown path, the catalog and a command help: each must be the metadata
|
|
858
|
+
// failure — exactly 1, never the help refusal's 2 — with nothing on stdout.
|
|
859
|
+
for (const argv of [
|
|
860
|
+
["--help"],
|
|
861
|
+
["no-such-command"],
|
|
862
|
+
["help", "all"],
|
|
863
|
+
["peer-facts", "--help"],
|
|
864
|
+
["help", "team"],
|
|
865
|
+
]) {
|
|
866
|
+
const b = runHelp(path.join(dir, "run.sh"), argv, dir);
|
|
867
|
+
if (b.status !== 1 || b.stdout !== "" || !b.stderr.includes(`entwurf: ${CLI_JSON} `)) {
|
|
868
|
+
refused = false;
|
|
869
|
+
failures.push(
|
|
870
|
+
`${label} ${JSON.stringify(argv)}: rc=${b.status} stdout=${b.stdout.length}B stderr=${b.stderr.trim()}`,
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
strictness[label] = refused;
|
|
875
|
+
}
|
|
876
|
+
ok(
|
|
877
|
+
"[QK:CLI-HELP-FAIL-LOUD] S9d a missing or unparseable help source fails help by name, with no stdout and no fallback text",
|
|
878
|
+
strictness.missing === true && strictness["not JSON"] === true,
|
|
879
|
+
failures.join("\n"),
|
|
880
|
+
);
|
|
881
|
+
ok(
|
|
882
|
+
"[QK:CLI-HELP-STRICT-FIELDS] S9e the renderer refuses an old schemaVersion (2 and 3 included), a field the schema does not know in an entry, the root page or a topic, and details that start empty or span lines",
|
|
883
|
+
strictness["unsupported schemaVersion"] === true &&
|
|
884
|
+
strictness["the previous schemaVersion (2, before the root page)"] === true &&
|
|
885
|
+
strictness["the previous schemaVersion (3, before the guide topics)"] === true &&
|
|
886
|
+
strictness["an entry with a field the schema does not know"] === true &&
|
|
887
|
+
strictness["a root page with a field the schema does not know"] === true &&
|
|
888
|
+
strictness["a topic with a field the schema does not know"] === true &&
|
|
889
|
+
strictness["details that start on an empty line"] === true &&
|
|
890
|
+
strictness["details that are not single-line strings"] === true,
|
|
891
|
+
failures.join("\n"),
|
|
892
|
+
);
|
|
893
|
+
ok(
|
|
894
|
+
"[QK:CLI-HELP-UNIQUE] S9f the renderer refuses a command described twice",
|
|
895
|
+
strictness["one command described twice"] === true,
|
|
896
|
+
failures.join("\n"),
|
|
897
|
+
);
|
|
898
|
+
ok(
|
|
899
|
+
"[QK:CLI-HELP-TOPIC-DISJOINT] S9f'' the renderer refuses a topic that takes a described command's name, `all`, or another topic's name — one word, one page",
|
|
900
|
+
strictness["a topic named like a described command"] === true &&
|
|
901
|
+
strictness["a topic named all"] === true &&
|
|
902
|
+
strictness["one topic name twice"] === true,
|
|
903
|
+
failures.join("\n"),
|
|
904
|
+
);
|
|
905
|
+
// The two failure kinds stay apart: a broken source is exit 1 on every form above, while the
|
|
906
|
+
// same command-help shape against an intact source, naming nothing it describes, is the help
|
|
907
|
+
// refusal's exit 2.
|
|
908
|
+
const intactRefusal = runHelp(path.join(REPO, "run.sh"), ["help", "no-such-command"], REPO);
|
|
909
|
+
ok(
|
|
910
|
+
"[QK:CLI-HELP-METADATA-EXIT] S9f' a missing or malformed source is exit 1 on root, Unknown and command help alike — distinct from the exit 2 of a refused help request",
|
|
911
|
+
Object.keys(strictness).length === broken.length &&
|
|
912
|
+
Object.values(strictness).every(Boolean) &&
|
|
913
|
+
intactRefusal.status === 2 &&
|
|
914
|
+
intactRefusal.stdout === "" &&
|
|
915
|
+
intactRefusal.stderr.startsWith("entwurf: no help for "),
|
|
916
|
+
[...failures, `intact refusal: rc=${intactRefusal.status} stderr=${intactRefusal.stderr.trim()}`].join("\n"),
|
|
917
|
+
);
|
|
918
|
+
} finally {
|
|
919
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Check time only: the committed bytes are the canonical serialisation of what they parse to.
|
|
923
|
+
// This is what catches a duplicated JSON property (JSON.parse keeps the last one silently, so it
|
|
924
|
+
// would change the rendering without failing), and it pins one format so a review reads one
|
|
925
|
+
// shape. It says nothing about a file someone edits after install.
|
|
926
|
+
const cliBytes = readFileSync(path.join(REPO, CLI_JSON), "utf8");
|
|
927
|
+
ok(
|
|
928
|
+
"[QK:CLI-HELP-CANONICAL] S9g ENTWURF-CLI.json is its own canonical serialisation (no duplicated property survives the commit)",
|
|
929
|
+
cliBytes === `${JSON.stringify(JSON.parse(cliBytes), null, "\t")}\n`,
|
|
930
|
+
'rewrite it with JSON.stringify(data, null, "\\t") + "\\n"',
|
|
931
|
+
);
|
|
932
|
+
|
|
933
|
+
// Coverage, read independently of the data. Operator commands are the case labels of the
|
|
934
|
+
// top-level dispatch: the `case "$cmd" in` after the `cmd=${1:-}` line, to the column-0 `esac`
|
|
935
|
+
// that closes it. That is a text read, so it is bound to the reviewed ce92dc7 case (plus its reviewed
|
|
936
|
+
// #153 two-arm amendment) by a byte pin
|
|
937
|
+
// — evidence for this change, not a ban on later dispatch work: a change to the case re-reviews
|
|
938
|
+
// its labels and moves the pin in the same change. The labels are not claimed to be
|
|
939
|
+
// runtime-proven. Gate commands are the check_/smoke_ functions bash itself defines when it reads
|
|
940
|
+
// run.sh — a definition spelled only in a heredoc or a comment defines nothing, and an
|
|
941
|
+
// environment function is not run.sh's.
|
|
942
|
+
const CASE_LINE = 'case "$cmd" in\n';
|
|
943
|
+
const start = RUN_SH.indexOf(DISPATCH);
|
|
944
|
+
const caseAt = RUN_SH.indexOf(`\n${CASE_LINE}`, start) + 1;
|
|
945
|
+
const end = RUN_SH.indexOf("\nesac\n", caseAt) + "\nesac\n".length;
|
|
946
|
+
// Moved for #153: two operator-link arms (herdr-plugin-expose-operator / -withdraw-operator), re-reviewed.
|
|
947
|
+
// Moved for #157 stage 4: one `claude)` arm, nothing removed (133 -> 134 labels, re-reviewed: `claude`
|
|
948
|
+
// is a described entry and outside the debt).
|
|
949
|
+
const REVIEWED_CASE_SHA256 = "bfdafcfad8645ea3651905dd08d73808a724c67e42f89f9721c917f3a494bef9";
|
|
950
|
+
ok(
|
|
951
|
+
"[QK:CLI-HELP-DISPATCH-REVIEWED] S9h the case the operator labels are read from is the reviewed ce92dc7 case plus the reviewed #153 two-arm amendment, byte for byte",
|
|
952
|
+
start > 0 && caseAt > start && sha256(RUN_SH.slice(caseAt, end)) === REVIEWED_CASE_SHA256,
|
|
953
|
+
"the top-level case changed: re-review its labels against ENTWURF-CLI.json and the debt below, then move the pin",
|
|
954
|
+
);
|
|
955
|
+
// The help preflight sits between `cmd=${1:-}` and that case, and nowhere else: the only case is
|
|
956
|
+
// the pinned one, and the preflight's code opens no case, esac or heredoc that could hide a
|
|
957
|
+
// second dispatch or move the pinned one.
|
|
958
|
+
const preflightCode = RUN_SH.slice(start + DISPATCH.length, caseAt)
|
|
959
|
+
.split("\n")
|
|
960
|
+
.filter((l) => !/^\s*#/.test(l))
|
|
961
|
+
.join("\n");
|
|
962
|
+
ok(
|
|
963
|
+
"[QK:CLI-HELP-PREFLIGHT-PLACEMENT] S9h' the help preflight is plain if/elif code between the dispatch's `cmd=` line and the one pinned case",
|
|
964
|
+
RUN_SH.split(CASE_LINE).length === 2 &&
|
|
965
|
+
/\bexec node "\$REPO_DIR\/scripts\/cli-help\.mjs" --command\b/.test(preflightCode) &&
|
|
966
|
+
!/\bcase\b|\besac\b|<</.test(preflightCode),
|
|
967
|
+
preflightCode,
|
|
968
|
+
);
|
|
969
|
+
const labels = [...RUN_SH.slice(caseAt, end).matchAll(/^ {2}([a-z][a-z0-9-]*)\)$/gm)].map((m) => m[1] as string);
|
|
970
|
+
|
|
971
|
+
// A byte copy that stops right before the dispatch and lists what bash defined while reading it.
|
|
972
|
+
// Everything above the dispatch runs — assignments and definitions — so the probe gets a scratch
|
|
973
|
+
// HOME/XDG and the same recording shims as S9c (node included, refusing): nothing may start and
|
|
974
|
+
// nothing may be written.
|
|
975
|
+
const bashDefinedGates = (text: string): { names: Set<string>; problems: string[] } => {
|
|
976
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "entwurf-gate-fns-"));
|
|
977
|
+
try {
|
|
978
|
+
if (text.split(DISPATCH).length !== 2) throw new Error("S9: the copy has no single dispatch line");
|
|
979
|
+
const home = path.join(dir, "home");
|
|
980
|
+
mkdirSync(home);
|
|
981
|
+
const { bin, trace } = makeShims(dir, "exit 99");
|
|
982
|
+
const copy = path.join(dir, "run.sh");
|
|
983
|
+
const probe = 'shopt -s extdebug\nfor __f in $(compgen -A function); do declare -F "$__f"; done\nexit 0';
|
|
984
|
+
writeFileSync(copy, text.replace(DISPATCH, `\n${probe}${DISPATCH}`));
|
|
985
|
+
const r = spawnSync("bash", [copy], {
|
|
986
|
+
cwd: dir,
|
|
987
|
+
encoding: "utf8",
|
|
988
|
+
env: {
|
|
989
|
+
PATH: `${bin}:${helpEnv.PATH}`,
|
|
990
|
+
HOME: home,
|
|
991
|
+
XDG_CONFIG_HOME: path.join(home, ".config"),
|
|
992
|
+
XDG_DATA_HOME: path.join(home, ".local/share"),
|
|
993
|
+
XDG_STATE_HOME: path.join(home, ".local/state"),
|
|
994
|
+
XDG_CACHE_HOME: path.join(home, ".cache"),
|
|
995
|
+
},
|
|
996
|
+
});
|
|
997
|
+
const problems: string[] = [];
|
|
998
|
+
if (r.status !== 0 || r.stderr !== "") problems.push(`probe rc=${r.status} stderr=${r.stderr.trim()}`);
|
|
999
|
+
const started = readFileSync(trace, "utf8");
|
|
1000
|
+
if (started !== "") problems.push(`the probe started:\n${started}`);
|
|
1001
|
+
if (readdirSync(home, { recursive: true }).length > 0) problems.push("the probe wrote below its HOME");
|
|
1002
|
+
const names = new Set<string>();
|
|
1003
|
+
for (const line of r.stdout.split("\n")) {
|
|
1004
|
+
const m = line.match(/^((?:check|smoke)_[a-z0-9_]+) [1-9][0-9]* (.*)$/);
|
|
1005
|
+
if (m && m[2] === copy) names.add((m[1] as string).replace(/_/g, "-"));
|
|
1006
|
+
}
|
|
1007
|
+
return { names, problems };
|
|
1008
|
+
} finally {
|
|
1009
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1010
|
+
}
|
|
1011
|
+
};
|
|
1012
|
+
const defined = bashDefinedGates(RUN_SH);
|
|
1013
|
+
const inventory = new Set([...labels, ...defined.names]);
|
|
1014
|
+
const described = (
|
|
1015
|
+
JSON.parse(cliBytes) as { usage: { commandGroups: { synopsis: string }[][] } }
|
|
1016
|
+
).usage.commandGroups.flatMap((g) => g.map((e) => e.synopsis.split(" ", 1)[0] as string));
|
|
1017
|
+
const orphansOf = (inv: Set<string>): string[] => described.filter((n) => !inv.has(n));
|
|
1018
|
+
const orphans = orphansOf(inventory);
|
|
1019
|
+
ok(
|
|
1020
|
+
`[QK:CLI-HELP-NO-ORPHAN] S9i every described command is a reviewed dispatch label or a gate function bash defines from run.sh (${described.length} described, ${labels.length} labels + ${defined.names.size} gate functions)`,
|
|
1021
|
+
labels.length > 0 && defined.names.size > 0 && defined.problems.length === 0 && orphans.length === 0,
|
|
1022
|
+
[...defined.problems, orphans.length ? `described but not reachable: ${orphans.join(", ")}` : ""]
|
|
1023
|
+
.filter(Boolean)
|
|
1024
|
+
.join("\n"),
|
|
1025
|
+
);
|
|
1026
|
+
|
|
1027
|
+
// The negative that makes the owner read worth having: a real, described gate whose definition
|
|
1028
|
+
// survives only as heredoc text. A text reader still finds `check_peer_facts() {` there; bash
|
|
1029
|
+
// defines nothing, so the name leaves the inventory and its description becomes an orphan.
|
|
1030
|
+
const ownerAt = RUN_SH.indexOf("\ncheck_peer_facts() {\n");
|
|
1031
|
+
const ownerEnd = RUN_SH.indexOf("\n}\n", ownerAt) + "\n}".length;
|
|
1032
|
+
if (ownerAt < 0 || ownerEnd <= ownerAt) throw new Error("S9: run.sh no longer defines check_peer_facts");
|
|
1033
|
+
const textOnlyOwner = bashDefinedGates(
|
|
1034
|
+
`${RUN_SH.slice(0, ownerAt)}\n: <<'TEXT_ONLY'${RUN_SH.slice(ownerAt, ownerEnd)}\nTEXT_ONLY${RUN_SH.slice(ownerEnd)}`,
|
|
1035
|
+
);
|
|
1036
|
+
ok(
|
|
1037
|
+
"[QK:CLI-HELP-REAL-OWNER] S9j a described gate whose definition is left only as heredoc text is not a command: it leaves the inventory and its description is an orphan",
|
|
1038
|
+
defined.names.has("check-peer-facts") &&
|
|
1039
|
+
textOnlyOwner.problems.length === 0 &&
|
|
1040
|
+
!textOnlyOwner.names.has("check-peer-facts") &&
|
|
1041
|
+
orphansOf(new Set([...labels, ...textOnlyOwner.names])).includes("check-peer-facts"),
|
|
1042
|
+
JSON.stringify({ problems: textOnlyOwner.problems, stillDefined: textOnlyOwner.names.has("check-peer-facts") }),
|
|
1043
|
+
);
|
|
1044
|
+
|
|
1045
|
+
// The commands that had no line in the ce92dc7 usage, computed from that commit's run.sh with
|
|
1046
|
+
// the reader above. The set only shrinks: a new command arrives with its description, and a
|
|
1047
|
+
// described debt name leaves this list in the same change.
|
|
1048
|
+
const DESCRIPTION_DEBT = new Set([
|
|
1049
|
+
"check-acp-backend-preflight",
|
|
1050
|
+
"check-acp-carrier-augment",
|
|
1051
|
+
"check-acp-config",
|
|
1052
|
+
"check-acp-cortex",
|
|
1053
|
+
"check-acp-event-mapper",
|
|
1054
|
+
"check-acp-foreground-only",
|
|
1055
|
+
"check-acp-launch-namespace",
|
|
1056
|
+
"check-acp-overlay",
|
|
1057
|
+
"check-acp-prompt-builder",
|
|
1058
|
+
"check-acp-prompt-lifecycle",
|
|
1059
|
+
"check-acp-provider-surface",
|
|
1060
|
+
"check-acp-sdk-surface",
|
|
1061
|
+
"check-acp-session-reuse",
|
|
1062
|
+
"check-acp-session-store",
|
|
1063
|
+
"check-acp-set-model-readback",
|
|
1064
|
+
"check-acp-stop-reason",
|
|
1065
|
+
"check-acp-stream-hooks",
|
|
1066
|
+
"check-acp-tool-surface",
|
|
1067
|
+
"check-acp-usage-accounting",
|
|
1068
|
+
"check-acp-vendor-rejection",
|
|
1069
|
+
"check-agent-guidance-beside",
|
|
1070
|
+
"check-auth-boundary",
|
|
1071
|
+
"check-cc-retire-pidfd",
|
|
1072
|
+
"check-cc-retire-request",
|
|
1073
|
+
"check-claude-floor-coherence",
|
|
1074
|
+
"check-control-retire",
|
|
1075
|
+
"check-control-send-receipt",
|
|
1076
|
+
"check-control-socket-path",
|
|
1077
|
+
"check-entwurf-control-rpc",
|
|
1078
|
+
"check-entwurf-resume-args",
|
|
1079
|
+
"check-entwurf-v2-native-push",
|
|
1080
|
+
"check-entwurf-v2-production",
|
|
1081
|
+
"check-entwurf-v2-retire",
|
|
1082
|
+
"check-entwurf-v2-runner",
|
|
1083
|
+
"check-entwurf-v2-send",
|
|
1084
|
+
"check-entwurf-v2-surface",
|
|
1085
|
+
"check-entwurf-v2-visible-resume",
|
|
1086
|
+
"check-env-namespace",
|
|
1087
|
+
"check-fresh-call-dispatch",
|
|
1088
|
+
"check-fresh-call-provider",
|
|
1089
|
+
"check-herdr-fresh-call",
|
|
1090
|
+
"check-herdr-sandbox",
|
|
1091
|
+
"check-herdr-supply",
|
|
1092
|
+
"check-install-preflight",
|
|
1093
|
+
"check-install-surface",
|
|
1094
|
+
"check-mux-fresh-call",
|
|
1095
|
+
"check-mux-launch",
|
|
1096
|
+
"check-mux-launch-tmux",
|
|
1097
|
+
"check-mux-placement",
|
|
1098
|
+
"check-mux-placement-tmux",
|
|
1099
|
+
"check-omp-receive-arm",
|
|
1100
|
+
"check-pack-win32-carrier",
|
|
1101
|
+
"check-pi-import-surface",
|
|
1102
|
+
"check-pi-launch",
|
|
1103
|
+
"check-pi-preflight",
|
|
1104
|
+
"check-pi-queue-oracle",
|
|
1105
|
+
"check-pi-retire-request",
|
|
1106
|
+
"check-pi-runtime-version",
|
|
1107
|
+
"check-probe-bridge-command",
|
|
1108
|
+
"check-retirement-event",
|
|
1109
|
+
"check-tests-beside-behavior",
|
|
1110
|
+
"check-win32-preview",
|
|
1111
|
+
"doctor-pi-provider",
|
|
1112
|
+
"expose-dev-bin",
|
|
1113
|
+
"remove-dev-bin",
|
|
1114
|
+
"smoke-acp-bundled-mcp-live",
|
|
1115
|
+
"smoke-acp-carrier-augment-live",
|
|
1116
|
+
"smoke-acp-cortex-live",
|
|
1117
|
+
"smoke-acp-long-turn-live",
|
|
1118
|
+
"smoke-acp-mcp-live",
|
|
1119
|
+
"smoke-acp-memory-containment-live",
|
|
1120
|
+
"smoke-acp-ordering-probe-live",
|
|
1121
|
+
"smoke-acp-overlay-live",
|
|
1122
|
+
"smoke-acp-provider-live",
|
|
1123
|
+
"smoke-acp-raw-turn-live",
|
|
1124
|
+
"smoke-acp-rgg-live",
|
|
1125
|
+
"smoke-acp-session-reuse-live",
|
|
1126
|
+
"smoke-acp-skill-live",
|
|
1127
|
+
"smoke-acp-socket-citizen-live",
|
|
1128
|
+
"smoke-acp-v2-send-live",
|
|
1129
|
+
"smoke-herdr-fresh-call-live",
|
|
1130
|
+
"smoke-herdr-plugin-build-live",
|
|
1131
|
+
"smoke-herdr-raw-install-live",
|
|
1132
|
+
"smoke-mux-fresh-call-live",
|
|
1133
|
+
"smoke-omp-receive-state",
|
|
1134
|
+
"smoke-pi-provider-state",
|
|
1135
|
+
"wire-agy-bridge",
|
|
1136
|
+
"wire-agy-hooks",
|
|
1137
|
+
"wire-agy-statusline",
|
|
1138
|
+
]);
|
|
1139
|
+
const undescribed = [...inventory].filter((n) => !described.includes(n));
|
|
1140
|
+
const newDebt = undescribed.filter((n) => !DESCRIPTION_DEBT.has(n));
|
|
1141
|
+
const paid = [...DESCRIPTION_DEBT].filter((n) => !undescribed.includes(n));
|
|
1142
|
+
ok(
|
|
1143
|
+
`[QK:CLI-HELP-DEBT-EXACT] S9k the commands with no description are exactly the ${DESCRIPTION_DEBT.size} the ce92dc7 usage already lacked`,
|
|
1144
|
+
newDebt.length === 0 && paid.length === 0,
|
|
1145
|
+
[
|
|
1146
|
+
newDebt.length ? `no description in ${CLI_JSON}: ${newDebt.join(", ")}` : "",
|
|
1147
|
+
paid.length ? `described or removed — drop from DESCRIPTION_DEBT: ${paid.join(", ")}` : "",
|
|
1148
|
+
]
|
|
1149
|
+
.filter(Boolean)
|
|
1150
|
+
.join("\n"),
|
|
1151
|
+
);
|
|
1152
|
+
|
|
1153
|
+
// The root page names only real, described commands. Read from the reviewed literal itself — not
|
|
1154
|
+
// from the data — every `entwurf <name>` it shows (rows and examples; `help` is the help verb) must
|
|
1155
|
+
// be a described entry, in the inventory, and outside the debt. Then the guard that keeps the data
|
|
1156
|
+
// honest: one example in an otherwise valid source names an undescribed command, and the source
|
|
1157
|
+
// fails by name at that example — an example prints verbatim, so without the guard it would be
|
|
1158
|
+
// advertised, not crash.
|
|
1159
|
+
const rootNamed = [
|
|
1160
|
+
...new Set([...expectedCompact.matchAll(/^ {2}entwurf ([a-z][a-z0-9-]*)/gm)].map((m) => m[1] as string)),
|
|
1161
|
+
].filter((n) => n !== "help");
|
|
1162
|
+
const unreal = rootNamed.filter((n) => !described.includes(n) || !inventory.has(n) || DESCRIPTION_DEBT.has(n));
|
|
1163
|
+
const BAD_NAME = "zz-undescribed";
|
|
1164
|
+
const badExampleDir = mkdtempSync(path.join(os.tmpdir(), "entwurf-root-names-"));
|
|
1165
|
+
let badExample: ReturnType<typeof runHelp>;
|
|
1166
|
+
try {
|
|
1167
|
+
const data = JSON.parse(cliBytes) as { root: { examples: { items: { command: string }[] } } };
|
|
1168
|
+
data.root.examples.items[1]!.command = `entwurf ${BAD_NAME}`;
|
|
1169
|
+
writeFileSync(path.join(badExampleDir, "run.sh"), RUN_SH);
|
|
1170
|
+
stageHelp(badExampleDir, JSON.stringify(data));
|
|
1171
|
+
badExample = runHelp(path.join(badExampleDir, "run.sh"), ["--help"], badExampleDir);
|
|
1172
|
+
} finally {
|
|
1173
|
+
rmSync(badExampleDir, { recursive: true, force: true });
|
|
1174
|
+
}
|
|
1175
|
+
ok(
|
|
1176
|
+
`[QK:CLI-HELP-ROOT-NAMES-REAL] S9k' every command the root page names is described, dispatchable and outside the debt (${rootNamed.length} names), and an example naming an undescribed command fails the source by name at that example`,
|
|
1177
|
+
rootNamed.length > 0 &&
|
|
1178
|
+
unreal.length === 0 &&
|
|
1179
|
+
!inventory.has(BAD_NAME) &&
|
|
1180
|
+
badExample.status === 1 &&
|
|
1181
|
+
badExample.stdout === "" &&
|
|
1182
|
+
badExample.stderr ===
|
|
1183
|
+
`entwurf: ${CLI_JSON} root.examples.items[1].command: ${JSON.stringify(BAD_NAME)} is not a command the catalog describes\n`,
|
|
1184
|
+
JSON.stringify({
|
|
1185
|
+
unreal,
|
|
1186
|
+
rc: badExample.status,
|
|
1187
|
+
stdout: badExample.stdout.slice(0, 120),
|
|
1188
|
+
stderr: badExample.stderr,
|
|
1189
|
+
}),
|
|
1190
|
+
);
|
|
1191
|
+
// ── the help preflight, proven on a copy that cannot dispatch ─────────────────────────
|
|
1192
|
+
// A byte copy of run.sh with one line added right before the unchanged case: whatever gets past
|
|
1193
|
+
// the preflight stops there (stderr DISPATCHED, exit 97) instead of entering an arm, so every
|
|
1194
|
+
// command can be asked safely. The bare root forms and the Unknown path go through that case and
|
|
1195
|
+
// are S9a's, on the real file. Expected pages are built from the entry the data holds; what is
|
|
1196
|
+
// compared is the output of the whole run.sh path — routing and format, not content, which the
|
|
1197
|
+
// catalog and fresh-cut fixtures bind.
|
|
1198
|
+
const VENDORS = ["pi", "pi-durable", "claude", "copilot", "codex-app-server"];
|
|
1199
|
+
type Entry = { synopsis: string; summary: string; details?: string[] };
|
|
1200
|
+
const entryOf = new Map<string, Entry>();
|
|
1201
|
+
for (const g of (JSON.parse(cliBytes) as { usage: { commandGroups: Entry[][] } }).usage.commandGroups)
|
|
1202
|
+
for (const e of g) entryOf.set(e.synopsis.split(" ", 1)[0] as string, e);
|
|
1203
|
+
const page = (e: Entry): string =>
|
|
1204
|
+
`${[`Usage: entwurf ${e.synopsis}`, "", e.summary, ...(e.details ? ["", ...e.details] : [])].join("\n")}\n`;
|
|
1205
|
+
const gated = mkdtempSync(path.join(os.tmpdir(), "entwurf-help-preflight-"));
|
|
1206
|
+
try {
|
|
1207
|
+
const copy = path.join(gated, "run.sh");
|
|
1208
|
+
writeFileSync(copy, RUN_SH.replace(`\n${CASE_LINE}`, `\necho DISPATCHED >&2; exit 97\n${CASE_LINE}`));
|
|
1209
|
+
stageHelp(gated);
|
|
1210
|
+
const run = (argv: string[], env: Record<string, string> = helpEnv) =>
|
|
1211
|
+
spawnSync("bash", [copy, ...argv], { cwd: gated, encoding: "utf8", env });
|
|
1212
|
+
type R = ReturnType<typeof run>;
|
|
1213
|
+
const dispatched = (r: R): boolean => r.status === 97 && r.stderr === "DISPATCHED\n" && r.stdout === "";
|
|
1214
|
+
const refused = (r: R): boolean =>
|
|
1215
|
+
r.status === 2 && r.stdout === "" && /^entwurf: [^\n]*\n$/.test(r.stderr) && !r.stderr.includes("DISPATCHED");
|
|
1216
|
+
const answers =
|
|
1217
|
+
(name: string) =>
|
|
1218
|
+
(r: R): boolean => {
|
|
1219
|
+
const e = entryOf.get(name);
|
|
1220
|
+
return e !== undefined && r.status === 0 && r.stderr === "" && r.stdout === page(e);
|
|
1221
|
+
};
|
|
1222
|
+
const show = (argv: string[], r: R): string =>
|
|
1223
|
+
`${JSON.stringify(argv)}: rc=${r.status} stdout=${r.stdout.length}B stderr=${JSON.stringify(r.stderr.slice(0, 160))}`;
|
|
1224
|
+
|
|
1225
|
+
// Axis 1 — every command run.sh has, asked `--help`, one run each, in order.
|
|
1226
|
+
const t0 = Date.now();
|
|
1227
|
+
const bad = { described: [] as string[], undescribed: [] as string[], vendor: [] as string[] };
|
|
1228
|
+
const count = { described: 0, undescribed: 0, vendor: 0 };
|
|
1229
|
+
for (const name of [...inventory].sort()) {
|
|
1230
|
+
const argv = [name, "--help"];
|
|
1231
|
+
const r = run(argv);
|
|
1232
|
+
if (VENDORS.includes(name)) {
|
|
1233
|
+
count.vendor++;
|
|
1234
|
+
if (!dispatched(r)) bad.vendor.push(show(argv, r));
|
|
1235
|
+
} else if (entryOf.has(name)) {
|
|
1236
|
+
count.described++;
|
|
1237
|
+
if (!answers(name)(r)) bad.described.push(show(argv, r));
|
|
1238
|
+
} else {
|
|
1239
|
+
count.undescribed++;
|
|
1240
|
+
if (!(refused(r) && r.stderr.includes(JSON.stringify(name)))) bad.undescribed.push(show(argv, r));
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
const axisSeconds = ((Date.now() - t0) / 1000).toFixed(1);
|
|
1244
|
+
ok(
|
|
1245
|
+
`[QK:CLI-HELP-COMMAND] S9l every described command answers \`<command> --help\` with its own page on stdout, exit 0, nothing on stderr, and no arm entered (${count.described} commands, axis ${axisSeconds}s)`,
|
|
1246
|
+
count.described > 0 && bad.described.length === 0,
|
|
1247
|
+
bad.described.join("\n"),
|
|
1248
|
+
);
|
|
1249
|
+
ok(
|
|
1250
|
+
`[QK:CLI-HELP-NO-DISPATCH] S9m every undescribed command asked \`--help\` is refused (one stderr line naming it, exit 2) and never run (${count.undescribed} commands)`,
|
|
1251
|
+
count.undescribed > 0 && bad.undescribed.length === 0,
|
|
1252
|
+
bad.undescribed.join("\n"),
|
|
1253
|
+
);
|
|
1254
|
+
|
|
1255
|
+
// Axis 2 — the other shapes, on a few representative names.
|
|
1256
|
+
const debtGate = [...DESCRIPTION_DEBT].find((n) => defined.names.has(n) && !VENDORS.includes(n)) as string;
|
|
1257
|
+
const shapes = (cells: [string[], (r: R) => boolean][]): string[] =>
|
|
1258
|
+
cells.flatMap(([argv, judge]) => {
|
|
1259
|
+
const r = run(argv);
|
|
1260
|
+
return judge(r) ? [] : [show(argv, r)];
|
|
1261
|
+
});
|
|
1262
|
+
const formsBad = shapes([
|
|
1263
|
+
[["peer-facts", "-h"], answers("peer-facts")],
|
|
1264
|
+
[["help", "peer-facts"], answers("peer-facts")],
|
|
1265
|
+
[["help", "meta-bridge-fresh-cut"], answers("meta-bridge-fresh-cut")],
|
|
1266
|
+
[["help", "copilot"], answers("copilot")],
|
|
1267
|
+
[["help", debtGate], refused],
|
|
1268
|
+
[["help", "pi"], answers("pi")],
|
|
1269
|
+
[["help", "claude"], answers("claude")],
|
|
1270
|
+
[["help", "no-such-command"], refused],
|
|
1271
|
+
[["no-such-command", "--help"], refused],
|
|
1272
|
+
]);
|
|
1273
|
+
ok(
|
|
1274
|
+
"[QK:CLI-HELP-FORMS] S9n `help <command>` and `<command> -h` answer like `--help`; a vendor launcher's own entry answers `help`; an undescribed or unknown name is refused",
|
|
1275
|
+
formsBad.length === 0,
|
|
1276
|
+
formsBad.join("\n"),
|
|
1277
|
+
);
|
|
1278
|
+
// #157 — a guide is not a command. Its name is no case label, gate function or described entry;
|
|
1279
|
+
// asked the way one asks about something to run (`<topic> --help` / `-h`) it is refused like any
|
|
1280
|
+
// undescribed name, one stderr line naming it, exit 2, nothing run; `help <topic>` still answers.
|
|
1281
|
+
const named = (name: string) => (r: R) => refused(r) && r.stderr.includes(JSON.stringify(name));
|
|
1282
|
+
const notCommandBad = [
|
|
1283
|
+
...TOPIC_FIXTURES.filter((t) => inventory.has(t.name) || entryOf.has(t.name)).map(
|
|
1284
|
+
(t) => `${t.name} is also a command`,
|
|
1285
|
+
),
|
|
1286
|
+
...shapes([
|
|
1287
|
+
[["team", "--help"], named("team")],
|
|
1288
|
+
[["team", "-h"], named("team")],
|
|
1289
|
+
[["remote-pair", "--help"], named("remote-pair")],
|
|
1290
|
+
[["help", "team"], (r) => r.status === 0 && r.stderr === "" && r.stdout === topicPage.get("team")],
|
|
1291
|
+
]),
|
|
1292
|
+
];
|
|
1293
|
+
ok(
|
|
1294
|
+
"[QK:CLI-HELP-TOPIC-NOT-COMMAND] S9n' a guide topic is not a command: its name is no dispatch label, gate or described entry, and `<topic> --help` / `-h` is refused like an undescribed name while `help <topic>` answers",
|
|
1295
|
+
notCommandBad.length === 0,
|
|
1296
|
+
notCommandBad.join("\n"),
|
|
1297
|
+
);
|
|
1298
|
+
const extraBad = shapes([
|
|
1299
|
+
[["peer-facts", "--help", "x"], refused],
|
|
1300
|
+
[["peer-facts", "-h", "x"], refused],
|
|
1301
|
+
[["help", "peer-facts", "x"], refused],
|
|
1302
|
+
[["help", "copilot", "x"], refused],
|
|
1303
|
+
[["help", "pi", "x"], refused],
|
|
1304
|
+
[["help", "all", "x"], refused],
|
|
1305
|
+
[["help", "team", "x"], refused],
|
|
1306
|
+
[[debtGate, "--help", "x"], refused],
|
|
1307
|
+
]);
|
|
1308
|
+
ok(
|
|
1309
|
+
"[QK:CLI-HELP-EXTRA-REFUSED] S9o a help form with more argv after it is refused, exit 2, and runs nothing",
|
|
1310
|
+
extraBad.length === 0,
|
|
1311
|
+
extraBad.join("\n"),
|
|
1312
|
+
);
|
|
1313
|
+
const aliasBad = shapes([
|
|
1314
|
+
[["--help", "peer-facts"], refused],
|
|
1315
|
+
[["-h", "peer-facts"], refused],
|
|
1316
|
+
]);
|
|
1317
|
+
ok(
|
|
1318
|
+
"[QK:CLI-HELP-ALIAS-REFUSED] S9p `--help <command>` / `-h <command>` are refused, not read as `help <command>`",
|
|
1319
|
+
aliasBad.length === 0,
|
|
1320
|
+
aliasBad.join("\n"),
|
|
1321
|
+
);
|
|
1322
|
+
const vendorBad = [
|
|
1323
|
+
...bad.vendor,
|
|
1324
|
+
...shapes([
|
|
1325
|
+
[["copilot", "--help", "x"], dispatched],
|
|
1326
|
+
[["pi", "-h", "x"], dispatched],
|
|
1327
|
+
]),
|
|
1328
|
+
];
|
|
1329
|
+
ok(
|
|
1330
|
+
`[QK:CLI-HELP-VENDOR-PASSTHROUGH] S9q the five vendor launchers keep every argv, help flags included: each reaches its arm (${count.vendor} of ${VENDORS.length})`,
|
|
1331
|
+
count.vendor === VENDORS.length && vendorBad.length === 0,
|
|
1332
|
+
vendorBad.join("\n"),
|
|
1333
|
+
);
|
|
1334
|
+
const positionBad = shapes([
|
|
1335
|
+
[["peer-facts", "x", "--help"], dispatched],
|
|
1336
|
+
[["peer-facts", "--", "--help"], dispatched],
|
|
1337
|
+
]);
|
|
1338
|
+
ok(
|
|
1339
|
+
"[QK:CLI-HELP-NOT-HELP-POSITION] S9r a help flag anywhere but right after the command, or after `--`, is not a help request: it reaches the dispatch as before",
|
|
1340
|
+
positionBad.length === 0,
|
|
1341
|
+
positionBad.join("\n"),
|
|
1342
|
+
);
|
|
1343
|
+
const odd = "bad\nname\u0007";
|
|
1344
|
+
const oddRun = run(["help", odd]);
|
|
1345
|
+
ok(
|
|
1346
|
+
"[QK:CLI-HELP-ONE-LINE] S9s an unknown name with a newline and a control character is quoted onto one stderr line",
|
|
1347
|
+
refused(oddRun) && oddRun.stderr.includes(JSON.stringify(odd)),
|
|
1348
|
+
show(["help", odd], oddRun),
|
|
1349
|
+
);
|
|
1350
|
+
|
|
1351
|
+
// What a command help starts, among the watched PATH tools: node on the renderer, asked for that
|
|
1352
|
+
// one command, and nothing else. #157 amendment: the `help` form asks with `--name`, the mode
|
|
1353
|
+
// that may also answer with a guide topic; the `--help` form keeps `--command`, which may not.
|
|
1354
|
+
const { bin, trace } = makeShims(gated, `exec '${process.execPath}' "$@"`);
|
|
1355
|
+
for (const argv of [
|
|
1356
|
+
["peer-facts", "--help"],
|
|
1357
|
+
["help", "peer-facts"],
|
|
1358
|
+
])
|
|
1359
|
+
run(argv, { PATH: `${bin}:${helpEnv.PATH}` });
|
|
1360
|
+
const traced = readFileSync(trace, "utf8");
|
|
1361
|
+
ok(
|
|
1362
|
+
"[QK:CLI-HELP-COMMAND-TRACE] S9t observed on peer-facts in both forms: of the watched PATH tools, only node on scripts/cli-help.mjs starts — `--command peer-facts` for `--help`, `--name peer-facts` for `help`",
|
|
1363
|
+
traced ===
|
|
1364
|
+
`node ${path.join(gated, CLI_RENDERER)} --command peer-facts\nnode ${path.join(gated, CLI_RENDERER)} --name peer-facts\n`,
|
|
1365
|
+
`trace:\n${traced}`,
|
|
1366
|
+
);
|
|
1367
|
+
} finally {
|
|
1368
|
+
rmSync(gated, { recursive: true, force: true });
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// fresh-cut's longer text: kept whole as the ce92dc7 source literal of its usage() (the template
|
|
1372
|
+
// slots filled with the constants it named) — read from the source, not captured from a run. The
|
|
1373
|
+
// details carry it with one explicit amendment: the two lines that named the old `node
|
|
1374
|
+
// --experimental-strip-types` invocation and the blank line after them, which the synopsis
|
|
1375
|
+
// `entwurf meta-bridge-fresh-cut` replaces. Nothing else is trimmed. The exit numbers are checked
|
|
1376
|
+
// against the constant the verb itself returns, here in the gate only.
|
|
1377
|
+
const FC_FIXTURE = "test/fixtures/cli-help/fresh-cut-usage-ce92dc7.source-literal.txt";
|
|
1378
|
+
const FC_SHA256 = "f31c766a64fc99705ce81cc413bc4eadc40edd8e031127551d92fa68a8ac35a2";
|
|
1379
|
+
const fcText = readFileSync(path.join(REPO, FC_FIXTURE), "utf8");
|
|
1380
|
+
if (sha256(fcText) !== FC_SHA256) throw new Error(`S9: ${FC_FIXTURE} is not the ce92dc7 source literal`);
|
|
1381
|
+
const fcLines = fcText.slice(0, -1).split("\n"); // the file's final LF is framing, not a line
|
|
1382
|
+
const invocationDropped =
|
|
1383
|
+
fcLines[0]?.startsWith("usage: node --experimental-strip-types ") === true &&
|
|
1384
|
+
fcLines[1]?.trimStart().startsWith("(dev clone:") === true &&
|
|
1385
|
+
fcLines[2] === "";
|
|
1386
|
+
const fcDetails = entryOf.get("meta-bridge-fresh-cut")?.details ?? [];
|
|
1387
|
+
const exitMarks: [keyof typeof FRESH_CUT_EXIT, string][] = [
|
|
1388
|
+
["COMPLETE", "cut complete:"],
|
|
1389
|
+
["NO_MOVE", "NOTHING MOVED:"],
|
|
1390
|
+
["USAGE", "usage."],
|
|
1391
|
+
["HALF_CUT", "CUT TRANSITION INCOMPLETE:"],
|
|
1392
|
+
["CLEANUP_INCOMPLETE", "CUT COMPLETE, CLEANUP INCOMPLETE:"],
|
|
1393
|
+
];
|
|
1394
|
+
const exitsAgree = exitMarks.every(
|
|
1395
|
+
([k, mark]) => fcDetails.filter((l) => l.startsWith(` ${FRESH_CUT_EXIT[k]} ${mark}`)).length === 1,
|
|
1396
|
+
);
|
|
1397
|
+
ok(
|
|
1398
|
+
"[QK:CLI-HELP-DETAILS-ORACLE] S9u fresh-cut's details are its ce92dc7 usage text minus only the old invocation lines, and every exit number is the one FRESH_CUT_EXIT returns",
|
|
1399
|
+
invocationDropped && JSON.stringify(fcDetails) === JSON.stringify(fcLines.slice(3)) && exitsAgree,
|
|
1400
|
+
`details=${fcDetails.length} lines, oracle=${fcLines.length - 3} lines, exits agree=${exitsAgree}`,
|
|
1401
|
+
);
|
|
1402
|
+
|
|
1403
|
+
// A reader that stops early (`entwurf --help | head`) closes the pipe. Adopted as a UX amendment:
|
|
1404
|
+
// that ends the page quietly with exit 0 — a stated choice, not a claim about what the old `cat`
|
|
1405
|
+
// returned. pipefail is on and both sides' statuses are read back, so the producer's 0 is seen,
|
|
1406
|
+
// not hidden behind head's. Both pages are read: the short root page can fit the pipe buffer
|
|
1407
|
+
// before the reader closes, so the long catalog (`help all`) is where an early close really bites.
|
|
1408
|
+
const runSh = path.join(REPO, "run.sh");
|
|
1409
|
+
const piped = spawnSync(
|
|
1410
|
+
"bash",
|
|
1411
|
+
[
|
|
1412
|
+
"-c",
|
|
1413
|
+
`set -o pipefail; bash '${runSh}' --help | head -c 1 >/dev/null; root="\${PIPESTATUS[0]} \${PIPESTATUS[1]}"; ` +
|
|
1414
|
+
`bash '${runSh}' help all | head -c 1 >/dev/null; all="\${PIPESTATUS[0]} \${PIPESTATUS[1]}"; ` +
|
|
1415
|
+
`bash '${runSh}' help team | head -c 1 >/dev/null; echo "$root $all \${PIPESTATUS[0]} \${PIPESTATUS[1]}"`,
|
|
1416
|
+
],
|
|
1417
|
+
{ cwd: REPO, encoding: "utf8", env: helpEnv },
|
|
1418
|
+
);
|
|
1419
|
+
ok(
|
|
1420
|
+
"[QK:CLI-HELP-EPIPE-QUIET] S9v `entwurf --help | head`, `entwurf help all | head` and `entwurf help team | head` end without an error on stderr",
|
|
1421
|
+
piped.stderr === "",
|
|
1422
|
+
`stderr=${piped.stderr.slice(0, 200)}`,
|
|
1423
|
+
);
|
|
1424
|
+
ok(
|
|
1425
|
+
"[QK:CLI-HELP-EPIPE-EXIT] S9v' under pipefail, the help producer and head both end 0 when the reader stops early, on the root page, the catalog and a guide topic",
|
|
1426
|
+
piped.status === 0 && piped.stdout === "0 0 0 0 0 0\n",
|
|
1427
|
+
`rc=${piped.status} statuses=${JSON.stringify(piped.stdout)}`,
|
|
1428
|
+
);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
201
1431
|
// ─── S3 — no npm bin can reach a raw .ts when installed ──────────────────────────────
|
|
202
1432
|
{
|
|
203
1433
|
// Two ways a bin dies under node_modules: it IS a .ts, or it is a wrapper that execs one
|