@codapult/guard 0.2.0 → 0.4.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,23 @@
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
+ export class DiscoveryStaleError extends Error {
14
+ constructor() {
15
+ super('Project changed while Guard was discovering it.');
16
+ this.name = 'DiscoveryStaleError';
17
+ }
18
+ }
19
+ const DEFAULT_MAX_FILES = 100_000;
20
+ const DEFAULT_MAX_FILE_BYTES = 25 * 1024 * 1024;
7
21
  const IGNORED_DIRECTORIES = new Set([
8
22
  '.git',
9
23
  '.next',
@@ -40,12 +54,16 @@ const CONFIG_NAMES = new Set([
40
54
  ]);
41
55
  const discoveryCache = new Map();
42
56
  const discoveryReuse = new Map();
57
+ const discoveryCacheHits = new Map();
43
58
  // Bump when the persisted model shape changes; old cache entries must never
44
59
  // bypass discovery and return an incomplete ProjectModel.
45
60
  const DISCOVERY_CACHE_VERSION = 3;
46
61
  function discoveryCachePath(root) {
47
62
  return resolve(root, `.${config.appName}/guard/cache.json`);
48
63
  }
64
+ export function clearDiscoveryCache(root) {
65
+ rmSync(discoveryCachePath(root), { force: true });
66
+ }
49
67
  function contentHash(root, path) {
50
68
  return createHash('sha256')
51
69
  .update(readFileSync(resolve(root, path)))
@@ -121,20 +139,45 @@ function workspacePackages(root, files) {
121
139
  ...asStringRecord(manifest.devDependencies),
122
140
  ...asStringRecord(manifest.peerDependencies),
123
141
  }).sort(),
142
+ ...(manifest.exports !== undefined
143
+ ? {
144
+ exports: Array.isArray(manifest.exports)
145
+ ? manifest.exports.filter((item) => typeof item === 'string')
146
+ : manifest.exports !== null && typeof manifest.exports === 'object'
147
+ ? Object.keys(manifest.exports)
148
+ : [],
149
+ }
150
+ : {}),
151
+ ...(Array.isArray(readJsonObject(resolve(root, dirname(path), 'tsconfig.json')).references)
152
+ ? {
153
+ projectReferences: readJsonObject(resolve(root, dirname(path), 'tsconfig.json'))
154
+ .references
155
+ .filter((reference) => reference !== null &&
156
+ typeof reference === 'object' &&
157
+ typeof reference.path === 'string')
158
+ .map((reference) => reference.path)
159
+ .sort(),
160
+ }
161
+ : {}),
124
162
  };
125
163
  })
126
164
  .sort((left, right) => left.path.localeCompare(right.path));
127
165
  }
128
- function listFiles(root, directory = root) {
166
+ function listFiles(root, directory = root, maxFiles = DEFAULT_MAX_FILES) {
129
167
  const result = [];
130
168
  for (const entry of readdirSync(directory, { withFileTypes: true })) {
131
169
  if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name))
132
170
  continue;
133
171
  const path = resolve(directory, entry.name);
134
172
  if (entry.isDirectory())
135
- result.push(...listFiles(root, path));
136
- else if (entry.isFile() && !ignoredFile(path))
173
+ result.push(...listFiles(root, path, maxFiles - result.length));
174
+ else if (entry.isFile() && !ignoredFile(path)) {
137
175
  result.push(relative(root, path));
176
+ if (result.length > maxFiles) {
177
+ throw new DiscoveryLimitError(`Project contains more than ${maxFiles} files; discovery limit was reached. ` +
178
+ 'Use an ignore file or raise maxFiles deliberately.');
179
+ }
180
+ }
138
181
  }
139
182
  return result.sort();
140
183
  }
@@ -169,7 +212,54 @@ function resolveInternalImport(from, importPath, modules) {
169
212
  ];
170
213
  return candidates.find((candidate) => modules.has(candidate));
171
214
  }
