@canonical/pragma-cli 0.30.0 → 0.31.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 (254) hide show
  1. package/README.md +64 -35
  2. package/dist/pragma +0 -0
  3. package/package.json +15 -13
  4. package/src/completions/constants.ts +7 -0
  5. package/src/completions/handleQuery.test.ts +32 -0
  6. package/src/completions/handleQuery.ts +20 -1
  7. package/src/completions/queryCompletions.test.ts +5 -0
  8. package/src/completions/queryCompletions.ts +17 -3
  9. package/src/completions/querySocket.test.ts +31 -0
  10. package/src/completions/querySocket.ts +36 -9
  11. package/src/config/parseConfigValues.ts +2 -0
  12. package/src/config/readConfigLayers.test.ts +2 -0
  13. package/src/config/readConfigLayers.ts +3 -0
  14. package/src/config/types.ts +7 -0
  15. package/src/constants.ts +3 -1
  16. package/src/domains/block/commands/sample.ts +2 -1
  17. package/src/domains/block/formatters/list.test.ts +3 -3
  18. package/src/domains/block/formatters/lookup.test.ts +5 -5
  19. package/src/domains/block/orchestration/enrichBlocks.ts +6 -3
  20. package/src/domains/block/stories.ts +9 -3
  21. package/src/domains/config/commands/channel.test.ts +16 -0
  22. package/src/domains/config/commands/channel.ts +14 -1
  23. package/src/domains/config/commands/framework.test.ts +14 -0
  24. package/src/domains/config/commands/framework.ts +13 -1
  25. package/src/domains/config/commands/tier.test.ts +7 -0
  26. package/src/domains/config/commands/tier.ts +14 -1
  27. package/src/domains/config/operations/validateTier.test.ts +9 -0
  28. package/src/domains/config/operations/validateTier.ts +10 -2
  29. package/src/domains/create/generatorPack/bundled.ts +49 -0
  30. package/src/domains/create/generatorPack/compileGeneratorPack.test.ts +159 -0
  31. package/src/domains/create/generatorPack/compileGeneratorPack.ts +312 -0
  32. package/src/domains/create/index.ts +13 -7
  33. package/src/domains/create/mcp/specs.ts +6 -161
  34. package/src/domains/create/renderGeneratorUi.test.ts +103 -0
  35. package/src/domains/create/renderGeneratorUi.ts +65 -0
  36. package/src/domains/doctor/formatters/doctor.test.ts +50 -2
  37. package/src/domains/doctor/formatters/doctor.ts +90 -38
  38. package/src/domains/doctor/operations/checks/checkMcpCommands.ts +9 -6
  39. package/src/domains/doctor/operations/checks/checkPackageRefs.ts +30 -12
  40. package/src/domains/doctor/operations/checks.test.ts +6 -4
  41. package/src/domains/doctor/operations/types.ts +23 -1
  42. package/src/domains/graph/commands/inspect.test.ts +2 -2
  43. package/src/domains/graph/commands/query.test.ts +5 -3
  44. package/src/domains/graph/formatters/inspect.ts +7 -7
  45. package/src/domains/graph/formatters/query.test.ts +87 -0
  46. package/src/domains/graph/formatters/query.ts +42 -5
  47. package/src/domains/graph/operations/inspectUri.test.ts +5 -5
  48. package/src/domains/graphql/commands/serve.test.ts +12 -6
  49. package/src/domains/graphql/commands/serve.ts +16 -6
  50. package/src/domains/graphql/helpers/parsePrefixes.test.ts +3 -3
  51. package/src/domains/graphql/helpers/parsePrefixes.ts +3 -3
  52. package/src/domains/graphql/helpers/resolveSourceFiles.test.ts +9 -0
  53. package/src/domains/graphql/helpers/resolveSourceFiles.ts +24 -3
  54. package/src/domains/graphql/operations/compileSchema.test.ts +2 -2
  55. package/src/domains/graphql/operations/createSchemaServer.test.ts +2 -2
  56. package/src/domains/graphql/operations/ensureSchemaArtifact.test.ts +167 -0
  57. package/src/domains/graphql/operations/ensureSchemaArtifact.ts +178 -0
  58. package/src/domains/graphql/operations/index.ts +5 -0
  59. package/src/domains/info/formatters/upgrade.test.ts +7 -4
  60. package/src/domains/info/formatters/upgrade.ts +3 -1
  61. package/src/domains/info/operations/collectEntityCounts.test.ts +156 -0
  62. package/src/domains/info/operations/collectEntityCounts.ts +123 -0
  63. package/src/domains/info/operations/index.ts +1 -0
  64. package/src/domains/info/types.ts +13 -0
  65. package/src/domains/llm/capabilities.test.ts +23 -3
  66. package/src/domains/llm/commands/capabilities.ts +12 -3
  67. package/src/domains/llm/commands/llm.ts +5 -1
  68. package/src/domains/llm/data/commandReference.ts +20 -11
  69. package/src/domains/llm/data/decisionTrees.ts +10 -5
  70. package/src/domains/llm/data/toolCatalog.ts +49 -18
  71. package/src/domains/llm/formatters/orientation.ts +5 -1
  72. package/src/domains/llm/llmCommand.test.ts +11 -3
  73. package/src/domains/llm/mcp/specs.ts +3 -4
  74. package/src/domains/llm/operations/collectContext.test.ts +64 -0
  75. package/src/domains/llm/operations/collectContext.ts +7 -17
  76. package/src/domains/modifier/commands/sample.ts +2 -1
  77. package/src/domains/modifier/operations/lookup.ts +5 -3
  78. package/src/domains/modifier/stories.ts +9 -0
  79. package/src/domains/ontology/commands/list.test.ts +5 -3
  80. package/src/domains/ontology/commands/show.test.ts +85 -9
  81. package/src/domains/ontology/formatters/index.ts +1 -0
  82. package/src/domains/ontology/formatters/list.ts +37 -12
  83. package/src/domains/ontology/formatters/show.ts +358 -67
  84. package/src/domains/ontology/helpers/buildClassTree.ts +89 -0
  85. package/src/domains/ontology/helpers/completeClassNames.ts +43 -0
  86. package/src/domains/ontology/helpers/expandOntologyIris.ts +79 -0
  87. package/src/domains/ontology/helpers/index.ts +14 -0
  88. package/src/domains/ontology/helpers/queryClasses.ts +32 -16
  89. package/src/domains/ontology/helpers/queryProperties.ts +39 -6
  90. package/src/domains/ontology/helpers/queryTboxFacts.ts +160 -0
  91. package/src/domains/ontology/operations/listOntologies.test.ts +80 -3
  92. package/src/domains/ontology/operations/listOntologies.ts +126 -30
  93. package/src/domains/ontology/operations/showOntology.test.ts +131 -12
  94. package/src/domains/ontology/operations/showOntology.ts +289 -7
  95. package/src/domains/ontology/operations/showOntologyRaw.test.ts +4 -2
  96. package/src/domains/ontology/stories.ts +53 -8
  97. package/src/domains/refs/operations/updateRefs.ts +0 -1
  98. package/src/domains/setup/commands/index.ts +1 -0
  99. package/src/domains/setup/commands/setup.ts +70 -0
  100. package/src/domains/setup/index.ts +7 -3
  101. package/src/domains/setup/operations/setupLsp.test.ts +3 -2
  102. package/src/domains/setup/operations/setupLsp.ts +7 -2
  103. package/src/domains/shared/bootStore.bareCore.test.ts +63 -0
  104. package/src/domains/shared/bootStore.test.ts +82 -0
  105. package/src/domains/shared/bootStore.ts +56 -20
  106. package/src/domains/shared/emptyErrorRecovery.test.ts +23 -26
  107. package/src/domains/shared/extractLocalName.test.ts +3 -3
  108. package/src/domains/shared/formatters.ts +23 -2
  109. package/src/domains/shared/listTiers.ts +40 -0
  110. package/src/domains/shared/loaders/bundledLoader.ts +48 -0
  111. package/src/domains/shared/loaders/gitLoader.ts +1 -2
  112. package/src/domains/shared/loaders/localLoader.ts +2 -18
  113. package/src/domains/shared/loaders/readPackageDir.test.ts +58 -0
  114. package/src/domains/shared/loaders/readPackageDir.ts +46 -8
  115. package/src/domains/shared/lookupMany.test.ts +15 -0
  116. package/src/domains/shared/lookupMany.ts +22 -6
  117. package/src/domains/shared/namespaces.ts +13 -5
  118. package/src/domains/shared/parseSampleCount.test.ts +47 -0
  119. package/src/domains/shared/parseSampleCount.ts +45 -0
  120. package/src/domains/shared/prefixes.test.ts +211 -0
  121. package/src/domains/shared/prefixes.ts +182 -15
  122. package/src/domains/shared/renderers.ts +5 -5
  123. package/src/domains/shared/resolveLookupExitCode.test.ts +48 -0
  124. package/src/domains/shared/resolveLookupExitCode.ts +42 -0
  125. package/src/domains/shared/semanticPackage.test.ts +89 -0
  126. package/src/domains/shared/semanticPackage.ts +17 -2
  127. package/src/domains/shared/stories/compileLookupCommand.ts +3 -1
  128. package/src/domains/shared/stories/compileLookupTool.ts +1 -1
  129. package/src/domains/shared/stories/pack/applyPackFilters.test.ts +49 -0
  130. package/src/domains/shared/stories/pack/applyPackFilters.ts +46 -11
  131. package/src/domains/shared/stories/pack/applyPackSearch.test.ts +57 -0
  132. package/src/domains/shared/stories/pack/applyPackSearch.ts +41 -0
  133. package/src/domains/shared/stories/pack/buildLookupQuery.test.ts +30 -0
  134. package/src/domains/shared/stories/pack/buildLookupQuery.ts +75 -1
  135. package/src/domains/shared/stories/pack/bundled/index.ts +23 -0
  136. package/src/domains/shared/stories/pack/bundled/standardPack.ts +164 -0
  137. package/src/domains/shared/stories/pack/bundled/tierPack.ts +50 -0
  138. package/src/domains/shared/stories/pack/collectPackStories.test.ts +305 -7
  139. package/src/domains/shared/stories/pack/collectPackStories.ts +90 -8
  140. package/src/domains/shared/stories/pack/compilePackCommands.ts +17 -7
  141. package/src/domains/shared/stories/pack/compilePackStories.test.ts +607 -2
  142. package/src/domains/shared/stories/pack/compilePackStories.ts +544 -73
  143. package/src/domains/shared/stories/pack/compilePackToolSpecs.ts +20 -8
  144. package/src/domains/shared/stories/pack/index.ts +14 -0
  145. package/src/domains/shared/stories/pack/reservedVerbs.test.ts +213 -0
  146. package/src/domains/shared/stories/pack/reservedVerbs.ts +170 -0
  147. package/src/domains/shared/stories/pack/standardParity.test.ts +293 -220
  148. package/src/domains/shared/stories/pack/types.ts +123 -3
  149. package/src/domains/shared/stories/pack/validateStoryPackDefinition.test.ts +502 -0
  150. package/src/domains/shared/stories/pack/validateStoryPackDefinition.ts +475 -24
  151. package/src/domains/shared/stories/types.ts +9 -1
  152. package/src/domains/shared/types/index.ts +5 -9
  153. package/src/domains/shared/types/ontology.ts +119 -24
  154. package/src/domains/shared/types/standard.ts +9 -52
  155. package/src/domains/token/commands/lookup.test.ts +8 -0
  156. package/src/domains/token/commands/lookup.ts +12 -0
  157. package/src/domains/token/commands/sample.ts +2 -1
  158. package/src/domains/token/featureFlag.ts +17 -0
  159. package/src/domains/token/index.ts +5 -0
  160. package/src/domains/token/mcp/specs.ts +13 -1
  161. package/src/domains/trace/commands/resolveTraceLimit.test.ts +31 -0
  162. package/src/domains/trace/commands/show.ts +30 -4
  163. package/src/index.ts +1 -13
  164. package/src/mcp/registerResources.test.ts +2 -2
  165. package/src/mcp/resources/pickPrimaryType.test.ts +5 -5
  166. package/src/mcp/tools/index.ts +18 -10
  167. package/src/mcp/tools/registerTools.test.ts +106 -30
  168. package/src/pipeline/collectCommands.test.ts +151 -1
  169. package/src/pipeline/collectCommands.ts +34 -19
  170. package/src/pipeline/createProgram.test.ts +5 -1
  171. package/src/pipeline/createProgram.ts +2 -2
  172. package/src/pipeline/firstRun.test.ts +80 -0
  173. package/src/pipeline/firstRun.ts +99 -0
  174. package/src/pipeline/parseGlobalFlags.test.ts +102 -1
  175. package/src/pipeline/parseGlobalFlags.ts +98 -9
  176. package/src/pipeline/resolveCommandKind.test.ts +12 -0
  177. package/src/pipeline/resolveCommandKind.ts +4 -0
  178. package/src/pipeline/rootHelp.test.ts +87 -0
  179. package/src/pipeline/rootHelp.ts +265 -0
  180. package/src/pipeline/runCli.orchestration.test.ts +28 -12
  181. package/src/pipeline/runCli.test.ts +17 -4
  182. package/src/pipeline/runCli.ts +162 -19
  183. package/src/testing/cli.ts +30 -2
  184. package/src/testing/dsFixtures.ts +21 -4
  185. package/src/testing/index.ts +1 -4
  186. package/src/testing/integration/__snapshots__/executor-golden.test.ts.snap +4 -4
  187. package/src/testing/integration/cli-user-stories.test.ts +63 -0
  188. package/src/testing/integration/cross-binary-byte-equality.test.ts +2 -2
  189. package/src/testing/integration/edge-cases.test.ts +62 -50
  190. package/src/testing/integration/executor-execution.test.ts +6 -4
  191. package/src/testing/integration/executor-golden.test.ts +2 -2
  192. package/src/testing/integration/mcp-surface.test.ts +40 -15
  193. package/src/testing/integration/parity.test.ts +119 -70
  194. package/src/testing/setupXdgIsolation.ts +16 -5
  195. package/src/testing/standardParityFixtures.ts +26 -83
  196. package/src/testing/store.ts +4 -4
  197. package/src/domains/create/commands/component.test.ts +0 -166
  198. package/src/domains/create/commands/component.ts +0 -123
  199. package/src/domains/create/commands/index.ts +0 -3
  200. package/src/domains/create/commands/package.test.ts +0 -35
  201. package/src/domains/create/commands/package.ts +0 -21
  202. package/src/domains/create/generators.ts +0 -8
  203. package/src/domains/standard/commands/categories.test.ts +0 -70
  204. package/src/domains/standard/commands/categories.ts +0 -48
  205. package/src/domains/standard/commands/index.ts +0 -6
  206. package/src/domains/standard/commands/list.test.ts +0 -105
  207. package/src/domains/standard/commands/list.ts +0 -11
  208. package/src/domains/standard/commands/lookup.test.ts +0 -135
  209. package/src/domains/standard/commands/lookup.ts +0 -14
  210. package/src/domains/standard/commands/sample.ts +0 -57
  211. package/src/domains/standard/formatters/categories.test.ts +0 -44
  212. package/src/domains/standard/formatters/categories.ts +0 -38
  213. package/src/domains/standard/formatters/index.ts +0 -10
  214. package/src/domains/standard/formatters/list.test.ts +0 -169
  215. package/src/domains/standard/formatters/list.ts +0 -184
  216. package/src/domains/standard/formatters/lookup.test.ts +0 -95
  217. package/src/domains/standard/formatters/lookup.ts +0 -101
  218. package/src/domains/standard/formatters/sample.ts +0 -15
  219. package/src/domains/standard/formatters/types.ts +0 -31
  220. package/src/domains/standard/index.ts +0 -40
  221. package/src/domains/standard/mcp/index.ts +0 -2
  222. package/src/domains/standard/mcp/specs.ts +0 -89
  223. package/src/domains/standard/operations/categories.test.ts +0 -61
  224. package/src/domains/standard/operations/categories.ts +0 -38
  225. package/src/domains/standard/operations/index.ts +0 -6
  226. package/src/domains/standard/operations/list.test.ts +0 -75
  227. package/src/domains/standard/operations/list.ts +0 -64
  228. package/src/domains/standard/operations/lookup.test.ts +0 -58
  229. package/src/domains/standard/operations/lookup.ts +0 -151
  230. package/src/domains/standard/operations/sample.ts +0 -35
  231. package/src/domains/standard/orchestration/buildStandardFilters.ts +0 -11
  232. package/src/domains/standard/orchestration/index.ts +0 -4
  233. package/src/domains/standard/orchestration/resolveStandardList.ts +0 -45
  234. package/src/domains/standard/orchestration/resolveStandardLookup.ts +0 -33
  235. package/src/domains/standard/orchestration/standardEmptyError.test.ts +0 -33
  236. package/src/domains/standard/orchestration/standardEmptyError.ts +0 -29
  237. package/src/domains/standard/standardConfig.ts +0 -42
  238. package/src/domains/standard/stories.test.ts +0 -24
  239. package/src/domains/standard/stories.ts +0 -151
  240. package/src/domains/standard/types.ts +0 -15
  241. package/src/domains/tier/commands/index.ts +0 -3
  242. package/src/domains/tier/commands/list.test.ts +0 -67
  243. package/src/domains/tier/commands/list.ts +0 -9
  244. package/src/domains/tier/formatters/index.ts +0 -3
  245. package/src/domains/tier/formatters/list.test.ts +0 -33
  246. package/src/domains/tier/formatters/list.ts +0 -45
  247. package/src/domains/tier/index.ts +0 -17
  248. package/src/domains/tier/mcp/index.ts +0 -2
  249. package/src/domains/tier/mcp/specs.ts +0 -12
  250. package/src/domains/tier/operations/index.ts +0 -3
  251. package/src/domains/tier/operations/list.test.ts +0 -32
  252. package/src/domains/tier/operations/list.ts +0 -33
  253. package/src/domains/tier/stories.test.ts +0 -22
  254. package/src/domains/tier/stories.ts +0 -52
