@nomos-arc/arc 0.1.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 (160) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/.nomos-config.json +5 -0
  3. package/CLAUDE.md +108 -0
  4. package/LICENSE +190 -0
  5. package/README.md +569 -0
  6. package/dist/cli.js +21120 -0
  7. package/docs/auth/googel_plan.yaml +1093 -0
  8. package/docs/auth/google_task.md +235 -0
  9. package/docs/auth/hardened_blueprint.yaml +1658 -0
  10. package/docs/auth/red_team_report.yaml +336 -0
  11. package/docs/auth/session_state.yaml +162 -0
  12. package/docs/certificate/cer_enhance_plan.md +605 -0
  13. package/docs/certificate/certificate_report.md +338 -0
  14. package/docs/dev_overview.md +419 -0
  15. package/docs/feature_assessment.md +156 -0
  16. package/docs/how_it_works.md +78 -0
  17. package/docs/infrastructure/map.md +867 -0
  18. package/docs/init/master_plan.md +3581 -0
  19. package/docs/init/red_team_report.md +215 -0
  20. package/docs/init/report_phase_1a.md +304 -0
  21. package/docs/integrity-gate/enhance_drift.md +703 -0
  22. package/docs/integrity-gate/overview.md +108 -0
  23. package/docs/management/manger-task.md +99 -0
  24. package/docs/management/scafffold.md +76 -0
  25. package/docs/map/ATOMIC_BLUEPRINT.md +1349 -0
  26. package/docs/map/RED_TEAM_REPORT.md +159 -0
  27. package/docs/map/map_task.md +147 -0
  28. package/docs/map/semantic_graph_task.md +792 -0
  29. package/docs/map/semantic_master_plan.md +705 -0
  30. package/docs/phase7/TEAM_RED.md +249 -0
  31. package/docs/phase7/plan.md +1682 -0
  32. package/docs/phase7/task.md +275 -0
  33. package/docs/prompts/USAGE.md +312 -0
  34. package/docs/prompts/architect.md +165 -0
  35. package/docs/prompts/executer.md +190 -0
  36. package/docs/prompts/hardener.md +190 -0
  37. package/docs/prompts/red_team.md +146 -0
  38. package/docs/verification/goveranance-overview.md +396 -0
  39. package/docs/verification/governance-overview.md +245 -0
  40. package/docs/verification/verification-arc-ar.md +560 -0
  41. package/docs/verification/verification-architecture.md +560 -0
  42. package/docs/very_next.md +52 -0
  43. package/docs/whitepaper.md +89 -0
  44. package/overview.md +1469 -0
  45. package/package.json +63 -0
  46. package/src/adapters/__tests__/git.test.ts +296 -0
  47. package/src/adapters/__tests__/stdio.test.ts +70 -0
  48. package/src/adapters/git.ts +226 -0
  49. package/src/adapters/pty.ts +159 -0
  50. package/src/adapters/stdio.ts +113 -0
  51. package/src/cli.ts +83 -0
  52. package/src/commands/apply.ts +47 -0
  53. package/src/commands/auth.ts +301 -0
  54. package/src/commands/certificate.ts +89 -0
  55. package/src/commands/discard.ts +24 -0
  56. package/src/commands/drift.ts +116 -0
  57. package/src/commands/index.ts +78 -0
  58. package/src/commands/init.ts +121 -0
  59. package/src/commands/list.ts +75 -0
  60. package/src/commands/map.ts +55 -0
  61. package/src/commands/plan.ts +30 -0
  62. package/src/commands/review.ts +58 -0
  63. package/src/commands/run.ts +63 -0
  64. package/src/commands/search.ts +147 -0
  65. package/src/commands/show.ts +63 -0
  66. package/src/commands/status.ts +59 -0
  67. package/src/core/__tests__/budget.test.ts +213 -0
  68. package/src/core/__tests__/certificate.test.ts +385 -0
  69. package/src/core/__tests__/config.test.ts +191 -0
  70. package/src/core/__tests__/preflight.test.ts +24 -0
  71. package/src/core/__tests__/prompt.test.ts +358 -0
  72. package/src/core/__tests__/review.test.ts +161 -0
  73. package/src/core/__tests__/state.test.ts +362 -0
  74. package/src/core/auth/__tests__/manager.test.ts +166 -0
  75. package/src/core/auth/__tests__/server.test.ts +220 -0
  76. package/src/core/auth/gcp-projects.ts +160 -0
  77. package/src/core/auth/manager.ts +114 -0
  78. package/src/core/auth/server.ts +141 -0
  79. package/src/core/budget.ts +119 -0
  80. package/src/core/certificate.ts +502 -0
  81. package/src/core/config.ts +212 -0
  82. package/src/core/errors.ts +54 -0
  83. package/src/core/factory.ts +49 -0
  84. package/src/core/graph/__tests__/builder.test.ts +272 -0
  85. package/src/core/graph/__tests__/contract-writer.test.ts +175 -0
  86. package/src/core/graph/__tests__/enricher.test.ts +299 -0
  87. package/src/core/graph/__tests__/parser.test.ts +200 -0
  88. package/src/core/graph/__tests__/pipeline.test.ts +202 -0
  89. package/src/core/graph/__tests__/renderer.test.ts +128 -0
  90. package/src/core/graph/__tests__/resolver.test.ts +185 -0
  91. package/src/core/graph/__tests__/scanner.test.ts +231 -0
  92. package/src/core/graph/__tests__/show.test.ts +134 -0
  93. package/src/core/graph/builder.ts +303 -0
  94. package/src/core/graph/constraints.ts +94 -0
  95. package/src/core/graph/contract-writer.ts +93 -0
  96. package/src/core/graph/drift/__tests__/classifier.test.ts +215 -0
  97. package/src/core/graph/drift/__tests__/comparator.test.ts +335 -0
  98. package/src/core/graph/drift/__tests__/drift.test.ts +453 -0
  99. package/src/core/graph/drift/__tests__/reporter.test.ts +203 -0
  100. package/src/core/graph/drift/classifier.ts +165 -0
  101. package/src/core/graph/drift/comparator.ts +205 -0
  102. package/src/core/graph/drift/reporter.ts +77 -0
  103. package/src/core/graph/enricher.ts +251 -0
  104. package/src/core/graph/grammar-paths.ts +30 -0
  105. package/src/core/graph/html-template.ts +493 -0
  106. package/src/core/graph/map-schema.ts +137 -0
  107. package/src/core/graph/parser.ts +336 -0
  108. package/src/core/graph/pipeline.ts +209 -0
  109. package/src/core/graph/renderer.ts +92 -0
  110. package/src/core/graph/resolver.ts +195 -0
  111. package/src/core/graph/scanner.ts +145 -0
  112. package/src/core/logger.ts +46 -0
  113. package/src/core/orchestrator.ts +792 -0
  114. package/src/core/plan-file-manager.ts +66 -0
  115. package/src/core/preflight.ts +64 -0
  116. package/src/core/prompt.ts +173 -0
  117. package/src/core/review.ts +95 -0
  118. package/src/core/state.ts +294 -0
  119. package/src/core/worktree-coordinator.ts +77 -0
  120. package/src/search/__tests__/chunk-extractor.test.ts +339 -0
  121. package/src/search/__tests__/embedder-auth.test.ts +124 -0
  122. package/src/search/__tests__/embedder.test.ts +267 -0
  123. package/src/search/__tests__/graph-enricher.test.ts +178 -0
  124. package/src/search/__tests__/indexer.test.ts +518 -0
  125. package/src/search/__tests__/integration.test.ts +649 -0
  126. package/src/search/__tests__/query-engine.test.ts +334 -0
  127. package/src/search/__tests__/similarity.test.ts +78 -0
  128. package/src/search/__tests__/vector-store.test.ts +281 -0
  129. package/src/search/chunk-extractor.ts +167 -0
  130. package/src/search/embedder.ts +209 -0
  131. package/src/search/graph-enricher.ts +95 -0
  132. package/src/search/indexer.ts +483 -0
  133. package/src/search/lexical-searcher.ts +190 -0
  134. package/src/search/query-engine.ts +225 -0
  135. package/src/search/vector-store.ts +311 -0
  136. package/src/types/index.ts +572 -0
  137. package/src/utils/__tests__/ansi.test.ts +54 -0
  138. package/src/utils/__tests__/frontmatter.test.ts +79 -0
  139. package/src/utils/__tests__/sanitize.test.ts +229 -0
  140. package/src/utils/ansi.ts +19 -0
  141. package/src/utils/context.ts +44 -0
  142. package/src/utils/frontmatter.ts +27 -0
  143. package/src/utils/sanitize.ts +78 -0
  144. package/test/e2e/lifecycle.test.ts +330 -0
  145. package/test/fixtures/mock-planner-hang.ts +5 -0
  146. package/test/fixtures/mock-planner.ts +26 -0
  147. package/test/fixtures/mock-reviewer-bad.ts +8 -0
  148. package/test/fixtures/mock-reviewer-retry.ts +34 -0
  149. package/test/fixtures/mock-reviewer.ts +18 -0
  150. package/test/fixtures/sample-project/src/circular-a.ts +6 -0
  151. package/test/fixtures/sample-project/src/circular-b.ts +6 -0
  152. package/test/fixtures/sample-project/src/config.ts +15 -0
  153. package/test/fixtures/sample-project/src/main.ts +19 -0
  154. package/test/fixtures/sample-project/src/services/product-service.ts +20 -0
  155. package/test/fixtures/sample-project/src/services/user-service.ts +18 -0
  156. package/test/fixtures/sample-project/src/types.ts +14 -0
  157. package/test/fixtures/sample-project/src/utils/index.ts +14 -0
  158. package/test/fixtures/sample-project/src/utils/validate.ts +12 -0
  159. package/tsconfig.json +20 -0
  160. package/vitest.config.ts +12 -0
