@event4u/agent-config 17.3.0 → 17.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.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +164 -342
- package/CREDITS.md +2 -2
- package/README.md +1 -1
- package/dist/agent-src/skills/code-intelligence/SKILL.md +3 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +2 -2
- package/dist/cli-delegate/{chunk-DHSSP7V3.js → chunk-5AYJGUA3.js} +0 -2
- package/dist/cli-delegate/chunk-DIFZBP4G.js +1336 -0
- package/dist/cli-delegate/{chunk-MT2WOOCY.js → chunk-WY5ZZPGL.js} +2 -2
- package/dist/cli-delegate/{chunk-ORJPBER2.js → chunk-X6LZ7W5A.js} +73 -10
- package/dist/cli-delegate/cmd_conformance.js +175 -183
- package/dist/cli-delegate/cmd_doctor.js +5 -5
- package/dist/cli-delegate/cmd_doctor_neighbours.js +3 -3
- package/dist/cli-delegate/cmd_explain.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +2 -2
- package/dist/cli-delegate/cmd_update.js +24 -13
- package/dist/cli-delegate/cmd_upgrade.js +2 -2
- package/dist/cli-delegate/dist-25AZAHOP.js +3240 -0
- package/dist/cli-delegate/dist-P7MFPMF3.js +411 -0
- package/dist/cli-delegate/dist-XAS5ZLQB.js +6 -0
- package/dist/cli-delegate/{upgrade_unattended-VVL6JLSX.js → upgrade_unattended-QCWOKTUN.js} +178 -163
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +3 -3
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/code_graph_refresh.chunk.cjs +56 -27
- package/dist/hooks/code_graph_refresh.chunk.cjs.sha256 +1 -1
- package/dist/hooks/dispatch.js +982 -929
- package/dist/hooks/dispatch.sha256 +1 -1
- package/dist/hooks/egress_engine.chunk.cjs +56 -6
- package/dist/hooks/egress_engine.chunk.cjs.sha256 +1 -1
- package/dist/hooks/update_notice.chunk.cjs +914 -0
- package/dist/hooks/update_notice.chunk.cjs.sha256 +1 -0
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +71259 -66437
- package/dist/scripts/condense.js +9 -6
- package/package.json +4 -1
- package/src/config/evaluator-budgets.json +9 -8
- package/src/config/gate-violation-baselines.json +2 -2
- package/src/config/hook-bundle-budget.json +31 -6
- package/src/config/packed-binary-manifest.json +9 -0
- package/src/config/sigstore/npm_keys.json +50 -0
- package/src/config/sigstore/trust-material.json +9 -0
- package/src/config/sigstore/trusted_root.json +126 -0
- package/src/config/skill-body-budget.json +7 -1
- package/src/scripts/_cli/cmd_update.ts +48 -20
- package/src/scripts/_cli/cmd_upgrade.ts +7 -1
- package/src/scripts/_cli/upgrade_unattended.ts +90 -76
- package/src/scripts/_dispatch.bash +3 -2
- package/src/scripts/_lib/confusables.ts +0 -19
- package/src/scripts/_lib/egress_normalize.ts +53 -23
- package/src/scripts/_lib/egress_text.ts +5 -3
- package/src/scripts/_lib/egress_token.ts +7 -5
- package/src/scripts/_lib/gate_collector_experimental.ts +633 -0
- package/src/scripts/_lib/git_common_dir.ts +10 -0
- package/src/scripts/_lib/graph_feeder_record.ts +3 -2
- package/src/scripts/_lib/install_reach_checks.ts +32 -2
- package/src/scripts/_lib/lineage_backfill.ts +136 -0
- package/src/scripts/_lib/lineage_ledger.ts +211 -0
- package/src/scripts/_lib/lineage_map.ts +55 -0
- package/src/scripts/_lib/lineage_query.ts +140 -0
- package/src/scripts/_lib/lineage_seed.ts +116 -0
- package/src/scripts/_lib/lineage_watch.ts +249 -0
- package/src/scripts/_lib/link_crypto.ts +8 -4
- package/src/scripts/_lib/production_reach.ts +3 -3
- package/src/scripts/_lib/project_registry.ts +227 -0
- package/src/scripts/_lib/provenance_verify.ts +14 -242
- package/src/scripts/_lib/sigstore_provenance.ts +177 -0
- package/src/scripts/_lib/source_id.ts +48 -0
- package/src/scripts/_lib/source_shape.ts +4 -0
- package/src/scripts/_lib/unattended_gates.ts +6 -1
- package/src/scripts/advisories.ts +26 -10
- package/src/scripts/ai_council/council_egress.ts +5 -6
- package/src/scripts/bench_graph_feeder_latency.ts +10 -3
- package/src/scripts/check_dependency_floors.ts +7 -0
- package/src/scripts/check_egress_mutations.ts +1 -0
- package/src/scripts/code_graph/build.ts +67 -3
- package/src/scripts/code_graph/cli.ts +131 -28
- package/src/scripts/code_graph/conventions_default.ts +20 -1
- package/src/scripts/code_graph/detect.ts +5 -5
- package/src/scripts/code_graph/estate_cli.ts +135 -0
- package/src/scripts/code_graph/estate_index.ts +394 -0
- package/src/scripts/code_graph/estate_manifests.ts +223 -0
- package/src/scripts/code_graph/estate_map.ts +141 -0
- package/src/scripts/code_graph/extract.ts +4 -1
- package/src/scripts/code_graph/extract_more.ts +137 -2
- package/src/scripts/code_graph/graph_budget.ts +158 -0
- package/src/scripts/code_graph/graph_home.ts +80 -0
- package/src/scripts/code_graph/lineage_cli.ts +315 -0
- package/src/scripts/code_graph/map.ts +22 -13
- package/src/scripts/code_graph/map_model.ts +67 -12
- package/src/scripts/code_graph/map_verify.ts +68 -5
- package/src/scripts/code_graph/map_viewer/viewer.css +12 -0
- package/src/scripts/code_graph/map_viewer/viewer.js +45 -15
- package/src/scripts/code_graph/project_ref.ts +25 -0
- package/src/scripts/code_graph/projects_cli.ts +254 -0
- package/src/scripts/code_graph/providers/catalog.ts +14 -5
- package/src/scripts/code_graph/providers/django.ts +52 -9
- package/src/scripts/code_graph/providers/index.ts +2 -0
- package/src/scripts/code_graph/providers/rails.ts +204 -0
- package/src/scripts/code_graph/providers/spring.ts +38 -1
- package/src/scripts/code_graph/query.ts +9 -9
- package/src/scripts/code_graph/refresh_lock.ts +12 -8
- package/src/scripts/code_graph/repo_identity.ts +169 -0
- package/src/scripts/code_graph/sqlite_store.ts +22 -25
- package/src/scripts/code_graph/types.ts +6 -2
- package/src/scripts/condense.ts +6 -6
- package/src/scripts/hook_chunks.mjs +7 -0
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +20 -17
- package/src/scripts/hooks/code_graph_refresh_hook.ts +10 -7
- package/src/scripts/hooks/dispatch_hook.ts +6 -7
- package/src/scripts/hooks/host_semantics.ts +72 -0
- package/src/scripts/hooks/lazy_concern.ts +4 -0
- package/src/scripts/hooks/user_notice.ts +105 -0
- package/src/scripts/lint_harvest_provenance.ts +137 -11
- package/src/scripts/mcp_server/consumer_tool_catalog.json +69 -15
- package/src/scripts/mcp_server/graph_tools.ts +161 -41
- package/src/scripts/network_sources.ts +24 -6
- package/src/scripts/schemas/provenance-mechanism.schema.json +46 -0
- package/src/scripts/update_notice_hook.ts +75 -7
- package/src/vendor/grammars/README.md +7 -6
- package/src/vendor/grammars/tree-sitter-ruby.wasm +0 -0
- /package/dist/cli-delegate/{chunk-FKYZOWVD.js → chunk-QBAFP67O.js} +0 -0
- /package/dist/cli-delegate/{chunk-BMGUGSY6.js → chunk-TQG6UMPC.js} +0 -0
|
@@ -62,11 +62,11 @@ export const CATALOG: readonly CatalogRow[] = [
|
|
|
62
62
|
name: 'Spring Boot',
|
|
63
63
|
language: 'Java',
|
|
64
64
|
markers: 'pom.xml / build.gradle names spring-boot',
|
|
65
|
-
relations: ['routes-to', 'persists', 'relates', 'injects'],
|
|
65
|
+
relations: ['routes-to', 'persists', 'relates', 'injects', 'renders'],
|
|
66
66
|
status: 'partial',
|
|
67
67
|
fixture: 'tests/fixtures/code-graph/spring',
|
|
68
68
|
usage: SO,
|
|
69
|
-
limits: '@Entity(name) is not read; a method @RequestMapping without method= is skipped; Kotlin is not covered.',
|
|
69
|
+
limits: '@Entity(name) is not read; a method @RequestMapping without method= is skipped; a view name built at runtime or held outside the file is not followed; Kotlin is not covered.',
|
|
70
70
|
},
|
|
71
71
|
{
|
|
72
72
|
id: 'go-http',
|
|
@@ -132,7 +132,7 @@ export const CATALOG: readonly CatalogRow[] = [
|
|
|
132
132
|
status: 'partial',
|
|
133
133
|
fixture: 'tests/fixtures/code-graph/django',
|
|
134
134
|
usage: SO,
|
|
135
|
-
limits: 'include()
|
|
135
|
+
limits: 'Class-based view template_name and include() of an attribute (admin.site.urls) are not followed.',
|
|
136
136
|
},
|
|
137
137
|
{
|
|
138
138
|
id: 'sqlalchemy',
|
|
@@ -244,10 +244,19 @@ export const CATALOG: readonly CatalogRow[] = [
|
|
|
244
244
|
usage: SO,
|
|
245
245
|
limits: 'MapControllerRoute tables, a view name held in a variable and primary constructors are not followed.',
|
|
246
246
|
},
|
|
247
|
+
{
|
|
248
|
+
id: 'rails',
|
|
249
|
+
name: 'Ruby on Rails',
|
|
250
|
+
language: 'Ruby',
|
|
251
|
+
markers: 'a Gemfile declaring the rails gem',
|
|
252
|
+
relations: ['routes-to', 'persists', 'relates', 'migrates', 'renders'],
|
|
253
|
+
status: 'partial',
|
|
254
|
+
fixture: 'tests/fixtures/code-graph/rails',
|
|
255
|
+
usage: SO,
|
|
256
|
+
limits: 'resource (singular), nested resources, scope/concerns, an explicit render, config/initializers/inflections and models outside app/models are not followed.',
|
|
257
|
+
},
|
|
247
258
|
...(
|
|
248
259
|
[
|
|
249
|
-
['ruby', 'Ruby', 'Ruby'],
|
|
250
|
-
['rails', 'Ruby on Rails', 'Ruby'],
|
|
251
260
|
['kotlin', 'Kotlin', 'Kotlin'],
|
|
252
261
|
['rust', 'Rust', 'Rust'],
|
|
253
262
|
['c-cpp', 'C / C++', 'C / C++'],
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
*
|
|
10
10
|
* A view is resolved the way the urls file names it: a same-file or imported
|
|
11
11
|
* symbol, or `module.member` through a module the file imports. `include(...)`
|
|
12
|
-
*
|
|
12
|
+
* of a dotted string, a `(module, namespace)` tuple or an imported module is
|
|
13
|
+
* followed: an included module's routes carry every prefix chain that reaches
|
|
14
|
+
* it from a URLconf nothing includes, and appear under no other path. An
|
|
13
15
|
* inline lambda gets a route with no target. A template found in two template
|
|
14
16
|
* folders, or built at runtime, draws nothing. The app label is the model file's
|
|
15
17
|
* directory (its parent for a `models/` package) unless `Meta.app_label` says
|
|
@@ -21,7 +23,7 @@ import type { CodeNode } from '../types.js';
|
|
|
21
23
|
import { markerRoots, pythonRoots, union } from './shared/detect.js';
|
|
22
24
|
import { joinRoute, templateRoots } from './shared/extractors.js';
|
|
23
25
|
import { lineIndex, makeKit } from './shared/kit.js';
|
|
24
|
-
import { balanced, classBases, importedModules, kwargString, spanText, splitArgs, stringLiteral } from './shared/python.js';
|
|
26
|
+
import { balanced, classBases, importedModules, kwargString, resolveModule, spanText, splitArgs, stringLiteral } from './shared/python.js';
|
|
25
27
|
import type { ConventionProvider } from './types.js';
|
|
26
28
|
|
|
27
29
|
const RELATION = /\b(?:models\.)?(?:ForeignKey|ManyToManyField|OneToOneField)\s*\(/g;
|
|
@@ -47,8 +49,14 @@ export const djangoProvider: ConventionProvider = {
|
|
|
47
49
|
const inventory = new Set(kit.paths);
|
|
48
50
|
const py = kit.filesWithExt('.py');
|
|
49
51
|
|
|
50
|
-
// Routes.
|
|
51
|
-
|
|
52
|
+
// Routes. Every URLconf is read once; `include()` links a parent to the
|
|
53
|
+
// module it names, and each route is emitted once per prefix chain that
|
|
54
|
+
// reaches its file from a URLconf nothing includes.
|
|
55
|
+
interface UrlRoute { path: string; view: string; line: number }
|
|
56
|
+
interface UrlConf { file: string; routes: UrlRoute[]; includes: Array<{ path: string; child: string }> }
|
|
57
|
+
const confs = new Map<string, UrlConf & { resolve: (expr: string) => string | null }>();
|
|
58
|
+
const pattern = (literal: string, re: boolean): string => (re ? literal.replace(/^\^/, '').replace(/\$$/, '') : literal);
|
|
59
|
+
for (const f of py.filter((x) => path.posix.basename(x.path) === 'urls.py' || /^urlpatterns\s*[:=]/m.test(x.source))) {
|
|
52
60
|
const at = lineIndex(f.source);
|
|
53
61
|
const modules = importedModules(f.path, f.source, kit.roots, inventory);
|
|
54
62
|
const resolve = (expr: string): string | null => {
|
|
@@ -62,15 +70,50 @@ export const djangoProvider: ConventionProvider = {
|
|
|
62
70
|
const cls = kit.resolveName(f.path, head);
|
|
63
71
|
return cls ? kit.methodOf(cls, member) : null;
|
|
64
72
|
};
|
|
73
|
+
/** The module an `include(...)` argument names: `"a.urls"`, `("a.urls", "ns")`, or an imported module. */
|
|
74
|
+
const includeTarget = (view: string): string | null => {
|
|
75
|
+
const open = view.indexOf('(');
|
|
76
|
+
let first = splitArgs(balanced(view, open))[0] ?? '';
|
|
77
|
+
if (first.startsWith('(') || first.startsWith('[')) first = splitArgs(balanced(first, 0))[0] ?? '';
|
|
78
|
+
const dotted = stringLiteral(first);
|
|
79
|
+
if (dotted !== null) return resolveModule(f.path, dotted, kit.roots, inventory);
|
|
80
|
+
return /^\w+$/.test(first) ? (modules.get(first) ?? null) : null;
|
|
81
|
+
};
|
|
82
|
+
const conf: UrlConf & { resolve: typeof resolve } = { file: f.path, routes: [], includes: [], resolve };
|
|
65
83
|
for (const m of f.source.matchAll(/\b(path|re_path)\s*\(/g)) {
|
|
66
84
|
const args = splitArgs(balanced(f.source, (m.index as number) + m[0].length - 1));
|
|
67
85
|
const literal = stringLiteral(args[0] ?? '');
|
|
68
86
|
const view = args[1];
|
|
69
|
-
if (literal === null || view === undefined
|
|
70
|
-
const routePath = m[1] === 're_path'
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
87
|
+
if (literal === null || view === undefined) continue;
|
|
88
|
+
const routePath = pattern(literal, m[1] === 're_path');
|
|
89
|
+
if (/^include\s*\(/.test(view)) {
|
|
90
|
+
const child = includeTarget(view);
|
|
91
|
+
if (child) conf.includes.push({ path: routePath, child });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
conf.routes.push({ path: routePath, view, line: at(m.index as number) });
|
|
95
|
+
}
|
|
96
|
+
confs.set(f.path, conf);
|
|
97
|
+
}
|
|
98
|
+
const included = new Set([...confs.values()].flatMap((c) => c.includes.map((i) => i.child)));
|
|
99
|
+
const prefixes = new Map<string, Set<string>>();
|
|
100
|
+
const walk = (file: string, prefix: string, stack: readonly string[]): void => {
|
|
101
|
+
const conf = confs.get(file);
|
|
102
|
+
if (!conf || stack.includes(file)) return;
|
|
103
|
+
const seen = prefixes.get(file) ?? new Set<string>();
|
|
104
|
+
if (seen.has(prefix)) return;
|
|
105
|
+
seen.add(prefix);
|
|
106
|
+
prefixes.set(file, seen);
|
|
107
|
+
for (const inc of conf.includes) walk(inc.child, joinRoute(prefix, inc.path), [...stack, file]);
|
|
108
|
+
};
|
|
109
|
+
for (const file of confs.keys()) if (!included.has(file)) walk(file, '', []);
|
|
110
|
+
for (const conf of confs.values()) {
|
|
111
|
+
for (const prefix of prefixes.get(conf.file) ?? []) {
|
|
112
|
+
for (const r of conf.routes) {
|
|
113
|
+
const id = kit.route(conf.file, 'ANY', joinRoute(prefix, r.path), r.line);
|
|
114
|
+
const target = conf.resolve(r.view);
|
|
115
|
+
if (target) kit.emit(id, target, 'routes-to', 'route-table');
|
|
116
|
+
}
|
|
74
117
|
}
|
|
75
118
|
}
|
|
76
119
|
|
|
@@ -19,6 +19,7 @@ import { nuxtProvider } from './nuxt.js';
|
|
|
19
19
|
import { reactRouterProvider } from './react-router.js';
|
|
20
20
|
import { springProvider } from './spring.js';
|
|
21
21
|
import { sqlalchemyProvider } from './sqlalchemy.js';
|
|
22
|
+
import { railsProvider } from './rails.js';
|
|
22
23
|
import { sveltekitProvider } from './sveltekit.js';
|
|
23
24
|
import { symfonyProvider } from './symfony.js';
|
|
24
25
|
import { typeormProvider } from './typeorm.js';
|
|
@@ -51,6 +52,7 @@ export const PROVIDERS: readonly ConventionProvider[] = [
|
|
|
51
52
|
sveltekitProvider,
|
|
52
53
|
typeormProvider,
|
|
53
54
|
aspnetcoreProvider,
|
|
55
|
+
railsProvider,
|
|
54
56
|
];
|
|
55
57
|
|
|
56
58
|
/** Every marker basename a registered provider asks the probe to record. */
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ruby on Rails: the links a Rails application makes by convention.
|
|
3
|
+
*
|
|
4
|
+
* config/routes.rb get '/x', to: 'users#index' · post 'x' => 'users#create'
|
|
5
|
+
* · root 'pages#home' · resources :users, only:/except:
|
|
6
|
+
* · namespace :admin do … end route --routes-to--> the action
|
|
7
|
+
* app/models class User < ApplicationRecord class --persists--> table:users
|
|
8
|
+
* self.table_name = "t" wins; a subclass of a model shares its table (STI)
|
|
9
|
+
* app/models has_many / has_one / belongs_to (class_name: wins) class --relates--> model
|
|
10
|
+
* db/migrate create_table :t file --migrates--> table:t
|
|
11
|
+
* a controller action app/views/<controller>/<action>.* action --renders--> the view
|
|
12
|
+
*
|
|
13
|
+
* `resources` expands into the seven REST actions the controller actually
|
|
14
|
+
* defines, within `only:` / `except:`; an explicit route keeps its node when
|
|
15
|
+
* the action is missing. The table name is the class in snake_case, pluralised
|
|
16
|
+
* by the common English rules plus Rails' irregulars; an `inflections` file is
|
|
17
|
+
* not read. Not followed: `resource` (singular), nested `resources` blocks,
|
|
18
|
+
* `scope` / `constraints`, `concerns`, an explicit `render`, and a model
|
|
19
|
+
* outside `app/models`.
|
|
20
|
+
*/
|
|
21
|
+
import * as path from 'node:path';
|
|
22
|
+
|
|
23
|
+
import type { CodeNode } from '../types.js';
|
|
24
|
+
import { gemRoots } from './shared/detect.js';
|
|
25
|
+
import { joinRoute } from './shared/extractors.js';
|
|
26
|
+
import { type Kit, lineIndex, makeKit } from './shared/kit.js';
|
|
27
|
+
import type { ConventionProvider } from './types.js';
|
|
28
|
+
|
|
29
|
+
const REST: ReadonlyArray<[action: string, verb: string, suffix: string]> = [
|
|
30
|
+
['index', 'GET', ''],
|
|
31
|
+
['new', 'GET', 'new'],
|
|
32
|
+
['create', 'POST', ''],
|
|
33
|
+
['show', 'GET', ':id'],
|
|
34
|
+
['edit', 'GET', ':id/edit'],
|
|
35
|
+
['update', 'PATCH', ':id'],
|
|
36
|
+
['destroy', 'DELETE', ':id'],
|
|
37
|
+
];
|
|
38
|
+
const IRREGULAR: Readonly<Record<string, string>> = { person: 'people', man: 'men', woman: 'women', child: 'children', mouse: 'mice' };
|
|
39
|
+
const VERB_ROUTE = /^\s*(get|post|put|patch|delete)\s+['"]([^'"]*)['"]\s*(?:,\s*to:\s*|=>\s*)['"](\w[\w/]*)#(\w+)['"]/;
|
|
40
|
+
const ROOT_ROUTE = /^\s*root\s+(?:to:\s*)?['"](\w[\w/]*)#(\w+)['"]/;
|
|
41
|
+
const RESOURCES = /^\s*resources\s+:(\w+)(.*)$/;
|
|
42
|
+
const NAMESPACE = /^\s*namespace\s+:(\w+)\s+do\b/;
|
|
43
|
+
const OPENS_BLOCK = /\bdo(\s*\|[^|]*\|)?\s*$/;
|
|
44
|
+
|
|
45
|
+
function snake(name: string): string {
|
|
46
|
+
return name.replace(/([a-z\d])([A-Z])/g, '$1_$2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2').toLowerCase();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pluralize(word: string): string {
|
|
50
|
+
const head = word.slice(0, word.lastIndexOf('_') + 1);
|
|
51
|
+
const last = word.slice(head.length);
|
|
52
|
+
if (IRREGULAR[last]) return head + IRREGULAR[last];
|
|
53
|
+
if (/[^aeiou]y$/.test(last)) return head + last.slice(0, -1) + 'ies';
|
|
54
|
+
if (/(s|x|z|ch|sh)$/.test(last)) return head + last + 'es';
|
|
55
|
+
return head + last + 's';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function singularize(word: string): string {
|
|
59
|
+
for (const [one, many] of Object.entries(IRREGULAR)) if (word === many) return one;
|
|
60
|
+
if (/ies$/.test(word)) return word.slice(0, -3) + 'y';
|
|
61
|
+
if (/(s|x|z|ch|sh)es$/.test(word)) return word.slice(0, -2);
|
|
62
|
+
return word.endsWith('s') ? word.slice(0, -1) : word;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function camel(snakeName: string): string {
|
|
66
|
+
return snakeName.replace(/(^|_)([a-z\d])/g, (_m, _s, c: string) => c.toUpperCase());
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The action method of the controller `ctrl` (`admin/reports`) under `root`, if the controller defines it. */
|
|
70
|
+
function action(kit: Kit, root: string, ctrl: string, name: string): CodeNode | null {
|
|
71
|
+
const file = path.posix.join(root, 'app/controllers', `${ctrl}_controller.rb`);
|
|
72
|
+
return kit.nodesInFile(file).find((n) => n.kind === 'method' && n.label === name) ?? null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function routes(kit: Kit, root: string): void {
|
|
76
|
+
const file = path.posix.join(root, 'config/routes.rb');
|
|
77
|
+
const src = kit.files.find((f) => f.path === file)?.source;
|
|
78
|
+
if (src === undefined) return;
|
|
79
|
+
// One frame per open `do` block: a namespace adds a path and a controller
|
|
80
|
+
// directory; any other block only keeps `end` balanced.
|
|
81
|
+
const frames: Array<string | null> = [];
|
|
82
|
+
const ns = (): string[] => frames.filter((f): f is string => f !== null);
|
|
83
|
+
const declare = (verb: string, routePath: string, ctrl: string, act: string, line: number, keepWithoutAction: boolean): void => {
|
|
84
|
+
const target = action(kit, root, [...ns(), ctrl].join('/'), act);
|
|
85
|
+
if (!target && !keepWithoutAction) return;
|
|
86
|
+
const id = kit.route(file, verb, joinRoute(...ns(), routePath), line);
|
|
87
|
+
if (target) kit.emit(id, target.id, 'routes-to', 'route-table');
|
|
88
|
+
};
|
|
89
|
+
src.split('\n').forEach((raw, i) => {
|
|
90
|
+
const line = i + 1;
|
|
91
|
+
// Whole-line comments only: `#` is also the controller#action separator.
|
|
92
|
+
if (/^\s*#/.test(raw)) return;
|
|
93
|
+
const text = raw;
|
|
94
|
+
if (/^\s*end\b/.test(text)) {
|
|
95
|
+
frames.pop();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
let m = NAMESPACE.exec(text);
|
|
99
|
+
if (m) {
|
|
100
|
+
frames.push(m[1] as string);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if ((m = ROOT_ROUTE.exec(text))) declare('GET', '/', m[1] as string, m[2] as string, line, true);
|
|
104
|
+
else if ((m = VERB_ROUTE.exec(text))) declare((m[1] as string).toUpperCase(), m[2] as string, m[3] as string, m[4] as string, line, true);
|
|
105
|
+
else if ((m = RESOURCES.exec(text))) {
|
|
106
|
+
const name = m[1] as string;
|
|
107
|
+
const opts = m[2] as string;
|
|
108
|
+
const list = (key: string): string[] | null => {
|
|
109
|
+
const hit = new RegExp(`\\b${key}:\\s*\\[([^\\]]*)\\]`).exec(opts) ?? new RegExp(`\\b${key}:\\s*:(\\w+)`).exec(opts);
|
|
110
|
+
return hit ? [...(hit[1] as string).matchAll(/:?(\w+)/g)].map((x) => x[1] as string) : null;
|
|
111
|
+
};
|
|
112
|
+
const only = list('only');
|
|
113
|
+
const except = list('except') ?? [];
|
|
114
|
+
for (const [act, verb, suffix] of REST) {
|
|
115
|
+
if ((only && !only.includes(act)) || except.includes(act)) continue;
|
|
116
|
+
declare(verb, joinRoute(name, suffix), name, act, line, false);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (OPENS_BLOCK.test(text)) frames.push(null);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function models(kit: Kit, root: string): void {
|
|
124
|
+
const dir = path.posix.join(root, 'app/models') + '/';
|
|
125
|
+
const files = kit.filesWithExt('.rb').filter((f) => f.path.startsWith(dir));
|
|
126
|
+
const classes = files.flatMap((f) =>
|
|
127
|
+
kit.nodesInFile(f.path).filter((n): n is CodeNode => n.kind === 'class').map((node) => {
|
|
128
|
+
const head = f.source.split('\n')[(node.source_location[0] as number) - 1] ?? '';
|
|
129
|
+
const parent = /<\s*([\w:]+)/.exec(head)?.[1]?.split('::').pop() ?? '';
|
|
130
|
+
const span = f.source.split('\n').slice((node.source_location[0] as number) - 1, node.source_location[2] as number).join('\n');
|
|
131
|
+
return { node, file: f, parent, span };
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
const byName = new Map(classes.map((c) => [c.node.label, c] as const));
|
|
135
|
+
const tableOf = new Map<string, string | null>();
|
|
136
|
+
const resolveTable = (name: string, seen: Set<string> = new Set()): string | null => {
|
|
137
|
+
if (tableOf.has(name)) return tableOf.get(name) as string | null;
|
|
138
|
+
const c = byName.get(name);
|
|
139
|
+
if (!c || seen.has(name)) return null;
|
|
140
|
+
seen.add(name);
|
|
141
|
+
const explicit = /self\.table_name\s*=\s*['"]([^'"]+)['"]/.exec(c.span)?.[1];
|
|
142
|
+
const abstract = /self\.abstract_class\s*=\s*true/.test(c.span);
|
|
143
|
+
let table: string | null = null;
|
|
144
|
+
if (explicit) table = explicit;
|
|
145
|
+
else if (abstract) table = null;
|
|
146
|
+
else if (c.parent === 'ApplicationRecord' || c.parent === 'Base') table = pluralize(snake(name));
|
|
147
|
+
else if (byName.has(c.parent)) table = resolveTable(c.parent, seen);
|
|
148
|
+
tableOf.set(name, table);
|
|
149
|
+
return table;
|
|
150
|
+
};
|
|
151
|
+
for (const c of classes) {
|
|
152
|
+
const table = resolveTable(c.node.label);
|
|
153
|
+
if (table) kit.emit(c.node.id, kit.table(table, c.file.path, c.node.source_location[0] as number), 'persists', 'schema-call');
|
|
154
|
+
for (const m of c.span.matchAll(/^\s*(has_many|has_one|belongs_to|has_and_belongs_to_many)\s+:(\w+)([^\n]*)/gm)) {
|
|
155
|
+
const named = /class_name:\s*['"]([\w:]+)['"]/.exec(m[3] as string)?.[1]?.split('::').pop();
|
|
156
|
+
const assoc = m[2] as string;
|
|
157
|
+
const target = byName.get(named ?? camel(m[1] === 'has_many' || m[1] === 'has_and_belongs_to_many' ? singularize(assoc) : assoc));
|
|
158
|
+
if (target && target.node.id !== c.node.id) kit.emit(c.node.id, target.node.id, 'relates', 'orm-relation');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function migrations(kit: Kit, root: string): void {
|
|
164
|
+
const dir = path.posix.join(root, 'db/migrate') + '/';
|
|
165
|
+
for (const f of kit.filesWithExt('.rb').filter((x) => x.path.startsWith(dir))) {
|
|
166
|
+
const at = lineIndex(f.source);
|
|
167
|
+
for (const m of f.source.matchAll(/\bcreate_table\s+(?::(\w+)|['"]([^'"]+)['"])/g)) {
|
|
168
|
+
const table = (m[1] ?? m[2]) as string;
|
|
169
|
+
kit.emit(f.path, kit.table(table, f.path, at(m.index as number)), 'migrates', 'schema-call');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function views(kit: Kit, root: string): void {
|
|
175
|
+
const ctrlDir = path.posix.join(root, 'app/controllers') + '/';
|
|
176
|
+
const viewDir = path.posix.join(root, 'app/views');
|
|
177
|
+
for (const f of kit.filesWithExt('.rb').filter((x) => x.path.startsWith(ctrlDir) && x.path.endsWith('_controller.rb'))) {
|
|
178
|
+
const ctrl = f.path.slice(ctrlDir.length, -'_controller.rb'.length);
|
|
179
|
+
for (const n of kit.nodesInFile(f.path)) {
|
|
180
|
+
if (n.kind !== 'method') continue;
|
|
181
|
+
const prefix = `${viewDir}/${ctrl}/${n.label}.`;
|
|
182
|
+
const hits = kit.paths.filter((p) => p.startsWith(prefix) && !p.slice(prefix.length).includes('/'));
|
|
183
|
+
if (hits.length !== 1) continue;
|
|
184
|
+
const target = hits[0] as string;
|
|
185
|
+
kit.fileNode(target);
|
|
186
|
+
kit.emit(n.id, target, 'renders', 'directive-scan');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const railsProvider: ConventionProvider = {
|
|
192
|
+
id: 'rails',
|
|
193
|
+
detect: (probe) => gemRoots(probe, 'rails'),
|
|
194
|
+
build: (input) => {
|
|
195
|
+
const kit = makeKit(input);
|
|
196
|
+
for (const root of kit.roots) {
|
|
197
|
+
routes(kit, root);
|
|
198
|
+
models(kit, root);
|
|
199
|
+
migrations(kit, root);
|
|
200
|
+
views(kit, root);
|
|
201
|
+
}
|
|
202
|
+
return kit.result();
|
|
203
|
+
},
|
|
204
|
+
};
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* @Entity class, @Table(name = "t") or the default class --persists--> table:t
|
|
7
7
|
* interface R extends JpaRepository<E, Id> R --relates--> E
|
|
8
8
|
* @Autowired field · constructor parameter class --injects--> the in-repo type
|
|
9
|
+
* @Controller method `return "a/b"` · ModelAndView("a/b")
|
|
10
|
+
* · a same-file `static final String` constant method --renders--> templates/a/b.html
|
|
9
11
|
*
|
|
10
12
|
* The Java extractor spans a method or class from its first annotation, so a
|
|
11
13
|
* mapping annotation belongs to the method whose span holds it; one outside
|
|
@@ -13,7 +15,10 @@
|
|
|
13
15
|
* table is Spring Boot's default physical name: the entity class in snake_case
|
|
14
16
|
* (`OrderLine` → `order_line`); `@Entity(name = "X")` is not read. A method-level
|
|
15
17
|
* `@RequestMapping` without `method =` and a constructor of a class carrying no
|
|
16
|
-
* stereotype annotation are not followed.
|
|
18
|
+
* stereotype annotation are not followed. A view name is read only in a
|
|
19
|
+
* `@Controller` file — a `@RestController` returns response bodies — and never
|
|
20
|
+
* from a `redirect:` / `forward:`; it renders the one template of that name
|
|
21
|
+
* under the Thymeleaf, FreeMarker, Mustache or JSP folders, or nothing.
|
|
17
22
|
*/
|
|
18
23
|
import { jvmRoots } from './shared/detect.js';
|
|
19
24
|
import { joinRoute, tableMapping } from './shared/extractors.js';
|
|
@@ -119,6 +124,37 @@ function injections(kit: Kit): void {
|
|
|
119
124
|
}
|
|
120
125
|
}
|
|
121
126
|
|
|
127
|
+
const VIEW_ROOTS = ['src/main/resources/templates', 'src/main/webapp/WEB-INF/views', 'src/main/webapp/WEB-INF/jsp'];
|
|
128
|
+
const VIEW_EXTS = ['.html', '.ftlh', '.ftl', '.mustache', '.jte', '.jsp'];
|
|
129
|
+
const VIEW_NAME = /\b(?:return\s+|new\s+ModelAndView\s*\(\s*)(?:"(?<lit>[^"]*)"|(?<const>[A-Z][A-Z0-9_]*))\s*[;),]/g;
|
|
130
|
+
const VIEW_CONSTANT = /\bstatic\s+final\s+String\s+(?<name>[A-Z][A-Z0-9_]*)\s*=\s*"(?<value>[^"]*)"/g;
|
|
131
|
+
|
|
132
|
+
function views(kit: Kit): void {
|
|
133
|
+
const inventory = new Set(kit.paths);
|
|
134
|
+
for (const f of kit.filesWithExt('.java')) {
|
|
135
|
+
if (!/@Controller\b/.test(f.source)) continue;
|
|
136
|
+
const at = lineIndex(f.source);
|
|
137
|
+
const constants = new Map([...f.source.matchAll(VIEW_CONSTANT)].map((m) => [m.groups?.['name'] as string, m.groups?.['value'] as string]));
|
|
138
|
+
for (const m of f.source.matchAll(VIEW_NAME)) {
|
|
139
|
+
const name = m.groups?.['lit'] ?? constants.get(m.groups?.['const'] ?? '');
|
|
140
|
+
if (!name || /^(?:redirect|forward):/.test(name)) continue;
|
|
141
|
+
const method = methodAt(kit, f.path, at(m.index as number));
|
|
142
|
+
if (!method) continue;
|
|
143
|
+
const hits = new Set<string>();
|
|
144
|
+
for (const r of kit.roots)
|
|
145
|
+
for (const dir of VIEW_ROOTS)
|
|
146
|
+
for (const ext of VIEW_EXTS) {
|
|
147
|
+
const c = [r, dir, `${name.replace(/^\/+/, '')}${ext}`].filter(Boolean).join('/');
|
|
148
|
+
if (inventory.has(c)) hits.add(c);
|
|
149
|
+
}
|
|
150
|
+
if (hits.size !== 1) continue;
|
|
151
|
+
const target = [...hits][0] as string;
|
|
152
|
+
kit.fileNode(target);
|
|
153
|
+
kit.emit(method.id, target, 'renders', 'directive-scan');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
122
158
|
export const springProvider: ConventionProvider = {
|
|
123
159
|
id: 'spring',
|
|
124
160
|
detect: (probe) => jvmRoots(probe, /spring-boot|org\.springframework\.boot/),
|
|
@@ -133,6 +169,7 @@ export const springProvider: ConventionProvider = {
|
|
|
133
169
|
});
|
|
134
170
|
repositories(kit);
|
|
135
171
|
injections(kit);
|
|
172
|
+
views(kit);
|
|
136
173
|
return kit.result();
|
|
137
174
|
},
|
|
138
175
|
};
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
emitSqliteTwin,
|
|
14
14
|
type GraphIndex,
|
|
15
15
|
INDEXED_READ_MIN_EDGES,
|
|
16
|
-
loadSerializedFromTwin,
|
|
17
16
|
openGraphIndex,
|
|
18
17
|
} from './sqlite_store.js';
|
|
19
18
|
import type { CodeEdge, CodeGraph, CodeNode, Relation } from './types.js';
|
|
@@ -132,6 +131,9 @@ export function loadGraph(graphPath: string, source = graphPath): LoadedGraph {
|
|
|
132
131
|
// used to be preferred and still returned the entire serialization, which
|
|
133
132
|
// made it a cheaper blob transport rather than an index.
|
|
134
133
|
const index = openGraphIndex(graphPath);
|
|
134
|
+
// `openGraphIndex` declines on exactly the conditions under which the twin
|
|
135
|
+
// is stale, absent or corrupt — so a handle here means it needs no re-emit.
|
|
136
|
+
const twinCurrent = index !== null;
|
|
135
137
|
if (index) {
|
|
136
138
|
if (index.edgeCount >= INDEXED_READ_MIN_EDGES) return indexBackedGraph(index, source, graphPath);
|
|
137
139
|
// Small graph: the in-memory path is cheaper. Release the handle rather
|
|
@@ -139,12 +141,10 @@ export function loadGraph(graphPath: string, source = graphPath): LoadedGraph {
|
|
|
139
141
|
index.close();
|
|
140
142
|
}
|
|
141
143
|
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
const fromTwin = loadSerializedFromTwin(graphPath);
|
|
147
|
-
const raw = fromTwin ?? fs.readFileSync(graphPath, 'utf-8');
|
|
144
|
+
// Below the threshold the JSON is parsed. The twin no longer carries a
|
|
145
|
+
// second copy of it to read instead; a missing or stale twin is re-emitted
|
|
146
|
+
// below (zero-touch rebuild-on-drift, ADR-129 Phase 7).
|
|
147
|
+
const raw = fs.readFileSync(graphPath, 'utf-8');
|
|
148
148
|
const parsed = JSON.parse(raw) as unknown;
|
|
149
149
|
// A consumer's own graph file, written by another tool, is DETECTED by
|
|
150
150
|
// `detect.ts` and used to throw here — detected-but-unloadable, the worst
|
|
@@ -166,8 +166,8 @@ export function loadGraph(graphPath: string, source = graphPath): LoadedGraph {
|
|
|
166
166
|
// The twin is a cache of a file's bytes keyed on its own checksum. An
|
|
167
167
|
// adapted graph's bytes are not the file's, so emitting one would cache a
|
|
168
168
|
// derivation under the source's identity.
|
|
169
|
-
if (
|
|
170
|
-
emitSqliteTwin(graph,
|
|
169
|
+
if (!twinCurrent && !foreignAdapted) {
|
|
170
|
+
emitSqliteTwin(graph, graphPath);
|
|
171
171
|
}
|
|
172
172
|
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
173
173
|
const outM = new Map<string, CodeEdge[]>();
|
|
@@ -20,9 +20,8 @@ import * as fs from 'node:fs';
|
|
|
20
20
|
import * as os from 'node:os';
|
|
21
21
|
import * as path from 'node:path';
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
export const AUTO_REFRESH_REL = path.join('agents', 'runtime', 'state', 'code-graph-auto-refresh.json');
|
|
23
|
+
import { graphHome } from './graph_home.js';
|
|
24
|
+
|
|
26
25
|
export const STALE_LOCK_MS = 5 * 60 * 1000;
|
|
27
26
|
/**
|
|
28
27
|
* How long an owner on ANOTHER host (a container sharing the checkout) is
|
|
@@ -54,12 +53,17 @@ export interface RefreshRecord {
|
|
|
54
53
|
|
|
55
54
|
/** One file per side, so the session concern writes without reading or merging anything. */
|
|
56
55
|
export const refreshRecordPath = (root: string, by: RefreshRecord['by']): string =>
|
|
57
|
-
path.join(
|
|
56
|
+
path.join(path.dirname(graphHome(root).lockPath), `code-graph-refresh.${by}.json`);
|
|
58
57
|
|
|
59
58
|
/** The last outcome of one side, so a refresh that never happens is visible. Best-effort. */
|
|
60
59
|
export function recordRefresh(root: string, rec: Omit<RefreshRecord, 'at'>): void {
|
|
60
|
+
recordRefreshIn(path.dirname(graphHome(root).lockPath), rec);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** For the session concern: its chunk must not carry the user-global resolver. */
|
|
64
|
+
export function recordRefreshIn(stateDir: string, rec: Omit<RefreshRecord, 'at'>): void {
|
|
61
65
|
try {
|
|
62
|
-
fs.writeFileSync(
|
|
66
|
+
fs.writeFileSync(path.join(stateDir, `code-graph-refresh.${rec.by}.json`), JSON.stringify({ at: new Date().toISOString(), ...rec }));
|
|
63
67
|
} catch {
|
|
64
68
|
/* never fails a refresh */
|
|
65
69
|
}
|
|
@@ -77,7 +81,7 @@ export function readRefreshRecords(root: string): Partial<Record<RefreshRecord['
|
|
|
77
81
|
return out;
|
|
78
82
|
}
|
|
79
83
|
|
|
80
|
-
function pidAlive(pid: number): boolean {
|
|
84
|
+
export function pidAlive(pid: number): boolean {
|
|
81
85
|
try {
|
|
82
86
|
process.kill(pid, 0);
|
|
83
87
|
return true;
|
|
@@ -143,7 +147,7 @@ export const reclaimMutexPath = (lock: string): string => `${lock}.reclaim`;
|
|
|
143
147
|
|
|
144
148
|
/** True when this process holds the lock (the CLI took it before calling in). */
|
|
145
149
|
export function heldByThisProcess(root: string): boolean {
|
|
146
|
-
const owner = readOwner(
|
|
150
|
+
const owner = readOwner(graphHome(root).lockPath);
|
|
147
151
|
return typeof owner !== 'string' && owner.pid === process.pid;
|
|
148
152
|
}
|
|
149
153
|
|
|
@@ -162,7 +166,7 @@ export function heldByThisProcess(root: string): boolean {
|
|
|
162
166
|
* `afterStaleCheck` exists for the interleaving test only.
|
|
163
167
|
*/
|
|
164
168
|
export function acquire(root: string, afterStaleCheck?: () => void): (() => void) | null {
|
|
165
|
-
const lock =
|
|
169
|
+
const lock = graphHome(root).lockPath;
|
|
166
170
|
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
|
167
171
|
try {
|
|
168
172
|
fs.mkdirSync(lock);
|