@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.
- package/.claude/settings.local.json +10 -0
- package/.nomos-config.json +5 -0
- package/CLAUDE.md +108 -0
- package/LICENSE +190 -0
- package/README.md +569 -0
- package/dist/cli.js +21120 -0
- package/docs/auth/googel_plan.yaml +1093 -0
- package/docs/auth/google_task.md +235 -0
- package/docs/auth/hardened_blueprint.yaml +1658 -0
- package/docs/auth/red_team_report.yaml +336 -0
- package/docs/auth/session_state.yaml +162 -0
- package/docs/certificate/cer_enhance_plan.md +605 -0
- package/docs/certificate/certificate_report.md +338 -0
- package/docs/dev_overview.md +419 -0
- package/docs/feature_assessment.md +156 -0
- package/docs/how_it_works.md +78 -0
- package/docs/infrastructure/map.md +867 -0
- package/docs/init/master_plan.md +3581 -0
- package/docs/init/red_team_report.md +215 -0
- package/docs/init/report_phase_1a.md +304 -0
- package/docs/integrity-gate/enhance_drift.md +703 -0
- package/docs/integrity-gate/overview.md +108 -0
- package/docs/management/manger-task.md +99 -0
- package/docs/management/scafffold.md +76 -0
- package/docs/map/ATOMIC_BLUEPRINT.md +1349 -0
- package/docs/map/RED_TEAM_REPORT.md +159 -0
- package/docs/map/map_task.md +147 -0
- package/docs/map/semantic_graph_task.md +792 -0
- package/docs/map/semantic_master_plan.md +705 -0
- package/docs/phase7/TEAM_RED.md +249 -0
- package/docs/phase7/plan.md +1682 -0
- package/docs/phase7/task.md +275 -0
- package/docs/prompts/USAGE.md +312 -0
- package/docs/prompts/architect.md +165 -0
- package/docs/prompts/executer.md +190 -0
- package/docs/prompts/hardener.md +190 -0
- package/docs/prompts/red_team.md +146 -0
- package/docs/verification/goveranance-overview.md +396 -0
- package/docs/verification/governance-overview.md +245 -0
- package/docs/verification/verification-arc-ar.md +560 -0
- package/docs/verification/verification-architecture.md +560 -0
- package/docs/very_next.md +52 -0
- package/docs/whitepaper.md +89 -0
- package/overview.md +1469 -0
- package/package.json +63 -0
- package/src/adapters/__tests__/git.test.ts +296 -0
- package/src/adapters/__tests__/stdio.test.ts +70 -0
- package/src/adapters/git.ts +226 -0
- package/src/adapters/pty.ts +159 -0
- package/src/adapters/stdio.ts +113 -0
- package/src/cli.ts +83 -0
- package/src/commands/apply.ts +47 -0
- package/src/commands/auth.ts +301 -0
- package/src/commands/certificate.ts +89 -0
- package/src/commands/discard.ts +24 -0
- package/src/commands/drift.ts +116 -0
- package/src/commands/index.ts +78 -0
- package/src/commands/init.ts +121 -0
- package/src/commands/list.ts +75 -0
- package/src/commands/map.ts +55 -0
- package/src/commands/plan.ts +30 -0
- package/src/commands/review.ts +58 -0
- package/src/commands/run.ts +63 -0
- package/src/commands/search.ts +147 -0
- package/src/commands/show.ts +63 -0
- package/src/commands/status.ts +59 -0
- package/src/core/__tests__/budget.test.ts +213 -0
- package/src/core/__tests__/certificate.test.ts +385 -0
- package/src/core/__tests__/config.test.ts +191 -0
- package/src/core/__tests__/preflight.test.ts +24 -0
- package/src/core/__tests__/prompt.test.ts +358 -0
- package/src/core/__tests__/review.test.ts +161 -0
- package/src/core/__tests__/state.test.ts +362 -0
- package/src/core/auth/__tests__/manager.test.ts +166 -0
- package/src/core/auth/__tests__/server.test.ts +220 -0
- package/src/core/auth/gcp-projects.ts +160 -0
- package/src/core/auth/manager.ts +114 -0
- package/src/core/auth/server.ts +141 -0
- package/src/core/budget.ts +119 -0
- package/src/core/certificate.ts +502 -0
- package/src/core/config.ts +212 -0
- package/src/core/errors.ts +54 -0
- package/src/core/factory.ts +49 -0
- package/src/core/graph/__tests__/builder.test.ts +272 -0
- package/src/core/graph/__tests__/contract-writer.test.ts +175 -0
- package/src/core/graph/__tests__/enricher.test.ts +299 -0
- package/src/core/graph/__tests__/parser.test.ts +200 -0
- package/src/core/graph/__tests__/pipeline.test.ts +202 -0
- package/src/core/graph/__tests__/renderer.test.ts +128 -0
- package/src/core/graph/__tests__/resolver.test.ts +185 -0
- package/src/core/graph/__tests__/scanner.test.ts +231 -0
- package/src/core/graph/__tests__/show.test.ts +134 -0
- package/src/core/graph/builder.ts +303 -0
- package/src/core/graph/constraints.ts +94 -0
- package/src/core/graph/contract-writer.ts +93 -0
- package/src/core/graph/drift/__tests__/classifier.test.ts +215 -0
- package/src/core/graph/drift/__tests__/comparator.test.ts +335 -0
- package/src/core/graph/drift/__tests__/drift.test.ts +453 -0
- package/src/core/graph/drift/__tests__/reporter.test.ts +203 -0
- package/src/core/graph/drift/classifier.ts +165 -0
- package/src/core/graph/drift/comparator.ts +205 -0
- package/src/core/graph/drift/reporter.ts +77 -0
- package/src/core/graph/enricher.ts +251 -0
- package/src/core/graph/grammar-paths.ts +30 -0
- package/src/core/graph/html-template.ts +493 -0
- package/src/core/graph/map-schema.ts +137 -0
- package/src/core/graph/parser.ts +336 -0
- package/src/core/graph/pipeline.ts +209 -0
- package/src/core/graph/renderer.ts +92 -0
- package/src/core/graph/resolver.ts +195 -0
- package/src/core/graph/scanner.ts +145 -0
- package/src/core/logger.ts +46 -0
- package/src/core/orchestrator.ts +792 -0
- package/src/core/plan-file-manager.ts +66 -0
- package/src/core/preflight.ts +64 -0
- package/src/core/prompt.ts +173 -0
- package/src/core/review.ts +95 -0
- package/src/core/state.ts +294 -0
- package/src/core/worktree-coordinator.ts +77 -0
- package/src/search/__tests__/chunk-extractor.test.ts +339 -0
- package/src/search/__tests__/embedder-auth.test.ts +124 -0
- package/src/search/__tests__/embedder.test.ts +267 -0
- package/src/search/__tests__/graph-enricher.test.ts +178 -0
- package/src/search/__tests__/indexer.test.ts +518 -0
- package/src/search/__tests__/integration.test.ts +649 -0
- package/src/search/__tests__/query-engine.test.ts +334 -0
- package/src/search/__tests__/similarity.test.ts +78 -0
- package/src/search/__tests__/vector-store.test.ts +281 -0
- package/src/search/chunk-extractor.ts +167 -0
- package/src/search/embedder.ts +209 -0
- package/src/search/graph-enricher.ts +95 -0
- package/src/search/indexer.ts +483 -0
- package/src/search/lexical-searcher.ts +190 -0
- package/src/search/query-engine.ts +225 -0
- package/src/search/vector-store.ts +311 -0
- package/src/types/index.ts +572 -0
- package/src/utils/__tests__/ansi.test.ts +54 -0
- package/src/utils/__tests__/frontmatter.test.ts +79 -0
- package/src/utils/__tests__/sanitize.test.ts +229 -0
- package/src/utils/ansi.ts +19 -0
- package/src/utils/context.ts +44 -0
- package/src/utils/frontmatter.ts +27 -0
- package/src/utils/sanitize.ts +78 -0
- package/test/e2e/lifecycle.test.ts +330 -0
- package/test/fixtures/mock-planner-hang.ts +5 -0
- package/test/fixtures/mock-planner.ts +26 -0
- package/test/fixtures/mock-reviewer-bad.ts +8 -0
- package/test/fixtures/mock-reviewer-retry.ts +34 -0
- package/test/fixtures/mock-reviewer.ts +18 -0
- package/test/fixtures/sample-project/src/circular-a.ts +6 -0
- package/test/fixtures/sample-project/src/circular-b.ts +6 -0
- package/test/fixtures/sample-project/src/config.ts +15 -0
- package/test/fixtures/sample-project/src/main.ts +19 -0
- package/test/fixtures/sample-project/src/services/product-service.ts +20 -0
- package/test/fixtures/sample-project/src/services/user-service.ts +18 -0
- package/test/fixtures/sample-project/src/types.ts +14 -0
- package/test/fixtures/sample-project/src/utils/index.ts +14 -0
- package/test/fixtures/sample-project/src/utils/validate.ts +12 -0
- package/tsconfig.json +20 -0
- 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
|
+
});
|