@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,1349 @@
1
+ # Atomic Remediation Blueprint — Semantic Graph (`arc map` / `arc show map`)
2
+
3
+ **Source Plan:** `docs/map/semantic_master_plan.md`
4
+ **Red Team Audit:** `docs/map/RED_TEAM_REPORT.md`
5
+ **Status:** Re-engineered. All 4 blockers neutralized, 8 ambiguities resolved, 6 resilience gaps closed.
6
+
7
+ ---
8
+
9
+ ## 1. Executive Summary of Fixes
10
+
11
+ | Red Team Finding | Resolution | Affected Steps |
12
+ |---|---|---|
13
+ | **BLK-1**: tree-sitter native binary + esbuild = MODULE_NOT_FOUND | Switch to `web-tree-sitter` (WASM). Eliminates native `.node` binding entirely. WASM bundles cleanly with esbuild or loads from `node_modules` without path issues. Post-build verification step added. | 0.2, 2.1, 2.3, Final Check |
14
+ | **BLK-2**: No file-level locking on `project_map.json` | Use `proper-lockfile` on every read-modify-write cycle of `project_map.json`. Same package already used in `src/core/state.ts`. | 5.1j |
15
+ | **BLK-3**: `GraphBuilder.build()` mutates without clearing stale data | `build()` now resets `dependencies = []`, `dependents = []`, `depth = 0` on every `FileNode` before computing the graph. Explicit step added. | 3.2 |
16
+ | **BLK-4/AMB-1**: Kahn's algorithm direction misleading | Clarified: in-degree = `dependents.length` (files that import this node). Depth 0 = nobody imports this file. Direction documented inline in code and plan. | 3.2 |
17
+ | **AMB-2**: SemanticEnricher content source unspecified | Enricher reads file content from disk via `fs/promises.readFile()`. Content is NOT carried from `ScanResult`. Explicit re-read per file. | 4.1 |
18
+ | **AMB-3**: tree-sitter-typescript ESM vs CJS | Eliminated by switching to `web-tree-sitter` with `.wasm` grammar files. Uses `await Parser.init()` + `Language.load(wasmPath)`. No CJS/ESM ambiguity. | 2.1 |
19
+ | **AMB-4**: `ImportResolver` uses sync `fs.existsSync` | `resolve()` is now `async`. Uses `fs.access()` with a `Set<string>` cache populated from the known-files set. Zero sync filesystem calls. | 3.1 |
20
+ | **AMB-5**: ContractWriter assumes parent directory exists | Added `fs.mkdir(dir, { recursive: true })` before every `.semantic.md` write. Handles all edge cases. | 4.2 |
21
+ | **AMB-6**: Exit code 2 semantics | Changed partial-success exit code from `2` to `10`. Avoids Unix/Bash convention collision. | 5.2 |
22
+ | **AMB-7**: `readArchitecturalConstraints` path resolution | All `contextFiles` are normalized to project-root-relative paths using `path.relative(projectRoot, path.resolve(projectRoot, contextFile))` before map lookup. | 5.4b |
23
+ | **AMB-8**: HTML template JSON escaping — XSS surface | `JSON.stringify()` output is post-processed: replace `</` with `<\/` and `${` with `\\${`. Prevents `</script>` injection and template literal breakout. | 6.2a |
24
+ | **GAP-1**: No timeout on tree-sitter parse | Files exceeding 500KB are skipped with a warning before parsing. `web-tree-sitter` WASM parsing is inherently sandboxed but the size guard prevents memory exhaustion. | 2.1 |
25
+ | **GAP-2**: AI failure loses all scan/parse work | Map is written to disk with `semantic: null` BEFORE AI enrichment begins. AI enrichment then updates the map in a second write. Crash-safe. | 5.1 |
26
+ | **GAP-3**: No SIGINT/SIGTERM handler | Pipeline registers a `process.on('SIGINT')` handler that sets a cancellation flag. Enrichment loop checks the flag between batches and writes partial map on abort. | 5.1 |
27
+ | **GAP-4**: CDN dependency for visualization — offline failure | Cytoscape.js is bundled inline (~500KB minified) via a build-time fetch + embed. Fallback: `<noscript>` tag with a text-based file list. | 6.2a |
28
+ | **GAP-5**: `migrateProjectMap()` no forward compatibility | Added version ceiling check: if `schema_version > CURRENT_SCHEMA_VERSION`, throw `NomosError('graph_parse_error', 'Map was created by a newer version of arc. Please upgrade.')`. | 1.3 |
29
+ | **GAP-6**: Circular dependency — incomplete cycle reporting | Added Tarjan's SCC post-Kahn's to extract distinct cycles. Each cycle is logged with its full path. Nodes are grouped by SCC for accurate depth assignment. | 3.2 |
30
+
31
+ ---
32
+
33
+ ## 2. Pre-Flight Checklist
34
+
35
+ Every check must pass before execution begins. Failure on any check = STOP.
36
+
37
+ | # | Check | Command | Expected |
38
+ |---|-------|---------|----------|
39
+ | PF-1 | Node.js >= 20 | `node --version` | Output starts with `v20` or higher |
40
+ | PF-2 | Project root valid | `ls package.json src/cli.ts` | Both exist, exit code 0 |
41
+ | PF-3 | Clean dependency install | `npm install` | Exit code 0 |
42
+ | PF-4 | Existing tests pass | `npx vitest run` | 0 failures |
43
+ | PF-5 | `src/core/graph/` does not exist | `ls src/core/graph/ 2>&1` | "No such file or directory" |
44
+ | PF-6 | `proper-lockfile` already installed | `grep 'proper-lockfile' package.json` | Match found (used in `src/core/state.ts`) |
45
+ | PF-7 | Git working tree clean | `git status --porcelain` | Empty output (or acknowledged untracked) |
46
+
47
+ ---
48
+
49
+ ## 3. Atomic Execution Sequence
50
+
51
+ ### Phase 0: Prerequisites — Dependency & Configuration Setup
52
+
53
+ ---
54
+
55
+ #### Step 0.1: Install runtime dependencies
56
+
57
+ **Pre-Condition:** PF-1 through PF-7 pass. `package.json` exists at project root.
58
+
59
+ **Action:**
60
+ ```bash
61
+ npm install fast-glob gitignore-parser p-limit open cytoscape
62
+ ```
63
+ Note: `cytoscape` is a runtime dependency used by `html-template.ts` to read `cytoscape.min.js` from `node_modules` and embed it inline in the generated HTML (see WATCH-2 in Step 6.2a). It is NOT bundled by esbuild.
64
+
65
+ **Validation:**
66
+ ```bash
67
+ node -e "import('fast-glob').then(()=>console.log('ok'))" # → "ok"
68
+ node -e "import('p-limit').then(()=>console.log('ok'))" # → "ok"
69
+ grep '"fast-glob"' package.json # → match in dependencies
70
+ grep '"open"' package.json # → match in dependencies
71
+ grep '"cytoscape"' package.json # → match in dependencies (WATCH-2)
72
+ ```
73
+
74
+ **Rollback:** `npm uninstall fast-glob gitignore-parser p-limit open`
75
+
76
+ **Idempotency:** `npm install` is idempotent — re-running with already-installed packages is a no-op.
77
+
78
+ ---
79
+
80
+ #### Step 0.2: Install parsing & AI dependencies + update esbuild externals
81
+
82
+ **Pre-Condition:** Step 0.1 complete. `package.json` contains `fast-glob` in dependencies.
83
+
84
+ **Action:**
85
+ 1. Install WASM-based parser (NOT native `tree-sitter`):
86
+ ```bash
87
+ npm install web-tree-sitter @google/generative-ai
88
+ ```
89
+ 2. **[WATCH-1 PROTOCOL] WASM Grammar Acquisition — Deterministic 3-Tier Fallback:**
90
+
91
+ WASM grammar files are NOT shipped with `web-tree-sitter` itself. They must be obtained separately. The following tiers are tried **in order** — stop at the first that succeeds:
92
+
93
+ **Tier A — `tree-sitter-wasms` npm package (preferred):**
94
+ ```bash
95
+ npm install tree-sitter-wasms
96
+ ```
97
+ Verify the exact files exist:
98
+ ```bash
99
+ ls node_modules/tree-sitter-wasms/out/tree-sitter-typescript.wasm # → exists
100
+ ls node_modules/tree-sitter-wasms/out/tree-sitter-tsx.wasm # → exists
101
+ ```
102
+ If BOTH files exist → Tier A succeeds. Set grammar resolution paths:
103
+ ```typescript
104
+ // In parser.ts — grammar paths resolved at runtime from node_modules
105
+ const GRAMMAR_BASE = path.join(
106
+ path.dirname(require.resolve('tree-sitter-wasms/package.json')),
107
+ 'out'
108
+ );
109
+ const TS_WASM = path.join(GRAMMAR_BASE, 'tree-sitter-typescript.wasm');
110
+ const TSX_WASM = path.join(GRAMMAR_BASE, 'tree-sitter-tsx.wasm');
111
+ ```
112
+ Note: `require.resolve` works in both ESM (via `createRequire`) and CJS. This avoids fragile relative paths and survives hoisted `node_modules`.
113
+
114
+ **Tier B — Official tree-sitter GitHub Release artifacts:**
115
+ If `tree-sitter-wasms` does not provide the files (package deprecated, renamed, or missing grammars):
116
+ ```bash
117
+ mkdir -p src/core/graph/grammars
118
+ # Download pinned versions from tree-sitter-typescript releases
119
+ curl -L -o src/core/graph/grammars/tree-sitter-typescript.wasm \
120
+ "https://github.com/nicolo-ribaudo/tree-sitter-typescript/releases/download/v0.23.2/tree-sitter-typescript.wasm"
121
+ curl -L -o src/core/graph/grammars/tree-sitter-tsx.wasm \
122
+ "https://github.com/nicolo-ribaudo/tree-sitter-typescript/releases/download/v0.23.2/tree-sitter-tsx.wasm"
123
+ ```
124
+ Verify:
125
+ ```bash
126
+ file src/core/graph/grammars/tree-sitter-typescript.wasm # → "WebAssembly (wasm) binary module"
127
+ file src/core/graph/grammars/tree-sitter-tsx.wasm # → "WebAssembly (wasm) binary module"
128
+ ```
129
+ Grammar paths in `parser.ts`:
130
+ ```typescript
131
+ const GRAMMAR_BASE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'grammars');
132
+ ```
133
+
134
+ **Tier C — Build from source (last resort):**
135
+ ```bash
136
+ npm install -g tree-sitter-cli
137
+ git clone --depth 1 https://github.com/nicolo-ribaudo/tree-sitter-typescript.git /tmp/ts-grammar
138
+ cd /tmp/ts-grammar/typescript && tree-sitter build --wasm
139
+ cd /tmp/ts-grammar/tsx && tree-sitter build --wasm
140
+ cp /tmp/ts-grammar/typescript/tree-sitter-typescript.wasm src/core/graph/grammars/
141
+ cp /tmp/ts-grammar/tsx/tree-sitter-tsx.wasm src/core/graph/grammars/
142
+ rm -rf /tmp/ts-grammar
143
+ ```
144
+
145
+ **CRITICAL GATE — Grammar Load Smoke Test:**
146
+ Regardless of which tier succeeded, run this Node.js script to confirm WASM grammars actually load:
147
+ ```bash
148
+ node --input-type=module -e "
149
+ import Parser from 'web-tree-sitter';
150
+ await Parser.init();
151
+ const p = new Parser();
152
+ // Adjust path based on which tier was used
153
+ const lang = await Parser.Language.load('<path-to-tree-sitter-typescript.wasm>');
154
+ p.setLanguage(lang);
155
+ const tree = p.parse('const x: number = 1;');
156
+ console.log(tree.rootNode.type === 'program' ? 'GRAMMAR_OK' : 'GRAMMAR_FAIL');
157
+ "
158
+ ```
159
+ Expected output: `GRAMMAR_OK`. If output is `GRAMMAR_FAIL` or the script throws → STOP. Do not proceed to Phase 2.
160
+
161
+ 3. In `package.json`, update the `"build"` script to append:
162
+ ```
163
+ --external:@google/generative-ai --external:open
164
+ ```
165
+ Note: `web-tree-sitter` is pure JS/WASM — it does NOT need `--external`. This eliminates BLK-1 entirely.
166
+
167
+ 4. **Grammar path abstraction** — To insulate `parser.ts` from which tier was used, create a one-line config constant:
168
+ ```typescript
169
+ // src/core/graph/grammar-paths.ts (new file, ~10 lines)
170
+ import { createRequire } from 'node:module';
171
+ import path from 'node:path';
172
+ import { fileURLToPath } from 'node:url';
173
+ import fs from 'node:fs';
174
+
175
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
176
+ const require = createRequire(import.meta.url);
177
+
178
+ function resolveGrammar(name: string): string {
179
+ // Tier A: try tree-sitter-wasms
180
+ try {
181
+ const wasmsBase = path.join(
182
+ path.dirname(require.resolve('tree-sitter-wasms/package.json')),
183
+ 'out'
184
+ );
185
+ const wasmPath = path.join(wasmsBase, name);
186
+ if (fs.existsSync(wasmPath)) return wasmPath;
187
+ } catch { /* package not installed */ }
188
+
189
+ // Tier B/C: local grammars directory
190
+ const localPath = path.join(__dirname, 'grammars', name);
191
+ if (fs.existsSync(localPath)) return localPath;
192
+
193
+ throw new Error(
194
+ `WASM grammar "${name}" not found. Run Step 0.2 grammar acquisition protocol.`
195
+ );
196
+ }
197
+
198
+ export const TS_WASM_PATH = resolveGrammar('tree-sitter-typescript.wasm');
199
+ export const TSX_WASM_PATH = resolveGrammar('tree-sitter-tsx.wasm');
200
+ ```
201
+ `parser.ts` imports `{ TS_WASM_PATH, TSX_WASM_PATH }` from `./grammar-paths.js` — single source of truth.
202
+
203
+ **Validation:**
204
+ ```bash
205
+ node -e "import('web-tree-sitter').then(()=>console.log('ok'))" # → "ok"
206
+ grep 'external:@google/generative-ai' package.json # → match
207
+ grep 'external:open' package.json # → match
208
+ npm run build # → exit code 0
209
+ node dist/cli.js --help # → shows help (BLK-1 post-build check)
210
+ # WATCH-1 CRITICAL GATE:
211
+ node --input-type=module -e "import Parser from 'web-tree-sitter'; await Parser.init(); console.log('WASM_INIT_OK')"
212
+ # → WASM_INIT_OK
213
+ ```
214
+
215
+ **Rollback:** Revert `package.json` to git HEAD (`git checkout package.json`), then `npm install`. Remove `src/core/graph/grammars/` if created. `npm uninstall tree-sitter-wasms` if installed.
216
+
217
+ **Idempotency:** `npm install` is idempotent. Grammar file resolution is deterministic — `resolveGrammar()` checks existence before returning. Build script append is guarded by checking if flag already exists.
218
+
219
+ ---
220
+
221
+ #### Step 0.3: Add new `NomosErrorCode` values
222
+
223
+ **Pre-Condition:** `src/core/errors.ts` exists. Contains `NomosErrorCode` union type.
224
+
225
+ **Action:** Add four new error code literals to the `NomosErrorCode` union type, immediately after `'certificate_invalid'`:
226
+ ```typescript
227
+ | 'graph_map_not_found'
228
+ | 'graph_parse_error'
229
+ | 'graph_ai_key_missing'
230
+ | 'graph_write_failed'
231
+ ```
232
+
233
+ **Validation:**
234
+ ```bash
235
+ grep 'graph_map_not_found' src/core/errors.ts # → exactly 1 match
236
+ grep 'graph_write_failed' src/core/errors.ts # → exactly 1 match
237
+ npx tsc --noEmit # → exit code 0
238
+ ```
239
+
240
+ **Rollback:** `git checkout src/core/errors.ts`
241
+
242
+ **Idempotency:** Check if `'graph_map_not_found'` already exists before inserting. If present, skip.
243
+
244
+ ---
245
+
246
+ #### Step 0.4: Update `.gitignore`
247
+
248
+ **Pre-Condition:** `.gitignore` exists at project root.
249
+
250
+ **Action:** Append (if not already present):
251
+ ```
252
+ *.semantic.md
253
+ tasks-management/graph/
254
+ .project-map.lock
255
+ ```
256
+
257
+ **Validation:**
258
+ ```bash
259
+ grep '*.semantic.md' .gitignore # → exactly 1 match
260
+ grep 'tasks-management/graph/' .gitignore # → exactly 1 match
261
+ grep '.project-map.lock' .gitignore # → exactly 1 match (WATCH-3: lockfile artifact)
262
+ ```
263
+
264
+ **Rollback:** Remove appended lines manually.
265
+
266
+ **Idempotency:** `grep` check before appending. If lines exist, skip.
267
+
268
+ ---
269
+
270
+ ### Phase 1: Foundation — Types, Schema, and File Discovery
271
+
272
+ **Gate:** Phase 0 complete (all 4 steps verified).
273
+
274
+ ---
275
+
276
+ #### Step 1.1: Define all Semantic Graph types
277
+
278
+ **Pre-Condition:** `src/types/index.ts` exists and compiles.
279
+
280
+ **Action:** Append after a `// ─── Semantic Graph Types ───` section comment:
281
+ - `export interface SymbolEntry { name: string; kind: 'class' | 'function' | 'method' | 'interface' | 'type' | 'enum' | 'variable' | 'export'; line: number; end_line: number | null; signature: string | null; exported: boolean; }`
282
+ - `export interface ImportEntry { source: string; resolved: string | null; symbols: string[]; is_external: boolean; }`
283
+ - `export interface SemanticInfo { overview: string; purpose: string; key_logic: string[]; usage_context: string[]; source_hash: string; enriched_at: string; model: string; }`
284
+ - `export interface FileNode { file: string; hash: string; language: string; symbols: SymbolEntry[]; imports: ImportEntry[]; dependents: string[]; dependencies: string[]; depth: number; last_parsed_at: string | null; semantic: SemanticInfo | null; }`
285
+ - `export interface ProjectMap { schema_version: 1; generated_at: string; root: string; files: Record<string, FileNode>; stats: { total_files: number; total_symbols: number; total_edges: number; core_modules: string[]; }; }`
286
+
287
+ **Validation:**
288
+ ```bash
289
+ npx tsc --noEmit # → exit code 0
290
+ grep 'export interface FileNode' src/types/index.ts # → exactly 1 match
291
+ grep 'export interface ProjectMap' src/types/index.ts # → exactly 1 match
292
+ ```
293
+
294
+ **Rollback:** Remove appended block.
295
+
296
+ **Idempotency:** Check if `interface FileNode` exists before appending.
297
+
298
+ ---
299
+
300
+ #### Step 1.2: Add `graph` section to NomosConfig and GraphConfigSchema
301
+
302
+ **Pre-Condition:** Step 1.1 complete. `src/types/index.ts` has `NomosConfig` interface. `src/core/config.ts` has `NomosConfigSchema`.
303
+
304
+ **Action:**
305
+ 1. In `src/types/index.ts`, add `graph` property to `NomosConfig`:
306
+ ```typescript
307
+ graph: {
308
+ exclude_patterns: string[];
309
+ ai_enrichment: boolean;
310
+ ai_model: string;
311
+ ai_concurrency: number;
312
+ ai_requests_per_minute: number;
313
+ max_file_chars: number;
314
+ core_modules_count: number;
315
+ output_dir: string;
316
+ };
317
+ ```
318
+ 2. In `src/core/config.ts`, define `GraphConfigSchema`:
319
+ ```typescript
320
+ const GraphConfigSchema = z.object({
321
+ exclude_patterns: z.array(z.string()).default(['node_modules', 'dist', '*.test.*', '*.spec.*', '*.semantic.md']),
322
+ ai_enrichment: z.boolean().default(true),
323
+ ai_model: z.string().default('gemini-1.5-flash'),
324
+ ai_concurrency: z.number().int().positive().default(5),
325
+ ai_requests_per_minute: z.number().int().positive().default(14),
326
+ max_file_chars: z.number().positive().default(4000),
327
+ core_modules_count: z.number().int().positive().default(10),
328
+ output_dir: z.string().default('tasks-management/graph'),
329
+ });
330
+ ```
331
+ 3. Add to `NomosConfigSchema`: `graph: GraphConfigSchema.default(() => GraphConfigSchema.parse({}))`
332
+
333
+ **Validation:**
334
+ ```bash
335
+ npx tsc --noEmit # → exit code 0
336
+ grep 'GraphConfigSchema' src/core/config.ts # → at least 2 matches
337
+ npx vitest run # → all existing tests pass
338
+ ```
339
+
340
+ **Rollback:** `git checkout src/types/index.ts src/core/config.ts`
341
+
342
+ ---
343
+
344
+ #### Step 1.3: Implement `ProjectMapSchema`, `migrateProjectMap()`, and `readProjectMap()`
345
+
346
+ **Pre-Condition:** Step 1.1 complete. Types available.
347
+
348
+ **Target File:** `src/core/graph/map-schema.ts` (new)
349
+
350
+ **Action:** Create with:
351
+ 1. Zod schemas for `SymbolEntrySchema`, `ImportEntrySchema`, `SemanticInfoSchema`, `FileNodeSchema`, `ProjectMapSchema`.
352
+ 2. `CURRENT_SCHEMA_VERSION = 1` constant (exported).
353
+ 3. `migrations: Record<number, (raw: unknown) => unknown>` — empty.
354
+ 4. `migrateProjectMap(raw: unknown): ProjectMap`:
355
+ - **[GAP-5 FIX]** First check: `if (raw.schema_version > CURRENT_SCHEMA_VERSION) throw new NomosError('graph_parse_error', 'Map was created by a newer version of arc. Please upgrade.')`
356
+ - Then apply migrations from current version to `CURRENT_SCHEMA_VERSION`.
357
+ - Validate with `ProjectMapSchema.parse()`.
358
+ 5. `readProjectMap(mapPath: string): Promise<ProjectMap | null>`:
359
+ - Read JSON from `mapPath` using `fs/promises`.
360
+ - Return `null` on `ENOENT`.
361
+ - Throw `NomosError('graph_parse_error', ...)` on parse/validation failure.
362
+
363
+ **Validation:**
364
+ ```bash
365
+ npx tsc --noEmit # → exit code 0
366
+ grep 'export const CURRENT_SCHEMA_VERSION' src/core/graph/map-schema.ts # → 1 match
367
+ grep 'Please upgrade' src/core/graph/map-schema.ts # → 1 match (GAP-5)
368
+ grep 'export function migrateProjectMap' src/core/graph/map-schema.ts # → 1 match
369
+ grep 'export async function readProjectMap' src/core/graph/map-schema.ts # → 1 match
370
+ ```
371
+
372
+ **Rollback:** `rm src/core/graph/map-schema.ts`
373
+
374
+ ---
375
+
376
+ #### Step 1.4: Implement `FileScanner`
377
+
378
+ **Pre-Condition:** Step 0.1 complete (`fast-glob`, `gitignore-parser` installed). Step 1.1 complete (types defined).
379
+
380
+ **Target File:** `src/core/graph/scanner.ts` (new)
381
+
382
+ **Action:** Create `FileScanner` class:
383
+ 1. Constructor: `(projectRoot: string, config: NomosConfig['graph'], logger: Logger)`
384
+ 2. Module-level `LANGUAGE_MAP` constant (exported): `.ts`, `.tsx`, `.mts`, `.cts`, `.d.ts`, `.js`, `.mjs`, `.cjs`, `.jsx`, `.py`, `.go`, `.rs`
385
+ 3. `async scan(existingMap: ProjectMap | null, force: boolean, globPatterns?: string[]): Promise<ScanResult>`
386
+ - `ScanResult = { files: Map<string, { file: string; hash: string; language: string; content: string }>; carried: Map<string, FileNode> }`
387
+ - Read `.gitignore` via `gitignore-parser`. If missing, use empty ignore list.
388
+ - `fast-glob` with patterns `**/*`, applying gitignore + `config.exclude_patterns` as `ignore`.
389
+ - For each file: check extension in `LANGUAGE_MAP` (skip unknown). Compute `sha256:<hex>` via `node:crypto`. On `EACCES`/`ENOENT`, log warning, skip.
390
+ - **[GAP-1 FIX]** Skip files exceeding 500KB (`stat.size > 512000`) with warning: `[nomos:graph:warn] Skipping {file} — exceeds 500KB size limit`.
391
+ - Incremental: if `existingMap` and `!force`, compare hash. Unchanged → `carried`. Changed → `files`.
392
+
393
+ **Validation:**
394
+ ```bash
395
+ npx tsc --noEmit # → exit code 0
396
+ grep 'export class FileScanner' src/core/graph/scanner.ts # → 1 match
397
+ grep '512000' src/core/graph/scanner.ts # → 1 match (GAP-1 size guard)
398
+ ```
399
+
400
+ **Rollback:** `rm src/core/graph/scanner.ts`
401
+
402
+ ---
403
+
404
+ #### Step 1.5: Test `FileScanner`
405
+
406
+ **Pre-Condition:** Step 1.4 complete.
407
+
408
+ **Target File:** `src/core/graph/__tests__/scanner.test.ts` (new)
409
+
410
+ **Action:** vitest tests covering:
411
+ 1. Finds `.ts` and `.js` files in a tmp directory.
412
+ 2. Computes correct SHA-256 hashes.
413
+ 3. Detects correct language from extension.
414
+ 4. Excludes `node_modules` directory.
415
+ 5. Handles unreadable file gracefully (no throw).
416
+ 6. Skips files with unknown extensions (`.png`).
417
+ 7. Carries forward unchanged `FileNode` when `force=false`.
418
+ 8. Re-scans all files when `force=true` even if hashes match.
419
+ 9. **[GAP-1]** Skips files exceeding 500KB.
420
+
421
+ **Validation:** `npx vitest run src/core/graph/__tests__/scanner.test.ts` — all pass.
422
+
423
+ **Rollback:** Delete test file and re-evaluate Step 1.4.
424
+
425
+ ---
426
+
427
+ ### Phase 2: AST Parsing & Symbol Extraction
428
+
429
+ **Gate:** Phase 1 complete (Steps 1.1–1.5 verified).
430
+
431
+ ---
432
+
433
+ #### Step 2.1: Implement `ASTParser` — WASM-based grammar + symbol extraction
434
+
435
+ **Pre-Condition:** Step 0.2 complete (`web-tree-sitter` installed, WASM grammars available).
436
+
437
+ **Target File:** `src/core/graph/parser.ts` (new)
438
+
439
+ **Action:** Create `ASTParser` class:
440
+ 1. **[BLK-1 FIX + WATCH-1]** Constructor uses `web-tree-sitter` (WASM), NOT native `tree-sitter`. Grammar paths are resolved via the `grammar-paths.ts` abstraction layer (created in Step 0.2):
441
+ ```typescript
442
+ import Parser from 'web-tree-sitter';
443
+ import { TS_WASM_PATH, TSX_WASM_PATH } from './grammar-paths.js';
444
+
445
+ export class ASTParser {
446
+ private parser: Parser | null = null;
447
+ private tsLang: Parser.Language | null = null;
448
+ private tsxLang: Parser.Language | null = null;
449
+
450
+ async init(): Promise<void> {
451
+ await Parser.init();
452
+ this.parser = new Parser();
453
+ // Grammar paths resolved by grammar-paths.ts (Tier A: node_modules, Tier B/C: local)
454
+ this.tsLang = await Parser.Language.load(TS_WASM_PATH);
455
+ this.tsxLang = await Parser.Language.load(TSX_WASM_PATH);
456
+ }
457
+ ```
458
+ No `__dirname` hacks, no conditional path resolution in parser code. `grammar-paths.ts` handles all tier fallback logic.
459
+ 2. `parse(filePath: string, content: string, language: string): ParseResult` where `ParseResult = { symbols: SymbolEntry[]; imports: ImportEntry[] }`.
460
+ 3. **[AMB-3 FIX]** Grammar selection: No CJS/ESM ambiguity. `web-tree-sitter` loads `.wasm` files uniformly:
461
+ - `.tsx` → `this.tsxLang`
462
+ - All other TS variants → `this.tsLang`
463
+ - JS files → `this.tsLang` (TypeScript grammar parses JS superset)
464
+ - Unsupported languages → return empty `{ symbols: [], imports: [] }` with debug log.
465
+ 4. Error-node threshold: count `ERROR` node chars. If `errorChars / totalChars > 0.20`, log warning, return empty result.
466
+ 5. Symbol extraction via tree walk (unchanged from original plan):
467
+ - `function_declaration`, `arrow_function` → `kind: 'function'`
468
+ - `class_declaration` → `kind: 'class'`; child `method_definition` → `kind: 'method'`, `name: 'ClassName.methodName'`
469
+ - `interface_declaration` → `kind: 'interface'`
470
+ - `type_alias_declaration` → `kind: 'type'`
471
+ - `enum_declaration` → `kind: 'enum'`
472
+ - Top-level `lexical_declaration` with `export` parent → `kind: 'variable'`
473
+ 6. Line extraction: `line = node.startPosition.row + 1`, `end_line = node.endPosition.row + 1`.
474
+ 7. Signature capture per original plan.
475
+
476
+ **Validation:**
477
+ ```bash
478
+ npx tsc --noEmit # → exit code 0
479
+ grep 'web-tree-sitter' src/core/graph/parser.ts # → at least 1 match
480
+ grep 'export class ASTParser' src/core/graph/parser.ts # → 1 match
481
+ grep 'async init' src/core/graph/parser.ts # → 1 match (WASM requires async init)
482
+ ```
483
+
484
+ **Rollback:** `rm src/core/graph/parser.ts`
485
+
486
+ ---
487
+
488
+ #### Step 2.2: Implement import extraction in `ASTParser`
489
+
490
+ **Pre-Condition:** Step 2.1 complete.
491
+
492
+ **Target File:** `src/core/graph/parser.ts` (modify)
493
+
494
+ **Action:** In the `parse()` tree walk, add:
495
+ 1. `import_statement` → extract `source` from string literal, `symbols` from named specifiers. Default/namespace → empty `symbols`.
496
+ 2. `call_expression` with callee `require` → extract `source` from first argument string literal.
497
+ 3. Create `ImportEntry` with `resolved: null`, `is_external: false` (resolver corrects later).
498
+
499
+ **Validation:**
500
+ ```bash
501
+ npx tsc --noEmit # → exit code 0
502
+ grep 'import_statement' src/core/graph/parser.ts # → at least 1 match
503
+ ```
504
+
505
+ **Rollback:** Revert import extraction changes only (keep Step 2.1).
506
+
507
+ ---
508
+
509
+ #### Step 2.3: Test `ASTParser`
510
+
511
+ **Pre-Condition:** Steps 2.1 + 2.2 complete.
512
+
513
+ **Target File:** `src/core/graph/__tests__/parser.test.ts` (new)
514
+
515
+ **Action:** vitest tests covering:
516
+ 1. Function declarations (name, line, end_line, exported, signature).
517
+ 2. Class + method extraction (`ClassName.methodName`).
518
+ 3. Interface, type, enum declarations.
519
+ 4. `export const` variable declarations.
520
+ 5. TSX grammar selection for `.tsx` extension (test with JSX content).
521
+ 6. Error-node threshold: malformed TS (> 20% errors) → empty arrays.
522
+ 7. Import statements with named symbols.
523
+ 8. `require()` calls.
524
+ 9. `resolved` is always `null` at this stage.
525
+
526
+ **Important:** `ASTParser.init()` must be called in `beforeAll` (WASM init is async).
527
+
528
+ **Validation:** `npx vitest run src/core/graph/__tests__/parser.test.ts` — all pass.
529
+
530
+ **Rollback:** Fix failing tests. If WASM loading fails, verify Step 0.2 grammar file paths.
531
+
532
+ ---
533
+
534
+ ### Phase 3: Import Resolution & Dependency Graph
535
+
536
+ **Gate:** Phase 2 complete (Steps 2.1–2.3 verified).
537
+
538
+ ---
539
+
540
+ #### Step 3.1: Implement `ImportResolver` (async)
541
+
542
+ **Pre-Condition:** Phase 2 complete.
543
+
544
+ **Target File:** `src/core/graph/resolver.ts` (new)
545
+
546
+ **Action:** Create `ImportResolver` class:
547
+ 1. Constructor: `(projectRoot: string, logger: Logger)` — reads `tsconfig.json` to extract `compilerOptions.paths`. Missing/no-paths → empty alias map.
548
+ 2. **[AMB-4 FIX]** `async resolve(importSource: string, importerFile: string, knownFiles: Set<string>): Promise<ResolveResult>` where `ResolveResult = { resolved: string | null; is_external: boolean }`.
549
+ 3. Resolution logic (in order):
550
+ a. **External check:** Does not start with `.`, `/`, or matched tsconfig alias → `{ resolved: null, is_external: true }`.
551
+ b. **Tsconfig alias:** Match against `compilerOptions.paths` keys (supporting `*` wildcard). Substitute capture into first value pattern. Continue to relative resolution.
552
+ c. **Relative resolution:** Compute absolute path from importer's directory. Try extensions: `.ts`, `.tsx`, `.js`. Then `/index.ts`, `/index.tsx`, `/index.js`. Check each against `knownFiles` set. First match → `{ resolved: match, is_external: false }`.
553
+ d. **Path traversal guard:** If resolved absolute path falls outside `projectRoot` → `{ resolved: null, is_external: true }`. Log warning.
554
+ e. **Unresolvable:** Log `[nomos:graph:warn] Cannot resolve import '{source}' from '{importer}'` → `{ resolved: null, is_external: false }`.
555
+ 4. **[AMB-4 FIX + WATCH-4] `knownFiles` Set Contract:**
556
+
557
+ The `knownFiles: Set<string>` parameter replaces ALL filesystem calls. This is O(1) per lookup vs O(1) amortized with syscall overhead. **Critical ordering invariant:**
558
+
559
+ > `knownFiles` MUST be populated from BOTH `ScanResult.files` (newly scanned) AND `ScanResult.carried` (unchanged from previous run) BEFORE any `resolve()` call.
560
+
561
+ The Set is constructed in `MapPipeline.run()` (Step 5.1) as follows:
562
+ ```typescript
563
+ // Step 5.1g — build knownFiles BEFORE resolution
564
+ const knownFiles = new Set<string>();
565
+ for (const key of parsedFiles.keys()) knownFiles.add(key); // newly parsed
566
+ for (const key of carriedFiles.keys()) knownFiles.add(key); // unchanged from prev run
567
+ ```
568
+
569
+ **Why both sources matter:** If only `parsedFiles` is included, carried-forward files (unchanged since last run) become invisible to the resolver. Import `./utils/hash` from a changed file would fail to resolve to `src/utils/hash.ts` if that file was unchanged — a silent, incremental-only bug.
570
+
571
+ **The resolver NEVER calls `fs.existsSync`, `fs.access`, `fs.stat`, or any filesystem API.** All existence checks use `knownFiles.has(candidatePath)`. The `async` signature exists solely for interface consistency — the implementation is synchronous in practice.
572
+
573
+ **Validation:**
574
+ ```bash
575
+ npx tsc --noEmit # → exit code 0
576
+ grep 'export class ImportResolver' src/core/graph/resolver.ts # → 1 match
577
+ grep 'async resolve' src/core/graph/resolver.ts # → 1 match (AMB-4)
578
+ grep -c 'existsSync' src/core/graph/resolver.ts # → 0 matches (AMB-4 verified)
579
+ ```
580
+
581
+ **Rollback:** `rm src/core/graph/resolver.ts`
582
+
583
+ ---
584
+
585
+ #### Step 3.2: Implement `GraphBuilder`
586
+
587
+ **Pre-Condition:** Step 3.1 complete.
588
+
589
+ **Target File:** `src/core/graph/builder.ts` (new)
590
+
591
+ **Action:** Create `GraphBuilder` class:
592
+ 1. Constructor: `(config: NomosConfig['graph'], logger: Logger)`
593
+ 2. `build(fileNodes: Map<string, FileNode>): { total_files: number; total_symbols: number; total_edges: number; core_modules: string[] }` — mutates `FileNode` objects in-place.
594
+ 3. Logic:
595
+ a. **[BLK-3 FIX — MANDATORY]** Reset ALL graph-computed fields before building:
596
+ ```typescript
597
+ for (const node of fileNodes.values()) {
598
+ node.dependencies = [];
599
+ node.dependents = [];
600
+ node.depth = 0;
601
+ }
602
+ ```
603
+ This prevents stale data accumulation across incremental runs.
604
+ b. **Adjacency list:** For each file, iterate `imports[]`. For `resolved !== null && !is_external`: add `resolved` to file's `dependencies` Set; add file to target's `dependents` Set. Deduplicate via `Set`, convert to array.
605
+ c. **[AMB-1/BLK-4 FIX] Kahn's BFS topological sort — DIRECTION CLARIFIED:**
606
+ ```
607
+ DIRECTION: in-degree = number of files that IMPORT this node = dependents.length
608
+ Depth 0: nodes with in-degree 0 (no file imports them — entry points, leaf consumers)
609
+ Depth N: nodes that become available after all their importers are processed
610
+ Core utilities (types/index.ts, config.ts) get HIGHEST depth — most files depend on them
611
+ ```
612
+ - Compute in-degree for each node: `dependents.length`
613
+ - Initialize queue with all nodes where `dependents.length === 0` → `depth = 0`
614
+ - BFS: dequeue node, for each entry in `node.dependencies`: decrement that target's in-degree. If in-degree reaches 0, enqueue with `depth = currentDepth + 1`.
615
+ d. **[GAP-6 FIX] Cycle detection with Tarjan's SCC:**
616
+ After Kahn's BFS, if unprocessed nodes remain (in-degree > 0), they contain cycles:
617
+ - Run **Tarjan's Strongly Connected Components** on the subgraph of remaining nodes.
618
+ - For each SCC (each is a distinct cycle):
619
+ - Find max depth among nodes that have edges INTO this SCC from already-processed nodes.
620
+ - Assign all SCC nodes `depth = maxDepth + 1`.
621
+ - Log the full cycle path: `[nomos:graph:warn] Circular dependency detected: {a.ts → b.ts → c.ts → a.ts}` (extracted from SCC traversal order).
622
+ e. **Stats:** `total_files = fileNodes.size`, `total_symbols = sum(symbols.length)`, `total_edges = count of non-external resolved imports`, `core_modules = top N by depth desc` (N = `config.core_modules_count`).
623
+
624
+ **Validation:**
625
+ ```bash
626
+ npx tsc --noEmit # → exit code 0
627
+ grep 'export class GraphBuilder' src/core/graph/builder.ts # → 1 match
628
+ grep 'node.dependencies = \[\]' src/core/graph/builder.ts # → 1 match (BLK-3 reset)
629
+ grep 'node.dependents = \[\]' src/core/graph/builder.ts # → 1 match (BLK-3 reset)
630
+ grep -i 'tarjan' src/core/graph/builder.ts # → at least 1 match (GAP-6)
631
+ grep 'in-degree = dependents' src/core/graph/builder.ts # → 1 match (AMB-1 direction comment)
632
+ ```
633
+
634
+ **Rollback:** `rm src/core/graph/builder.ts`
635
+
636
+ ---
637
+
638
+ #### Step 3.3: Test `ImportResolver`
639
+
640
+ **Pre-Condition:** Step 3.1 complete.
641
+
642
+ **Target File:** `src/core/graph/__tests__/resolver.test.ts` (new)
643
+
644
+ **Action:** vitest tests:
645
+ 1. `./utils/hash` → `src/utils/hash.ts` (extension append).
646
+ 2. `./utils` → `src/utils/index.ts` (index resolution).
647
+ 3. Tsconfig alias `@/utils` → `src/utils/index.ts`.
648
+ 4. `zod` → external.
649
+ 5. `commander` → external.
650
+ 6. Path traversal `../../outside-project` → external with warning.
651
+ 7. Unresolvable relative → `{ resolved: null, is_external: false }`.
652
+ 8. Missing `tsconfig.json` → alias skipped gracefully.
653
+ 9. **[AMB-4]** All resolution uses `knownFiles` Set, no filesystem calls.
654
+ 10. **[WATCH-4]** Resolver finds a carried-forward file: `knownFiles` contains `src/utils/hash.ts` (from carried map, not freshly scanned) → `./utils/hash` resolves correctly. This confirms the Set includes both new and carried files.
655
+
656
+ **Validation:** `npx vitest run src/core/graph/__tests__/resolver.test.ts` — all pass.
657
+
658
+ **Rollback:** Fix tests or re-evaluate Step 3.1.
659
+
660
+ ---
661
+
662
+ #### Step 3.4: Test `GraphBuilder`
663
+
664
+ **Pre-Condition:** Step 3.2 complete.
665
+
666
+ **Target File:** `src/core/graph/__tests__/builder.test.ts` (new)
667
+
668
+ **Action:** vitest tests:
669
+ 1. Chain: A→B→C → depths: A=0, B=1, C=2.
670
+ 2. Fan-in: A→C, B→C → C highest depth, A,B = 0.
671
+ 3. Forward/reverse edges correctly populated.
672
+ 4. 2-node cycle: A↔B → both `depth = max + 1`, warning logged.
673
+ 5. 3-node cycle: A→B→C→A → all three get cycle depth, warning with full path.
674
+ 6. **[BLK-3]** Incremental: run `build()` twice on same nodes with changed imports → second run does NOT carry stale edges.
675
+ 7. **[GAP-6]** Disconnected cycles: A↔B and C↔D with no connection → distinct cycle warnings logged for each.
676
+ 8. `core_modules` extraction: top N by depth.
677
+ 9. Stats accuracy.
678
+
679
+ **Validation:** `npx vitest run src/core/graph/__tests__/builder.test.ts` — all pass.
680
+
681
+ **Rollback:** Fix tests or re-evaluate Step 3.2.
682
+
683
+ ---
684
+
685
+ ### Phase 4: AI Semantic Enrichment
686
+
687
+ **Gate:** Phase 3 complete (Steps 3.1–3.4 verified).
688
+
689
+ ---
690
+
691
+ #### Step 4.1: Implement `SemanticEnricher`
692
+
693
+ **Pre-Condition:** Phase 3 complete. `@google/generative-ai` installed.
694
+
695
+ **Target File:** `src/core/graph/enricher.ts` (new)
696
+
697
+ **Action:** Create `SemanticEnricher` class:
698
+ 1. Constructor: `(config: NomosConfig['graph'], logger: Logger)` — validates `GEMINI_API_KEY`. Missing + `ai_enrichment=true` → throw `NomosError('graph_ai_key_missing')`. Init `GoogleGenerativeAI` client + `p-limit(config.ai_concurrency)`.
699
+ 2. `async enrich(fileNodes: Map<string, FileNode>, cancellationFlag: { cancelled: boolean }): Promise<number>` — returns count of failures. Mutates `FileNode.semantic`.
700
+ 3. Logic per file:
701
+ a. Staleness: skip if `semantic !== null && semantic.source_hash === hash`.
702
+ b. **[AMB-2 FIX]** Content source: read file from disk via `fs/promises.readFile(path.join(projectRoot, fileNode.file), 'utf-8')`. Do NOT rely on `ScanResult.content` — it may be garbage-collected. Truncate at last complete line before `config.max_file_chars`.
703
+ c. Prompt: File, Language, Exports, Used by, content, JSON schema instruction (per task spec).
704
+ d. Gemini call with `responseSchema` for structured output.
705
+ e. Rate limiting: `p-limit` + minimum gap of `Math.ceil(60000 / config.ai_requests_per_minute)` ms.
706
+ f. Retry: 429/503 → 3 retries, exponential backoff (2s, 4s, 8s). After 3 failures → `semantic: null`, log error.
707
+ g. Zod validation on response. Failure → log truncated raw (200 chars), `semantic: null`.
708
+ h. Populate `SemanticInfo`.
709
+ i. **[GAP-3 FIX]** Between each batch, check `cancellationFlag.cancelled`. If true, stop processing and return current failure count.
710
+
711
+ **Validation:**
712
+ ```bash
713
+ npx tsc --noEmit # → exit code 0
714
+ grep 'export class SemanticEnricher' src/core/graph/enricher.ts # → 1 match
715
+ grep 'readFile' src/core/graph/enricher.ts # → at least 1 match (AMB-2: reads from disk)
716
+ grep 'cancellationFlag' src/core/graph/enricher.ts # → at least 1 match (GAP-3)
717
+ ```
718
+
719
+ **Rollback:** `rm src/core/graph/enricher.ts`
720
+
721
+ ---
722
+
723
+ #### Step 4.2: Implement `ContractWriter`
724
+
725
+ **Pre-Condition:** Step 4.1 complete.
726
+
727
+ **Target File:** `src/core/graph/contract-writer.ts` (new)
728
+
729
+ **Action:** Create `ContractWriter` class:
730
+ 1. Constructor: `(projectRoot: string, logger: Logger)`
731
+ 2. `async writeContracts(fileNodes: Map<string, FileNode>): Promise<void>`
732
+ 3. Logic per file:
733
+ a. Skip if `semantic === null`.
734
+ b. Output path: replace extension with `.semantic.md`.
735
+ c. Overwrite skip: if `.semantic.md` exists and contains current `source_hash`, skip.
736
+ d. **[AMB-5 FIX]** Before writing, ensure parent directory exists:
737
+ ```typescript
738
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
739
+ ```
740
+ e. Write markdown per template.
741
+
742
+ **Validation:**
743
+ ```bash
744
+ npx tsc --noEmit # → exit code 0
745
+ grep 'export class ContractWriter' src/core/graph/contract-writer.ts # → 1 match
746
+ grep 'recursive: true' src/core/graph/contract-writer.ts # → at least 1 match (AMB-5)
747
+ ```
748
+
749
+ **Rollback:** `rm src/core/graph/contract-writer.ts`
750
+
751
+ ---
752
+
753
+ #### Step 4.3: Test `SemanticEnricher`
754
+
755
+ **Pre-Condition:** Step 4.1 complete.
756
+
757
+ **Target File:** `src/core/graph/__tests__/enricher.test.ts` (new)
758
+
759
+ **Action:** vitest tests with `vi.mock('@google/generative-ai')`:
760
+ 1. Enriches file with `semantic: null` → mock returns valid JSON → populated.
761
+ 2. Staleness: matching `source_hash` → skipped.
762
+ 3. Zod failure: mock returns `{ overview: 123 }` → `semantic: null`.
763
+ 4. Retry on 429: mock throws twice then succeeds → populated.
764
+ 5. Missing API key → constructor throws.
765
+ 6. All retries exhausted → `semantic: null`.
766
+ 7. **[GAP-3]** Cancellation flag set mid-enrichment → stops processing.
767
+ 8. **[AMB-2]** Verifies file content is read from disk (mock `fs.readFile`).
768
+
769
+ **Validation:** `npx vitest run src/core/graph/__tests__/enricher.test.ts` — all pass.
770
+
771
+ ---
772
+
773
+ #### Step 4.4: Test `ContractWriter`
774
+
775
+ **Pre-Condition:** Step 4.2 complete.
776
+
777
+ **Target File:** `src/core/graph/__tests__/contract-writer.test.ts` (new)
778
+
779
+ **Action:** vitest tests:
780
+ 1. Writes `.semantic.md` with correct structure.
781
+ 2. Skips when `source_hash` matches.
782
+ 3. Overwrites when hash changed.
783
+ 4. Skips files with `semantic: null`.
784
+ 5. **[AMB-5]** Creates parent directory if missing.
785
+
786
+ **Validation:** `npx vitest run src/core/graph/__tests__/contract-writer.test.ts` — all pass.
787
+
788
+ ---
789
+
790
+ ### Phase 5: Command, Pipeline, and Integration
791
+
792
+ **Gate:** Phase 4 complete (Steps 4.1–4.4 verified).
793
+
794
+ ---
795
+
796
+ #### Step 5.1: Implement `MapPipeline`
797
+
798
+ **Pre-Condition:** All Phase 1–4 components exist and pass tests.
799
+
800
+ **Target File:** `src/core/graph/pipeline.ts` (new)
801
+
802
+ **Action:** Create `MapPipeline` class:
803
+ 1. Constructor: `(config: NomosConfig, projectRoot: string, logger: Logger)`
804
+ 2. `async run(options: { noAi: boolean; force: boolean; patterns?: string[] }): Promise<{ map: ProjectMap; aiFailures: number }>`
805
+ 3. Pipeline flow:
806
+ a. Read existing map via `readProjectMap()`.
807
+ b. `FileScanner.scan()` → `files` (need parsing) + `carried` (unchanged).
808
+ c. If both empty: log warning, return empty map.
809
+ d. **Init `ASTParser`** — call `await parser.init()` (WASM requires async initialization).
810
+ e. For each file in `files`: `ASTParser.parse()` → build `FileNode`.
811
+ f. Merge parsed + carried into `Map<string, FileNode>`.
812
+ g. **[WATCH-4 — CRITICAL ORDERING]** Build `knownFiles` Set BEFORE resolution:
813
+ ```typescript
814
+ // Merge ALL known file paths — both newly parsed and carried forward
815
+ const allFiles = new Map<string, FileNode>(); // from step f
816
+ const knownFiles = new Set<string>(allFiles.keys());
817
+ // knownFiles now contains EVERY file the scanner saw, regardless of parse status
818
+ ```
819
+ Then call `ImportResolver.resolve()` for every `ImportEntry`, passing `knownFiles`.
820
+ `resolve()` is async — use `Promise.all` with `p-limit` if needed.
821
+ **Invariant:** `knownFiles` is immutable after construction. Never add/remove entries during resolution.
822
+ h. `GraphBuilder.build()` → mutates graph fields, returns stats.
823
+ i. **[GAP-2 FIX]** Write map to disk BEFORE AI enrichment (with `semantic: null` for new files):
824
+ ```typescript
825
+ const intermediateMap: ProjectMap = { schema_version: 1, generated_at, root, files, stats };
826
+ await this.writeMap(intermediateMap, mapPath);
827
+ ```
828
+ j. If `!noAi`:
829
+ - **[GAP-3 FIX]** Set up cancellation:
830
+ ```typescript
831
+ const cancellation = { cancelled: false };
832
+ const sigintHandler = () => { cancellation.cancelled = true; };
833
+ process.on('SIGINT', sigintHandler);
834
+ ```
835
+ - Call `SemanticEnricher.enrich(fileNodes, cancellation)`.
836
+ - Call `ContractWriter.writeContracts(fileNodes)`.
837
+ - Remove SIGINT handler: `process.removeListener('SIGINT', sigintHandler)`.
838
+ k. **[BLK-2 FIX + WATCH-3] Atomic write with `proper-lockfile` — Safe on Non-Existent Files:**
839
+
840
+ **Problem:** `proper-lockfile` requires the target file to exist before it can acquire a lock. On first run, `project_map.json` does not exist. The original `.catch()` approach (create empty file then re-lock) has a TOCTOU race: two concurrent processes both see the file missing, both create it, and the second `lock()` call may fail or lock a file the first process is about to overwrite.
841
+
842
+ **Solution: Lock the DIRECTORY, not the file.** The output directory is created with `mkdir -p` and is guaranteed to exist before any write. A lockfile on the directory serializes all map writes regardless of whether the map file itself exists:
843
+
844
+ ```typescript
845
+ import lockfile from 'proper-lockfile';
846
+
847
+ private async writeMap(map: ProjectMap, mapPath: string): Promise<void> {
848
+ const outDir = path.dirname(mapPath);
849
+ await fs.mkdir(outDir, { recursive: true });
850
+
851
+ // Lock the DIRECTORY — works even when project_map.json doesn't exist yet.
852
+ // proper-lockfile creates a .lock file (e.g., tasks-management/graph/.lock)
853
+ // which serializes concurrent arc map invocations.
854
+ const release = await lockfile.lock(outDir, {
855
+ lockfilePath: path.join(outDir, '.project-map.lock'),
856
+ realpath: false,
857
+ retries: { retries: 10, minTimeout: 200, maxTimeout: 2000 },
858
+ stale: 60000, // 60s stale lock — enrichment can be slow
859
+ });
860
+
861
+ try {
862
+ const tmpPath = `${mapPath}.tmp`;
863
+ await fs.writeFile(tmpPath, JSON.stringify(map, null, 2), 'utf-8');
864
+ const fd = await fs.open(tmpPath, 'r+');
865
+ try { await fd.sync(); } finally { await fd.close(); }
866
+ await fs.rename(tmpPath, mapPath);
867
+ } finally {
868
+ await release();
869
+ }
870
+ }
871
+ ```
872
+
873
+ **Why directory locking is correct:**
874
+ - `outDir` is always created BEFORE lock acquisition → no existence check needed
875
+ - `lockfilePath` is explicit → no reliance on `proper-lockfile`'s default `.lock` naming
876
+ - `stale: 60000` is higher than file-lock (30s) because AI enrichment writes can take time
877
+ - The `.project-map.lock` file is an artifact — add to `.gitignore` in Step 0.4
878
+ - `rename()` is atomic on POSIX — combined with the directory lock, this is double-safe
879
+
880
+ **Additional `.gitignore` entry** (add in Step 0.4):
881
+ ```
882
+ .project-map.lock
883
+ ```
884
+ l. Count AI failures and return.
885
+
886
+ **Validation:**
887
+ ```bash
888
+ npx tsc --noEmit # → exit code 0
889
+ grep 'export class MapPipeline' src/core/graph/pipeline.ts # → 1 match
890
+ grep 'proper-lockfile' src/core/graph/pipeline.ts # → at least 1 match (BLK-2)
891
+ grep 'SIGINT' src/core/graph/pipeline.ts # → at least 1 match (GAP-3)
892
+ grep 'writeMap.*intermediateMap' src/core/graph/pipeline.ts # → pattern present (GAP-2)
893
+ ```
894
+
895
+ **Rollback:** `rm src/core/graph/pipeline.ts`
896
+
897
+ ---
898
+
899
+ #### Step 5.2: Implement `arc map` command
900
+
901
+ **Pre-Condition:** Step 5.1 complete.
902
+
903
+ **Target File:** `src/commands/map.ts` (new)
904
+
905
+ **Action:** `registerMapCommand(program: Command): void`:
906
+ 1. Options: `--no-ai`, `--force`, variadic `[patterns...]`.
907
+ 2. Handler:
908
+ a. `loadConfig()`.
909
+ b. If `--no-ai` → `config.graph.ai_enrichment = false`.
910
+ c. `MapPipeline.run()`.
911
+ d. Print summary: `Mapped {N} files, enriched {M}, {K} skipped`.
912
+ e. **[AMB-6 FIX]** Exit codes: `0` = success, `1` = fatal error, `10` = partial AI failure (NOT `2`).
913
+ 3. Progress on stderr.
914
+
915
+ **Validation:**
916
+ ```bash
917
+ npx tsc --noEmit # → exit code 0
918
+ grep 'registerMapCommand' src/commands/map.ts # → 1 match
919
+ grep 'exit(10)' src/commands/map.ts # → 1 match (AMB-6: not exit(2))
920
+ ```
921
+
922
+ **Rollback:** `rm src/commands/map.ts`
923
+
924
+ ---
925
+
926
+ #### Step 5.3: Register `arc map` in CLI entry point
927
+
928
+ **Pre-Condition:** Step 5.2 complete.
929
+
930
+ **Target File:** `src/cli.ts` (modify)
931
+
932
+ **Action:**
933
+ 1. Add: `import { registerMapCommand } from './commands/map.js';`
934
+ 2. Add `registerMapCommand` to registration array.
935
+
936
+ **Validation:**
937
+ ```bash
938
+ npx tsc --noEmit # → exit code 0
939
+ grep 'registerMapCommand' src/cli.ts # → 2 matches (import + usage)
940
+ npx tsx src/cli.ts map --help # → shows help
941
+ ```
942
+
943
+ **Rollback:** `git checkout src/cli.ts`
944
+
945
+ ---
946
+
947
+ #### Step 5.4a: Extend `PromptOptions` with `architecturalConstraints`
948
+
949
+ **Pre-Condition:** `src/types/index.ts` compiles.
950
+
951
+ **Action:** Add `architecturalConstraints: string | null;` to `PromptOptions` interface.
952
+
953
+ **Validation:** `grep 'architecturalConstraints' src/types/index.ts` — 1 match.
954
+
955
+ **Rollback:** Remove added line.
956
+
957
+ ---
958
+
959
+ #### Step 5.4b: Implement `readArchitecturalConstraints`
960
+
961
+ **Pre-Condition:** Steps 1.3 and 5.4a complete.
962
+
963
+ **Target File:** `src/core/graph/constraints.ts` (new)
964
+
965
+ **Action:** Export `async function readArchitecturalConstraints(projectRoot: string, outputDir: string, contextFiles: string[]): Promise<string | null>`:
966
+ 1. Read `project_map.json`. Missing → return `null`.
967
+ 2. Parse with `migrateProjectMap()`.
968
+ 3. **[AMB-7 FIX]** Normalize each `contextFile` before lookup:
969
+ ```typescript
970
+ const normalizedPath = path.relative(projectRoot, path.resolve(projectRoot, contextFile))
971
+ .split(path.sep).join('/'); // ensure forward slashes
972
+ const fileNode = map.files[normalizedPath];
973
+ ```
974
+ 4. Collect dependents with non-null `semantic`, identify consumed symbols.
975
+ 5. Build constraint string per spec format.
976
+ 6. Return `null` if no constraints found.
977
+
978
+ **Validation:**
979
+ ```bash
980
+ npx tsc --noEmit # → exit code 0
981
+ grep 'path.relative' src/core/graph/constraints.ts # → at least 1 match (AMB-7)
982
+ grep 'export async function readArchitecturalConstraints' src/core/graph/constraints.ts # → 1 match
983
+ ```
984
+
985
+ **Rollback:** `rm src/core/graph/constraints.ts`
986
+
987
+ ---
988
+
989
+ #### Step 5.4c: Wire constraints into `Orchestrator.plan()` and `assemblePrompt()`
990
+
991
+ **Pre-Condition:** Steps 5.4a + 5.4b complete.
992
+
993
+ **Target Files:** `src/core/orchestrator.ts`, `src/core/prompt.ts`
994
+
995
+ **Action:**
996
+ 1. In `prompt.ts`: add `[ARCHITECTURAL CONSTRAINTS]` section when `options.architecturalConstraints !== null`.
997
+ 2. In `orchestrator.ts`: import `readArchitecturalConstraints`, call before `assemblePrompt`, pass result.
998
+ 3. Fix all `PromptOptions` construction sites to include `architecturalConstraints: null`.
999
+
1000
+ **Validation:**
1001
+ ```bash
1002
+ npx tsc --noEmit # → exit code 0
1003
+ npx vitest run # → all tests pass
1004
+ grep 'ARCHITECTURAL CONSTRAINTS' src/core/prompt.ts # → 1 match
1005
+ ```
1006
+
1007
+ **Rollback:** Revert both files.
1008
+
1009
+ ---
1010
+
1011
+ #### Step 5.5: Test `MapPipeline` end-to-end
1012
+
1013
+ **Pre-Condition:** Steps 5.1–5.4c complete.
1014
+
1015
+ **Sub-step 5.5a:** Create test fixture at `test/fixtures/sample-project/` with 8 TS files (per original plan).
1016
+
1017
+ **Sub-step 5.5b:** Write `src/core/graph/__tests__/pipeline.test.ts`:
1018
+ 1. Full pipeline `noAi: true` → 8 files mapped.
1019
+ 2. `src/types.ts` + `src/utils/index.ts` have highest depth.
1020
+ 3. `src/main.ts` = depth 0.
1021
+ 4. Circular pair detected.
1022
+ 5. Incremental: second run carries all (0 re-parsed).
1023
+ 6. `--force`: all re-parsed.
1024
+ 7. `core_modules` correct.
1025
+ 8. `stats.total_edges` correct.
1026
+ 9. **[BLK-2]** Concurrent: launch two pipeline runs simultaneously → both complete without corruption (lockfile prevents race).
1027
+ 10. **[GAP-2]** Kill-safe: verify intermediate map is on disk before enrichment begins.
1028
+
1029
+ **Validation:** `npx vitest run src/core/graph/__tests__/pipeline.test.ts` — all pass.
1030
+
1031
+ ---
1032
+
1033
+ #### Step 5.6: Test Architectural Constraint injection
1034
+
1035
+ **Pre-Condition:** Step 5.4c complete.
1036
+
1037
+ **Target File:** `src/core/__tests__/prompt.test.ts` (modify)
1038
+
1039
+ **Action:**
1040
+ 1. Non-null constraints → `[ARCHITECTURAL CONSTRAINTS]` present.
1041
+ 2. Null → section absent.
1042
+ 3. Integration: minimal `project_map.json` → constraint string correct.
1043
+ 4. **[AMB-7]** Context file with relative path `../src/core/state.ts` → correctly normalized and found.
1044
+ 5. Missing-from-map file → warning logged, skipped.
1045
+
1046
+ **Validation:** `npx vitest run src/core/__tests__/prompt.test.ts` — all pass.
1047
+
1048
+ ---
1049
+
1050
+ ### Phase 6: Visualization — `arc show map`
1051
+
1052
+ **Gate:** Phase 5 complete (Steps 5.1–5.6 verified).
1053
+
1054
+ ---
1055
+
1056
+ #### Step 6.1: Implement `MapRenderer`
1057
+
1058
+ **Pre-Condition:** Phase 5 complete.
1059
+
1060
+ **Target File:** `src/core/graph/renderer.ts` (new)
1061
+
1062
+ **Action:** Per original plan. `render()` → Cytoscape nodes/edges. `computeColor()` → depth-based gradient.
1063
+
1064
+ **Validation:**
1065
+ ```bash
1066
+ npx tsc --noEmit # → exit code 0
1067
+ grep 'export class MapRenderer' src/core/graph/renderer.ts # → 1 match
1068
+ ```
1069
+
1070
+ **Rollback:** `rm src/core/graph/renderer.ts`
1071
+
1072
+ ---
1073
+
1074
+ #### Step 6.2a: Create HTML template — self-contained with inline Cytoscape
1075
+
1076
+ **Pre-Condition:** Step 6.1 complete.
1077
+
1078
+ **Target File:** `src/core/graph/html-template.ts` (new)
1079
+
1080
+ **Action:** `export function generateHtml(mapJson: string, cytoscapeNodes: string, cytoscapeEdges: string): string`:
1081
+ 1. **[GAP-4 FIX + WATCH-2] Cytoscape.js bundled INLINE — Exact Mechanism:**
1082
+
1083
+ **Problem:** The original plan said "bundle inline" without specifying how. Cytoscape.js minified is ~500KB — too large for a string literal in source code (breaks linters, slows IDE indexing, bloats git diffs).
1084
+
1085
+ **Solution: Runtime read from `node_modules` at generation time.** The `generateHtml()` function reads `cytoscape.min.js` from disk when generating the HTML, NOT at module import time:
1086
+
1087
+ ```typescript
1088
+ // src/core/graph/html-template.ts
1089
+ import fs from 'node:fs';
1090
+ import { createRequire } from 'node:module';
1091
+
1092
+ const require = createRequire(import.meta.url);
1093
+
1094
+ // Lazy-loaded, cached after first call
1095
+ let _cytoscapeSource: string | null = null;
1096
+
1097
+ function getCytoscapeSource(): string {
1098
+ if (_cytoscapeSource) return _cytoscapeSource;
1099
+
1100
+ // Resolve from node_modules — works regardless of hoisting
1101
+ const cytoscapePath = require.resolve('cytoscape/dist/cytoscape.min.js');
1102
+ _cytoscapeSource = fs.readFileSync(cytoscapePath, 'utf-8');
1103
+ return _cytoscapeSource;
1104
+ }
1105
+
1106
+ export function generateHtml(
1107
+ mapJson: string,
1108
+ cytoscapeNodes: string,
1109
+ cytoscapeEdges: string,
1110
+ ): string {
1111
+ const cytoscapeJs = getCytoscapeSource();
1112
+ return `<!DOCTYPE html>
1113
+ <html>
1114
+ <head><meta charset="utf-8"><title>nomos-arc Dependency Graph</title></head>
1115
+ <body>
1116
+ <div id="cy"></div>
1117
+ <noscript><pre>Dependency graph requires JavaScript. Files:\n${textFallback}</pre></noscript>
1118
+ <script>${cytoscapeJs}</script>
1119
+ <script>
1120
+ const PROJECT_MAP = ${safeJson};
1121
+ // ... Cytoscape init, layout, interactivity ...
1122
+ </script>
1123
+ </body>
1124
+ </html>`;
1125
+ }
1126
+ ```
1127
+
1128
+ **Why this approach:**
1129
+ - No build script needed — `cytoscape` is a regular npm dependency
1130
+ - No string literal in source — the 500KB is read at runtime only when `arc show map` runs
1131
+ - `require.resolve()` survives npm hoisting, monorepos, and `node_modules/.pnpm`
1132
+ - Lazy caching means the file is read once per process, not per call
1133
+ - The generated `index.html` is fully self-contained — works offline, no CDN
1134
+
1135
+ **Pre-Requisite Dependency:** Add `cytoscape` as a runtime dependency in Step 0.1:
1136
+ ```bash
1137
+ npm install cytoscape
1138
+ ```
1139
+ (This replaces the CDN dependency. ~500KB is added to `node_modules`, NOT to the esbuild bundle — `html-template.ts` reads it at runtime.)
1140
+
1141
+ **esbuild consideration:** Do NOT add `--external:cytoscape`. The `require.resolve()` call resolves a path at runtime — esbuild does not attempt to bundle it because it's inside a dynamic `readFileSync`, not a static `import`. If esbuild does attempt to bundle it, add `--external:cytoscape` to the build script.
1142
+
1143
+ - Add `<noscript>` fallback with plain-text file listing for offline/no-JS environments.
1144
+ 2. **[AMB-8 FIX]** JSON embedding with XSS prevention:
1145
+ ```typescript
1146
+ const safeJson = JSON.stringify(map)
1147
+ .replace(/<\//g, '<\\/') // prevent </script> injection
1148
+ .replace(/\$\{/g, '\\${'); // prevent template literal breakout
1149
+ // Embed as: const PROJECT_MAP = ${safeJson};
1150
+ ```
1151
+ 3. BFS layout, depth-based coloring, full-viewport CSS — per original plan.
1152
+
1153
+ **Validation:**
1154
+ ```bash
1155
+ npx tsc --noEmit # → exit code 0
1156
+ grep 'cytoscape.min' src/core/graph/html-template.ts # → at least 1 match (GAP-4: inline)
1157
+ grep '<\\\\/' src/core/graph/html-template.ts # → at least 1 match (AMB-8: XSS fix)
1158
+ grep 'noscript' src/core/graph/html-template.ts # → at least 1 match (GAP-4: fallback)
1159
+ ```
1160
+
1161
+ **Rollback:** `rm src/core/graph/html-template.ts`
1162
+
1163
+ ---
1164
+
1165
+ #### Step 6.2b: Add context card panel
1166
+
1167
+ Per original plan. No changes from Red Team findings.
1168
+
1169
+ ---
1170
+
1171
+ #### Step 6.2c: Add ripple analysis
1172
+
1173
+ Per original plan. No changes from Red Team findings.
1174
+
1175
+ ---
1176
+
1177
+ #### Step 6.2d: Add symbol search bar
1178
+
1179
+ Per original plan. No changes from Red Team findings.
1180
+
1181
+ ---
1182
+
1183
+ #### Step 6.3: Implement `arc show map` command
1184
+
1185
+ **Pre-Condition:** Steps 6.1–6.2d complete.
1186
+
1187
+ **Target File:** `src/commands/show.ts` (new)
1188
+
1189
+ **Action:** Per original plan. `registerShowCommand(program: Command)`, read map, render, generate HTML, open browser.
1190
+
1191
+ **Validation:**
1192
+ ```bash
1193
+ npx tsc --noEmit # → exit code 0
1194
+ grep 'registerShowCommand' src/commands/show.ts # → 1 match
1195
+ ```
1196
+
1197
+ **Rollback:** `rm src/commands/show.ts`
1198
+
1199
+ ---
1200
+
1201
+ #### Step 6.4: Register `arc show map` in CLI
1202
+
1203
+ Per original plan. Add import + registration in `src/cli.ts`.
1204
+
1205
+ ---
1206
+
1207
+ #### Step 6.5: Test `MapRenderer`
1208
+
1209
+ Per original plan (8 test cases). No changes from Red Team findings.
1210
+
1211
+ ---
1212
+
1213
+ #### Step 6.6: Test `arc show map` command
1214
+
1215
+ Per original plan (5 test cases). Add:
1216
+ 6. **[GAP-4]** Generated HTML does NOT contain `cdnjs.cloudflare.com` (Cytoscape is inline).
1217
+ 7. **[AMB-8]** Generated HTML with a file path containing `</script>` does not break the page.
1218
+
1219
+ ---
1220
+
1221
+ ## 4. Final System Integrity Check
1222
+
1223
+ ### 4.1 Full Test Suite
1224
+ ```bash
1225
+ npx vitest run
1226
+ ```
1227
+ Expected: all tests pass — 0 failures. 9 new test files + all existing.
1228
+
1229
+ ### 4.2 Type Check
1230
+ ```bash
1231
+ npx tsc --noEmit
1232
+ ```
1233
+ Expected: exit code 0, no errors.
1234
+
1235
+ ### 4.3 Build Verification
1236
+ ```bash
1237
+ npm run build
1238
+ ```
1239
+ Expected: `dist/cli.js` produced. No `--external:tree-sitter` needed (WASM bundles natively).
1240
+
1241
+ ### 4.4 Post-Build Runtime Verification [BLK-1 CRITICAL]
1242
+ ```bash
1243
+ node dist/cli.js map --no-ai --help
1244
+ ```
1245
+ Expected: shows help text. Proves WASM parser loads correctly from built artifact. **This was the exact failure mode BLK-1 identified.**
1246
+
1247
+ ### 4.5 End-to-End Smoke Test (Structural)
1248
+ ```bash
1249
+ npx tsx src/cli.ts map --no-ai
1250
+ ```
1251
+ Expected: `project_map.json` at `tasks-management/graph/`. Summary printed. Exit code 0.
1252
+ ```bash
1253
+ node -e "const m=JSON.parse(require('fs').readFileSync('tasks-management/graph/project_map.json','utf8')); console.log('files:', Object.keys(m.files).length, 'edges:', m.stats.total_edges, 'version:', m.schema_version)"
1254
+ ```
1255
+ Expected: file count > 0, edge count > 0, version = 1.
1256
+
1257
+ ### 4.6 Visualization Smoke Test
1258
+ ```bash
1259
+ npx tsx src/cli.ts show map --no-open
1260
+ ```
1261
+ Expected: `index.html` written. Exit code 0.
1262
+ ```bash
1263
+ grep -c 'cytoscape' tasks-management/graph/index.html
1264
+ ```
1265
+ Expected: > 0 matches (inline source, not CDN).
1266
+
1267
+ ### 4.7 Incremental Run Verification
1268
+ ```bash
1269
+ time npx tsx src/cli.ts map --no-ai # First run
1270
+ time npx tsx src/cli.ts map --no-ai # Second run
1271
+ ```
1272
+ Expected: second run completes significantly faster (all files carried forward).
1273
+
1274
+ ### 4.8 Concurrent Execution Safety [BLK-2]
1275
+ ```bash
1276
+ npx tsx src/cli.ts map --no-ai & npx tsx src/cli.ts map --no-ai & wait
1277
+ ```
1278
+ Expected: both complete without error. `project_map.json` is valid JSON. No corruption.
1279
+
1280
+ ### 4.9 Forward Version Check [GAP-5]
1281
+ ```bash
1282
+ node -e "const fs=require('fs'); const m=JSON.parse(fs.readFileSync('tasks-management/graph/project_map.json','utf8')); m.schema_version=99; fs.writeFileSync('tasks-management/graph/project_map.json',JSON.stringify(m))"
1283
+ npx tsx src/cli.ts map --no-ai 2>&1
1284
+ ```
1285
+ Expected: error message containing "newer version" or "Please upgrade".
1286
+
1287
+ ### 4.10 File Inventory Check
1288
+
1289
+ **New files (22 total):**
1290
+ ```
1291
+ src/core/graph/map-schema.ts
1292
+ src/core/graph/scanner.ts
1293
+ src/core/graph/parser.ts
1294
+ src/core/graph/grammar-paths.ts (WATCH-1: tier-based WASM grammar resolution)
1295
+ src/core/graph/resolver.ts
1296
+ src/core/graph/builder.ts
1297
+ src/core/graph/enricher.ts
1298
+ src/core/graph/contract-writer.ts
1299
+ src/core/graph/constraints.ts
1300
+ src/core/graph/pipeline.ts
1301
+ src/core/graph/renderer.ts
1302
+ src/core/graph/html-template.ts
1303
+ src/core/graph/grammars/ (WASM grammar files — Tier B/C only, not needed if Tier A)
1304
+ src/core/graph/__tests__/scanner.test.ts
1305
+ src/core/graph/__tests__/parser.test.ts
1306
+ src/core/graph/__tests__/resolver.test.ts
1307
+ src/core/graph/__tests__/builder.test.ts
1308
+ src/core/graph/__tests__/enricher.test.ts
1309
+ src/core/graph/__tests__/contract-writer.test.ts
1310
+ src/core/graph/__tests__/pipeline.test.ts
1311
+ src/core/graph/__tests__/renderer.test.ts
1312
+ src/core/graph/__tests__/show.test.ts
1313
+ src/commands/map.ts
1314
+ src/commands/show.ts
1315
+ test/fixtures/sample-project/ (8 files)
1316
+ ```
1317
+
1318
+ **Modified files (6 total):**
1319
+ ```
1320
+ package.json (new deps + esbuild externals)
1321
+ src/core/errors.ts (4 new error codes)
1322
+ src/types/index.ts (graph types + config + PromptOptions field)
1323
+ src/core/config.ts (GraphConfigSchema)
1324
+ src/core/orchestrator.ts (constraint injection)
1325
+ src/core/prompt.ts (architectural constraints section)
1326
+ src/cli.ts (2 new command registrations)
1327
+ ```
1328
+
1329
+ ---
1330
+
1331
+ ## 5. Trade-Off Register
1332
+
1333
+ | Decision | Chosen Option | Alternative | Rationale |
1334
+ |---|---|---|---|
1335
+ | `web-tree-sitter` over native `tree-sitter` | WASM | Native C++ N-API | Eliminates BLK-1 entirely. ~10-20% slower parsing but zero bundling complexity. Production stability > raw speed. |
1336
+ | `proper-lockfile` for map writes | File locking | Advisory lock via `flock` | Matches existing codebase pattern (`state.ts`). Cross-platform. Already a dependency. |
1337
+ | `knownFiles` Set over `fs.access()` | In-memory lookup | Async filesystem check | O(1) per lookup vs O(1) amortized with syscall overhead. Scanner already knows all files. Zero event-loop blocking. |
1338
+ | Inline Cytoscape.js over CDN | ~500KB larger HTML | CDN with offline fallback | Zero external dependencies. Works offline, on air-gapped networks, behind corporate proxies. HTML is generated once, not served repeatedly. |
1339
+ | Tarjan's SCC over "assign max+1 to remnants" | Full cycle extraction | Simpler but lossy | Distinct cycles get distinct log entries. Disconnected cycles get correct depth assignment. Marginal code complexity increase for significant diagnostic improvement. |
1340
+ | Exit code 10 over exit code 2 | Non-standard but safe | Exit code 2 (Bash convention) | Avoids CI/CD misinterpretation. Exit code 2 = "misuse of shell command" in Bash. Exit code 10 is unambiguous. |
1341
+
1342
+ ### WATCH Resolutions (Implementation-Time Risks)
1343
+
1344
+ | WATCH | Risk | Resolution | Affected Steps |
1345
+ |---|---|---|---|
1346
+ | **WATCH-1** | `web-tree-sitter` WASM grammars not available via npm | 3-tier deterministic fallback (npm package → GitHub release → build from source) + `grammar-paths.ts` abstraction layer + CRITICAL GATE smoke test | 0.2, 2.1 |
1347
+ | **WATCH-2** | Cytoscape.js inline bundling mechanics unspecified | Runtime `readFileSync` from `node_modules/cytoscape/dist/cytoscape.min.js` via `require.resolve()`. Lazy-cached. No string literal in source, no build script. `cytoscape` added as npm dependency in Step 0.1 | 0.1, 6.2a |
1348
+ | **WATCH-3** | `proper-lockfile` fails on non-existent file (TOCTOU race) | Lock the output DIRECTORY instead of the file. Directory is guaranteed to exist after `mkdir -p`. Uses explicit `lockfilePath` to `.project-map.lock`. Lockfile added to `.gitignore` | 0.4, 5.1 |
1349
+ | **WATCH-4** | `knownFiles` Set must include carried files, not just newly parsed | Explicit construction from `allFiles.keys()` (merged parsed + carried) BEFORE any `resolve()` call. Ordering invariant documented in Step 5.1g. Dedicated test case in resolver tests | 3.1, 5.1, 3.3 |