@codapult/guard 0.2.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.
@@ -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 parseModule(project, root, path, projectFiles) {
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
- if (resolvedPath)
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
- if (resolvedPath)
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
- if (resolvedPath)
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,
@@ -645,9 +740,23 @@ function buildInsights(root, files, modules, sourceFiles, dependencies, astProje
645
740
  function createAstProject(root) {
646
741
  const tsConfigPath = resolve(root, 'tsconfig.json');
647
742
  try {
648
- return existsSync(tsConfigPath)
743
+ const project = existsSync(tsConfigPath)
649
744
  ? new Project({ tsConfigFilePath: tsConfigPath })
650
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;
651
760
  }
652
761
  catch {
653
762
  return new Project({ skipAddingFilesFromTsConfig: true });
@@ -658,19 +767,42 @@ export function discoverProject(root, options = {}) {
658
767
  const dependencies = asStringRecord(packageJson.dependencies);
659
768
  const devDependencies = asStringRecord(packageJson.devDependencies);
660
769
  const scripts = asStringRecord(packageJson.scripts);
661
- const allFiles = listFiles(root);
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
+ }
662
790
  const signature = JSON.stringify([
663
791
  JSON.stringify(packageJson),
664
792
  allFiles.map((path) => {
665
793
  const stat = statSync(resolve(root, path));
666
- return [path, stat.size, stat.mtimeMs];
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)];
667
797
  }),
668
798
  runGit(root, ['rev-parse', 'HEAD']),
669
799
  runGit(root, ['status', '--porcelain']),
670
800
  ]);
671
801
  const cacheKey = resolve(root);
802
+ discoveryCacheHits.set(cacheKey, false);
672
803
  const cached = discoveryCache.get(cacheKey);
673
804
  if (cached?.signature === signature) {
805
+ discoveryCacheHits.set(cacheKey, true);
674
806
  discoveryReuse.set(cacheKey, cached.model.modules.length);
675
807
  return cached.model;
676
808
  }
@@ -683,6 +815,7 @@ export function discoverProject(root, options = {}) {
683
815
  persisted.model.version === 1) {
684
816
  previousModel = persisted.model;
685
817
  if (persisted.signature === signature) {
818
+ discoveryCacheHits.set(cacheKey, true);
686
819
  discoveryCache.set(cacheKey, { signature, model: previousModel });
687
820
  discoveryReuse.set(cacheKey, previousModel.modules.length);
688
821
  return previousModel;
@@ -699,6 +832,7 @@ export function discoverProject(root, options = {}) {
699
832
  }));
700
833
  const projectFiles = new Set(files.map((file) => file.path));
701
834
  const astProject = createAstProject(root);
835
+ const aliases = pathAliases(root);
702
836
  const previousModules = new Map(previousModel?.modules.map((module) => [module.path, module]));
703
837
  const currentShape = files
704
838
  .filter((file) => file.kind === 'source' || file.kind === 'test')
@@ -723,7 +857,7 @@ export function discoverProject(root, options = {}) {
723
857
  reusedModules += 1;
724
858
  return previous;
725
859
  }
726
- return parseModule(astProject, root, file.path, projectFiles);
860
+ return parseModule(astProject, root, file.path, projectFiles, aliases);
727
861
  })
728
862
  .filter((module) => module !== undefined);
729
863
  const sourceFiles = files.filter((file) => file.kind === 'source').map((file) => file.path);
@@ -806,7 +940,6 @@ export function discoverProject(root, options = {}) {
806
940
  }
