profile-completeness.test.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. /**
  2. * Profile completeness — every profile's transitive closure must be installable.
  3. *
  4. * A profile is a promise: "install `developer` and you get a working set". That promise is
  5. * only kept if every component the profile names resolves, AND every dependency of those
  6. * components resolves, recursively. A profile that names a live component which depends on a
  7. * dead one is just as broken as a profile that names the dead one directly — the install
  8. * fails at the same point, one level deeper.
  9. *
  10. * `validate-registry.sh` checks NEITHER half of this: it never reads `.profiles.*` at all
  11. * (see reference-resolution.test.ts), which is how a dead `context:context-system/*` sat in
  12. * the `advanced` profile expanding to zero components — until repaired 2026-07-17 to
  13. * `context:core/context-system/*` — while the validator printed 244/244 green. These tests
  14. * are the only thing that would catch the next one.
  15. */
  16. import { describe, it, expect } from "vitest";
  17. import {
  18. format,
  19. loadRegistry,
  20. profileReferences,
  21. resolveAll,
  22. type Reference,
  23. type Registry,
  24. } from "../../support/references.js";
  25. import { importPendingSymbols, repoPath } from "../../support/pending.js";
  26. const PROFILES = ["advanced", "business", "developer", "essential", "full"] as const;
  27. function refsFor(profile: string, registry: Registry): Reference[] {
  28. return profileReferences(registry).filter((reference) =>
  29. reference.source.endsWith(`profiles.${profile}.components`)
  30. );
  31. }
  32. /**
  33. * Expand a profile to its transitive closure of registry ids, following each component's
  34. * declared `dependencies`. Cycles terminate on the `seen` set.
  35. */
  36. function closure(profile: string, registry: Registry): { ids: Set<string>; refs: Reference[] } {
  37. const byRef = new Map<string, { dependencies?: string[] }>();
  38. for (const [category, components] of Object.entries(registry.components)) {
  39. // agents -> agent, contexts -> context, ... and `config` (already singular) stays put.
  40. const type = category.replace(/s$/, "");
  41. for (const component of components) byRef.set(`${type}:${component.id}`, component);
  42. }
  43. const ids = new Set<string>();
  44. const refs: Reference[] = [];
  45. const queue = refsFor(profile, registry);
  46. while (queue.length > 0) {
  47. const reference = queue.shift()!;
  48. if (ids.has(reference.ref)) continue;
  49. ids.add(reference.ref);
  50. refs.push(reference);
  51. for (const dependency of byRef.get(reference.ref)?.dependencies ?? []) {
  52. queue.push({
  53. ref: dependency,
  54. source: `${reference.source} -> ${reference.ref} depends on`,
  55. });
  56. }
  57. }
  58. return { ids, refs };
  59. }
  60. // ============================================================================
  61. // Green today — the profile corpus, and the closure minus the one known hole
  62. // ============================================================================
  63. describe("profiles", () => {
  64. it("registry carries exactly the 5 profiles on disk", () => {
  65. expect(Object.keys(loadRegistry().profiles).sort()).toEqual([...PROFILES].sort());
  66. });
  67. it.each(PROFILES)("%s names at least one component", (profile) => {
  68. expect(refsFor(profile, loadRegistry()).length).toBeGreaterThan(0);
  69. });
  70. it.each(PROFILES)("%s resolves every component it names", (profile) => {
  71. const registry = loadRegistry();
  72. const dead = resolveAll(refsFor(profile, registry), registry).filter(
  73. (resolution) => resolution.status !== "ok"
  74. );
  75. expect(dead, `${profile} names components that do not exist:\n${format(dead)}`).toEqual([]);
  76. });
  77. it.each(PROFILES)("%s has an installable transitive closure", (profile) => {
  78. const registry = loadRegistry();
  79. const { refs } = closure(profile, registry);
  80. const dead = resolveAll(refs, registry).filter((resolution) => resolution.status !== "ok");
  81. expect(
  82. dead,
  83. `${profile}'s closure reaches components that do not exist — installing it would fail:\n${format(dead)}`
  84. ).toEqual([]);
  85. });
  86. it("the closure is strictly larger than the named set for at least one profile", () => {
  87. // Guards the test itself: if dependency-following silently did nothing, every closure
  88. // would equal its profile's own list and these tests would prove much less than they read.
  89. const registry = loadRegistry();
  90. const grew = PROFILES.some(
  91. (profile) => closure(profile, registry).ids.size > refsFor(profile, registry).length
  92. );
  93. expect(grew, "closure() followed no dependencies — it is not testing transitivity").toBe(true);
  94. });
  95. });
  96. // ============================================================================
  97. // RED — the shipped profile loader (subtask 05)
  98. // ============================================================================
  99. const OWED_BY = "subtask 05 (src/core/ProfileLoader.ts)";
  100. describe("ProfileLoader (shipped)", () => {
  101. it("loads all 5 profiles", async () => {
  102. const { ProfileLoader } = await importPendingSymbols<{
  103. ProfileLoader: new (root: string) => { list(): Promise<string[]> };
  104. }>(
  105. "src/core/ProfileLoader.ts",
  106. ["ProfileLoader"],
  107. OWED_BY,
  108. "the build can enumerate the 5 profiles rather than hard-coding them"
  109. );
  110. expect((await new ProfileLoader(repoPath()).list()).sort()).toEqual([...PROFILES].sort());
  111. });
  112. it.each(PROFILES)("reports %s's closure as installable", async (profile) => {
  113. const { ProfileLoader } = await importPendingSymbols<{
  114. ProfileLoader: new (root: string) => {
  115. resolveClosure(profile: string): Promise<{ missing: { ref: string }[] }>;
  116. };
  117. }>(
  118. "src/core/ProfileLoader.ts",
  119. ["ProfileLoader"],
  120. OWED_BY,
  121. `every component in ${profile}'s transitive closure resolves, so installing it works`
  122. );
  123. const { missing } = await new ProfileLoader(repoPath()).resolveClosure(profile);
  124. expect(missing).toEqual([]);
  125. });
  126. });