@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,202 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as fsSync from 'node:fs';
4
+ import * as path from 'node:path';
5
+ import * as os from 'node:os';
6
+ import { MapPipeline } from '../pipeline.js';
7
+ import { getDefaultConfig } from '../../config.js';
8
+ import type { NomosConfig } from '../../../types/index.js';
9
+
10
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
11
+
12
+ let tmpDir: string;
13
+
14
+ beforeEach(async () => {
15
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nomos-pipeline-test-'));
16
+ });
17
+
18
+ afterEach(async () => {
19
+ await fs.rm(tmpDir, { recursive: true, force: true });
20
+ });
21
+
22
+ const FIXTURE_DIR = path.resolve(
23
+ new URL(import.meta.url).pathname,
24
+ '../../../../../test/fixtures/sample-project',
25
+ );
26
+
27
+ function makeConfig(overrides: Partial<NomosConfig['graph']> = {}): NomosConfig {
28
+ const config = getDefaultConfig();
29
+ config.graph = {
30
+ ...config.graph,
31
+ ai_enrichment: false,
32
+ output_dir: path.relative(tmpDir, path.join(tmpDir, 'tasks-management/graph')),
33
+ exclude_patterns: ['node_modules', 'dist', '.git', '**/*.semantic.md'],
34
+ ...overrides,
35
+ };
36
+ return config;
37
+ }
38
+
39
+ function makeLogger() {
40
+ return {
41
+ info: vi.fn(),
42
+ warn: vi.fn(),
43
+ error: vi.fn(),
44
+ };
45
+ }
46
+
47
+ async function copyFixture(targetDir: string): Promise<void> {
48
+ const files = await fs.readdir(FIXTURE_DIR, { recursive: true });
49
+ for (const file of files) {
50
+ const srcPath = path.join(FIXTURE_DIR, file as string);
51
+ const stat = await fs.stat(srcPath);
52
+ if (stat.isDirectory()) continue;
53
+ const destPath = path.join(targetDir, file as string);
54
+ await fs.mkdir(path.dirname(destPath), { recursive: true });
55
+ await fs.copyFile(srcPath, destPath);
56
+ }
57
+ }
58
+
59
+ // ─── Tests ────────────────────────────────────────────────────────────────────
60
+
61
+ describe('MapPipeline', () => {
62
+ // 1. Full pipeline noAi: true → all 8 fixture files mapped
63
+ it('maps all 8 fixture files with noAi=true', async () => {
64
+ await copyFixture(tmpDir);
65
+ const config = makeConfig();
66
+ const pipeline = new MapPipeline(config, tmpDir, makeLogger());
67
+
68
+ const result = await pipeline.run({ noAi: true, force: false, patterns: ['src/**/*.ts'] });
69
+
70
+ expect(result.map.stats.total_files).toBe(9);
71
+ expect(result.aiFailures).toBe(0);
72
+ expect(Object.keys(result.map.files)).toHaveLength(9);
73
+ });
74
+
75
+ // 2. src/types.ts and src/utils/index.ts have highest depth (most dependents)
76
+ it('assigns highest depth to most-imported files', async () => {
77
+ await copyFixture(tmpDir);
78
+ const config = makeConfig();
79
+ const pipeline = new MapPipeline(config, tmpDir, makeLogger());
80
+
81
+ const result = await pipeline.run({ noAi: true, force: false, patterns: ['src/**/*.ts'] });
82
+
83
+ const typesDepth = result.map.files['src/types.ts']?.depth ?? -1;
84
+ const utilsDepth = result.map.files['src/utils/index.ts']?.depth ?? -1;
85
+ const mainDepth = result.map.files['src/main.ts']?.depth ?? -1;
86
+
87
+ // types.ts and utils/index.ts are imported by many — should have higher depth than main
88
+ expect(typesDepth).toBeGreaterThan(0);
89
+ expect(utilsDepth).toBeGreaterThan(0);
90
+ // main.ts imports everything but nothing imports it
91
+ expect(mainDepth).toBe(0);
92
+ });
93
+
94
+ // 3. src/main.ts = depth 0 (nobody imports it)
95
+ it('assigns depth 0 to entry-point files', async () => {
96
+ await copyFixture(tmpDir);
97
+ const pipeline = new MapPipeline(makeConfig(), tmpDir, makeLogger());
98
+ const result = await pipeline.run({ noAi: true, force: false, patterns: ['src/**/*.ts'] });
99
+ expect(result.map.files['src/main.ts']?.depth).toBe(0);
100
+ });
101
+
102
+ // 4. Circular pair detected — circular-a and circular-b both present
103
+ it('handles circular dependency pair without crashing', async () => {
104
+ await copyFixture(tmpDir);
105
+ const pipeline = new MapPipeline(makeConfig(), tmpDir, makeLogger());
106
+ const result = await pipeline.run({ noAi: true, force: false, patterns: ['src/**/*.ts'] });
107
+ expect(result.map.files['src/circular-a.ts']).toBeDefined();
108
+ expect(result.map.files['src/circular-b.ts']).toBeDefined();
109
+ });
110
+
111
+ // 5. Incremental: second run carries all (0 re-parsed)
112
+ it('carries all files on second run when nothing changed', async () => {
113
+ await copyFixture(tmpDir);
114
+ const config = makeConfig();
115
+ const logger = makeLogger();
116
+ const pipeline = new MapPipeline(config, tmpDir, logger);
117
+
118
+ // First run
119
+ await pipeline.run({ noAi: true, force: false, patterns: ['src/**/*.ts'] });
120
+
121
+ // Second run — everything carried, no re-parsing
122
+ const result2 = await pipeline.run({ noAi: true, force: false, patterns: ['src/**/*.ts'] });
123
+
124
+ // All files should still be present
125
+ expect(result2.map.stats.total_files).toBe(9);
126
+ });
127
+
128
+ // 6. --force: all re-parsed
129
+ it('re-parses all files when force=true', async () => {
130
+ await copyFixture(tmpDir);
131
+ const config = makeConfig();
132
+ const pipeline = new MapPipeline(config, tmpDir, makeLogger());
133
+
134
+ // First run to populate the map
135
+ await pipeline.run({ noAi: true, force: false, patterns: ['src/**/*.ts'] });
136
+
137
+ // Force run
138
+ const result = await pipeline.run({ noAi: true, force: true, patterns: ['src/**/*.ts'] });
139
+
140
+ expect(result.map.stats.total_files).toBe(9);
141
+ });
142
+
143
+ // 7. core_modules identified correctly
144
+ it('identifies core_modules', async () => {
145
+ await copyFixture(tmpDir);
146
+ const config = makeConfig({ core_modules_count: 2 });
147
+ const pipeline = new MapPipeline(config, tmpDir, makeLogger());
148
+ const result = await pipeline.run({ noAi: true, force: false, patterns: ['src/**/*.ts'] });
149
+
150
+ // src/types.ts should be in core_modules as it is imported by many files
151
+ expect(result.map.stats.core_modules).toContain('src/types.ts');
152
+ });
153
+
154
+ // 8. stats.total_edges correct
155
+ it('records correct total_edges', async () => {
156
+ await copyFixture(tmpDir);
157
+ const pipeline = new MapPipeline(makeConfig(), tmpDir, makeLogger());
158
+ const result = await pipeline.run({ noAi: true, force: false, patterns: ['src/**/*.ts'] });
159
+ // There are multiple import edges in the fixture — just check > 0
160
+ expect(result.map.stats.total_edges).toBeGreaterThan(0);
161
+ });
162
+
163
+ // 9. [BLK-2] Concurrent: two pipeline runs simultaneously — no corruption
164
+ it('handles concurrent runs without map corruption', async () => {
165
+ await copyFixture(tmpDir);
166
+ const config = makeConfig();
167
+ const pipeline1 = new MapPipeline(config, tmpDir, makeLogger());
168
+ const pipeline2 = new MapPipeline(config, tmpDir, makeLogger());
169
+
170
+ // Launch both simultaneously
171
+ const [result1, result2] = await Promise.all([
172
+ pipeline1.run({ noAi: true, force: false, patterns: ['src/**/*.ts'] }),
173
+ pipeline2.run({ noAi: true, force: false, patterns: ['src/**/*.ts'] }),
174
+ ]);
175
+
176
+ // Both should complete with valid maps
177
+ expect(result1.map.stats.total_files).toBe(9);
178
+ expect(result2.map.stats.total_files).toBe(9);
179
+
180
+ // Final map on disk should be valid JSON
181
+ const mapPath = path.join(tmpDir, 'tasks-management/graph', 'project_map.json');
182
+ const raw = await fs.readFile(mapPath, 'utf-8');
183
+ expect(() => JSON.parse(raw)).not.toThrow();
184
+ });
185
+
186
+ // 10. [GAP-2] Intermediate map written to disk before enrichment begins
187
+ it('writes intermediate map before AI enrichment', async () => {
188
+ await copyFixture(tmpDir);
189
+ const config = makeConfig();
190
+ const mapPath = path.join(tmpDir, 'tasks-management/graph', 'project_map.json');
191
+
192
+ // Run with noAi to simulate the intermediate write (the intermediate map IS the final map when noAi=true)
193
+ const pipeline = new MapPipeline(config, tmpDir, makeLogger());
194
+ await pipeline.run({ noAi: true, force: false, patterns: ['src/**/*.ts'] });
195
+
196
+ // Map must exist on disk after run
197
+ const raw = await fs.readFile(mapPath, 'utf-8');
198
+ const parsed = JSON.parse(raw);
199
+ expect(parsed.schema_version).toBe(1);
200
+ expect(typeof parsed.files).toBe('object');
201
+ });
202
+ });
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { MapRenderer } from '../renderer.js';
3
+ import type { FileNode, ProjectMap } from '../../../types/index.js';
4
+
5
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
6
+
7
+ function makeNode(
8
+ file: string,
9
+ deps: string[] = [],
10
+ dependents: string[] = [],
11
+ depth = 0,
12
+ ): FileNode {
13
+ return {
14
+ file,
15
+ hash: 'sha256:test',
16
+ language: 'typescript',
17
+ symbols: [
18
+ { name: 'MyClass', kind: 'class', line: 1, end_line: 10, signature: null, exported: true },
19
+ ],
20
+ imports: [],
21
+ dependencies: deps,
22
+ dependents,
23
+ depth,
24
+ last_parsed_at: null,
25
+ semantic: null,
26
+ enrichment_status: 'structural',
27
+ };
28
+ }
29
+
30
+ function makeMap(files: FileNode[]): ProjectMap {
31
+ const filesRecord: Record<string, FileNode> = {};
32
+ for (const f of files) {
33
+ filesRecord[f.file] = f;
34
+ }
35
+ return {
36
+ schema_version: 1,
37
+ generated_at: new Date().toISOString(),
38
+ root: '/project',
39
+ files: filesRecord,
40
+ stats: {
41
+ total_files: files.length,
42
+ total_symbols: files.reduce((acc, f) => acc + f.symbols.length, 0),
43
+ total_edges: files.reduce((acc, f) => acc + f.dependencies.length, 0),
44
+ core_modules: [],
45
+ structural_only: 0,
46
+ semantically_enriched: 0,
47
+ indexed: 0,
48
+ },
49
+ };
50
+ }
51
+
52
+ // ─── Tests ────────────────────────────────────────────────────────────────────
53
+
54
+ describe('MapRenderer', () => {
55
+ const renderer = new MapRenderer();
56
+
57
+ it('1. node count matches ProjectMap.files count', () => {
58
+ const map = makeMap([
59
+ makeNode('src/a.ts'),
60
+ makeNode('src/b.ts'),
61
+ makeNode('src/c.ts'),
62
+ ]);
63
+ const { nodes } = renderer.render(map);
64
+ expect(nodes).toHaveLength(3);
65
+ });
66
+
67
+ it('2. edge count matches total internal dependency links', () => {
68
+ const map = makeMap([
69
+ makeNode('src/a.ts', ['src/b.ts', 'src/c.ts']),
70
+ makeNode('src/b.ts', ['src/c.ts']),
71
+ makeNode('src/c.ts'),
72
+ ]);
73
+ const { edges } = renderer.render(map);
74
+ // a→b, a→c, b→c = 3 edges
75
+ expect(edges).toHaveLength(3);
76
+ });
77
+
78
+ it('3. color at depth 0 is #a8d8ea (cool blue)', () => {
79
+ const color = renderer.computeColor(0, 10);
80
+ expect(color).toBe('#a8d8ea');
81
+ });
82
+
83
+ it('4. color at max depth is #c0392b (deep red)', () => {
84
+ const color = renderer.computeColor(10, 10);
85
+ expect(color).toBe('#c0392b');
86
+ });
87
+
88
+ it('5. color at mid depth is in amber range (between blue and red)', () => {
89
+ const color = renderer.computeColor(5, 10);
90
+ // Should be approximately #f9c784 (warm amber) at exact midpoint
91
+ // Parse hex and verify it has high red, moderate green, low blue
92
+ const r = parseInt(color.slice(1, 3), 16);
93
+ const g = parseInt(color.slice(3, 5), 16);
94
+ const b = parseInt(color.slice(5, 7), 16);
95
+ // Amber = high R, mid G, low B
96
+ expect(r).toBeGreaterThan(200);
97
+ expect(g).toBeGreaterThan(150);
98
+ expect(b).toBeLessThan(150);
99
+ });
100
+
101
+ it('6. node data includes all FileNode fields', () => {
102
+ const node = makeNode('src/a.ts');
103
+ const map = makeMap([node]);
104
+ const { nodes } = renderer.render(map);
105
+ expect(nodes[0].data.file).toBe('src/a.ts');
106
+ expect(nodes[0].data.language).toBe('typescript');
107
+ expect(nodes[0].data.symbols).toHaveLength(1);
108
+ expect(nodes[0].data.hash).toBe('sha256:test');
109
+ });
110
+
111
+ it('7. edge source and target match dependency relationships', () => {
112
+ const map = makeMap([
113
+ makeNode('src/a.ts', ['src/b.ts']),
114
+ makeNode('src/b.ts'),
115
+ ]);
116
+ const { edges } = renderer.render(map);
117
+ expect(edges).toHaveLength(1);
118
+ expect(edges[0].data.source).toBe('src/a.ts');
119
+ expect(edges[0].data.target).toBe('src/b.ts');
120
+ });
121
+
122
+ it('8. empty map produces 0 nodes and 0 edges without crashing', () => {
123
+ const map = makeMap([]);
124
+ const { nodes, edges } = renderer.render(map);
125
+ expect(nodes).toHaveLength(0);
126
+ expect(edges).toHaveLength(0);
127
+ });
128
+ });
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import * as os from 'node:os';
5
+ import { ImportResolver } from '../resolver.js';
6
+
7
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
8
+
9
+ function makeLogger() {
10
+ return { warn: vi.fn(), info: vi.fn() };
11
+ }
12
+
13
+ /**
14
+ * Build a knownFiles Set<string> from a list of project-relative paths.
15
+ * Mirrors the contract: caller must include both new and carried files.
16
+ */
17
+ function makeKnownFiles(...relativePaths: string[]): Set<string> {
18
+ return new Set(relativePaths);
19
+ }
20
+
21
+ // ─── Tests ────────────────────────────────────────────────────────────────────
22
+
23
+ describe('ImportResolver', () => {
24
+ let tmpDir: string;
25
+ let resolver: ImportResolver;
26
+ let logger: ReturnType<typeof makeLogger>;
27
+
28
+ beforeEach(async () => {
29
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nomos-resolver-'));
30
+ // Write a minimal tsconfig.json (no paths by default)
31
+ await fs.writeFile(
32
+ path.join(tmpDir, 'tsconfig.json'),
33
+ JSON.stringify({ compilerOptions: { target: 'ES2022' } }),
34
+ );
35
+ logger = makeLogger();
36
+ resolver = new ImportResolver(tmpDir, logger);
37
+ });
38
+
39
+ afterEach(async () => {
40
+ await fs.rm(tmpDir, { recursive: true, force: true });
41
+ });
42
+
43
+ // ── Test 1: Extension append ──────────────────────────────────────────────
44
+
45
+ it('resolves ./utils/hash → src/utils/hash.ts (extension append)', async () => {
46
+ const knownFiles = makeKnownFiles('src/utils/hash.ts', 'src/importer.ts');
47
+ const result = await resolver.resolve('./utils/hash', 'src/importer.ts', knownFiles);
48
+ expect(result).toEqual({ resolved: 'src/utils/hash.ts', is_external: false });
49
+ });
50
+
51
+ // ── Test 2: Index resolution ──────────────────────────────────────────────
52
+
53
+ it('resolves ./utils → src/utils/index.ts (index resolution)', async () => {
54
+ const knownFiles = makeKnownFiles('src/utils/index.ts', 'src/importer.ts');
55
+ const result = await resolver.resolve('./utils', 'src/importer.ts', knownFiles);
56
+ expect(result).toEqual({ resolved: 'src/utils/index.ts', is_external: false });
57
+ });
58
+
59
+ // ── Test 3: Tsconfig alias ────────────────────────────────────────────────
60
+
61
+ it('resolves tsconfig alias @/utils → src/utils/index.ts', async () => {
62
+ // Write tsconfig with path alias
63
+ await fs.writeFile(
64
+ path.join(tmpDir, 'tsconfig.json'),
65
+ JSON.stringify({
66
+ compilerOptions: {
67
+ paths: {
68
+ '@/*': ['src/*'],
69
+ },
70
+ },
71
+ }),
72
+ );
73
+ resolver = new ImportResolver(tmpDir, logger);
74
+
75
+ const knownFiles = makeKnownFiles('src/utils/index.ts', 'src/importer.ts');
76
+ const result = await resolver.resolve('@/utils', 'src/importer.ts', knownFiles);
77
+ expect(result).toEqual({ resolved: 'src/utils/index.ts', is_external: false });
78
+ });
79
+
80
+ // ── Test 4: zod → external ────────────────────────────────────────────────
81
+
82
+ it('marks zod as external', async () => {
83
+ const knownFiles = makeKnownFiles('src/importer.ts');
84
+ const result = await resolver.resolve('zod', 'src/importer.ts', knownFiles);
85
+ expect(result).toEqual({ resolved: null, is_external: true });
86
+ });
87
+
88
+ // ── Test 5: commander → external ─────────────────────────────────────────
89
+
90
+ it('marks commander as external', async () => {
91
+ const knownFiles = makeKnownFiles('src/importer.ts');
92
+ const result = await resolver.resolve('commander', 'src/importer.ts', knownFiles);
93
+ expect(result).toEqual({ resolved: null, is_external: true });
94
+ });
95
+
96
+ // ── Test 6: Path traversal → external with warning ───────────────────────
97
+
98
+ it('treats path traversal outside projectRoot as external with warning', async () => {
99
+ const knownFiles = makeKnownFiles('src/importer.ts');
100
+ const result = await resolver.resolve(
101
+ '../../outside-project',
102
+ 'src/importer.ts',
103
+ knownFiles,
104
+ );
105
+ expect(result).toEqual({ resolved: null, is_external: true });
106
+ expect(logger.warn).toHaveBeenCalledWith(
107
+ expect.stringContaining('traversal outside projectRoot'),
108
+ );
109
+ });
110
+
111
+ // ── Test 7: Unresolvable relative ────────────────────────────────────────
112
+
113
+ it('returns { resolved: null, is_external: false } for unresolvable relative import', async () => {
114
+ const knownFiles = makeKnownFiles('src/importer.ts');
115
+ const result = await resolver.resolve('./nonexistent', 'src/importer.ts', knownFiles);
116
+ expect(result).toEqual({ resolved: null, is_external: false });
117
+ expect(logger.warn).toHaveBeenCalledWith(
118
+ expect.stringContaining("Cannot resolve import './nonexistent'"),
119
+ );
120
+ });
121
+
122
+ // ── Test 8: Missing tsconfig.json → alias skipped gracefully ─────────────
123
+
124
+ it('gracefully skips alias resolution when tsconfig.json is missing', async () => {
125
+ await fs.rm(path.join(tmpDir, 'tsconfig.json'));
126
+ resolver = new ImportResolver(tmpDir, logger);
127
+
128
+ // Relative imports still work; alias-looking bare specifiers go external
129
+ const knownFiles = makeKnownFiles('src/utils/hash.ts', 'src/importer.ts');
130
+ const relResult = await resolver.resolve('./utils/hash', 'src/importer.ts', knownFiles);
131
+ expect(relResult).toEqual({ resolved: 'src/utils/hash.ts', is_external: false });
132
+
133
+ const aliasResult = await resolver.resolve('@/utils', 'src/importer.ts', knownFiles);
134
+ expect(aliasResult).toEqual({ resolved: null, is_external: true });
135
+ });
136
+
137
+ // ── Test 9: [AMB-4] No filesystem calls during resolve ───────────────────
138
+
139
+ it('[AMB-4] resolve() uses knownFiles Set — resolves file that does NOT exist on disk', async () => {
140
+ // The file 'src/utils/ghost.ts' is in knownFiles but does NOT physically
141
+ // exist in tmpDir. If resolver relied on fs.existsSync/fs.access, it would
142
+ // return null. Since it uses only knownFiles.has(), it resolves correctly.
143
+ const knownFiles = makeKnownFiles('src/utils/ghost.ts', 'src/importer.ts');
144
+ // Verify the file truly does not exist on disk
145
+ await expect(
146
+ fs.access(path.join(tmpDir, 'src/utils/ghost.ts')),
147
+ ).rejects.toThrow();
148
+
149
+ const result = await resolver.resolve('./utils/ghost', 'src/importer.ts', knownFiles);
150
+ expect(result).toEqual({ resolved: 'src/utils/ghost.ts', is_external: false });
151
+ });
152
+
153
+ // ── Test 10: [WATCH-4] Resolver finds a carried-forward file ─────────────
154
+
155
+ it('[WATCH-4] resolves import to a carried-forward file not in fresh scan', async () => {
156
+ // knownFiles includes src/utils/hash.ts ONLY from the carried map
157
+ // (simulates: the file was unchanged so it was NOT freshly scanned)
158
+ const freshlyScanned = new Set<string>(['src/importer.ts']);
159
+ const fromCarried = new Set<string>(['src/utils/hash.ts']);
160
+ const knownFiles = new Set([...freshlyScanned, ...fromCarried]);
161
+
162
+ const result = await resolver.resolve('./utils/hash', 'src/importer.ts', knownFiles);
163
+ expect(result).toEqual({ resolved: 'src/utils/hash.ts', is_external: false });
164
+ });
165
+
166
+ // ── Bonus: Exact tsconfig alias (non-wildcard) ────────────────────────────
167
+
168
+ it('resolves exact tsconfig alias', async () => {
169
+ await fs.writeFile(
170
+ path.join(tmpDir, 'tsconfig.json'),
171
+ JSON.stringify({
172
+ compilerOptions: {
173
+ paths: {
174
+ 'shared': ['src/shared/index.ts'],
175
+ },
176
+ },
177
+ }),
178
+ );
179
+ resolver = new ImportResolver(tmpDir, logger);
180
+
181
+ const knownFiles = makeKnownFiles('src/shared/index.ts', 'src/importer.ts');
182
+ const result = await resolver.resolve('shared', 'src/importer.ts', knownFiles);
183
+ expect(result).toEqual({ resolved: 'src/shared/index.ts', is_external: false });
184
+ });
185
+ });