@canonical/pragma-cli 0.27.1-experimental.0 → 0.29.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 (169) hide show
  1. package/dist/pragma +0 -0
  2. package/package.json +16 -17
  3. package/src/config/readConfig.ts +38 -2
  4. package/src/config/types.ts +12 -1
  5. package/src/config/writeConfig.ts +12 -0
  6. package/src/constants.ts +12 -1
  7. package/src/domains/block/commands/index.ts +2 -1
  8. package/src/domains/block/commands/sample.ts +55 -0
  9. package/src/domains/block/formatters/index.ts +2 -1
  10. package/src/domains/block/formatters/sample.ts +21 -0
  11. package/src/domains/block/index.ts +3 -3
  12. package/src/domains/block/mcp/specs.ts +48 -0
  13. package/src/domains/block/operations/index.ts +2 -1
  14. package/src/domains/block/operations/lookup.ts +9 -0
  15. package/src/domains/block/operations/sample.ts +41 -0
  16. package/src/domains/block/orchestration/blockEmptyError.test.ts +26 -0
  17. package/src/domains/block/orchestration/resolveBlockLookup.ts +10 -3
  18. package/src/domains/config/commands/framework.test.ts +88 -0
  19. package/src/domains/config/commands/framework.ts +85 -0
  20. package/src/domains/config/commands/index.ts +2 -0
  21. package/src/domains/config/commands/trace.test.ts +148 -0
  22. package/src/domains/config/commands/trace.ts +80 -0
  23. package/src/domains/config/formatters/framework.ts +34 -0
  24. package/src/domains/config/formatters/index.ts +2 -0
  25. package/src/domains/config/formatters/trace.ts +28 -0
  26. package/src/domains/config/index.ts +15 -2
  27. package/src/domains/config/operations/index.ts +1 -0
  28. package/src/domains/config/operations/validateFramework.ts +24 -0
  29. package/src/domains/config/tasks/index.ts +2 -0
  30. package/src/domains/config/tasks/setChannel.ts +7 -1
  31. package/src/domains/config/tasks/setFramework.ts +40 -0
  32. package/src/domains/config/tasks/setTier.ts +7 -1
  33. package/src/domains/config/tasks/setTrace.ts +38 -0
  34. package/src/domains/doctor/operations/checks/checkKeStore.ts +5 -5
  35. package/src/domains/doctor/operations/checks/checkPackageRefs.ts +35 -41
  36. package/src/domains/doctor/operations/checks.test.ts +4 -1
  37. package/src/domains/doctor/operations/runChecks.test.ts +72 -0
  38. package/src/domains/doctor/operations/runChecks.ts +48 -12
  39. package/src/domains/graphql/commands/build.test.ts +194 -0
  40. package/src/domains/graphql/commands/build.ts +125 -0
  41. package/src/domains/graphql/commands/check.test.ts +139 -0
  42. package/src/domains/graphql/commands/check.ts +87 -0
  43. package/src/domains/graphql/commands/index.ts +4 -0
  44. package/src/domains/graphql/formatters/index.ts +3 -0
  45. package/src/domains/graphql/formatters/report.ts +78 -0
  46. package/src/domains/graphql/helpers/index.ts +4 -0
  47. package/src/domains/graphql/helpers/parsePrefixes.test.ts +44 -0
  48. package/src/domains/graphql/helpers/parsePrefixes.ts +38 -0
  49. package/src/domains/graphql/helpers/resolveSourceFiles.test.ts +56 -0
  50. package/src/domains/graphql/helpers/resolveSourceFiles.ts +45 -0
  51. package/src/domains/graphql/index.ts +21 -0
  52. package/src/domains/graphql/operations/compileSchema.test.ts +119 -0
  53. package/src/domains/graphql/operations/compileSchema.ts +109 -0
  54. package/src/domains/graphql/operations/index.ts +7 -0
  55. package/src/domains/graphql/types.ts +29 -0
  56. package/src/domains/info/operations/collectInfo.test.ts +2 -2
  57. package/src/domains/info/operations/collectInfo.ts +4 -4
  58. package/src/domains/llm/capabilities.test.ts +91 -0
  59. package/src/domains/llm/commands/capabilities.ts +28 -0
  60. package/src/domains/llm/commands/index.ts +1 -0
  61. package/src/domains/llm/data/commandReference.ts +90 -18
  62. package/src/domains/llm/data/decisionTrees.ts +15 -13
  63. package/src/domains/llm/data/index.ts +2 -0
  64. package/src/domains/llm/data/toolCatalog.ts +184 -0
  65. package/src/domains/llm/formatters/capabilities.ts +50 -0
  66. package/src/domains/llm/formatters/index.ts +1 -0
  67. package/src/domains/llm/index.ts +3 -2
  68. package/src/domains/llm/mcp/index.ts +1 -1
  69. package/src/domains/llm/mcp/specs.ts +59 -52
  70. package/src/domains/llm/types.ts +40 -0
  71. package/src/domains/modifier/commands/index.ts +2 -1
  72. package/src/domains/modifier/commands/sample.ts +53 -0
  73. package/src/domains/modifier/formatters/index.ts +2 -1
  74. package/src/domains/modifier/formatters/sample.ts +15 -0
  75. package/src/domains/modifier/index.ts +7 -3
  76. package/src/domains/modifier/mcp/specs.ts +47 -0
  77. package/src/domains/modifier/operations/index.ts +2 -1
  78. package/src/domains/modifier/operations/lookup.ts +9 -0
  79. package/src/domains/modifier/operations/sample.ts +32 -0
  80. package/src/domains/modifier/orchestration/modifierEmptyError.test.ts +26 -0
  81. package/src/domains/modifier/orchestration/modifierEmptyError.ts +7 -2
  82. package/src/domains/modifier/orchestration/resolveModifierLookup.ts +14 -2
  83. package/src/domains/refs/operations/index.ts +1 -1
  84. package/src/domains/refs/operations/paths.ts +29 -1
  85. package/src/domains/refs/operations/updateRefs.test.ts +6 -3
  86. package/src/domains/refs/operations/updateRefs.ts +3 -2
  87. package/src/domains/shared/bootStore.ts +88 -49
  88. package/src/domains/shared/contracts.ts +1 -0
  89. package/src/domains/shared/emptyErrorRecovery.test.ts +109 -0
  90. package/src/domains/shared/loaders/bundledLoader.ts +108 -0
  91. package/src/domains/shared/loaders/gitLoader.ts +31 -0
  92. package/src/domains/shared/loaders/index.ts +6 -0
  93. package/src/domains/shared/loaders/localLoader.ts +73 -0
  94. package/src/domains/shared/loaders/readPackageDir.ts +112 -0
  95. package/src/domains/shared/lookupMany.ts +9 -1
  96. package/src/domains/shared/packages.ts +21 -120
  97. package/src/domains/shared/pickRandom.ts +22 -0
  98. package/src/domains/shared/runtime.ts +6 -3
  99. package/src/domains/shared/sampleFormatters.ts +47 -0
  100. package/src/domains/shared/semanticPackage.ts +96 -0
  101. package/src/domains/shared/suggestions/detectCrossDomain.test.ts +43 -0
  102. package/src/domains/shared/suggestions/detectCrossDomain.ts +116 -0
  103. package/src/domains/shared/suggestions/expandGlob.test.ts +92 -0
  104. package/src/domains/shared/suggestions/expandGlob.ts +51 -0
  105. package/src/domains/shared/suggestions/expandLookupQueries.ts +57 -0
  106. package/src/domains/shared/suggestions/index.ts +7 -0
  107. package/src/domains/shared/suggestions/listDomainNames.ts +61 -0
  108. package/src/domains/shared/suggestions/suggestNames.test.ts +70 -0
  109. package/src/domains/shared/suggestions/suggestNames.ts +97 -0
  110. package/src/domains/shared/types/index.ts +1 -0
  111. package/src/domains/shared/types/runtime.ts +4 -1
  112. package/src/domains/shared/types/sample.ts +17 -0
  113. package/src/domains/skill/helpers/resolveSkillSources.ts +49 -16
  114. package/src/domains/standard/commands/index.ts +2 -1
  115. package/src/domains/standard/commands/sample.ts +57 -0
  116. package/src/domains/standard/formatters/index.ts +2 -1
  117. package/src/domains/standard/formatters/sample.ts +15 -0
  118. package/src/domains/standard/index.ts +8 -1
  119. package/src/domains/standard/mcp/specs.ts +48 -1
  120. package/src/domains/standard/operations/index.ts +2 -1
  121. package/src/domains/standard/operations/lookup.ts +9 -0
  122. package/src/domains/standard/operations/sample.ts +35 -0
  123. package/src/domains/standard/orchestration/resolveStandardLookup.ts +14 -2
  124. package/src/domains/standard/orchestration/standardEmptyError.test.ts +33 -0
  125. package/src/domains/standard/orchestration/standardEmptyError.ts +16 -6
  126. package/src/domains/token/commands/index.ts +2 -1
  127. package/src/domains/token/commands/sample.ts +53 -0
  128. package/src/domains/token/formatters/index.ts +2 -1
  129. package/src/domains/token/formatters/sample.ts +16 -0
  130. package/src/domains/token/index.ts +8 -1
  131. package/src/domains/token/mcp/specs.ts +47 -1
  132. package/src/domains/token/operations/index.ts +2 -1
  133. package/src/domains/token/operations/lookup.ts +9 -0
  134. package/src/domains/token/operations/sample.ts +32 -0
  135. package/src/domains/token/orchestration/resolveTokenLookup.ts +14 -2
  136. package/src/domains/token/orchestration/tokenEmptyError.test.ts +33 -0
  137. package/src/domains/token/orchestration/tokenEmptyError.ts +5 -2
  138. package/src/domains/trace/commands/clear.ts +71 -0
  139. package/src/domains/trace/commands/index.ts +5 -0
  140. package/src/domains/trace/commands/sessions.ts +32 -0
  141. package/src/domains/trace/commands/show.ts +145 -0
  142. package/src/domains/trace/formatters/index.ts +9 -0
  143. package/src/domains/trace/formatters/sessions.ts +80 -0
  144. package/src/domains/trace/formatters/show.ts +122 -0
  145. package/src/domains/trace/index.ts +17 -0
  146. package/src/domains/trace/operations/index.ts +8 -0
  147. package/src/domains/trace/operations/listSessions.test.ts +55 -0
  148. package/src/domains/trace/operations/listSessions.ts +52 -0
  149. package/src/domains/trace/operations/readTraceLog.test.ts +100 -0
  150. package/src/domains/trace/operations/readTraceLog.ts +104 -0
  151. package/src/domains/trace/tracePlugin.test.ts +171 -0
  152. package/src/domains/trace/tracePlugin.ts +159 -0
  153. package/src/domains/trace/types.ts +65 -0
  154. package/src/error/PragmaError.ts +15 -2
  155. package/src/error/types.ts +15 -1
  156. package/src/mcp/error/serializeErrorPayload.ts +7 -0
  157. package/src/mcp/error/types.ts +8 -0
  158. package/src/mcp/registerResources.test.ts +38 -16
  159. package/src/mcp/registerResources.ts +57 -8
  160. package/src/mcp/tools/registerTools.test.ts +22 -7
  161. package/src/mcp/utils/wrapTool.test.ts +1 -0
  162. package/src/pipeline/collectCommands.ts +7 -1
  163. package/src/pipeline/renderError.ts +15 -0
  164. package/src/pipeline/resolveCommandKind.ts +8 -1
  165. package/src/pipeline/runCli.ts +12 -1
  166. package/src/testing/createTestMcpClient.ts +1 -0
  167. package/src/testing/graphqlFixtures.ts +72 -0
  168. package/src/testing/index.ts +7 -0
  169. package/src/testing/integration/mcp-surface.test.ts +11 -5