807
941
  export function discoverProjectWithMetrics(root, options = {}) {
808
942
  const startedAt = Date.now();
809
- const cacheHit = discoveryCache.has(resolve(root));
810
943
  const model = discoverProject(root, options);
811
944
  return {
812
945
  model,
@@ -815,7 +948,7 @@ export function discoverProjectWithMetrics(root, options = {}) {
815
948
  files: model.files.length,
816
949
  modules: model.modules.length,
817
950
  cacheAvailable: existsSync(discoveryCachePath(root)),
818
- cacheHit,
951
+ cacheHit: discoveryCacheHits.get(resolve(root)) ?? false,
819
952
  changedFiles: model.git.changedFiles.length,
820
953
  reusedModules: discoveryReuse.get(resolve(root)) ?? 0,
821
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
+ }
@@ -1,5 +1,5 @@
1
- import type { ProjectModel } from './discovery/discovery.js';
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
3
  import { type GuardImpactAnalysis } from './analysis/impact.js';
4
4
  export type GuardSeverity = 'error' | 'warning' | 'info';
5
5
  export type GuardOutcomeStatus = 'pass' | 'fail' | 'warning' | 'needs-review' | 'not-configured';
@@ -11,7 +11,7 @@ export declare function classifyGuardOutcome(input: {
11
11
  }): GuardOutcomeStatus;
12
12
  export type GuardRuleKind = 'forbidden-import' | 'client-forbidden-import';
13
13
  export type GuardRuleStatus = 'active' | 'proposed';
14
- export type GuardContractKind = 'guidance' | 'import-boundary' | 'required-call';
14
+ export type GuardContractKind = 'guidance' | 'import-boundary' | 'required-call' | 'package-boundary';
15
15
  export interface GuardContract {
16
16
  id: string;
17
17
  statement: string;
@@ -25,6 +25,8 @@ export interface GuardContract {
25
25
  mustImport?: string[];
26
26
  mustNotImport?: string[];
27
27
  mustCall?: string[];
28
+ fromPackages?: string[];
29
+ mustNotImportPackages?: string[];
28
30
  status?: GuardRuleStatus;
29
31
  confidence?: 'high' | 'medium' | 'low';
30
32
  evidence?: string[];
@@ -89,6 +91,7 @@ export interface GuardFinding {
89
91
  file: string;
90
92
  line: number;
91
93
  importPath: string;
94
+ resolvedPath?: string;
92
95
  message: string;
93
96
  fingerprint: string;
94
97
  }
@@ -148,6 +151,12 @@ export declare const defaultGuardAgentConfig: GuardAgentConfig;
148
151
  export declare class GuardAlreadyInitializedError extends Error {
149
152
  constructor();
150
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
+ }
151
160
  export declare function isGuardContractsFile(value: unknown): value is {
152
161
  version: 1;
153
162
  contracts: GuardContract[];
@@ -160,7 +169,6 @@ export declare function loadGuardProposals(root: string): GuardProposalFile | un
160
169
  export declare function isGuardAgentConfig(value: unknown): value is GuardAgentConfig;
161
170
  export declare function loadGuardAgentConfig(root: string): GuardAgentConfig;
162
171
  export declare function writeGuardAgentConfig(root: string, agentConfig?: GuardAgentConfig): void;
163
- export declare function loadGuardArtifact(root: string, relativePath: string): unknown;
164
172
  export declare function loadBaseline(root: string): Set<string>;
165
173
  export declare function updateBaseline(root: string, options?: {
166
174
  add?: string[];
@@ -170,6 +178,10 @@ export declare function updateBaseline(root: string, options?: {
170
178
  export declare function writeGuardConfig(root: string, guardConfig: GuardConfig): void;
171
179
  export declare function writeGuardProposals(root: string, proposals: GuardProposalFile): void;
172
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
+ }[];
173
185
  export declare function buildGuardProposals(model: ProjectModel, guardConfig: GuardConfig): GuardProposalFile;
174
186
  export declare function buildArchitectureMemory(model: ProjectModel): Record<string, unknown>;
175
187
  export declare function buildConventionsMemory(model: ProjectModel): Record<string, unknown>;
@@ -179,6 +191,7 @@ export declare function writeBaseline(root: string, findings: GuardFinding[], mo
179
191
  export declare function loadProjectModel(root: string): ProjectModel | undefined;
180
192
  export declare function writeProjectModel(root: string, model: ProjectModel): void;
181
193
  export declare function writeProjectSnapshot(root: string, model: ProjectModel): string;
194
+ export declare function loadGuardArtifact(root: string, relativePath: string): unknown;
182
195
  export declare function validateGuardContracts(root: string, contracts?: GuardContract[]): GuardContractIssue[];
183
196
  /** @internal Redacts common credential shapes before a diff enters an AI review packet. */
184
197
  export declare function redactSensitiveText(value: string): {
@@ -194,4 +207,4 @@ export declare function initializeGuard(root: string, options?: {
194
207
  config: GuardConfig;
195
208
  report: GuardReport;
196
209
  };
197
- export { discoverProject, discoverProjectWithMetrics, findGuardRoot };
210
+ export { clearDiscoveryCache, discoverProject, discoverProjectWithMetrics, findGuardRoot };