@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,175 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { ContractWriter } from '../contract-writer.js';
6
+ import type { FileNode, SemanticInfo } from '../../../types/index.js';
7
+
8
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
9
+
10
+ function makeLogger() {
11
+ return { info: () => {}, debug: () => {} };
12
+ }
13
+
14
+ function makeSemantic(overrides: Partial<SemanticInfo> = {}): SemanticInfo {
15
+ return {
16
+ overview: 'Manages state transitions',
17
+ purpose: 'Provides atomic read-write operations',
18
+ key_logic: ['Reads JSON from disk', 'Writes atomically via tmp→rename'],
19
+ usage_context: ['Used by Orchestrator', 'Used by CLI'],
20
+ source_hash: 'deadbeef',
21
+ enriched_at: '2026-04-05T12:00:00.000Z',
22
+ model: 'gemini-1.5-flash',
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ function makeFileNode(overrides: Partial<FileNode> = {}): FileNode {
28
+ return {
29
+ file: 'src/core/state.ts',
30
+ hash: 'deadbeef',
31
+ language: 'typescript',
32
+ symbols: [],
33
+ imports: [],
34
+ dependents: ['src/orchestrator.ts'],
35
+ dependencies: [],
36
+ depth: 2,
37
+ last_parsed_at: null,
38
+ semantic: makeSemantic(),
39
+ enrichment_status: 'semantic',
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ // ─── Tests ────────────────────────────────────────────────────────────────────
45
+
46
+ describe('ContractWriter', () => {
47
+ let tmpDir: string;
48
+
49
+ beforeEach(async () => {
50
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'contract-writer-test-'));
51
+ });
52
+
53
+ afterEach(async () => {
54
+ await fs.rm(tmpDir, { recursive: true, force: true });
55
+ });
56
+
57
+ // ─── Test 1: Writes .semantic.md with correct structure ──────────────────
58
+
59
+ it('writes .semantic.md with all four sections', async () => {
60
+ const writer = new ContractWriter(tmpDir, makeLogger());
61
+ const node = makeFileNode({ file: 'src/core/state.ts' });
62
+
63
+ // Create the source directory
64
+ await fs.mkdir(path.join(tmpDir, 'src', 'core'), { recursive: true });
65
+
66
+ const nodes = new Map([['src/core/state.ts', node]]);
67
+ await writer.writeContracts(nodes);
68
+
69
+ const outputPath = path.join(tmpDir, 'src', 'core', 'state.semantic.md');
70
+ const content = await fs.readFile(outputPath, 'utf-8');
71
+
72
+ // Header
73
+ expect(content).toContain('# state.ts — Semantic Contract');
74
+ expect(content).toContain('Auto-generated by `arc map`');
75
+
76
+ // All four sections
77
+ expect(content).toContain('## Overview');
78
+ expect(content).toContain('Manages state transitions');
79
+
80
+ expect(content).toContain('## Purpose');
81
+ expect(content).toContain('Provides atomic read-write operations');
82
+
83
+ expect(content).toContain('## Key Logic');
84
+ expect(content).toContain('1. Reads JSON from disk');
85
+ expect(content).toContain('2. Writes atomically via tmp→rename');
86
+
87
+ expect(content).toContain('## Usage Context');
88
+ expect(content).toContain('- Used by Orchestrator');
89
+ expect(content).toContain('- Used by CLI');
90
+
91
+ // Footer
92
+ expect(content).toContain('Enriched at: 2026-04-05T12:00:00.000Z');
93
+ expect(content).toContain('Model: gemini-1.5-flash');
94
+
95
+ // source_hash must be present (for skip-on-unchanged check)
96
+ expect(content).toContain('deadbeef');
97
+ });
98
+
99
+ // ─── Test 2: Skips when source_hash matches ───────────────────────────────
100
+
101
+ it('skips writing when existing .semantic.md contains the current source_hash', async () => {
102
+ const writer = new ContractWriter(tmpDir, makeLogger());
103
+ const node = makeFileNode({ file: 'src/core/state.ts' });
104
+
105
+ await fs.mkdir(path.join(tmpDir, 'src', 'core'), { recursive: true });
106
+ const outputPath = path.join(tmpDir, 'src', 'core', 'state.semantic.md');
107
+
108
+ // Pre-create file that already contains the current source_hash
109
+ const existingContent = `# state.ts — Semantic Contract\ndeadbeef\nold content`;
110
+ await fs.writeFile(outputPath, existingContent, 'utf-8');
111
+
112
+ const nodes = new Map([['src/core/state.ts', node]]);
113
+ await writer.writeContracts(nodes);
114
+
115
+ // File should be unchanged
116
+ const content = await fs.readFile(outputPath, 'utf-8');
117
+ expect(content).toBe(existingContent);
118
+ });
119
+
120
+ // ─── Test 3: Overwrites when hash changed ─────────────────────────────────
121
+
122
+ it('overwrites .semantic.md when source_hash has changed', async () => {
123
+ const writer = new ContractWriter(tmpDir, makeLogger());
124
+ const node = makeFileNode({
125
+ file: 'src/core/state.ts',
126
+ hash: 'newhash999',
127
+ semantic: makeSemantic({ source_hash: 'newhash999' }),
128
+ });
129
+
130
+ await fs.mkdir(path.join(tmpDir, 'src', 'core'), { recursive: true });
131
+ const outputPath = path.join(tmpDir, 'src', 'core', 'state.semantic.md');
132
+
133
+ // Existing file has old hash
134
+ await fs.writeFile(outputPath, '# old content\noldhash000', 'utf-8');
135
+
136
+ const nodes = new Map([['src/core/state.ts', node]]);
137
+ await writer.writeContracts(nodes);
138
+
139
+ const content = await fs.readFile(outputPath, 'utf-8');
140
+ expect(content).toContain('newhash999');
141
+ expect(content).not.toContain('old content');
142
+ });
143
+
144
+ // ─── Test 4: Skips files with semantic: null ──────────────────────────────
145
+
146
+ it('skips files where semantic is null', async () => {
147
+ const writer = new ContractWriter(tmpDir, makeLogger());
148
+ const node = makeFileNode({ file: 'src/core/state.ts', semantic: null });
149
+
150
+ await fs.mkdir(path.join(tmpDir, 'src', 'core'), { recursive: true });
151
+ const outputPath = path.join(tmpDir, 'src', 'core', 'state.semantic.md');
152
+
153
+ const nodes = new Map([['src/core/state.ts', node]]);
154
+ await writer.writeContracts(nodes);
155
+
156
+ // File should NOT have been created
157
+ await expect(fs.access(outputPath)).rejects.toThrow();
158
+ });
159
+
160
+ // ─── Test 5: [AMB-5] Creates parent directory if missing ─────────────────
161
+
162
+ it('[AMB-5] creates missing parent directory before writing', async () => {
163
+ const writer = new ContractWriter(tmpDir, makeLogger());
164
+ // Nested path whose parent directories do not exist yet
165
+ const node = makeFileNode({ file: 'src/deep/nested/util.ts' });
166
+
167
+ // Do NOT create the directory ahead of time
168
+ const nodes = new Map([['src/deep/nested/util.ts', node]]);
169
+ await writer.writeContracts(nodes);
170
+
171
+ const outputPath = path.join(tmpDir, 'src', 'deep', 'nested', 'util.semantic.md');
172
+ const content = await fs.readFile(outputPath, 'utf-8');
173
+ expect(content).toContain('# util.ts — Semantic Contract');
174
+ });
175
+ });
@@ -0,0 +1,299 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { NomosError } from '../../errors.js';
3
+ import type { NomosConfig, FileNode } from '../../../types/index.js';
4
+
5
+ // ─── Hoist mocks so they are available in vi.mock factory ────────────────────
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ generateContent: vi.fn(),
9
+ getGenerativeModel: vi.fn(),
10
+ readFile: vi.fn(),
11
+ }));
12
+
13
+ // ─── Mock @google/generative-ai ───────────────────────────────────────────────
14
+
15
+ vi.mock('@google/generative-ai', () => ({
16
+ // Use a class so `new GoogleGenerativeAI()` works correctly in the enricher
17
+ GoogleGenerativeAI: class MockGAI {
18
+ getGenerativeModel(...args: unknown[]) {
19
+ return mocks.getGenerativeModel(...args);
20
+ }
21
+ },
22
+ SchemaType: {
23
+ OBJECT: 'OBJECT',
24
+ STRING: 'STRING',
25
+ ARRAY: 'ARRAY',
26
+ },
27
+ }));
28
+
29
+ // ─── Mock fs/promises ─────────────────────────────────────────────────────────
30
+
31
+ vi.mock('node:fs/promises', () => ({
32
+ readFile: mocks.readFile,
33
+ }));
34
+
35
+ // ─── Import under test (after mocks registered) ───────────────────────────────
36
+
37
+ const { SemanticEnricher } = await import('../enricher.js');
38
+
39
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
40
+
41
+ function makeConfig(overrides: Partial<NomosConfig['graph']> = {}): NomosConfig['graph'] {
42
+ return {
43
+ exclude_patterns: [],
44
+ ai_enrichment: true,
45
+ ai_model: 'gemini-1.5-flash',
46
+ ai_concurrency: 1,
47
+ ai_requests_per_minute: 3600, // gapMs = 16ms — fast for tests
48
+ max_file_chars: 4000,
49
+ core_modules_count: 3,
50
+ output_dir: 'tasks-management/graph',
51
+ ...overrides,
52
+ };
53
+ }
54
+
55
+ function makeLogger() {
56
+ return { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
57
+ }
58
+
59
+ function makeFileNode(overrides: Partial<FileNode> = {}): FileNode {
60
+ return {
61
+ file: 'src/foo.ts',
62
+ hash: 'abc123',
63
+ language: 'typescript',
64
+ symbols: [],
65
+ imports: [],
66
+ dependents: [],
67
+ dependencies: [],
68
+ depth: 0,
69
+ last_parsed_at: null,
70
+ semantic: null,
71
+ enrichment_status: 'structural',
72
+ ...overrides,
73
+ };
74
+ }
75
+
76
+ const VALID_RESPONSE = {
77
+ overview: 'Overview text',
78
+ purpose: 'Purpose text',
79
+ key_logic: ['Logic A', 'Logic B'],
80
+ usage_context: ['Context A'],
81
+ };
82
+
83
+ function okResponse(data = VALID_RESPONSE) {
84
+ return { response: { text: () => JSON.stringify(data) } };
85
+ }
86
+
87
+ // ─── Tests ────────────────────────────────────────────────────────────────────
88
+
89
+ describe('SemanticEnricher', () => {
90
+ let savedApiKey: string | undefined;
91
+
92
+ beforeEach(() => {
93
+ vi.clearAllMocks();
94
+ savedApiKey = process.env['GEMINI_API_KEY'];
95
+ process.env['GEMINI_API_KEY'] = 'test-key';
96
+
97
+ // Wire getGenerativeModel to return an object with a generateContent mock
98
+ mocks.getGenerativeModel.mockReturnValue({ generateContent: mocks.generateContent });
99
+ mocks.generateContent.mockResolvedValue(okResponse());
100
+ mocks.readFile.mockResolvedValue('file content here');
101
+
102
+ vi.useFakeTimers();
103
+ });
104
+
105
+ afterEach(() => {
106
+ if (savedApiKey === undefined) {
107
+ delete process.env['GEMINI_API_KEY'];
108
+ } else {
109
+ process.env['GEMINI_API_KEY'] = savedApiKey;
110
+ }
111
+ vi.restoreAllMocks();
112
+ vi.useRealTimers();
113
+ });
114
+
115
+ // ─── Test 1: Enriches a file with semantic: null ─────────────────────────
116
+
117
+ it('enriches a file with null semantic — populates SemanticInfo', async () => {
118
+ const enricher = new SemanticEnricher('/project', makeConfig(), makeLogger());
119
+ const node = makeFileNode();
120
+ const nodes = new Map([['src/foo.ts', node]]);
121
+
122
+ const enrichPromise = enricher.enrich(nodes, { cancelled: false });
123
+ await vi.runAllTimersAsync();
124
+ const failures = await enrichPromise;
125
+
126
+ expect(failures).toBe(0);
127
+ expect(node.semantic).not.toBeNull();
128
+ expect(node.semantic?.overview).toBe('Overview text');
129
+ expect(node.semantic?.purpose).toBe('Purpose text');
130
+ expect(node.semantic?.key_logic).toEqual(['Logic A', 'Logic B']);
131
+ expect(node.semantic?.source_hash).toBe('abc123');
132
+ expect(node.semantic?.model).toBe('gemini-1.5-flash');
133
+ });
134
+
135
+ // ─── Test 2: Staleness check — skips when source_hash matches ────────────
136
+
137
+ it('skips enrichment when source_hash matches current hash', async () => {
138
+ const enricher = new SemanticEnricher('/project', makeConfig(), makeLogger());
139
+ const node = makeFileNode({
140
+ hash: 'abc123',
141
+ semantic: {
142
+ overview: 'Existing',
143
+ purpose: 'Existing purpose',
144
+ key_logic: [],
145
+ usage_context: [],
146
+ source_hash: 'abc123', // matches hash → should skip
147
+ enriched_at: '2026-01-01T00:00:00.000Z',
148
+ model: 'gemini-1.5-flash',
149
+ },
150
+ });
151
+ const nodes = new Map([['src/foo.ts', node]]);
152
+
153
+ const failures = await enricher.enrich(nodes, { cancelled: false });
154
+
155
+ expect(failures).toBe(0);
156
+ expect(mocks.generateContent).not.toHaveBeenCalled();
157
+ expect(node.semantic?.overview).toBe('Existing'); // unchanged
158
+ });
159
+
160
+ // ─── Test 3: Zod validation failure → semantic: null ─────────────────────
161
+
162
+ it('sets semantic: null when Zod validation fails (wrong type for overview)', async () => {
163
+ const logger = makeLogger();
164
+ const enricher = new SemanticEnricher('/project', makeConfig(), logger);
165
+ const node = makeFileNode();
166
+ const nodes = new Map([['src/foo.ts', node]]);
167
+
168
+ // overview is a number instead of string → Zod fails
169
+ mocks.generateContent.mockResolvedValueOnce(
170
+ okResponse({ overview: 123 as unknown as string, purpose: 'p', key_logic: [], usage_context: [] }),
171
+ );
172
+
173
+ const enrichPromise = enricher.enrich(nodes, { cancelled: false });
174
+ await vi.runAllTimersAsync();
175
+ const failures = await enrichPromise;
176
+
177
+ expect(failures).toBe(1);
178
+ expect(node.semantic).toBeNull();
179
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Zod validation failed'));
180
+ });
181
+
182
+ // ─── Test 4: Retry on 429 — succeeds on 3rd attempt ──────────────────────
183
+
184
+ it('retries on 429 and succeeds on the 3rd attempt', async () => {
185
+ const enricher = new SemanticEnricher('/project', makeConfig(), makeLogger());
186
+ const node = makeFileNode();
187
+ const nodes = new Map([['src/foo.ts', node]]);
188
+
189
+ const retryError = new Error('HTTP 429 Too Many Requests');
190
+ mocks.generateContent
191
+ .mockRejectedValueOnce(retryError)
192
+ .mockRejectedValueOnce(retryError)
193
+ .mockResolvedValueOnce(okResponse());
194
+
195
+ const enrichPromise = enricher.enrich(nodes, { cancelled: false });
196
+ await vi.runAllTimersAsync();
197
+ const failures = await enrichPromise;
198
+
199
+ expect(failures).toBe(0);
200
+ expect(mocks.generateContent).toHaveBeenCalledTimes(3);
201
+ expect(node.semantic).not.toBeNull();
202
+ expect(node.semantic?.overview).toBe('Overview text');
203
+ });
204
+
205
+ // ─── Test 5: Missing API key → constructor throws NomosError ─────────────
206
+
207
+ it('throws NomosError with code graph_ai_key_missing when GEMINI_API_KEY unset', () => {
208
+ delete process.env['GEMINI_API_KEY'];
209
+ const config = makeConfig({ ai_enrichment: true });
210
+
211
+ let caught: unknown;
212
+ try {
213
+ new SemanticEnricher('/project', config, makeLogger());
214
+ } catch (err) {
215
+ caught = err;
216
+ }
217
+
218
+ expect(caught).toBeInstanceOf(NomosError);
219
+ expect((caught as NomosError).code).toBe('graph_ai_key_missing');
220
+ });
221
+
222
+ // ─── Test 6: All retries exhausted → semantic: null, error logged ─────────
223
+
224
+ it('sets semantic: null and logs error when all 3 retries are exhausted', async () => {
225
+ const logger = makeLogger();
226
+ const enricher = new SemanticEnricher('/project', makeConfig(), logger);
227
+ const node = makeFileNode();
228
+ const nodes = new Map([['src/foo.ts', node]]);
229
+
230
+ const retryError = new Error('HTTP 429 Too Many Requests');
231
+ // 1 initial + 3 retries = 4 total calls, all fail
232
+ mocks.generateContent
233
+ .mockRejectedValueOnce(retryError)
234
+ .mockRejectedValueOnce(retryError)
235
+ .mockRejectedValueOnce(retryError)
236
+ .mockRejectedValueOnce(retryError);
237
+
238
+ const enrichPromise = enricher.enrich(nodes, { cancelled: false });
239
+ await vi.runAllTimersAsync();
240
+ const failures = await enrichPromise;
241
+
242
+ expect(failures).toBe(1);
243
+ expect(node.semantic).toBeNull();
244
+ expect(mocks.generateContent).toHaveBeenCalledTimes(4);
245
+ expect(logger.error).toHaveBeenCalledWith(
246
+ expect.stringContaining('after 3 retries — skipping'),
247
+ );
248
+ });
249
+
250
+ // ─── Test 7: [GAP-3] Cancellation flag stops processing ──────────────────
251
+
252
+ it('[GAP-3] stops enrichment when cancellationFlag is set mid-run', async () => {
253
+ const logger = makeLogger();
254
+ const enricher = new SemanticEnricher('/project', makeConfig({ ai_concurrency: 1 }), logger);
255
+
256
+ const node1 = makeFileNode({ file: 'src/a.ts' });
257
+ const node2 = makeFileNode({ file: 'src/b.ts' });
258
+ const cancellationFlag = { cancelled: false };
259
+
260
+ // After first file is enriched, set the cancellation flag
261
+ mocks.generateContent.mockImplementationOnce(async () => {
262
+ cancellationFlag.cancelled = true;
263
+ return okResponse();
264
+ });
265
+
266
+ const nodes = new Map([
267
+ ['src/a.ts', node1],
268
+ ['src/b.ts', node2],
269
+ ]);
270
+
271
+ const enrichPromise = enricher.enrich(nodes, cancellationFlag);
272
+ await vi.runAllTimersAsync();
273
+ await enrichPromise;
274
+
275
+ // Only a.ts should have been processed
276
+ expect(mocks.generateContent).toHaveBeenCalledTimes(1);
277
+ expect(node2.semantic).toBeNull();
278
+ expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('cancelled'));
279
+ });
280
+
281
+ // ─── Test 8: [AMB-2] Reads file content from disk (not ScanResult) ───────
282
+
283
+ it('[AMB-2] reads file content from disk via fs.readFile, not from ScanResult', async () => {
284
+ const enricher = new SemanticEnricher('/project', makeConfig(), makeLogger());
285
+ const node = makeFileNode({ file: 'src/foo.ts' });
286
+ const nodes = new Map([['src/foo.ts', node]]);
287
+
288
+ mocks.readFile.mockResolvedValueOnce('actual disk content');
289
+
290
+ const enrichPromise = enricher.enrich(nodes, { cancelled: false });
291
+ await vi.runAllTimersAsync();
292
+ await enrichPromise;
293
+
294
+ expect(mocks.readFile).toHaveBeenCalledWith(
295
+ expect.stringContaining('src/foo.ts'),
296
+ 'utf-8',
297
+ );
298
+ });
299
+ });
@@ -0,0 +1,200 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest';
2
+ import { ASTParser } from '../parser.js';
3
+
4
+ let parser: ASTParser;
5
+
6
+ beforeAll(async () => {
7
+ // WASM init is async — must complete before any tests run
8
+ parser = new ASTParser();
9
+ await parser.init();
10
+ });
11
+
12
+ // ─── 1. Function declarations ────────────────────────────────────────────────
13
+
14
+ describe('function declarations', () => {
15
+ it('extracts name, line, end_line, exported, and signature', () => {
16
+ const src = `export function greet(name: string): string {\n return name;\n}`;
17
+ const { symbols } = parser.parse('test.ts', src, 'typescript');
18
+ const fn = symbols.find(s => s.name === 'greet');
19
+ expect(fn).toBeDefined();
20
+ expect(fn!.kind).toBe('function');
21
+ expect(fn!.line).toBe(1);
22
+ expect(fn!.end_line).toBe(3);
23
+ expect(fn!.exported).toBe(true);
24
+ expect(fn!.signature).toContain('greet');
25
+ });
26
+
27
+ it('marks non-exported function as exported=false', () => {
28
+ const src = `function hidden() {}`;
29
+ const { symbols } = parser.parse('test.ts', src, 'typescript');
30
+ const fn = symbols.find(s => s.name === 'hidden');
31
+ expect(fn).toBeDefined();
32
+ expect(fn!.exported).toBe(false);
33
+ });
34
+ });
35
+
36
+ // ─── 2. Class + method extraction ────────────────────────────────────────────
37
+
38
+ describe('class and method extraction', () => {
39
+ it('extracts class and methods with ClassName.methodName format', () => {
40
+ const src = `
41
+ export class MyService {
42
+ doWork(x: number): void {}
43
+ private helper(): string { return ''; }
44
+ }`.trim();
45
+ const { symbols } = parser.parse('test.ts', src, 'typescript');
46
+
47
+ const cls = symbols.find(s => s.name === 'MyService');
48
+ expect(cls).toBeDefined();
49
+ expect(cls!.kind).toBe('class');
50
+ expect(cls!.exported).toBe(true);
51
+
52
+ const method = symbols.find(s => s.name === 'MyService.doWork');
53
+ expect(method).toBeDefined();
54
+ expect(method!.kind).toBe('method');
55
+
56
+ const priv = symbols.find(s => s.name === 'MyService.helper');
57
+ expect(priv).toBeDefined();
58
+ });
59
+ });
60
+
61
+ // ─── 3. Interface, type, enum ─────────────────────────────────────────────────
62
+
63
+ describe('interface, type, and enum declarations', () => {
64
+ it('extracts interface declaration', () => {
65
+ const src = `export interface Config { host: string; }`;
66
+ const { symbols } = parser.parse('test.ts', src, 'typescript');
67
+ const iface = symbols.find(s => s.name === 'Config');
68
+ expect(iface).toBeDefined();
69
+ expect(iface!.kind).toBe('interface');
70
+ expect(iface!.exported).toBe(true);
71
+ });
72
+
73
+ it('extracts type alias declaration', () => {
74
+ const src = `export type ID = string | number;`;
75
+ const { symbols } = parser.parse('test.ts', src, 'typescript');
76
+ const t = symbols.find(s => s.name === 'ID');
77
+ expect(t).toBeDefined();
78
+ expect(t!.kind).toBe('type');
79
+ });
80
+
81
+ it('extracts enum declaration', () => {
82
+ const src = `export enum Status { Active, Inactive }`;
83
+ const { symbols } = parser.parse('test.ts', src, 'typescript');
84
+ const e = symbols.find(s => s.name === 'Status');
85
+ expect(e).toBeDefined();
86
+ expect(e!.kind).toBe('enum');
87
+ });
88
+ });
89
+
90
+ // ─── 4. export const variable declarations ────────────────────────────────────
91
+
92
+ describe('export const variable / arrow function declarations', () => {
93
+ it('extracts export const arrow function as kind=function', () => {
94
+ const src = `export const multiply = (a: number, b: number) => a * b;`;
95
+ const { symbols } = parser.parse('test.ts', src, 'typescript');
96
+ const sym = symbols.find(s => s.name === 'multiply');
97
+ expect(sym).toBeDefined();
98
+ expect(sym!.kind).toBe('function');
99
+ expect(sym!.exported).toBe(true);
100
+ });
101
+
102
+ it('extracts export const non-function variable', () => {
103
+ const src = `export const VERSION = '1.0.0';`;
104
+ const { symbols } = parser.parse('test.ts', src, 'typescript');
105
+ const sym = symbols.find(s => s.name === 'VERSION');
106
+ expect(sym).toBeDefined();
107
+ expect(sym!.kind).toBe('variable');
108
+ expect(sym!.exported).toBe(true);
109
+ });
110
+ });
111
+
112
+ // ─── 5. TSX grammar selection ─────────────────────────────────────────────────
113
+
114
+ describe('TSX grammar selection', () => {
115
+ it('parses .tsx file with JSX content without throwing', () => {
116
+ const src = `
117
+ export function Button({ label }: { label: string }) {
118
+ return <button>{label}</button>;
119
+ }`.trim();
120
+ const { symbols } = parser.parse('Button.tsx', src, 'tsx');
121
+ const fn = symbols.find(s => s.name === 'Button');
122
+ expect(fn).toBeDefined();
123
+ expect(fn!.kind).toBe('function');
124
+ });
125
+ });
126
+
127
+ // ─── 6. Error-node threshold ──────────────────────────────────────────────────
128
+
129
+ describe('error-node threshold', () => {
130
+ it('returns empty arrays for heavily malformed TypeScript (> 20% errors)', () => {
131
+ // Produce a file that tree-sitter cannot parse — mostly garbage tokens
132
+ const src = '??? @@@ !!! ??? @@@ !!! ??? @@@ !!! ??? @@@ !!!'.repeat(20);
133
+ const { symbols, imports } = parser.parse('broken.ts', src, 'typescript');
134
+ expect(symbols).toHaveLength(0);
135
+ expect(imports).toHaveLength(0);
136
+ });
137
+ });
138
+
139
+ // ─── 7. Import statements with named symbols ──────────────────────────────────
140
+
141
+ describe('import statement extraction', () => {
142
+ it('extracts named import symbols and source', () => {
143
+ const src = `import { readFile, writeFile } from 'node:fs/promises';`;
144
+ const { imports } = parser.parse('test.ts', src, 'typescript');
145
+ expect(imports).toHaveLength(1);
146
+ expect(imports[0].source).toBe('node:fs/promises');
147
+ expect(imports[0].symbols).toEqual(expect.arrayContaining(['readFile', 'writeFile']));
148
+ expect(imports[0].is_external).toBe(false); // resolver sets this later
149
+ });
150
+
151
+ it('extracts default import with empty symbols array', () => {
152
+ const src = `import path from 'node:path';`;
153
+ const { imports } = parser.parse('test.ts', src, 'typescript');
154
+ expect(imports).toHaveLength(1);
155
+ expect(imports[0].source).toBe('node:path');
156
+ expect(imports[0].symbols).toHaveLength(0);
157
+ });
158
+
159
+ it('extracts namespace import with empty symbols array', () => {
160
+ const src = `import * as fs from 'fs';`;
161
+ const { imports } = parser.parse('test.ts', src, 'typescript');
162
+ expect(imports).toHaveLength(1);
163
+ expect(imports[0].symbols).toHaveLength(0);
164
+ });
165
+
166
+ it('extracts relative import source correctly', () => {
167
+ const src = `import { foo } from './utils/helper';`;
168
+ const { imports } = parser.parse('test.ts', src, 'typescript');
169
+ expect(imports[0].source).toBe('./utils/helper');
170
+ });
171
+ });
172
+
173
+ // ─── 8. require() calls ──────────────────────────────────────────────────────
174
+
175
+ describe('require() call extraction', () => {
176
+ it('extracts require() call source', () => {
177
+ const src = `const mod = require('./legacy-module');`;
178
+ const { imports } = parser.parse('test.ts', src, 'typescript');
179
+ const req = imports.find(i => i.source === './legacy-module');
180
+ expect(req).toBeDefined();
181
+ expect(req!.symbols).toHaveLength(0);
182
+ });
183
+ });
184
+
185
+ // ─── 9. resolved is always null at parse stage ────────────────────────────────
186
+
187
+ describe('resolved field', () => {
188
+ it('sets resolved to null for all imports at parse stage', () => {
189
+ const src = `
190
+ import { a } from './a';
191
+ import b from './b';
192
+ const c = require('./c');
193
+ `.trim();
194
+ const { imports } = parser.parse('test.ts', src, 'typescript');
195
+ expect(imports.length).toBeGreaterThanOrEqual(2);
196
+ for (const imp of imports) {
197
+ expect(imp.resolved).toBeNull();
198
+ }
199
+ });
200
+ });