@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,649 @@
1
+ /**
2
+ * Task 7.7.1 — Integration tests: Full pipeline + CI-compatible mock [S-4]
3
+ *
4
+ * Test A — Live API: gated on GEMINI_API_KEY. Exercises real Gemini embeddings.
5
+ * Test B — Mock-embedder: runs in CI always. Exercises full pipeline with
6
+ * deterministic fake vectors — no API key required.
7
+ */
8
+
9
+ import * as os from 'node:os';
10
+ import * as fs from 'node:fs/promises';
11
+ import * as path from 'node:path';
12
+ import * as crypto from 'node:crypto';
13
+ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
14
+ import type { Logger } from 'winston';
15
+ import type { NomosConfig, ProjectMap } from '../../types/index.js';
16
+ import { SearchIndexer } from '../indexer.js';
17
+ import { VectorStore } from '../vector-store.js';
18
+ import { QueryEngine } from '../query-engine.js';
19
+ import { GraphEnricher } from '../graph-enricher.js';
20
+
21
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
22
+
23
+ function makeLogger(): Logger {
24
+ return {
25
+ info: vi.fn(),
26
+ warn: vi.fn(),
27
+ error: vi.fn(),
28
+ debug: vi.fn(),
29
+ } as unknown as Logger;
30
+ }
31
+
32
+ const VECTOR_DIMS = 64; // small but realistic for mock
33
+
34
+ function makeConfig(vectorStorePath: string, graphOutputDir: string): NomosConfig {
35
+ return {
36
+ execution: {
37
+ default_mode: 'supervised',
38
+ shadow_branch_prefix: 'nomos/',
39
+ worktree_base: '/tmp',
40
+ supervised_heartbeat_timeout_ms: 30_000,
41
+ },
42
+ binaries: {
43
+ planner: {
44
+ cmd: 'claude',
45
+ args: [],
46
+ pty: true,
47
+ total_timeout_ms: 60_000,
48
+ heartbeat_timeout_ms: 30_000,
49
+ max_output_bytes: 1_000_000,
50
+ usage_pattern: null,
51
+ },
52
+ reviewer: {
53
+ cmd: 'openai',
54
+ args: [],
55
+ pty: false,
56
+ total_timeout_ms: 60_000,
57
+ heartbeat_timeout_ms: 30_000,
58
+ max_output_bytes: 1_000_000,
59
+ usage_pattern: null,
60
+ },
61
+ },
62
+ convergence: { score_threshold: 8, max_iterations: 3 },
63
+ budget: { max_tokens_per_task: 100_000, warn_at_percent: 80, cost_per_1k_tokens: {} },
64
+ security: {
65
+ sanitize_patterns: [],
66
+ entropy_threshold: 4.5,
67
+ sanitize_on: ['input', 'output'],
68
+ safe_commands: [],
69
+ redaction_label: '[REDACTED]',
70
+ },
71
+ git: { auto_commit: false, include_logs: true, commit_prefix: 'arc:', sign_commits: false },
72
+ review: { max_context_files: 10 },
73
+ graph: {
74
+ exclude_patterns: [],
75
+ ai_enrichment: false,
76
+ ai_model: 'gemini-pro',
77
+ ai_concurrency: 1,
78
+ ai_requests_per_minute: 60,
79
+ max_file_chars: 50_000,
80
+ core_modules_count: 5,
81
+ output_dir: graphOutputDir,
82
+ },
83
+ logging: { level: 'info', retain_days: 7 },
84
+ search: {
85
+ embedding_model: 'gemini-embedding-001',
86
+ embedding_dimensions: VECTOR_DIMS,
87
+ vector_store_path: vectorStorePath,
88
+ default_top_k: 5,
89
+ default_threshold: 0.0, // low threshold so mock results always pass
90
+ batch_size: 5,
91
+ embedding_requests_per_minute: 60,
92
+ request_timeout_ms: 30_000,
93
+ },
94
+ } as unknown as NomosConfig;
95
+ }
96
+
97
+ /** Minimal fixture: 5 files, with symbols and semantic data. */
98
+ function makeFixtureProjectMap(): ProjectMap {
99
+ return {
100
+ schema_version: 1,
101
+ generated_at: new Date().toISOString(),
102
+ root: '/project',
103
+ files: {
104
+ 'src/errors.ts': {
105
+ file: 'src/errors.ts',
106
+ hash: 'hash-errors',
107
+ language: 'typescript',
108
+ symbols: [
109
+ {
110
+ name: 'NomosError',
111
+ kind: 'class',
112
+ line: 1,
113
+ end_line: 30,
114
+ signature: 'class NomosError extends Error',
115
+ exported: true,
116
+ },
117
+ {
118
+ name: 'handleError',
119
+ kind: 'function',
120
+ line: 32,
121
+ end_line: 50,
122
+ signature: 'function handleError(err: unknown): void',
123
+ exported: true,
124
+ },
125
+ ],
126
+ imports: [],
127
+ dependents: ['src/indexer.ts', 'src/query.ts', 'src/retry.ts'],
128
+ dependencies: [],
129
+ depth: 0,
130
+ last_parsed_at: null,
131
+ semantic: {
132
+ overview: 'Custom error classes and error handling utilities',
133
+ purpose: 'Centralised error handling and retry logic for the nomos-arc system',
134
+ key_logic: ['extends Error with typed codes', 'provides retry boundary'],
135
+ usage_context: ['imported by all modules that can fail'],
136
+ source_hash: 'h-errors',
137
+ enriched_at: '2024-01-01T00:00:00.000Z',
138
+ model: 'gemini-pro',
139
+ },
140
+ enrichment_status: 'semantic',
141
+ },
142
+ 'src/indexer.ts': {
143
+ file: 'src/indexer.ts',
144
+ hash: 'hash-indexer',
145
+ language: 'typescript',
146
+ symbols: [
147
+ {
148
+ name: 'SearchIndexer',
149
+ kind: 'class',
150
+ line: 1,
151
+ end_line: 100,
152
+ signature: 'class SearchIndexer',
153
+ exported: true,
154
+ },
155
+ ],
156
+ imports: [
157
+ {
158
+ source: 'src/errors.ts',
159
+ resolved: 'src/errors.ts',
160
+ symbols: ['NomosError'],
161
+ is_external: false,
162
+ },
163
+ ],
164
+ dependents: [],
165
+ dependencies: ['src/errors.ts'],
166
+ depth: 1,
167
+ last_parsed_at: null,
168
+ semantic: {
169
+ overview: 'Coordinates full and incremental vector indexing pipeline',
170
+ purpose: 'Builds and maintains the semantic vector search index',
171
+ key_logic: ['fullIndex with table-swap', 'incrementalIndex with diff'],
172
+ usage_context: ['invoked by arc index command'],
173
+ source_hash: 'h-indexer',
174
+ enriched_at: '2024-01-01T00:00:00.000Z',
175
+ model: 'gemini-pro',
176
+ },
177
+ enrichment_status: 'semantic',
178
+ },
179
+ 'src/config.ts': {
180
+ file: 'src/config.ts',
181
+ hash: 'hash-config',
182
+ language: 'typescript',
183
+ symbols: [
184
+ {
185
+ name: 'loadConfig',
186
+ kind: 'function',
187
+ line: 1,
188
+ end_line: 40,
189
+ signature: 'function loadConfig(root: string): NomosConfig',
190
+ exported: true,
191
+ },
192
+ ],
193
+ imports: [],
194
+ dependents: ['src/indexer.ts', 'src/cli.ts'],
195
+ dependencies: [],
196
+ depth: 0,
197
+ last_parsed_at: null,
198
+ semantic: {
199
+ overview: 'Configuration loading and validation using Zod schemas',
200
+ purpose: 'Load and validate nomos configuration from .nomos-config.json',
201
+ key_logic: ['Zod validation', 'deep defaults merging'],
202
+ usage_context: ['called at CLI startup'],
203
+ source_hash: 'h-config',
204
+ enriched_at: '2024-01-01T00:00:00.000Z',
205
+ model: 'gemini-pro',
206
+ },
207
+ enrichment_status: 'semantic',
208
+ },
209
+ 'src/retry.ts': {
210
+ file: 'src/retry.ts',
211
+ hash: 'hash-retry',
212
+ language: 'typescript',
213
+ symbols: [
214
+ {
215
+ name: 'withRetry',
216
+ kind: 'function',
217
+ line: 1,
218
+ end_line: 25,
219
+ signature: 'function withRetry<T>(fn: () => Promise<T>): Promise<T>',
220
+ exported: true,
221
+ },
222
+ ],
223
+ imports: [
224
+ {
225
+ source: 'src/errors.ts',
226
+ resolved: 'src/errors.ts',
227
+ symbols: ['NomosError'],
228
+ is_external: false,
229
+ },
230
+ ],
231
+ dependents: ['src/indexer.ts'],
232
+ dependencies: ['src/errors.ts'],
233
+ depth: 1,
234
+ last_parsed_at: null,
235
+ semantic: {
236
+ overview: 'Exponential backoff retry utility with jitter',
237
+ purpose: 'Retry transient failures with exponential backoff and error boundary',
238
+ key_logic: ['exponential backoff', 'jitter', 'max retries'],
239
+ usage_context: ['wraps API calls in embedder'],
240
+ source_hash: 'h-retry',
241
+ enriched_at: '2024-01-01T00:00:00.000Z',
242
+ model: 'gemini-pro',
243
+ },
244
+ enrichment_status: 'semantic',
245
+ },
246
+ 'src/query.ts': {
247
+ file: 'src/query.ts',
248
+ hash: 'hash-query',
249
+ language: 'typescript',
250
+ symbols: [
251
+ {
252
+ name: 'QueryEngine',
253
+ kind: 'class',
254
+ line: 1,
255
+ end_line: 80,
256
+ signature: 'class QueryEngine',
257
+ exported: true,
258
+ },
259
+ ],
260
+ imports: [
261
+ {
262
+ source: 'src/errors.ts',
263
+ resolved: 'src/errors.ts',
264
+ symbols: ['NomosError'],
265
+ is_external: false,
266
+ },
267
+ ],
268
+ dependents: [],
269
+ dependencies: ['src/errors.ts'],
270
+ depth: 1,
271
+ last_parsed_at: null,
272
+ semantic: {
273
+ overview: 'Semantic search query execution pipeline',
274
+ purpose: 'Execute semantic search queries against the vector index',
275
+ key_logic: ['embed query', 'vector search', 'graph enrich', 'dedup', 'rank'],
276
+ usage_context: ['invoked by arc search command'],
277
+ source_hash: 'h-query',
278
+ enriched_at: '2024-01-01T00:00:00.000Z',
279
+ model: 'gemini-pro',
280
+ },
281
+ enrichment_status: 'semantic',
282
+ },
283
+ },
284
+ stats: {
285
+ total_files: 5,
286
+ total_symbols: 7,
287
+ total_edges: 5,
288
+ core_modules: ['src/errors.ts', 'src/config.ts'],
289
+ structural_only: 0,
290
+ semantically_enriched: 5,
291
+ indexed: 0,
292
+ },
293
+ };
294
+ }
295
+
296
+ /**
297
+ * MockEmbedder — deterministic vectors without any API calls [S-4].
298
+ * Uses SHA-256 of text → repeated to fill `dims` Float32Array values.
299
+ * Output vectors are L2-normalised so cosine similarity (via dot product) works correctly.
300
+ */
301
+ class MockEmbedder {
302
+ readonly dimensions: number;
303
+
304
+ constructor(dims: number) {
305
+ this.dimensions = dims;
306
+ }
307
+
308
+ async embedOne(text: string): Promise<Float32Array> {
309
+ return this._make(text);
310
+ }
311
+
312
+ async embedBatch(
313
+ texts: string[],
314
+ onBatchComplete?: (batchIndex: number, total: number) => void,
315
+ ): Promise<Float32Array[]> {
316
+ const result = texts.map(t => this._make(t));
317
+ onBatchComplete?.(0, 1);
318
+ return result;
319
+ }
320
+
321
+ private _make(text: string): Float32Array {
322
+ const hash = crypto.createHash('sha256').update(text, 'utf8').digest();
323
+ const v = new Float32Array(this.dimensions);
324
+ for (let i = 0; i < this.dimensions; i++) {
325
+ v[i] = hash[i % hash.byteLength]! / 255.0;
326
+ }
327
+ // L2-normalise
328
+ let norm = 0;
329
+ for (let i = 0; i < v.length; i++) norm += v[i]! * v[i]!;
330
+ norm = Math.sqrt(norm);
331
+ if (norm > 0) {
332
+ for (let i = 0; i < v.length; i++) v[i]! / norm; // eslint-disable-line
333
+ for (let i = 0; i < v.length; i++) v[i] = v[i]! / norm;
334
+ }
335
+ return v;
336
+ }
337
+ }
338
+
339
+ // ─── Test B: Mock-embedder integration (always runs in CI) [S-4] ──────────────
340
+
341
+ describe('mock-embedder integration — full pipeline (CI-compatible)', () => {
342
+ let tmpDir: string;
343
+ let vectorStorePath: string;
344
+ let graphDir: string;
345
+ let config: NomosConfig;
346
+ let logger: Logger;
347
+ let projectRoot: string;
348
+
349
+ beforeAll(async () => {
350
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nomos-integ-mock-'));
351
+ vectorStorePath = path.join(tmpDir, 'vector_index');
352
+ graphDir = path.join(tmpDir, 'graph');
353
+ projectRoot = tmpDir;
354
+ await fs.mkdir(graphDir, { recursive: true });
355
+
356
+ const fixture = makeFixtureProjectMap();
357
+ await fs.writeFile(
358
+ path.join(graphDir, 'project_map.json'),
359
+ JSON.stringify(fixture, null, 2),
360
+ 'utf-8',
361
+ );
362
+
363
+ config = makeConfig(vectorStorePath, graphDir);
364
+ logger = makeLogger();
365
+ });
366
+
367
+ afterAll(async () => {
368
+ await fs.rm(tmpDir, { recursive: true, force: true });
369
+ });
370
+
371
+ it('full index with mock embedder completes end-to-end', async () => {
372
+ const mockEmbedder = new MockEmbedder(VECTOR_DIMS);
373
+
374
+ const indexer = new SearchIndexer(projectRoot, config, logger);
375
+ // @ts-expect-error — inject mock via private field for testing (lazy _embedder)
376
+ indexer['_embedder'] = mockEmbedder;
377
+
378
+ const meta = await indexer.fullIndex();
379
+
380
+ // [BLOCKER-2] status must be "complete"
381
+ expect(meta.status).toBe('complete');
382
+
383
+ // 5 files indexed
384
+ expect(meta.total_files_indexed).toBe(5);
385
+
386
+ // Symbols present → total_chunks > 5
387
+ expect(meta.total_chunks).toBeGreaterThan(5);
388
+
389
+ // [BLOCKER-3] model recorded in metadata
390
+ expect(meta.embedding_model).toBe(config.search.embedding_model);
391
+ expect(meta.vector_dimensions).toBe(config.search.embedding_dimensions);
392
+
393
+ // [GAP-1] No failures
394
+ expect(meta.failed_files.length).toBe(0);
395
+
396
+ // index-meta.json must exist
397
+ const metaPath = path.join(vectorStorePath, 'index-meta.json');
398
+ await expect(fs.access(metaPath)).resolves.toBeUndefined();
399
+
400
+ // Vector store must have records
401
+ const store = new VectorStore(vectorStorePath, logger, VECTOR_DIMS);
402
+ await store.init();
403
+ const count = await store.count();
404
+ expect(count).toBeGreaterThan(0);
405
+ });
406
+
407
+ it('search with mock embedder returns ranked results', async () => {
408
+ const mockEmbedder = new MockEmbedder(VECTOR_DIMS);
409
+
410
+ const engine = new QueryEngine(projectRoot, config, logger);
411
+ // @ts-expect-error — inject mock via lazy private field
412
+ engine['_embedder'] = mockEmbedder;
413
+ // Fix double-join: override enricher and projectMapPath with correct absolute paths
414
+ // @ts-expect-error
415
+ engine['enricher'] = new GraphEnricher(path.join(graphDir, 'project_map.json'), logger);
416
+ // @ts-expect-error
417
+ engine['projectMapPath'] = path.join(graphDir, 'project_map.json');
418
+
419
+ const results = await engine.search('error handling and retry logic');
420
+
421
+ expect(results.length).toBeGreaterThan(0);
422
+
423
+ for (const r of results) {
424
+ // [S-3] similarity ∈ [0, 1]
425
+ expect(r.similarity_score).toBeGreaterThanOrEqual(0);
426
+ expect(r.similarity_score).toBeLessThanOrEqual(1);
427
+
428
+ // [S-5] no vector field
429
+ expect(r).not.toHaveProperty('vector');
430
+
431
+ // dependency graph fields present
432
+ expect(typeof r.file_path).toBe('string');
433
+ expect(typeof r.graph_depth).toBe('number');
434
+ expect(typeof r.dependents_count).toBe('number');
435
+ }
436
+
437
+ // Results sorted descending by similarity_score
438
+ for (let i = 1; i < results.length; i++) {
439
+ expect(results[i - 1]!.similarity_score).toBeGreaterThanOrEqual(
440
+ results[i]!.similarity_score,
441
+ );
442
+ }
443
+ });
444
+
445
+ it('incremental index only re-embeds 1 changed file [BLOCKER-2]', async () => {
446
+ const mockEmbedder = new MockEmbedder(VECTOR_DIMS);
447
+ const embedSpy = vi.spyOn(mockEmbedder, 'embedBatch');
448
+
449
+ // Modify one file's semantic data in the fixture
450
+ const fixturePath = path.join(graphDir, 'project_map.json');
451
+ const raw = await fs.readFile(fixturePath, 'utf-8');
452
+ const fixture = JSON.parse(raw) as ProjectMap;
453
+ fixture.files['src/config.ts']!.hash = 'hash-config-MODIFIED';
454
+ fixture.files['src/config.ts']!.semantic!.purpose = 'Updated config loading purpose';
455
+ fixture.generated_at = new Date().toISOString();
456
+ await fs.writeFile(fixturePath, JSON.stringify(fixture, null, 2), 'utf-8');
457
+
458
+ const indexer = new SearchIndexer(projectRoot, config, logger);
459
+ // @ts-expect-error — inject mock via lazy private field
460
+ indexer['_embedder'] = mockEmbedder;
461
+
462
+ const meta = await indexer.incrementalIndex();
463
+
464
+ expect(meta.status).toBe('complete');
465
+
466
+ // Count total texts embedded across all embedBatch calls
467
+ const totalEmbedded = embedSpy.mock.calls.reduce(
468
+ (sum, call) => sum + (call[0] as string[]).length,
469
+ 0,
470
+ );
471
+
472
+ // Only chunks from 1 file should have been re-embedded
473
+ // src/config.ts has 1 file chunk + 1 symbol chunk = 2 chunks max
474
+ expect(totalEmbedded).toBeLessThanOrEqual(2);
475
+ expect(totalEmbedded).toBeGreaterThan(0);
476
+ });
477
+
478
+ it('[BLOCKER-1] staging table does not exist after full index completion', async () => {
479
+ // Perform a fresh full index in a new temp dir to check staging cleanup
480
+ const freshTmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nomos-staging-'));
481
+ const freshVectorPath = path.join(freshTmpDir, 'vector_index');
482
+ const freshGraphDir = path.join(freshTmpDir, 'graph');
483
+ await fs.mkdir(freshGraphDir, { recursive: true });
484
+
485
+ const fixture = makeFixtureProjectMap();
486
+ await fs.writeFile(
487
+ path.join(freshGraphDir, 'project_map.json'),
488
+ JSON.stringify(fixture, null, 2),
489
+ 'utf-8',
490
+ );
491
+
492
+ const freshConfig = makeConfig(freshVectorPath, freshGraphDir);
493
+ const freshLogger = makeLogger();
494
+ const freshIndexer = new SearchIndexer(freshTmpDir, freshConfig, freshLogger);
495
+ // @ts-expect-error — inject mock via lazy private field
496
+ freshIndexer['_embedder'] = new MockEmbedder(VECTOR_DIMS);
497
+
498
+ await freshIndexer.fullIndex();
499
+
500
+ // Open the store directly and verify staging table is gone
501
+ const store = new VectorStore(freshVectorPath, freshLogger, VECTOR_DIMS);
502
+ await store.init();
503
+
504
+ // promoteStagingToLive drops staging; cleanupStaging is idempotent
505
+ // Verify by calling cleanupStaging — it should find nothing to clean
506
+ const warnFn = freshLogger.warn as ReturnType<typeof vi.fn>;
507
+ const warnCallsBefore = warnFn.mock.calls.length;
508
+ await store.cleanupStaging();
509
+ // No new "Cleaned up orphaned staging table" warning means staging was already gone
510
+ const newWarnCalls = (warnFn.mock.calls as unknown[][]).slice(warnCallsBefore);
511
+ const stagingWarn = newWarnCalls.find(args =>
512
+ String(args[0]).includes('Cleaned up orphaned staging table'),
513
+ );
514
+ expect(stagingWarn).toBeUndefined();
515
+
516
+ await fs.rm(freshTmpDir, { recursive: true, force: true });
517
+ });
518
+
519
+ it('[TRAP-3] de-duplication rule — file-level result removed when symbol within 0.05', async () => {
520
+ // Use a real QueryEngine with mock embedder; the dedup logic is deterministic
521
+ const mockEmbedder = new MockEmbedder(VECTOR_DIMS);
522
+ const engine = new QueryEngine(projectRoot, config, logger);
523
+ // @ts-expect-error — inject mock via lazy private field
524
+ engine['_embedder'] = mockEmbedder;
525
+ // Fix double-join: override enricher and projectMapPath with correct absolute paths
526
+ // @ts-expect-error
527
+ engine['enricher'] = new GraphEnricher(path.join(graphDir, 'project_map.json'), logger);
528
+ // @ts-expect-error
529
+ engine['projectMapPath'] = path.join(graphDir, 'project_map.json');
530
+
531
+ const results = await engine.search('error handling');
532
+
533
+ // For any file that appears in both file-level and symbol-level results,
534
+ // if the gap is <= 0.05, the file-level result should be absent.
535
+ const fileResults = results.filter(r => r.type === 'file');
536
+ const symbolResults = results.filter(r => r.type === 'symbol');
537
+
538
+ for (const fileResult of fileResults) {
539
+ const symbols = symbolResults.filter(s => s.file_path === fileResult.file_path);
540
+ if (symbols.length > 0) {
541
+ const maxSymbolScore = Math.max(...symbols.map(s => s.similarity_score));
542
+ const gap = Math.abs(fileResult.similarity_score - maxSymbolScore);
543
+ // If file-level result IS present, the gap must be > 0.05
544
+ expect(gap).toBeGreaterThan(0.05);
545
+ }
546
+ }
547
+ });
548
+ });
549
+
550
+ // ─── Test A: Live API (requires GEMINI_API_KEY) ───────────────────────────────
551
+
552
+ describe.skipIf(!process.env['GEMINI_API_KEY'])(
553
+ 'live API integration — full pipeline (requires GEMINI_API_KEY)',
554
+ () => {
555
+ let tmpDir: string;
556
+ let vectorStorePath: string;
557
+ let graphDir: string;
558
+ let config: NomosConfig;
559
+ let logger: Logger;
560
+ let projectRoot: string;
561
+
562
+ beforeAll(async () => {
563
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nomos-integ-live-'));
564
+ vectorStorePath = path.join(tmpDir, 'vector_index');
565
+ graphDir = path.join(tmpDir, 'graph');
566
+ projectRoot = tmpDir;
567
+ await fs.mkdir(graphDir, { recursive: true });
568
+
569
+ const fixture = makeFixtureProjectMap();
570
+ await fs.writeFile(
571
+ path.join(graphDir, 'project_map.json'),
572
+ JSON.stringify(fixture, null, 2),
573
+ 'utf-8',
574
+ );
575
+
576
+ config = makeConfig(vectorStorePath, graphDir);
577
+ logger = makeLogger();
578
+ });
579
+
580
+ afterAll(async () => {
581
+ await fs.rm(tmpDir, { recursive: true, force: true });
582
+ });
583
+
584
+ it('full index with real Gemini API completes', async () => {
585
+ const indexer = new SearchIndexer(projectRoot, config, logger);
586
+ const meta = await indexer.fullIndex();
587
+
588
+ // [BLOCKER-2]
589
+ expect(meta.status).toBe('complete');
590
+ expect(meta.total_files_indexed).toBe(5);
591
+ expect(meta.total_chunks).toBeGreaterThan(5);
592
+
593
+ // [BLOCKER-3]
594
+ expect(meta.embedding_model).toBe('gemini-embedding-001');
595
+ expect(meta.vector_dimensions).toBe(config.search.embedding_dimensions);
596
+
597
+ // [GAP-1]
598
+ expect(meta.failed_files.length).toBe(0);
599
+
600
+ // index-meta.json exists
601
+ const metaPath = path.join(vectorStorePath, 'index-meta.json');
602
+ await expect(fs.access(metaPath)).resolves.toBeUndefined();
603
+
604
+ // Vector store has records
605
+ const store = new VectorStore(vectorStorePath, logger, VECTOR_DIMS);
606
+ await store.init();
607
+ expect(await store.count()).toBeGreaterThan(0);
608
+ }, 120_000);
609
+
610
+ it('search returns semantically relevant results', async () => {
611
+ const engine = new QueryEngine(projectRoot, config, logger);
612
+ const results = await engine.search('error handling and retry logic');
613
+
614
+ expect(results.length).toBeGreaterThan(0);
615
+
616
+ for (const r of results) {
617
+ // [S-3] similarity ∈ [0, 1]
618
+ expect(r.similarity_score).toBeGreaterThanOrEqual(0.0);
619
+ expect(r.similarity_score).toBeLessThanOrEqual(1.0);
620
+
621
+ // [S-5] no vector field
622
+ expect(r).not.toHaveProperty('vector');
623
+
624
+ // Valid dependency fields
625
+ expect(typeof r.file_path).toBe('string');
626
+ expect(r.graph_depth).toBeGreaterThanOrEqual(0);
627
+ expect(typeof r.dependents_count).toBe('number');
628
+
629
+ // [TRAP-4] no stale results with depth -1
630
+ expect(r.graph_depth).not.toBe(-1);
631
+ }
632
+ }, 60_000);
633
+
634
+ it('incremental re-index only re-embeds changed file', async () => {
635
+ const fixturePath = path.join(graphDir, 'project_map.json');
636
+ const raw = await fs.readFile(fixturePath, 'utf-8');
637
+ const fixture = JSON.parse(raw) as ProjectMap;
638
+ fixture.files['src/retry.ts']!.hash = 'hash-retry-LIVE-MODIFIED';
639
+ fixture.files['src/retry.ts']!.semantic!.purpose = 'Updated retry purpose for live test';
640
+ fixture.generated_at = new Date().toISOString();
641
+ await fs.writeFile(fixturePath, JSON.stringify(fixture, null, 2), 'utf-8');
642
+
643
+ const indexer = new SearchIndexer(projectRoot, config, logger);
644
+ const meta = await indexer.incrementalIndex();
645
+
646
+ expect(meta.status).toBe('complete');
647
+ }, 60_000);
648
+ },
649
+ );