@canonical/pragma-cli 0.36.0 → 0.37.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.
Files changed (107) hide show
  1. package/dist/package.json +22 -19
  2. package/dist/pragma.conf.js +35 -5
  3. package/dist/pragma.conf.js.map +1 -1
  4. package/dist/src/capabilities/capabilities/hints.js +1 -1
  5. package/dist/src/capabilities/capabilities/hints.js.map +1 -1
  6. package/dist/src/capabilities/create/create.verb.js +2 -1
  7. package/dist/src/capabilities/create/create.verb.js.map +1 -1
  8. package/dist/src/capabilities/create/createSurface.generated.js +23 -1
  9. package/dist/src/capabilities/create/createSurface.generated.js.map +1 -1
  10. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js +11 -6
  11. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js.map +1 -1
  12. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js +38 -0
  13. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js.map +1 -0
  14. package/dist/src/capabilities/setup/operations/setupCompletions.js +6 -2
  15. package/dist/src/capabilities/setup/operations/setupCompletions.js.map +1 -1
  16. package/dist/src/capabilities/setup/operations/setupConfig.js +4 -2
  17. package/dist/src/capabilities/setup/operations/setupConfig.js.map +1 -1
  18. package/dist/src/capabilities/setup/operations/setupGenerator.js +64 -13
  19. package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -1
  20. package/dist/src/capabilities/setup/operations/setupLsp.js +59 -13
  21. package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -1
  22. package/dist/src/capabilities/setup/operations/setupMcp.js +4 -1
  23. package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -1
  24. package/dist/src/capabilities/setup/operations/setupSkills.js +4 -2
  25. package/dist/src/capabilities/setup/operations/setupSkills.js.map +1 -1
  26. package/dist/src/capabilities/setup/setup.verb.js +79 -30
  27. package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
  28. package/dist/src/capabilities/setup/targets.js +12 -6
  29. package/dist/src/capabilities/setup/targets.js.map +1 -1
  30. package/dist/src/capabilities/shared/index.js +1 -1
  31. package/dist/src/capabilities/shared/index.js.map +1 -1
  32. package/dist/src/constants.js +7 -1
  33. package/dist/src/constants.js.map +1 -1
  34. package/dist/src/kernel/packs/sparql/buildLookupQuery.js +11 -7
  35. package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -1
  36. package/dist/src/kernel/project/cli/dispatch.js +23 -1
  37. package/dist/src/kernel/project/cli/dispatch.js.map +1 -1
  38. package/dist/src/kernel/project/cli/rootHelp.js +8 -5
  39. package/dist/src/kernel/project/cli/rootHelp.js.map +1 -1
  40. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js +2 -3
  41. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js.map +1 -1
  42. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js +1 -1
  43. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js.map +1 -1
  44. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js +4 -0
  45. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js.map +1 -0
  46. package/dist/src/kernel/runtime/graphpack/embedded.js +13 -6
  47. package/dist/src/kernel/runtime/graphpack/embedded.js.map +1 -1
  48. package/dist/src/kernel/runtime/graphpack/read.js +14 -9
  49. package/dist/src/kernel/runtime/graphpack/read.js.map +1 -1
  50. package/dist/src/kernel/runtime/loadSession.js +3 -1
  51. package/dist/src/kernel/runtime/loadSession.js.map +1 -1
  52. package/dist/src/kernel/runtime/probe.js +1 -1
  53. package/dist/src/kernel/runtime/probe.js.map +1 -1
  54. package/dist/src/kernel/runtime/refs/parseRef.js +17 -1
  55. package/dist/src/kernel/runtime/refs/parseRef.js.map +1 -1
  56. package/dist/src/kernel/runtime/refs/resolve.js +9 -3
  57. package/dist/src/kernel/runtime/refs/resolve.js.map +1 -1
  58. package/package.json +22 -19
  59. package/pragma.conf.ts +36 -5
  60. package/src/capabilities/capabilities/hints.ts +1 -1
  61. package/src/capabilities/config/collectConfigShow.test.ts +1 -0
  62. package/src/capabilities/create/byteEquality.test.ts +28 -1
  63. package/src/capabilities/create/create.test.ts +1 -0
  64. package/src/capabilities/create/create.verb.ts +2 -1
  65. package/src/capabilities/create/createGrammar.test.ts +4 -1
  66. package/src/capabilities/create/createSurface.generated.ts +23 -1
  67. package/src/capabilities/create/crossCli.subprocess.test.ts +10 -3
  68. package/src/capabilities/doctor/checks/checkNodeVersion.test.ts +49 -0
  69. package/src/capabilities/doctor/checks/checkNodeVersion.ts +11 -7
  70. package/src/capabilities/doctor/checks/isSupportedNodeVersion.test.ts +111 -0
  71. package/src/capabilities/doctor/checks/isSupportedNodeVersion.ts +34 -0
  72. package/src/capabilities/doctor/checks/mcpCommand.test.ts +7 -1
  73. package/src/capabilities/lazy.test.ts +26 -2
  74. package/src/capabilities/listLookup.shipped.exec.test.ts +340 -0
  75. package/src/capabilities/resources/resources.test.ts +38 -2
  76. package/src/capabilities/setup/operations/setupCompletions.ts +9 -2
  77. package/src/capabilities/setup/operations/setupConfig.ts +7 -2
  78. package/src/capabilities/setup/operations/setupGenerator.ts +83 -14
  79. package/src/capabilities/setup/operations/setupLsp.ts +95 -22
  80. package/src/capabilities/setup/operations/setupMcp.ts +7 -1
  81. package/src/capabilities/setup/operations/setupSkills.ts +7 -2
  82. package/src/capabilities/setup/setup.test.ts +247 -2
  83. package/src/capabilities/setup/setup.verb.ts +83 -35
  84. package/src/capabilities/setup/targets.ts +18 -8
  85. package/src/capabilities/shared/index.ts +1 -0
  86. package/src/capabilities/surface.test.ts +12 -4
  87. package/src/constants.ts +8 -0
  88. package/src/kernel/config/defaults.test.ts +17 -2
  89. package/src/kernel/config/readConfig.test.ts +1 -1
  90. package/src/kernel/packs/sparql/buildLookupQuery.ts +11 -7
  91. package/src/kernel/project/cli/dispatch.ts +23 -1
  92. package/src/kernel/project/cli/rootHelp.ts +8 -5
  93. package/src/kernel/render/prefixes.test.ts +2 -0
  94. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +2 -3
  95. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -1
  96. package/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.ts +3 -0
  97. package/src/kernel/runtime/graphpack/embedded.ts +14 -6
  98. package/src/kernel/runtime/graphpack/graphpack.test.ts +31 -5
  99. package/src/kernel/runtime/graphpack/read.ts +14 -12
  100. package/src/kernel/runtime/loadSession.ts +3 -1
  101. package/src/kernel/runtime/probe.ts +1 -1
  102. package/src/kernel/runtime/refs/parseRef.test.ts +49 -1
  103. package/src/kernel/runtime/refs/parseRef.ts +25 -1
  104. package/src/kernel/runtime/refs/resolve.ts +9 -3
  105. package/src/kernel/runtime/types.ts +17 -0
  106. package/src/testing/helpers/runCli.test.ts +44 -0
  107. package/src/testing/helpers/runCli.ts +27 -3