@@ -1,126 +1,27 @@
1
1
  /**
2
- * Design system package registry and resolution.
2
+ * Default semantic graph package registry.
3
3
  *
4
- * Resolves semantic packages from three sources (in priority order):
5
- * 1. file:// local path (development)
6
- * 2. git ref cache (shared/CI)
7
- * 3. require.resolve from node_modules (npm fallback)
4
+ * Defines the default graph packages loaded when no config overrides
5
+ * are present. Each entry is a git ref pointing to the canonical
6
+ * repository so `pragma update-refs` fetches data out of the box.
8
7
  *
9
- * The package list itself is configurable via pragma.config.json `packages`
10
- * field. When absent, the hardcoded DEFAULT_PACKAGES are used.
8
+ * Resolution is handled by the loader chain in `loaders/`.
11
9
  */
12
10
 
13
- import { existsSync } from "node:fs";
14
- import { createRequire } from "node:module";
15
- import { dirname } from "node:path";
16
- import type { PackageRef } from "../refs/operations/parseRef.js";
17
- import { gitCacheDir } from "../refs/operations/paths.js";
18
-
19
- const require = createRequire(import.meta.url);
20
-
21
- // ---------------------------------------------------------------------------
22
- // Default package registry (used when config.packages is absent)
23
- // ---------------------------------------------------------------------------
24
-
25
- /** Default semantic packages loaded when no config overrides are present. */
26
- export const DEFAULT_PACKAGES: readonly string[] = [
27
- "@canonical/design-system",
28
- "@canonical/anatomy-dsl",
29
- "@canonical/code-standards",
30
- "@canonical/pragma-cli",
11
+ import type { RawPackageEntry } from "../refs/operations/parseRef.js";
12
+
13
+ /** Default semantic graph packages loaded when no config overrides are present. */
14
+ export const DEFAULT_PACKAGES: readonly RawPackageEntry[] = [
15
+ {
16
+ name: "@canonical/design-system",
17
+ source: "git+https://github.com/canonical/design-system.git#main",
18
+ },
19
+ {
20
+ name: "@canonical/anatomy-dsl",
21
+ source: "git+https://github.com/canonical/anatomy-dsl.git#main",
22
+ },
23
+ {
24
+ name: "@canonical/code-standards",
25
+ source: "git+https://github.com/canonical/web-code-standards.git#main",
26
+ },
31
27
  ];
32
-
33
- // ---------------------------------------------------------------------------
34
- // Types
35
- // ---------------------------------------------------------------------------
36
-
37
- export interface ResolvedPackage {
38
- /** npm package name. */
39
- readonly pkg: string;
40
- /** Absolute path to the package root. */
41
- readonly dir: string;
42
- }
43
-
44
- // ---------------------------------------------------------------------------
45
- // Resolution
46
- // ---------------------------------------------------------------------------
47
-
48
- /**
49
- * Resolve packages to filesystem directories.
50
- *
51
- * When `refs` is provided, each PackageRef is resolved according to its kind.
52
- * When `refs` is omitted, the DEFAULT_PACKAGES are resolved via require.resolve.
53
- *
54
- * @param refs - Parsed package references (from config merge). Omit for defaults.
55
- * @returns Array of resolved packages with absolute directory paths.
56
- */
57
- export function resolvePackages(
58
- refs?: ReadonlyArray<PackageRef>,
59
- ): ResolvedPackage[] {
60
- if (!refs) {
61
- return resolveNpmPackages(DEFAULT_PACKAGES);
62
- }
63
-
64
- const resolved: ResolvedPackage[] = [];
65
-
66
- for (const ref of refs) {
67
- switch (ref.kind) {
68
- case "file": {
69
- if (!existsSync(ref.path)) {
70
- console.warn(
71
- `pragma: package "${ref.pkg}" points to ${ref.path} which does not exist. Check the path or remove the file:// source.`,
72
- );
73
- break;
74
- }
75
- resolved.push({ pkg: ref.pkg, dir: ref.path });
76
- break;
77
- }
78
-
79
- case "git": {
80
- const cacheDir = gitCacheDir(ref.pkg, ref.ref);
81
- if (existsSync(cacheDir)) {
82
- resolved.push({ pkg: ref.pkg, dir: cacheDir });
83
- break;
84
- }
85
- // Cache miss — warn and fall through to npm
86
- console.warn(
87
- `pragma: git ref for "${ref.pkg}" not cached. Run "pragma update-refs" to fetch it.`,
88
- );
89
- resolveNpmFallback(ref.pkg, resolved);
90
- break;
91
- }
92
-
93
- case "npm": {
94
- resolveNpmFallback(ref.pkg, resolved);
95
- break;
96
- }
97
- }
98
- }
99
-
100
- return resolved;
101
- }
102
-
103
- // ---------------------------------------------------------------------------
104
- // Helpers
105
- // ---------------------------------------------------------------------------
106
-
107
- /** Resolve a list of package names via require.resolve. */
108
- function resolveNpmPackages(pkgs: readonly string[]): ResolvedPackage[] {
109
- const resolved: ResolvedPackage[] = [];
110
- for (const pkg of pkgs) {
111
- resolveNpmFallback(pkg, resolved);
112
- }
113
- return resolved;
114
- }
115
-
116
- /** Try to resolve a single package via require.resolve, push if found. */
117
- function resolveNpmFallback(pkg: string, resolved: ResolvedPackage[]): void {
118
- try {
119
- resolved.push({
120
- pkg,
121
- dir: dirname(require.resolve(`${pkg}/package.json`)),
122
- });
123
- } catch {
124
- // package not installed — skip
125
- }
126
- }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Pick `count` random items from an array without replacement.
3
+ *
4
+ * Uses Fisher-Yates partial shuffle for uniform distribution.
5
+ * Returns at most `arr.length` items if count exceeds the array size.
6
+ */
7
+ export default function pickRandom<T>(
8
+ arr: readonly T[],
9
+ count: number,
10
+ ): readonly T[] {
11
+ const n = Math.min(count, arr.length);
12
+ if (n === 0) return [];
13
+
14
+ const copy = [...arr];
15
+ for (let i = 0; i < n; i++) {
16
+ const j = i + Math.floor(Math.random() * (copy.length - i));
17
+ const tmp = copy[i];
18
+ copy[i] = copy[j] as T;
19
+ copy[j] = tmp as T;
20
+ }
21
+ return copy.slice(0, n);
22
+ }
@@ -48,16 +48,18 @@ export async function bootPragma(options?: {
48
48
  ? undefined
49
49
  : mergeAndParseRefs(config.packages);
50
50
 
51
- const store = await bootStore({
51
+ const { store, packages } = await bootStore({
52
52
  cwd,
53
53
  sources: options?.sources,
54
54
  refs,
55
+ trace: config.trace,
55
56
  });
56
57
 
57
58
  return {
58
59
  store,
59
60
  config,
60
61
  cwd,
62
+ packages,
61
63
  dispose: () => store.dispose(),
62
64
  };
63
65
  }
@@ -92,8 +94,9 @@ function mergeAndParseRefs(
92
94
  const merged = new Map<string, RawPackageEntry>();
93
95
 
94
96
  // Start with defaults (lowest priority)
95
- for (const pkg of DEFAULT_PACKAGES) {
96
- merged.set(pkg, pkg);
97
+ for (const entry of DEFAULT_PACKAGES) {
98
+ const name = typeof entry === "string" ? entry : entry.name;
99
+ merged.set(name, entry);
97
100
  }
98
101
 
99
102
  // Global overrides defaults
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Factory for sample command formatters.
3
+ *
4
+ * Every domain's sample formatter follows the same pattern: a count
5
+ * header, then each item rendered via the domain's lookup formatter.
6
+ * This factory eliminates the duplication.
7
+ */
8
+
9
+ import type { Formatters } from "./formatters.js";
10
+ import type { SampleOutput } from "./types/index.js";
11
+
12
+ /**
13
+ * Create a three-mode formatter for `pragma <domain> sample`.
14
+ *
15
+ * @param domain - plural domain label for the header (e.g. "standards")
16
+ * @param renderItem - renders one item; receives the item and the mode
17
+ */
18
+ export default function createSampleFormatters<T>(
19
+ domain: string,
20
+ renderItem: (item: T, mode: "plain" | "llm") => string,
21
+ ): Formatters<SampleOutput<T>> {
22
+ return {
23
+ plain(output) {
24
+ const header = `Showing ${output.samples.length} of ${output.totalCount} ${domain}\n`;
25
+ const items = output.samples.map((item) => renderItem(item, "plain"));
26
+ return header + items.join("\n\n");
27
+ },
28
+
29
+ llm(output) {
30
+ const header = `Showing ${output.samples.length} of ${output.totalCount} ${domain}\n`;
31
+ const items = output.samples.map((item) => renderItem(item, "llm"));
32
+ return header + items.join("\n\n");
33
+ },
34
+
35
+ json(output) {
36
+ return JSON.stringify(
37
+ {
38
+ samples: output.samples,
39
+ totalCount: output.totalCount,
40
+ nextSteps: output.nextSteps,
41
+ },
42
+ null,
43
+ 2,
44
+ );
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Semantic package contracts and resolution orchestrator.
3
+ *
4
+ * A semantic package is a resolved graph dependency containing TTL
5
+ * definitions, instance data, and skills. Three loaders produce the
6
+ * same `SemanticPackage` shape — local (file/npm), git cache, and
7
+ * bundled (compiled binary). The orchestrator tries each loader in
8
+ * precedence order: local > git > bundled.
9
+ */
10
+
11
+ import type { PackageRef } from "../refs/operations/parseRef.js";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Contracts
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** A single TTL file's content with provenance annotation. */
18
+ export interface GraphContent {
19
+ /** Original file path or virtual identifier (for provenance/debugging). */
20
+ readonly path: string;
21
+ /** TTL content string. */
22
+ readonly content: string;
23
+ /** RDF serialization format. */
24
+ readonly format: "turtle";
25
+ }
26
+
27
+ /** A discovered skill directory with its filesystem path. */
28
+ export interface SkillEntry {
29
+ /** Absolute path to the skill directory (must exist on disk for symlinking). */
30
+ readonly dir: string;
31
+ /** Skill folder name (e.g. "anatomy-author"). */
32
+ readonly folderName: string;
33
+ }
34
+
35
+ /** A resolved graph package. All loaders produce this uniform shape. */
36
+ export interface SemanticPackage {
37
+ /** npm package name (e.g. "@canonical/design-system"). */
38
+ readonly name: string;
39
+ /** Version string from package.json. */
40
+ readonly version: string;
41
+ /** How this package was resolved. */
42
+ readonly source: "local" | "git" | "bundled";
43
+ /** TTL graph content to load into the ke store. */
44
+ readonly graphs: GraphContent[];
45
+ /** Skills available in this package. */
46
+ readonly skills: SkillEntry[];
47
+ }
48
+
49
+ /**
50
+ * Resolves a package reference to a SemanticPackage.
51
+ * Returns `undefined` if this loader cannot resolve the given ref.
52
+ * May be async (e.g. BundledLoader reads blob content asynchronously).
53
+ */
54
+ export interface PackageLoader {
55
+ /** Loader identity for diagnostics. */
56
+ readonly name: "local" | "git" | "bundled";
57
+ /** Attempt to resolve a package reference. */
58
+ resolve(
59
+ ref: PackageRef,
60
+ ): SemanticPackage | undefined | Promise<SemanticPackage | undefined>;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Orchestrator
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /**
68
+ * Resolve package references to semantic packages using a loader chain.
69
+ *
70
+ * Tries each loader in array order for each ref. The first loader that
71
+ * returns a result wins — subsequent loaders are skipped for that ref.
72
+ * Refs that no loader can resolve are silently dropped.
73
+ *
74
+ * @param refs - Parsed package references from config merge.
75
+ * @param loaders - Loaders in precedence order (local > git > bundled).
76
+ * @returns Array of resolved semantic packages.
77
+ */
78
+ export async function resolveSemanticPackages(
79
+ refs: ReadonlyArray<PackageRef>,
80
+ loaders: ReadonlyArray<PackageLoader>,
81
+ ): Promise<SemanticPackage[]> {
82
+ const packages: SemanticPackage[] = [];
83
+
84
+ for (const ref of refs) {
85
+ let resolved: SemanticPackage | undefined;
86
+ for (const loader of loaders) {
87
+ resolved = await loader.resolve(ref);
88
+ if (resolved) break;
89
+ }
90
+ if (resolved) {
91
+ packages.push(resolved);
92
+ }
93
+ }
94
+
95
+ return packages;
96
+ }
@@ -0,0 +1,43 @@
1
+ import type { Store } from "@canonical/ke";
2
+ import { describe, expect, it } from "vitest";
3
+ import { createTestStore, DS_ALL_TTL } from "#testing";
4
+ import detectCrossDomain from "./detectCrossDomain.js";
5
+
6
+ let store: Store;
7
+ let cleanup: () => void;
8
+
9
+ describe("detectCrossDomain", () => {
10
+ beforeAll(async () => {
11
+ const result = await createTestStore({ ttl: DS_ALL_TTL });
12
+ store = result.store;
13
+ cleanup = result.cleanup;
14
+ });
15
+
16
+ afterAll(() => {
17
+ cleanup();
18
+ });
19
+
20
+ it("T2-4: returns undefined when name is not in any domain", async () => {
21
+ const hint = await detectCrossDomain("nonexistent_xyz", "token", store);
22
+ expect(hint).toBeUndefined();
23
+ });
24
+
25
+ it("returns a hint with the correct shape", async () => {
26
+ const hint = await detectCrossDomain("Button", "modifier", store);
27
+ if (hint) {
28
+ expect(hint).toHaveProperty("domain");
29
+ expect(hint).toHaveProperty("entityType");
30
+ expect(hint).toHaveProperty("cli");
31
+ expect(hint).toHaveProperty("mcp");
32
+ expect(hint.mcp).toHaveProperty("tool");
33
+ expect(hint.mcp).toHaveProperty("params");
34
+ }
35
+ });
36
+
37
+ it("skips the current domain", async () => {
38
+ const hint = await detectCrossDomain("Button", "block", store);
39
+ if (hint) {
40
+ expect(hint.domain).not.toBe("block");
41
+ }
42
+ });
43
+ });
@@ -0,0 +1,116 @@
1
+ import type { SPARQL, Store } from "@canonical/ke";
2
+ import { escapeSparqlValue } from "@canonical/ke";
3
+ import { buildQuery } from "../buildQuery.js";
4
+ import { P } from "../prefixes.js";
5
+
6
+ /**
7
+ * Structured hint pointing the user/agent to the correct domain
8
+ * when a name exists in a different domain than the one queried.
9
+ */
10
+ export interface CrossDomainHint {
11
+ /** Domain where the name was found (e.g., "modifier"). */
12
+ readonly domain: string;
13
+ /** Entity type label for messages (e.g., "modifier"). */
14
+ readonly entityType: string;
15
+ /** CLI recovery command. */
16
+ readonly cli: string;
17
+ /** MCP recovery tool + params. */
18
+ readonly mcp: { tool: string; params: Record<string, unknown> };
19
+ }
20
+
21
+ interface DomainSpec {
22
+ readonly domain: string;
23
+ readonly entityType: string;
24
+ readonly lookupTool: string;
25
+ readonly cliCommand: string;
26
+ readonly query: (name: string) => SPARQL<string>;
27
+ }
28
+
29
+ const DOMAIN_SPECS: readonly DomainSpec[] = [
30
+ {
31
+ domain: "block",
32
+ entityType: "block",
33
+ lookupTool: "block_lookup",
34
+ cliCommand: "pragma block lookup",
35
+ query: (name) =>
36
+ buildQuery(`
37
+ ASK {
38
+ VALUES ?type { ${P.ds}Component ${P.ds}Pattern ${P.ds}Layout ${P.ds}Subcomponent }
39
+ ?s a ?type ; ${P.ds}name ?name .
40
+ FILTER(LCASE(STR(?name)) = LCASE(${escapeSparqlValue(name)}))
41
+ }
42
+ `),
43
+ },
44
+ {
45
+ domain: "token",
46
+ entityType: "token",
47
+ lookupTool: "token_lookup",
48
+ cliCommand: "pragma token lookup",
49
+ query: (name) =>
50
+ buildQuery(`
51
+ ASK {
52
+ ?s a ${P.ds}Token ; ${P.ds}tokenId ?id .
53
+ FILTER(LCASE(STR(?id)) = LCASE(${escapeSparqlValue(name)}))
54
+ }
55
+ `),
56
+ },
57
+ {
58
+ domain: "modifier",
59
+ entityType: "modifier",
60
+ lookupTool: "modifier_lookup",
61
+ cliCommand: "pragma modifier lookup",
62
+ query: (name) =>
63
+ buildQuery(`
64
+ ASK {
65
+ ?s a ${P.ds}ModifierFamily ; ${P.ds}name ?name .
66
+ FILTER(LCASE(STR(?name)) = LCASE(${escapeSparqlValue(name)}))
67
+ }
68
+ `),
69
+ },
70
+ {
71
+ domain: "standard",
72
+ entityType: "standard",
73
+ lookupTool: "standard_lookup",
74
+ cliCommand: "pragma standard lookup",
75
+ query: (name) =>
76
+ buildQuery(`
77
+ ASK {
78
+ ?s a ${P.cs}CodeStandard ; ${P.cs}name ?name .
79
+ FILTER(LCASE(STR(?name)) = LCASE(${escapeSparqlValue(name)}))
80
+ }
81
+ `),
82
+ },
83
+ ];
84
+
85
+ /**
86
+ * Check if a name exists in a domain other than the current one.
87
+ *
88
+ * Queries each domain in a deterministic order (block → token → modifier → standard)
89
+ * and returns the first match. Returns undefined if the name is not found anywhere.
90
+ *
91
+ * @param name - The user-supplied name.
92
+ * @param currentDomain - The domain the user queried (e.g., "token").
93
+ * @param store - ke store for cross-domain queries.
94
+ * @returns Hint pointing to the correct domain, or undefined.
95
+ */
96
+ export default async function detectCrossDomain(
97
+ name: string,
98
+ currentDomain: string,
99
+ store: Store,
100
+ ): Promise<CrossDomainHint | undefined> {
101
+ for (const spec of DOMAIN_SPECS) {
102
+ if (spec.domain === currentDomain) continue;
103
+
104
+ const result = await store.query(spec.query(name));
105
+ if (result.type === "ask" && result.result) {
106
+ return {
107
+ domain: spec.domain,
108
+ entityType: spec.entityType,
109
+ cli: `${spec.cliCommand} ${name}`,
110
+ mcp: { tool: spec.lookupTool, params: { names: [name] } },
111
+ };
112
+ }
113
+ }
114
+
115
+ return undefined;
116
+ }
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import expandGlob, { isGlobPattern } from "./expandGlob.js";
3
+
4
+ const standardNames = [
5
+ "react.component.props",
6
+ "react.component.structure.folder",
7
+ "react.component.barrel_exports",
8
+ "react.hooks.naming",
9
+ "css.selectors.namespace",
10
+ "css.selectors.specificity",
11
+ "css.component.encapsulation",
12
+ "git.branch.name",
13
+ "git.commit.message",
14
+ ];
15
+
16
+ describe("expandGlob", () => {
17
+ it("T5-1: expands react.component.* to all matching entries", () => {
18
+ const result = expandGlob("react.component.*", standardNames);
19
+ expect(result).toContain("react.component.props");
20
+ expect(result).toContain("react.component.structure.folder");
21
+ expect(result).toContain("react.component.barrel_exports");
22
+ expect(result).not.toContain("react.hooks.naming");
23
+ expect(result).toHaveLength(3);
24
+ });
25
+
26
+ it("T5-2: expands css.* to all CSS entries", () => {
27
+ const result = expandGlob("css.*", standardNames);
28
+ expect(result).toHaveLength(3);
29
+ expect(result).toContain("css.selectors.namespace");
30
+ });
31
+
32
+ it("T5-3: expands prefix wildcard against plain names", () => {
33
+ const names = ["NavigationBar", "NavigationMenu", "Button"];
34
+ expect(expandGlob("Nav*", names)).toEqual([
35
+ "NavigationBar",
36
+ "NavigationMenu",
37
+ ]);
38
+ });
39
+
40
+ it("T5-4: full wildcard returns all candidates", () => {
41
+ expect(expandGlob("*", ["A", "B", "C"])).toEqual(["A", "B", "C"]);
42
+ });
43
+
44
+ it("T5-5: unmatched glob returns empty", () => {
45
+ expect(expandGlob("nonexistent.*", standardNames)).toEqual([]);
46
+ });
47
+
48
+ it("T5-6: non-glob name passes through unchanged", () => {
49
+ expect(expandGlob("Button", ["Button", "Card"])).toEqual(["Button"]);
50
+ });
51
+
52
+ it("T5-7: trailing dot treated as prefix glob", () => {
53
+ const withDot = expandGlob("react.component.", standardNames);
54
+ const withStar = expandGlob("react.component.*", standardNames);
55
+ expect(withDot).toEqual(withStar);
56
+ });
57
+
58
+ it("T5-8: suffix wildcard matches end of name", () => {
59
+ const result = expandGlob("*.props", [
60
+ "react.component.props",
61
+ "react.component.props.wrapper",
62
+ "css.selectors.namespace",
63
+ ]);
64
+ expect(result).toEqual(["react.component.props"]);
65
+ });
66
+
67
+ it("matches case-insensitively", () => {
68
+ expect(expandGlob("REACT.*", standardNames)).toHaveLength(4);
69
+ });
70
+ });
71
+
72
+ describe("isGlobPattern", () => {
73
+ it("T5-9: detects star pattern", () => {
74
+ expect(isGlobPattern("react.component.*")).toBe(true);
75
+ });
76
+
77
+ it("T5-10: detects prefix star", () => {
78
+ expect(isGlobPattern("Nav*")).toBe(true);
79
+ });
80
+
81
+ it("T5-11: detects trailing dot", () => {
82
+ expect(isGlobPattern("react.component.")).toBe(true);
83
+ });
84
+
85
+ it("T5-12: rejects plain name", () => {
86
+ expect(isGlobPattern("Button")).toBe(false);
87
+ });
88
+
89
+ it("T5-13: rejects empty string", () => {
90
+ expect(isGlobPattern("")).toBe(false);
91
+ });
92
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Expand a glob pattern against a list of known names.
3
+ *
4
+ * Supports:
5
+ * - `*` anywhere: wildcard matching (`react.component.*`, `Nav*`, `*.props`)
6
+ * - Trailing `.`: shorthand for `.*` (`react.component.` = `react.component.*`)
7
+ *
8
+ * Non-glob names are returned unchanged (wrapped in a single-element array)
9
+ * so callers can pass the result directly to the lookup pipeline.
10
+ *
11
+ * @param pattern - User-supplied name, possibly containing glob characters.
12
+ * @param candidates - All known names in the domain.
13
+ * @returns Expanded concrete names, or empty array if no matches.
14
+ */
15
+ export default function expandGlob(
16
+ pattern: string,
17
+ candidates: readonly string[],
18
+ ): string[] {
19
+ if (!isGlobPattern(pattern)) {
20
+ return [pattern];
21
+ }
22
+
23
+ const normalized = pattern.endsWith(".") ? `${pattern}*` : pattern;
24
+ const regex = globToRegex(normalized);
25
+
26
+ return candidates.filter((name) => regex.test(name));
27
+ }
28
+
29
+ /**
30
+ * Check whether a string contains glob meta-characters.
31
+ *
32
+ * A string is a glob pattern if it contains `*` or ends with `.` (trailing dot).
33
+ */
34
+ export function isGlobPattern(name: string): boolean {
35
+ if (name === "") return false;
36
+ return name.includes("*") || name.endsWith(".");
37
+ }
38
+
39
+ function globToRegex(glob: string): RegExp {
40
+ let pattern = "";
41
+ for (const char of glob) {
42
+ if (char === "*") {
43
+ pattern += ".*";
44
+ } else if (".+^${}()|[]\\".includes(char)) {
45
+ pattern += `\\${char}`;
46
+ } else {
47
+ pattern += char;
48
+ }
49
+ }
50
+ return new RegExp(`^${pattern}$`, "i");
51
+ }