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