@@ -0,0 +1,3 @@
1
+ // AUTO-GENERATED by scripts/bundle.ts — do not edit by hand.
2
+ // Regenerate from the packs pragma.conf.ts declares: `bun run bundle`.
3
+ export const manifestJson = "{\"name\":\"pragma\",\"version\":\"0.36.0\",\"sourceRef\":\"@canonical/design-system@git:ce00818852628857da9c4a67bf8556c7cfda287d, @canonical/anatomy-dsl@npm:0.3.0, @canonical/token-ontology@npm:0.8.1, @canonical/code-standards@git:fcd3ac2dea458775e5b79344b3237a3fcb37add9, @canonical/ds-implementations@self:v0.36.0\",\"contentHash\":\"7ff2bbd5fd059584ad3285c7b1c7d59c734efef8006fb0bba726db03254392e8\",\"prefixes\":{\"rdf\":\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\",\"rdfs\":\"http://www.w3.org/2000/01/rdf-schema#\",\"owl\":\"http://www.w3.org/2002/07/owl#\",\"xsd\":\"http://www.w3.org/2001/XMLSchema#\",\"skos\":\"http://www.w3.org/2004/02/skos/core#\",\"sh\":\"http://www.w3.org/ns/shacl#\",\"dcterms\":\"http://purl.org/dc/terms/\",\"ds\":\"https://ds.canonical.com/\",\"anatomy\":\"http://anatomy-dsl.example.org/ontology#\",\"graphql\":\"https://pragma.canonical.com/graphql#\",\"w3c-tokens\":\"https://dt.canonical.com/w3c-tokens/\",\"dt\":\"https://dt.canonical.com/\",\"cs\":\"http://pragma.canonical.com/codestandards#\",\"dso\":\"https://ds.canonical.com/ontology#\"},\"createdAt\":\"2026-09-01T20:29:05.995Z\",\"tripleCount\":30346,\"entityCount\":2906}";
@@ -18,10 +18,12 @@
18
18
  *
