@neurcode-ai/governance-runtime 0.1.3 → 0.1.5
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/dist/admission-provenance.d.ts +111 -0
- package/dist/admission-provenance.d.ts.map +1 -0
- package/dist/admission-provenance.js +735 -0
- package/dist/admission-provenance.js.map +1 -0
- package/dist/agent-guard-posture.d.ts +40 -0
- package/dist/agent-guard-posture.d.ts.map +1 -0
- package/dist/agent-guard-posture.js +117 -0
- package/dist/agent-guard-posture.js.map +1 -0
- package/dist/agent-invocation-observability.d.ts +47 -0
- package/dist/agent-invocation-observability.d.ts.map +1 -0
- package/dist/agent-invocation-observability.js +229 -0
- package/dist/agent-invocation-observability.js.map +1 -0
- package/dist/agent-plan.d.ts +119 -0
- package/dist/agent-plan.d.ts.map +1 -0
- package/dist/agent-plan.js +590 -0
- package/dist/agent-plan.js.map +1 -0
- package/dist/agent-runtime-adapter.d.ts +69 -0
- package/dist/agent-runtime-adapter.d.ts.map +1 -0
- package/dist/agent-runtime-adapter.js +274 -0
- package/dist/agent-runtime-adapter.js.map +1 -0
- package/dist/ai-change-record.d.ts +185 -0
- package/dist/ai-change-record.d.ts.map +1 -0
- package/dist/ai-change-record.js +580 -0
- package/dist/ai-change-record.js.map +1 -0
- package/dist/architecture-graph.d.ts +153 -0
- package/dist/architecture-graph.d.ts.map +1 -0
- package/dist/architecture-graph.js +646 -0
- package/dist/architecture-graph.js.map +1 -0
- package/dist/architecture-obligations.d.ts +161 -0
- package/dist/architecture-obligations.d.ts.map +1 -0
- package/dist/architecture-obligations.js +553 -0
- package/dist/architecture-obligations.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +104 -1
- package/dist/index.js.map +1 -1
- package/dist/profile.d.ts +159 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +611 -0
- package/dist/profile.js.map +1 -0
- package/dist/session.d.ts +428 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +2206 -0
- package/dist/session.js.map +1 -0
- package/package.json +13 -2
- package/src/constraints.ts +0 -828
- package/src/index.test.ts +0 -502
- package/src/index.ts +0 -463
- package/tsconfig.json +0 -19
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Repository Architecture Graph — V2.
|
|
4
|
+
*
|
|
5
|
+
* Turns the path/owner profile into an architecture-aware model that can reason
|
|
6
|
+
* about module boundaries, ownership, dependency direction, and sensitive
|
|
7
|
+
* surfaces during agentic development.
|
|
8
|
+
*
|
|
9
|
+
* Source-free guarantees:
|
|
10
|
+
* - Import *specifiers* (module strings) may be read locally to infer edges,
|
|
11
|
+
* but raw source, diffs, and file contents are NEVER stored on the graph.
|
|
12
|
+
* The graph holds only module ids, owners, surface tags, and module→module
|
|
13
|
+
* dependency edges — architecture metadata, not code.
|
|
14
|
+
* - Deterministic: same inputs → same `architectureHash`.
|
|
15
|
+
*
|
|
16
|
+
* The extractor + resolver are pure functions so the CLI can read local files,
|
|
17
|
+
* derive specifiers, build edges, and discard the content immediately.
|
|
18
|
+
*/
|
|
19
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
20
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
21
|
+
};
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.ARCHITECTURE_GRAPH_SCHEMA_VERSION = void 0;
|
|
24
|
+
exports.moduleIdForPath = moduleIdForPath;
|
|
25
|
+
exports.extractImportSpecifiers = extractImportSpecifiers;
|
|
26
|
+
exports.resolveImportSpecifier = resolveImportSpecifier;
|
|
27
|
+
exports.buildArchitectureGraph = buildArchitectureGraph;
|
|
28
|
+
exports.findModuleForPath = findModuleForPath;
|
|
29
|
+
exports.dependentsOf = dependentsOf;
|
|
30
|
+
exports.dependenciesOf = dependenciesOf;
|
|
31
|
+
exports.modulesInPlay = modulesInPlay;
|
|
32
|
+
exports.deriveGraphObligationSeeds = deriveGraphObligationSeeds;
|
|
33
|
+
exports.isModuleTestSatisfiable = isModuleTestSatisfiable;
|
|
34
|
+
const micromatch_1 = __importDefault(require("micromatch"));
|
|
35
|
+
const node_crypto_1 = require("node:crypto");
|
|
36
|
+
const profile_1 = require("./profile");
|
|
37
|
+
exports.ARCHITECTURE_GRAPH_SCHEMA_VERSION = 2;
|
|
38
|
+
// ── Language + path helpers ───────────────────────────────────────────────────
|
|
39
|
+
const TS_JS_EXT = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts']);
|
|
40
|
+
const PY_EXT = new Set(['py', 'pyi']);
|
|
41
|
+
const SOURCE_EXT = new Set([
|
|
42
|
+
...TS_JS_EXT,
|
|
43
|
+
...PY_EXT,
|
|
44
|
+
'go',
|
|
45
|
+
'rs',
|
|
46
|
+
'java',
|
|
47
|
+
'kt',
|
|
48
|
+
'rb',
|
|
49
|
+
'php',
|
|
50
|
+
'cs',
|
|
51
|
+
// `.sql` is included so migration/database directories form modules even when
|
|
52
|
+
// they hold only SQL files (no imports are extracted from them).
|
|
53
|
+
'sql',
|
|
54
|
+
]);
|
|
55
|
+
function extOf(filePath) {
|
|
56
|
+
const m = filePath.match(/\.([a-z0-9]+)$/i);
|
|
57
|
+
return m ? m[1].toLowerCase() : '';
|
|
58
|
+
}
|
|
59
|
+
function languageForExt(ext) {
|
|
60
|
+
if (ext === 'ts' || ext === 'tsx' || ext === 'mts' || ext === 'cts')
|
|
61
|
+
return 'TypeScript';
|
|
62
|
+
if (ext === 'js' || ext === 'jsx' || ext === 'mjs' || ext === 'cjs')
|
|
63
|
+
return 'JavaScript';
|
|
64
|
+
if (ext === 'py' || ext === 'pyi')
|
|
65
|
+
return 'Python';
|
|
66
|
+
if (ext === 'go')
|
|
67
|
+
return 'Go';
|
|
68
|
+
if (ext === 'rs')
|
|
69
|
+
return 'Rust';
|
|
70
|
+
if (ext === 'java')
|
|
71
|
+
return 'Java';
|
|
72
|
+
if (ext === 'kt')
|
|
73
|
+
return 'Kotlin';
|
|
74
|
+
if (ext === 'rb')
|
|
75
|
+
return 'Ruby';
|
|
76
|
+
if (ext === 'php')
|
|
77
|
+
return 'PHP';
|
|
78
|
+
if (ext === 'cs')
|
|
79
|
+
return 'C#';
|
|
80
|
+
if (ext === 'sql')
|
|
81
|
+
return 'SQL';
|
|
82
|
+
return 'unknown';
|
|
83
|
+
}
|
|
84
|
+
function isSourcePath(filePath) {
|
|
85
|
+
return SOURCE_EXT.has(extOf(filePath));
|
|
86
|
+
}
|
|
87
|
+
function normalizePath(filePath) {
|
|
88
|
+
return filePath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').trim();
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Collapse a file path to a module id using the first `depth` directory
|
|
92
|
+
* segments. Root-level files map to the synthetic module ".".
|
|
93
|
+
*
|
|
94
|
+
* When a repository contains an embedded service/app fixture, preserve the
|
|
95
|
+
* prefix up to a recognizable app root (`src`, `packages`, `services`, etc.)
|
|
96
|
+
* and then apply depth from there. Without this, paths such as
|
|
97
|
+
* `fixtures/demo-svc/src/billing/charge.py` collapse to `fixtures/demo-svc`,
|
|
98
|
+
* mixing billing/auth/migration ownership into one misleading module.
|
|
99
|
+
*/
|
|
100
|
+
function moduleIdForPath(filePath, depth = 2) {
|
|
101
|
+
const norm = normalizePath(filePath);
|
|
102
|
+
const segments = norm.split('/').filter(Boolean);
|
|
103
|
+
if (segments.length <= 1)
|
|
104
|
+
return '.';
|
|
105
|
+
const dirSegments = segments.slice(0, -1);
|
|
106
|
+
const effectiveDepth = Math.max(1, depth);
|
|
107
|
+
const topLevelWorkspaceRoot = ['packages', 'services', 'apps', 'web', 'actions'].includes(dirSegments[0] ?? '');
|
|
108
|
+
const embeddedRootIndex = topLevelWorkspaceRoot
|
|
109
|
+
? -1
|
|
110
|
+
: dirSegments.findIndex((segment, index) => {
|
|
111
|
+
if (index === 0)
|
|
112
|
+
return false;
|
|
113
|
+
return ['src', 'packages', 'services', 'apps', 'web', 'actions', 'migrations'].includes(segment);
|
|
114
|
+
});
|
|
115
|
+
if (embeddedRootIndex > 0) {
|
|
116
|
+
const rootSegment = dirSegments[embeddedRootIndex];
|
|
117
|
+
const rootDepth = rootSegment === 'migrations' ? 1 : effectiveDepth;
|
|
118
|
+
return dirSegments.slice(0, embeddedRootIndex + rootDepth).join('/');
|
|
119
|
+
}
|
|
120
|
+
return dirSegments.slice(0, effectiveDepth).join('/');
|
|
121
|
+
}
|
|
122
|
+
function moduleGlob(moduleId) {
|
|
123
|
+
return moduleId === '.' ? '*' : `${moduleId}/**`;
|
|
124
|
+
}
|
|
125
|
+
function uniqueSorted(values) {
|
|
126
|
+
return Array.from(new Set(values)).sort();
|
|
127
|
+
}
|
|
128
|
+
// ── Import specifier extraction (pure, source-free output) ────────────────────
|
|
129
|
+
const TS_IMPORT_RE = /\b(?:import|export)\s+(?:[^'"`;]*?\sfrom\s+)?['"]([^'"]+)['"]/g;
|
|
130
|
+
const TS_BARE_IMPORT_RE = /\bimport\s+['"]([^'"]+)['"]/g;
|
|
131
|
+
const TS_REQUIRE_RE = /\brequire\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
132
|
+
const TS_DYNAMIC_IMPORT_RE = /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
133
|
+
const PY_IMPORT_RE = /^[ \t]*import\s+([a-zA-Z0-9_.]+)/gm;
|
|
134
|
+
const PY_FROM_RE = /^[ \t]*from\s+(\.*[a-zA-Z0-9_.]*)\s+import\b/gm;
|
|
135
|
+
/**
|
|
136
|
+
* Extract import specifiers (module strings) from a single file's content.
|
|
137
|
+
*
|
|
138
|
+
* Returns only the quoted/dotted module specifiers — never source text. The
|
|
139
|
+
* caller reads file content locally and discards it after calling this.
|
|
140
|
+
*/
|
|
141
|
+
function extractImportSpecifiers(filePath, content) {
|
|
142
|
+
if (!content)
|
|
143
|
+
return [];
|
|
144
|
+
const ext = extOf(filePath);
|
|
145
|
+
const found = new Set();
|
|
146
|
+
const collect = (re, input) => {
|
|
147
|
+
const pattern = new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`);
|
|
148
|
+
let match;
|
|
149
|
+
while ((match = pattern.exec(input)) !== null) {
|
|
150
|
+
const spec = match[1]?.trim();
|
|
151
|
+
if (spec)
|
|
152
|
+
found.add(spec);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
if (TS_JS_EXT.has(ext)) {
|
|
156
|
+
collect(TS_IMPORT_RE, content);
|
|
157
|
+
collect(TS_BARE_IMPORT_RE, content);
|
|
158
|
+
collect(TS_REQUIRE_RE, content);
|
|
159
|
+
collect(TS_DYNAMIC_IMPORT_RE, content);
|
|
160
|
+
}
|
|
161
|
+
else if (PY_EXT.has(ext)) {
|
|
162
|
+
collect(PY_IMPORT_RE, content);
|
|
163
|
+
collect(PY_FROM_RE, content);
|
|
164
|
+
}
|
|
165
|
+
return Array.from(found);
|
|
166
|
+
}
|
|
167
|
+
// ── Import resolution (specifier → repo-relative module file) ──────────────────
|
|
168
|
+
const TS_RESOLVE_SUFFIXES = [
|
|
169
|
+
'',
|
|
170
|
+
'.ts',
|
|
171
|
+
'.tsx',
|
|
172
|
+
'.js',
|
|
173
|
+
'.jsx',
|
|
174
|
+
'.mjs',
|
|
175
|
+
'.cjs',
|
|
176
|
+
'.mts',
|
|
177
|
+
'.cts',
|
|
178
|
+
'/index.ts',
|
|
179
|
+
'/index.tsx',
|
|
180
|
+
'/index.js',
|
|
181
|
+
'/index.jsx',
|
|
182
|
+
'/index.mjs',
|
|
183
|
+
];
|
|
184
|
+
const PY_RESOLVE_SUFFIXES = ['.py', '.pyi', '/__init__.py', '/__init__.pyi'];
|
|
185
|
+
function joinPath(base, rel) {
|
|
186
|
+
const stack = base ? base.split('/') : [];
|
|
187
|
+
for (const part of rel.split('/')) {
|
|
188
|
+
if (part === '' || part === '.')
|
|
189
|
+
continue;
|
|
190
|
+
if (part === '..')
|
|
191
|
+
stack.pop();
|
|
192
|
+
else
|
|
193
|
+
stack.push(part);
|
|
194
|
+
}
|
|
195
|
+
return stack.join('/');
|
|
196
|
+
}
|
|
197
|
+
function dirOf(filePath) {
|
|
198
|
+
const norm = normalizePath(filePath);
|
|
199
|
+
const idx = norm.lastIndexOf('/');
|
|
200
|
+
return idx === -1 ? '' : norm.slice(0, idx);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Resolve an import specifier to a repo-relative source file, if it points to a
|
|
204
|
+
* known in-repo module. External packages (e.g. "fastapi", "react") resolve to
|
|
205
|
+
* null and are intentionally excluded from the internal dependency graph.
|
|
206
|
+
*/
|
|
207
|
+
function resolveImportSpecifier(fromFile, specifier, knownPaths) {
|
|
208
|
+
const ext = extOf(fromFile);
|
|
209
|
+
const spec = specifier.trim();
|
|
210
|
+
if (!spec)
|
|
211
|
+
return null;
|
|
212
|
+
// ── TypeScript / JavaScript ──────────────────────────────────────────────
|
|
213
|
+
if (TS_JS_EXT.has(ext)) {
|
|
214
|
+
if (!spec.startsWith('.'))
|
|
215
|
+
return null; // bare/package import → external
|
|
216
|
+
const base = joinPath(dirOf(fromFile), spec.replace(/\/$/, ''));
|
|
217
|
+
for (const suffix of TS_RESOLVE_SUFFIXES) {
|
|
218
|
+
const candidate = `${base}${suffix}`;
|
|
219
|
+
if (knownPaths.has(candidate))
|
|
220
|
+
return candidate;
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
// ── Python ────────────────────────────────────────────────────────────────
|
|
225
|
+
if (PY_EXT.has(ext)) {
|
|
226
|
+
if (spec.startsWith('.')) {
|
|
227
|
+
// Relative import: leading dots indicate package levels.
|
|
228
|
+
const dots = spec.match(/^\.+/)?.[0].length ?? 0;
|
|
229
|
+
const rest = spec.slice(dots).replace(/\./g, '/');
|
|
230
|
+
let base = dirOf(fromFile);
|
|
231
|
+
for (let i = 1; i < dots; i += 1)
|
|
232
|
+
base = joinPath(base, '..');
|
|
233
|
+
const target = rest ? joinPath(base, rest) : base;
|
|
234
|
+
for (const suffix of PY_RESOLVE_SUFFIXES) {
|
|
235
|
+
const candidate = `${target}${suffix}`;
|
|
236
|
+
if (knownPaths.has(candidate))
|
|
237
|
+
return candidate;
|
|
238
|
+
}
|
|
239
|
+
// `from . import x` → the package's __init__
|
|
240
|
+
if (!rest) {
|
|
241
|
+
for (const suffix of PY_RESOLVE_SUFFIXES) {
|
|
242
|
+
const candidate = `${base}${suffix}`;
|
|
243
|
+
if (knownPaths.has(candidate))
|
|
244
|
+
return candidate;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
// Absolute dotted import: try to map onto a repo file (else external).
|
|
250
|
+
const asPath = spec.replace(/\./g, '/');
|
|
251
|
+
for (const suffix of PY_RESOLVE_SUFFIXES) {
|
|
252
|
+
const candidate = `${asPath}${suffix}`;
|
|
253
|
+
if (knownPaths.has(candidate))
|
|
254
|
+
return candidate;
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
// ── Surface detection ──────────────────────────────────────────────────────
|
|
261
|
+
const SENSITIVE_TAG_TO_SURFACE = {
|
|
262
|
+
auth: 'auth',
|
|
263
|
+
crypto: 'crypto',
|
|
264
|
+
secrets: 'secrets',
|
|
265
|
+
payments: 'payments',
|
|
266
|
+
migrations: 'migration',
|
|
267
|
+
security: 'security',
|
|
268
|
+
custom: null,
|
|
269
|
+
};
|
|
270
|
+
const PUBLIC_API_SEGMENTS = new Set([
|
|
271
|
+
'api',
|
|
272
|
+
'apis',
|
|
273
|
+
'routes',
|
|
274
|
+
'router',
|
|
275
|
+
'routers',
|
|
276
|
+
'controller',
|
|
277
|
+
'controllers',
|
|
278
|
+
'handler',
|
|
279
|
+
'handlers',
|
|
280
|
+
'endpoints',
|
|
281
|
+
'graphql',
|
|
282
|
+
'resolvers',
|
|
283
|
+
'rest',
|
|
284
|
+
]);
|
|
285
|
+
const DATABASE_SEGMENTS = new Set([
|
|
286
|
+
'db',
|
|
287
|
+
'database',
|
|
288
|
+
'models',
|
|
289
|
+
'model',
|
|
290
|
+
'schema',
|
|
291
|
+
'schemas',
|
|
292
|
+
'entities',
|
|
293
|
+
'entity',
|
|
294
|
+
'repositories',
|
|
295
|
+
'repository',
|
|
296
|
+
'dao',
|
|
297
|
+
'orm',
|
|
298
|
+
]);
|
|
299
|
+
const MIGRATION_RE = /(^|\/)(migrations?|alembic|flyway|liquibase)(\/|$)/i;
|
|
300
|
+
function surfacesForModule(moduleId, files, sensitiveTags) {
|
|
301
|
+
const surfaces = new Set();
|
|
302
|
+
for (const tag of sensitiveTags) {
|
|
303
|
+
const surface = SENSITIVE_TAG_TO_SURFACE[tag];
|
|
304
|
+
if (surface)
|
|
305
|
+
surfaces.add(surface);
|
|
306
|
+
}
|
|
307
|
+
const segments = moduleId.split('/').filter(Boolean);
|
|
308
|
+
for (const seg of segments) {
|
|
309
|
+
if (PUBLIC_API_SEGMENTS.has(seg))
|
|
310
|
+
surfaces.add('public-api');
|
|
311
|
+
if (DATABASE_SEGMENTS.has(seg))
|
|
312
|
+
surfaces.add('database');
|
|
313
|
+
}
|
|
314
|
+
for (const file of files) {
|
|
315
|
+
if (MIGRATION_RE.test(file))
|
|
316
|
+
surfaces.add('migration');
|
|
317
|
+
if (/\.sql$/i.test(file))
|
|
318
|
+
surfaces.add('database');
|
|
319
|
+
// Next.js / framework API route convention: pages/api or app/api.
|
|
320
|
+
if (/(^|\/)(pages|app)\/api(\/|$)/i.test(file))
|
|
321
|
+
surfaces.add('public-api');
|
|
322
|
+
if (/(^|\/)openapi|swagger|\.proto$/i.test(file))
|
|
323
|
+
surfaces.add('public-api');
|
|
324
|
+
}
|
|
325
|
+
return uniqueSorted(surfaces);
|
|
326
|
+
}
|
|
327
|
+
function dominantLanguage(languages) {
|
|
328
|
+
let best = 'unknown';
|
|
329
|
+
let bestCount = -1;
|
|
330
|
+
for (const [lang, count] of languages) {
|
|
331
|
+
if (count > bestCount || (count === bestCount && lang < best)) {
|
|
332
|
+
best = lang;
|
|
333
|
+
bestCount = count;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return best;
|
|
337
|
+
}
|
|
338
|
+
function ownersForModuleFiles(files, ownershipBoundaries) {
|
|
339
|
+
const counts = new Map();
|
|
340
|
+
for (const file of files) {
|
|
341
|
+
const owners = (0, profile_1.ownersForPath)(file, ownershipBoundaries);
|
|
342
|
+
if (owners.length === 0)
|
|
343
|
+
continue;
|
|
344
|
+
const sortedOwners = uniqueSorted(owners);
|
|
345
|
+
const key = sortedOwners.join('\u0000');
|
|
346
|
+
const existing = counts.get(key);
|
|
347
|
+
if (existing) {
|
|
348
|
+
existing.count += 1;
|
|
349
|
+
if (file < existing.firstPath)
|
|
350
|
+
existing.firstPath = file;
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
counts.set(key, { owners: sortedOwners, count: 1, firstPath: file });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const ranked = Array.from(counts.values()).sort((a, b) => {
|
|
357
|
+
if (b.count !== a.count)
|
|
358
|
+
return b.count - a.count;
|
|
359
|
+
return a.firstPath.localeCompare(b.firstPath);
|
|
360
|
+
});
|
|
361
|
+
return ranked[0]?.owners ?? [];
|
|
362
|
+
}
|
|
363
|
+
function sensitiveTagsForModule(moduleId, sensitiveBoundaries) {
|
|
364
|
+
const tags = new Set();
|
|
365
|
+
for (const boundary of sensitiveBoundaries) {
|
|
366
|
+
const prefix = boundary.glob.replace('/**', '').replace('/*', '');
|
|
367
|
+
if (moduleId === prefix || moduleId.startsWith(prefix + '/') || prefix.startsWith(moduleId + '/')) {
|
|
368
|
+
tags.add(boundary.tag);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return uniqueSorted(tags);
|
|
372
|
+
}
|
|
373
|
+
function moduleApprovalRequired(moduleId, approvalRequiredGlobs) {
|
|
374
|
+
return approvalRequiredGlobs.some((glob) => {
|
|
375
|
+
const prefix = glob.replace('/**', '').replace('/*', '');
|
|
376
|
+
return moduleId === prefix || moduleId.startsWith(prefix + '/') || prefix.startsWith(moduleId + '/');
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Build the deterministic repository architecture graph. Pure and source-free:
|
|
381
|
+
* the only edge inputs are import *specifiers*, and only module→module edges
|
|
382
|
+
* are retained.
|
|
383
|
+
*/
|
|
384
|
+
function buildArchitectureGraph(input) {
|
|
385
|
+
const moduleDepth = Math.max(1, input.moduleDepth ?? 2);
|
|
386
|
+
const ownershipBoundaries = input.ownershipBoundaries ?? [];
|
|
387
|
+
const sensitiveBoundaries = input.sensitiveBoundaries ?? [];
|
|
388
|
+
const approvalRequiredGlobs = input.approvalRequiredGlobs ?? [];
|
|
389
|
+
const now = input.now || new Date().toISOString();
|
|
390
|
+
const sourcePaths = input.paths.map(normalizePath).filter(isSourcePath);
|
|
391
|
+
const knownPaths = new Set(sourcePaths);
|
|
392
|
+
// 1. Accumulate modules from source paths.
|
|
393
|
+
const accumulators = new Map();
|
|
394
|
+
for (const filePath of sourcePaths) {
|
|
395
|
+
const id = moduleIdForPath(filePath, moduleDepth);
|
|
396
|
+
let acc = accumulators.get(id);
|
|
397
|
+
if (!acc) {
|
|
398
|
+
acc = { id, files: [], languages: new Map() };
|
|
399
|
+
accumulators.set(id, acc);
|
|
400
|
+
}
|
|
401
|
+
acc.files.push(filePath);
|
|
402
|
+
const lang = languageForExt(extOf(filePath));
|
|
403
|
+
acc.languages.set(lang, (acc.languages.get(lang) ?? 0) + 1);
|
|
404
|
+
}
|
|
405
|
+
const modules = Array.from(accumulators.values())
|
|
406
|
+
.map((acc) => {
|
|
407
|
+
const sensitiveTags = sensitiveTagsForModule(acc.id, sensitiveBoundaries);
|
|
408
|
+
const owners = ownersForModuleFiles(acc.files, ownershipBoundaries);
|
|
409
|
+
return {
|
|
410
|
+
id: acc.id,
|
|
411
|
+
glob: moduleGlob(acc.id),
|
|
412
|
+
fileCount: acc.files.length,
|
|
413
|
+
owners,
|
|
414
|
+
sensitiveTags,
|
|
415
|
+
surfaces: surfacesForModule(acc.id, acc.files, sensitiveTags),
|
|
416
|
+
approvalRequired: moduleApprovalRequired(acc.id, approvalRequiredGlobs),
|
|
417
|
+
language: dominantLanguage(acc.languages),
|
|
418
|
+
};
|
|
419
|
+
})
|
|
420
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
421
|
+
const moduleIds = new Set(modules.map((m) => m.id));
|
|
422
|
+
// 2. Resolve import specifiers into module→module edges.
|
|
423
|
+
const edgeWeights = new Map();
|
|
424
|
+
let analyzedFiles = 0;
|
|
425
|
+
let resolvedImports = 0;
|
|
426
|
+
for (const record of input.imports ?? []) {
|
|
427
|
+
const fromFile = normalizePath(record.filePath);
|
|
428
|
+
if (!knownPaths.has(fromFile))
|
|
429
|
+
continue;
|
|
430
|
+
analyzedFiles += 1;
|
|
431
|
+
const fromModule = moduleIdForPath(fromFile, moduleDepth);
|
|
432
|
+
for (const specifier of record.specifiers) {
|
|
433
|
+
const targetFile = resolveImportSpecifier(fromFile, specifier, knownPaths);
|
|
434
|
+
if (!targetFile)
|
|
435
|
+
continue;
|
|
436
|
+
const toModule = moduleIdForPath(targetFile, moduleDepth);
|
|
437
|
+
if (!moduleIds.has(fromModule) || !moduleIds.has(toModule))
|
|
438
|
+
continue;
|
|
439
|
+
if (fromModule === toModule)
|
|
440
|
+
continue; // ignore intra-module imports
|
|
441
|
+
resolvedImports += 1;
|
|
442
|
+
const key = `${fromModule}${toModule}`;
|
|
443
|
+
edgeWeights.set(key, (edgeWeights.get(key) ?? 0) + 1);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
const edges = Array.from(edgeWeights.entries())
|
|
447
|
+
.map(([key, weight]) => {
|
|
448
|
+
const [from, to] = key.split('');
|
|
449
|
+
return { from, to, weight };
|
|
450
|
+
})
|
|
451
|
+
.sort((a, b) => (a.from === b.from ? a.to.localeCompare(b.to) : a.from.localeCompare(b.from)));
|
|
452
|
+
const languages = uniqueSorted(modules.map((m) => m.language).filter((l) => l !== 'unknown'));
|
|
453
|
+
const stats = {
|
|
454
|
+
moduleCount: modules.length,
|
|
455
|
+
edgeCount: edges.length,
|
|
456
|
+
analyzedFiles,
|
|
457
|
+
resolvedImports,
|
|
458
|
+
languages,
|
|
459
|
+
};
|
|
460
|
+
const canonical = JSON.stringify({
|
|
461
|
+
moduleDepth,
|
|
462
|
+
modules: modules.map((m) => ({
|
|
463
|
+
id: m.id,
|
|
464
|
+
owners: [...m.owners].sort(),
|
|
465
|
+
sensitiveTags: m.sensitiveTags,
|
|
466
|
+
surfaces: m.surfaces,
|
|
467
|
+
approvalRequired: m.approvalRequired,
|
|
468
|
+
})),
|
|
469
|
+
edges: edges.map((e) => `${e.from}->${e.to}:${e.weight}`),
|
|
470
|
+
});
|
|
471
|
+
const architectureHash = (0, node_crypto_1.createHash)('sha256').update(canonical).digest('hex').slice(0, 24);
|
|
472
|
+
return {
|
|
473
|
+
schemaVersion: exports.ARCHITECTURE_GRAPH_SCHEMA_VERSION,
|
|
474
|
+
generatedAt: now,
|
|
475
|
+
moduleDepth,
|
|
476
|
+
modules,
|
|
477
|
+
edges,
|
|
478
|
+
stats,
|
|
479
|
+
architectureHash,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
// ── Graph queries ─────────────────────────────────────────────────────────────
|
|
483
|
+
function findModuleForPath(graph, filePath) {
|
|
484
|
+
const norm = normalizePath(filePath);
|
|
485
|
+
const id = moduleIdForPath(norm, graph.moduleDepth);
|
|
486
|
+
const direct = graph.modules.find((m) => m.id === id);
|
|
487
|
+
if (direct)
|
|
488
|
+
return direct;
|
|
489
|
+
// Fall back to the deepest module whose glob contains the path.
|
|
490
|
+
const containing = graph.modules
|
|
491
|
+
.filter((m) => norm === m.id || norm.startsWith(m.id + '/'))
|
|
492
|
+
.sort((a, b) => b.id.length - a.id.length);
|
|
493
|
+
return containing[0] ?? null;
|
|
494
|
+
}
|
|
495
|
+
/** Modules that import the given module (its downstream consumers). */
|
|
496
|
+
function dependentsOf(graph, moduleId) {
|
|
497
|
+
return uniqueSorted(graph.edges.filter((e) => e.to === moduleId).map((e) => e.from));
|
|
498
|
+
}
|
|
499
|
+
/** Modules the given module imports (its upstream providers / dependencies). */
|
|
500
|
+
function dependenciesOf(graph, moduleId) {
|
|
501
|
+
return uniqueSorted(graph.edges.filter((e) => e.from === moduleId).map((e) => e.to));
|
|
502
|
+
}
|
|
503
|
+
const COMPAT_PLAN_PATTERN = '(backward[- ]?compat|backwards[- ]?compat|breaking change|contract|interface stays|preserve (?:the )?(?:public )?(?:interface|api|contract)|do not break|no breaking)';
|
|
504
|
+
const SECURITY_PLAN_PATTERN = '(security review|security owner|security team|threat model|secure by|authn|authz|access control)';
|
|
505
|
+
const MIGRATION_PLAN_PATTERN = '(rollback|reversible|down migration|backfill safety|restore strategy|migration review|expand[- ]and[- ]contract)';
|
|
506
|
+
function moduleMatchesCandidate(module, candidate) {
|
|
507
|
+
const norm = normalizePath(candidate);
|
|
508
|
+
if (!norm)
|
|
509
|
+
return false;
|
|
510
|
+
const prefix = norm.replace('/**', '').replace('/*', '');
|
|
511
|
+
if (module.id === prefix)
|
|
512
|
+
return true;
|
|
513
|
+
if (prefix.startsWith(module.id + '/'))
|
|
514
|
+
return true; // candidate file under module
|
|
515
|
+
if (module.id.startsWith(prefix + '/'))
|
|
516
|
+
return true; // candidate glob over module
|
|
517
|
+
// Glob form (e.g. "src/api/**") matching a file under the module.
|
|
518
|
+
if (norm.includes('*') && micromatch_1.default.isMatch(module.id, prefix, { dot: true }))
|
|
519
|
+
return true;
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
/** Modules considered "in play" for a set of candidate paths/globs. */
|
|
523
|
+
function modulesInPlay(graph, candidatePaths) {
|
|
524
|
+
const candidates = candidatePaths.map(normalizePath).filter(Boolean);
|
|
525
|
+
if (candidates.length === 0)
|
|
526
|
+
return [];
|
|
527
|
+
return graph.modules.filter((module) => candidates.some((candidate) => moduleMatchesCandidate(module, candidate)));
|
|
528
|
+
}
|
|
529
|
+
function ownerLabel(owners) {
|
|
530
|
+
if (owners.length === 0)
|
|
531
|
+
return 'the owning team';
|
|
532
|
+
return owners.join(', ');
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Derive graph obligation seeds for the modules currently in play. Deterministic
|
|
536
|
+
* and ordered by id.
|
|
537
|
+
*/
|
|
538
|
+
function deriveGraphObligationSeeds(args) {
|
|
539
|
+
const { graph } = args;
|
|
540
|
+
const inPlay = modulesInPlay(graph, args.candidatePaths);
|
|
541
|
+
const seeds = [];
|
|
542
|
+
for (const module of inPlay) {
|
|
543
|
+
const owners = ownerLabel(module.owners);
|
|
544
|
+
// Payments: billing/payments-owned code requires explicit approval.
|
|
545
|
+
if (module.surfaces.includes('payments')) {
|
|
546
|
+
seeds.push({
|
|
547
|
+
id: `architecture:payments-approval:${module.id}`,
|
|
548
|
+
category: 'payments',
|
|
549
|
+
title: `Approve billing-owned edit in ${module.id}`,
|
|
550
|
+
description: `This edit touches billing-owned code. Approval required from ${owners}.`,
|
|
551
|
+
severity: 'critical',
|
|
552
|
+
module: module.id,
|
|
553
|
+
requiredPath: module.glob,
|
|
554
|
+
triggeredBy: [`edit targets payments surface ${module.id}`],
|
|
555
|
+
requiredEvidence: [`Obtain an approval covering ${module.id} from ${owners}.`],
|
|
556
|
+
surface: 'payments',
|
|
557
|
+
satisfy: { approval: true },
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
// Auth / security / secrets / crypto: security-owner awareness.
|
|
561
|
+
const securitySurface = ['auth', 'security', 'secrets', 'crypto'].find((s) => module.surfaces.includes(s));
|
|
562
|
+
if (securitySurface) {
|
|
563
|
+
seeds.push({
|
|
564
|
+
id: `architecture:security-awareness:${module.id}`,
|
|
565
|
+
category: 'security',
|
|
566
|
+
title: `Security-owner awareness for ${module.id}`,
|
|
567
|
+
description: `This edit touches ${securitySurface}-sensitive code in ${module.id}. Security owner (${owners}) awareness required.`,
|
|
568
|
+
severity: 'critical',
|
|
569
|
+
module: module.id,
|
|
570
|
+
requiredPath: module.glob,
|
|
571
|
+
triggeredBy: [`edit targets ${securitySurface} surface ${module.id}`],
|
|
572
|
+
requiredEvidence: [
|
|
573
|
+
`Approve ${module.id} or record a security-review commitment in the accepted plan.`,
|
|
574
|
+
],
|
|
575
|
+
surface: securitySurface,
|
|
576
|
+
satisfy: { approval: true, planPattern: SECURITY_PLAN_PATTERN },
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
// Migration / database migration: migration review.
|
|
580
|
+
if (module.surfaces.includes('migration')) {
|
|
581
|
+
seeds.push({
|
|
582
|
+
id: `architecture:migration-review:${module.id}`,
|
|
583
|
+
category: 'data-model',
|
|
584
|
+
title: `Migration review for ${module.id}`,
|
|
585
|
+
description: `This edit touches database migration files in ${module.id}. Migration review required.`,
|
|
586
|
+
severity: 'critical',
|
|
587
|
+
module: module.id,
|
|
588
|
+
requiredPath: module.glob,
|
|
589
|
+
triggeredBy: [`edit targets migration surface ${module.id}`],
|
|
590
|
+
requiredEvidence: [
|
|
591
|
+
`Approve ${module.id} or state a rollback / migration-review commitment in the accepted plan.`,
|
|
592
|
+
],
|
|
593
|
+
surface: 'migration',
|
|
594
|
+
satisfy: { approval: true, planPattern: MIGRATION_PLAN_PATTERN },
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
// Public API: contract compatibility.
|
|
598
|
+
if (module.surfaces.includes('public-api')) {
|
|
599
|
+
seeds.push({
|
|
600
|
+
id: `architecture:api-contract:${module.id}`,
|
|
601
|
+
category: 'api-contract',
|
|
602
|
+
title: `Confirm API contract compatibility for ${module.id}`,
|
|
603
|
+
description: `This edit touches public API routes in ${module.id}. Confirm contract compatibility before shipping.`,
|
|
604
|
+
severity: 'warn',
|
|
605
|
+
module: module.id,
|
|
606
|
+
requiredPath: module.glob,
|
|
607
|
+
triggeredBy: [`edit targets public-api surface ${module.id}`],
|
|
608
|
+
requiredEvidence: [
|
|
609
|
+
`State a backward-compatibility commitment in the accepted plan, or cover the route with a test.`,
|
|
610
|
+
],
|
|
611
|
+
surface: 'public-api',
|
|
612
|
+
satisfy: { approval: false, planPattern: COMPAT_PLAN_PATTERN, moduleTest: true },
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
// Downstream impact: editing a module with consumers may break their contract.
|
|
616
|
+
const dependents = dependentsOf(graph, module.id);
|
|
617
|
+
const contractBearing = module.surfaces.includes('public-api') ||
|
|
618
|
+
/(^|\/)(lib|core|shared|common|contracts?|api|utils?|types?|models?)(\/|$)/i.test(module.id);
|
|
619
|
+
if (dependents.length > 0 && (contractBearing || dependents.length >= 3)) {
|
|
620
|
+
const shown = dependents.slice(0, 3).join(', ');
|
|
621
|
+
const more = dependents.length > 3 ? ` (+${dependents.length - 3} more)` : '';
|
|
622
|
+
seeds.push({
|
|
623
|
+
id: `architecture:downstream-impact:${module.id}`,
|
|
624
|
+
category: 'dependency',
|
|
625
|
+
title: `Review downstream impact of ${module.id}`,
|
|
626
|
+
description: `This edit may affect downstream module${dependents.length === 1 ? '' : 's'} ${shown}${more}. Review obligation pending.`,
|
|
627
|
+
severity: 'warn',
|
|
628
|
+
module: module.id,
|
|
629
|
+
requiredPath: module.glob,
|
|
630
|
+
triggeredBy: [`${dependents.length} module(s) depend on ${module.id}`],
|
|
631
|
+
requiredEvidence: [
|
|
632
|
+
`State that ${module.id}'s public contract is preserved in the accepted plan, or add a covering test.`,
|
|
633
|
+
],
|
|
634
|
+
surface: 'dependency',
|
|
635
|
+
satisfy: { approval: false, planPattern: COMPAT_PLAN_PATTERN, moduleTest: true },
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return seeds.sort((a, b) => a.id.localeCompare(b.id));
|
|
640
|
+
}
|
|
641
|
+
/** True when a graph obligation can be satisfied by editing the module's tests. */
|
|
642
|
+
function isModuleTestSatisfiable(obligationId) {
|
|
643
|
+
return (obligationId.startsWith('architecture:api-contract:') ||
|
|
644
|
+
obligationId.startsWith('architecture:downstream-impact:'));
|
|
645
|
+
}
|
|
646
|
+
//# sourceMappingURL=architecture-graph.js.map
|