@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,49 @@
1
+ /**
2
+ * Covers the two things `checkNodeVersion` owns beyond the range comparison:
3
+ * the shape of the CheckResult, and that the failure path quotes the declared
4
+ * range rather than a number of its own. The range logic itself is pinned in
5
+ * `isSupportedNodeVersion.test.ts`.
6
+ */
7
+
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+ import { SUPPORTED_NODE_RANGE } from "../../../constants.js";
10
+ import { checkNodeVersion } from "./checkNodeVersion.js";
11
+
12
+ /** Swap `process.versions.node`, which is read-only in the normal way. */
13
+ function withNodeVersion(version: string) {
14
+ vi.spyOn(process, "versions", "get").mockReturnValue({
15
+ ...process.versions,
16
+ node: version,
17
+ });
18
+ }
19
+
20
+ afterEach(() => {
21
+ vi.restoreAllMocks();
22
+ });
23
+
24
+ describe("checkNodeVersion — reports against the declared range", () => {
25
+ it("passes on a supported version and reports it", async () => {
26
+ withNodeVersion("24.18.1");
27
+ const result = await checkNodeVersion();
28
+ expect(result).toEqual({
29
+ name: "Node version",
30
+ status: "pass",
31
+ detail: "v24.18.1",
32
+ });
33
+ });
34
+
35
+ it("fails on an unsupported version and quotes the declared range", async () => {
36
+ withNodeVersion("20.19.0");
37
+ const result = await checkNodeVersion();
38
+ expect(result.status).toBe("fail");
39
+ expect(result.detail).toContain("v20.19.0");
40
+ // The range must come from the manifest, never from a literal in the check.
41
+ expect(result.detail).toContain(SUPPORTED_NODE_RANGE);
42
+ expect(result.remedy).toContain(SUPPORTED_NODE_RANGE);
43
+ });
44
+
45
+ it("fails inside the 23.0-23.5 window the range excludes", async () => {
46
+ withNodeVersion("23.2.0");
47
+ expect((await checkNodeVersion()).status).toBe("fail");
48
+ });
49
+ });
@@ -1,25 +1,29 @@
1
+ import { SUPPORTED_NODE_RANGE } from "../../../constants.js";
1
2
  import type { CheckResult } from "../types.js";
2
-
3
- const MIN_NODE_MAJOR = 20;
3
+ import { isSupportedNodeVersion } from "./isSupportedNodeVersion.js";
4
4
 
5
5
  /**
6
- * Check that the Node.js major version is at least {@link MIN_NODE_MAJOR}.
6
+ * Check that the running Node.js version satisfies this package's declared
7
+ * `engines.node` range.
8
+ *
9
+ * The floor is a property of the manifest, not of this file: a check that
10
+ * hardcodes its own number can disagree with the range the package publishes,
11
+ * and a consumer then passes a check the install contract would have failed.
7
12
  *
8
13
  * @returns A CheckResult with the current version and pass/fail status.
9
14
  * @note Impure — reads `process.versions`.
10
15
  */