19
19
  * The inlined strings are materialized into the ordinary content-addressed pack
20
20
  * cache on first use and then read back through {@link readPack}, so the
21
- * embedded pack and a built pack share one boot path. The story records live in
22
- * their OWN generated module (like the index): dispatch reads them on every
23
- * command, and pulling them out of `pack.generated.ts` would load its ~1.9 MB
24
- * of n-quads with them — a measured +28 ms on every invocation. Inlining as JS strings
21
+ * embedded pack and a built pack share one boot path. The story records, the
22
+ * index and the MANIFEST each live in their own generated module: every one of
23
+ * them is read on a path that does not need the n-quads, and leaving any of
24
+ * them beside `pack.generated.ts` would load its ~2.1 MB with them — a measured
25
+ * +28 ms on every invocation for the stories, and ~370 ms per CLI invocation
26
+ * for the manifest at a 30,340-triple pack. Inlining as JS strings
25
27
  * (rather than file assets) means the pack travels with the emitted modules —
26
28
  * it needs no asset step in any build, and no file lookup at run time.
27
29
  */
@@ -36,8 +38,8 @@ import {
36
38
  } from "node:fs";
37
39
  import { join } from "node:path";
38
40
  import { packDir, packsCacheDir } from "../paths.js";
39
- import { dataNq, manifestJson, schemaJson } from "./embedded/pack.generated.js";
40
41
  import { indexJson } from "./embedded/pack.index.generated.js";
42
+ import { manifestJson } from "./embedded/pack.manifest.generated.js";
41
43
  import { storiesJson } from "./embedded/pack.stories.generated.js";
42
44
  import { packIsComplete } from "./manifest.js";
43
45
  import { manifestSchema } from "./schemas.js";
