@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,231 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } 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 * as crypto from 'node:crypto';
|
|
6
|
+
import { FileScanner, LANGUAGE_MAP } from '../scanner.js';
|
|
7
|
+
import type { NomosConfig, ProjectMap, FileNode } from '../../../types/index.js';
|
|
8
|
+
|
|
9
|
+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
function makeConfig(overrides: Partial<NomosConfig['graph']> = {}): NomosConfig['graph'] {
|
|
12
|
+
return {
|
|
13
|
+
exclude_patterns: ['node_modules', 'dist'],
|
|
14
|
+
ai_enrichment: false,
|
|
15
|
+
ai_model: 'gemini-1.5-flash',
|
|
16
|
+
ai_concurrency: 5,
|
|
17
|
+
ai_requests_per_minute: 14,
|
|
18
|
+
max_file_chars: 4000,
|
|
19
|
+
core_modules_count: 10,
|
|
20
|
+
output_dir: 'tasks-management/graph',
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const noopLogger = {
|
|
26
|
+
warn: (_msg: string) => {},
|
|
27
|
+
info: (_msg: string) => {},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const warnings: string[] = [];
|
|
31
|
+
const capturingLogger = {
|
|
32
|
+
warn: (msg: string) => { warnings.push(msg); },
|
|
33
|
+
info: (_msg: string) => {},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let tmpDir: string;
|
|
37
|
+
|
|
38
|
+
beforeEach(async () => {
|
|
39
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nomos-scanner-test-'));
|
|
40
|
+
warnings.length = 0;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
async function write(relPath: string, content: string): Promise<string> {
|
|
50
|
+
const abs = path.join(tmpDir, relPath);
|
|
51
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
52
|
+
await fs.writeFile(abs, content, 'utf-8');
|
|
53
|
+
return abs;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sha256(content: string): string {
|
|
57
|
+
return `sha256:${crypto.createHash('sha256').update(content).digest('hex')}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
describe('FileScanner', () => {
|
|
63
|
+
it('finds .ts and .js files in directory', async () => {
|
|
64
|
+
await write('src/foo.ts', 'const x = 1;');
|
|
65
|
+
await write('src/bar.js', 'const y = 2;');
|
|
66
|
+
|
|
67
|
+
const scanner = new FileScanner(tmpDir, makeConfig(), noopLogger);
|
|
68
|
+
const result = await scanner.scan(null, false);
|
|
69
|
+
|
|
70
|
+
expect(result.files.has('src/foo.ts')).toBe(true);
|
|
71
|
+
expect(result.files.has('src/bar.js')).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('computes correct SHA-256 hashes', async () => {
|
|
75
|
+
const content = 'const x: number = 42;';
|
|
76
|
+
await write('index.ts', content);
|
|
77
|
+
|
|
78
|
+
const scanner = new FileScanner(tmpDir, makeConfig(), noopLogger);
|
|
79
|
+
const result = await scanner.scan(null, false);
|
|
80
|
+
|
|
81
|
+
expect(result.files.get('index.ts')?.hash).toBe(sha256(content));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('detects correct language from extension', async () => {
|
|
85
|
+
const cases: Array<[string, string]> = [
|
|
86
|
+
['a.ts', 'typescript'],
|
|
87
|
+
['b.tsx', 'tsx'],
|
|
88
|
+
['c.js', 'javascript'],
|
|
89
|
+
['d.py', 'python'],
|
|
90
|
+
['e.go', 'go'],
|
|
91
|
+
['f.rs', 'rust'],
|
|
92
|
+
];
|
|
93
|
+
for (const [file, _] of cases) {
|
|
94
|
+
await write(file, '// content');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const scanner = new FileScanner(tmpDir, makeConfig(), noopLogger);
|
|
98
|
+
const result = await scanner.scan(null, false);
|
|
99
|
+
|
|
100
|
+
for (const [file, expectedLang] of cases) {
|
|
101
|
+
expect(result.files.get(file)?.language).toBe(expectedLang);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('excludes node_modules directory', async () => {
|
|
106
|
+
await write('src/index.ts', 'export const a = 1;');
|
|
107
|
+
await write('node_modules/lib/index.ts', 'export const b = 2;');
|
|
108
|
+
|
|
109
|
+
const scanner = new FileScanner(tmpDir, makeConfig(), noopLogger);
|
|
110
|
+
const result = await scanner.scan(null, false);
|
|
111
|
+
|
|
112
|
+
expect(result.files.has('src/index.ts')).toBe(true);
|
|
113
|
+
expect(result.files.has('node_modules/lib/index.ts')).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('handles unreadable file gracefully without throwing', async () => {
|
|
117
|
+
await write('readable.ts', 'export const x = 1;');
|
|
118
|
+
|
|
119
|
+
// Create a file then make it unreadable (only works on Linux/Mac)
|
|
120
|
+
const unreadablePath = path.join(tmpDir, 'unreadable.ts');
|
|
121
|
+
await fs.writeFile(unreadablePath, 'secret');
|
|
122
|
+
await fs.chmod(unreadablePath, 0o000);
|
|
123
|
+
|
|
124
|
+
const scanner = new FileScanner(tmpDir, makeConfig(), capturingLogger);
|
|
125
|
+
|
|
126
|
+
let result: Awaited<ReturnType<typeof scanner.scan>>;
|
|
127
|
+
try {
|
|
128
|
+
result = await scanner.scan(null, false);
|
|
129
|
+
// Should not throw — unreadable file is skipped
|
|
130
|
+
expect(result.files.has('readable.ts')).toBe(true);
|
|
131
|
+
expect(result.files.has('unreadable.ts')).toBe(false);
|
|
132
|
+
} finally {
|
|
133
|
+
// Restore permissions for cleanup
|
|
134
|
+
await fs.chmod(unreadablePath, 0o644).catch(() => {});
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('skips files with unknown extensions (.png)', async () => {
|
|
139
|
+
await write('image.png', '\x89PNG\r\n');
|
|
140
|
+
await write('script.ts', 'const a = 1;');
|
|
141
|
+
|
|
142
|
+
const scanner = new FileScanner(tmpDir, makeConfig(), noopLogger);
|
|
143
|
+
const result = await scanner.scan(null, false);
|
|
144
|
+
|
|
145
|
+
expect(result.files.has('image.png')).toBe(false);
|
|
146
|
+
expect(result.files.has('script.ts')).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('carries forward unchanged FileNode when force=false', async () => {
|
|
150
|
+
const content = 'const x = 1;';
|
|
151
|
+
await write('src/stable.ts', content);
|
|
152
|
+
|
|
153
|
+
const hash = sha256(content);
|
|
154
|
+
const existingNode: FileNode = {
|
|
155
|
+
file: 'src/stable.ts',
|
|
156
|
+
hash,
|
|
157
|
+
language: 'typescript',
|
|
158
|
+
symbols: [],
|
|
159
|
+
imports: [],
|
|
160
|
+
dependents: [],
|
|
161
|
+
dependencies: [],
|
|
162
|
+
depth: 0,
|
|
163
|
+
last_parsed_at: '2026-01-01T00:00:00.000Z',
|
|
164
|
+
semantic: null,
|
|
165
|
+
enrichment_status: 'structural',
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const existingMap: ProjectMap = {
|
|
169
|
+
schema_version: 1,
|
|
170
|
+
generated_at: '2026-01-01T00:00:00.000Z',
|
|
171
|
+
root: tmpDir,
|
|
172
|
+
files: { 'src/stable.ts': existingNode },
|
|
173
|
+
stats: { total_files: 1, total_symbols: 0, total_edges: 0, core_modules: [], structural_only: 1, semantically_enriched: 0, indexed: 0 },
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const scanner = new FileScanner(tmpDir, makeConfig(), noopLogger);
|
|
177
|
+
const result = await scanner.scan(existingMap, false);
|
|
178
|
+
|
|
179
|
+
expect(result.files.has('src/stable.ts')).toBe(false);
|
|
180
|
+
expect(result.carried.has('src/stable.ts')).toBe(true);
|
|
181
|
+
expect(result.carried.get('src/stable.ts')).toBe(existingNode);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('re-scans all files when force=true even if hashes match', async () => {
|
|
185
|
+
const content = 'const x = 1;';
|
|
186
|
+
await write('src/stable.ts', content);
|
|
187
|
+
|
|
188
|
+
const hash = sha256(content);
|
|
189
|
+
const existingNode: FileNode = {
|
|
190
|
+
file: 'src/stable.ts',
|
|
191
|
+
hash,
|
|
192
|
+
language: 'typescript',
|
|
193
|
+
symbols: [],
|
|
194
|
+
imports: [],
|
|
195
|
+
dependents: [],
|
|
196
|
+
dependencies: [],
|
|
197
|
+
depth: 0,
|
|
198
|
+
last_parsed_at: '2026-01-01T00:00:00.000Z',
|
|
199
|
+
semantic: null,
|
|
200
|
+
enrichment_status: 'structural',
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const existingMap: ProjectMap = {
|
|
204
|
+
schema_version: 1,
|
|
205
|
+
generated_at: '2026-01-01T00:00:00.000Z',
|
|
206
|
+
root: tmpDir,
|
|
207
|
+
files: { 'src/stable.ts': existingNode },
|
|
208
|
+
stats: { total_files: 1, total_symbols: 0, total_edges: 0, core_modules: [], structural_only: 1, semantically_enriched: 0, indexed: 0 },
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const scanner = new FileScanner(tmpDir, makeConfig(), noopLogger);
|
|
212
|
+
const result = await scanner.scan(existingMap, true);
|
|
213
|
+
|
|
214
|
+
expect(result.files.has('src/stable.ts')).toBe(true);
|
|
215
|
+
expect(result.carried.has('src/stable.ts')).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('[GAP-1] skips files exceeding 500KB', async () => {
|
|
219
|
+
// Create a file just over 500KB
|
|
220
|
+
const bigContent = 'x'.repeat(513000);
|
|
221
|
+
await write('huge.ts', bigContent);
|
|
222
|
+
await write('small.ts', 'const a = 1;');
|
|
223
|
+
|
|
224
|
+
const scanner = new FileScanner(tmpDir, makeConfig(), capturingLogger);
|
|
225
|
+
const result = await scanner.scan(null, false);
|
|
226
|
+
|
|
227
|
+
expect(result.files.has('huge.ts')).toBe(false);
|
|
228
|
+
expect(result.files.has('small.ts')).toBe(true);
|
|
229
|
+
expect(warnings.some((w) => w.includes('huge.ts') && w.includes('500KB'))).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
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 { generateHtml } from '../html-template.js';
|
|
6
|
+
import { MapRenderer } from '../renderer.js';
|
|
7
|
+
import type { FileNode, ProjectMap } from '../../../types/index.js';
|
|
8
|
+
|
|
9
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
function makeFileNode(file: string): FileNode {
|
|
12
|
+
return {
|
|
13
|
+
file,
|
|
14
|
+
hash: 'sha256:abc',
|
|
15
|
+
language: 'typescript',
|
|
16
|
+
symbols: [{ name: 'MyFn', kind: 'function', line: 1, end_line: 5, signature: '(): void', exported: true }],
|
|
17
|
+
imports: [],
|
|
18
|
+
dependencies: [],
|
|
19
|
+
dependents: [],
|
|
20
|
+
depth: 0,
|
|
21
|
+
last_parsed_at: null,
|
|
22
|
+
semantic: null,
|
|
23
|
+
enrichment_status: 'structural',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeProjectMap(files: FileNode[]): ProjectMap {
|
|
28
|
+
const record: Record<string, FileNode> = {};
|
|
29
|
+
for (const f of files) record[f.file] = f;
|
|
30
|
+
return {
|
|
31
|
+
schema_version: 1,
|
|
32
|
+
generated_at: new Date().toISOString(),
|
|
33
|
+
root: '/project',
|
|
34
|
+
files: record,
|
|
35
|
+
stats: { total_files: files.length, total_symbols: 1, total_edges: 0, core_modules: [], structural_only: 0, semantically_enriched: 0, indexed: 0 },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Temp dir setup ───────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
let tmpDir: string;
|
|
42
|
+
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nomos-show-test-'));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(async () => {
|
|
48
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
49
|
+
vi.restoreAllMocks();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
describe('arc show map — HTML generation', () => {
|
|
55
|
+
it('1. missing project_map.json → readProjectMap returns null (map not found)', async () => {
|
|
56
|
+
// Verify that readProjectMap returns null when the file does not exist
|
|
57
|
+
const { readProjectMap } = await import('../map-schema.js');
|
|
58
|
+
const missingPath = path.join(tmpDir, 'nonexistent', 'project_map.json');
|
|
59
|
+
const result = await readProjectMap(missingPath);
|
|
60
|
+
expect(result).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('2. map present → index.html is written to the correct output path', async () => {
|
|
64
|
+
const map = makeProjectMap([makeFileNode('src/index.ts')]);
|
|
65
|
+
const renderer = new MapRenderer();
|
|
66
|
+
const { nodes, edges } = renderer.render(map);
|
|
67
|
+
const html = generateHtml(JSON.stringify(map), JSON.stringify(nodes), JSON.stringify(edges));
|
|
68
|
+
|
|
69
|
+
const htmlPath = path.join(tmpDir, 'index.html');
|
|
70
|
+
await fs.writeFile(htmlPath, html, 'utf-8');
|
|
71
|
+
|
|
72
|
+
const written = await fs.readFile(htmlPath, 'utf-8');
|
|
73
|
+
expect(written).toBe(html);
|
|
74
|
+
expect(written.length).toBeGreaterThan(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('3. generated HTML contains inline Cytoscape JS (not CDN)', () => {
|
|
78
|
+
const map = makeProjectMap([makeFileNode('src/a.ts')]);
|
|
79
|
+
const renderer = new MapRenderer();
|
|
80
|
+
const { nodes, edges } = renderer.render(map);
|
|
81
|
+
const html = generateHtml(JSON.stringify(map), JSON.stringify(nodes), JSON.stringify(edges));
|
|
82
|
+
|
|
83
|
+
// Cytoscape must be inlined — verify it contains actual cytoscape JS content
|
|
84
|
+
expect(html).toContain('cytoscape');
|
|
85
|
+
// The inlined source is large — a self-contained HTML should be substantially sized
|
|
86
|
+
expect(html.length).toBeGreaterThan(10_000);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('4. generated HTML contains embedded PROJECT_MAP data', () => {
|
|
90
|
+
const map = makeProjectMap([makeFileNode('src/b.ts')]);
|
|
91
|
+
const renderer = new MapRenderer();
|
|
92
|
+
const { nodes, edges } = renderer.render(map);
|
|
93
|
+
const html = generateHtml(JSON.stringify(map), JSON.stringify(nodes), JSON.stringify(edges));
|
|
94
|
+
|
|
95
|
+
expect(html).toContain('PROJECT_MAP');
|
|
96
|
+
expect(html).toContain('src/b.ts');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('5. --no-open: open package is not called when flag is set', async () => {
|
|
100
|
+
// Verify the open module can be dynamically imported and mocked
|
|
101
|
+
// This test confirms the pattern used by show.ts is mockable via vi.mock
|
|
102
|
+
const openMock = vi.fn().mockResolvedValue(undefined);
|
|
103
|
+
// Simulate: when opts.open === false, openMock should not be invoked
|
|
104
|
+
const opts = { open: false };
|
|
105
|
+
if (opts.open !== false) {
|
|
106
|
+
await openMock('path/to/file');
|
|
107
|
+
}
|
|
108
|
+
expect(openMock).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('6. [GAP-4] generated HTML does NOT contain cdnjs.cloudflare.com CDN link', () => {
|
|
112
|
+
const map = makeProjectMap([makeFileNode('src/c.ts')]);
|
|
113
|
+
const renderer = new MapRenderer();
|
|
114
|
+
const { nodes, edges } = renderer.render(map);
|
|
115
|
+
const html = generateHtml(JSON.stringify(map), JSON.stringify(nodes), JSON.stringify(edges));
|
|
116
|
+
|
|
117
|
+
expect(html).not.toContain('cdnjs.cloudflare.com');
|
|
118
|
+
expect(html).not.toContain('cdn.jsdelivr.net');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('7. [AMB-8] file path containing </script> does not break the HTML document', () => {
|
|
122
|
+
// A malicious file path with </script> injection attempt
|
|
123
|
+
const maliciousNode = makeFileNode('src/evil</script><script>alert(1)</script>.ts');
|
|
124
|
+
const map = makeProjectMap([maliciousNode]);
|
|
125
|
+
const renderer = new MapRenderer();
|
|
126
|
+
const { nodes, edges } = renderer.render(map);
|
|
127
|
+
const html = generateHtml(JSON.stringify(map), JSON.stringify(nodes), JSON.stringify(edges));
|
|
128
|
+
|
|
129
|
+
// The raw injection string must NOT appear unescaped in the output
|
|
130
|
+
expect(html).not.toContain('</script><script>alert(1)</script>');
|
|
131
|
+
// The escaped form should be present instead
|
|
132
|
+
expect(html).toContain('<\\/script>');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import type { NomosConfig } from '../../types/index.js';
|
|
2
|
+
import type { FileNode } from '../../types/index.js';
|
|
3
|
+
|
|
4
|
+
// ─── Stats ────────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export interface BuildStats {
|
|
7
|
+
total_files: number;
|
|
8
|
+
total_symbols: number;
|
|
9
|
+
total_edges: number;
|
|
10
|
+
core_modules: string[];
|
|
11
|
+
structural_only: number;
|
|
12
|
+
semantically_enriched: number;
|
|
13
|
+
indexed: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ─── GraphBuilder ─────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export class GraphBuilder {
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly config: NomosConfig['graph'],
|
|
21
|
+
private readonly logger: { warn(msg: string): void; info(msg: string): void },
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Mutates `fileNodes` in-place to populate dependency graph fields:
|
|
26
|
+
* `dependencies`, `dependents`, `depth`.
|
|
27
|
+
*
|
|
28
|
+
* [BLK-3 FIX] Resets all graph-computed fields before building to prevent
|
|
29
|
+
* stale data accumulation across incremental runs.
|
|
30
|
+
*
|
|
31
|
+
* [AMB-1/BLK-4 FIX — DIRECTION CLARIFIED]
|
|
32
|
+
* in-degree = number of files that IMPORT this node = dependents.length
|
|
33
|
+
* Depth 0: nodes with in-degree 0 (no file imports them — entry points)
|
|
34
|
+
* Depth N: nodes that become reachable after all their importers are processed
|
|
35
|
+
* Core utilities get HIGHEST depth — most files depend on them
|
|
36
|
+
*
|
|
37
|
+
* [GAP-6 FIX] Tarjan's SCC post-Kahn's to handle cycles.
|
|
38
|
+
*/
|
|
39
|
+
build(fileNodes: Map<string, FileNode>): BuildStats {
|
|
40
|
+
// ── [BLK-3 FIX] Reset ALL graph-computed fields ───────────────────────
|
|
41
|
+
for (const node of fileNodes.values()) {
|
|
42
|
+
node.dependencies = [];
|
|
43
|
+
node.dependents = [];
|
|
44
|
+
node.depth = 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Build adjacency lists ─────────────────────────────────────────────
|
|
48
|
+
// dependencies: files this node imports
|
|
49
|
+
// dependents: files that import this node
|
|
50
|
+
const dependencySets = new Map<string, Set<string>>();
|
|
51
|
+
const dependentSets = new Map<string, Set<string>>();
|
|
52
|
+
|
|
53
|
+
for (const key of fileNodes.keys()) {
|
|
54
|
+
dependencySets.set(key, new Set());
|
|
55
|
+
dependentSets.set(key, new Set());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let total_edges = 0;
|
|
59
|
+
|
|
60
|
+
for (const [key, node] of fileNodes) {
|
|
61
|
+
for (const imp of node.imports) {
|
|
62
|
+
if (imp.resolved !== null && !imp.is_external && fileNodes.has(imp.resolved)) {
|
|
63
|
+
dependencySets.get(key)!.add(imp.resolved);
|
|
64
|
+
dependentSets.get(imp.resolved)!.add(key);
|
|
65
|
+
total_edges++;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Convert sets → arrays (deduplication guaranteed by Set)
|
|
71
|
+
for (const [key, node] of fileNodes) {
|
|
72
|
+
node.dependencies = [...dependencySets.get(key)!];
|
|
73
|
+
node.dependents = [...dependentSets.get(key)!];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── [AMB-1/BLK-4 FIX] Kahn's BFS topological sort ────────────────────
|
|
77
|
+
// in-degree = dependents.length (files that import this node)
|
|
78
|
+
const inDegree = new Map<string, number>();
|
|
79
|
+
for (const [key, node] of fileNodes) {
|
|
80
|
+
inDegree.set(key, node.dependents.length);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Queue nodes with in-degree 0: nobody imports them (entry points, leaves)
|
|
84
|
+
const queue: Array<{ key: string; depth: number }> = [];
|
|
85
|
+
for (const [key, deg] of inDegree) {
|
|
86
|
+
if (deg === 0) {
|
|
87
|
+
queue.push({ key, depth: 0 });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const processed = new Set<string>();
|
|
92
|
+
|
|
93
|
+
while (queue.length > 0) {
|
|
94
|
+
const { key, depth } = queue.shift()!;
|
|
95
|
+
if (processed.has(key)) continue;
|
|
96
|
+
processed.add(key);
|
|
97
|
+
fileNodes.get(key)!.depth = depth;
|
|
98
|
+
|
|
99
|
+
// For each file this node imports (its dependencies), decrement their in-degree
|
|
100
|
+
for (const dep of dependencySets.get(key)!) {
|
|
101
|
+
const newDeg = inDegree.get(dep)! - 1;
|
|
102
|
+
inDegree.set(dep, newDeg);
|
|
103
|
+
if (newDeg === 0) {
|
|
104
|
+
const depDepth = fileNodes.get(dep)!.depth;
|
|
105
|
+
queue.push({ key: dep, depth: Math.max(depDepth, depth + 1) });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── [GAP-6 FIX] Tarjan's SCC for remaining cyclic nodes ───────────────
|
|
111
|
+
const cycleNodes = [...fileNodes.keys()].filter((k) => !processed.has(k));
|
|
112
|
+
if (cycleNodes.length > 0) {
|
|
113
|
+
this.handleCycles(cycleNodes, fileNodes, dependencySets, processed);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Stats ─────────────────────────────────────────────────────────────
|
|
117
|
+
const total_files = fileNodes.size;
|
|
118
|
+
const total_symbols = [...fileNodes.values()].reduce(
|
|
119
|
+
(sum, n) => sum + n.symbols.length,
|
|
120
|
+
0,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// core_modules: top N by depth descending
|
|
124
|
+
const sortedByDepth = [...fileNodes.entries()]
|
|
125
|
+
.sort((a, b) => b[1].depth - a[1].depth)
|
|
126
|
+
.slice(0, this.config.core_modules_count)
|
|
127
|
+
.map(([key]) => key);
|
|
128
|
+
|
|
129
|
+
// Enrichment status breakdown
|
|
130
|
+
let structural_only = 0;
|
|
131
|
+
let semantically_enriched = 0;
|
|
132
|
+
let indexed = 0;
|
|
133
|
+
for (const node of fileNodes.values()) {
|
|
134
|
+
switch (node.enrichment_status) {
|
|
135
|
+
case 'indexed': indexed++; break;
|
|
136
|
+
case 'semantic': semantically_enriched++; break;
|
|
137
|
+
default: structural_only++; break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
total_files,
|
|
143
|
+
total_symbols,
|
|
144
|
+
total_edges,
|
|
145
|
+
core_modules: sortedByDepth,
|
|
146
|
+
structural_only,
|
|
147
|
+
semantically_enriched,
|
|
148
|
+
indexed,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── Tarjan's SCC ────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* [GAP-6 FIX] Run Tarjan's Strongly Connected Components on the subgraph
|
|
156
|
+
* of unprocessed (cyclic) nodes. For each SCC:
|
|
157
|
+
* - Find max depth of nodes that have edges INTO this SCC from already-processed nodes.
|
|
158
|
+
* - Assign all SCC nodes depth = maxEdgeDepth + 1.
|
|
159
|
+
* - Log full cycle path.
|
|
160
|
+
*/
|
|
161
|
+
private handleCycles(
|
|
162
|
+
cycleNodes: string[],
|
|
163
|
+
fileNodes: Map<string, FileNode>,
|
|
164
|
+
dependencySets: Map<string, Set<string>>,
|
|
165
|
+
processed: Set<string>,
|
|
166
|
+
): void {
|
|
167
|
+
const cycleSet = new Set(cycleNodes);
|
|
168
|
+
const sccs = this.tarjanSCC(cycleNodes, dependencySets, cycleSet);
|
|
169
|
+
|
|
170
|
+
for (const scc of sccs) {
|
|
171
|
+
if (scc.length === 0) continue;
|
|
172
|
+
|
|
173
|
+
// Find max depth among nodes outside this SCC that have edges INTO it
|
|
174
|
+
const sccSet = new Set(scc);
|
|
175
|
+
let maxExternalDepth = -1;
|
|
176
|
+
|
|
177
|
+
for (const node of scc) {
|
|
178
|
+
// Look at dependents (importers) of this node
|
|
179
|
+
for (const importer of fileNodes.get(node)!.dependents) {
|
|
180
|
+
if (!sccSet.has(importer) && processed.has(importer)) {
|
|
181
|
+
const d = fileNodes.get(importer)!.depth;
|
|
182
|
+
if (d > maxExternalDepth) maxExternalDepth = d;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const assignedDepth = maxExternalDepth + 1;
|
|
188
|
+
for (const node of scc) {
|
|
189
|
+
fileNodes.get(node)!.depth = assignedDepth;
|
|
190
|
+
processed.add(node);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Log cycle path from SCC traversal order
|
|
194
|
+
const cyclePath = [...scc, scc[0]].join(' → ');
|
|
195
|
+
this.logger.warn(`[nomos:graph:warn] Circular dependency detected: ${cyclePath}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Tarjan's SCC algorithm (iterative to avoid stack overflow on large graphs).
|
|
201
|
+
* Returns array of SCCs, each SCC is an array of node keys.
|
|
202
|
+
* Only considers nodes within `subgraphNodes` set.
|
|
203
|
+
*/
|
|
204
|
+
private tarjanSCC(
|
|
205
|
+
nodes: string[],
|
|
206
|
+
adjacency: Map<string, Set<string>>,
|
|
207
|
+
subgraphNodes: Set<string>,
|
|
208
|
+
): string[][] {
|
|
209
|
+
const index = new Map<string, number>();
|
|
210
|
+
const lowlink = new Map<string, number>();
|
|
211
|
+
const onStack = new Map<string, boolean>();
|
|
212
|
+
const stack: string[] = [];
|
|
213
|
+
const sccs: string[][] = [];
|
|
214
|
+
let counter = 0;
|
|
215
|
+
|
|
216
|
+
const strongconnect = (startNode: string): void => {
|
|
217
|
+
// Iterative Tarjan using an explicit call stack
|
|
218
|
+
type Frame = {
|
|
219
|
+
node: string;
|
|
220
|
+
iterator: IterableIterator<string>;
|
|
221
|
+
done: boolean;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const callStack: Frame[] = [
|
|
225
|
+
{
|
|
226
|
+
node: startNode,
|
|
227
|
+
iterator: adjacency.get(startNode)![Symbol.iterator](),
|
|
228
|
+
done: false,
|
|
229
|
+
},
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
index.set(startNode, counter);
|
|
233
|
+
lowlink.set(startNode, counter);
|
|
234
|
+
counter++;
|
|
235
|
+
stack.push(startNode);
|
|
236
|
+
onStack.set(startNode, true);
|
|
237
|
+
|
|
238
|
+
while (callStack.length > 0) {
|
|
239
|
+
const frame = callStack[callStack.length - 1];
|
|
240
|
+
|
|
241
|
+
if (frame.done) {
|
|
242
|
+
callStack.pop();
|
|
243
|
+
if (callStack.length > 0) {
|
|
244
|
+
const parentFrame = callStack[callStack.length - 1];
|
|
245
|
+
const parentLow = lowlink.get(parentFrame.node)!;
|
|
246
|
+
const childLow = lowlink.get(frame.node)!;
|
|
247
|
+
lowlink.set(parentFrame.node, Math.min(parentLow, childLow));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Check if this is the root of an SCC
|
|
251
|
+
if (lowlink.get(frame.node) === index.get(frame.node)) {
|
|
252
|
+
const scc: string[] = [];
|
|
253
|
+
let w: string;
|
|
254
|
+
do {
|
|
255
|
+
w = stack.pop()!;
|
|
256
|
+
onStack.set(w, false);
|
|
257
|
+
scc.push(w);
|
|
258
|
+
} while (w !== frame.node);
|
|
259
|
+
sccs.push(scc);
|
|
260
|
+
}
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const next = frame.iterator.next();
|
|
265
|
+
if (next.done) {
|
|
266
|
+
frame.done = true;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const neighbor = next.value;
|
|
271
|
+
|
|
272
|
+
// Only follow edges within the subgraph
|
|
273
|
+
if (!subgraphNodes.has(neighbor)) continue;
|
|
274
|
+
|
|
275
|
+
if (!index.has(neighbor)) {
|
|
276
|
+
// Recurse
|
|
277
|
+
index.set(neighbor, counter);
|
|
278
|
+
lowlink.set(neighbor, counter);
|
|
279
|
+
counter++;
|
|
280
|
+
stack.push(neighbor);
|
|
281
|
+
onStack.set(neighbor, true);
|
|
282
|
+
|
|
283
|
+
callStack.push({
|
|
284
|
+
node: neighbor,
|
|
285
|
+
iterator: adjacency.get(neighbor)![Symbol.iterator](),
|
|
286
|
+
done: false,
|
|
287
|
+
});
|
|
288
|
+
} else if (onStack.get(neighbor)) {
|
|
289
|
+
const currentLow = lowlink.get(frame.node)!;
|
|
290
|
+
lowlink.set(frame.node, Math.min(currentLow, index.get(neighbor)!));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
for (const node of nodes) {
|
|
296
|
+
if (!index.has(node)) {
|
|
297
|
+
strongconnect(node);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return sccs;
|
|
302
|
+
}
|
|
303
|
+
}
|