@codapult/guard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +290 -0
  3. package/dist/adapters/agents/agent-integration.d.ts +9 -0
  4. package/dist/adapters/agents/agent-integration.js +62 -0
  5. package/dist/adapters/command.d.ts +21 -0
  6. package/dist/adapters/command.js +60 -0
  7. package/dist/adapters/project-checks.d.ts +37 -0
  8. package/dist/adapters/project-checks.js +149 -0
  9. package/dist/cli/commands/guard.d.ts +55 -0
  10. package/dist/cli/commands/guard.js +510 -0
  11. package/dist/cli/index.d.ts +2 -0
  12. package/dist/cli/index.js +75 -0
  13. package/dist/cli/ui.d.ts +6 -0
  14. package/dist/cli/ui.js +7 -0
  15. package/dist/commands/guard.d.ts +2 -0
  16. package/dist/commands/guard.js +2 -0
  17. package/dist/core/analysis/doctor.d.ts +12 -0
  18. package/dist/core/analysis/doctor.js +85 -0
  19. package/dist/core/analysis/packs.d.ts +21 -0
  20. package/dist/core/analysis/packs.js +251 -0
  21. package/dist/core/config.d.ts +6 -0
  22. package/dist/core/config.js +6 -0
  23. package/dist/core/discovery/discovery.d.ts +134 -0
  24. package/dist/core/discovery/discovery.js +816 -0
  25. package/dist/core/guard.d.ts +189 -0
  26. package/dist/core/guard.js +936 -0
  27. package/dist/core/history/history.d.ts +24 -0
  28. package/dist/core/history/history.js +65 -0
  29. package/dist/core/output/sarif.d.ts +35 -0
  30. package/dist/core/output/sarif.js +29 -0
  31. package/dist/core/policy/schemas.d.ts +272 -0
  32. package/dist/core/policy/schemas.js +72 -0
  33. package/dist/core/verification/verify.d.ts +29 -0
  34. package/dist/core/verification/verify.js +77 -0
  35. package/dist/index.d.ts +10 -0
  36. package/dist/index.js +9 -0
  37. package/dist/mcp/prompts.d.ts +2 -0
  38. package/dist/mcp/prompts.js +54 -0
  39. package/dist/mcp/resources.d.ts +2 -0
  40. package/dist/mcp/resources.js +64 -0
  41. package/dist/mcp/server.d.ts +1 -0
  42. package/dist/mcp/server.js +17 -0
  43. package/dist/mcp/tools/guard.d.ts +2 -0
  44. package/dist/mcp/tools/guard.js +375 -0
  45. package/package.json +117 -0
