@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,77 @@
1
+ import type { Logger } from 'winston';
2
+ import type { NomosConfig } from '../types/index.js';
3
+ import { GitAdapter } from '../adapters/git.js';
4
+ import { NomosError } from './errors.js';
5
+
6
+ /**
7
+ * Coordinates the Git worktree lifecycle: creation, validation,
8
+ * recovery, diff extraction, commits, and merges.
9
+ * The Orchestrator delegates ALL git operations to this class.
10
+ */
11
+ export class WorktreeCoordinator {
12
+ constructor(
13
+ private gitAdapter: GitAdapter,
14
+ private config: NomosConfig,
15
+ private logger: Logger,
16
+ ) {}
17
+
18
+ async createWorktree(taskId: string) {
19
+ return this.gitAdapter.createWorktree(taskId);
20
+ }
21
+
22
+ async ensureWorktreeExists(taskId: string, branch: string): Promise<void> {
23
+ if (!this.gitAdapter.worktreeExists(taskId)) {
24
+ try {
25
+ await this.gitAdapter.recoverWorktree(taskId, branch);
26
+ this.logger.info(`Worktree recovered for task "${taskId}".`);
27
+ } catch {
28
+ throw new NomosError('worktree_missing',
29
+ `Worktree for task "${taskId}" is missing and unrecoverable. ` +
30
+ `Run: arc discard ${taskId} && arc init ${taskId}`);
31
+ }
32
+ }
33
+ }
34
+
35
+ async getDiff(taskId: string, baseCommit: string): Promise<string> {
36
+ return this.gitAdapter.getDiff(taskId, baseCommit);
37
+ }
38
+
39
+ async commitPlanFiles(taskId: string, version: number, files: string[]): Promise<void> {
40
+ if (this.config.git.auto_commit) {
41
+ await this.gitAdapter.commitToShadowBranch(
42
+ taskId,
43
+ `${this.config.git.commit_prefix} plan(${taskId}): v${version}`,
44
+ files,
45
+ );
46
+ }
47
+ }
48
+
49
+ async commitReviewFiles(taskId: string, version: number, files: string[]): Promise<void> {
50
+ if (this.config.git.auto_commit) {
51
+ await this.gitAdapter.commitToShadowBranch(
52
+ taskId,
53
+ `${this.config.git.commit_prefix} review(${taskId}): v${version}`,
54
+ files,
55
+ );
56
+ }
57
+ }
58
+
59
+ async mergeToMain(taskId: string, version: number) {
60
+ return this.gitAdapter.mergeToMain(
61
+ taskId, version, this.config.git.commit_prefix,
62
+ );
63
+ }
64
+
65
+ async removeWorktree(taskId: string, force: boolean) {
66
+ return this.gitAdapter.removeWorktree(taskId, force);
67
+ }
68
+
69
+ worktreeExists(taskId: string): boolean {
70
+ return this.gitAdapter.worktreeExists(taskId);
71
+ }
72
+
73
+ /** Search for files matching a pattern. Used by context injection. */
74
+ async grep(pattern: string, cwd: string, timeoutMs?: number): Promise<string[]> {
75
+ return this.gitAdapter.grep(pattern, cwd, timeoutMs);
76
+ }
77
+ }
@@ -0,0 +1,339 @@
1
+ import crypto from 'node:crypto';
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import { ChunkExtractor } from '../chunk-extractor.js';
4
+ import type { Logger } from 'winston';
5
+ import type { FileNode, ProjectMap } from '../../types/index.js';
6
+
7
+ // ─── fs mocks (TRAP-5 verification) ──────────────────────────────────────────
8
+ // ChunkExtractor must NEVER read from the filesystem.
9
+ // We mock node:fs and node:fs/promises at the module level so any accidental
10
+ // call to readFile/readFileSync inside ChunkExtractor will be detectable.
11
+
12
+ const mockReadFileSync = vi.fn();
13
+ const mockReadFile = vi.fn();
14
+
15
+ vi.mock('node:fs', () => ({
16
+ default: { readFileSync: mockReadFileSync },
17
+ readFileSync: mockReadFileSync,
18
+ }));
19
+
20
+ vi.mock('node:fs/promises', () => ({
21
+ default: { readFile: mockReadFile },
22
+ readFile: mockReadFile,
23
+ }));
24
+
25
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
26
+
27
+ function makeLogger(): Logger {
28
+ return {
29
+ warn: vi.fn(),
30
+ info: vi.fn(),
31
+ error: vi.fn(),
32
+ debug: vi.fn(),
33
+ } as unknown as Logger;
34
+ }
35
+
36
+ function makeSemanticNode(overrides: Partial<FileNode> = {}): FileNode {
37
+ return {
38
+ file: 'src/services/payment.ts',
39
+ hash: 'abc123',
40
+ language: 'typescript',
41
+ symbols: [
42
+ {
43
+ name: 'PaymentService',
44
+ kind: 'class',
45
+ line: 10,
46
+ end_line: 80,
47
+ signature: 'class PaymentService',
48
+ exported: true,
49
+ },
50
+ {
51
+ name: 'processPayment',
52
+ kind: 'function',
53
+ line: 20,
54
+ end_line: 40,
55
+ signature: 'processPayment(amount: number): Promise<void>',
56
+ exported: true,
57
+ },
58
+ {
59
+ name: '_internalHelper',
60
+ kind: 'method',
61
+ line: 50,
62
+ end_line: 60,
63
+ signature: '_internalHelper(): void',
64
+ exported: false,
65
+ },
66
+ ],
67
+ imports: [{ source: 'stripe', resolved: null, symbols: ['Stripe'], is_external: true }],
68
+ dependents: ['src/api/checkout.ts'],
69
+ dependencies: ['stripe', 'src/core/logger.ts'],
70
+ depth: 2,
71
+ last_parsed_at: '2024-01-01T00:00:00.000Z',
72
+ semantic: {
73
+ overview: 'Handles payment processing via Stripe.',
74
+ purpose: 'Process and validate customer payments.',
75
+ key_logic: ['Validates card', 'Charges via Stripe API'],
76
+ usage_context: ['Called from checkout flow', 'Used in subscription renewal'],
77
+ source_hash: 'def456',
78
+ enriched_at: '2024-01-01T00:00:00.000Z',
79
+ model: 'gemini-pro',
80
+ },
81
+ enrichment_status: 'semantic',
82
+ ...overrides,
83
+ };
84
+ }
85
+
86
+ function makeNoSemanticNode(): FileNode {
87
+ return {
88
+ file: 'src/utils/helpers.ts',
89
+ hash: 'xyz789',
90
+ language: 'typescript',
91
+ symbols: [
92
+ {
93
+ name: 'formatDate',
94
+ kind: 'function',
95
+ line: 5,
96
+ end_line: 10,
97
+ signature: 'formatDate(d: Date): string',
98
+ exported: true,
99
+ },
100
+ ],
101
+ imports: [{ source: 'date-fns', resolved: null, symbols: ['format'], is_external: true }],
102
+ dependents: [],
103
+ dependencies: ['date-fns'],
104
+ depth: 3,
105
+ last_parsed_at: null,
106
+ semantic: null,
107
+ enrichment_status: 'structural',
108
+ };
109
+ }
110
+
111
+ function makeProjectMap(files: Record<string, FileNode>): ProjectMap {
112
+ return {
113
+ schema_version: 1,
114
+ generated_at: '2024-01-01T00:00:00.000Z',
115
+ root: '/project',
116
+ files,
117
+ stats: {
118
+ total_files: Object.keys(files).length,
119
+ total_symbols: 0,
120
+ total_edges: 0,
121
+ core_modules: [],
122
+ structural_only: 0,
123
+ semantically_enriched: 0,
124
+ indexed: 0,
125
+ },
126
+ };
127
+ }
128
+
129
+ function computeExpectedFileHash(file_path: string, fileNode: FileNode): string {
130
+ const hashInput = JSON.stringify({
131
+ file_path,
132
+ semantic: fileNode.semantic,
133
+ symbols: fileNode.symbols.map(s => ({
134
+ name: s.name,
135
+ kind: s.kind,
136
+ signature: s.signature,
137
+ })),
138
+ dependencies: fileNode.dependencies,
139
+ });
140
+ return crypto.createHash('sha256').update(hashInput).digest('hex');
141
+ }
142
+
143
+ // ─── Tests ────────────────────────────────────────────────────────────────────
144
+
145
+ describe('ChunkExtractor', () => {
146
+ let logger: Logger;
147
+ let extractor: ChunkExtractor;
148
+ const FILE_PATH = 'src/services/payment.ts';
149
+ const NO_SEM_PATH = 'src/utils/helpers.ts';
150
+
151
+ beforeEach(() => {
152
+ logger = makeLogger();
153
+ extractor = new ChunkExtractor('/project', logger);
154
+ });
155
+
156
+ // ─── Test 1: File-level chunk with semantic data ────────────────────────
157
+
158
+ it('produces a file-level chunk with all semantic fields concatenated', () => {
159
+ const fileNode = makeSemanticNode();
160
+ const map = makeProjectMap({ [FILE_PATH]: fileNode });
161
+
162
+ const chunks = extractor.extract(map);
163
+ const fileChunk = chunks.find(c => c.id === FILE_PATH && c.type === 'file');
164
+
165
+ expect(fileChunk).toBeDefined();
166
+ expect(fileChunk!.text).toContain(`File: ${FILE_PATH}`);
167
+ expect(fileChunk!.text).toContain('Purpose: Process and validate customer payments.');
168
+ expect(fileChunk!.text).toContain('Overview: Handles payment processing via Stripe.');
169
+ expect(fileChunk!.text).toContain('Key Logic: Validates card; Charges via Stripe API');
170
+ expect(fileChunk!.text).toContain('Usage Context: Called from checkout flow; Used in subscription renewal');
171
+ expect(fileChunk!.text).toContain('Exports:');
172
+ expect(fileChunk!.text).toContain('Dependencies: stripe, src/core/logger.ts');
173
+ });
174
+
175
+ // ─── Test 2: Fallback chunk without semantic data ───────────────────────
176
+
177
+ it('produces a fallback chunk with file path and symbol names when semantic is null', () => {
178
+ const fileNode = makeNoSemanticNode();
179
+ const map = makeProjectMap({ [NO_SEM_PATH]: fileNode });
180
+
181
+ const chunks = extractor.extract(map);
182
+ const fileChunk = chunks.find(c => c.id === NO_SEM_PATH && c.type === 'file');
183
+
184
+ expect(fileChunk).toBeDefined();
185
+ expect(fileChunk!.text).toContain(`File: ${NO_SEM_PATH}`);
186
+ expect(fileChunk!.text).toContain('Symbols: formatDate');
187
+ expect(logger.warn).toHaveBeenCalledWith(
188
+ expect.stringContaining(NO_SEM_PATH),
189
+ );
190
+ });
191
+
192
+ // ─── Test 3: Exported symbols produce symbol-level chunks ───────────────
193
+
194
+ it('produces symbol-level chunks for exported symbols', () => {
195
+ const fileNode = makeSemanticNode();
196
+ const map = makeProjectMap({ [FILE_PATH]: fileNode });
197
+
198
+ const chunks = extractor.extract(map);
199
+ const symbolChunks = chunks.filter(c => c.type === 'symbol');
200
+
201
+ // PaymentService (class, exported), processPayment (function, exported)
202
+ // _internalHelper (method, NOT exported, NOT class/function) — skipped
203
+ expect(symbolChunks.length).toBeGreaterThanOrEqual(2);
204
+
205
+ const ids = symbolChunks.map(c => c.id);
206
+ expect(ids).toContain(`${FILE_PATH}::PaymentService`);
207
+ expect(ids).toContain(`${FILE_PATH}::processPayment`);
208
+ });
209
+
210
+ // ─── Test 4: Non-exported, non-class, non-function symbols are skipped ──
211
+
212
+ it('skips non-exported symbols that are not class or function kind', () => {
213
+ const fileNode = makeSemanticNode();
214
+ const map = makeProjectMap({ [FILE_PATH]: fileNode });
215
+
216
+ const chunks = extractor.extract(map);
217
+ const ids = chunks.map(c => c.id);
218
+
219
+ // _internalHelper is method + non-exported — must not appear
220
+ expect(ids).not.toContain(`${FILE_PATH}::_internalHelper`);
221
+ });
222
+
223
+ // ─── Test 5: Correct chunk IDs ─────────────────────────────────────────
224
+
225
+ it('assigns correct IDs — file path for file-level, "file::symbol" for symbol-level', () => {
226
+ const fileNode = makeSemanticNode();
227
+ const map = makeProjectMap({ [FILE_PATH]: fileNode });
228
+
229
+ const chunks = extractor.extract(map);
230
+
231
+ const fileChunk = chunks.find(c => c.type === 'file');
232
+ expect(fileChunk!.id).toBe(FILE_PATH);
233
+
234
+ const symbolChunk = chunks.find(c => c.id === `${FILE_PATH}::PaymentService`);
235
+ expect(symbolChunk).toBeDefined();
236
+ });
237
+
238
+ // ─── Test 6: parent_file_id on symbol chunks ────────────────────────────
239
+
240
+ it('sets parent_file_id on symbol chunks to the parent file path', () => {
241
+ const fileNode = makeSemanticNode();
242
+ const map = makeProjectMap({ [FILE_PATH]: fileNode });
243
+
244
+ const chunks = extractor.extract(map);
245
+ const symbolChunks = chunks.filter(c => c.type === 'symbol');
246
+
247
+ for (const chunk of symbolChunks) {
248
+ expect(chunk.parent_file_id).toBe(FILE_PATH);
249
+ }
250
+ });
251
+
252
+ // ─── Test 7: Chunks shorter than 20 chars are skipped ──────────────────
253
+
254
+ it('skips chunks whose composed text is shorter than 20 characters', () => {
255
+ // A node with minimal content that would produce very short text
256
+ const tinyNode: FileNode = {
257
+ file: 'x.ts',
258
+ hash: 'h',
259
+ language: 'typescript',
260
+ symbols: [],
261
+ imports: [],
262
+ dependents: [],
263
+ dependencies: [],
264
+ depth: 0,
265
+ last_parsed_at: null,
266
+ semantic: null,
267
+ enrichment_status: 'structural',
268
+ };
269
+ // "File: x.ts" is exactly 10 chars — too short
270
+ const map = makeProjectMap({ 'x.ts': tinyNode });
271
+
272
+ const chunks = extractor.extract(map);
273
+ expect(chunks.length).toBe(0);
274
+ });
275
+
276
+ // ─── Test 8: content_hash is deterministic ──────────────────────────────
277
+
278
+ it('produces the same content_hash for identical inputs (deterministic) [S-6]', () => {
279
+ const fileNode = makeSemanticNode();
280
+ const map = makeProjectMap({ [FILE_PATH]: fileNode });
281
+
282
+ const chunks1 = new ChunkExtractor('/project', logger).extract(map);
283
+ const chunks2 = new ChunkExtractor('/project', logger).extract(map);
284
+
285
+ const hash1 = chunks1.find(c => c.id === FILE_PATH)!.content_hash;
286
+ const hash2 = chunks2.find(c => c.id === FILE_PATH)!.content_hash;
287
+
288
+ expect(hash1).toBe(hash2);
289
+ expect(hash1).toMatch(/^[0-9a-f]{64}$/);
290
+ });
291
+
292
+ // ─── Test 9: content_hash stable across text composition changes ────────
293
+
294
+ it('produces stable content_hash — only raw inputs matter, not composed text [S-6]', () => {
295
+ const fileNode = makeSemanticNode();
296
+ const expectedHash = computeExpectedFileHash(FILE_PATH, fileNode);
297
+
298
+ const map = makeProjectMap({ [FILE_PATH]: fileNode });
299
+ const chunks = extractor.extract(map);
300
+ const fileChunk = chunks.find(c => c.id === FILE_PATH)!;
301
+
302
+ // Hash must equal what we compute from raw inputs directly
303
+ expect(fileChunk.content_hash).toBe(expectedHash);
304
+
305
+ // Changing only the composition format (e.g. changing semantic.overview
306
+ // delimiter) would not change the hash because we hash raw inputs.
307
+ // We verify that semantic content changes DO change the hash:
308
+ const modifiedNode = makeSemanticNode({
309
+ semantic: {
310
+ ...fileNode.semantic!,
311
+ overview: 'CHANGED overview',
312
+ },
313
+ });
314
+ const mapModified = makeProjectMap({ [FILE_PATH]: modifiedNode });
315
+ const chunksModified = extractor.extract(mapModified);
316
+ const modifiedHash = chunksModified.find(c => c.id === FILE_PATH)!.content_hash;
317
+
318
+ expect(modifiedHash).not.toBe(expectedHash);
319
+ });
320
+
321
+ // ─── Test 10: No .semantic.md file reads ───────────────────────────────
322
+
323
+ it('performs no filesystem reads — all data from ProjectMap [TRAP-5]', () => {
324
+ mockReadFileSync.mockClear();
325
+ mockReadFile.mockClear();
326
+
327
+ const fileNode = makeSemanticNode();
328
+ const noSemNode = makeNoSemanticNode();
329
+ const map = makeProjectMap({
330
+ [FILE_PATH]: fileNode,
331
+ [NO_SEM_PATH]: noSemNode,
332
+ });
333
+
334
+ extractor.extract(map);
335
+
336
+ expect(mockReadFileSync).not.toHaveBeenCalled();
337
+ expect(mockReadFile).not.toHaveBeenCalled();
338
+ });
339
+ });
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import type { Logger } from 'winston';
3
+ import { NomosError } from '../../core/errors.js';
4
+ import type { NomosConfig } from '../../types/index.js';
5
+
6
+ // ─── Mock google-generative-ai to avoid real API calls ────────────────────────
7
+
8
+ vi.mock('@google/generative-ai', () => ({
9
+ // Must use a regular function (not arrow) so it can be called with `new`
10
+ GoogleGenerativeAI: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
11
+ this['getGenerativeModel'] = vi.fn();
12
+ }),
13
+ }));
14
+
15
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
16
+
17
+ function makeLogger(): Logger {
18
+ return {
19
+ info: vi.fn(),
20
+ warn: vi.fn(),
21
+ error: vi.fn(),
22
+ debug: vi.fn(),
23
+ } as unknown as Logger;
24
+ }
25
+
26
+ function makeSearchConfig(): NomosConfig['search'] {
27
+ return {
28
+ embedding_model: 'text-embedding-004',
29
+ embedding_dimensions: 256,
30
+ vector_store_path: '/tmp/test-vectors',
31
+ default_top_k: 10,
32
+ default_threshold: 0.7,
33
+ batch_size: 100,
34
+ embedding_requests_per_minute: 60,
35
+ request_timeout_ms: 30_000,
36
+ };
37
+ }
38
+
39
+ function makeMockAuthManager(overrides: { isLoggedIn?: boolean; accessToken?: string } = {}) {
40
+ return {
41
+ isLoggedIn: vi.fn().mockReturnValue(overrides.isLoggedIn ?? false),
42
+ getAccessToken: vi.fn().mockResolvedValue(overrides.accessToken ?? 'fake-oauth-token'),
43
+ saveCredentials: vi.fn(),
44
+ loadCredentials: vi.fn(),
45
+ clearCredentials: vi.fn(),
46
+ getAuthenticatedClient: vi.fn(),
47
+ };
48
+ }
49
+
50
+ // ─── Tests ────────────────────────────────────────────────────────────────────
51
+
52
+ describe('Embedder.create — credential chain', () => {
53
+ let Embedder: (typeof import('../embedder.js'))['Embedder'];
54
+ const originalEnv = process.env['GEMINI_API_KEY'];
55
+
56
+ beforeEach(async () => {
57
+ vi.clearAllMocks();
58
+ ({ Embedder } = await import('../embedder.js'));
59
+ });
60
+
61
+ afterEach(() => {
62
+ // Restore the env var after each test
63
+ if (originalEnv === undefined) {
64
+ delete process.env['GEMINI_API_KEY'];
65
+ } else {
66
+ process.env['GEMINI_API_KEY'] = originalEnv;
67
+ }
68
+ });
69
+
70
+ // ─── 1. API key takes priority ──────────────────────────────────────────────
71
+
72
+ it('uses GEMINI_API_KEY and does NOT call authManager when env var is set', async () => {
73
+ process.env['GEMINI_API_KEY'] = 'env-api-key';
74
+ const authManager = makeMockAuthManager({ isLoggedIn: true, accessToken: 'oauth-token' });
75
+
76
+ const embedder = await Embedder.create(makeSearchConfig(), makeLogger(), authManager as never);
77
+
78
+ expect(embedder).toBeInstanceOf(Embedder);
79
+ // isLoggedIn and getAccessToken must not be called when env key is present
80
+ expect(authManager.isLoggedIn).not.toHaveBeenCalled();
81
+ expect(authManager.getAccessToken).not.toHaveBeenCalled();
82
+ });
83
+
84
+ // ─── 2. OAuth fallback ─────────────────────────────────────────────────────
85
+
86
+ it('falls back to OAuth token when GEMINI_API_KEY is unset and user is logged in', async () => {
87
+ delete process.env['GEMINI_API_KEY'];
88
+ const authManager = makeMockAuthManager({ isLoggedIn: true, accessToken: 'fake-oauth-token' });
89
+
90
+ const embedder = await Embedder.create(makeSearchConfig(), makeLogger(), authManager as never);
91
+
92
+ expect(embedder).toBeInstanceOf(Embedder);
93
+ expect(authManager.isLoggedIn).toHaveBeenCalled();
94
+ expect(authManager.getAccessToken).toHaveBeenCalled();
95
+ });
96
+
97
+ // ─── 3. Neither available ──────────────────────────────────────────────────
98
+
99
+ it('throws search_api_key_missing when neither GEMINI_API_KEY nor OAuth are available', async () => {
100
+ delete process.env['GEMINI_API_KEY'];
101
+ const authManager = makeMockAuthManager({ isLoggedIn: false });
102
+
103
+ await expect(
104
+ Embedder.create(makeSearchConfig(), makeLogger(), authManager as never),
105
+ ).rejects.toSatisfy((err: unknown) => {
106
+ if (!(err instanceof NomosError)) return false;
107
+ if (err.code !== 'search_api_key_missing') return false;
108
+ // Error message must mention both GEMINI_API_KEY and arc auth login
109
+ return /GEMINI_API_KEY/i.test(err.message) && /arc auth login/i.test(err.message);
110
+ });
111
+ });
112
+
113
+ // ─── 4. No authManager provided ───────────────────────────────────────────
114
+
115
+ it('throws search_api_key_missing when GEMINI_API_KEY is unset and no authManager provided', async () => {
116
+ delete process.env['GEMINI_API_KEY'];
117
+
118
+ await expect(
119
+ Embedder.create(makeSearchConfig(), makeLogger(), null),
120
+ ).rejects.toSatisfy(
121
+ (err: unknown) => err instanceof NomosError && err.code === 'search_api_key_missing',
122
+ );
123
+ });
124
+ });