@event4u/agent-config 17.1.0 → 17.2.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 (73) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +70 -0
  5. package/README.md +2 -2
  6. package/dist/agent-src/commands/project/analyze.md +9 -6
  7. package/dist/agent-src/commands/project/map.md +58 -0
  8. package/dist/agent-src/commands/project.md +5 -3
  9. package/dist/agent-src/skills/code-intelligence/SKILL.md +6 -0
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/cli/registry.js +1 -1
  12. package/dist/cli-delegate/cmd_conformance.js +23 -39
  13. package/dist/discovery/deprecation-report.md +1 -1
  14. package/dist/discovery/discovery-manifest.json +39 -13
  15. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  16. package/dist/discovery/discovery-manifest.summary.md +3 -3
  17. package/dist/discovery/dormancy-report.md +1 -1
  18. package/dist/discovery/orphan-report.md +1 -1
  19. package/dist/discovery/packs.json +6 -5
  20. package/dist/discovery/trust-report.md +2 -2
  21. package/dist/discovery/workspaces.json +5 -4
  22. package/dist/hooks/dispatch.js +65 -74
  23. package/dist/hooks/dispatch.sha256 +1 -1
  24. package/dist/hooks/instructions_observer.chunk.cjs +301 -0
  25. package/dist/hooks/instructions_observer.chunk.cjs.sha256 +1 -0
  26. package/dist/mcp/registry-manifest.json +2 -2
  27. package/dist/mcp/server.json +2 -2
  28. package/dist/mcp/server.mjs +33058 -22930
  29. package/package.json +4 -1
  30. package/src/config/continuity-surface.json +7 -0
  31. package/src/config/evaluator-budgets.json +4 -4
  32. package/src/config/gate-violation-baselines.json +3 -3
  33. package/src/config/gitignore-block.txt +6 -0
  34. package/src/config/hook-bundle-budget.json +17 -2
  35. package/src/config/hook-token-budget.json +1 -1
  36. package/src/config/packed-binary-manifest.json +27 -0
  37. package/src/config/skill-body-budget.json +7 -1
  38. package/src/scripts/_lib/bench_ab_complexity.ts +14 -2
  39. package/src/scripts/_lib/collector_record.ts +1 -0
  40. package/src/scripts/_lib/runtime_journal.ts +7 -7
  41. package/src/scripts/code_graph/build.ts +210 -10
  42. package/src/scripts/code_graph/cli.ts +168 -46
  43. package/src/scripts/code_graph/detect.ts +5 -4
  44. package/src/scripts/code_graph/docs_layer.ts +57 -0
  45. package/src/scripts/code_graph/extract.ts +58 -13
  46. package/src/scripts/code_graph/extract_more.ts +314 -0
  47. package/src/scripts/code_graph/laravel_overlay.ts +454 -0
  48. package/src/scripts/code_graph/map.ts +846 -0
  49. package/src/scripts/code_graph/map_model.ts +977 -0
  50. package/src/scripts/code_graph/map_verify.ts +140 -0
  51. package/src/scripts/code_graph/map_viewer/viewer.css +278 -0
  52. package/src/scripts/code_graph/map_viewer/viewer.js +756 -0
  53. package/src/scripts/code_graph/scip_reader.ts +201 -0
  54. package/src/scripts/code_graph/sqlite_store.ts +8 -1
  55. package/src/scripts/code_graph/types.ts +81 -15
  56. package/src/scripts/code_graph/validate.ts +16 -26
  57. package/src/scripts/code_graph/verbs.ts +7 -0
  58. package/src/scripts/hook_chunks.mjs +7 -0
  59. package/src/scripts/hook_manifest.json +1 -1
  60. package/src/scripts/hook_manifest.yaml +20 -0
  61. package/src/scripts/hooks/dispatch_hook.ts +2 -2
  62. package/src/scripts/hooks/instructions_observer_hook.ts +25 -6
  63. package/src/scripts/hooks/lazy_concern.ts +4 -0
  64. package/src/scripts/install-hooks.sh +5 -0
  65. package/src/scripts/lint_agents_layout.ts +2 -0
  66. package/src/scripts/lint_hook_manifest.ts +1 -0
  67. package/src/scripts/mcp_server/consumer_tool_catalog.json +53 -6
  68. package/src/scripts/mcp_server/graph_tools.ts +73 -1
  69. package/src/scripts/mcp_server/tools.ts +19 -36
  70. package/src/vendor/grammars/README.md +10 -5
  71. package/src/vendor/grammars/tree-sitter-go.wasm +0 -0
  72. package/src/vendor/grammars/tree-sitter-java.wasm +0 -0
  73. package/src/vendor/grammars/tree-sitter-python.wasm +0 -0