172
- function parseModule(project, root, path, projectFiles) {
215
+ function pathAliases(root) {
216
+ return listFiles(root)
217
+ .filter((path) => basename(path) === 'tsconfig.json' || basename(path) === 'jsconfig.json')
218
+ .flatMap((path) => {
219
+ const tsconfigJson = readJsonObject(resolve(root, path));
220
+ const compilerOptions = tsconfigJson.compilerOptions !== null && typeof tsconfigJson.compilerOptions === 'object'
221
+ ? tsconfigJson.compilerOptions
222
+ : {};
223
+ const paths = compilerOptions.paths;
224
+ if (paths === null || typeof paths !== 'object')
225
+ return [];
226
+ const baseUrl = typeof compilerOptions.baseUrl === 'string' ? compilerOptions.baseUrl : '.';
227
+ return Object.entries(paths).flatMap(([pattern, value]) => {
228
+ if (!Array.isArray(value))
229
+ return [];
230
+ const targets = value.filter((item) => typeof item === 'string');
231
+ return targets.length > 0
232
+ ? [{ pattern, baseDirectory: resolve(root, dirname(path), baseUrl), targets }]
233
+ : [];
234
+ });
235
+ });
236
+ }
237
+ function resolveAliasedImport(root, importPath, aliases, modules) {
238
+ for (const alias of aliases) {
239
+ const wildcard = alias.pattern.indexOf('*');
240
+ const prefix = wildcard >= 0 ? alias.pattern.slice(0, wildcard) : alias.pattern;
241
+ const suffix = wildcard >= 0 ? alias.pattern.slice(wildcard + 1) : '';
242
+ if (!importPath.startsWith(prefix) || (suffix && !importPath.endsWith(suffix)))
243
+ continue;
244
+ const replacement = importPath.slice(prefix.length, importPath.length - suffix.length);
245
+ for (const target of alias.targets) {
246
+ const targetPath = target.replace('*', replacement);
247
+ const absoluteBase = resolve(alias.baseDirectory, targetPath);
248
+ const relativeBase = relative(root, absoluteBase);
249
+ const normalizedBase = relativeBase.replace(/^\.\//, '').replaceAll('\\', '/');
250
+ const candidates = [
251
+ normalizedBase,
252
+ ...[...SOURCE_EXTENSIONS].map((extension) => `${normalizedBase}${extension}`),
253
+ ...[...SOURCE_EXTENSIONS].map((extension) => `${normalizedBase}/index${extension}`),
254
+ ];
255
+ const projectCandidate = candidates.find((candidate) => modules.has(candidate));
256
+ if (projectCandidate)
257
+ return projectCandidate;
258
+ }
259
+ }
260
+ return undefined;
261
+ }
262
+ function parseModule(project, root, path, projectFiles, aliases) {
173
263
  if (!SOURCE_EXTENSIONS.has(extname(path)))
174
264
  return undefined;
175
265
  let sourceFile;
@@ -187,6 +277,7 @@ function parseModule(project, root, path, projectFiles) {
187
277
  const dynamicImports = [];
188
278
  const declarations = { classes: 0, functions: 0, interfaces: 0, types: 0, variables: 0 };
189
279
  const resolvedImports = new Set();
280
+ const resolvedImportMap = {};
190
281
  for (const declaration of sourceFile.getImportDeclarations()) {
191
282
  const importPath = declaration.getModuleSpecifierValue();
192
283
  imports.push(importPath);
@@ -200,9 +291,12 @@ function parseModule(project, root, path, projectFiles) {
200
291
  importedSymbols.push(namedImport.getAliasNode()?.getText() ?? namedImport.getName());
201
292
  }
202
293
  const resolved = declaration.getModuleSpecifierSourceFile();
203
- const resolvedPath = resolved && sourceFilePath(root, resolved, projectFiles);
204
- if (resolvedPath)
294
+ const resolvedPath = (resolved && sourceFilePath(root, resolved, projectFiles)) ??
295
+ resolveAliasedImport(root, importPath, aliases, projectFiles);
296
+ if (resolvedPath) {
205
297
  resolvedImports.add(resolvedPath);
298
+ resolvedImportMap[importPath] = resolvedPath;
299
+ }
206
300
  }
207
301
  for (const declaration of sourceFile.getExportDeclarations()) {
208
302
  const moduleSpecifier = declaration.getModuleSpecifierValue();
@@ -210,9 +304,12 @@ function parseModule(project, root, path, projectFiles) {
210
304
  continue;
211
305
  exports.push(moduleSpecifier);
212
306
  const resolved = declaration.getModuleSpecifierSourceFile();
213
- const resolvedPath = resolved && sourceFilePath(root, resolved, projectFiles);
214
- if (resolvedPath)
307
+ const resolvedPath = (resolved && sourceFilePath(root, resolved, projectFiles)) ??
308
+ resolveAliasedImport(root, moduleSpecifier, aliases, projectFiles);
309
+ if (resolvedPath) {
215
310
  resolvedImports.add(resolvedPath);
311
+ resolvedImportMap[moduleSpecifier] = resolvedPath;
312
+ }
216
313
  }
217
314
  declarations.classes = sourceFile.getClasses().length;
218
315
  declarations.functions = sourceFile.getFunctions().length;
@@ -234,9 +331,12 @@ function parseModule(project, root, path, projectFiles) {
234
331
  if (!importPath)
235
332
  continue;
236
333
  dynamicImports.push(importPath);
237
- const resolvedPath = resolveInternalImport(path, importPath, projectFiles);
238
- if (resolvedPath)
334
+ const resolvedPath = resolveInternalImport(path, importPath, projectFiles) ??
335
+ resolveAliasedImport(root, importPath, aliases, projectFiles);
336
+ if (resolvedPath) {
239
337
  resolvedImports.add(resolvedPath);
338
+ resolvedImportMap[importPath] = resolvedPath;
339
+ }
240
340
  }
241
341
  const directives = sourceFile
242
342
  .getStatements()
@@ -257,6 +357,7 @@ function parseModule(project, root, path, projectFiles) {
257
357
  exports: [...new Set(exports)].sort(),
258
358
  calls: [...new Set(calls)].sort(),
259
359
  resolvedImports: [...resolvedImports].sort(),
360
+ resolvedImportMap,
260
361
  dynamicImports: [...new Set(dynamicImports)].sort(),
261
362
  declarations,
262
363
  directives,
@@ -271,6 +372,17 @@ function runGit(root, args) {
271
372
  return stdout?.toString().trim() || undefined;
272
373
  }
273
374
  }
375
+ function discoverySignature(root, packageJson, allFiles) {
376
+ return JSON.stringify([
377
+ JSON.stringify(packageJson),
378
+ allFiles.map((path) => {
379
+ const stat = statSync(resolve(root, path));
380
+ return [path, stat.size, stat.mtimeMs, contentHash(root, path)];
381
+ }),
382
+ runGit(root, ['rev-parse', 'HEAD']),
383
+ runGit(root, ['status', '--porcelain']),
384
+ ]);
385
+ }
274
386
  function gitModel(root) {
275
387
  const branch = runGit(root, ['branch', '--show-current']);
276
388
  const status = runGit(root, ['status', '--porcelain'])?.split('\n').filter(Boolean) ?? [];
@@ -288,7 +400,7 @@ function gitModel(root) {
288
400
  });
289
401
  return {
290
402
  repository: runGit(root, ['rev-parse', '--is-inside-work-tree']) === 'true',
291
- branch: branch || undefined,
403
+ ...(branch ? { branch } : {}),
292
404
  dirty: status.length > 0,
293
405
  changedFiles: [...changed].sort(),
294
406
  status,
@@ -645,32 +757,59 @@ function buildInsights(root, files, modules, sourceFiles, dependencies, astProje
645
757
  function createAstProject(root) {
646
758
  const tsConfigPath = resolve(root, 'tsconfig.json');
647
759
  try {
648
- return existsSync(tsConfigPath)
760
+ const project = existsSync(tsConfigPath)
649
761
  ? new Project({ tsConfigFilePath: tsConfigPath })
650
762
  : new Project({ skipAddingFilesFromTsConfig: true });
763
+ // Monorepos commonly keep compiler options and path aliases per package.
764
+ // Load those projects as well; files still remain bounded by listFiles().
765
+ for (const configPath of listFiles(root).filter((path) => basename(path) === 'tsconfig.json')) {
766
+ const absolutePath = resolve(root, configPath);
767
+ if (absolutePath === tsConfigPath)
768
+ continue;
769
+ try {
770
+ project.addSourceFilesFromTsConfig(absolutePath);
771
+ }
772
+ catch {
773
+ // A package config can be partial or reference an unavailable project.
774
+ }
775
+ }
776
+ return project;
651
777
  }
652
778
  catch {
653
779
  return new Project({ skipAddingFilesFromTsConfig: true });
654
780
  }
655
781
  }
656
- export function discoverProject(root, options = {}) {
782
+ function discoverProjectOnce(root, options = {}, attempt = 0) {
657
783
  const packageJson = readJsonObject(resolve(root, 'package.json'));
658
784
  const dependencies = asStringRecord(packageJson.dependencies);
659
785
  const devDependencies = asStringRecord(packageJson.devDependencies);
660
786
  const scripts = asStringRecord(packageJson.scripts);
661
- const allFiles = listFiles(root);
662
- const signature = JSON.stringify([
663
- JSON.stringify(packageJson),
664
- allFiles.map((path) => {
665
- const stat = statSync(resolve(root, path));
666
- return [path, stat.size, stat.mtimeMs];
667
- }),
668
- runGit(root, ['rev-parse', 'HEAD']),
669
- runGit(root, ['status', '--porcelain']),
670
- ]);
787
+ const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
788
+ const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
789
+ if (!Number.isInteger(maxFiles) || maxFiles < 1) {
790
+ throw new DiscoveryLimitError('Discovery maxFiles must be a positive integer.');
791
+ }
792
+ if (!Number.isInteger(maxFileBytes) || maxFileBytes < 1) {
793
+ throw new DiscoveryLimitError('Discovery maxFileBytes must be a positive integer.');
794
+ }
795
+ const allFiles = listFiles(root, root, maxFiles);
796
+ if (allFiles.length > maxFiles) {
797
+ throw new DiscoveryLimitError(`Project contains ${allFiles.length} files; discovery limit is ${maxFiles}. ` +
798
+ 'Use an ignore file or raise maxFiles deliberately.');
799
+ }
800
+ for (const path of allFiles) {
801
+ const bytes = statSync(resolve(root, path)).size;
802
+ if (bytes > maxFileBytes) {
803
+ throw new DiscoveryLimitError(`File ${path} is ${bytes} bytes; discovery limit is ${maxFileBytes}. ` +
804
+ 'Ignore the file or raise maxFileBytes deliberately.');
805
+ }
806
+ }
807
+ const signature = discoverySignature(root, packageJson, allFiles);
671
808
  const cacheKey = resolve(root);
809
+ discoveryCacheHits.set(cacheKey, false);
672
810
  const cached = discoveryCache.get(cacheKey);
673
811
  if (cached?.signature === signature) {
812
+ discoveryCacheHits.set(cacheKey, true);
674
813
  discoveryReuse.set(cacheKey, cached.model.modules.length);
675
814
  return cached.model;
676
815
  }
@@ -683,6 +822,7 @@ export function discoverProject(root, options = {}) {
683
822
  persisted.model.version === 1) {
684
823
  previousModel = persisted.model;
685
824
  if (persisted.signature === signature) {
825
+ discoveryCacheHits.set(cacheKey, true);
686
826
  discoveryCache.set(cacheKey, { signature, model: previousModel });
687
827
  discoveryReuse.set(cacheKey, previousModel.modules.length);
688
828
  return previousModel;
@@ -699,6 +839,7 @@ export function discoverProject(root, options = {}) {
699
839
  }));
700
840
  const projectFiles = new Set(files.map((file) => file.path));
701
841
  const astProject = createAstProject(root);
842
+ const aliases = pathAliases(root);
702
843
  const previousModules = new Map(previousModel?.modules.map((module) => [module.path, module]));
703
844
  const currentShape = files
704
845
  .filter((file) => file.kind === 'source' || file.kind === 'test')
@@ -723,7 +864,7 @@ export function discoverProject(root, options = {}) {
723
864
  reusedModules += 1;
724
865
  return previous;
725
866
  }
726
- return parseModule(astProject, root, file.path, projectFiles);
867
+ return parseModule(astProject, root, file.path, projectFiles, aliases);
727
868
  })
728
869
  .filter((module) => module !== undefined);
729
870
  const sourceFiles = files.filter((file) => file.kind === 'source').map((file) => file.path);
@@ -756,11 +897,12 @@ export function discoverProject(root, options = {}) {
756
897
  }
757
898
  return { path, methods: [...methods].sort() };
758
899
  });
900
+ const detectedPackageManager = packageManager(root);
759
901
  const model = {
760
902
  version: 1,
761
903
  project: {
762
- name: typeof packageJson.name === 'string' ? packageJson.name : undefined,
763
- packageManager: packageManager(root),
904
+ ...(typeof packageJson.name === 'string' ? { name: packageJson.name } : {}),
905
+ ...(detectedPackageManager ? { packageManager: detectedPackageManager } : {}),
764
906
  frameworks: inferFrameworks(dependencies, devDependencies),
765
907
  scripts,
766
908
  dependencies,
@@ -788,6 +930,15 @@ export function discoverProject(root, options = {}) {
788
930
  },
789
931
  insights: buildInsights(root, files, modules, sourceFiles, dependencies, astProject),
790
932
  };
933
+ const finalFiles = listFiles(root, root, maxFiles);
934
+ const finalSignature = finalFiles.length <= maxFiles
935
+ ? discoverySignature(root, readJsonObject(resolve(root, 'package.json')), finalFiles)
936
+ : undefined;
937
+ if (finalSignature !== signature) {
938
+ if (attempt < 2)
939
+ return discoverProjectOnce(root, options, attempt + 1);
940
+ throw new DiscoveryStaleError();
941
+ }
791
942
  discoveryCache.set(cacheKey, { signature, model });
792
943
  discoveryReuse.set(cacheKey, reusedModules);
793
944
  if (options.persistCache ?? existsSync(resolve(root, `.${config.appName}/guard`))) {
@@ -804,9 +955,11 @@ export function discoverProject(root, options = {}) {
804
955
  }
805
956
  return model;
806
957
  }
958
+ export function discoverProject(root, options = {}) {
959
+ return discoverProjectOnce(root, options);
960
+ }
807
961
  export function discoverProjectWithMetrics(root, options = {}) {
808
962
  const startedAt = Date.now();
809
- const cacheHit = discoveryCache.has(resolve(root));
810
963
  const model = discoverProject(root, options);
811
964
  return {
812
965
  model,
@@ -815,7 +968,7 @@ export function discoverProjectWithMetrics(root, options = {}) {
815
968
  files: model.files.length,
816
969
  modules: model.modules.length,
817
970
  cacheAvailable: existsSync(discoveryCachePath(root)),
818
- cacheHit,
971
+ cacheHit: discoveryCacheHits.get(resolve(root)) ?? false,
819
972
  changedFiles: model.git.changedFiles.length,
820
973
  reusedModules: discoveryReuse.get(resolve(root)) ?? 0,
821
974
  },
@@ -0,0 +1,11 @@
1
+ export type GuardErrorCode = 'GUARD_NOT_CONFIGURED' | 'GUARD_CONFIG_INVALID' | 'GUARD_STATE_BUSY' | 'GUARD_STATE_STALE' | '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 | undefined;
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
+ }