@@ -28,30 +28,14 @@ export default function createLocalLoader(): PackageLoader {
28
28
  // file:// refs → direct path check first
29
29
  if (ref.kind === "file") {
30
30
  if (!existsSync(ref.path)) return undefined;
31
- const { version, graphs, skills, stories } = readPackageDir(ref.path);
32
- return {
33
- name: ref.pkg,
34
- version,
35
- source: "local",
36
- graphs,
37
- skills,
38
- stories,
39
- };
31
+ return { name: ref.pkg, source: "local", ...readPackageDir(ref.path) };
40
32
  }
41
33
 
42
34
  // All ref kinds → try node_modules walk
43
35
  const dir = findPackageDir(ref.pkg);
44
36
  if (!dir) return undefined;
45
37
 
46
- const { version, graphs, skills, stories } = readPackageDir(dir);
47
- return {
48
- name: ref.pkg,
49
- version,
50
- source: "local",
51
- graphs,
52
- skills,
53
- stories,
54
- };
38
+ return { name: ref.pkg, source: "local", ...readPackageDir(dir) };
55
39
  },
56
40
  };
57
41
  }
@@ -0,0 +1,58 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import readPackageDir from "./readPackageDir.js";
6
+
7
+ let dir: string;
8
+
9
+ beforeEach(() => {
10
+ dir = mkdtempSync(join(tmpdir(), "pragma-readpkg-"));
11
+ });
12
+
13
+ afterEach(() => {
14
+ rmSync(dir, { recursive: true, force: true });
15
+ });
16
+
17
+ /** Write a package.json into the temp package root. */
18
+ function writeManifest(manifest: unknown): void {
19
+ writeFileSync(join(dir, "package.json"), JSON.stringify(manifest), "utf-8");
20
+ }
21
+
22
+ describe("readPackageDir — prefixes", () => {
23
+ it("reads package-declared prefixes from package.json pragma.prefixes", () => {
24
+ writeManifest({
25
+ version: "1.2.3",
26
+ pragma: { prefixes: { ds: "https://ds.canonical.com/" } },
27
+ });
28
+ const { version, prefixes } = readPackageDir(dir);
29
+ expect(version).toBe("1.2.3");
30
+ expect(prefixes).toEqual({ ds: "https://ds.canonical.com/" });
31
+ });
32
+
33
+ it("omits prefixes when the package declares none", () => {
34
+ writeManifest({ version: "1.0.0" });
35
+ expect(readPackageDir(dir).prefixes).toBeUndefined();
36
+ });
37
+
38
+ it("ignores a malformed pragma.prefixes field", () => {
39
+ writeManifest({ version: "1.0.0", pragma: { prefixes: "not-an-object" } });
40
+ expect(readPackageDir(dir).prefixes).toBeUndefined();
41
+ });
42
+
43
+ it("keeps only string-valued prefix entries", () => {
44
+ writeManifest({
45
+ version: "1.0.0",
46
+ pragma: { prefixes: { ds: "https://ds.canonical.com/", bad: 42 } },
47
+ });
48
+ expect(readPackageDir(dir).prefixes).toEqual({
49
+ ds: "https://ds.canonical.com/",
50
+ });
51
+ });
52
+
53
+ it("returns a default version and no prefixes when package.json is absent", () => {
54
+ const contents = readPackageDir(dir);
55
+ expect(contents.version).toBe("0.0.0");
56
+ expect(contents.prefixes).toBeUndefined();
57
+ });
58
+ });
@@ -38,6 +38,7 @@ const TTL_DIRS: readonly { dir: string; glob: string }[] = [
38
38
  export interface PackageDirContents {
39
39
  readonly version: string;
40
40
  readonly graphs: GraphContent[];
41
+ readonly prefixes?: Readonly<Record<string, string>>;
41
42
  readonly skills: SkillEntry[];
42
43
  readonly stories: StoryFileEntry[];
43
44
  }
@@ -46,12 +47,15 @@ export interface PackageDirContents {
46
47
  * Read semantic package content from a directory.
47
48
  *
48
49
  * @param dir - Absolute path to the package root directory.
49
- * @returns Package version, TTL graph content, and skill entries.
50
+ * @returns Package version, TTL graph content, declared prefixes, and skills.
50
51
  */
51
52
  export default function readPackageDir(dir: string): PackageDirContents {
53
+ const manifest = readManifest(dir);
54
+ const prefixes = parsePrefixesField(manifest);
52
55
  return {
53
- version: readVersion(dir),
56
+ version: typeof manifest.version === "string" ? manifest.version : "0.0.0",
54
57
  graphs: readGraphs(dir),
58
+ ...(prefixes ? { prefixes } : {}),
55
59
  skills: readSkills(dir),
56
60
  stories: readStories(dir),
57
61
  };
@@ -61,15 +65,49 @@ export default function readPackageDir(dir: string): PackageDirContents {
61
65
  // Helpers
62
66
  // ---------------------------------------------------------------------------
63
67
 
64
- function readVersion(dir: string): string {
65
- const pjsonPath = join(dir, "package.json");
68
+ /** A package.json shape with the fields this loader reads. */
69
+ interface PackageManifest {
70
+ readonly version?: unknown;
71
+ readonly pragma?: unknown;
72
+ }
73
+
74
+ /** Read and parse `package.json`, returning an empty object on failure. */
75
+ function readManifest(dir: string): PackageManifest {
66
76
  try {
67
- const raw = readFileSync(pjsonPath, "utf-8");
68
- const parsed = JSON.parse(raw) as { version?: string };
69
- return parsed.version ?? "0.0.0";
77
+ return JSON.parse(
78
+ readFileSync(join(dir, "package.json"), "utf-8"),
79
+ ) as PackageManifest;
70
80
  } catch {
71
- return "0.0.0";
81
+ return {};
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Extract package-declared prefixes from a parsed manifest.
87
+ *
88
+ * Convention: a package declares its namespaces under
89
+ * `pragma.prefixes` in `package.json`, e.g.
90
+ * `{ "pragma": { "prefixes": { "ds": "https://ds.canonical.com/" } } }`.
91
+ * Chosen over a separate file because the manifest is already read for the
92
+ * version, keeping the convention zero-extra-file and discoverable. Only
93
+ * string→string entries are kept; malformed shapes are ignored so one bad
94
+ * field cannot break boot.
95
+ *
96
+ * @returns The declared prefix map, or `undefined` when none is present.
97
+ */
98
+ function parsePrefixesField(
99
+ manifest: PackageManifest,
100
+ ): Readonly<Record<string, string>> | undefined {
101
+ const pragma = manifest.pragma;
102
+ if (typeof pragma !== "object" || pragma === null) return undefined;
103
+ const raw = (pragma as { prefixes?: unknown }).prefixes;
104
+ if (typeof raw !== "object" || raw === null) return undefined;
105
+
106
+ const prefixes: Record<string, string> = {};
107
+ for (const [name, namespace] of Object.entries(raw)) {
108
+ if (typeof namespace === "string") prefixes[name] = namespace;
72
109
  }
110
+ return Object.keys(prefixes).length > 0 ? prefixes : undefined;
73
111
  }
74
112
 
75
113
  function readGraphs(dir: string): GraphContent[] {
@@ -88,6 +88,21 @@ describe("lookupMany", () => {
88
88
  ]);
89
89
  expect(result.meta).toEqual({ internalErrorCount: 2 });
90
90
  });
91
+
92
+ it("reclassifies a rejected SPARQL value as INVALID_INPUT, not internal", async () => {
93
+ const result = await lookupMany(["}; DROP"], async (q) => {
94
+ throw new Error(
95
+ `Potentially dangerous SPARQL value rejected: ${JSON.stringify(q)}`,
96
+ );
97
+ });
98
+
99
+ expect(result.results).toEqual([]);
100
+ expect(result.errors).toHaveLength(1);
101
+ expect(result.errors.at(0)?.code).toBe("INVALID_INPUT");
102
+ expect(result.errors.at(0)?.message).not.toContain("Internal error");
103
+ // A rejected value is user input, so it is not counted as an internal fault.
104
+ expect(result.meta).toEqual({ internalErrorCount: 0 });
105
+ });
91
106
  });
92
107
 
93
108
  describe("lookupToolMeta", () => {
@@ -1,6 +1,13 @@
1
1
  import { PragmaError } from "#error";
2
2
  import type { LookupResult } from "./contracts.js";
3
3
 
4
+ /**
5
+ * Marker in the ke SPARQL guard's rejection message. A rejected value is user
6
+ * input, not an internal fault, so it is reclassified as INVALID_INPUT rather
7
+ * than mislabelled as an internal error.
8
+ */
9
+ const SPARQL_REJECTION_MARKER = "Potentially dangerous SPARQL value rejected";
10
+
4
11
  /**
5
12
  * Resolve multiple lookup queries and collect structured per-query failures.
6
13
  *
@@ -51,13 +58,22 @@ export default async function lookupMany<TResult>(
51
58
  }),
52
59
  });
53
60
  } else {
54
- internalErrorCount += 1;
55
61
  const reason = error instanceof Error ? error.message : String(error);
56
- errors.push({
57
- query,
58
- code: "INTERNAL_ERROR",
59
- message: `Internal error: ${reason}`,
60
- });
62
+ if (reason.includes(SPARQL_REJECTION_MARKER)) {
63
+ // A rejected value is invalid user input, not an internal fault.
64
+ errors.push({
65
+ query,
66
+ code: "INVALID_INPUT",
67
+ message: `Invalid lookup value "${query}": rejected as unsafe.`,
68
+ });
69
+ } else {
70
+ internalErrorCount += 1;
71
+ errors.push({
72
+ query,
73
+ code: "INTERNAL_ERROR",
74
+ message: `Internal error: ${reason}`,
75
+ });
76
+ }
61
77
  }
62
78
  }
63
79
 
@@ -6,12 +6,20 @@
6
6
  */
7
7
 
8
8
  import { createNamespace } from "@canonical/ke";
9
- import { PREFIX_MAP } from "./prefixes.js";
9
+ import { PREFIX_MAP, TRANSITIONAL_DS_PREFIX_MAP } from "./prefixes.js";
10
10
 
11
- /** Design system namespace — classes, properties, and instances (e.g., `ds:Component`). */
12
- export const ds = createNamespace(PREFIX_MAP.ds);
13
- /** Component specification namespace — component-level metadata (e.g., `cs:anatomy`). */
14
- export const cs = createNamespace(PREFIX_MAP.cs);
11
+ /**
12
+ * Design system namespace — classes, properties, and instances (e.g., `ds:Component`).
13
+ * @remarks transitional — sourced from the bundled DS fallback until the
14
+ * design-system package declares its own prefixes (remove in P4).
15
+ */
16
+ export const ds = createNamespace(TRANSITIONAL_DS_PREFIX_MAP.ds);
17
+ /**
18
+ * Component specification namespace — component-level metadata (e.g., `cs:anatomy`).
19
+ * @remarks transitional — sourced from the bundled DS fallback until the
20
+ * code-standards package declares its own prefixes (remove in P4).
21
+ */
22
+ export const cs = createNamespace(TRANSITIONAL_DS_PREFIX_MAP.cs);
15
23
  /** RDF Schema namespace — core vocabulary (e.g., `rdfs:label`, `rdfs:subClassOf`). */
16
24
  export const rdfs = createNamespace(PREFIX_MAP.rdfs);
17
25
  /** OWL namespace — ontology constructs (e.g., `owl:Class`, `owl:ObjectProperty`). */
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { PragmaError } from "../../error/index.js";
3
+ import parseSampleCount, {
4
+ DEFAULT_SAMPLE_COUNT,
5
+ MAX_SAMPLE_COUNT,
6
+ MIN_SAMPLE_COUNT,
7
+ } from "./parseSampleCount.js";
8
+
9
+ describe("parseSampleCount", () => {
10
+ it("returns the default for undefined/null/empty input", () => {
11
+ expect(parseSampleCount(undefined)).toBe(DEFAULT_SAMPLE_COUNT);
12
+ expect(parseSampleCount(null)).toBe(DEFAULT_SAMPLE_COUNT);
13
+ expect(parseSampleCount("")).toBe(DEFAULT_SAMPLE_COUNT);
14
+ });
15
+
16
+ it("accepts an in-range integer verbatim", () => {
17
+ expect(parseSampleCount("3")).toBe(3);
18
+ expect(parseSampleCount(4)).toBe(4);
19
+ });
20
+
21
+ it("clamps below the minimum and above the maximum", () => {
22
+ expect(parseSampleCount("0")).toBe(MIN_SAMPLE_COUNT);
23
+ expect(parseSampleCount("-3")).toBe(MIN_SAMPLE_COUNT);
24
+ expect(parseSampleCount("99")).toBe(MAX_SAMPLE_COUNT);
25
+ });
26
+
27
+ it("tolerates surrounding whitespace", () => {
28
+ expect(parseSampleCount(" 3 ")).toBe(3);
29
+ });
30
+
31
+ it.each([
32
+ "abc",
33
+ "3.5",
34
+ "0x5",
35
+ "1e1",
36
+ "3,4",
37
+ "NaN",
38
+ "Infinity",
39
+ ])("throws INVALID_INPUT for the non-integer value %j", (value) => {
40
+ expect(() => parseSampleCount(value)).toThrowError(PragmaError);
41
+ try {
42
+ parseSampleCount(value);
43
+ } catch (error) {
44
+ expect((error as PragmaError).code).toBe("INVALID_INPUT");
45
+ }
46
+ });
47
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Parse and validate the `--count` argument shared by every `sample` command.
3
+ *
4
+ * A missing count falls back to the default. A non-numeric or non-integer
5
+ * value is rejected as invalid input (so the command exits non-zero) rather
6
+ * than silently coercing to `NaN` and returning zero samples. A valid integer
7
+ * is clamped to the supported 1–5 range.
8
+ */
9
+
10
+ import { PragmaError } from "#error";
11
+
12
+ /** Smallest number of samples a `sample` command will return. */
13
+ export const MIN_SAMPLE_COUNT = 1;
14
+ /** Largest number of samples a `sample` command will return. */
15
+ export const MAX_SAMPLE_COUNT = 5;
16
+ /** Number of samples returned when `--count` is omitted. */
17
+ export const DEFAULT_SAMPLE_COUNT = 2;
18
+
19
+ /**
20
+ * Resolve the raw `--count` param into a clamped sample count.
21
+ *
22
+ * @param raw - The raw param value (`params.count`), possibly undefined.
23
+ * @returns An integer in [MIN_SAMPLE_COUNT, MAX_SAMPLE_COUNT].
24
+ * @throws PragmaError INVALID_INPUT when `raw` is present but not an integer.
25
+ */
26
+ export default function parseSampleCount(raw: unknown): number {
27
+ if (raw === undefined || raw === null || raw === "") {
28
+ return DEFAULT_SAMPLE_COUNT;
29
+ }
30
+
31
+ // Accept only a plain decimal integer. Number() would otherwise coerce hex
32
+ // ("0x5"), scientific ("1e1"), and whitespace-padded forms, none of which a
33
+ // user should be able to pass to --count.
34
+ const text = String(raw).trim();
35
+ if (!/^-?\d+$/.test(text)) {
36
+ throw PragmaError.invalidInput("count", String(raw), {
37
+ recovery: {
38
+ message: `Provide an integer between ${MIN_SAMPLE_COUNT} and ${MAX_SAMPLE_COUNT}.`,
39
+ },
40
+ });
41
+ }
42
+
43
+ const parsed = Number(text);
44
+ return Math.max(MIN_SAMPLE_COUNT, Math.min(MAX_SAMPLE_COUNT, parsed));
45
+ }
@@ -0,0 +1,211 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import {
3
+ DEFAULT_PREFIX_MAP,
4
+ P,
5
+ PREFIX_MAP,
6
+ resolvePrefixes,
7
+ TRANSITIONAL_DS_PREFIX_MAP,
8
+ TTL_PREFIXES,
9
+ } from "./prefixes.js";
10
+
11
+ describe("core PREFIX_MAP", () => {
12
+ it("ships only generic RDF vocabularies — no design-system prefixes", () => {
13
+ expect(Object.keys(PREFIX_MAP).sort()).toEqual([
14
+ "owl",
15
+ "rdf",
16
+ "rdfs",
17
+ "skos",
18
+ "xsd",
19
+ ]);
20
+ expect(PREFIX_MAP).not.toHaveProperty("ds");
21
+ expect(PREFIX_MAP).not.toHaveProperty("cs");
22
+ });
23
+ });
24
+
25
+ describe("TRANSITIONAL_DS_PREFIX_MAP", () => {
26
+ it("bundles the design-system prefixes separately from the core", () => {
27
+ expect(TRANSITIONAL_DS_PREFIX_MAP).toEqual({
28
+ ds: "https://ds.canonical.com/",
29
+ cs: "http://pragma.canonical.com/codestandards#",
30
+ });
31
+ });
32
+ });
33
+
34
+ describe("DEFAULT_PREFIX_MAP (display set)", () => {
35
+ it("reproduces the pre-P0 compaction set — ds/cs plus rdfs/owl/skos", () => {
36
+ expect(Object.keys(DEFAULT_PREFIX_MAP).sort()).toEqual([
37
+ "cs",
38
+ "ds",
39
+ "owl",
40
+ "rdfs",
41
+ "skos",
42
+ ]);
43
+ });
44
+
45
+ it("omits rdf/xsd so URI compaction in output is unchanged", () => {
46
+ expect(DEFAULT_PREFIX_MAP).not.toHaveProperty("rdf");
47
+ expect(DEFAULT_PREFIX_MAP).not.toHaveProperty("xsd");
48
+ });
49
+ });
50
+
51
+ describe("P accessor", () => {
52
+ it("exposes prefixed-name accessors including the transitional ds/cs", () => {
53
+ expect(P.ds).toBe("ds:");
54
+ expect(P.cs).toBe("cs:");
55
+ expect(P.rdfs).toBe("rdfs:");
56
+ expect(P.owl).toBe("owl:");
57
+ expect(P.skos).toBe("skos:");
58
+ });
59
+ });
60
+
61
+ describe("TTL_PREFIXES", () => {
62
+ it("declares every display prefix plus xsd", () => {
63
+ expect(TTL_PREFIXES).toContain("@prefix ds: <https://ds.canonical.com/> .");
64
+ expect(TTL_PREFIXES).toContain(
65
+ "@prefix cs: <http://pragma.canonical.com/codestandards#> .",
66
+ );
67
+ expect(TTL_PREFIXES).toContain(
68
+ "@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .",
69
+ );
70
+ });
71
+ });
72
+
73
+ describe("resolvePrefixes", () => {
74
+ it("returns core plus the transitional DS fallback for a bare core", () => {
75
+ const resolved = resolvePrefixes([]);
76
+ expect(resolved).toEqual({ ...PREFIX_MAP, ...TRANSITIONAL_DS_PREFIX_MAP });
77
+ });
78
+
79
+ it("layers package-declared prefixes over the fallback", () => {
80
+ const resolved = resolvePrefixes([
81
+ { prefixes: { ex: "https://example.org/" } },
82
+ ]);
83
+ expect(resolved.ex).toBe("https://example.org/");
84
+ // core + fallback still present
85
+ expect(resolved.ds).toBe(TRANSITIONAL_DS_PREFIX_MAP.ds);
86
+ expect(resolved.rdf).toBe(PREFIX_MAP.rdf);
87
+ });
88
+
89
+ it("lets a package override the transitional DS fallback", () => {
90
+ const resolved = resolvePrefixes([
91
+ { prefixes: { ds: "https://packaged-ds.example/" } },
92
+ ]);
93
+ expect(resolved.ds).toBe("https://packaged-ds.example/");
94
+ });
95
+
96
+ it("lets config prefixes win over both packages and the fallback", () => {
97
+ const resolved = resolvePrefixes(
98
+ [{ prefixes: { ds: "https://packaged-ds.example/" } }],
99
+ { ds: "https://config-ds.example/" },
100
+ );
101
+ expect(resolved.ds).toBe("https://config-ds.example/");
102
+ });
103
+
104
+ it("merges multiple packages in array order", () => {
105
+ const resolved = resolvePrefixes([
106
+ { prefixes: { a: "https://one.example/" } },
107
+ { prefixes: { a: "https://two.example/", b: "https://b.example/" } },
108
+ ]);
109
+ expect(resolved.a).toBe("https://two.example/");
110
+ expect(resolved.b).toBe("https://b.example/");
111
+ });
112
+
113
+ it("tolerates packages that declare no prefixes", () => {
114
+ const resolved = resolvePrefixes([{}, { prefixes: undefined }]);
115
+ expect(resolved).toEqual({ ...PREFIX_MAP, ...TRANSITIONAL_DS_PREFIX_MAP });
116
+ });
117
+ });
118
+
119
+ describe("resolvePrefixes — injection & shadowing hardening", () => {
120
+ let warnings: string[];
121
+ let write: typeof process.stderr.write;
122
+
123
+ beforeEach(() => {
124
+ warnings = [];
125
+ write = process.stderr.write;
126
+ process.stderr.write = ((chunk: string | Uint8Array) => {
127
+ warnings.push(String(chunk));
128
+ return true;
129
+ }) as typeof process.stderr.write;
130
+ });
131
+
132
+ afterEach(() => {
133
+ process.stderr.write = write;
134
+ });
135
+
136
+ it("drops a namespace that could break out of the SPARQL IRIREF", () => {
137
+ // A `>` would close `<...>` early and inject arbitrary SPARQL into every
138
+ // query the store runs.
139
+ const resolved = resolvePrefixes([
140
+ { prefixes: { evil: "http://e> SELECT * WHERE {?s ?p ?o} #" } },
141
+ ]);
142
+ expect(resolved).not.toHaveProperty("evil");
143
+ expect(warnings.join("")).toContain("not valid in an IRI");
144
+ });
145
+
146
+ it("rejects namespaces containing whitespace or angle/quote/brace chars", () => {
147
+ const resolved = resolvePrefixes([
148
+ {
149
+ prefixes: {
150
+ space: "http://e /x",
151
+ quote: 'http://e"x',
152
+ brace: "http://e{x}",
153
+ },
154
+ },
155
+ ]);
156
+ expect(resolved).not.toHaveProperty("space");
157
+ expect(resolved).not.toHaveProperty("quote");
158
+ expect(resolved).not.toHaveProperty("brace");
159
+ });
160
+
161
+ it("keeps a clean namespace untouched", () => {
162
+ const resolved = resolvePrefixes([
163
+ { prefixes: { ex: "https://example.org/ns#" } },
164
+ ]);
165
+ expect(resolved.ex).toBe("https://example.org/ns#");
166
+ expect(warnings).toHaveLength(0);
167
+ });
168
+
169
+ it("refuses to let a package redefine a reserved core prefix", () => {
170
+ const resolved = resolvePrefixes([
171
+ { prefixes: { rdf: "https://evil.example/rdf#" } },
172
+ ]);
173
+ expect(resolved.rdf).toBe(PREFIX_MAP.rdf);
174
+ expect(warnings.join("")).toContain("reserved");
175
+ });
176
+
177
+ it("refuses reserved prefixes from config too", () => {
178
+ const resolved = resolvePrefixes([], { owl: "https://evil.example/owl#" });
179
+ expect(resolved.owl).toBe(PREFIX_MAP.owl);
180
+ });
181
+
182
+ it("skips a malformed prefix name", () => {
183
+ const resolved = resolvePrefixes([
184
+ { prefixes: { "bad name!": "https://example.org/" } },
185
+ ]);
186
+ expect(resolved).not.toHaveProperty("bad name!");
187
+ expect(warnings.join("")).toContain("not a valid prefix name");
188
+ });
189
+
190
+ it("warns on a last-wins collision between two packages", () => {
191
+ const resolved = resolvePrefixes([
192
+ { prefixes: { a: "https://one.example/" } },
193
+ { prefixes: { a: "https://two.example/" } },
194
+ ]);
195
+ expect(resolved.a).toBe("https://two.example/");
196
+ expect(warnings.join("")).toContain("overrides an earlier declaration");
197
+ });
198
+
199
+ it("names the declaring package in collision warnings when available", () => {
200
+ resolvePrefixes([
201
+ { name: "@canonical/one", prefixes: { a: "https://one.example/" } },
202
+ { name: "@canonical/two", prefixes: { a: "https://two.example/" } },
203
+ ]);
204
+ expect(warnings.join("")).toContain('package "@canonical/two"');
205
+ });
206
+
207
+ it("does not warn when a package overrides the trusted DS fallback", () => {
208
+ resolvePrefixes([{ prefixes: { ds: "https://packaged-ds.example/" } }]);
209
+ expect(warnings).toHaveLength(0);
210
+ });
211
+ });