references.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. /**
  2. * A reference-resolution ORACLE for the tests.
  3. *
  4. * Why a test-local oracle exists at all: `scripts/registry/validate-registry.sh` reports
  5. * "244/244 valid, 0 missing dependencies" and exits 0 — a FALSE GREEN. It is blind in two
  6. * specific ways, both verified on disk (see reference-resolution.test.ts):
  7. *
  8. * 1. It only ever reads `registry.json`. Component `dependencies:` authored in on-disk
  9. * frontmatter are never parsed, so frontmatter that has drifted from its registry
  10. * entry is invisible to it.
  11. * 2. It walks `.components.*[].dependencies` only. `.profiles.*.components` — 209 refs
  12. * across 5 profiles — is never validated at all.
  13. *
  14. * This oracle reads both sources from disk and applies the resolution rule the validator
  15. * itself documents, so the tests can assert what the validator cannot see. Subtask 05 owns
  16. * the SHIPPED resolver (`src/core/ReferenceResolver.ts`); this oracle is what that resolver
  17. * is measured against.
  18. */
  19. import { readFileSync } from "node:fs";
  20. import { relative } from "node:path";
  21. import matter from "gray-matter";
  22. import { listFiles, repoPath } from "./pending.js";
  23. /** Registry category key -> the `type` prefix used in a `type:id` reference. */
  24. const CATEGORY_TO_TYPE: Readonly<Record<string, string>> = {
  25. agents: "agent",
  26. subagents: "subagent",
  27. commands: "command",
  28. tools: "tool",
  29. plugins: "plugin",
  30. skills: "skill",
  31. contexts: "context",
  32. config: "config",
  33. };
  34. const CONTEXT_ROOT = ".opencode/context/";
  35. export type ResolutionStatus =
  36. | "ok"
  37. | "invalid-format"
  38. | "unknown-type"
  39. | "dead-id"
  40. | "dead-wildcard";
  41. export interface RegistryComponent {
  42. id: string;
  43. name: string;
  44. path: string;
  45. dependencies?: string[];
  46. }
  47. export interface Registry {
  48. components: Record<string, RegistryComponent[]>;
  49. profiles: Record<string, { components?: string[] }>;
  50. }
  51. /** Where a reference was authored — reported verbatim in failure messages. */
  52. export interface Reference {
  53. /** The raw `type:id` string as authored. */
  54. ref: string;
  55. /** Human-readable origin, e.g. `.opencode/command/add-context.md` or `registry.json profiles.advanced`. */
  56. source: string;
  57. }
  58. export interface Resolution extends Reference {
  59. status: ResolutionStatus;
  60. /** Why it failed, in one sentence. Empty for `ok`. */
  61. reason: string;
  62. }
  63. export function loadRegistry(): Registry {
  64. return JSON.parse(readFileSync(repoPath("registry.json"), "utf-8")) as Registry;
  65. }
  66. interface Index {
  67. idsByType: Map<string, Set<string>>;
  68. contextPaths: Set<string>;
  69. }
  70. function index(registry: Registry): Index {
  71. const idsByType = new Map<string, Set<string>>();
  72. const contextPaths = new Set<string>();
  73. for (const [category, components] of Object.entries(registry.components)) {
  74. const type = CATEGORY_TO_TYPE[category];
  75. if (type === undefined) continue;
  76. const ids = idsByType.get(type) ?? new Set<string>();
  77. for (const component of components) {
  78. ids.add(component.id);
  79. if (type === "context") contextPaths.add(component.path);
  80. }
  81. idsByType.set(type, ids);
  82. }
  83. return { idsByType, contextPaths };
  84. }
  85. /**
  86. * Resolve one `type:id` reference against the registry.
  87. *
  88. * The rule is the one `validate-registry.sh` documents for itself:
  89. * - exact registry id match, OR
  90. * - for `context:`, the path form `.opencode/context/<id>.md`, OR
  91. * - for a trailing `*`, at least one registry context path under `.opencode/context/<prefix>`.
  92. *
  93. * Note what is deliberately NOT tolerated: an id that already ends in `.md`. Registry ids are
  94. * bare slugs — zero of the registry's context ids contain `/` or end in `.md` (asserted in
  95. * reference-resolution.test.ts). Accepting `context:core/standards/mvi.md` as a path would
  96. * invent a second, undeclared namespace and paper over exactly the drift these tests exist
  97. * to catch.
  98. */
  99. export function resolveReference(ref: string, registryIndex: Index): Resolution {
  100. const base: Reference = { ref, source: "" };
  101. const separator = ref.indexOf(":");
  102. if (separator <= 0 || separator === ref.length - 1) {
  103. return {
  104. ...base,
  105. status: "invalid-format",
  106. reason: `"${ref}" is not of the form "<type>:<id>"`,
  107. };
  108. }
  109. const type = ref.slice(0, separator);
  110. const id = ref.slice(separator + 1);
  111. const ids = registryIndex.idsByType.get(type);
  112. if (ids === undefined) {
  113. return {
  114. ...base,
  115. status: "unknown-type",
  116. reason: `"${type}" is not a registry component type (known: ${[...registryIndex.idsByType.keys()].sort().join(", ")})`,
  117. };
  118. }
  119. if (id.includes("*")) {
  120. const prefix = id.slice(0, id.indexOf("*"));
  121. const matches = [...registryIndex.contextPaths].filter((path) =>
  122. path.startsWith(CONTEXT_ROOT + prefix)
  123. );
  124. return matches.length > 0
  125. ? { ...base, status: "ok", reason: "" }
  126. : {
  127. ...base,
  128. status: "dead-wildcard",
  129. reason: `wildcard "${ref}" expands to 0 components — no registry context path starts with "${CONTEXT_ROOT}${prefix}"`,
  130. };
  131. }
  132. if (ids.has(id)) return { ...base, status: "ok", reason: "" };
  133. if (type === "context" && registryIndex.contextPaths.has(`${CONTEXT_ROOT}${id}.md`)) {
  134. return { ...base, status: "ok", reason: "" };
  135. }
  136. return {
  137. ...base,
  138. status: "dead-id",
  139. reason: `no registry component has id "${id}" of type "${type}"`,
  140. };
  141. }
  142. /** Every reference authored in `registry.json` component `dependencies` arrays. */
  143. export function registryComponentReferences(registry: Registry): Reference[] {
  144. return Object.entries(registry.components).flatMap(([category, components]) =>
  145. components.flatMap((component) =>
  146. (component.dependencies ?? []).map((ref) => ({
  147. ref,
  148. source: `registry.json components.${category}[id=${component.id}].dependencies`,
  149. }))
  150. )
  151. );
  152. }
  153. /**
  154. * Every reference authored in `registry.json` profile component lists.
  155. * `validate-registry.sh` never looks here.
  156. */
  157. export function profileReferences(registry: Registry): Reference[] {
  158. return Object.entries(registry.profiles).flatMap(([name, profile]) =>
  159. (profile.components ?? []).map((ref) => ({
  160. ref,
  161. source: `registry.json profiles.${name}.components`,
  162. }))
  163. );
  164. }
  165. /**
  166. * Every reference authored in on-disk `dependencies:` frontmatter under `.opencode/`.
  167. * `validate-registry.sh` never looks here either — this is where 3 of the 4 dead refs live.
  168. */
  169. export function frontmatterReferences(): Reference[] {
  170. return listFiles(repoPath(".opencode"), ".md").flatMap((file) => {
  171. const source = relative(repoPath(), file);
  172. let data: Record<string, unknown>;
  173. try {
  174. ({ data } = matter(readFileSync(file, "utf-8")));
  175. } catch {
  176. // A file whose frontmatter does not parse is a different defect, owned by the schema
  177. // suite. Skipping it here keeps this collector about reference rot only.
  178. return [];
  179. }
  180. const dependencies = data.dependencies;
  181. if (!Array.isArray(dependencies)) return [];
  182. return dependencies
  183. .filter((ref): ref is string => typeof ref === "string")
  184. .map((ref) => ({ ref, source }));
  185. });
  186. }
  187. /** Every reference the repo authors, from all three sources. */
  188. export function allReferences(registry: Registry = loadRegistry()): Reference[] {
  189. return [
  190. ...registryComponentReferences(registry),
  191. ...profileReferences(registry),
  192. ...frontmatterReferences(),
  193. ];
  194. }
  195. /** Resolve a batch of references, preserving their source attribution. */
  196. export function resolveAll(references: Reference[], registry: Registry = loadRegistry()): Resolution[] {
  197. const registryIndex = index(registry);
  198. return references.map((reference) => ({
  199. ...resolveReference(reference.ref, registryIndex),
  200. source: reference.source,
  201. }));
  202. }
  203. /** Just the broken ones. */
  204. export function deadReferences(registry: Registry = loadRegistry()): Resolution[] {
  205. return resolveAll(allReferences(registry), registry).filter(
  206. (resolution) => resolution.status !== "ok"
  207. );
  208. }
  209. /** Render resolutions as one greppable line each, for failure messages. */
  210. export function format(resolutions: readonly Resolution[]): string {
  211. return resolutions.length === 0
  212. ? " (none)"
  213. : resolutions.map((r) => ` ${r.source}\n ${r.ref} -> ${r.status}: ${r.reason}`).join("\n");
  214. }