@@ -0,0 +1,267 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { Embedder } from '../embedder.js';
3
+ import { NomosError } from '../../core/errors.js';
4
+ import type { Logger } from 'winston';
5
+ import type { NomosConfig } from '../../types/index.js';
6
+
7
+ // ─── Mocks ────────────────────────────────────────────────────────────────────
8
+ // vi.mock() is hoisted to the top of the file before any variable declarations.
9
+ // vi.hoisted() ensures mock references are created at hoist-time so the factory
10
+ // can close over them.
11
+
12
+ const { mockEmbedContent, mockBatchEmbedContents, mockGetGenerativeModel } = vi.hoisted(() => {
13
+ const mockEmbedContent = vi.fn();
14
+ const mockBatchEmbedContents = vi.fn();
15
+ const mockGetGenerativeModel = vi.fn(() => ({
16
+ embedContent: mockEmbedContent,
17
+ batchEmbedContents: mockBatchEmbedContents,
18
+ }));
19
+ return { mockEmbedContent, mockBatchEmbedContents, mockGetGenerativeModel };
20
+ });
21
+
22
+ vi.mock('@google/generative-ai', () => ({
23
+ // Regular function required: arrow functions cannot be called with `new`
24
+ GoogleGenerativeAI: vi.fn(function () {
25
+ return { getGenerativeModel: mockGetGenerativeModel };
26
+ }),
27
+ }));
28
+
29
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
30
+
31
+ const DIMS = 768;
32
+
33
+ function makeConfig(overrides: Partial<NomosConfig['search']> = {}): NomosConfig['search'] {
34
+ return {
35
+ embedding_model: 'gemini-embedding-001',
36
+ embedding_dimensions: DIMS,
37
+ vector_store_path: '.nomos/vectors',
38
+ default_top_k: 10,
39
+ default_threshold: 0.7,
40
+ batch_size: 3,
41
+ embedding_requests_per_minute: 60,
42
+ request_timeout_ms: 30_000,
43
+ ...overrides,
44
+ };
45
+ }
46
+
47
+ function makeLogger(): Logger {
48
+ return {
49
+ info: vi.fn(),
50
+ warn: vi.fn(),
51
+ error: vi.fn(),
52
+ debug: vi.fn(),
53
+ } as unknown as Logger;
54
+ }
55
+
56
+ function makeVector(dims = DIMS, fill = 0): number[] {
57
+ return Array.from({ length: dims }, (_, i) => fill || i * 0.001);
58
+ }
59
+
60
+ function makeEmbedResponse(dims = DIMS) {
61
+ return { embedding: { values: makeVector(dims) } };
62
+ }
63
+
64
+ function makeBatchResponse(count: number, dims = DIMS) {
65
+ return { embeddings: Array.from({ length: count }, () => ({ values: makeVector(dims) })) };
66
+ }
67
+
68
+ // ─── Tests ────────────────────────────────────────────────────────────────────
69
+
70
+ describe('Embedder', () => {
71
+ let config: NomosConfig['search'];
72
+ let logger: Logger;
73
+
74
+ beforeEach(() => {
75
+ config = makeConfig();
76
+ logger = makeLogger();
77
+ vi.stubEnv('GEMINI_API_KEY', 'test-api-key-12345');
78
+ mockEmbedContent.mockReset();
79
+ mockBatchEmbedContents.mockReset();
80
+ vi.useFakeTimers();
81
+ });
82
+
83
+ afterEach(() => {
84
+ vi.useRealTimers();
85
+ vi.unstubAllEnvs();
86
+ });
87
+
88
+ // ── Test 7: API key missing ──────────────────────────────────────────────────
89
+
90
+ it('throws search_api_key_missing when GEMINI_API_KEY is unset', () => {
91
+ vi.unstubAllEnvs();
92
+ delete process.env['GEMINI_API_KEY'];
93
+
94
+ expect(() => new Embedder(config, logger)).toThrow(
95
+ expect.objectContaining({ code: 'search_api_key_missing' }),
96
+ );
97
+ });
98
+
99
+ // ── Test 1: embedOne() calls Gemini and returns Float32Array ─────────────────
100
+
101
+ it('embedOne() calls Gemini API with correct model and returns Float32Array', async () => {
102
+ mockEmbedContent.mockResolvedValueOnce(makeEmbedResponse());
103
+ const embedder = new Embedder(config, logger);
104
+
105
+ const runPromise = embedder.embedOne('hello world');
106
+ await vi.runAllTimersAsync();
107
+ const result = await runPromise;
108
+
109
+ expect(mockGetGenerativeModel).toHaveBeenCalledWith({
110
+ model: 'gemini-embedding-001',
111
+ }, undefined);
112
+ expect(mockEmbedContent).toHaveBeenCalledTimes(1);
113
+ expect(result).toBeInstanceOf(Float32Array);
114
+ expect(result.length).toBe(DIMS);
115
+ });
116
+
117
+ // ── Test 9: dimensions match config ─────────────────────────────────────────
118
+
119
+ it('dimensions getter returns config.embedding_dimensions and vector matches', async () => {
120
+ mockEmbedContent.mockResolvedValueOnce(makeEmbedResponse(DIMS));
121
+ const embedder = new Embedder(config, logger);
122
+
123
+ expect(embedder.dimensions).toBe(DIMS);
124
+ expect(embedder.dimensions).toBe(config.embedding_dimensions);
125
+
126
+ const runPromise = embedder.embedOne('test');
127
+ await vi.runAllTimersAsync();
128
+ const vector = await runPromise;
129
+ expect(vector.length).toBe(DIMS);
130
+ });
131
+
132
+ // ── Test 2: embedBatch() splits into chunks and processes sequentially ───────
133
+
134
+ it('embedBatch() splits texts into batch_size chunks and processes sequentially', async () => {
135
+ const texts = ['a', 'b', 'c', 'd', 'e']; // batch_size=3 → 2 batches
136
+ // First batch: 3 texts, second batch: 2 texts
137
+ mockBatchEmbedContents
138
+ .mockResolvedValueOnce(makeBatchResponse(3))
139
+ .mockResolvedValueOnce(makeBatchResponse(2));
140
+
141
+ const embedder = new Embedder(config, logger);
142
+ const runPromise = embedder.embedBatch(texts);
143
+ await vi.runAllTimersAsync();
144
+ const results = await runPromise;
145
+
146
+ expect(mockBatchEmbedContents).toHaveBeenCalledTimes(2);
147
+ expect(results).toHaveLength(5);
148
+ results.forEach(v => expect(v).toBeInstanceOf(Float32Array));
149
+ });
150
+
151
+ // ── Test 3: embedBatch() respects rate-limit delay between batches ───────────
152
+
153
+ it('embedBatch() inserts rate-limit delay between batches', async () => {
154
+ const rpm = 60;
155
+ const expectedDelay = Math.ceil(60_000 / rpm); // 1000ms
156
+ const cfg = makeConfig({ embedding_requests_per_minute: rpm });
157
+
158
+ const texts = ['a', 'b', 'c', 'd']; // 2 batches of 3
159
+ mockBatchEmbedContents
160
+ .mockResolvedValueOnce(makeBatchResponse(3))
161
+ .mockResolvedValueOnce(makeBatchResponse(1));
162
+
163
+ const embedder = new Embedder(cfg, logger);
164
+ const runPromise = embedder.embedBatch(texts);
165
+
166
+ // First batch completes immediately; second needs the delay to pass
167
+ await vi.advanceTimersByTimeAsync(expectedDelay + 100);
168
+ await runPromise;
169
+
170
+ expect(mockBatchEmbedContents).toHaveBeenCalledTimes(2);
171
+ });
172
+
173
+ // ── Test 4: rate-limit delay is logged [S-8] ─────────────────────────────────
174
+
175
+ it('embedBatch() logs rate-limit wait message between batches [S-8]', async () => {
176
+ const texts = ['a', 'b', 'c', 'd']; // 2 batches with batch_size=3
177
+ mockBatchEmbedContents
178
+ .mockResolvedValueOnce(makeBatchResponse(3))
179
+ .mockResolvedValueOnce(makeBatchResponse(1));
180
+
181
+ const embedder = new Embedder(config, logger);
182
+ const runPromise = embedder.embedBatch(texts);
183
+ await vi.runAllTimersAsync();
184
+ await runPromise;
185
+
186
+ expect(logger.warn).toHaveBeenCalledWith(
187
+ expect.stringMatching(/\[nomos:search:warn\] Rate limiting\. Waiting \d+ms before batch 2\/2/),
188
+ );
189
+ });
190
+
191
+ // ── Test 10: onBatchComplete callback fires after each batch ─────────────────
192
+
193
+ it('onBatchComplete callback fires after each batch', async () => {
194
+ const texts = ['a', 'b', 'c', 'd', 'e'];
195
+ mockBatchEmbedContents
196
+ .mockResolvedValueOnce(makeBatchResponse(3))
197
+ .mockResolvedValueOnce(makeBatchResponse(2));
198
+
199
+ const onBatchComplete = vi.fn();
200
+ const embedder = new Embedder(config, logger);
201
+ const runPromise = embedder.embedBatch(texts, onBatchComplete);
202
+ await vi.runAllTimersAsync();
203
+ await runPromise;
204
+
205
+ expect(onBatchComplete).toHaveBeenCalledTimes(2);
206
+ expect(onBatchComplete).toHaveBeenNthCalledWith(1, 0, 2);
207
+ expect(onBatchComplete).toHaveBeenNthCalledWith(2, 1, 2);
208
+ });
209
+
210
+ // ── Test 5: retries on 429 with exponential backoff ──────────────────────────
211
+
212
+ it('embedOne() retries on 429 error with exponential backoff', async () => {
213
+ const error429 = new Error('Request failed with status 429');
214
+ mockEmbedContent
215
+ .mockRejectedValueOnce(error429)
216
+ .mockRejectedValueOnce(error429)
217
+ .mockResolvedValueOnce(makeEmbedResponse());
218
+
219
+ const embedder = new Embedder(config, logger);
220
+ const runPromise = embedder.embedOne('hello');
221
+ await vi.runAllTimersAsync();
222
+ const result = await runPromise;
223
+
224
+ expect(mockEmbedContent).toHaveBeenCalledTimes(3);
225
+ expect(result).toBeInstanceOf(Float32Array);
226
+ });
227
+
228
+ // ── Test 6: throws after max retries ─────────────────────────────────────────
229
+
230
+ it('embedOne() throws search_embedding_failed after max retries exceeded', async () => {
231
+ const error429 = new Error('Rate limit 429');
232
+ mockEmbedContent.mockRejectedValue(error429);
233
+
234
+ const embedder = new Embedder(config, logger);
235
+ const runPromise = embedder.embedOne('hello');
236
+ // Attach rejection handler BEFORE advancing timers to prevent
237
+ // "unhandled rejection" warnings from the window between timer fire and handler attach.
238
+ const assertion = expect(runPromise).rejects.toMatchObject({
239
+ code: 'search_embedding_failed',
240
+ });
241
+ await vi.runAllTimersAsync();
242
+ await assertion;
243
+ // 1 initial + 3 retries = 4 calls
244
+ expect(mockEmbedContent).toHaveBeenCalledTimes(4);
245
+ });
246
+
247
+ // ── Test 8: timeout throws search_embedding_failed [GAP-4] ──────────────────
248
+
249
+ it('embedOne() throws search_embedding_failed with "timed out" when API hangs past request_timeout_ms [GAP-4]', async () => {
250
+ const cfg = makeConfig({ request_timeout_ms: 1_000 });
251
+ // Never-resolving promise simulates a hung API
252
+ mockEmbedContent.mockReturnValueOnce(new Promise(() => {}));
253
+
254
+ const embedder = new Embedder(cfg, logger);
255
+ const runPromise = embedder.embedOne('hang');
256
+ // Attach rejection handler BEFORE advancing timers to prevent
257
+ // "unhandled rejection" warnings from the window between timer fire and handler attach.
258
+ const assertion = expect(runPromise).rejects.toMatchObject({
259
+ code: 'search_embedding_failed',
260
+ message: expect.stringMatching(/timed out/),
261
+ });
262
+
263
+ // Advance past the timeout
264
+ await vi.advanceTimersByTimeAsync(cfg.request_timeout_ms + 100);
265
+ await assertion;
266
+ });
267
+ });
@@ -0,0 +1,178 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { GraphEnricher } from '../graph-enricher.js';
3
+ import type { RawSearchResult } from '../graph-enricher.js';
4
+ import type { Logger } from 'winston';
5
+ import type { ProjectMap } from '../../types/index.js';
6
+
7
+ // ─── Mocks ────────────────────────────────────────────────────────────────────
8
+
9
+ vi.mock('node:fs/promises', () => ({
10
+ default: { readFile: vi.fn() },
11
+ }));
12
+
13
+ import fs from 'node:fs/promises';
14
+ const mockReadFile = vi.mocked(fs.readFile);
15
+
16
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
17
+
18
+ function makeLogger(): Logger {
19
+ return {
20
+ info: vi.fn(),
21
+ warn: vi.fn(),
22
+ error: vi.fn(),
23
+ debug: vi.fn(),
24
+ } as unknown as Logger;
25
+ }
26
+
27
+ function makeProjectMap(overrides: Partial<ProjectMap> = {}): ProjectMap {
28
+ return {
29
+ schema_version: 1,
30
+ generated_at: '2026-04-01T00:00:00.000Z',
31
+ root: '/project',
32
+ files: {
33
+ 'src/core.ts': {
34
+ file: 'src/core.ts',
35
+ hash: 'abc123',
36
+ language: 'typescript',
37
+ symbols: [],
38
+ imports: [],
39
+ dependents: ['src/a.ts', 'src/b.ts', 'src/c.ts'],
40
+ dependencies: [],
41
+ depth: 2,
42
+ last_parsed_at: null,
43
+ semantic: null,
44
+ enrichment_status: 'structural',
45
+ },
46
+ 'src/utils.ts': {
47
+ file: 'src/utils.ts',
48
+ hash: 'def456',
49
+ language: 'typescript',
50
+ symbols: [],
51
+ imports: [],
52
+ dependents: ['src/a.ts'],
53
+ dependencies: [],
54
+ depth: 5,
55
+ last_parsed_at: null,
56
+ semantic: null,
57
+ enrichment_status: 'structural',
58
+ },
59
+ },
60
+ stats: {
61
+ total_files: 2,
62
+ total_symbols: 0,
63
+ total_edges: 4,
64
+ core_modules: ['src/core.ts'],
65
+ structural_only: 2,
66
+ semantically_enriched: 0,
67
+ indexed: 0,
68
+ },
69
+ ...overrides,
70
+ };
71
+ }
72
+
73
+ function makeRaw(overrides: Partial<RawSearchResult> = {}): RawSearchResult {
74
+ return {
75
+ id: 'src/core.ts',
76
+ type: 'file',
77
+ file_path: 'src/core.ts',
78
+ symbol_name: null,
79
+ symbol_type: null,
80
+ line_start: null,
81
+ line_end: null,
82
+ purpose: 'Core module',
83
+ similarity_score: 0.9,
84
+ ...overrides,
85
+ };
86
+ }
87
+
88
+ // ─── Tests ────────────────────────────────────────────────────────────────────
89
+
90
+ describe('GraphEnricher', () => {
91
+ let logger: Logger;
92
+ let enricher: GraphEnricher;
93
+
94
+ beforeEach(() => {
95
+ logger = makeLogger();
96
+ enricher = new GraphEnricher('/project/project_map.json', logger);
97
+ vi.clearAllMocks();
98
+ });
99
+
100
+ it('enriches results with correct graph_depth and dependents_count from project map', async () => {
101
+ const projectMap = makeProjectMap();
102
+ mockReadFile.mockResolvedValueOnce(JSON.stringify(projectMap));
103
+
104
+ const results = await enricher.enrich([
105
+ makeRaw({ file_path: 'src/core.ts', similarity_score: 0.9 }),
106
+ makeRaw({ id: 'src/utils.ts', file_path: 'src/utils.ts', similarity_score: 0.8 }),
107
+ ]);
108
+
109
+ const core = results.find((r) => r.file_path === 'src/core.ts')!;
110
+ expect(core.graph_depth).toBe(2);
111
+ expect(core.dependents_count).toBe(3);
112
+ expect(core.is_stale).toBe(false);
113
+
114
+ const utils = results.find((r) => r.file_path === 'src/utils.ts')!;
115
+ expect(utils.graph_depth).toBe(5);
116
+ expect(utils.dependents_count).toBe(1);
117
+ expect(utils.is_stale).toBe(false);
118
+ });
119
+
120
+ it('flags core modules correctly (is_core_module = true)', async () => {
121
+ const projectMap = makeProjectMap();
122
+ mockReadFile.mockResolvedValueOnce(JSON.stringify(projectMap));
123
+
124
+ const results = await enricher.enrich([
125
+ makeRaw({ file_path: 'src/core.ts' }),
126
+ makeRaw({ id: 'src/utils.ts', file_path: 'src/utils.ts' }),
127
+ ]);
128
+
129
+ const core = results.find((r) => r.file_path === 'src/core.ts')!;
130
+ expect(core.is_core_module).toBe(true);
131
+
132
+ const utils = results.find((r) => r.file_path === 'src/utils.ts')!;
133
+ expect(utils.is_core_module).toBe(false);
134
+ });
135
+
136
+ it('marks missing file as is_stale = true with graph_depth = -1 [TRAP-4]', async () => {
137
+ const projectMap = makeProjectMap();
138
+ mockReadFile.mockResolvedValueOnce(JSON.stringify(projectMap));
139
+
140
+ const results = await enricher.enrich([
141
+ makeRaw({ id: 'src/deleted.ts', file_path: 'src/deleted.ts', similarity_score: 0.75 }),
142
+ ]);
143
+
144
+ expect(results).toHaveLength(1);
145
+ expect(results[0]!.is_stale).toBe(true);
146
+ expect(results[0]!.graph_depth).toBe(-1);
147
+ expect(results[0]!.dependents_count).toBe(0);
148
+ expect(results[0]!.is_core_module).toBe(false);
149
+ });
150
+
151
+ it('maintains similarity_score ranking after enrichment', async () => {
152
+ const projectMap = makeProjectMap();
153
+ mockReadFile.mockResolvedValueOnce(JSON.stringify(projectMap));
154
+
155
+ // Input in non-sorted order
156
+ const results = await enricher.enrich([
157
+ makeRaw({ id: 'src/utils.ts', file_path: 'src/utils.ts', similarity_score: 0.7 }),
158
+ makeRaw({ file_path: 'src/core.ts', similarity_score: 0.95 }),
159
+ ]);
160
+
161
+ expect(results[0]!.file_path).toBe('src/core.ts');
162
+ expect(results[0]!.similarity_score).toBe(0.95);
163
+ expect(results[1]!.file_path).toBe('src/utils.ts');
164
+ expect(results[1]!.similarity_score).toBe(0.7);
165
+ });
166
+
167
+ it('calls loadMap only once across multiple enrich() calls [GAP-5]', async () => {
168
+ const projectMap = makeProjectMap();
169
+ mockReadFile.mockResolvedValue(JSON.stringify(projectMap));
170
+
171
+ await enricher.enrich([makeRaw()]);
172
+ await enricher.enrich([makeRaw()]);
173
+ await enricher.enrich([makeRaw()]);
174
+
175
+ // fs.readFile should only be called ONCE — map is cached
176
+ expect(mockReadFile).toHaveBeenCalledTimes(1);
177
+ });
178
+ });