@@ -72,10 +74,16 @@ export function embeddedManifest(): Manifest {
72
74
  * @returns The pack directory, ready for {@link readPack}.
73
75
  * @note Impure — writes the inlined pack files into the cache.
74
76
  */
75
- export function materializeEmbeddedPack(): string {
77
+ export async function materializeEmbeddedPack(): Promise<string> {
76
78
  const dir = packDir(embeddedManifest().contentHash);
77
79
  if (packIsComplete(dir)) return dir;
78
80
 
81
+ // Loaded HERE, not at module scope: this is the only branch that needs the
82
+ // payload, and it is the branch that does NOT run once the pack is cached.
83
+ // A static import would parse ~2.1 MB of inlined n-quads on every boot to
84
+ // reach a return statement six lines above.
85
+ const { dataNq, schemaJson } = await import("./embedded/pack.generated.js");
86
+
79
87
  mkdirSync(packsCacheDir(), { recursive: true });
80
88
  const temp = mkdtempSync(join(packsCacheDir(), ".embed-"));
81
89
  try {
@@ -144,15 +144,21 @@ describe("the committed embedded pack (PROTECTED)", () => {
144
144
  // directory holds — which the next build then reuses, silently dropping the
145
145
  // difference. Comparing the two directories catches that on the day it lands.
146
146
  const built = await build([{ path: "a.ttl", content: TTL }]);
147
- expect(readdirSync(materializeEmbeddedPack()).sort()).toEqual(
147
+ expect(readdirSync(await materializeEmbeddedPack()).sort()).toEqual(
148
148
  readdirSync(built.dir).sort(),
149
149
  );
150
- });
151
-
152
- it("is self-consistent: complete, content-addressed, and non-empty", () => {
150
+ // 60s, not the 5s default. This test does real work twice: it BUILDS a
151
+ // pack from source, and it materialises the embedded one — which on a cold
152
+ // cache takes the miss branch and imports the multi-megabyte payload. Both
153
+ // costs grow with the pack, and the pack grows whenever a source is added.
154
+ // A default that a pack size can outgrow turns a real assertion into an
155
+ // intermittent one.
156
+ }, 60_000);
157
+
158
+ it("is self-consistent: complete, content-addressed, and non-empty", async () => {
153
159
  // No network, so CI runs it: the committed strings really do materialize a
154
160
  // bootable pack whose parts agree with each other.
155
- const dir = materializeEmbeddedPack();
161
+ const dir = await materializeEmbeddedPack();
156
162
  const manifest = embeddedManifest();
157
163
  expect(packIsComplete(dir)).toBe(true);
158
164
  expect(basename(dir)).toBe(manifest.contentHash);
@@ -162,6 +168,26 @@ describe("the committed embedded pack (PROTECTED)", () => {
162
168
  expect(index.contentHash).toBe(manifest.contentHash);
163
169
  expect(manifest.tripleCount ?? 0).toBeGreaterThan(0);
164
170
  expect(manifest.entityCount ?? 0).toBeGreaterThan(0);
171
+
172
+ // EXACT, not "> 0", and it earns its keep twice.
173
+ //
174
+ // The manifest and the n-quads now live in SEPARATE generated modules, so
175
+ // a partial regeneration or a one-file `git add` can leave them describing
176
+ // different packs — a skew that could not exist while they shared a
177
+ // module. `read.ts` tolerates a superset deliberately, so it would serve
178
+ // the skew rather than refuse it.
179
+ //
180
+ // It also pins the equivalence `read.ts` depends on but cannot state:
181
+ // `store.size` (quads) against a `tripleCount` that `build.ts` computes
182
+ // with `SELECT (COUNT(*))` over the union graph. They agree on oxigraph
183
+ // 0.5.6; oxigraph is pinned by caret, so this is where a future version
184
+ // that deduplicates the union would be caught.
185
+ const session = await readPack(dir);
186
+ try {
187
+ expect(session.store.size).toBe(manifest.tripleCount);
188
+ } finally {
189
+ session.store.dispose();
190
+ }
165
191
  });
166
192
  });
167
193
 
@@ -80,15 +80,25 @@ export async function readPack(dir: string): Promise<StoreSession> {
80
80
  // "populated index but empty store" check (via the now-empty dump,
81
81
  // `packIsComplete` turns false so recovery rebuilds) rather than serving an
82
82
  // empty graph as the pack.
83
- const actualTriples = await countTriples(store);
83
+ // `store.size` is a field oxigraph maintains. The equivalent query —
84
+ // `SELECT (COUNT(*)) WHERE { ?s ?p ?o }`, which is what `build.ts` still runs
85
+ // to WRITE `tripleCount` — is a scan, and it ran on every boot to read a
86
+ // number the store already knew.
87
+ //
88
+ // The two agree: ke queries with `use_default_graph_as_union`, and oxigraph
89
+ // does not deduplicate across that union, so a quad count and the union's
90
+ // solution count are the same number even for a triple repeated across
91
+ // graphs. Verified against 0.5.6 rather than assumed — and the guard is `<`,
92
+ // which tolerates the direction a set-semantic union would drift.
93
+ const actualQuads = store.size;
84
94
  if (manifest.tripleCount !== undefined) {
85
- if (actualTriples < manifest.tripleCount) {
95
+ if (actualQuads < manifest.tripleCount) {
86
96
  store.dispose();
87
97
  throw packUnavailable(
88
- `The pack at ${dir} has a corrupt data cache (expected at least ${manifest.tripleCount} triples, loaded ${actualTriples}).`,
98
+ `The pack at ${dir} has a corrupt data cache (expected at least ${manifest.tripleCount} triples, loaded ${actualQuads}).`,
89
99
  );
90
100
  }
91
- } else if (index.entities.length > 0 && actualTriples === 0) {
101
+ } else if (index.entities.length > 0 && actualQuads === 0) {
92
102
  store.dispose();
93
103
  throw packUnavailable(`The pack at ${dir} has a corrupt data cache.`);
94
104
  }
@@ -101,11 +111,3 @@ export async function readPack(dir: string): Promise<StoreSession> {
101
111
  index,
102
112
  };
103
113
  }
104
-
105
- /** Count the booted store's triples (a cheap aggregate over the union graph). */
106
- async function countTriples(store: StoreSession["store"]): Promise<number> {
107
- const result = (await store.query(
108
- "SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }" as never,
109
- )) as import("@canonical/ke").SelectResult;
110
- return Number(result.bindings.at(0)?.n ?? 0);
111
- }
@@ -43,6 +43,8 @@ export async function loadStoreSession(
43
43
  }
44
44
 
45
45
  const dir =
46
- decision.kind === "embedded" ? materializeEmbeddedPack() : decision.dir;
46
+ decision.kind === "embedded"
47
+ ? await materializeEmbeddedPack()
48
+ : decision.dir;
47
49
  return readPack(dir);
48
50
  }
@@ -21,7 +21,7 @@ export async function runStoreProbe(): Promise<string> {
21
21
  import("./graphpack/embedded.js"),
22
22
  import("./graphpack/read.js"),
23
23
  ]);
24
- const session = await readPack(materializeEmbeddedPack());
24
+ const session = await readPack(await materializeEmbeddedPack());
25
25
  try {
26
26
  const result = await session.store.query(
27
27
  "SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }" as never,
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { redactUrl } from "./parseRef.js";
2
+ import { parsePackDeclaration, redactUrl } from "./parseRef.js";
3
3
 
4
4
  describe("redactUrl", () => {
5
5
  it("strips user:password userinfo so an inlined token never reaches output", () => {
@@ -32,3 +32,51 @@ describe("redactUrl", () => {
32
32
  );
33
33
  });
34
34
  });
35
+
36
+ describe("parsePackDeclaration — a git source may name a subdirectory", () => {
37
+ const url = "git+https://github.com/org/repo.git";
38
+
39
+ it("reaches a package nested in a monorepo", () => {
40
+ expect(
41
+ parsePackDeclaration({
42
+ name: "@org/thing",
43
+ source: `${url}#main:pkgs/thing`,
44
+ }),
45
+ ).toEqual({
46
+ kind: "git",
47
+ pkg: "@org/thing",
48
+ url: "https://github.com/org/repo.git",
49
+ ref: "main",
50
+ subdir: "pkgs/thing",
51
+ source: `${url}#main:pkgs/thing`,
52
+ });
53
+ });
54
+
55
+ it("leaves a plain ref untouched, with no subdir key", () => {
56
+ const ref = parsePackDeclaration({
57
+ name: "@org/thing",
58
+ source: `${url}#main`,
59
+ });
60
+ expect(ref).toEqual({
61
+ kind: "git",
62
+ pkg: "@org/thing",
63
+ url: "https://github.com/org/repo.git",
64
+ ref: "main",
65
+ source: `${url}#main`,
66
+ });
67
+ expect("subdir" in ref).toBe(false);
68
+ });
69
+
70
+ it("splits on the FIRST colon, so a nested path survives", () => {
71
+ // The ref cannot contain a colon; the subdirectory can contain slashes.
72
+ expect(
73
+ parsePackDeclaration({ name: "@org/t", source: `${url}#v1.2:a/b/c` }),
74
+ ).toMatchObject({ ref: "v1.2", subdir: "a/b/c" });
75
+ });
76
+
77
+ it("rejects an empty subdirectory rather than silently ignoring it", () => {
78
+ expect(() =>
79
+ parsePackDeclaration({ name: "@org/thing", source: `${url}#main:` }),
80
+ ).toThrow(/subdirectory after : is empty/);
81
+ });
82
+ });
@@ -25,6 +25,8 @@ export type PackageRef =
25
25
  readonly pkg: string;
26
26
  readonly url: string;
27
27
  readonly ref: string;
28
+ /** Package root WITHIN the clone, for a package nested in a monorepo. */
29
+ readonly subdir?: string;
28
30
  readonly source: string;
29
31
  };
30
32
 
@@ -72,11 +74,33 @@ export function parsePackDeclaration(entry: PackDeclaration): PackageRef {
72
74
  },
73
75
  );
74
76
  }
77
+ // `#<ref>` or `#<ref>:<subdir>`. A git ref cannot contain a colon
78
+ // (git check-ref-format), so the split is unambiguous.
79
+ const fragment = source.slice(hashIdx + 1);
80
+ const colonIdx = fragment.indexOf(":");
81
+ if (colonIdx !== -1 && colonIdx === fragment.length - 1) {
82
+ throw PragmaError.configError(
83
+ `Invalid source for "${name}": the subdirectory after : is empty.`,
84
+ {
85
+ recovery: {
86
+ message:
87
+ "Example: git+https://github.com/org/repo.git#main:packages/thing",
88
+ },
89
+ },
90
+ );
91
+ }
92
+ const ref = colonIdx === -1 ? fragment : fragment.slice(0, colonIdx);
93
+ if (ref.length === 0) {
94
+ throw PragmaError.configError(
95
+ `Invalid source for "${name}": git URL must include a ref after #.`,
96
+ );
97
+ }
75
98
  return {
76
99
  kind: "git",
77
100
  pkg: name,
78
101
  url: source.slice("git+".length, hashIdx),
79
- ref: source.slice(hashIdx + 1),
102
+ ref,
103
+ ...(colonIdx === -1 ? {} : { subdir: fragment.slice(colonIdx + 1) }),
80
104
  source,
81
105
  };
82
106
  }
@@ -383,13 +383,19 @@ export async function resolvePackage(
383
383
  },
384
384
  );
385
385
  }
386
+ // The clone lives at `dir`; the PACKAGE may be nested within it. A git
387
+ // source names a repository, so a package inside a monorepo is reached
388
+ // by its subdirectory — without which `readTtlSources` would scan a
389
+ // repository root that has no `definitions/` or `data/` and contribute
390
+ // nothing at all.
391
+ const packageRoot = ref.subdir ? join(dir, ref.subdir) : dir;
386
392
  return {
387
393
  name: ref.pkg,
388
394
  source: ref.source,
389
395
  resolved,
390
- root: dir,
391
- sources: readTtlSources(dir, ref.pkg),
392
- stories: readStorySources(dir, ref.pkg),
396
+ root: packageRoot,
397
+ sources: readTtlSources(packageRoot, ref.pkg),
398
+ stories: readStorySources(packageRoot, ref.pkg),
393
399
  };
394
400
  }