11
16
  export async function checkNodeVersion(): Promise<CheckResult> {
12
17
  const version = process.versions.node;
13
- const major = Number.parseInt(version.split(".")[0] ?? "0", 10);
14
18
 
15
- if (major >= MIN_NODE_MAJOR) {
19
+ if (isSupportedNodeVersion(version)) {
16
20
  return { name: "Node version", status: "pass", detail: `v${version}` };
17
21
  }
18
22
 
19
23
  return {
20
24
  name: "Node version",
21
25
  status: "fail",
22
- detail: `v${version} (requires >= ${MIN_NODE_MAJOR})`,
23
- remedy: `Install Node.js >= ${MIN_NODE_MAJOR}`,
26
+ detail: `v${version} (requires ${SUPPORTED_NODE_RANGE})`,
27
+ remedy: `Install Node.js ${SUPPORTED_NODE_RANGE}`,
24
28
  };
25
29
  }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Pins the hand-written comparison in `isSupportedNodeVersion` to the range the
3
+ * package actually declares.
4
+ *
5
+ * The implementation encodes `engines.node` as a chain of comparisons rather
6
+ * than parsing it, so nothing structural stops the two drifting apart. This
7
+ * suite closes that by building a SECOND, independent predicate directly from
8
+ * the declared range string and requiring both to agree across a version grid.
9
+ * A change to `engines.node` therefore moves the oracle and fails here until the
10
+ * implementation is moved with it — which a table of hand-written expectations
11
+ * could not do, since its cheapest repair is editing the expectations.
12
+ */
13
+
14
+ import { describe, expect, it } from "vitest";
15
+ import { SUPPORTED_NODE_RANGE } from "../../../constants.js";
16
+ import { isSupportedNodeVersion } from "./isSupportedNodeVersion.js";
17
+
18
+ /**
19
+ * Evaluate a version against a range of the form `>=22.18 <23 || >=23.6`.
20
+ *
21
+ * Deliberately naive and deliberately separate from the implementation: it is
22
+ * an oracle, not a utility, and it throws on any comparator shape it was not
23
+ * written for so a widened range fails loudly instead of silently passing.
24
+ */
25
+ function satisfiesDeclaredRange(version: string, range: string): boolean {
26
+ // Semver excludes prereleases from a range unless a comparator opts in, and
27
+ // none of ours does. Encoded here too, so the oracle and the implementation
28
+ // are held to the same rule rather than agreeing by omission.
29
+ if (version.includes("-")) return false;
30
+
31
+ const rank = (major: number, minor: number) => major * 1_000_000 + minor;
32
+ const [major = Number.NaN, minor = Number.NaN] = version
33
+ .split(".")
34
+ .map(Number);
35
+
36
+ return range.split("||").some((clause) =>
37
+ clause
38
+ .trim()
39
+ .split(/\s+/)
40
+ .every((comparator) => {
41
+ // A comparator may be major-only (`<23`) or major.minor (`>=22.18`).
42
+ const parsed = /^(>=|<)(\d+)(?:\.(\d+))?$/.exec(comparator);
43
+ if (!parsed)
44
+ throw new Error(`oracle cannot parse comparator: ${comparator}`);
45
+ const [, operator, boundMajor, boundMinor] = parsed;
46
+ const actual = rank(major, minor);
47
+ const bound = rank(Number(boundMajor), Number(boundMinor ?? 0));
48
+ return operator === ">=" ? actual >= bound : actual < bound;
49
+ }),
50
+ );
51
+ }
52
+
53
+ const GRID = [
54
+ "20.19.0",
55
+ "21.7.3",
56
+ "22.0.0",
57
+ "22.12.0",
58
+ "22.17.9",
59
+ "22.18.0",
60
+ "22.18.1",
61
+ "22.99.0",
62
+ "23.0.0",
63
+ "23.5.9",
64
+ "23.6.0",
65
+ "23.9.0",
66
+ "24.0.0",
67
+ "24.18.1",
68
+ "25.0.0",
69
+ "26.0.0",
70
+ // Prereleases: numerically inside the range, excluded by semver.
71
+ "22.18.0-pre",
72
+ "23.6.0-nightly",
73
+ "24.0.0-rc.1",
74
+ ];
75
+
76
+ describe("isSupportedNodeVersion — agrees with the declared engines range", () => {
77
+ it.each(GRID)("matches the oracle for %s", (version) => {
78
+ expect(isSupportedNodeVersion(version)).toBe(
79
+ satisfiesDeclaredRange(version, SUPPORTED_NODE_RANGE),
80
+ );
81
+ });
82
+
83
+ // The oracle is only trustworthy if it can disagree; prove it discriminates
84
+ // rather than returning a constant.
85
+ it("uses an oracle that actually discriminates", () => {
86
+ const verdicts = GRID.map((v) =>
87
+ satisfiesDeclaredRange(v, SUPPORTED_NODE_RANGE),
88
+ );
89
+ expect(verdicts).toContain(true);
90
+ expect(verdicts).toContain(false);
91
+ });
92
+
93
+ it("rejects prerelease builds whose numeric part is in range", () => {
94
+ // Asserted directly, not only through the oracle: if both sides dropped the
95
+ // rule together the grid would still agree and prove nothing.
96
+ for (const pre of ["22.18.0-pre", "23.6.0-nightly", "24.0.0-rc.1"]) {
97
+ expect(isSupportedNodeVersion(pre)).toBe(false);
98
+ }
99
+ // The same versions without the suffix are supported, so it is the suffix
100
+ // doing the work and not the numbers.
101
+ for (const stable of ["22.18.0", "23.6.0", "24.0.0"]) {
102
+ expect(isSupportedNodeVersion(stable)).toBe(true);
103
+ }
104
+ });
105
+
106
+ it("fails closed on a version it cannot parse", () => {
107
+ for (const bad of ["not-a-version", "", "v24.1.0", "24"]) {
108
+ expect(isSupportedNodeVersion(bad)).toBe(false);
109
+ }
110
+ });
111
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Whether a Node.js version satisfies the range this package declares in its
3
+ * own `engines.node`, surfaced as `SUPPORTED_NODE_RANGE` in `constants.ts`.
4
+ *
5
+ * The range is expressed here as a comparison rather than parsed: the CLI
6
+ * carries no semver dependency, and one range does not justify a range parser.
7
+ * What keeps the two honest is `isSupportedNodeVersion.test.ts`, which builds an
8
+ * independent predicate straight from the declared range string and requires
9
+ * the two to agree across a version grid — so a change to `engines.node` that
10
+ * is not mirrored here fails the suite rather than shipping.
11
+ *
12
+ * A major-only comparison cannot express this range: it cannot say "22.18 but
13
+ * not 22.0", and it cannot exclude the 23.0–23.5 window.
14
+ *
15
+ * @param version - A Node version string, e.g. `process.versions.node`.
16
+ * @returns Whether the version falls inside the declared range.
17
+ */
18
+ export function isSupportedNodeVersion(version: string): boolean {
19
+ // A semver range excludes prereleases unless a comparator opts into them, and
20
+ // `engines.node` opts into none. So `22.18.0-pre` is outside the declared
21
+ // range even though its numeric part is inside it, and reporting it as
22
+ // supported would be doctor passing a runtime the install contract rejects.
23
+ if (version.includes("-")) return false;
24
+
25
+ const [rawMajor, rawMinor] = version.split(".");
26
+ const major = Number(rawMajor);
27
+ const minor = Number(rawMinor);
28
+
29
+ if (!Number.isInteger(major) || !Number.isInteger(minor)) return false;
30
+ if (major < 22) return false;
31
+ if (major === 22) return minor >= 18;
32
+ if (major === 23) return minor >= 6;
33
+ return true;
34
+ }
@@ -45,6 +45,12 @@ describe("commandOf — one executable, either entry shape", () => {
45
45
  });
46
46
  });
47
47
 
48
+ // Every test in this suite writes into a temp directory and then resolves
49
+ // against it, so each one is bound by filesystem latency rather than by any
50
+ // work of its own. Vitest's 5s default is comfortable on an idle machine and
51
+ // marginal on a loaded one — this suite has timed out in CI while asserting
52
+ // nothing slow. Ten seconds keeps the assertions meaningful without making a
53
+ // genuine hang cheap to ignore.
48
54
  describe("commandResolves — the host's own resolution rules", () => {
49
55
  const dirs: string[] = [];
50
56
  const tmp = (): string => {
@@ -97,4 +103,4 @@ describe("commandResolves — the host's own resolution rules", () => {
97
103
  await commandResolves("./local-bin", dir, hostOf("linux", dir)),
98
104
  ).toBe(true);
99
105
  });
100
- });
106
+ }, 10_000);
@@ -88,8 +88,9 @@ describe("lazy dispatch — module-graph probe (PROTECTED)", () => {
88
88
 
89
89
  it("the dispatch path never reaches the embedded n-quads module (PROTECTED)", () => {
90
90
  // `activeStories` reads the pack's carried stories from their OWN generated
91
- // module rather than through `graphpack/embedded.ts`, which statically
92
- // imports the ~1.9 MB `pack.generated.ts`. Restoring that edge costs a
91
+ // module rather than through `graphpack/embedded.ts`, which reaches the
92
+ // ~2.1 MB `pack.generated.ts` (dynamically since the manifest split, but
93
+ // this path must not reach it at all). Restoring that edge costs a
93
94
  // measured +23 ms on EVERY dispatched command, and the whole test suite
94
95
  // passes with it restored — the split is prose in three docblocks and
95
96
  // nothing else. This is the constraint those docblocks describe.
@@ -104,6 +105,29 @@ describe("lazy dispatch — module-graph probe (PROTECTED)", () => {
104
105
  }
105
106
  });
106
107
 
108
+ it("`embedded.ts` itself never statically imports the payload (PROTECTED)", () => {
109
+ // The store-boot path DOES go through `graphpack/embedded.ts`, so the test
110
+ // above cannot cover it. `materializeEmbeddedPack` reads the manifest to
111
+ // name the cache directory and then returns early when the pack is already
112
+ // there — the common case — so a static import of `pack.generated.ts` would
113
+ // parse ~2.1 MB of n-quads to reach that return, on every boot.
114
+ //
115
+ // Restoring `import { dataNq } from "./embedded/pack.generated.js"` at
116
+ // module scope costs a measured ~370 ms per CLI invocation on a
117
+ // 30,340-triple pack, and every other test still passes. This is the only
118
+ // thing standing between that edit and a silent regression.
119
+ for (const entry of [
120
+ "../kernel/runtime/graphpack/embedded.ts",
121
+ "../kernel/runtime/loadSession.ts",
122
+ ]) {
123
+ const graph = staticImportGraph(resolve(here, entry));
124
+ expect(has(graph, "embedded/pack.manifest.generated.ts"), entry).toBe(
125
+ true,
126
+ );
127
+ expect(has(graph, "embedded/pack.generated.ts"), entry).toBe(false);
128
+ }
129
+ });
130
+
107
131
  it("the help path (buildProgram) imports no zod schema module", () => {
108
132
  const graph = staticImportGraph(
109
133
  resolve(here, "../kernel/project/cli/buildProgram.ts"),
@@ -0,0 +1,340 @@
1
+ /**
2
+ * The list→lookup grammar, EXECUTED against the shipped pack, for every noun
3
+ * that has both halves (PROTECTED).
4
+ *
5
+ * The tool descriptions instruct an agent to take a row's `name` VERBATIM from
6
+ * `<noun>_list` to `<noun>_lookup`. That promise broke for `standard` when the
7
+ * shipped data and the story keyed the two halves on different properties:
8
+ * `standard list` published names synthesized from the IRI while
9
+ * `standard lookup` answered only the ~13% of standards carrying an asserted
10
+ * `cs:name` — a closed loop where every published address missed (#1047). The
11
+ * fixture suites could not see it, because a fixture obligingly asserts
12
+ * whatever the story reads; only the corpus the release actually ships can
13
+ * falsify the promise the release actually makes.
14
+ *
15
+ * So this file boots the SHIPPED graph and asserts the round trip over the
16
+ * WHOLE corpus, not a sample, for every noun with a list+lookup pair — the
17
+ * defect class is not standard-specific, since every pair shares this
18
+ * machinery. Three guarantees per noun:
19
+ *
20
+ * - every name `list` publishes resolves through `lookup` (executed through
21
+ * the real verb bodies, so glob/IRI argument-shape dispatch is exercised
22
+ * with the very strings an agent would paste);
23
+ * - every published name is in the addressable population (`listEntityNames`
24
+ * — the ONE pool that feeds miss-suggestions, glob expansion AND `sample`'s
25
+ * draw), so a suggestion can never offer a name the resolve cannot answer;
26
+ * - for a story that derives names (`nameFallback: "iri"`), that pool covers
27
+ * the WHOLE class population — `sample` is presented as "see real data
28
+ * shapes", and drawing only from the subset carrying an asserted name would
29
+ * be the #1047 defect wearing sample's clothes.
30
+ *
31
+ * The final describe is the negative control: the same assertions run against
32
+ * a fixture compiled in the exact pre-#1047 shape (a list that synthesizes
33
+ * names over a lookup that requires the asserted property) and are shown to
34
+ * FAIL. A guard whose failing case no longer fails is worse than no guard.
35
+ */
36
+
37
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
38
+ import { compileStoryModule } from "../kernel/packs/compile.js";
39
+ import { listEntityNames } from "../kernel/packs/resolveEntity.js";
40
+ import {
41
+ distributionSource,
42
+ type PackDefinition,
43
+ } from "../kernel/packs/types.js";
44
+ import { verbKey } from "../kernel/packs/uniqueness.js";
45
+ import { bootRuntime } from "../kernel/runtime/boot.js";
46
+ import type { PragmaRuntime } from "../kernel/runtime/types.js";
47
+ import type { CapabilityModule, VerbSpec } from "../kernel/spec/types.js";
48
+ import { buildFixtureRuntime } from "../testing/helpers/packRuntime.js";
49
+ import { TEST_FLAGS } from "../testing/helpers/projectCli.js";
50
+ import { declaredStories, storyModules } from "./distribution.js";
51
+
52
+ /** The provenance label the distribution's own compiled stories carry. */
53
+ const SOURCE = distributionSource("pragma.conf.ts");
54
+
55
+ /**
56
+ * Every noun declaring BOTH halves of the grammar, derived from the config
57
+ * rather than listed — a story added tomorrow is covered the moment it is
58
+ * declared, by the author who declares it and without their help.
59
+ */
60
+ const NOUNS: readonly string[] = [...declaredStories]
61
+ .filter(([, story]) => story.list !== undefined && story.lookup !== undefined)
62
+ .map(([noun]) => noun);
63
+
64
+ /**
65
+ * Nouns whose shipped corpus is empty TODAY (`ds:Token` has no instances in
66
+ * the current packs) — a data-content gap this suite does not own.
67
+ *
68
+ * The allowlist EXPIRES BY CONSTRUCTION: an entry here is asserted to have
69
+ * ZERO rows, so the moment upstream ships instances the entry goes red and
70
+ * must be deleted. A list that merely skipped the non-empty assertion would
71
+ * be a permanent blind spot — `token` gains data, nobody removes the entry,
72
+ * a later regression back to zero rows stays vacuously green — which is
73
+ * exactly the hidden-empty case this guard exists to prevent, sitting inside
74
+ * the guard itself.
75
+ */
76
+ const EMPTY_CORPUS_TODAY: readonly string[] = ["token"];
77
+
78
+ /** The verb `<noun> <verb>` from a compiled module, or throw naming the gap. */
79
+ function verbOf(
80
+ module: CapabilityModule,
81
+ noun: string,
82
+ verb: string,
83
+ ): VerbSpec {
84
+ const found = module.verbs.find((v) => verbKey(v.path) === `${noun} ${verb}`);
85
+ if (!found) throw new Error(`no compiled "${noun} ${verb}" verb`);
86
+ return found;
87
+ }
88
+
89
+ /** Run `<noun> list` and return the names it publishes, verbatim, deduped. */
90
+ async function publishedNames(
91
+ rt: PragmaRuntime,
92
+ module: CapabilityModule,
93
+ noun: string,
94
+ ): Promise<string[]> {
95
+ const rows = (await verbOf(module, noun, "list").run({}, rt)) as Record<
96
+ string,
97
+ string
98
+ >[];
99
+ return [
100
+ ...new Set(rows.map((row) => row.name ?? "").filter((name) => name !== "")),
101
+ ];
102
+ }
103
+
104
+ /**
105
+ * Take every published name VERBATIM through the real lookup verb, exactly as
106
+ * the tool description instructs an agent to, and return the per-name errors.
107
+ * One batched call: the resolver treats each name independently, and a total
108
+ * miss throws — surfaced as one synthetic error so the assertion that reads
109
+ * this stays a set comparison rather than a try/catch.
110
+ */
111
+ async function lookupErrors(
112
+ rt: PragmaRuntime,
113
+ module: CapabilityModule,
114
+ noun: string,
115
+ names: readonly string[],
116
+ ): Promise<{ query: string; code: string }[]> {
117
+ if (names.length === 0) return [];
118
+ try {
119
+ const output = (await verbOf(module, noun, "lookup").run(
120
+ { name: [...names] },
121
+ rt,
122
+ )) as { errors: { query: string; code: string }[] };
123
+ return output.errors;
124
+ } catch (error) {
125
+ return [
126
+ {
127
+ query: `(all ${names.length})`,
128
+ code: error instanceof Error ? error.message : String(error),
129
+ },
130
+ ];
131
+ }
132
+ }
133
+
134
+ // ONE store for the file — booting the shipped pack is the expensive part, and
135
+ // this package already contends under parallel runs.
136
+ let rt: PragmaRuntime;
137
+ beforeAll(async () => {
138
+ rt = bootRuntime(TEST_FLAGS);
139
+ await rt.store.get();
140
+ }, 60_000);
141
+ afterAll(async () => {
142
+ (await rt.store.get()).store.dispose();
143
+ });
144
+
145
+ /** One scalar from the shipped graph, queried directly. */
146
+ async function scalar(query: string): Promise<string | undefined> {
147
+ const result = await rt.query.sparql(query);
148
+ if (result.type !== "select") return undefined;
149
+ const row = result.bindings[0];
150
+ return row === undefined ? undefined : Object.values(row)[0];
151
+ }
152
+
153
+ describe("every list-published name resolves through lookup, whole corpus (PROTECTED)", () => {
154
+ it("derives at least one list+lookup noun from the config", () => {
155
+ expect(NOUNS.length).toBeGreaterThan(0);
156
+ // The two nouns this guarantee has already been broken for / leaned on.
157
+ expect(NOUNS).toContain("standard");
158
+ expect(NOUNS).toContain("block");
159
+ });
160
+
161
+ it.each(
162
+ NOUNS.map((noun) => [noun] as const),
163
+ )("%s: every published name round-trips, and its corpus is not silently empty", async (noun) => {
164
+ const module = storyModules.get(noun) as CapabilityModule;
165
+ const names = await publishedNames(rt, module, noun);
166
+ if (EMPTY_CORPUS_TODAY.includes(noun)) {
167
+ // Asserted empty, not skipped: this is what expires the allowlist.
168
+ expect(
169
+ names,
170
+ `${noun} is allowlisted as an empty corpus but published rows — ` +
171
+ "delete it from EMPTY_CORPUS_TODAY so a later regression to zero rows cannot hide behind the entry",
172
+ ).toEqual([]);
173
+ } else {
174
+ expect(
175
+ names.length,
176
+ `${noun} list published no rows at all from the shipped pack`,
177
+ ).toBeGreaterThan(0);
178
+ }
179
+
180
+ const errors = await lookupErrors(rt, module, noun, names);
181
+ expect(
182
+ errors,
183
+ `${noun} list published ${errors.length} name(s) its own lookup cannot resolve — ` +
184
+ "the two-step grammar the tool descriptions document is broken for every agent following it",
185
+ ).toEqual([]);
186
+ }, 120_000);
187
+
188
+ it.each(
189
+ NOUNS.map((noun) => [noun] as const),
190
+ )("%s: every published name is in the pool that feeds suggestions, globs and sample", async (noun) => {
191
+ const story = declaredStories.get(noun) as PackDefinition;
192
+ const lookup = story.lookup;
193
+ if (!lookup) throw new Error(`no "${noun}" lookup`);
194
+ const module = storyModules.get(noun) as CapabilityModule;
195
+
196
+ const names = await publishedNames(rt, module, noun);
197
+ const pool = await listEntityNames(rt, lookup, SOURCE);
198
+ // The resolve matches case-insensitively; hold the pool to the same bar.
199
+ const addressable = new Set(pool.map((name) => name.toLowerCase()));
200
+ const missing = names.filter(
201
+ (name) => !addressable.has(name.toLowerCase()),
202
+ );
203
+ expect(
204
+ missing,
205
+ `${noun}: published but not addressable — a miss-suggestion or glob over this pool can hand out none of these, and sample can never draw them`,
206
+ ).toEqual([]);
207
+ }, 60_000);
208
+
209
+ it.each(
210
+ NOUNS.filter(
211
+ (noun) => declaredStories.get(noun)?.lookup?.nameFallback === "iri",
212
+ ).map((noun) => [noun] as const),
213
+ )("%s: the derived-name pool covers the WHOLE class population (sample is representative)", async (noun) => {
214
+ const story = declaredStories.get(noun) as PackDefinition;
215
+ const lookup = story.lookup;
216
+ if (!lookup?.type && !lookup?.types?.length) {
217
+ throw new Error(`"${noun}" derives names without a class constraint`);
218
+ }
219
+ const types = (lookup.types ?? [lookup.type as string]).join(" ");
220
+ // The pool is DISTINCT NAMES, and a name may be legitimately shared (two
221
+ // shipped standards carry the same upstream rdfs:label; the ranked
222
+ // resolve answers a shared name with every entity it reaches). So the
223
+ // full-population claim is counted the way the pool is built: one name
224
+ // per asserted `by` value, plus one derived name per instance carrying
225
+ // none. A pool smaller than that is the #1047 defect — sample and
226
+ // suggestions drawing from the labelled subset only. (A prose label
227
+ // colliding with a slash-derived name would make this one too strict;
228
+ // that collision would itself be a data defect worth the red bar.)
229
+ const labelled = Number(
230
+ await scalar(
231
+ [
232
+ "SELECT (COUNT(DISTINCT ?name) AS ?n) WHERE {",
233
+ ` VALUES ?class { ${types} }`,
234
+ ` ?uri a ?class ; ${lookup.by} ?name .`,
235
+ "}",
236
+ ].join("\n"),
237
+ ),
238
+ );
239
+ const bare = Number(
240
+ await scalar(
241
+ [
242
+ "SELECT (COUNT(DISTINCT ?uri) AS ?n) WHERE {",
243
+ ` VALUES ?class { ${types} }`,
244
+ " ?uri a ?class .",
245
+ ` FILTER NOT EXISTS { ?uri ${lookup.by} ?any . }`,
246
+ "}",
247
+ ].join("\n"),
248
+ ),
249
+ );
250
+ const pool = await listEntityNames(rt, lookup, SOURCE);
251
+
252
+ expect(labelled + bare).toBeGreaterThan(0);
253
+ expect(
254
+ pool.length,
255
+ `${noun}: ${labelled} asserted name(s) + ${bare} instance(s) with none, but only ` +
256
+ `${pool.length} addressable names — sample's draw pool is an unrepresentative slice of the corpus`,
257
+ ).toBe(labelled + bare);
258
+ }, 60_000);
259
+ });
260
+
261
+ describe("negative control: the pre-#1047 shape fails these assertions", () => {
262
+ // The exact divergence that shipped: a list that COALESCEs an OPTIONAL
263
+ // display name with an IRI-derived one, over a lookup that requires the
264
+ // asserted property (no nameFallback). One fixture standard carries the
265
+ // property; the other is published under a name the lookup cannot reach.
266
+ const PREFIXES = {
267
+ rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
268
+ cs: "http://pragma.canonical.com/codestandards#",
269
+ };
270
+ const TTL = `
271
+ @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
272
+ @prefix owl: <http://www.w3.org/2002/07/owl#> .
273
+ @prefix cs: <http://pragma.canonical.com/codestandards#> .
274
+
275
+ cs:name a owl:DatatypeProperty .
276
+ cs:description a owl:DatatypeProperty .
277
+
278
+ cs:react.component.tsdoc a cs:CodeStandard ;
279
+ cs:description "Published under a derived name; carries no cs:name." .
280
+ cs:code.function.purity a cs:CodeStandard ;
281
+ cs:name "Function purity" ;
282
+ cs:description "Carries an asserted cs:name." .
283
+ `;
284
+ const BROKEN_STORY: PackDefinition = {
285
+ noun: "standard",
286
+ description: "pre-#1047 shape",
287
+ list: {
288
+ query: [
289
+ "SELECT ?uri ?name ?description WHERE {",
290
+ " ?uri a cs:CodeStandard ;",
291
+ " cs:description ?description .",
292
+ " OPTIONAL { ?uri cs:name ?n . }",
293
+ ' BIND(COALESCE(?n, REPLACE(STRAFTER(STR(?uri), "#"), "\\\\.", "/")) AS ?name)',
294
+ "}",
295
+ "ORDER BY ?name",
296
+ ].join("\n"),
297
+ columns: [
298
+ { field: "name", label: "Name" },
299
+ { field: "description", label: "Description" },
300
+ ],
301
+ },
302
+ lookup: {
303
+ source: "sparql",
304
+ by: "cs:name", // required — no nameFallback: the pre-#1047 population
305
+ type: "cs:CodeStandard",
306
+ fields: [
307
+ {
308
+ name: "description",
309
+ property: "cs:description",
310
+ label: "Description",
311
+ },
312
+ ],
313
+ },
314
+ };
315
+
316
+ it("publishes a name its own lookup cannot resolve, and these assertions catch it", async () => {
317
+ const { rt: fixtureRt } = await buildFixtureRuntime({
318
+ ttl: TTL,
319
+ prefixes: PREFIXES,
320
+ });
321
+ const module = compileStoryModule(BROKEN_STORY, SOURCE, PREFIXES);
322
+
323
+ const names = await publishedNames(fixtureRt, module, "standard");
324
+ expect(names).toContain("react/component/tsdoc");
325
+ expect(names).toContain("Function purity");
326
+
327
+ // The round-trip assertion goes RED on the synthesized name…
328
+ const errors = await lookupErrors(fixtureRt, module, "standard", names);
329
+ expect(errors.map((error) => error.query)).toEqual([
330
+ "react/component/tsdoc",
331
+ ]);
332
+
333
+ // …and so does the pool-inclusion assertion: the addressable population
334
+ // is only the labelled subset.
335
+ const lookup = BROKEN_STORY.lookup;
336
+ if (!lookup) throw new Error("fixture story has no lookup");
337
+ const pool = await listEntityNames(fixtureRt, lookup, SOURCE);
338
+ expect(pool).toEqual(["Function purity"]);
339
+ }, 60_000);
340
+ });