@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,165 @@
1
+ import type {
2
+ DriftReport,
3
+ ClassifiedDrift,
4
+ ClassifiedChange,
5
+ DriftSeverity,
6
+ } from '../../../types/index.js';
7
+
8
+ /**
9
+ * classify — takes a DriftReport and classifies each change by severity.
10
+ * Returns a ClassifiedDrift with all changes annotated, has_breaking flag, and summary.
11
+ */
12
+ export function classify(report: DriftReport): ClassifiedDrift {
13
+ const changes: ClassifiedChange[] = [];
14
+
15
+ // ── Symbol classification ─────────────────────────────────────────────────
16
+ for (const sym of report.symbols) {
17
+ if (sym.status === 'added') {
18
+ changes.push({
19
+ severity: 'info',
20
+ category: 'symbol',
21
+ message: `Symbol '${sym.name}' (${sym.kind}) added in ${sym.file}`,
22
+ file: sym.file,
23
+ detail: `Exported: ${sym.exported}`,
24
+ suggestion: "New symbol — no breaking impact.",
25
+ });
26
+ continue;
27
+ }
28
+
29
+ // removed or signature_changed
30
+ if (sym.exported && sym.dependents_affected.length > 0) {
31
+ const action = sym.status === 'removed' ? 'removed' : 'signature changed';
32
+ changes.push({
33
+ severity: 'breaking',
34
+ category: 'symbol',
35
+ message: `Exported symbol '${sym.name}' (${sym.kind}) ${action} in ${sym.file}`,
36
+ file: sym.file,
37
+ detail:
38
+ sym.status === 'signature_changed'
39
+ ? `Before: ${sym.signature_before ?? 'none'} | After: ${sym.signature_after ?? 'none'}`
40
+ : `Affects ${sym.dependents_affected.length} dependent(s): ${sym.dependents_affected.join(', ')}`,
41
+ suggestion: `Update all ${sym.dependents_affected.length} dependent(s) that reference '${sym.name}'.`,
42
+ });
43
+ } else if (sym.exported && sym.dependents_affected.length === 0) {
44
+ const action = sym.status === 'removed' ? 'removed' : 'signature changed';
45
+ changes.push({
46
+ severity: 'warning',
47
+ category: 'symbol',
48
+ message: `Exported symbol '${sym.name}' (${sym.kind}) ${action} in ${sym.file}`,
49
+ file: sym.file,
50
+ detail:
51
+ sym.status === 'signature_changed'
52
+ ? `Before: ${sym.signature_before ?? 'none'} | After: ${sym.signature_after ?? 'none'}`
53
+ : 'No tracked dependents, but public API has changed.',
54
+ suggestion: `Check for external consumers of '${sym.name}' not tracked in the project map.`,
55
+ });
56
+ } else {
57
+ // Not exported
58
+ const action = sym.status === 'removed' ? 'removed' : 'signature changed';
59
+ changes.push({
60
+ severity: 'info',
61
+ category: 'symbol',
62
+ message: `Internal symbol '${sym.name}' (${sym.kind}) ${action} in ${sym.file}`,
63
+ file: sym.file,
64
+ detail:
65
+ sym.status === 'signature_changed'
66
+ ? `Before: ${sym.signature_before ?? 'none'} | After: ${sym.signature_after ?? 'none'}`
67
+ : 'Non-exported symbol removed — no public API impact.',
68
+ suggestion: 'No action required — internal change only.',
69
+ });
70
+ }
71
+ }
72
+
73
+ // ── File classification ───────────────────────────────────────────────────
74
+ for (const f of report.files) {
75
+ if (f.status === 'removed') {
76
+ changes.push({
77
+ severity: 'warning',
78
+ category: 'file',
79
+ message: `File removed: ${f.file}`,
80
+ file: f.file,
81
+ detail: `Hash before: ${f.hash_before}`,
82
+ suggestion: `Check dependent files that imported from '${f.file}'.`,
83
+ });
84
+ } else if (f.status === 'added') {
85
+ changes.push({
86
+ severity: 'info',
87
+ category: 'file',
88
+ message: `File added: ${f.file}`,
89
+ file: f.file,
90
+ detail: `Hash after: ${f.hash_after}`,
91
+ suggestion: 'New file — no breaking impact.',
92
+ });
93
+ }
94
+ // modified → covered by symbol and import diffs — no standalone change entry
95
+ }
96
+
97
+ // ── Import classification ─────────────────────────────────────────────────
98
+ for (const imp of report.imports) {
99
+ changes.push({
100
+ severity: 'info',
101
+ category: 'import',
102
+ message: `Import '${imp.source}' ${imp.status} in ${imp.file}`,
103
+ file: imp.file,
104
+ detail: `Status: ${imp.status}`,
105
+ suggestion: 'Verify import graph integrity after this change.',
106
+ });
107
+ }
108
+
109
+ // ── Graph classification ──────────────────────────────────────────────────
110
+ for (const mod of report.graph.core_modules_added) {
111
+ changes.push({
112
+ severity: 'info',
113
+ category: 'graph',
114
+ message: `Module promoted to core: ${mod}`,
115
+ file: mod,
116
+ detail: 'Module is now in the core_modules list.',
117
+ suggestion: 'No action required — module importance increased.',
118
+ });
119
+ }
120
+
121
+ for (const mod of report.graph.core_modules_removed) {
122
+ changes.push({
123
+ severity: 'warning',
124
+ category: 'graph',
125
+ message: `Module demoted from core: ${mod}`,
126
+ file: mod,
127
+ detail: 'Module was removed from the core_modules list.',
128
+ suggestion: 'Verify this module is still in use or intentionally demoted.',
129
+ });
130
+ }
131
+
132
+ for (const dc of report.graph.depth_changes) {
133
+ const delta = dc.after - dc.before;
134
+ if (Math.abs(delta) >= 2) {
135
+ changes.push({
136
+ severity: 'warning',
137
+ category: 'graph',
138
+ message: `Significant depth change in ${dc.file}: ${dc.before} → ${dc.after}`,
139
+ file: dc.file,
140
+ detail: `Depth delta: ${delta > 0 ? '+' : ''}${delta}`,
141
+ suggestion: 'Review the dependency chain — significant structural shift detected.',
142
+ });
143
+ }
144
+ }
145
+
146
+ // ── Enrichment staleness classification ───────────────────────────────────
147
+ for (const stale of report.stale_enrichments) {
148
+ if (stale.hash_changed) {
149
+ changes.push({
150
+ severity: 'stale',
151
+ category: 'enrichment',
152
+ message: `Stale enrichment detected in ${stale.file}`,
153
+ file: stale.file,
154
+ detail: stale.was_semantic_now_structural
155
+ ? 'File was semantically enriched but is now structural — enrichment regression.'
156
+ : 'File hash changed but semantic data was not updated.',
157
+ suggestion: `Run 'arc map' with AI enrichment to refresh semantic data for ${stale.file}.`,
158
+ });
159
+ }
160
+ }
161
+
162
+ const has_breaking = changes.some((c) => c.severity === 'breaking');
163
+
164
+ return { changes, has_breaking, summary: report.summary };
165
+ }
@@ -0,0 +1,205 @@
1
+ import type {
2
+ ProjectMap,
3
+ DriftReport,
4
+ FileDiff,
5
+ SymbolDiff,
6
+ ImportDiff,
7
+ GraphDiff,
8
+ EnrichmentStaleness,
9
+ } from '../../../types/index.js';
10
+
11
+ /**
12
+ * compare — pure function that diffs two ProjectMap objects and returns a DriftReport.
13
+ * No I/O, no side effects.
14
+ */
15
+ export function compare(baseline: ProjectMap, current: ProjectMap): DriftReport {
16
+ const files: FileDiff[] = [];
17
+ const symbols: SymbolDiff[] = [];
18
+ const imports: ImportDiff[] = [];
19
+ const stale_enrichments: EnrichmentStaleness[] = [];
20
+
21
+ // ── 6.1 File-Level Diff ────────────────────────────────────────────────────
22
+ const allFileKeys = new Set([
23
+ ...Object.keys(baseline.files),
24
+ ...Object.keys(current.files),
25
+ ]);
26
+
27
+ for (const fileKey of allFileKeys) {
28
+ const baseFile = baseline.files[fileKey];
29
+ const currFile = current.files[fileKey];
30
+
31
+ if (!baseFile && currFile) {
32
+ // Added
33
+ files.push({
34
+ file: fileKey,
35
+ status: 'added',
36
+ hash_before: null,
37
+ hash_after: currFile.hash,
38
+ });
39
+ } else if (baseFile && !currFile) {
40
+ // Removed
41
+ files.push({
42
+ file: fileKey,
43
+ status: 'removed',
44
+ hash_before: baseFile.hash,
45
+ hash_after: null,
46
+ });
47
+
48
+ // ── 6.2 All symbols in removed file are 'removed' ─────────────────────
49
+ for (const sym of baseFile.symbols) {
50
+ symbols.push({
51
+ file: fileKey,
52
+ name: sym.name,
53
+ kind: sym.kind,
54
+ status: 'removed',
55
+ exported: sym.exported,
56
+ signature_before: sym.signature,
57
+ signature_after: null,
58
+ dependents_affected: [...baseFile.dependents],
59
+ });
60
+ }
61
+ } else if (baseFile && currFile) {
62
+ if (baseFile.hash === currFile.hash) {
63
+ // Unchanged — skip
64
+ continue;
65
+ }
66
+
67
+ // Modified
68
+ files.push({
69
+ file: fileKey,
70
+ status: 'modified',
71
+ hash_before: baseFile.hash,
72
+ hash_after: currFile.hash,
73
+ });
74
+
75
+ // ── 6.2 Symbol-Level Diff for modified files ───────────────────────────
76
+ // Use `name:kind` composite key to handle same-named symbols of different kinds (F-003)
77
+ const baseSymMap = new Map<string, (typeof baseFile.symbols)[0]>();
78
+ for (const sym of baseFile.symbols) {
79
+ baseSymMap.set(`${sym.name}:${sym.kind}`, sym);
80
+ }
81
+
82
+ const currSymMap = new Map<string, (typeof currFile.symbols)[0]>();
83
+ for (const sym of currFile.symbols) {
84
+ currSymMap.set(`${sym.name}:${sym.kind}`, sym);
85
+ }
86
+
87
+ // Detect removed and signature_changed
88
+ for (const [key, baseSym] of baseSymMap) {
89
+ const currSym = currSymMap.get(key);
90
+ if (!currSym) {
91
+ symbols.push({
92
+ file: fileKey,
93
+ name: baseSym.name,
94
+ kind: baseSym.kind,
95
+ status: 'removed',
96
+ exported: baseSym.exported,
97
+ signature_before: baseSym.signature,
98
+ signature_after: null,
99
+ dependents_affected: [...baseFile.dependents],
100
+ });
101
+ } else if (baseSym.signature !== currSym.signature) {
102
+ symbols.push({
103
+ file: fileKey,
104
+ name: baseSym.name,
105
+ kind: baseSym.kind,
106
+ status: 'signature_changed',
107
+ exported: baseSym.exported,
108
+ signature_before: baseSym.signature,
109
+ signature_after: currSym.signature,
110
+ dependents_affected: [...baseFile.dependents],
111
+ });
112
+ }
113
+ }
114
+
115
+ // Detect added
116
+ for (const [key, currSym] of currSymMap) {
117
+ if (!baseSymMap.has(key)) {
118
+ symbols.push({
119
+ file: fileKey,
120
+ name: currSym.name,
121
+ kind: currSym.kind,
122
+ status: 'added',
123
+ exported: currSym.exported,
124
+ signature_before: null,
125
+ signature_after: currSym.signature,
126
+ dependents_affected: [],
127
+ });
128
+ }
129
+ }
130
+
131
+ // ── 6.3 Import-Level Diff for modified files (internal only) ──────────
132
+ const baseInternalImports = new Set(
133
+ baseFile.imports
134
+ .filter((i) => !i.is_external && i.resolved !== null)
135
+ .map((i) => i.resolved as string),
136
+ );
137
+ const currInternalImports = new Set(
138
+ currFile.imports
139
+ .filter((i) => !i.is_external && i.resolved !== null)
140
+ .map((i) => i.resolved as string),
141
+ );
142
+
143
+ for (const resolved of currInternalImports) {
144
+ if (!baseInternalImports.has(resolved)) {
145
+ imports.push({ file: fileKey, source: resolved, status: 'added' });
146
+ }
147
+ }
148
+ for (const resolved of baseInternalImports) {
149
+ if (!currInternalImports.has(resolved)) {
150
+ imports.push({ file: fileKey, source: resolved, status: 'removed' });
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ // ── 6.4 Graph-Level Diff ──────────────────────────────────────────────────
157
+ const depth_changes: GraphDiff['depth_changes'] = [];
158
+ for (const fileKey of Object.keys(baseline.files)) {
159
+ const baseFile = baseline.files[fileKey];
160
+ const currFile = current.files[fileKey];
161
+ if (baseFile && currFile && baseFile.depth !== currFile.depth) {
162
+ depth_changes.push({ file: fileKey, before: baseFile.depth, after: currFile.depth });
163
+ }
164
+ }
165
+
166
+ const baseCore = new Set(baseline.stats.core_modules);
167
+ const currCore = new Set(current.stats.core_modules);
168
+ const core_modules_added = current.stats.core_modules.filter((m) => !baseCore.has(m));
169
+ const core_modules_removed = baseline.stats.core_modules.filter((m) => !currCore.has(m));
170
+
171
+ const graph: GraphDiff = { depth_changes, core_modules_added, core_modules_removed };
172
+
173
+ // ── 6.5 Enrichment Staleness Detection ───────────────────────────────────
174
+ for (const [fileKey, currFile] of Object.entries(current.files)) {
175
+ const baseFile = baseline.files[fileKey];
176
+
177
+ const hash_changed =
178
+ currFile.semantic !== null && currFile.semantic.source_hash !== currFile.hash;
179
+
180
+ const was_semantic_now_structural =
181
+ baseFile !== undefined &&
182
+ baseFile.enrichment_status === 'semantic' &&
183
+ currFile.enrichment_status === 'structural';
184
+
185
+ if (hash_changed || was_semantic_now_structural) {
186
+ stale_enrichments.push({ file: fileKey, hash_changed, was_semantic_now_structural });
187
+ }
188
+ }
189
+
190
+ // ── Summary ───────────────────────────────────────────────────────────────
191
+ const summary: DriftReport['summary'] = {
192
+ files_added: files.filter((f) => f.status === 'added').length,
193
+ files_removed: files.filter((f) => f.status === 'removed').length,
194
+ files_modified: files.filter((f) => f.status === 'modified').length,
195
+ symbols_added: symbols.filter((s) => s.status === 'added').length,
196
+ symbols_removed: symbols.filter((s) => s.status === 'removed').length,
197
+ symbols_changed: symbols.filter((s) => s.status === 'signature_changed').length,
198
+ imports_added: imports.filter((i) => i.status === 'added').length,
199
+ imports_removed: imports.filter((i) => i.status === 'removed').length,
200
+ depth_changes: depth_changes.length,
201
+ stale_enrichments: stale_enrichments.length,
202
+ };
203
+
204
+ return { files, symbols, imports, graph, stale_enrichments, summary };
205
+ }
@@ -0,0 +1,77 @@
1
+ import type { ClassifiedDrift, DriftSeverity } from '../../../types/index.js';
2
+
3
+ const SEVERITY_ORDER: DriftSeverity[] = ['breaking', 'warning', 'info', 'stale'];
4
+
5
+ const SEVERITY_LABELS: Record<DriftSeverity, string> = {
6
+ breaking: 'BREAKING',
7
+ warning: 'WARNING',
8
+ info: 'INFO',
9
+ stale: 'STALE ENRICHMENT',
10
+ };
11
+
12
+ /**
13
+ * render — converts a ClassifiedDrift into a formatted string for stdout.
14
+ * Returns terminal-formatted text or JSON depending on options.json.
15
+ */
16
+ export function render(
17
+ drift: ClassifiedDrift,
18
+ baselineDate: string,
19
+ currentDate: string,
20
+ options: { json: boolean; breakingOnly: boolean },
21
+ ): string {
22
+ const { json, breakingOnly } = options;
23
+
24
+ const filteredChanges = breakingOnly
25
+ ? drift.changes.filter((c) => c.severity === 'breaking')
26
+ : drift.changes;
27
+
28
+ // ── JSON format ───────────────────────────────────────────────────────────
29
+ if (json) {
30
+ return JSON.stringify(
31
+ {
32
+ baseline_generated_at: baselineDate,
33
+ current_generated_at: currentDate,
34
+ has_breaking: drift.has_breaking,
35
+ changes: filteredChanges,
36
+ summary: drift.summary,
37
+ },
38
+ null,
39
+ 2,
40
+ );
41
+ }
42
+
43
+ // ── Terminal format ───────────────────────────────────────────────────────
44
+ const lines: string[] = [];
45
+ lines.push(`Drift Report: baseline (${baselineDate}) → current (${currentDate})`);
46
+ lines.push('');
47
+
48
+ if (filteredChanges.length === 0) {
49
+ lines.push('No drift detected.');
50
+ return lines.join('\n');
51
+ }
52
+
53
+ for (const severity of SEVERITY_ORDER) {
54
+ const group = filteredChanges.filter((c) => c.severity === severity);
55
+ if (group.length === 0) continue;
56
+
57
+ lines.push(`${SEVERITY_LABELS[severity]} (${group.length})`);
58
+ for (const change of group) {
59
+ lines.push(` ${change.message}`);
60
+ if (change.detail) {
61
+ lines.push(` ${change.detail}`);
62
+ }
63
+ lines.push(` → ${change.suggestion}`);
64
+ }
65
+ lines.push('');
66
+ }
67
+
68
+ const s = drift.summary;
69
+ lines.push(
70
+ `Summary: ${s.files_added} added, ${s.files_removed} removed, ${s.files_modified} modified` +
71
+ ` | ${s.symbols_added + s.symbols_removed + s.symbols_changed} symbol changes` +
72
+ ` | ${s.imports_added + s.imports_removed} import changes` +
73
+ ` | ${s.stale_enrichments} stale enrichments`,
74
+ );
75
+
76
+ return lines.join('\n');
77
+ }
@@ -0,0 +1,251 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import { GoogleGenerativeAI, SchemaType } from '@google/generative-ai';
4
+ import type { RequestOptions } from '@google/generative-ai';
5
+ import pLimit from 'p-limit';
6
+ import { z } from 'zod';
7
+ import { NomosError } from '../errors.js';
8
+ import type { NomosConfig, FileNode, SemanticInfo } from '../../types/index.js';
9
+ import { AuthManager } from '../auth/manager.js';
10
+
11
+ // ─── Zod schema for Gemini response validation ────────────────────────────────
12
+
13
+ const SemanticResponseSchema = z.object({
14
+ overview: z.string(),
15
+ purpose: z.string(),
16
+ key_logic: z.array(z.string()),
17
+ usage_context: z.array(z.string()),
18
+ });
19
+
20
+ // ─── Gemini responseSchema (passed to generationConfig) ──────────────────────
21
+
22
+ const GEMINI_RESPONSE_SCHEMA = {
23
+ type: SchemaType.OBJECT,
24
+ properties: {
25
+ overview: { type: SchemaType.STRING },
26
+ purpose: { type: SchemaType.STRING },
27
+ key_logic: { type: SchemaType.ARRAY, items: { type: SchemaType.STRING } },
28
+ usage_context: { type: SchemaType.ARRAY, items: { type: SchemaType.STRING } },
29
+ },
30
+ required: ['overview', 'purpose', 'key_logic', 'usage_context'],
31
+ };
32
+
33
+ // ─── SemanticEnricher ─────────────────────────────────────────────────────────
34
+
35
+ export class SemanticEnricher {
36
+ private readonly client: GoogleGenerativeAI;
37
+ private readonly limit: ReturnType<typeof pLimit>;
38
+ private readonly requestOptions: RequestOptions | undefined;
39
+
40
+ constructor(
41
+ private readonly projectRoot: string,
42
+ private readonly config: NomosConfig['graph'],
43
+ private readonly logger: {
44
+ info(msg: string): void;
45
+ warn(msg: string): void;
46
+ error(msg: string): void;
47
+ },
48
+ apiKey?: string,
49
+ quotaProjectId?: string,
50
+ ) {
51
+ const key = apiKey ?? process.env['GEMINI_API_KEY'];
52
+ if (!key && config.ai_enrichment) {
53
+ throw new NomosError(
54
+ 'graph_ai_key_missing',
55
+ 'No credentials found. Set GEMINI_API_KEY or run: arc auth login',
56
+ );
57
+ }
58
+ this.client = new GoogleGenerativeAI(key ?? '');
59
+ this.limit = pLimit(config.ai_concurrency);
60
+ if (quotaProjectId) {
61
+ this.requestOptions = {
62
+ customHeaders: { 'x-goog-user-project': quotaProjectId },
63
+ };
64
+ }
65
+ }
66
+
67
+ static async create(
68
+ projectRoot: string,
69
+ config: NomosConfig['graph'],
70
+ logger: { info(msg: string): void; warn(msg: string): void; error(msg: string): void },
71
+ authManager?: AuthManager | null,
72
+ ): Promise<SemanticEnricher> {
73
+ // Priority 1: GEMINI_API_KEY (no quota header — billing is tied to the API key's project)
74
+ const envKey = process.env['GEMINI_API_KEY'];
75
+ if (envKey) {
76
+ return new SemanticEnricher(projectRoot, config, logger, envKey);
77
+ }
78
+ // Priority 2: OAuth credentials + quota project
79
+ if (authManager?.isLoggedIn()) {
80
+ const token = await authManager.getAccessToken();
81
+ const creds = authManager.loadCredentials();
82
+ return new SemanticEnricher(projectRoot, config, logger, token, creds?.quota_project_id);
83
+ }
84
+ // No key and enrichment enabled — let constructor throw
85
+ return new SemanticEnricher(projectRoot, config, logger);
86
+ }
87
+
88
+ /**
89
+ * Enriches all file nodes with AI-generated semantic metadata.
90
+ * Mutates `FileNode.semantic` in-place.
91
+ * Returns count of failures (files that could not be enriched).
92
+ *
93
+ * [GAP-3 FIX] Checks `cancellationFlag.cancelled` between each batch.
94
+ * [AMB-2 FIX] Reads file content from disk, not from ScanResult.
95
+ */
96
+ async enrich(
97
+ fileNodes: Map<string, FileNode>,
98
+ cancellationFlag: { cancelled: boolean },
99
+ ): Promise<number> {
100
+ const gapMs = Math.ceil(60000 / this.config.ai_requests_per_minute);
101
+ let failures = 0;
102
+ const entries = [...fileNodes.entries()];
103
+
104
+ for (let i = 0; i < entries.length; i++) {
105
+ // [GAP-3 FIX] Check cancellation flag between each file
106
+ if (cancellationFlag.cancelled) {
107
+ this.logger.info('[nomos:graph:info] Enrichment cancelled — stopping early.');
108
+ break;
109
+ }
110
+
111
+ const [, fileNode] = entries[i];
112
+
113
+ // Staleness check: skip if source_hash matches current hash
114
+ if (fileNode.semantic !== null && fileNode.semantic.source_hash === fileNode.hash) {
115
+ continue;
116
+ }
117
+
118
+ const success = await this.limit(() => this.enrichFile(fileNode, gapMs));
119
+ if (!success) failures++;
120
+ }
121
+
122
+ return failures;
123
+ }
124
+
125
+ private async enrichFile(fileNode: FileNode, gapMs: number): Promise<boolean> {
126
+ // [AMB-2 FIX] Read file content from disk
127
+ let content: string;
128
+ try {
129
+ content = await fs.readFile(path.join(this.projectRoot, fileNode.file), 'utf-8');
130
+ } catch (err) {
131
+ this.logger.error(
132
+ `[nomos:graph:error] Failed to read ${fileNode.file}: ${String(err)}`,
133
+ );
134
+ fileNode.semantic = null;
135
+ return false;
136
+ }
137
+
138
+ // Truncate at last complete line before max_file_chars
139
+ if (content.length > this.config.max_file_chars) {
140
+ const truncated = content.slice(0, this.config.max_file_chars);
141
+ const lastNewline = truncated.lastIndexOf('\n');
142
+ content = lastNewline > 0 ? truncated.slice(0, lastNewline) : truncated;
143
+ }
144
+
145
+ // Build prompt
146
+ const exports = fileNode.symbols
147
+ .filter((s) => s.exported)
148
+ .map((s) => (s.signature ? `${s.name}: ${s.signature}` : s.name))
149
+ .join(', ');
150
+
151
+ const prompt = [
152
+ `File: ${fileNode.file}`,
153
+ `Language: ${fileNode.language}`,
154
+ `Exports: ${exports || '(none)'}`,
155
+ `Used by: ${fileNode.dependents.join(', ') || '(none)'}`,
156
+ '---',
157
+ content,
158
+ '---',
159
+ 'Respond in JSON matching this schema exactly:',
160
+ '{ "overview": string, "purpose": string, "key_logic": string[], "usage_context": string[] }',
161
+ ].join('\n');
162
+
163
+ // Retry with exponential backoff: 3 retries, delays of 2s, 4s, 8s
164
+ const retryDelays = [2000, 4000, 8000];
165
+ let lastError: unknown;
166
+
167
+ for (let attempt = 0; attempt <= 3; attempt++) {
168
+ if (attempt > 0) {
169
+ await sleep(retryDelays[attempt - 1]);
170
+ }
171
+
172
+ try {
173
+ const model = this.client.getGenerativeModel({
174
+ model: this.config.ai_model,
175
+ generationConfig: {
176
+ responseMimeType: 'application/json',
177
+ responseSchema: GEMINI_RESPONSE_SCHEMA,
178
+ } as never,
179
+ }, this.requestOptions);
180
+
181
+ const result = await model.generateContent(prompt);
182
+ const text = result.response.text();
183
+
184
+ // Enforce minimum gap between requests
185
+ await sleep(gapMs);
186
+
187
+ let parsed: unknown;
188
+ try {
189
+ parsed = JSON.parse(text);
190
+ } catch {
191
+ this.logger.warn(
192
+ `[nomos:graph:warn] Non-JSON response for ${fileNode.file}: ${text.slice(0, 200)}`,
193
+ );
194
+ fileNode.semantic = null;
195
+ return false;
196
+ }
197
+
198
+ const validated = SemanticResponseSchema.safeParse(parsed);
199
+ if (!validated.success) {
200
+ this.logger.warn(
201
+ `[nomos:graph:warn] Zod validation failed for ${fileNode.file}: ${text.slice(0, 200)}`,
202
+ );
203
+ fileNode.semantic = null;
204
+ return false;
205
+ }
206
+
207
+ const { overview, purpose, key_logic, usage_context } = validated.data;
208
+ fileNode.semantic = {
209
+ overview,
210
+ purpose,
211
+ key_logic,
212
+ usage_context,
213
+ source_hash: fileNode.hash,
214
+ enriched_at: new Date().toISOString(),
215
+ model: this.config.ai_model,
216
+ } satisfies SemanticInfo;
217
+ fileNode.enrichment_status = 'semantic';
218
+
219
+ return true;
220
+ } catch (err: unknown) {
221
+ lastError = err;
222
+ if (!isRetryableError(err) || attempt >= 3) break;
223
+ }
224
+ }
225
+
226
+ this.logger.error(
227
+ `[nomos:graph:error] AI enrichment failed for ${fileNode.file} after 3 retries — skipping. ${String(lastError)}`,
228
+ );
229
+ fileNode.semantic = null;
230
+ return false;
231
+ }
232
+ }
233
+
234
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
235
+
236
+ function sleep(ms: number): Promise<void> {
237
+ return new Promise((resolve) => setTimeout(resolve, ms));
238
+ }
239
+
240
+ function isRetryableError(err: unknown): boolean {
241
+ if (err instanceof Error) {
242
+ const msg = err.message;
243
+ return (
244
+ msg.includes('429') ||
245
+ msg.includes('503') ||
246
+ msg.includes('Too Many Requests') ||
247
+ msg.includes('Service Unavailable')
248
+ );
249
+ }
250
+ return false;
251
+ }