@codapult/guard 0.1.0 → 0.3.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/README.md +95 -15
- package/dist/adapters/command.js +23 -2
- package/dist/cli/commands/guard.d.ts +10 -1
- package/dist/cli/commands/guard.js +148 -14
- package/dist/cli/index.js +28 -4
- package/dist/core/analysis/doctor.js +19 -5
- package/dist/core/analysis/impact.d.ts +22 -0
- package/dist/core/analysis/impact.js +73 -0
- package/dist/core/discovery/discovery.d.ts +23 -6
- package/dist/core/discovery/discovery.js +166 -26
- package/dist/core/errors.d.ts +11 -0
- package/dist/core/errors.js +11 -0
- package/dist/core/guard.d.ts +26 -5
- package/dist/core/guard.js +262 -43
- package/dist/core/history/history.d.ts +6 -0
- package/dist/core/history/history.js +26 -2
- package/dist/core/policy/schemas.d.ts +12 -0
- package/dist/core/policy/schemas.js +21 -17
- package/dist/core/verification/verify.d.ts +5 -0
- package/dist/core/verification/verify.js +26 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mcp/tools/guard.js +167 -47
- package/package.json +17 -12
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { buildModuleTargetGraph } from '../discovery/discovery.js';
|
|
2
|
+
function matchesScope(path, scope) {
|
|
3
|
+
return path === scope || path.startsWith(`${scope.replace(/\\/g, '/')}/`);
|
|
4
|
+
}
|
|
5
|
+
function walk(graph, starts) {
|
|
6
|
+
const visited = new Set();
|
|
7
|
+
const queue = [...starts];
|
|
8
|
+
while (queue.length > 0) {
|
|
9
|
+
const current = queue.shift();
|
|
10
|
+
if (!current || visited.has(current))
|
|
11
|
+
continue;
|
|
12
|
+
visited.add(current);
|
|
13
|
+
for (const next of graph.get(current) ?? []) {
|
|
14
|
+
if (!visited.has(next))
|
|
15
|
+
queue.push(next);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return visited;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Computes the blast radius of a change from the discovered graph.
|
|
22
|
+
*
|
|
23
|
+
* The graph direction is importer -> imported module. Therefore `dependencies` are what the
|
|
24
|
+
* changed code relies on, while `dependents` are callers that can regress when the changed
|
|
25
|
+
* code changes. Both directions matter for an AI review packet.
|
|
26
|
+
*/
|
|
27
|
+
export function analyzeProjectImpact(model, files, contracts = []) {
|
|
28
|
+
const requestedFiles = [...new Set([...files].map((file) => file.replace(/\\/g, '/')))].sort();
|
|
29
|
+
const modulePaths = new Set(model.modules.map((module) => module.path));
|
|
30
|
+
const directModules = requestedFiles.filter((file) => modulePaths.has(file));
|
|
31
|
+
const graph = buildModuleTargetGraph(model.modules);
|
|
32
|
+
const reverse = new Map();
|
|
33
|
+
for (const [from, targets] of graph) {
|
|
34
|
+
for (const to of targets) {
|
|
35
|
+
const callers = reverse.get(to) ?? [];
|
|
36
|
+
callers.push(from);
|
|
37
|
+
reverse.set(to, callers);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const dependencies = walk(graph, directModules);
|
|
41
|
+
const dependencyList = [...dependencies].filter((path) => !directModules.includes(path)).sort();
|
|
42
|
+
const dependents = walk(reverse, directModules);
|
|
43
|
+
const dependentList = [...dependents].filter((path) => !directModules.includes(path)).sort();
|
|
44
|
+
const affected = new Set([...directModules, ...dependencyList, ...dependentList]);
|
|
45
|
+
const impactPaths = model.insights.impactPaths.filter((impact) => impact.entrypoint &&
|
|
46
|
+
(affected.has(impact.entrypoint) || impact.files.some((file) => affected.has(file))));
|
|
47
|
+
const capabilities = Object.entries(model.capabilities)
|
|
48
|
+
.filter(([, signal]) => signal.files.some((file) => affected.has(file)))
|
|
49
|
+
.map(([id]) => id)
|
|
50
|
+
.sort();
|
|
51
|
+
const relevantContracts = contracts.filter((contract) => {
|
|
52
|
+
const scopes = [...(contract.scope ?? []), ...(contract.entrypoints ?? [])];
|
|
53
|
+
return (scopes.length === 0 ||
|
|
54
|
+
scopes.some((scope) => [...affected].some((file) => matchesScope(file, scope))));
|
|
55
|
+
});
|
|
56
|
+
const dependencyEdges = model.insights.dependencyEdges.filter((edge) => affected.has(edge.from) || affected.has(edge.to));
|
|
57
|
+
const affectedLayers = new Set(Object.entries(model.insights.layers)
|
|
58
|
+
.filter(([, paths]) => paths.some((path) => affected.has(path)))
|
|
59
|
+
.map(([layer]) => layer));
|
|
60
|
+
const layerEdges = model.insights.layerEdges.filter((edge) => affectedLayers.has(edge.from) || affectedLayers.has(edge.to));
|
|
61
|
+
return {
|
|
62
|
+
requestedFiles,
|
|
63
|
+
directModules,
|
|
64
|
+
dependencies: dependencyList,
|
|
65
|
+
dependents: dependentList,
|
|
66
|
+
affectedModules: [...affected].sort(),
|
|
67
|
+
impactPaths,
|
|
68
|
+
capabilities,
|
|
69
|
+
relevantContracts,
|
|
70
|
+
dependencyEdges,
|
|
71
|
+
layerEdges,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -11,6 +11,7 @@ export interface ModuleRecord {
|
|
|
11
11
|
exports: string[];
|
|
12
12
|
calls: string[];
|
|
13
13
|
resolvedImports: string[];
|
|
14
|
+
resolvedImportMap?: Record<string, string>;
|
|
14
15
|
dynamicImports: string[];
|
|
15
16
|
declarations: {
|
|
16
17
|
classes: number;
|
|
@@ -44,6 +45,8 @@ export interface ProjectModel {
|
|
|
44
45
|
private: boolean;
|
|
45
46
|
scripts: Record<string, string>;
|
|
46
47
|
dependencies: string[];
|
|
48
|
+
exports?: string[];
|
|
49
|
+
projectReferences?: string[];
|
|
47
50
|
}[];
|
|
48
51
|
};
|
|
49
52
|
files: ProjectFileRecord[];
|
|
@@ -122,13 +125,27 @@ export interface DiscoveryMetrics {
|
|
|
122
125
|
changedFiles: number;
|
|
123
126
|
reusedModules: number;
|
|
124
127
|
}
|
|
125
|
-
export
|
|
126
|
-
export declare function discoverProject(root: string, options?: {
|
|
127
|
-
persistCache?: boolean;
|
|
128
|
-
}): ProjectModel;
|
|
129
|
-
export declare function discoverProjectWithMetrics(root: string, options?: {
|
|
128
|
+
export interface DiscoveryOptions {
|
|
130
129
|
persistCache?: boolean;
|
|
131
|
-
|
|
130
|
+
/** Maximum number of files included in one model. */
|
|
131
|
+
maxFiles?: number;
|
|
132
|
+
/** Maximum size of one included file in bytes. */
|
|
133
|
+
maxFileBytes?: number;
|
|
134
|
+
}
|
|
135
|
+
export declare class DiscoveryLimitError extends Error {
|
|
136
|
+
constructor(message: string);
|
|
137
|
+
}
|
|
138
|
+
export declare function clearDiscoveryCache(root: string): void;
|
|
139
|
+
export declare function findGuardRoot(from?: string): string;
|
|
140
|
+
/** Build the internal module graph from AST resolution plus conservative fallback resolution.
|
|
141
|
+
*
|
|
142
|
+
* TypeScript resolution is authoritative when available, but it is not complete for every
|
|
143
|
+
* JavaScript/configuration shape. The fallback must be additive: using it only when there are
|
|
144
|
+
* no resolved imports silently drops unresolved edges from mixed projects.
|
|
145
|
+
*/
|
|
146
|
+
export declare function buildModuleTargetGraph(modules: ModuleRecord[]): Map<string, string[]>;
|
|
147
|
+
export declare function discoverProject(root: string, options?: DiscoveryOptions): ProjectModel;
|
|
148
|
+
export declare function discoverProjectWithMetrics(root: string, options?: DiscoveryOptions): {
|
|
132
149
|
model: ProjectModel;
|
|
133
150
|
metrics: DiscoveryMetrics;
|
|
134
151
|
};
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, renameSync, statSync, writeFileSync, } from 'node:fs';
|
|
4
4
|
import { basename, dirname, extname, join, normalize, relative, resolve } from 'node:path';
|
|
5
5
|
import { Project, SyntaxKind } from 'ts-morph';
|
|
6
6
|
import { config } from '../config.js';
|
|
7
|
+
export class DiscoveryLimitError extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'DiscoveryLimitError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const DEFAULT_MAX_FILES = 100_000;
|
|
14
|
+
const DEFAULT_MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|
7
15
|
const IGNORED_DIRECTORIES = new Set([
|
|
8
16
|
'.git',
|
|
9
17
|
'.next',
|
|
@@ -40,12 +48,16 @@ const CONFIG_NAMES = new Set([
|
|
|
40
48
|
]);
|
|
41
49
|
const discoveryCache = new Map();
|
|
42
50
|
const discoveryReuse = new Map();
|
|
51
|
+
const discoveryCacheHits = new Map();
|
|
43
52
|
// Bump when the persisted model shape changes; old cache entries must never
|
|
44
53
|
// bypass discovery and return an incomplete ProjectModel.
|
|
45
54
|
const DISCOVERY_CACHE_VERSION = 3;
|
|
46
55
|
function discoveryCachePath(root) {
|
|
47
56
|
return resolve(root, `.${config.appName}/guard/cache.json`);
|
|
48
57
|
}
|
|
58
|
+
export function clearDiscoveryCache(root) {
|
|
59
|
+
rmSync(discoveryCachePath(root), { force: true });
|
|
60
|
+
}
|
|
49
61
|
function contentHash(root, path) {
|
|
50
62
|
return createHash('sha256')
|
|
51
63
|
.update(readFileSync(resolve(root, path)))
|
|
@@ -121,20 +133,45 @@ function workspacePackages(root, files) {
|
|
|
121
133
|
...asStringRecord(manifest.devDependencies),
|
|
122
134
|
...asStringRecord(manifest.peerDependencies),
|
|
123
135
|
}).sort(),
|
|
136
|
+
...(manifest.exports !== undefined
|
|
137
|
+
? {
|
|
138
|
+
exports: Array.isArray(manifest.exports)
|
|
139
|
+
? manifest.exports.filter((item) => typeof item === 'string')
|
|
140
|
+
: manifest.exports !== null && typeof manifest.exports === 'object'
|
|
141
|
+
? Object.keys(manifest.exports)
|
|
142
|
+
: [],
|
|
143
|
+
}
|
|
144
|
+
: {}),
|
|
145
|
+
...(Array.isArray(readJsonObject(resolve(root, dirname(path), 'tsconfig.json')).references)
|
|
146
|
+
? {
|
|
147
|
+
projectReferences: readJsonObject(resolve(root, dirname(path), 'tsconfig.json'))
|
|
148
|
+
.references
|
|
149
|
+
.filter((reference) => reference !== null &&
|
|
150
|
+
typeof reference === 'object' &&
|
|
151
|
+
typeof reference.path === 'string')
|
|
152
|
+
.map((reference) => reference.path)
|
|
153
|
+
.sort(),
|
|
154
|
+
}
|
|
155
|
+
: {}),
|
|
124
156
|
};
|
|
125
157
|
})
|
|
126
158
|
.sort((left, right) => left.path.localeCompare(right.path));
|
|
127
159
|
}
|
|
128
|
-
function listFiles(root, directory = root) {
|
|
160
|
+
function listFiles(root, directory = root, maxFiles = DEFAULT_MAX_FILES) {
|
|
129
161
|
const result = [];
|
|
130
162
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
131
163
|
if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name))
|
|
132
164
|
continue;
|
|
133
165
|
const path = resolve(directory, entry.name);
|
|
134
166
|
if (entry.isDirectory())
|
|
135
|
-
result.push(...listFiles(root, path));
|
|
136
|
-
else if (entry.isFile() && !ignoredFile(path))
|
|
167
|
+
result.push(...listFiles(root, path, maxFiles - result.length));
|
|
168
|
+
else if (entry.isFile() && !ignoredFile(path)) {
|
|
137
169
|
result.push(relative(root, path));
|
|
170
|
+
if (result.length > maxFiles) {
|
|
171
|
+
throw new DiscoveryLimitError(`Project contains more than ${maxFiles} files; discovery limit was reached. ` +
|
|
172
|
+
'Use an ignore file or raise maxFiles deliberately.');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
138
175
|
}
|
|
139
176
|
return result.sort();
|
|
140
177
|
}
|
|
@@ -169,7 +206,54 @@ function resolveInternalImport(from, importPath, modules) {
|
|
|
169
206
|
];
|
|
170
207
|
return candidates.find((candidate) => modules.has(candidate));
|
|
171
208
|
}
|
|
172
|
-
function
|
|
209
|
+
function pathAliases(root) {
|
|
210
|
+
return listFiles(root)
|
|
211
|
+
.filter((path) => basename(path) === 'tsconfig.json' || basename(path) === 'jsconfig.json')
|
|
212
|
+
.flatMap((path) => {
|
|
213
|
+
const tsconfigJson = readJsonObject(resolve(root, path));
|
|
214
|
+
const compilerOptions = tsconfigJson.compilerOptions !== null && typeof tsconfigJson.compilerOptions === 'object'
|
|
215
|
+
? tsconfigJson.compilerOptions
|
|
216
|
+
: {};
|
|
217
|
+
const paths = compilerOptions.paths;
|
|
218
|
+
if (paths === null || typeof paths !== 'object')
|
|
219
|
+
return [];
|
|
220
|
+
const baseUrl = typeof compilerOptions.baseUrl === 'string' ? compilerOptions.baseUrl : '.';
|
|
221
|
+
return Object.entries(paths).flatMap(([pattern, value]) => {
|
|
222
|
+
if (!Array.isArray(value))
|
|
223
|
+
return [];
|
|
224
|
+
const targets = value.filter((item) => typeof item === 'string');
|
|
225
|
+
return targets.length > 0
|
|
226
|
+
? [{ pattern, baseDirectory: resolve(root, dirname(path), baseUrl), targets }]
|
|
227
|
+
: [];
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
function resolveAliasedImport(root, importPath, aliases, modules) {
|
|
232
|
+
for (const alias of aliases) {
|
|
233
|
+
const wildcard = alias.pattern.indexOf('*');
|
|
234
|
+
const prefix = wildcard >= 0 ? alias.pattern.slice(0, wildcard) : alias.pattern;
|
|
235
|
+
const suffix = wildcard >= 0 ? alias.pattern.slice(wildcard + 1) : '';
|
|
236
|
+
if (!importPath.startsWith(prefix) || (suffix && !importPath.endsWith(suffix)))
|
|
237
|
+
continue;
|
|
238
|
+
const replacement = importPath.slice(prefix.length, importPath.length - suffix.length);
|
|
239
|
+
for (const target of alias.targets) {
|
|
240
|
+
const targetPath = target.replace('*', replacement);
|
|
241
|
+
const absoluteBase = resolve(alias.baseDirectory, targetPath);
|
|
242
|
+
const relativeBase = relative(root, absoluteBase);
|
|
243
|
+
const normalizedBase = relativeBase.replace(/^\.\//, '').replaceAll('\\', '/');
|
|
244
|
+
const candidates = [
|
|
245
|
+
normalizedBase,
|
|
246
|
+
...[...SOURCE_EXTENSIONS].map((extension) => `${normalizedBase}${extension}`),
|
|
247
|
+
...[...SOURCE_EXTENSIONS].map((extension) => `${normalizedBase}/index${extension}`),
|
|
248
|
+
];
|
|
249
|
+
const projectCandidate = candidates.find((candidate) => modules.has(candidate));
|
|
250
|
+
if (projectCandidate)
|
|
251
|
+
return projectCandidate;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
function parseModule(project, root, path, projectFiles, aliases) {
|
|
173
257
|
if (!SOURCE_EXTENSIONS.has(extname(path)))
|
|
174
258
|
return undefined;
|
|
175
259
|
let sourceFile;
|
|
@@ -187,6 +271,7 @@ function parseModule(project, root, path, projectFiles) {
|
|
|
187
271
|
const dynamicImports = [];
|
|
188
272
|
const declarations = { classes: 0, functions: 0, interfaces: 0, types: 0, variables: 0 };
|
|
189
273
|
const resolvedImports = new Set();
|
|
274
|
+
const resolvedImportMap = {};
|
|
190
275
|
for (const declaration of sourceFile.getImportDeclarations()) {
|
|
191
276
|
const importPath = declaration.getModuleSpecifierValue();
|
|
192
277
|
imports.push(importPath);
|
|
@@ -200,9 +285,12 @@ function parseModule(project, root, path, projectFiles) {
|
|
|
200
285
|
importedSymbols.push(namedImport.getAliasNode()?.getText() ?? namedImport.getName());
|
|
201
286
|
}
|
|
202
287
|
const resolved = declaration.getModuleSpecifierSourceFile();
|
|
203
|
-
const resolvedPath = resolved && sourceFilePath(root, resolved, projectFiles)
|
|
204
|
-
|
|
288
|
+
const resolvedPath = (resolved && sourceFilePath(root, resolved, projectFiles)) ??
|
|
289
|
+
resolveAliasedImport(root, importPath, aliases, projectFiles);
|
|
290
|
+
if (resolvedPath) {
|
|
205
291
|
resolvedImports.add(resolvedPath);
|
|
292
|
+
resolvedImportMap[importPath] = resolvedPath;
|
|
293
|
+
}
|
|
206
294
|
}
|
|
207
295
|
for (const declaration of sourceFile.getExportDeclarations()) {
|
|
208
296
|
const moduleSpecifier = declaration.getModuleSpecifierValue();
|
|
@@ -210,9 +298,12 @@ function parseModule(project, root, path, projectFiles) {
|
|
|
210
298
|
continue;
|
|
211
299
|
exports.push(moduleSpecifier);
|
|
212
300
|
const resolved = declaration.getModuleSpecifierSourceFile();
|
|
213
|
-
const resolvedPath = resolved && sourceFilePath(root, resolved, projectFiles)
|
|
214
|
-
|
|
301
|
+
const resolvedPath = (resolved && sourceFilePath(root, resolved, projectFiles)) ??
|
|
302
|
+
resolveAliasedImport(root, moduleSpecifier, aliases, projectFiles);
|
|
303
|
+
if (resolvedPath) {
|
|
215
304
|
resolvedImports.add(resolvedPath);
|
|
305
|
+
resolvedImportMap[moduleSpecifier] = resolvedPath;
|
|
306
|
+
}
|
|
216
307
|
}
|
|
217
308
|
declarations.classes = sourceFile.getClasses().length;
|
|
218
309
|
declarations.functions = sourceFile.getFunctions().length;
|
|
@@ -234,9 +325,12 @@ function parseModule(project, root, path, projectFiles) {
|
|
|
234
325
|
if (!importPath)
|
|
235
326
|
continue;
|
|
236
327
|
dynamicImports.push(importPath);
|
|
237
|
-
const resolvedPath = resolveInternalImport(path, importPath, projectFiles)
|
|
238
|
-
|
|
328
|
+
const resolvedPath = resolveInternalImport(path, importPath, projectFiles) ??
|
|
329
|
+
resolveAliasedImport(root, importPath, aliases, projectFiles);
|
|
330
|
+
if (resolvedPath) {
|
|
239
331
|
resolvedImports.add(resolvedPath);
|
|
332
|
+
resolvedImportMap[importPath] = resolvedPath;
|
|
333
|
+
}
|
|
240
334
|
}
|
|
241
335
|
const directives = sourceFile
|
|
242
336
|
.getStatements()
|
|
@@ -257,6 +351,7 @@ function parseModule(project, root, path, projectFiles) {
|
|
|
257
351
|
exports: [...new Set(exports)].sort(),
|
|
258
352
|
calls: [...new Set(calls)].sort(),
|
|
259
353
|
resolvedImports: [...resolvedImports].sort(),
|
|
354
|
+
resolvedImportMap,
|
|
260
355
|
dynamicImports: [...new Set(dynamicImports)].sort(),
|
|
261
356
|
declarations,
|
|
262
357
|
directives,
|
|
@@ -337,16 +432,23 @@ function layerForPath(path) {
|
|
|
337
432
|
}
|
|
338
433
|
return 'other';
|
|
339
434
|
}
|
|
340
|
-
|
|
435
|
+
/** Build the internal module graph from AST resolution plus conservative fallback resolution.
|
|
436
|
+
*
|
|
437
|
+
* TypeScript resolution is authoritative when available, but it is not complete for every
|
|
438
|
+
* JavaScript/configuration shape. The fallback must be additive: using it only when there are
|
|
439
|
+
* no resolved imports silently drops unresolved edges from mixed projects.
|
|
440
|
+
*/
|
|
441
|
+
export function buildModuleTargetGraph(modules) {
|
|
341
442
|
const moduleSet = new Set(modules.map((candidate) => candidate.path));
|
|
342
|
-
return new Map(modules.map((module) =>
|
|
343
|
-
module.path
|
|
344
|
-
module.
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
.
|
|
348
|
-
|
|
349
|
-
|
|
443
|
+
return new Map(modules.map((module) => {
|
|
444
|
+
const targets = new Set(module.resolvedImports.filter((path) => moduleSet.has(path)));
|
|
445
|
+
for (const importPath of [...module.imports, ...module.dynamicImports]) {
|
|
446
|
+
const target = resolveInternalImport(module.path, importPath, moduleSet);
|
|
447
|
+
if (target)
|
|
448
|
+
targets.add(target);
|
|
449
|
+
}
|
|
450
|
+
return [module.path, [...targets].sort()];
|
|
451
|
+
}));
|
|
350
452
|
}
|
|
351
453
|
function findCycles(modules) {
|
|
352
454
|
const graph = buildModuleTargetGraph(modules);
|
|
@@ -638,9 +740,23 @@ function buildInsights(root, files, modules, sourceFiles, dependencies, astProje
|
|
|
638
740
|
function createAstProject(root) {
|
|
639
741
|
const tsConfigPath = resolve(root, 'tsconfig.json');
|
|
640
742
|
try {
|
|
641
|
-
|
|
743
|
+
const project = existsSync(tsConfigPath)
|
|
642
744
|
? new Project({ tsConfigFilePath: tsConfigPath })
|
|
643
745
|
: new Project({ skipAddingFilesFromTsConfig: true });
|
|
746
|
+
// Monorepos commonly keep compiler options and path aliases per package.
|
|
747
|
+
// Load those projects as well; files still remain bounded by listFiles().
|
|
748
|
+
for (const configPath of listFiles(root).filter((path) => basename(path) === 'tsconfig.json')) {
|
|
749
|
+
const absolutePath = resolve(root, configPath);
|
|
750
|
+
if (absolutePath === tsConfigPath)
|
|
751
|
+
continue;
|
|
752
|
+
try {
|
|
753
|
+
project.addSourceFilesFromTsConfig(absolutePath);
|
|
754
|
+
}
|
|
755
|
+
catch {
|
|
756
|
+
// A package config can be partial or reference an unavailable project.
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return project;
|
|
644
760
|
}
|
|
645
761
|
catch {
|
|
646
762
|
return new Project({ skipAddingFilesFromTsConfig: true });
|
|
@@ -651,19 +767,42 @@ export function discoverProject(root, options = {}) {
|
|
|
651
767
|
const dependencies = asStringRecord(packageJson.dependencies);
|
|
652
768
|
const devDependencies = asStringRecord(packageJson.devDependencies);
|
|
653
769
|
const scripts = asStringRecord(packageJson.scripts);
|
|
654
|
-
const
|
|
770
|
+
const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
|
|
771
|
+
const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
|
|
772
|
+
if (!Number.isInteger(maxFiles) || maxFiles < 1) {
|
|
773
|
+
throw new DiscoveryLimitError('Discovery maxFiles must be a positive integer.');
|
|
774
|
+
}
|
|
775
|
+
if (!Number.isInteger(maxFileBytes) || maxFileBytes < 1) {
|
|
776
|
+
throw new DiscoveryLimitError('Discovery maxFileBytes must be a positive integer.');
|
|
777
|
+
}
|
|
778
|
+
const allFiles = listFiles(root, root, maxFiles);
|
|
779
|
+
if (allFiles.length > maxFiles) {
|
|
780
|
+
throw new DiscoveryLimitError(`Project contains ${allFiles.length} files; discovery limit is ${maxFiles}. ` +
|
|
781
|
+
'Use an ignore file or raise maxFiles deliberately.');
|
|
782
|
+
}
|
|
783
|
+
for (const path of allFiles) {
|
|
784
|
+
const bytes = statSync(resolve(root, path)).size;
|
|
785
|
+
if (bytes > maxFileBytes) {
|
|
786
|
+
throw new DiscoveryLimitError(`File ${path} is ${bytes} bytes; discovery limit is ${maxFileBytes}. ` +
|
|
787
|
+
'Ignore the file or raise maxFileBytes deliberately.');
|
|
788
|
+
}
|
|
789
|
+
}
|
|
655
790
|
const signature = JSON.stringify([
|
|
656
791
|
JSON.stringify(packageJson),
|
|
657
792
|
allFiles.map((path) => {
|
|
658
793
|
const stat = statSync(resolve(root, path));
|
|
659
|
-
|
|
794
|
+
// Size/mtime alone can miss an in-place edit that preserves both values.
|
|
795
|
+
// The model is a correctness boundary, so include content in the cache key.
|
|
796
|
+
return [path, stat.size, stat.mtimeMs, contentHash(root, path)];
|
|
660
797
|
}),
|
|
661
798
|
runGit(root, ['rev-parse', 'HEAD']),
|
|
662
799
|
runGit(root, ['status', '--porcelain']),
|
|
663
800
|
]);
|
|
664
801
|
const cacheKey = resolve(root);
|
|
802
|
+
discoveryCacheHits.set(cacheKey, false);
|
|
665
803
|
const cached = discoveryCache.get(cacheKey);
|
|
666
804
|
if (cached?.signature === signature) {
|
|
805
|
+
discoveryCacheHits.set(cacheKey, true);
|
|
667
806
|
discoveryReuse.set(cacheKey, cached.model.modules.length);
|
|
668
807
|
return cached.model;
|
|
669
808
|
}
|
|
@@ -676,6 +815,7 @@ export function discoverProject(root, options = {}) {
|
|
|
676
815
|
persisted.model.version === 1) {
|
|
677
816
|
previousModel = persisted.model;
|
|
678
817
|
if (persisted.signature === signature) {
|
|
818
|
+
discoveryCacheHits.set(cacheKey, true);
|
|
679
819
|
discoveryCache.set(cacheKey, { signature, model: previousModel });
|
|
680
820
|
discoveryReuse.set(cacheKey, previousModel.modules.length);
|
|
681
821
|
return previousModel;
|
|
@@ -692,6 +832,7 @@ export function discoverProject(root, options = {}) {
|
|
|
692
832
|
}));
|
|
693
833
|
const projectFiles = new Set(files.map((file) => file.path));
|
|
694
834
|
const astProject = createAstProject(root);
|
|
835
|
+
const aliases = pathAliases(root);
|
|
695
836
|
const previousModules = new Map(previousModel?.modules.map((module) => [module.path, module]));
|
|
696
837
|
const currentShape = files
|
|
697
838
|
.filter((file) => file.kind === 'source' || file.kind === 'test')
|
|
@@ -716,7 +857,7 @@ export function discoverProject(root, options = {}) {
|
|
|
716
857
|
reusedModules += 1;
|
|
717
858
|
return previous;
|
|
718
859
|
}
|
|
719
|
-
return parseModule(astProject, root, file.path, projectFiles);
|
|
860
|
+
return parseModule(astProject, root, file.path, projectFiles, aliases);
|
|
720
861
|
})
|
|
721
862
|
.filter((module) => module !== undefined);
|
|
722
863
|
const sourceFiles = files.filter((file) => file.kind === 'source').map((file) => file.path);
|
|
@@ -799,7 +940,6 @@ export function discoverProject(root, options = {}) {
|
|
|
799
940
|
}
|
|
800
941
|
export function discoverProjectWithMetrics(root, options = {}) {
|
|
801
942
|
const startedAt = Date.now();
|
|
802
|
-
const cacheHit = discoveryCache.has(resolve(root));
|
|
803
943
|
const model = discoverProject(root, options);
|
|
804
944
|
return {
|
|
805
945
|
model,
|
|
@@ -808,7 +948,7 @@ export function discoverProjectWithMetrics(root, options = {}) {
|
|
|
808
948
|
files: model.files.length,
|
|
809
949
|
modules: model.modules.length,
|
|
810
950
|
cacheAvailable: existsSync(discoveryCachePath(root)),
|
|
811
|
-
cacheHit,
|
|
951
|
+
cacheHit: discoveryCacheHits.get(resolve(root)) ?? false,
|
|
812
952
|
changedFiles: model.git.changedFiles.length,
|
|
813
953
|
reusedModules: discoveryReuse.get(resolve(root)) ?? 0,
|
|
814
954
|
},
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type GuardErrorCode = 'GUARD_NOT_CONFIGURED' | 'GUARD_CONFIG_INVALID' | 'GUARD_STATE_BUSY' | 'GUARD_INVALID_INPUT';
|
|
2
|
+
export interface GuardErrorPayload {
|
|
3
|
+
status: 'error';
|
|
4
|
+
outcome: 'not-configured' | 'error';
|
|
5
|
+
configured: boolean;
|
|
6
|
+
errorCode: GuardErrorCode;
|
|
7
|
+
message: string;
|
|
8
|
+
recoverable: boolean;
|
|
9
|
+
hint?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function guardErrorPayload(errorCode: GuardErrorCode, message: string, options: Pick<GuardErrorPayload, 'configured' | 'recoverable' | 'outcome'> & Partial<Pick<GuardErrorPayload, 'hint'>>): GuardErrorPayload;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function guardErrorPayload(errorCode, message, options) {
|
|
2
|
+
return {
|
|
3
|
+
status: 'error',
|
|
4
|
+
outcome: options.outcome,
|
|
5
|
+
configured: options.configured,
|
|
6
|
+
errorCode,
|
|
7
|
+
message,
|
|
8
|
+
recoverable: options.recoverable,
|
|
9
|
+
...(options.hint ? { hint: options.hint } : {}),
|
|
10
|
+
};
|
|
11
|
+
}
|
package/dist/core/guard.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { discoverProject, discoverProjectWithMetrics, findGuardRoot } from './discovery/discovery.js';
|
|
1
|
+
import { type ProjectModel } from './discovery/discovery.js';
|
|
2
|
+
import { clearDiscoveryCache, discoverProject, discoverProjectWithMetrics, findGuardRoot } from './discovery/discovery.js';
|
|
3
|
+
import { type GuardImpactAnalysis } from './analysis/impact.js';
|
|
3
4
|
export type GuardSeverity = 'error' | 'warning' | 'info';
|
|
4
5
|
export type GuardOutcomeStatus = 'pass' | 'fail' | 'warning' | 'needs-review' | 'not-configured';
|
|
5
6
|
export declare function classifyGuardOutcome(input: {
|
|
@@ -10,7 +11,7 @@ export declare function classifyGuardOutcome(input: {
|
|
|
10
11
|
}): GuardOutcomeStatus;
|
|
11
12
|
export type GuardRuleKind = 'forbidden-import' | 'client-forbidden-import';
|
|
12
13
|
export type GuardRuleStatus = 'active' | 'proposed';
|
|
13
|
-
export type GuardContractKind = 'guidance' | 'import-boundary' | 'required-call';
|
|
14
|
+
export type GuardContractKind = 'guidance' | 'import-boundary' | 'required-call' | 'package-boundary';
|
|
14
15
|
export interface GuardContract {
|
|
15
16
|
id: string;
|
|
16
17
|
statement: string;
|
|
@@ -24,6 +25,8 @@ export interface GuardContract {
|
|
|
24
25
|
mustImport?: string[];
|
|
25
26
|
mustNotImport?: string[];
|
|
26
27
|
mustCall?: string[];
|
|
28
|
+
fromPackages?: string[];
|
|
29
|
+
mustNotImportPackages?: string[];
|
|
27
30
|
status?: GuardRuleStatus;
|
|
28
31
|
confidence?: 'high' | 'medium' | 'low';
|
|
29
32
|
evidence?: string[];
|
|
@@ -88,9 +91,15 @@ export interface GuardFinding {
|
|
|
88
91
|
file: string;
|
|
89
92
|
line: number;
|
|
90
93
|
importPath: string;
|
|
94
|
+
resolvedPath?: string;
|
|
91
95
|
message: string;
|
|
92
96
|
fingerprint: string;
|
|
93
97
|
}
|
|
98
|
+
export interface GuardFileChange {
|
|
99
|
+
path: string;
|
|
100
|
+
status: 'added' | 'modified' | 'deleted' | 'renamed';
|
|
101
|
+
previousPath?: string;
|
|
102
|
+
}
|
|
94
103
|
export interface GuardContractIssue {
|
|
95
104
|
contractId: string;
|
|
96
105
|
field: 'scope' | 'reference' | 'definition';
|
|
@@ -114,6 +123,7 @@ export interface GuardReviewPacket {
|
|
|
114
123
|
outcome: GuardOutcomeStatus;
|
|
115
124
|
diffBase?: string;
|
|
116
125
|
changedFiles: string[];
|
|
126
|
+
changes: GuardFileChange[];
|
|
117
127
|
diff: string;
|
|
118
128
|
truncated: boolean;
|
|
119
129
|
redacted: boolean;
|
|
@@ -122,6 +132,7 @@ export interface GuardReviewPacket {
|
|
|
122
132
|
contracts: GuardContract[];
|
|
123
133
|
requirement?: string;
|
|
124
134
|
deterministicFindings: GuardFinding[];
|
|
135
|
+
impact: GuardImpactAnalysis;
|
|
125
136
|
reviewInstructions: string[];
|
|
126
137
|
}
|
|
127
138
|
export declare const GUARD_DIR: string;
|
|
@@ -140,6 +151,12 @@ export declare const defaultGuardAgentConfig: GuardAgentConfig;
|
|
|
140
151
|
export declare class GuardAlreadyInitializedError extends Error {
|
|
141
152
|
constructor();
|
|
142
153
|
}
|
|
154
|
+
export declare class GuardConfigError extends Error {
|
|
155
|
+
constructor(path: string);
|
|
156
|
+
}
|
|
157
|
+
export declare class GuardStateBusyError extends Error {
|
|
158
|
+
constructor(path: string);
|
|
159
|
+
}
|
|
143
160
|
export declare function isGuardContractsFile(value: unknown): value is {
|
|
144
161
|
version: 1;
|
|
145
162
|
contracts: GuardContract[];
|
|
@@ -152,7 +169,6 @@ export declare function loadGuardProposals(root: string): GuardProposalFile | un
|
|
|
152
169
|
export declare function isGuardAgentConfig(value: unknown): value is GuardAgentConfig;
|
|
153
170
|
export declare function loadGuardAgentConfig(root: string): GuardAgentConfig;
|
|
154
171
|
export declare function writeGuardAgentConfig(root: string, agentConfig?: GuardAgentConfig): void;
|
|
155
|
-
export declare function loadGuardArtifact(root: string, relativePath: string): unknown;
|
|
156
172
|
export declare function loadBaseline(root: string): Set<string>;
|
|
157
173
|
export declare function updateBaseline(root: string, options?: {
|
|
158
174
|
add?: string[];
|
|
@@ -162,6 +178,10 @@ export declare function updateBaseline(root: string, options?: {
|
|
|
162
178
|
export declare function writeGuardConfig(root: string, guardConfig: GuardConfig): void;
|
|
163
179
|
export declare function writeGuardProposals(root: string, proposals: GuardProposalFile): void;
|
|
164
180
|
export declare function recordGuardProposalDecision(root: string, decisions: Pick<GuardProposalDecision, 'id' | 'type' | 'decision'>[]): void;
|
|
181
|
+
export declare function getPendingGuardProposals(proposals: GuardProposalFile | undefined): {
|
|
182
|
+
id: string;
|
|
183
|
+
type: 'rule' | 'contract';
|
|
184
|
+
}[];
|
|
165
185
|
export declare function buildGuardProposals(model: ProjectModel, guardConfig: GuardConfig): GuardProposalFile;
|
|
166
186
|
export declare function buildArchitectureMemory(model: ProjectModel): Record<string, unknown>;
|
|
167
187
|
export declare function buildConventionsMemory(model: ProjectModel): Record<string, unknown>;
|
|
@@ -171,6 +191,7 @@ export declare function writeBaseline(root: string, findings: GuardFinding[], mo
|
|
|
171
191
|
export declare function loadProjectModel(root: string): ProjectModel | undefined;
|
|
172
192
|
export declare function writeProjectModel(root: string, model: ProjectModel): void;
|
|
173
193
|
export declare function writeProjectSnapshot(root: string, model: ProjectModel): string;
|
|
194
|
+
export declare function loadGuardArtifact(root: string, relativePath: string): unknown;
|
|
174
195
|
export declare function validateGuardContracts(root: string, contracts?: GuardContract[]): GuardContractIssue[];
|
|
175
196
|
/** @internal Redacts common credential shapes before a diff enters an AI review packet. */
|
|
176
197
|
export declare function redactSensitiveText(value: string): {
|
|
@@ -186,4 +207,4 @@ export declare function initializeGuard(root: string, options?: {
|
|
|
186
207
|
config: GuardConfig;
|
|
187
208
|
report: GuardReport;
|
|
188
209
|
};
|
|
189
|
-
export { discoverProject, discoverProjectWithMetrics, findGuardRoot };
|
|
210
|
+
export { clearDiscoveryCache, discoverProject, discoverProjectWithMetrics, findGuardRoot };
|