@@ -0,0 +1,454 @@
1
+ /**
2
+ * The Laravel overlay: the links a Laravel project makes by convention, which
3
+ * the PHP extractor cannot see because no import or call states them.
4
+ *
5
+ * routes/*.php Route::get('/x', [C::class, 'm']) → route node --routes-to--> C::m
6
+ * C::m view('a.b') --renders--> resources/views/a/b.blade.php
7
+ * a.blade.php @include('c') · <x-d> --includes--> the included view file
8
+ * any PHP __('file.key') · config('app.x') --reads--> lang/* /file.php · config/app.php
9
+ * migration Schema::create('t') --migrates--> table node `table:t`
10
+ * Model class $table / naming convention --persists--> table node
11
+ * Model method $this->hasMany(Other::class) --relates--> Other
12
+ *
13
+ * Every link is read off the source text by rule, and is emitted only when its
14
+ * target exists in the tree: a missing edge is preferred to a wrong one. What a
15
+ * rule cannot settle — a closure handler, a view name built at runtime — is
16
+ * counted in `stats`, never guessed.
17
+ */
18
+ import type { SourceFile } from './build.js';
19
+ import { resolvePsr4, type ResolutionConfig } from './resolution_tiers.js';
20
+ import type { CodeEdge, CodeNode } from './types.js';
21
+
22
+ export interface LaravelOverlayStats {
23
+ routes: number;
24
+ routesResolved: number;
25
+ routeClosures: number;
26
+ routeStringHandlers: number;
27
+ routesUnresolved: number;
28
+ views: number;
29
+ viewsResolved: number;
30
+ viewsDynamic: number;
31
+ includes: number;
32
+ includesResolved: number;
33
+ tables: number;
34
+ models: number;
35
+ relations: number;
36
+ reads: number;
37
+ builderCallsDropped: number;
38
+ }
39
+
40
+ export interface LaravelOverlay {
41
+ nodes: CodeNode[];
42
+ edges: CodeEdge[];
43
+ /** Edges of the base graph the overlay proves false (fluent route-builder calls). */
44
+ dropEdge: (e: CodeEdge) => boolean;
45
+ stats: LaravelOverlayStats;
46
+ }
47
+
48
+ const VERBS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'any'] as const;
49
+ const RESOURCE_METHODS: Record<string, Array<[string, string, string]>> = {
50
+ resource: [
51
+ ['GET', '', 'index'],
52
+ ['GET', '/create', 'create'],
53
+ ['POST', '', 'store'],
54
+ ['GET', '/{id}', 'show'],
55
+ ['GET', '/{id}/edit', 'edit'],
56
+ ['PUT', '/{id}', 'update'],
57
+ ['DELETE', '/{id}', 'destroy'],
58
+ ],
59
+ apiResource: [
60
+ ['GET', '', 'index'],
61
+ ['POST', '', 'store'],
62
+ ['GET', '/{id}', 'show'],
63
+ ['PUT', '/{id}', 'update'],
64
+ ['DELETE', '/{id}', 'destroy'],
65
+ ],
66
+ };
67
+ /** Methods of Laravel's route builder; a call to one is never an in-repo method. */
68
+ const ROUTE_BUILDER_METHODS = new Set([
69
+ 'middleware',
70
+ 'withoutMiddleware',
71
+ 'prefix',
72
+ 'name',
73
+ 'as',
74
+ 'group',
75
+ 'where',
76
+ 'whereNumber',
77
+ 'whereAlpha',
78
+ 'whereUuid',
79
+ 'domain',
80
+ 'controller',
81
+ 'namespace',
82
+ 'scopeBindings',
83
+ 'withoutScopedBindings',
84
+ 'missing',
85
+ 'can',
86
+ ]);
87
+ const MODEL_BASES = new Set(['Model', 'Authenticatable', 'Pivot', 'MorphPivot', 'User']);
88
+ const RELATION_METHODS =
89
+ 'hasOne|hasMany|belongsTo|belongsToMany|hasOneThrough|hasManyThrough|morphOne|morphMany|morphToMany|morphedByMany';
90
+
91
+ /** Line-start offsets, so many `lineAt` lookups stay linear. */
92
+ function lineIndex(src: string): (index: number) => number {
93
+ const starts = [0];
94
+ for (let i = 0; i < src.length; i++) if (src.charCodeAt(i) === 10) starts.push(i + 1);
95
+ return (index: number): number => {
96
+ let lo = 0;
97
+ let hi = starts.length - 1;
98
+ while (lo < hi) {
99
+ const mid = (lo + hi + 1) >> 1;
100
+ if ((starts[mid] as number) <= index) lo = mid;
101
+ else hi = mid - 1;
102
+ }
103
+ return lo + 1;
104
+ };
105
+ }
106
+
107
+ /** Offset of the `}` that closes the `{` at `open`, skipping strings and comments. */
108
+ function matchingBrace(src: string, open: number): number {
109
+ let depth = 0;
110
+ for (let i = open; i < src.length; i++) {
111
+ const c = src[i];
112
+ if (c === "'" || c === '"') {
113
+ const q = c;
114
+ for (i += 1; i < src.length && src[i] !== q; i++) if (src[i] === '\\') i += 1;
115
+ continue;
116
+ }
117
+ if (c === '/' && src[i + 1] === '/') {
118
+ while (i < src.length && src[i] !== '\n') i += 1;
119
+ continue;
120
+ }
121
+ if (c === '/' && src[i + 1] === '*') {
122
+ const end = src.indexOf('*/', i + 2);
123
+ i = end < 0 ? src.length : end + 1;
124
+ continue;
125
+ }
126
+ if (c === '{') depth += 1;
127
+ else if (c === '}') {
128
+ depth -= 1;
129
+ if (depth === 0) return i;
130
+ }
131
+ }
132
+ return src.length;
133
+ }
134
+
135
+ interface PhpScope {
136
+ namespace: string;
137
+ uses: Map<string, string>;
138
+ }
139
+
140
+ function phpScope(src: string): PhpScope {
141
+ const ns = /^\s*namespace\s+([\\\w]+)\s*;/m.exec(src)?.[1] ?? '';
142
+ const uses = new Map<string, string>();
143
+ for (const m of src.matchAll(/^\s*use\s+([\\\w]+)(?:\s+as\s+(\w+))?\s*;/gm)) {
144
+ const fq = (m[1] as string).replace(/^\\/, '');
145
+ const alias = m[2] ?? fq.split('\\').pop() ?? fq;
146
+ uses.set(alias, fq);
147
+ }
148
+ return { namespace: ns, uses };
149
+ }
150
+
151
+ /** Fully-qualified name of a class reference as written in a file. */
152
+ function qualify(ref: string, scope: PhpScope): string {
153
+ if (ref.startsWith('\\')) return ref.slice(1);
154
+ const [head, ...rest] = ref.split('\\');
155
+ const aliased = scope.uses.get(head as string);
156
+ if (aliased) return [aliased, ...rest].join('\\');
157
+ return scope.namespace ? `${scope.namespace}\\${ref}` : ref;
158
+ }
159
+
160
+ function shortName(fq: string): string {
161
+ return fq.split('\\').pop() ?? fq;
162
+ }
163
+
164
+ function snakePlural(name: string): string {
165
+ const snake = name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
166
+ if (/[^aeiou]y$/.test(snake)) return `${snake.slice(0, -1)}ies`;
167
+ if (/(s|x|z|ch|sh)$/.test(snake)) return `${snake}es`;
168
+ return `${snake}s`;
169
+ }
170
+
171
+ export function buildLaravelOverlay(
172
+ files: readonly SourceFile[],
173
+ baseNodes: readonly CodeNode[],
174
+ resolution: ResolutionConfig,
175
+ ): LaravelOverlay {
176
+ const stats: LaravelOverlayStats = {
177
+ routes: 0,
178
+ routesResolved: 0,
179
+ routeClosures: 0,
180
+ routeStringHandlers: 0,
181
+ routesUnresolved: 0,
182
+ views: 0,
183
+ viewsResolved: 0,
184
+ viewsDynamic: 0,
185
+ includes: 0,
186
+ includesResolved: 0,
187
+ tables: 0,
188
+ models: 0,
189
+ relations: 0,
190
+ reads: 0,
191
+ builderCallsDropped: 0,
192
+ };
193
+ const fileIds = new Set(files.map((f) => f.path));
194
+ const nodeById = new Map(baseNodes.map((n) => [n.id, n] as const));
195
+ const nodes: CodeNode[] = [];
196
+ const edges: CodeEdge[] = [];
197
+ const seenEdge = new Set<string>();
198
+ const emit = (source: string, target: string, relation: CodeEdge['relation'], via: CodeEdge['resolved_via']): void => {
199
+ const key = `${source}\0${relation}\0${target}`;
200
+ if (seenEdge.has(key)) return;
201
+ seenEdge.add(key);
202
+ edges.push({ source, target, relation, confidence: 'EXTRACTED', resolved_via: via, provider: 'native' });
203
+ };
204
+
205
+ // Innermost function/method node per file, by line, for attributing a
206
+ // `view()` or `__()` call to the symbol that makes it.
207
+ const symbolsByFile = new Map<string, CodeNode[]>();
208
+ for (const n of baseNodes) {
209
+ if (n.kind !== 'method' && n.kind !== 'function') continue;
210
+ const list = symbolsByFile.get(n.source_file) ?? [];
211
+ list.push(n);
212
+ symbolsByFile.set(n.source_file, list);
213
+ }
214
+ const enclosing = (file: string, line: number): string => {
215
+ let best: CodeNode | null = null;
216
+ for (const n of symbolsByFile.get(file) ?? []) {
217
+ const [s, , e] = n.source_location as [number, number, number, number];
218
+ if (s <= line && line <= e && (!best || s >= (best.source_location[0] as number))) best = n;
219
+ }
220
+ return best ? best.id : file;
221
+ };
222
+
223
+ const php = files.filter((f) => f.path.endsWith('.php'));
224
+ const blade = (p: string): boolean => p.endsWith('.blade.php');
225
+ const viewFile = (name: string): string | null => {
226
+ if (name.includes('::')) return null;
227
+ const rel = name.replace(/\./g, '/');
228
+ for (const c of [`resources/views/${rel}.blade.php`, `resources/views/${rel}.php`, `resources/views/${rel}/index.blade.php`]) {
229
+ if (fileIds.has(c)) return c;
230
+ }
231
+ return null;
232
+ };
233
+ const classFile = (fq: string): string | null => resolvePsr4(fq, resolution, fileIds);
234
+
235
+ // Routes.
236
+ let routeOrdinal = new Map<string, number>();
237
+ for (const f of php.filter((x) => x.path.startsWith('routes/'))) {
238
+ const src = f.source;
239
+ const at = lineIndex(src);
240
+ const scope = phpScope(src);
241
+ routeOrdinal = new Map();
242
+ const groups: Array<{ start: number; end: number; prefix: string }> = [];
243
+ for (const m of src.matchAll(/Route::((?:\w+\((?:[^()]|\([^()]*\))*\)\s*->\s*)*)group\(\s*(?:static\s+)?function\s*\([^)]*\)\s*(?:use\s*\([^)]*\)\s*)?\{/g)) {
244
+ const open = (m.index as number) + m[0].length - 1;
245
+ const prefix = /prefix\(\s*['"]([^'"]*)['"]\s*\)/.exec(m[1] ?? '')?.[1] ?? '';
246
+ groups.push({ start: open, end: matchingBrace(src, open), prefix });
247
+ }
248
+ const prefixAt = (i: number): string =>
249
+ groups
250
+ .filter((g) => g.start < i && i < g.end)
251
+ .map((g) => g.prefix.replace(/^\/|\/$/g, ''))
252
+ .filter(Boolean)
253
+ .join('/');
254
+ const addRoute = (verb: string, uri: string, index: number, target: { fq: string; method: string } | null): void => {
255
+ stats.routes += 1;
256
+ const pre = prefixAt(index);
257
+ const full = `/${[pre, uri.replace(/^\/|\/$/g, '')].filter(Boolean).join('/')}`;
258
+ const base = `${f.path}#route:${verb} ${full}`;
259
+ const n = (routeOrdinal.get(base) ?? 0) + 1;
260
+ routeOrdinal.set(base, n);
261
+ const id = n === 1 ? base : `${base}@${n}`;
262
+ const line = at(index);
263
+ nodes.push({ id, label: `${verb} ${full}`, kind: 'route', source_file: f.path, source_location: [line, 1, line, 1] });
264
+ emit(f.path, id, 'member', 'same-file');
265
+ if (!target) return;
266
+ const file = classFile(target.fq);
267
+ const cls = file ? `${file}#${shortName(target.fq)}` : null;
268
+ const method = cls ? `${cls}::${target.method}` : null;
269
+ if (method && nodeById.has(method)) {
270
+ emit(id, method, 'routes-to', 'route-table');
271
+ stats.routesResolved += 1;
272
+ } else if (cls && nodeById.has(cls)) {
273
+ emit(id, cls, 'routes-to', 'route-table');
274
+ stats.routesResolved += 1;
275
+ } else stats.routesUnresolved += 1;
276
+ };
277
+ const verbRe = new RegExp(`Route::(${VERBS.join('|')})\\(\\s*(['"])(.*?)\\2\\s*,\\s*`, 'g');
278
+ for (const m of src.matchAll(verbRe)) {
279
+ const verb = (m[1] as string).toUpperCase();
280
+ const uri = m[3] as string;
281
+ const rest = src.slice((m.index as number) + m[0].length, (m.index as number) + m[0].length + 300);
282
+ const arr = /^\[\s*([\\\w]+)::class\s*,\s*['"](\w+)['"]\s*\]/.exec(rest);
283
+ const inv = /^([\\\w]+)::class\b/.exec(rest);
284
+ if (arr) addRoute(verb, uri, m.index as number, { fq: qualify(arr[1] as string, scope), method: arr[2] as string });
285
+ else if (inv) addRoute(verb, uri, m.index as number, { fq: qualify(inv[1] as string, scope), method: '__invoke' });
286
+ else if (/^(?:static\s+)?(?:function|fn)\b/.test(rest)) {
287
+ stats.routeClosures += 1;
288
+ addRoute(verb, uri, m.index as number, null);
289
+ } else if (/^['"]/.test(rest)) {
290
+ stats.routeStringHandlers += 1;
291
+ addRoute(verb, uri, m.index as number, null);
292
+ } else addRoute(verb, uri, m.index as number, null);
293
+ }
294
+ for (const m of src.matchAll(/Route::(resource|apiResource)\(\s*(['"])(.*?)\2\s*,\s*([\\\w]+)::class/g)) {
295
+ for (const [verb, suffix, method] of RESOURCE_METHODS[m[1] as string] ?? []) {
296
+ addRoute(verb, `${m[3] as string}${suffix}`, m.index as number, { fq: qualify(m[4] as string, scope), method });
297
+ }
298
+ }
299
+ }
300
+
301
+ const langByGroup = new Map<string, string[]>();
302
+ const configByName = new Map<string, string[]>();
303
+ for (const p of [...fileIds].sort()) {
304
+ const lang = /(?:^|\/)lang\/[^/]+\/([\w-]+)\.php$/.exec(p);
305
+ if (lang) langByGroup.set(lang[1] as string, [...(langByGroup.get(lang[1] as string) ?? []), p]);
306
+ const cfg = /(?:^|\/)[Cc]onfig\/([\w-]+)\.php$/.exec(p);
307
+ if (cfg) configByName.set(cfg[1] as string, [...(configByName.get(cfg[1] as string) ?? []), p]);
308
+ }
309
+
310
+ // Views rendered from PHP, and the Blade directives between views.
311
+ for (const f of php) {
312
+ const at = lineIndex(f.source);
313
+ if (blade(f.path)) {
314
+ const directives = [
315
+ ...f.source.matchAll(/@(?:extends|include|includeIf|includeUnless|includeFirst|each|component)\(\s*(['"])([\w.\-/]+)\1/g),
316
+ ...f.source.matchAll(/@includeWhen\([^,]+,\s*(['"])([\w.\-/]+)\1/g),
317
+ ];
318
+ for (const m of directives) {
319
+ stats.includes += 1;
320
+ const target = viewFile(m[2] as string);
321
+ if (target && target !== f.path) {
322
+ emit(f.path, target, 'includes', 'directive-scan');
323
+ stats.includesResolved += 1;
324
+ }
325
+ }
326
+ for (const m of f.source.matchAll(/<x-([\w\-.]+)/g)) {
327
+ stats.includes += 1;
328
+ const target = viewFile(`components.${m[1] as string}`);
329
+ if (target && target !== f.path) {
330
+ emit(f.path, target, 'includes', 'directive-scan');
331
+ stats.includesResolved += 1;
332
+ }
333
+ }
334
+ } else {
335
+ for (const m of f.source.matchAll(/(?:\bview\(|View::make\(|->view\()\s*(['"])([\w.\-/:]+)\1(\s*\.)?/g)) {
336
+ stats.views += 1;
337
+ if (m[3]) {
338
+ stats.viewsDynamic += 1;
339
+ continue;
340
+ }
341
+ const target = viewFile(m[2] as string);
342
+ if (!target) continue;
343
+ emit(enclosing(f.path, at(m.index as number)), target, 'renders', 'directive-scan');
344
+ stats.viewsResolved += 1;
345
+ }
346
+ }
347
+ // Translation groups and config files a file reads.
348
+ for (const m of f.source.matchAll(/(?:\b__|\btrans|\btrans_choice|@lang)\(\s*(['"])([\w-]+)\.[\w.\-]+\1/g)) {
349
+ for (const t of langByGroup.get(m[2] as string) ?? []) {
350
+ emit(blade(f.path) ? f.path : enclosing(f.path, at(m.index as number)), t, 'reads', 'directive-scan');
351
+ stats.reads += 1;
352
+ }
353
+ }
354
+ for (const m of f.source.matchAll(/\bconfig\(\s*(['"])([\w-]+)\.[\w.\-]+\1/g)) {
355
+ const targets = configByName.get(m[2] as string) ?? [];
356
+ if (targets.length !== 1) continue;
357
+ emit(blade(f.path) ? f.path : enclosing(f.path, at(m.index as number)), targets[0] as string, 'reads', 'directive-scan');
358
+ stats.reads += 1;
359
+ }
360
+ }
361
+
362
+ // Tables, from the migrations that create or change them.
363
+ const tableNodes = new Map<string, string>();
364
+ const migrations = php.filter((f) => /(^|\/)migrations\//.test(f.path)).sort((a, b) => (a.path < b.path ? -1 : 1));
365
+ for (const f of migrations) {
366
+ const at = lineIndex(f.source);
367
+ for (const m of f.source.matchAll(/Schema::(create|table)\(\s*(['"])(\w+)\2/g)) {
368
+ const table = m[3] as string;
369
+ let id = tableNodes.get(table);
370
+ if (!id) {
371
+ id = `table:${table}`;
372
+ tableNodes.set(table, id);
373
+ const line = at(m.index as number);
374
+ nodes.push({ id, label: table, kind: 'table', source_file: f.path, source_location: [line, 1, line, 1] });
375
+ stats.tables += 1;
376
+ }
377
+ emit(f.path, id, 'migrates', 'schema-call');
378
+ }
379
+ }
380
+
381
+ // Models: classes whose parent chain reaches an Eloquent base, by short name.
382
+ const classDecl = new Map<string, { file: SourceFile; parent: string; abstract: boolean; scope: PhpScope }>();
383
+ for (const f of php) {
384
+ if (blade(f.path)) continue;
385
+ const scope = phpScope(f.source);
386
+ for (const m of f.source.matchAll(/(abstract\s+)?class\s+(\w+)\s+extends\s+([\\\w]+)/g)) {
387
+ classDecl.set(`${f.path}#${m[2] as string}`, {
388
+ file: f,
389
+ parent: shortName(m[3] as string),
390
+ abstract: Boolean(m[1]),
391
+ scope,
392
+ });
393
+ }
394
+ }
395
+ const modelNames = new Set(MODEL_BASES);
396
+ for (let changed = true; changed; ) {
397
+ changed = false;
398
+ for (const [id, d] of classDecl) {
399
+ const name = id.split('#')[1] as string;
400
+ if (!modelNames.has(name) && modelNames.has(d.parent)) {
401
+ modelNames.add(name);
402
+ changed = true;
403
+ }
404
+ }
405
+ }
406
+ // `$table` is inherited: a model whose ancestor names its table persists
407
+ // there, and the naming convention applies only when no ancestor names one.
408
+ const byShortName = new Map<string, { source: string; parent: string }>();
409
+ for (const [id, d] of classDecl) byShortName.set(id.split('#')[1] as string, { source: d.file.source, parent: d.parent });
410
+ const inheritedTable = (name: string): string | undefined => {
411
+ const seen = new Set<string>();
412
+ for (let cur: string | undefined = name; cur && !seen.has(cur); ) {
413
+ seen.add(cur);
414
+ const decl = byShortName.get(cur);
415
+ if (!decl) return undefined;
416
+ const own = /protected\s+\$table\s*=\s*['"](\w+)['"]/.exec(decl.source)?.[1];
417
+ if (own) return own;
418
+ cur = decl.parent;
419
+ }
420
+ return undefined;
421
+ };
422
+ for (const [id, d] of [...classDecl.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
423
+ const name = id.split('#')[1] as string;
424
+ if (!modelNames.has(d.parent) || MODEL_BASES.has(name) || !nodeById.has(id)) continue;
425
+ stats.models += 1;
426
+ const explicit = inheritedTable(name);
427
+ const table = explicit ?? (d.abstract ? null : snakePlural(name));
428
+ const tableId = table ? tableNodes.get(table) : undefined;
429
+ if (tableId) emit(id, tableId, 'persists', 'schema-call');
430
+ const at = lineIndex(d.file.source);
431
+ for (const m of d.file.source.matchAll(new RegExp(`\\$this->(?:${RELATION_METHODS})\\(\\s*([\\\\\\w]+)::class`, 'g'))) {
432
+ const fq = qualify(m[1] as string, d.scope);
433
+ const file = classFile(fq);
434
+ const target = file ? `${file}#${shortName(fq)}` : null;
435
+ if (!target || !nodeById.has(target)) continue;
436
+ emit(enclosing(d.file.path, at(m.index as number)), target, 'relates', 'eloquent-relation');
437
+ stats.relations += 1;
438
+ }
439
+ }
440
+
441
+ const routesFiles = new Set(php.filter((x) => x.path.startsWith('routes/')).map((x) => x.path));
442
+ const dropEdge = (e: CodeEdge): boolean => {
443
+ if (e.relation !== 'calls' || e.resolved_via === 'same-file') return false;
444
+ const src = nodeById.get(e.source);
445
+ if (!src || !routesFiles.has(src.source_file)) return false;
446
+ const target = nodeById.get(e.target);
447
+ const name = target ? target.label : e.target.replace(/^symbol:/, '');
448
+ if (!ROUTE_BUILDER_METHODS.has(name)) return false;
449
+ stats.builderCallsDropped += 1;
450
+ return true;
451
+ };
452
+
453
+ return { nodes, edges, dropEdge, stats };
454
+ }