395
401
  }
@@ -8,6 +8,7 @@
8
8
  * that are actually populated by the time it runs.
9
9
  */
10
10
 
11
+ import type { UndoOutcome } from "@canonical/task/node";
11
12
  import type { DetailLevel, OutputFormat } from "../../constants.js";
12
13
  import type { ConfigLayers } from "../config/types.js";
13
14
 
@@ -221,4 +222,20 @@ export interface PragmaRuntime {
221
222
  * plan of its own, which is what keeps the raw-effect render the default.
222
223
  */
223
224
  planData?: unknown;
225
+ /**
226
+ * The post-undo projection seam. `--undo` is two-phase: the undo
227
+ * interpreter WALKS the verb's task with every effect mocked to collect the
228
+ * reversals, and only then executes what it collected — so nothing the task
229
+ * body says while it is interpreted describes what the reversals did (the
230
+ * body only ever runs under the mocked walk). What DOES describe them is
231
+ * the interpreter's own per-undo outcomes, correlated by the `undoKey` each
232
+ * reversal was declared with. A verb that reports per-target results sets
233
+ * this (the same way it sets `exec`/`planData`, before returning its Task);
234
+ * the projector's undo branch awaits it with those outcomes AFTER the
235
+ * collected undos executed and before the `Undid N step(s).` line, so the
236
+ * verb projects them onto its own rows through the same path its forward
237
+ * run uses. A throw here is rendered as the run's error (nonzero exit) —
238
+ * the honest outcome when a reversal did not take.
239
+ */
240
+ undoReport?: (outcomes: readonly UndoOutcome[]) => void | Promise<void>;
224
241
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Pins how `runCli` reports a child that does not finish.
3
+ *
4
+ * `spawnSync` reports a timeout the same way it reports a failure to spawn —
5
+ * through `error` — and a caller that does not look will see only
6
+ * `status: null`, which reads downstream as an ordinary non-zero exit or, worse,
7
+ * as a content mismatch. These tests hold the boundary where that distinction
8
+ * is made, because the cost of losing it is paid in diagnosis rather than in a
9
+ * red test.
10
+ */
11
+
12
+ import { describe, expect, it } from "vitest";
13
+ import { runCli } from "./runCli.js";
14
+
15
+ describe("runCli — a child that does not finish", () => {
16
+ it("reports a timeout as a timeout, with the duration", async () => {
17
+ // `--help` on a 1ms budget cannot complete; any real CLI start-up exceeds it.
18
+ await expect(async () =>
19
+ runCli(["--help"], { timeoutMs: 1 }),
20
+ ).rejects.toThrow(/timed out after 1ms/);
21
+ });
22
+
23
+ it("names the signal that killed it, so a hang is distinguishable", async () => {
24
+ await expect(async () =>
25
+ runCli(["--help"], { timeoutMs: 1 }),
26
+ ).rejects.toThrow(/killed by SIG/);
27
+ });
28
+
29
+ it("does not describe a timeout as a failure to spawn", async () => {
30
+ // The previous message said "failed to spawn", which sent a reader looking
31
+ // for a missing binary or a bad path instead of a slow or hung command.
32
+ await expect(async () =>
33
+ runCli(["--help"], { timeoutMs: 1 }),
34
+ ).rejects.not.toThrow(/failed to spawn/);
35
+ });
36
+
37
+ it("attaches whatever the child managed to write", async () => {
38
+ // Empty streams are still reported explicitly: "(empty)" is evidence, an
39
+ // absent section is an omission the reader has to interpret.
40
+ await expect(async () =>
41
+ runCli(["--help"], { timeoutMs: 1 }),
42
+ ).rejects.toThrow(/stdout:[\s\S]*stderr:/);
43
+ });
44
+ });
@@ -46,10 +46,17 @@ export interface RunCliOptions {
46
46
  * test the shipped-entry boundary itself.
47
47
  */
48
48
  readonly mode?: "shipped" | "source";
49
- /** Spawn timeout in milliseconds (default 20000). */
49
+ /** Spawn timeout in milliseconds (default {@link DEFAULT_TIMEOUT_MS}). */
50
50
  readonly timeoutMs?: number;
51
51
  }
52
52
 
53
+ /**
54
+ * How long a spawned CLI may run before it is killed. Twenty seconds is far
55
+ * past any assertion these tests make; a run that reaches it has hung, not
56
+ * been slow.
57
+ */
58
+ const DEFAULT_TIMEOUT_MS = 20_000;
59
+
53
60
  /** The captured outcome of a `runCli` invocation. */
54
61
  export interface RunCliResult {
55
62
  readonly stdout: string;
@@ -114,12 +121,29 @@ export function runCli(
114
121
  cwd: options.cwd,
115
122
  env: { ...env, ...options.env },
116
123
  encoding: "utf-8",
117
- timeout: options.timeoutMs ?? 20_000,
124
+ timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
118
125
  });
119
126
 
120
127
  if (result.error) {
128
+ // A timeout is not a spawn failure, and saying so costs real diagnosis
129
+ // time: `spawnSync` reports both through `error`, but a timed-out child
130
+ // has already produced output worth seeing, and the duration is the whole
131
+ // finding. Separated here so the message names what happened, and carries
132
+ // the evidence rather than making the reader go and get it.
133
+ const timedOutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
134
+ const invocation = `${command} ${spawnArgs.join(" ")}`;
135
+ const captured = [
136
+ result.stdout ? `stdout:\n${result.stdout}` : "stdout: (empty)",
137
+ result.stderr ? `stderr:\n${result.stderr}` : "stderr: (empty)",
138
+ ].join("\n");
139
+
140
+ if ((result.error as NodeJS.ErrnoException).code === "ETIMEDOUT") {
141
+ throw new Error(
142
+ `runCli: ${invocation} timed out after ${timedOutMs}ms (killed by ${result.signal ?? "signal"}).\n${captured}`,
143
+ );
144
+ }
121
145
  throw new Error(
122
- `runCli: failed to spawn ${command} ${spawnArgs.join(" ")} — ${result.error.message}`,
146
+ `runCli: failed to spawn ${invocation} — ${result.error.message}.\n${captured}`,
123
147
  );
124
148
  }
125
149