@@ -0,0 +1,816 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync, } from 'node:fs';
4
+ import { basename, dirname, extname, join, normalize, relative, resolve } from 'node:path';
5
+ import { Project, SyntaxKind } from 'ts-morph';
6
+ import { config } from '../config.js';
7
+ const IGNORED_DIRECTORIES = new Set([
8
+ '.git',
9
+ '.next',
10
+ '.turbo',
11
+ 'build',
12
+ 'coverage',
13
+ 'dist',
14
+ 'node_modules',
15
+ '.codapult',
16
+ '.vercel',
17
+ 'out',
18
+ 'playwright-report',
19
+ 'test-results',
20
+ 'certificates',
21
+ ]);
22
+ const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts']);
23
+ const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
24
+ const DOCUMENTATION_EXTENSIONS = new Set(['.md', '.mdx', '.rst', '.txt']);
25
+ const CONFIG_NAMES = new Set([
26
+ 'package.json',
27
+ 'tsconfig.json',
28
+ 'jsconfig.json',
29
+ '.eslintrc',
30
+ '.prettierrc',
31
+ 'vite.config.ts',
32
+ 'vite.config.js',
33
+ 'next.config.ts',
34
+ 'next.config.js',
35
+ 'next.config.mjs',
36
+ 'webpack.config.js',
37
+ 'jest.config.ts',
38
+ 'vitest.config.ts',
39
+ 'playwright.config.ts',
40
+ ]);
41
+ const discoveryCache = new Map();
42
+ const discoveryReuse = new Map();
43
+ // Bump when the persisted model shape changes; old cache entries must never
44
+ // bypass discovery and return an incomplete ProjectModel.
45
+ const DISCOVERY_CACHE_VERSION = 3;
46
+ function discoveryCachePath(root) {
47
+ return resolve(root, `.${config.appName}/guard/cache.json`);
48
+ }
49
+ function contentHash(root, path) {
50
+ return createHash('sha256')
51
+ .update(readFileSync(resolve(root, path)))
52
+ .digest('hex');
53
+ }
54
+ function ignoredFile(path) {
55
+ const name = basename(path).toLowerCase();
56
+ return ((name.startsWith('.env.') && name !== '.env.example') ||
57
+ name === '.env' ||
58
+ /\.(?:db|sqlite|sqlite3|tsbuildinfo|pem|key|p12|pfx)$/.test(name));
59
+ }
60
+ export function findGuardRoot(from = process.cwd()) {
61
+ let directory = resolve(from);
62
+ const filesystemRoot = resolve('/');
63
+ while (directory !== filesystemRoot) {
64
+ if (existsSync(resolve(directory, '.git')) ||
65
+ existsSync(resolve(directory, 'package.json')) ||
66
+ existsSync(resolve(directory, 'tsconfig.json')) ||
67
+ existsSync(resolve(directory, 'jsconfig.json'))) {
68
+ return directory;
69
+ }
70
+ directory = dirname(directory);
71
+ }
72
+ return resolve(from);
73
+ }
74
+ function readJsonObject(path) {
75
+ try {
76
+ const value = JSON.parse(readFileSync(path, 'utf8'));
77
+ return value !== null && typeof value === 'object' ? value : {};
78
+ }
79
+ catch {
80
+ return {};
81
+ }
82
+ }
83
+ function asStringRecord(value) {
84
+ if (value === null || typeof value !== 'object')
85
+ return {};
86
+ return Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === 'string'));
87
+ }
88
+ function packageManager(root) {
89
+ const packageJson = readJsonObject(resolve(root, 'package.json'));
90
+ if (typeof packageJson.packageManager === 'string')
91
+ return packageJson.packageManager;
92
+ for (const [file, manager] of [
93
+ ['pnpm-lock.yaml', 'pnpm'],
94
+ ['yarn.lock', 'yarn'],
95
+ ['package-lock.json', 'npm'],
96
+ ['bun.lockb', 'bun'],
97
+ ['bun.lock', 'bun'],
98
+ ]) {
99
+ try {
100
+ statSync(resolve(root, file));
101
+ return manager;
102
+ }
103
+ catch {
104
+ // Try the next lockfile.
105
+ }
106
+ }
107
+ return undefined;
108
+ }
109
+ function workspacePackages(root, files) {
110
+ return files
111
+ .filter((path) => path !== 'package.json' && basename(path) === 'package.json')
112
+ .map((path) => {
113
+ const manifest = readJsonObject(resolve(root, path));
114
+ return {
115
+ path: dirname(path).replaceAll('\\', '/') || '.',
116
+ ...(typeof manifest.name === 'string' ? { name: manifest.name } : {}),
117
+ private: manifest.private === true,
118
+ scripts: asStringRecord(manifest.scripts),
119
+ dependencies: Object.keys({
120
+ ...asStringRecord(manifest.dependencies),
121
+ ...asStringRecord(manifest.devDependencies),
122
+ ...asStringRecord(manifest.peerDependencies),
123
+ }).sort(),
124
+ };
125
+ })
126
+ .sort((left, right) => left.path.localeCompare(right.path));
127
+ }
128
+ function listFiles(root, directory = root) {
129
+ const result = [];
130
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
131
+ if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name))
132
+ continue;
133
+ const path = resolve(directory, entry.name);
134
+ if (entry.isDirectory())
135
+ result.push(...listFiles(root, path));
136
+ else if (entry.isFile() && !ignoredFile(path))
137
+ result.push(relative(root, path));
138
+ }
139
+ return result.sort();
140
+ }
141
+ function fileKind(path) {
142
+ const name = basename(path).toLowerCase();
143
+ if (/\.(?:test|spec)\.[^.]+$/.test(name) || path.includes('/__tests__/'))
144
+ return 'test';
145
+ if (name.includes('schema') || path.includes('/schemas/'))
146
+ return 'schema';
147
+ if (path.includes('/migrations/') || path.includes('/drizzle/'))
148
+ return 'migration';
149
+ if (DOCUMENTATION_EXTENSIONS.has(extname(name)))
150
+ return 'documentation';
151
+ if (CONFIG_NAMES.has(name) || name.includes('config'))
152
+ return 'config';
153
+ if (SOURCE_EXTENSIONS.has(extname(name)))
154
+ return 'source';
155
+ return 'other';
156
+ }
157
+ function sourceFilePath(root, sourceFile, projectFiles) {
158
+ const path = relative(root, sourceFile.getFilePath()).replaceAll('\\', '/');
159
+ return path.startsWith('..') || !projectFiles.has(path) ? undefined : path;
160
+ }
161
+ function resolveInternalImport(from, importPath, modules) {
162
+ if (!importPath.startsWith('.'))
163
+ return undefined;
164
+ const base = normalize(join(dirname(from), importPath));
165
+ const candidates = [
166
+ base,
167
+ ...[...SOURCE_EXTENSIONS].map((extension) => `${base}${extension}`),
168
+ ...[...SOURCE_EXTENSIONS].map((extension) => `${base}/index${extension}`),
169
+ ];
170
+ return candidates.find((candidate) => modules.has(candidate));
171
+ }
172
+ function parseModule(project, root, path, projectFiles) {
173
+ if (!SOURCE_EXTENSIONS.has(extname(path)))
174
+ return undefined;
175
+ let sourceFile;
176
+ try {
177
+ sourceFile =
178
+ project.getSourceFile(resolve(root, path)) ??
179
+ project.addSourceFileAtPath(resolve(root, path));
180
+ }
181
+ catch {
182
+ return undefined;
183
+ }
184
+ const imports = [];
185
+ const importedSymbols = [];
186
+ const exports = [];
187
+ const dynamicImports = [];
188
+ const declarations = { classes: 0, functions: 0, interfaces: 0, types: 0, variables: 0 };
189
+ const resolvedImports = new Set();
190
+ for (const declaration of sourceFile.getImportDeclarations()) {
191
+ const importPath = declaration.getModuleSpecifierValue();
192
+ imports.push(importPath);
193
+ const defaultImport = declaration.getDefaultImport()?.getText();
194
+ if (defaultImport)
195
+ importedSymbols.push(defaultImport);
196
+ const namespaceImport = declaration.getNamespaceImport()?.getText();
197
+ if (namespaceImport)
198
+ importedSymbols.push(namespaceImport);
199
+ for (const namedImport of declaration.getNamedImports()) {
200
+ importedSymbols.push(namedImport.getAliasNode()?.getText() ?? namedImport.getName());
201
+ }
202
+ const resolved = declaration.getModuleSpecifierSourceFile();
203
+ const resolvedPath = resolved && sourceFilePath(root, resolved, projectFiles);
204
+ if (resolvedPath)
205
+ resolvedImports.add(resolvedPath);
206
+ }
207
+ for (const declaration of sourceFile.getExportDeclarations()) {
208
+ const moduleSpecifier = declaration.getModuleSpecifierValue();
209
+ if (!moduleSpecifier)
210
+ continue;
211
+ exports.push(moduleSpecifier);
212
+ const resolved = declaration.getModuleSpecifierSourceFile();
213
+ const resolvedPath = resolved && sourceFilePath(root, resolved, projectFiles);
214
+ if (resolvedPath)
215
+ resolvedImports.add(resolvedPath);
216
+ }
217
+ declarations.classes = sourceFile.getClasses().length;
218
+ declarations.functions = sourceFile.getFunctions().length;
219
+ declarations.interfaces = sourceFile.getInterfaces().length;
220
+ declarations.types = sourceFile.getTypeAliases().length;
221
+ declarations.variables = sourceFile.getVariableStatements().length;
222
+ const calls = sourceFile
223
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
224
+ .map((call) => call.getExpression().getText())
225
+ .filter((expression) => /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(expression));
226
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
227
+ const expression = call.getExpression().getText();
228
+ if (expression !== 'import' && expression !== 'require')
229
+ continue;
230
+ const argument = call.getArguments()[0];
231
+ if (!argument.isKind(SyntaxKind.StringLiteral))
232
+ continue;
233
+ const importPath = argument.getText().slice(1, -1);
234
+ if (!importPath)
235
+ continue;
236
+ dynamicImports.push(importPath);
237
+ const resolvedPath = resolveInternalImport(path, importPath, projectFiles);
238
+ if (resolvedPath)
239
+ resolvedImports.add(resolvedPath);
240
+ }
241
+ const directives = sourceFile
242
+ .getStatements()
243
+ .filter((statement) => statement.isKind(SyntaxKind.ExpressionStatement))
244
+ .map((statement) => statement.getExpression())
245
+ .filter((expression) => {
246
+ if (!expression.isKind(SyntaxKind.StringLiteral))
247
+ return false;
248
+ const value = expression.getText().slice(1, -1);
249
+ return value === 'use client' || value === 'use server';
250
+ })
251
+ .map((expression) => expression.getText().slice(1, -1));
252
+ return {
253
+ path,
254
+ contentHash: contentHash(root, path),
255
+ imports: [...new Set(imports)].sort(),
256
+ importedSymbols: [...new Set(importedSymbols)].sort(),
257
+ exports: [...new Set(exports)].sort(),
258
+ calls: [...new Set(calls)].sort(),
259
+ resolvedImports: [...resolvedImports].sort(),
260
+ dynamicImports: [...new Set(dynamicImports)].sort(),
261
+ declarations,
262
+ directives,
263
+ };
264
+ }
265
+ function runGit(root, args) {
266
+ try {
267
+ return execFileSync('git', args, { cwd: root, stdio: 'pipe' }).toString().trim();
268
+ }
269
+ catch (error) {
270
+ const stdout = error.stdout;
271
+ return stdout?.toString().trim() || undefined;
272
+ }
273
+ }
274
+ function gitModel(root) {
275
+ const branch = runGit(root, ['branch', '--show-current']);
276
+ const status = runGit(root, ['status', '--porcelain'])?.split('\n').filter(Boolean) ?? [];
277
+ const changed = new Set([
278
+ ...(runGit(root, ['diff', '--name-only', 'HEAD'])?.split('\n').filter(Boolean) ?? []),
279
+ ...(runGit(root, ['ls-files', '--others', '--exclude-standard'])?.split('\n').filter(Boolean) ??
280
+ []),
281
+ ]);
282
+ const history = (runGit(root, ['log', '-n', '30', '--date=iso-strict', '--format=%H%x1f%ad%x1f%s']) ?? '')
283
+ .split('\n')
284
+ .filter(Boolean)
285
+ .map((line) => {
286
+ const [hash = '', date = '', subject = ''] = line.split('\x1f');
287
+ return { hash, date, subject };
288
+ });
289
+ return {
290
+ repository: runGit(root, ['rev-parse', '--is-inside-work-tree']) === 'true',
291
+ branch: branch || undefined,
292
+ dirty: status.length > 0,
293
+ changedFiles: [...changed].sort(),
294
+ status,
295
+ diffStat: runGit(root, ['diff', '--stat', 'HEAD']) ?? '',
296
+ history,
297
+ };
298
+ }
299
+ function inferFrameworks(dependencies, devDependencies) {
300
+ const all = new Set([...Object.keys(dependencies), ...Object.keys(devDependencies)]);
301
+ const known = [
302
+ ['next', 'Next.js'],
303
+ ['react', 'React'],
304
+ ['vue', 'Vue'],
305
+ ['svelte', 'Svelte'],
306
+ ['express', 'Express'],
307
+ ['fastify', 'Fastify'],
308
+ ['@nestjs/core', 'NestJS'],
309
+ ['drizzle-orm', 'Drizzle'],
310
+ ['prisma', 'Prisma'],
311
+ ];
312
+ const frameworks = known.filter(([dependency]) => all.has(dependency)).map(([, name]) => name);
313
+ if (all.has('vite') && !all.has('next'))
314
+ frameworks.push('Vite');
315
+ return frameworks;
316
+ }
317
+ function layerForPath(path) {
318
+ const segments = path.toLowerCase().split('/');
319
+ if (segments.includes('__tests__') || /\.(?:test|spec)\./.test(path))
320
+ return 'tests';
321
+ if (segments.some((segment) => ['app', 'pages', 'routes', 'api'].includes(segment))) {
322
+ return 'routes';
323
+ }
324
+ if (segments.includes('components') || segments.includes('ui'))
325
+ return 'ui';
326
+ if (segments.some((segment) => ['domain', 'entities', 'models'].includes(segment))) {
327
+ return 'domain';
328
+ }
329
+ if (segments.some((segment) => ['services', 'use-cases', 'usecases', 'application'].includes(segment))) {
330
+ return 'application';
331
+ }
332
+ if (segments.some((segment) => ['db', 'database', 'repositories', 'adapters', 'infrastructure', 'lib'].includes(segment))) {
333
+ return 'infrastructure';
334
+ }
335
+ if (segments.some((segment) => ['config', 'configs', 'configuration'].includes(segment))) {
336
+ return 'config';
337
+ }
338
+ return 'other';
339
+ }
340
+ function buildModuleTargetGraph(modules) {
341
+ const moduleSet = new Set(modules.map((candidate) => candidate.path));
342
+ return new Map(modules.map((module) => [
343
+ module.path,
344
+ module.resolvedImports.length > 0
345
+ ? module.resolvedImports
346
+ : module.imports
347
+ .map((importPath) => resolveInternalImport(module.path, importPath, moduleSet))
348
+ .filter((path) => path !== undefined),
349
+ ]));
350
+ }
351
+ function findCycles(modules) {
352
+ const graph = buildModuleTargetGraph(modules);
353
+ const indexByNode = new Map();
354
+ const lowLink = new Map();
355
+ const stack = [];
356
+ const onStack = new Set();
357
+ const components = [];
358
+ let index = 0;
359
+ const visit = (node) => {
360
+ indexByNode.set(node, index);
361
+ lowLink.set(node, index);
362
+ index += 1;
363
+ stack.push(node);
364
+ onStack.add(node);
365
+ for (const next of graph.get(node) ?? []) {
366
+ if (!indexByNode.has(next)) {
367
+ visit(next);
368
+ lowLink.set(node, Math.min(lowLink.get(node) ?? 0, lowLink.get(next) ?? 0));
369
+ }
370
+ else if (onStack.has(next)) {
371
+ lowLink.set(node, Math.min(lowLink.get(node) ?? 0, indexByNode.get(next) ?? 0));
372
+ }
373
+ }
374
+ if (lowLink.get(node) !== indexByNode.get(node))
375
+ return;
376
+ const component = [];
377
+ let member;
378
+ do {
379
+ member = stack.pop();
380
+ if (member) {
381
+ onStack.delete(member);
382
+ component.push(member);
383
+ }
384
+ } while (member !== node);
385
+ if (component.length > 1 || (component.length === 1 && graph.get(node)?.includes(node))) {
386
+ components.push(component.sort());
387
+ }
388
+ };
389
+ for (const module of modules) {
390
+ if (!indexByNode.has(module.path))
391
+ visit(module.path);
392
+ }
393
+ return components.sort((a, b) => a.join().localeCompare(b.join()));
394
+ }
395
+ function buildImpactPaths(modules, routeHandlers, serverActions) {
396
+ const moduleByPath = new Map(modules.map((module) => [module.path, module]));
397
+ const graph = buildModuleTargetGraph(modules);
398
+ const entrypoints = [...new Set([...routeHandlers, ...serverActions])]
399
+ .filter((path) => moduleByPath.has(path))
400
+ .sort()
401
+ .slice(0, 200);
402
+ return entrypoints.map((entrypoint) => {
403
+ const queue = [entrypoint];
404
+ const visited = new Set();
405
+ while (queue.length > 0 && visited.size < 200) {
406
+ const current = queue.shift();
407
+ if (!current || visited.has(current))
408
+ continue;
409
+ visited.add(current);
410
+ const module = moduleByPath.get(current);
411
+ if (!module)
412
+ continue;
413
+ for (const target of graph.get(module.path) ?? []) {
414
+ if (!visited.has(target) && moduleByPath.has(target))
415
+ queue.push(target);
416
+ }
417
+ }
418
+ const files = [...visited].sort();
419
+ const layers = [...new Set(files.map(layerForPath))];
420
+ const boundaries = [
421
+ ...new Set(files.flatMap((file) => {
422
+ const module = moduleByPath.get(file);
423
+ return (module?.directives.filter((directive) => directive === 'use client' || directive === 'use server') ?? []);
424
+ })),
425
+ ].sort();
426
+ return { entrypoint, files, layers, boundaries };
427
+ });
428
+ }
429
+ function detectCapabilities(files, modules, dependencies, devDependencies, scripts) {
430
+ const allPackages = Object.keys({ ...dependencies, ...devDependencies });
431
+ const evidenceFiles = files
432
+ .filter((file) => file.kind !== 'documentation')
433
+ .map((file) => file.path);
434
+ const allImports = [...new Set(modules.flatMap((module) => module.imports))].sort();
435
+ const packageSignal = (packagePattern, filePattern, importPattern, evidence) => {
436
+ const packages = allPackages.filter((name) => packagePattern.test(name));
437
+ const matchedFiles = evidenceFiles.filter((file) => filePattern.test(file));
438
+ const imports = allImports.filter((importPath) => importPattern.test(importPath));
439
+ return {
440
+ status: matchedFiles.length > 0 || imports.length > 0 ? 'observed' : 'inferred',
441
+ confidence: packages.length > 0 && (matchedFiles.length > 0 || imports.length > 0)
442
+ ? 'high'
443
+ : packages.length > 0 || matchedFiles.length > 0 || imports.length > 0
444
+ ? 'medium'
445
+ : 'low',
446
+ packages,
447
+ files: matchedFiles,
448
+ imports,
449
+ evidence: [
450
+ ...(packages.length > 0 ? [`dependency: ${packages.join(', ')}`] : []),
451
+ ...(matchedFiles.length > 0 ? [`file pattern: ${filePattern.source}`] : []),
452
+ ...(imports.length > 0 ? [`import pattern: ${importPattern.source}`] : []),
453
+ ...(packages.length > 0 || matchedFiles.length > 0 || imports.length > 0 ? [evidence] : []),
454
+ ],
455
+ };
456
+ };
457
+ const capabilities = {
458
+ runtime: {
459
+ status: 'observed',
460
+ confidence: 'high',
461
+ packages: allPackages.filter((name) => /^(next|react|vue|svelte|express|fastify|@nestjs\/core)$/.test(name)),
462
+ files: files.filter((file) => file.kind === 'config').map((file) => file.path),
463
+ imports: allImports.filter((importPath) => /^(next|react|vue|svelte|express|fastify)(\/|$)/.test(importPath)),
464
+ evidence: ['package metadata and runtime imports'],
465
+ },
466
+ routing: packageSignal(/^next$/, /(?:^|\/)(?:route|page)\.[cm]?[jt]sx?$/, /^(next\/navigation|next\/server|next\/headers)$/, 'route and page conventions'),
467
+ 'server-client-boundaries': {
468
+ status: 'observed',
469
+ confidence: 'high',
470
+ packages: [],
471
+ files: modules
472
+ .filter((module) => module.directives.some((directive) => directive === 'use client' || directive === 'use server'))
473
+ .map((module) => module.path),
474
+ imports: [],
475
+ evidence: ['use client/use server directives'],
476
+ },
477
+ persistence: packageSignal(/^(?:drizzle-orm|drizzle-kit|prisma|@prisma\/client|typeorm|sequelize|mongoose|knex|@libsql\/client)$/, /(?:^|\/)(?:db|database|repositories|schema|schemas|migrations|drizzle)(?:\/|[^/]*$)/i, /^(?:@prisma\/client|prisma|drizzle-orm|drizzle-kit|typeorm|sequelize|mongoose|knex|@libsql\/client)(\/|$)|(?:^|\/)(?:db|database)(\/|$)/i, 'database and ORM conventions'),
478
+ identity: packageSignal(/auth|identity|session|kinde|clerk|lucia|next-auth|better-auth/i, /(?:^|\/)(?:auth|authentication|identity|session)(?:\/|[^/]*$)/i, /(?:^|\/)(?:auth|authentication|identity|session)(?:\/|$)/i, 'identity and session conventions'),
479
+ payments: packageSignal(/stripe|lemonsqueezy|paddle|paypal|braintree|polar/i, /(?:^|\/)(?:billing|payment|payments|checkout|subscription|subscriptions)(?:\/|[^/]*$)/i, /stripe|lemonsqueezy|paddle|paypal|braintree|polar/i, 'payment provider conventions'),
480
+ email: packageSignal(/resend|react-email|nodemailer|postmark|sendgrid|mailgun/i, /(?:^|\/)(?:email|emails|mail|templates)(?:\/|[^/]*$)/i, /resend|react-email|nodemailer|postmark|sendgrid|mailgun/i, 'email delivery and template conventions'),
481
+ ai: packageSignal(/^(?:ai|openai|anthropic|@ai-sdk\/|@langchain\/)/i, /(?:^|\/)(?:ai|agents|prompts|embeddings|rag|llm)(?:\/|[^/]*$)/i, /^(?:ai|openai|anthropic|@ai-sdk\/|@langchain\/)/i, 'AI and model integration conventions'),
482
+ jobs: packageSignal(/bullmq|agenda|inngest|trigger\.dev|@temporalio|graphile-worker|bee-queue|pg-boss/i, /(?:^|\/)(?:jobs|queues|queue|workers|worker|background|tasks)(?:\/|[^/]*$)/i, /bullmq|agenda|inngest|trigger\.dev|@temporalio|graphile-worker|bee-queue|pg-boss/i, 'background job and queue conventions'),
483
+ env: packageSignal(/dotenv|envalid|convex|@t3-oss\/env/i, /(?:^|\/)(?:env|environment)(?:\/|[^/]*$)|(?:^|\/)\.env(?:\.example)?$|(?:^|\/)(?:config|configuration)\/env(?:[^/]*)$/i, /dotenv|envalid|@t3-oss\/env/i, 'environment and configuration conventions'),
484
+ deployment: packageSignal(/vercel|docker|pulumi|terraform|kubernetes|helm|aws-sdk|@aws-sdk\//i, /(?:^|\/)(?:Dockerfile|docker-compose|infra|terraform|pulumi|helm|k8s|\.github\/workflows)(?:\/|[^/]*$)/i, /vercel|pulumi|terraform|kubernetes|helm|aws-sdk|@aws-sdk\//i, 'deployment and infrastructure conventions'),
485
+ testing: packageSignal(/vitest|jest|playwright|cypress|mocha|ava/i, /(?:^|\/)(?:__tests__|tests?|e2e)(?:\/|[^/]*$)|\.(?:test|spec)\./i, /vitest|jest|playwright|cypress|mocha|ava/i, 'test runner and test layout conventions'),
486
+ observability: packageSignal(/sentry|opentelemetry|datadog|pino|winston|otel/i, /(?:^|\/)(?:instrumentation|observability|monitoring|logging|telemetry)(?:\/|[^/]*$)/i, /sentry|opentelemetry|datadog|pino|winston|otel/i, 'logging, tracing, and error monitoring conventions'),
487
+ storage: packageSignal(/s3|minio|r2|uploadthing|cloudinary|sharp/i, /(?:^|\/)(?:storage|uploads?|media|assets)(?:\/|[^/]*$)/i, /s3|minio|r2|uploadthing|cloudinary/i, 'file and object storage conventions'),
488
+ 'graphql-rpc': packageSignal(/graphql|trpc|grpc|@connectrpc\//i, /(?:^|\/)(?:graphql|trpc|rpc)(?:\/|[^/]*$)/i, /graphql|trpc|grpc|@connectrpc\//i, 'GraphQL and RPC conventions'),
489
+ i18n: packageSignal(/next-intl|i18next|react-i18next|lingui/i, /(?:^|\/)(?:i18n|locales?|messages|translations?)(?:\/|[^/]*$)/i, /next-intl|i18next|react-i18next|lingui/i, 'internationalization conventions'),
490
+ dependencies: {
491
+ status: 'observed',
492
+ confidence: 'high',
493
+ packages: allPackages,
494
+ files: files
495
+ .filter((file) => /(?:package\.json|lock$|lock\.yaml$)/i.test(file.path))
496
+ .map((file) => file.path),
497
+ imports: [],
498
+ evidence: ['package manifest and lockfile metadata'],
499
+ },
500
+ configs: packageSignal(/^(?:cosmiconfig|convict|config|dotenv|jiti|tsx|ts-node)$/i, /(?:^|\/)(?:config|configs|configuration)(?:\/|[^/]*$)|(?:^|\/)[^/]+\.config\.[cm]?[jt]sx?$/i, /^(?:cosmiconfig|convict|config|dotenv|jiti|tsx|ts-node)(\/|$)/i, 'configuration files and loaders'),
501
+ security: packageSignal(/(?:helmet|csrf|rate-limit|ratelimit|clerk|lucia|next-auth|better-auth|sentry|semgrep|zod|valibot|jose|bcrypt|argon2)/i, /(?:^|\/)(?:security|middleware|auth|authentication|permissions?|policies?|crypto)(?:\/|[^/]*$)/i, /(?:helmet|csrf|rate-limit|ratelimit|jose|bcrypt|argon2|sentry)(\/|$)/i, 'security, validation, and permission conventions'),
502
+ webhooks: packageSignal(/stripe|svix|webhooks?[-/]?js|inngest|resend|paypal|paddle/i, /(?:^|\/)(?:webhooks?|events?)(?:\/|[^/]*$)/i, /(?:webhooks?|events?)(\/|$)/i, 'webhook and event receiver conventions'),
503
+ cache: packageSignal(/redis|ioredis|upstash|lru-cache|node-cache|react-cache/i, /(?:^|\/)(?:cache|caching|redis)(?:\/|[^/]*$)/i, /redis|ioredis|upstash|lru-cache|node-cache/i, 'cache and key-value storage conventions'),
504
+ analytics: packageSignal(/posthog|segment|mixpanel|amplitude|plausible|google-analytics|vercel\/analytics/i, /(?:^|\/)(?:analytics|tracking|telemetry)(?:\/|[^/]*$)/i, /posthog|segment|mixpanel|amplitude|plausible|analytics/i, 'product analytics and tracking conventions'),
505
+ search: packageSignal(/algoliasearch|elasticsearch|opensearch|meilisearch|typesense|instantsearch/i, /(?:^|\/)(?:search|indexing|indexes)(?:\/|[^/]*$)/i, /algoliasearch|elasticsearch|opensearch|meilisearch|typesense|instantsearch/i, 'search and indexing conventions'),
506
+ content: packageSignal(/contentlayer|mdx|remark|rehype|sanity|contentful|strapi|payload/i, /(?:^|\/)(?:content|cms|mdx|posts?|articles?)(?:\/|[^/]*$)/i, /contentlayer|remark|rehype|sanity|contentful|strapi|payload/i, 'content and CMS conventions'),
507
+ tooling: packageSignal(/^(?:typescript|eslint|prettier|biome|dependency-cruiser|madge|knip|semgrep|snyk|npm-check-updates|lefthook|husky)$/i, /(?:^|\/)(?:\.github\/workflows|\.husky|\.lefthook|scripts?|tools?)(?:\/|[^/]*$)/i, /^(?:typescript|eslint|prettier|biome|dependency-cruiser|madge|knip|semgrep|snyk|lefthook|husky)(\/|$)/i, 'static analysis, formatting, dependency graph, and hook tooling'),
508
+ };
509
+ const scriptNames = Object.keys(scripts).filter((name) => /deploy|worker|job|queue|seed|migrat/i.test(name));
510
+ if (scriptNames.length > 0) {
511
+ capabilities.jobs = {
512
+ ...capabilities.jobs,
513
+ evidence: [...capabilities.jobs.evidence, `scripts: ${scriptNames.join(', ')}`],
514
+ };
515
+ }
516
+ return Object.fromEntries(Object.entries(capabilities).filter(([, signal]) => signal.packages.length > 0 || signal.files.length > 0 || signal.imports.length > 0));
517
+ }
518
+ function buildInsights(root, files, modules, sourceFiles, dependencies, astProject) {
519
+ const layers = {};
520
+ for (const file of files.filter((candidate) => ['source', 'test', 'schema', 'migration'].includes(candidate.kind))) {
521
+ const layer = layerForPath(file.path);
522
+ (layers[layer] ??= []).push(file.path);
523
+ }
524
+ for (const paths of Object.values(layers))
525
+ paths.sort();
526
+ const importCounts = new Map();
527
+ for (const module of modules) {
528
+ for (const importPath of module.imports) {
529
+ if (!importPath.startsWith('.') && !importPath.startsWith('@/')) {
530
+ importCounts.set(importPath, (importCounts.get(importPath) ?? 0) + 1);
531
+ }
532
+ }
533
+ }
534
+ const importHotspots = [...importCounts.entries()]
535
+ .map(([module, count]) => ({ module, count }))
536
+ .sort((a, b) => (b.count !== a.count ? b.count - a.count : a.module.localeCompare(b.module)))
537
+ .slice(0, 30);
538
+ const boundaries = [];
539
+ const clientFiles = modules
540
+ .filter((module) => module.directives.includes('use client'))
541
+ .map((module) => module.path);
542
+ if (clientFiles.length > 0) {
543
+ boundaries.push({ kind: 'client', files: clientFiles, evidence: 'use client directive' });
544
+ }
545
+ const serverFiles = modules
546
+ .filter((module) => module.directives.includes('use server'))
547
+ .map((module) => module.path);
548
+ if (serverFiles.length > 0) {
549
+ boundaries.push({ kind: 'server', files: serverFiles, evidence: 'use server directive' });
550
+ }
551
+ const ormPackages = Object.keys(dependencies).filter((name) => ['drizzle-orm', 'prisma', 'typeorm', 'sequelize', 'mongoose', 'knex'].includes(name));
552
+ if (ormPackages.length > 0) {
553
+ boundaries.push({
554
+ kind: 'persistence',
555
+ files: files
556
+ .filter((file) => file.kind === 'schema' || layerForPath(file.path) === 'infrastructure')
557
+ .map((file) => file.path),
558
+ evidence: `ORM dependency: ${ormPackages.join(', ')}`,
559
+ });
560
+ }
561
+ const moduleGraph = buildModuleTargetGraph(modules);
562
+ const dependencyEdges = modules.flatMap((module) => moduleGraph.get(module.path)?.map((target) => ({ from: module.path, to: target })) ?? []);
563
+ const edgeCounts = new Map();
564
+ for (const module of modules) {
565
+ const targets = moduleGraph.get(module.path) ?? [];
566
+ for (const target of targets) {
567
+ const edge = `${layerForPath(module.path)}\0${layerForPath(target)}`;
568
+ edgeCounts.set(edge, (edgeCounts.get(edge) ?? 0) + 1);
569
+ }
570
+ }
571
+ const layerEdges = [...edgeCounts.entries()]
572
+ .map(([edge, count]) => {
573
+ const [from, to] = edge.split('\0');
574
+ return { from: from ?? 'unknown', to: to ?? 'unknown', count };
575
+ })
576
+ .sort((a, b) => b.count !== a.count
577
+ ? b.count - a.count
578
+ : `${a.from}${a.to}`.localeCompare(`${b.from}${b.to}`));
579
+ return {
580
+ layers,
581
+ cycles: findCycles(modules),
582
+ importHotspots,
583
+ boundaries,
584
+ layerEdges,
585
+ dependencyEdges,
586
+ impactPaths: buildImpactPaths(modules, sourceFiles.filter((path) => /(?:^|\/)route\.[cm]?[jt]sx?$/.test(path)), modules
587
+ .filter((module) => module.directives.includes('use server'))
588
+ .map((module) => module.path)),
589
+ envReferences: sourceFiles
590
+ .reduce((references, path) => {
591
+ const sourceFile = astProject.getSourceFile(resolve(root, path));
592
+ if (!sourceFile)
593
+ return references;
594
+ const names = new Set();
595
+ for (const access of sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
596
+ const expression = access.getExpression().getText();
597
+ const name = access.getName();
598
+ if ((expression === 'process.env' || expression === 'env') &&
599
+ /^[A-Z][A-Z0-9_]*$/.test(name)) {
600
+ names.add(name);
601
+ }
602
+ }
603
+ for (const access of sourceFile.getDescendantsOfKind(SyntaxKind.ElementAccessExpression)) {
604
+ if (access.getExpression().getText() !== 'process.env')
605
+ continue;
606
+ const argument = access.getArgumentExpression();
607
+ if (!argument?.isKind(SyntaxKind.StringLiteral))
608
+ continue;
609
+ const name = argument.getLiteralValue();
610
+ if (typeof name === 'string' && /^[A-Z][A-Z0-9_]*$/.test(name))
611
+ names.add(name);
612
+ }
613
+ for (const name of names) {
614
+ const reference = references.find((item) => item.name === name);
615
+ if (reference)
616
+ reference.files.push(path);
617
+ else
618
+ references.push({ name, files: [path] });
619
+ }
620
+ return references;
621
+ }, [])
622
+ .map((reference) => ({
623
+ ...reference,
624
+ files: [...new Set(reference.files)].sort(),
625
+ declared: (() => {
626
+ try {
627
+ const example = readFileSync(resolve(root, '.env.example'), 'utf8');
628
+ return new RegExp(`^${reference.name}=`, 'm').test(example);
629
+ }
630
+ catch {
631
+ return false;
632
+ }
633
+ })(),
634
+ }))
635
+ .sort((a, b) => a.name.localeCompare(b.name)),
636
+ };
637
+ }
638
+ function createAstProject(root) {
639
+ const tsConfigPath = resolve(root, 'tsconfig.json');
640
+ try {
641
+ return existsSync(tsConfigPath)
642
+ ? new Project({ tsConfigFilePath: tsConfigPath })
643
+ : new Project({ skipAddingFilesFromTsConfig: true });
644
+ }
645
+ catch {
646
+ return new Project({ skipAddingFilesFromTsConfig: true });
647
+ }
648
+ }
649
+ export function discoverProject(root, options = {}) {
650
+ const packageJson = readJsonObject(resolve(root, 'package.json'));
651
+ const dependencies = asStringRecord(packageJson.dependencies);
652
+ const devDependencies = asStringRecord(packageJson.devDependencies);
653
+ const scripts = asStringRecord(packageJson.scripts);
654
+ const allFiles = listFiles(root);
655
+ const signature = JSON.stringify([
656
+ JSON.stringify(packageJson),
657
+ allFiles.map((path) => {
658
+ const stat = statSync(resolve(root, path));
659
+ return [path, stat.size, stat.mtimeMs];
660
+ }),
661
+ runGit(root, ['rev-parse', 'HEAD']),
662
+ runGit(root, ['status', '--porcelain']),
663
+ ]);
664
+ const cacheKey = resolve(root);
665
+ const cached = discoveryCache.get(cacheKey);
666
+ if (cached?.signature === signature) {
667
+ discoveryReuse.set(cacheKey, cached.model.modules.length);
668
+ return cached.model;
669
+ }
670
+ let previousModel = cached?.model;
671
+ try {
672
+ const persisted = JSON.parse(readFileSync(discoveryCachePath(root), 'utf8'));
673
+ if (persisted.version === DISCOVERY_CACHE_VERSION &&
674
+ persisted.model !== null &&
675
+ typeof persisted.model === 'object' &&
676
+ persisted.model.version === 1) {
677
+ previousModel = persisted.model;
678
+ if (persisted.signature === signature) {
679
+ discoveryCache.set(cacheKey, { signature, model: previousModel });
680
+ discoveryReuse.set(cacheKey, previousModel.modules.length);
681
+ return previousModel;
682
+ }
683
+ }
684
+ }
685
+ catch {
686
+ // A missing or invalid cache is a normal cold-start path.
687
+ }
688
+ const files = allFiles.map((path) => ({
689
+ path,
690
+ kind: fileKind(path),
691
+ bytes: statSync(resolve(root, path)).size,
692
+ }));
693
+ const projectFiles = new Set(files.map((file) => file.path));
694
+ const astProject = createAstProject(root);
695
+ const previousModules = new Map(previousModel?.modules.map((module) => [module.path, module]));
696
+ const currentShape = files
697
+ .filter((file) => file.kind === 'source' || file.kind === 'test')
698
+ .map((file) => `${file.kind}:${file.path}`)
699
+ .join('\n');
700
+ const previousShape = previousModel?.files
701
+ .filter((file) => file.kind === 'source' || file.kind === 'test')
702
+ .map((file) => `${file.kind}:${file.path}`)
703
+ .join('\n');
704
+ const changedGitFiles = [
705
+ ...(runGit(root, ['diff', '--name-only', 'HEAD'])?.split('\n') ?? []),
706
+ ...(runGit(root, ['ls-files', '--others', '--exclude-standard'])?.split('\n') ?? []),
707
+ ].filter(Boolean);
708
+ const resolutionConfigChanged = changedGitFiles.some((path) => /(?:^|\/)(?:tsconfig|jsconfig)\.json$/.test(path));
709
+ const canReuseModules = previousShape === currentShape && !resolutionConfigChanged;
710
+ let reusedModules = 0;
711
+ const modules = files
712
+ .filter((file) => file.kind === 'source' || file.kind === 'test')
713
+ .map((file) => {
714
+ const previous = canReuseModules ? previousModules.get(file.path) : undefined;
715
+ if (previous?.contentHash === contentHash(root, file.path)) {
716
+ reusedModules += 1;
717
+ return previous;
718
+ }
719
+ return parseModule(astProject, root, file.path, projectFiles);
720
+ })
721
+ .filter((module) => module !== undefined);
722
+ const sourceFiles = files.filter((file) => file.kind === 'source').map((file) => file.path);
723
+ const testFiles = files.filter((file) => file.kind === 'test').map((file) => file.path);
724
+ const configs = files.filter((file) => file.kind === 'config').map((file) => file.path);
725
+ const schemas = files.filter((file) => file.kind === 'schema').map((file) => file.path);
726
+ const testScripts = Object.fromEntries(Object.entries(scripts).filter(([name]) => /test|check|lint|type/.test(name)));
727
+ const routeDetails = sourceFiles
728
+ .filter((path) => /(?:^|\/)route\.[cm]?[jt]sx?$/.test(path))
729
+ .map((path) => {
730
+ const sourceFile = astProject.getSourceFile(resolve(root, path));
731
+ const methods = new Set();
732
+ for (const declaration of sourceFile?.getFunctions() ?? []) {
733
+ const name = declaration.getName();
734
+ if (declaration.isExported() && name && HTTP_METHODS.has(name))
735
+ methods.add(name);
736
+ }
737
+ for (const statement of sourceFile?.getVariableStatements() ?? []) {
738
+ if (!statement.isExported())
739
+ continue;
740
+ for (const declaration of statement.getDeclarations()) {
741
+ const name = declaration.getName();
742
+ if (HTTP_METHODS.has(name))
743
+ methods.add(name);
744
+ }
745
+ }
746
+ for (const name of sourceFile?.getExportedDeclarations().keys() ?? []) {
747
+ if (HTTP_METHODS.has(name))
748
+ methods.add(name);
749
+ }
750
+ return { path, methods: [...methods].sort() };
751
+ });
752
+ const model = {
753
+ version: 1,
754
+ project: {
755
+ name: typeof packageJson.name === 'string' ? packageJson.name : undefined,
756
+ packageManager: packageManager(root),
757
+ frameworks: inferFrameworks(dependencies, devDependencies),
758
+ scripts,
759
+ dependencies,
760
+ devDependencies,
761
+ workspacePackages: workspacePackages(root, allFiles),
762
+ },
763
+ files,
764
+ modules,
765
+ configs,
766
+ schemas,
767
+ tests: { files: testFiles, scripts: testScripts },
768
+ capabilities: detectCapabilities(files, modules, dependencies, devDependencies, scripts),
769
+ git: gitModel(root),
770
+ patterns: {
771
+ clientComponents: modules
772
+ .filter((module) => module.directives.includes('use client'))
773
+ .map((module) => module.path),
774
+ serverActions: modules
775
+ .filter((module) => module.directives.includes('use server'))
776
+ .map((module) => module.path),
777
+ routeHandlers: sourceFiles.filter((path) => /(?:^|\/)route\.[cm]?[jt]sx?$/.test(path)),
778
+ routeDetails,
779
+ barrelFiles: sourceFiles.filter((path) => /(?:^|\/)index\.[cm]?[jt]s$/.test(path)),
780
+ importGraphEdges: modules.reduce((count, module) => count + module.imports.length, 0),
781
+ },
782
+ insights: buildInsights(root, files, modules, sourceFiles, dependencies, astProject),
783
+ };
784
+ discoveryCache.set(cacheKey, { signature, model });
785
+ discoveryReuse.set(cacheKey, reusedModules);
786
+ if (options.persistCache ?? existsSync(resolve(root, `.${config.appName}/guard`))) {
787
+ try {
788
+ mkdirSync(resolve(root, `.${config.appName}/guard`), { recursive: true });
789
+ const cachePath = discoveryCachePath(root);
790
+ const temporaryPath = `${cachePath}.tmp-${process.pid}`;
791
+ writeFileSync(temporaryPath, `${JSON.stringify({ version: DISCOVERY_CACHE_VERSION, signature, model }, null, 2)}\n`, 'utf8');
792
+ renameSync(temporaryPath, cachePath);
793
+ }
794
+ catch {
795
+ // Discovery remains usable when the optional cache cannot be written.
796
+ }
797
+ }
798
+ return model;
799
+ }
800
+ export function discoverProjectWithMetrics(root, options = {}) {
801
+ const startedAt = Date.now();
802
+ const cacheHit = discoveryCache.has(resolve(root));
803
+ const model = discoverProject(root, options);
804
+ return {
805
+ model,
806
+ metrics: {
807
+ durationMs: Math.max(0, Date.now() - startedAt),
808
+ files: model.files.length,
809
+ modules: model.modules.length,
810
+ cacheAvailable: existsSync(discoveryCachePath(root)),
811
+ cacheHit,
812
+ changedFiles: model.git.changedFiles.length,
813
+ reusedModules: discoveryReuse.get(resolve(root)) ?? 0,
814
+ },
815
+ };
816
+ }