reference-resolution.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. /**
  2. * Reference resolution — every `context:` / `subagent:` dep must resolve to a real component.
  3. *
  4. * ─── Why this suite is the important one ────────────────────────────────────────────────
  5. *
  6. * `scripts/registry/validate-registry.sh` prints "All component dependencies are valid!" and
  7. * exits 0 — and did so for months while four references were broken. That was a FALSE GREEN,
  8. * reproduced on disk 2026-07-15, because the validator is blind in two ways the tests below
  9. * pin down structurally:
  10. *
  11. * 1. It reads `registry.json` and nothing else. It never parses the `dependencies:`
  12. * frontmatter that actually ships in the component files, so drift between a registry
  13. * entry and its own file is undetectable. `.opencode/command/add-context.md` was exactly
  14. * that: its registry entry listed the four correct bare ids while its frontmatter listed
  15. * three path-style ids that resolved to nothing.
  16. * 2. It iterates `.components.*[].dependencies` only. `.profiles.*.components` — 209 refs
  17. * across 5 profiles — is never validated, which is how a wildcard expanding to zero
  18. * matches sat in the `advanced` profile unnoticed.
  19. *
  20. * ─── The four dead refs, and their repair ───────────────────────────────────────────────
  21. *
  22. * Four dead references were verified on disk 2026-07-15 and repaired 2026-07-17: the three
  23. * path-style frontmatter refs in `add-context.md` became the bare slugs its registry entry
  24. * always carried, and the `advanced` profile's `context:context-system/*` gained its missing
  25. * `core/` prefix. {@link REPAIRED} records them; the tree now carries ZERO dead references
  26. * and this suite fails on the first new one.
  27. *
  28. * ─── The dead-ref count was 4, not 9 ────────────────────────────────────────────────────
  29. *
  30. * A "9 known dead context import paths" figure circulated in earlier planning notes. It was
  31. * never substantiated: no doc, commit or script produces it, and no scan of this tree ever
  32. * reproduced it. The repo's own docs said three (`01-feature-inventory.md:955`,
  33. * `06-REVIEW.md:309`); the fourth — the profile wildcard — was found while writing this
  34. * suite. `9` is folklore.
  35. */
  36. import { describe, it, expect } from "vitest";
  37. import { readFileSync } from "node:fs";
  38. import {
  39. allReferences,
  40. deadReferences,
  41. format,
  42. frontmatterReferences,
  43. loadRegistry,
  44. profileReferences,
  45. registryComponentReferences,
  46. resolveAll,
  47. type Resolution,
  48. } from "../../support/references.js";
  49. import { importPendingSymbols, repoPath } from "../../support/pending.js";
  50. /**
  51. * The four references that were dead from 2026-07-15 to 2026-07-17, in their repaired form.
  52. *
  53. * Each must now resolve. If one of these goes red, the repair has been reverted or the
  54. * component it points at has been removed — either way that is a deliberate content decision
  55. * and this list must be edited with it, never weakened around.
  56. */
  57. const REPAIRED: readonly { ref: string; source: string; was: string }[] = [
  58. {
  59. ref: "context:mvi",
  60. source: ".opencode/command/add-context.md",
  61. was: "context:core/context-system/standards/mvi.md (path-style, a dead id)",
  62. },
  63. {
  64. ref: "context:frontmatter",
  65. source: ".opencode/command/add-context.md",
  66. was: "context:core/context-system/standards/frontmatter.md (path-style, a dead id)",
  67. },
  68. {
  69. ref: "context:project-intelligence",
  70. source: ".opencode/command/add-context.md",
  71. was: "context:core/standards/project-intelligence.md (path-style, a dead id)",
  72. },
  73. {
  74. ref: "context:core/context-system/*",
  75. source: "registry.json profiles.advanced.components",
  76. was: "context:context-system/* (missing the core/ prefix, a dead wildcard)",
  77. },
  78. ];
  79. function describeAll(resolutions: readonly Resolution[]): string {
  80. return `\n${format(resolutions)}\n`;
  81. }
  82. // ============================================================================
  83. // The facts — the tree is clean, and stays clean
  84. // ============================================================================
  85. describe("dead references in this tree", () => {
  86. it("finds none — the four known dead refs were repaired 2026-07-17", () => {
  87. const dead = deadReferences();
  88. expect(dead, `reference rot has appeared:${describeAll(dead)}`).toEqual([]);
  89. });
  90. it.each(REPAIRED)("still resolves $ref (was $was)", ({ ref, source }) => {
  91. const [resolution] = resolveAll([{ ref, source }]);
  92. expect(
  93. resolution?.status,
  94. `${ref} (authored in ${source}) no longer resolves. If that is deliberate, ` +
  95. `update REPAIRED in this file alongside the content change.`
  96. ).toBe("ok");
  97. });
  98. });
  99. // ============================================================================
  100. // Why the shell validator could not see them — the mechanism, asserted structurally
  101. // ============================================================================
  102. describe("validate-registry.sh blind spots", () => {
  103. it("cannot see frontmatter drift: add-context.md's file and registry entry must agree by hand", () => {
  104. const registry = loadRegistry();
  105. const entry = registry.components.commands?.find((c) => c.id === "add-context");
  106. const onDisk = frontmatterReferences().filter((r) =>
  107. r.source.endsWith("command/add-context.md")
  108. );
  109. // Nothing enforces this agreement but this test: the validator reads only the registry
  110. // side, so if the shipped frontmatter drifts again it is the ONLY thing that goes red.
  111. expect(entry?.dependencies).toEqual([
  112. "subagent:context-organizer",
  113. "context:mvi",
  114. "context:frontmatter",
  115. "context:project-intelligence",
  116. ]);
  117. expect(onDisk.map((r) => r.ref)).toEqual([...(entry?.dependencies ?? [])]);
  118. });
  119. it("never validates profile component lists, where the repaired wildcard lives", () => {
  120. const registry = loadRegistry();
  121. const profileRefs = profileReferences(registry);
  122. const componentRefs = registryComponentReferences(registry);
  123. expect(Object.keys(registry.profiles).sort()).toEqual([
  124. "advanced",
  125. "business",
  126. "developer",
  127. "essential",
  128. "full",
  129. ]);
  130. expect(profileRefs.length).toBeGreaterThan(200);
  131. // The dead spelling was authored ONLY in a profile — no component depended on it — so a
  132. // validator that walks components alone could not reach it by any path. That is how it
  133. // survived unnoticed, and why this suite must keep watching profiles. (The repaired
  134. // spelling also appears as a component dependency — context-organizer's — which is
  135. // precisely why only the profile copy could rot invisibly.)
  136. expect(profileRefs.map((r) => r.ref)).toContain("context:core/context-system/*");
  137. expect(profileRefs.map((r) => r.ref)).not.toContain("context:context-system/*");
  138. expect(componentRefs.map((r) => r.ref)).not.toContain("context:context-system/*");
  139. });
  140. it("wildcard misses are silent: a wrong prefix reports dead-wildcard, not an error", () => {
  141. const registry = loadRegistry();
  142. const [repaired] = resolveAll(
  143. [{ ref: "context:core/context-system/*", source: "control" }],
  144. registry
  145. );
  146. const [misspelt] = resolveAll(
  147. [{ ref: "context:context-system/*", source: "control" }],
  148. registry
  149. );
  150. // The two spellings differ by one path segment. One expands, one silently matches
  151. // nothing — which is what made the miss invisible to eyeballing as well as to the
  152. // validator, and why the resolver must classify it rather than ignore it.
  153. expect(repaired?.status).toBe("ok");
  154. expect(misspelt?.status).toBe("dead-wildcard");
  155. });
  156. it("registry context ids are bare slugs, so a path-style ref is genuinely a different namespace", () => {
  157. const registry = loadRegistry();
  158. const pathish = (registry.components.contexts ?? []).filter(
  159. (c) => c.id.includes("/") || c.id.endsWith(".md")
  160. );
  161. // If this ever becomes non-empty, path-style refs stop being a namespace error and this
  162. // whole diagnosis needs revisiting.
  163. expect(
  164. pathish.map((c) => c.id),
  165. "no registry context id may contain '/' or end in '.md'"
  166. ).toEqual([]);
  167. // The three former add-context targets exist on disk under their bare ids — the files
  168. // were always fine; only the refs were wrong.
  169. for (const id of ["mvi", "frontmatter", "project-intelligence"]) {
  170. const component = (registry.components.contexts ?? []).find((c) => c.id === id);
  171. expect(component, `registry should carry the bare context id "${id}"`).toBeDefined();
  172. expect(() => readFileSync(repoPath(component!.path), "utf-8")).not.toThrow();
  173. }
  174. });
  175. });
  176. // ============================================================================
  177. // Coverage of the reference corpus
  178. // ============================================================================
  179. describe("reference corpus", () => {
  180. it("collects references from all three sources the repo authors", () => {
  181. const registry = loadRegistry();
  182. expect(registryComponentReferences(registry).length).toBeGreaterThan(0);
  183. expect(profileReferences(registry).length).toBeGreaterThan(0);
  184. expect(frontmatterReferences().length).toBeGreaterThan(0);
  185. });
  186. it("resolves every reference in the corpus", () => {
  187. const resolutions = resolveAll(allReferences());
  188. const notOk = resolutions.filter((r) => r.status !== "ok");
  189. expect(resolutions.length).toBeGreaterThan(200);
  190. expect(notOk, `unresolved references:${describeAll(notOk)}`).toEqual([]);
  191. });
  192. });
  193. // ============================================================================
  194. // The shipped resolver (subtask 05) must agree with the oracle
  195. // ============================================================================
  196. const OWED_BY = "subtask 05 (src/core/ReferenceResolver.ts)";
  197. describe("ReferenceResolver (shipped)", () => {
  198. it("exports a resolver", async () => {
  199. await importPendingSymbols(
  200. "src/core/ReferenceResolver.ts",
  201. ["ReferenceResolver"],
  202. OWED_BY,
  203. "the build has a real resolver rather than a test-local oracle"
  204. );
  205. });
  206. it("finds the same zero dead references the oracle finds", async () => {
  207. const { ReferenceResolver } = await importPendingSymbols<{
  208. ReferenceResolver: new (root: string) => {
  209. findDeadReferences(): Promise<{ ref: string; source: string }[]>;
  210. };
  211. }>(
  212. "src/core/ReferenceResolver.ts",
  213. ["ReferenceResolver"],
  214. OWED_BY,
  215. "the shipped resolver agrees with the oracle that the tree is clean — and therefore " +
  216. "catches what validate-registry.sh cannot"
  217. );
  218. const found = await new ReferenceResolver(repoPath()).findDeadReferences();
  219. expect(found).toEqual([]);
  220. });
  221. it("resolves a live reference to the component's real path on disk", async () => {
  222. const { ReferenceResolver } = await importPendingSymbols<{
  223. ReferenceResolver: new (root: string) => {
  224. resolve(ref: string): { ok: boolean; path?: string };
  225. };
  226. }>(
  227. "src/core/ReferenceResolver.ts",
  228. ["ReferenceResolver"],
  229. OWED_BY,
  230. "a good reference resolves to the file that backs it"
  231. );
  232. const result = new ReferenceResolver(repoPath()).resolve("context:mvi");
  233. expect(result.ok).toBe(true);
  234. expect(result.path).toBe(".opencode/context/core/context-system/standards/mvi.md");
  235. });
  236. it("reports a dead reference with its source and a reason, not just a boolean", async () => {
  237. const { ReferenceResolver } = await importPendingSymbols<{
  238. ReferenceResolver: new (root: string) => {
  239. resolve(ref: string): { ok: boolean; reason?: string };
  240. };
  241. }>(
  242. "src/core/ReferenceResolver.ts",
  243. ["ReferenceResolver"],
  244. OWED_BY,
  245. "a dead reference is reported with a diagnostic reason a human can act on"
  246. );
  247. const result = new ReferenceResolver(repoPath()).resolve("context:context-system/*");
  248. expect(result.ok).toBe(false);
  249. expect(result.reason).toMatch(/0 matches|expands to nothing|no .* match/i);
  250. });
  251. });