reference-resolution.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  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` currently prints:
  7. *
  8. * Total paths checked: 244
  9. * Valid paths: 244
  10. * Missing paths: 0
  11. * Missing dependencies: 0
  12. * ✓ All component dependencies are valid!
  13. *
  14. * and exits 0. That is a FALSE GREEN, reproduced on disk 2026-07-15. Four references are
  15. * broken right now and the validator reports none of them, because it is blind in two ways
  16. * that the tests below pin down structurally:
  17. *
  18. * 1. It reads `registry.json` and nothing else. It never parses the `dependencies:`
  19. * frontmatter that actually ships in the component files, so drift between a registry
  20. * entry and its own file is undetectable. `.opencode/command/add-context.md` is exactly
  21. * that: its registry entry lists the four correct bare ids while its frontmatter lists
  22. * three path-style ids that resolve to nothing.
  23. * 2. It iterates `.components.*[].dependencies` only. `.profiles.*.components` — 209 refs
  24. * across 5 profiles — is never validated, which is how a wildcard expanding to zero
  25. * matches sits in the `advanced` profile unnoticed.
  26. *
  27. * ─── The dead-ref count is 4, not 9 ─────────────────────────────────────────────────────
  28. *
  29. * A "9 known dead context import paths" figure circulated in earlier planning notes. It was
  30. * never substantiated: no doc, commit or script produces it, and no scan of this tree
  31. * reproduces it. The repo's own docs say three
  32. * (`01-feature-inventory.md:955`, `06-REVIEW.md:309`); a fourth — the profile wildcard —
  33. * was found while writing this suite and is recorded in `task.json` notes. The oracle in
  34. * `tests/support/references.ts` finds exactly these 4 and no others. `9` is treated here as
  35. * folklore and is asserted against, so it cannot quietly return.
  36. */
  37. import { describe, it, expect } from "vitest";
  38. import { readFileSync } from "node:fs";
  39. import {
  40. allReferences,
  41. deadReferences,
  42. format,
  43. frontmatterReferences,
  44. loadRegistry,
  45. profileReferences,
  46. registryComponentReferences,
  47. resolveAll,
  48. type Resolution,
  49. } from "../../support/references.js";
  50. import { importPendingSymbols, repoPath } from "../../support/pending.js";
  51. /**
  52. * The four dead references in this tree, verified on disk 2026-07-15.
  53. *
  54. * This is a snapshot of a KNOWN BUG, not an invariant to preserve. When a subtask repairs
  55. * one of these refs, the "is still dead" test below turns red — that is correct and
  56. * intended: fixing a dead ref must be a deliberate edit here, never a silent drift.
  57. */
  58. const KNOWN_DEAD: readonly { ref: string; source: string; why: string }[] = [
  59. {
  60. ref: "context:core/context-system/standards/mvi.md",
  61. source: ".opencode/command/add-context.md",
  62. why: "path-style-with-.md, but the registry id is the bare slug `mvi`",
  63. },
  64. {
  65. ref: "context:core/context-system/standards/frontmatter.md",
  66. source: ".opencode/command/add-context.md",
  67. why: "path-style-with-.md, but the registry id is the bare slug `frontmatter`",
  68. },
  69. {
  70. ref: "context:core/standards/project-intelligence.md",
  71. source: ".opencode/command/add-context.md",
  72. why: "path-style-with-.md, but the registry id is the bare slug `project-intelligence`",
  73. },
  74. {
  75. ref: "context:context-system/*",
  76. source: "registry.json profiles.advanced.components",
  77. why: "expands to 0 matches — the real directory is .opencode/context/core/context-system/",
  78. },
  79. ];
  80. /** The count that folklore claims. Asserted against so it cannot creep back in. */
  81. const UNSUBSTANTIATED_COUNT = 9;
  82. function describeAll(resolutions: readonly Resolution[]): string {
  83. return `\n${format(resolutions)}\n`;
  84. }
  85. // ============================================================================
  86. // The facts — green today, and they lock the ground truth
  87. // ============================================================================
  88. describe("dead references in this tree", () => {
  89. it("finds exactly 4 dead references — not the unsubstantiated 9", () => {
  90. const dead = deadReferences();
  91. expect(dead.length, `dead references found:${describeAll(dead)}`).toBe(KNOWN_DEAD.length);
  92. expect(dead.length).not.toBe(UNSUBSTANTIATED_COUNT);
  93. });
  94. it.each(KNOWN_DEAD)("still reports $ref as dead ($why)", ({ ref, source }) => {
  95. const dead = deadReferences();
  96. const match = dead.find((d) => d.ref === ref && d.source.includes(source));
  97. expect(
  98. match,
  99. `expected ${ref} (authored in ${source}) to resolve to nothing.\n` +
  100. `If a subtask has repaired it, delete its entry from KNOWN_DEAD in this file — ` +
  101. `do not weaken the resolver.\nCurrently dead:${describeAll(dead)}`
  102. ).toBeDefined();
  103. });
  104. it("reports no dead references beyond the 4 known ones", () => {
  105. const unexpected = deadReferences().filter(
  106. (dead) => !KNOWN_DEAD.some((known) => known.ref === dead.ref)
  107. );
  108. expect(
  109. unexpected,
  110. `new reference rot has appeared since 2026-07-15:${describeAll(unexpected)}`
  111. ).toEqual([]);
  112. });
  113. it("classifies the three add-context refs as dead ids and the profile ref as a dead wildcard", () => {
  114. const dead = deadReferences();
  115. expect(dead.filter((d) => d.status === "dead-id").length).toBe(3);
  116. expect(dead.filter((d) => d.status === "dead-wildcard").length).toBe(1);
  117. });
  118. });
  119. // ============================================================================
  120. // Why the shell validator cannot see them — the mechanism, asserted structurally
  121. // ============================================================================
  122. describe("validate-registry.sh blind spots", () => {
  123. it("cannot see frontmatter drift: add-context.md's file and registry entry disagree", () => {
  124. const registry = loadRegistry();
  125. const entry = registry.components.commands?.find((c) => c.id === "add-context");
  126. const onDisk = frontmatterReferences().filter((r) =>
  127. r.source.endsWith("command/add-context.md")
  128. );
  129. // The registry entry is correct — which is precisely why a registry-only validator is happy.
  130. expect(entry?.dependencies).toEqual([
  131. "subagent:context-organizer",
  132. "context:mvi",
  133. "context:frontmatter",
  134. "context:project-intelligence",
  135. ]);
  136. // The file that actually ships says something else, and three of its refs resolve to nothing.
  137. expect(onDisk.map((r) => r.ref)).toEqual([
  138. "subagent:context-organizer",
  139. "context:core/context-system/standards/mvi.md",
  140. "context:core/context-system/standards/frontmatter.md",
  141. "context:core/standards/project-intelligence.md",
  142. ]);
  143. expect(
  144. resolveAll(onDisk).filter((r) => r.status !== "ok").length,
  145. "the shipped frontmatter should contain 3 dead refs the registry entry hides"
  146. ).toBe(3);
  147. });
  148. it("never validates profile component lists, where the dead wildcard lives", () => {
  149. const registry = loadRegistry();
  150. const profileRefs = profileReferences(registry);
  151. const componentRefs = registryComponentReferences(registry);
  152. expect(Object.keys(registry.profiles).sort()).toEqual([
  153. "advanced",
  154. "business",
  155. "developer",
  156. "essential",
  157. "full",
  158. ]);
  159. expect(profileRefs.length).toBeGreaterThan(200);
  160. // The dead wildcard is authored ONLY in a profile — no component depends on it — so a
  161. // validator that walks components alone cannot reach it by any path.
  162. expect(profileRefs.map((r) => r.ref)).toContain("context:context-system/*");
  163. expect(componentRefs.map((r) => r.ref)).not.toContain("context:context-system/*");
  164. });
  165. it("wildcard misses are silent: the sibling context:openagents-repo/* does resolve", () => {
  166. const registry = loadRegistry();
  167. const [openagentsRepo] = resolveAll(
  168. [{ ref: "context:openagents-repo/*", source: "control" }],
  169. registry
  170. );
  171. const [contextSystem] = resolveAll(
  172. [{ ref: "context:context-system/*", source: "control" }],
  173. registry
  174. );
  175. // Both are authored in the same profile list, one line apart. Only one expands. That is
  176. // what makes the miss invisible to eyeballing as well as to the validator.
  177. expect(openagentsRepo?.status).toBe("ok");
  178. expect(contextSystem?.status).toBe("dead-wildcard");
  179. });
  180. it("registry context ids are bare slugs, so a path-style ref is genuinely a different namespace", () => {
  181. const registry = loadRegistry();
  182. const pathish = (registry.components.contexts ?? []).filter(
  183. (c) => c.id.includes("/") || c.id.endsWith(".md")
  184. );
  185. // If this ever becomes non-empty, path-style refs stop being a namespace error and this
  186. // whole diagnosis needs revisiting.
  187. expect(
  188. pathish.map((c) => c.id),
  189. "no registry context id may contain '/' or end in '.md'"
  190. ).toEqual([]);
  191. // The three add-context targets exist on disk — the files are fine, the refs are not.
  192. for (const id of ["mvi", "frontmatter", "project-intelligence"]) {
  193. const component = (registry.components.contexts ?? []).find((c) => c.id === id);
  194. expect(component, `registry should carry the bare context id "${id}"`).toBeDefined();
  195. expect(() => readFileSync(repoPath(component!.path), "utf-8")).not.toThrow();
  196. }
  197. });
  198. });
  199. // ============================================================================
  200. // Coverage of the reference corpus
  201. // ============================================================================
  202. describe("reference corpus", () => {
  203. it("collects references from all three sources the repo authors", () => {
  204. const registry = loadRegistry();
  205. expect(registryComponentReferences(registry).length).toBeGreaterThan(0);
  206. expect(profileReferences(registry).length).toBeGreaterThan(0);
  207. expect(frontmatterReferences().length).toBeGreaterThan(0);
  208. });
  209. it("resolves the overwhelming majority of references, so the 4 stand out", () => {
  210. const resolutions = resolveAll(allReferences());
  211. const ok = resolutions.filter((r) => r.status === "ok");
  212. expect(resolutions.length).toBeGreaterThan(200);
  213. expect(ok.length).toBe(resolutions.length - KNOWN_DEAD.length);
  214. });
  215. });
  216. // ============================================================================
  217. // RED — the shipped resolver (subtask 05) must agree with the oracle
  218. // ============================================================================
  219. const OWED_BY = "subtask 05 (src/core/ReferenceResolver.ts)";
  220. describe("ReferenceResolver (shipped)", () => {
  221. it("exports a resolver", async () => {
  222. await importPendingSymbols(
  223. "src/core/ReferenceResolver.ts",
  224. ["ReferenceResolver"],
  225. OWED_BY,
  226. "the build has a real resolver rather than a test-local oracle"
  227. );
  228. });
  229. it("finds the same 4 dead references the oracle finds", async () => {
  230. const { ReferenceResolver } = await importPendingSymbols<{
  231. ReferenceResolver: new (root: string) => {
  232. findDeadReferences(): Promise<{ ref: string; source: string }[]>;
  233. };
  234. }>(
  235. "src/core/ReferenceResolver.ts",
  236. ["ReferenceResolver"],
  237. OWED_BY,
  238. "the shipped resolver finds exactly the 4 dead refs the oracle finds — and therefore " +
  239. "catches what validate-registry.sh reports as 244/244 green"
  240. );
  241. const found = await new ReferenceResolver(repoPath()).findDeadReferences();
  242. expect(found.map((f) => f.ref).sort()).toEqual(KNOWN_DEAD.map((k) => k.ref).sort());
  243. });
  244. it("resolves a live reference to the component's real path on disk", async () => {
  245. const { ReferenceResolver } = await importPendingSymbols<{
  246. ReferenceResolver: new (root: string) => {
  247. resolve(ref: string): { ok: boolean; path?: string };
  248. };
  249. }>(
  250. "src/core/ReferenceResolver.ts",
  251. ["ReferenceResolver"],
  252. OWED_BY,
  253. "a good reference resolves to the file that backs it"
  254. );
  255. const result = new ReferenceResolver(repoPath()).resolve("context:mvi");
  256. expect(result.ok).toBe(true);
  257. expect(result.path).toBe(".opencode/context/core/context-system/standards/mvi.md");
  258. });
  259. it("reports a dead reference with its source and a reason, not just a boolean", async () => {
  260. const { ReferenceResolver } = await importPendingSymbols<{
  261. ReferenceResolver: new (root: string) => {
  262. resolve(ref: string): { ok: boolean; reason?: string };
  263. };
  264. }>(
  265. "src/core/ReferenceResolver.ts",
  266. ["ReferenceResolver"],
  267. OWED_BY,
  268. "a dead reference is reported with a diagnostic reason a human can act on"
  269. );
  270. const result = new ReferenceResolver(repoPath()).resolve("context:context-system/*");
  271. expect(result.ok).toBe(false);
  272. expect(result.reason).toMatch(/0 matches|expands to nothing|no .* match/i);
  273. });
  274. });