@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.
Files changed (137) 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 +164 -342
  5. package/CREDITS.md +2 -2
  6. package/README.md +1 -1
  7. package/dist/agent-src/skills/code-intelligence/SKILL.md +3 -0
  8. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  9. package/dist/cli/registry.js +2 -2
  10. package/dist/cli-delegate/{chunk-DHSSP7V3.js → chunk-5AYJGUA3.js} +0 -2
  11. package/dist/cli-delegate/chunk-DIFZBP4G.js +1336 -0
  12. package/dist/cli-delegate/{chunk-MT2WOOCY.js → chunk-WY5ZZPGL.js} +2 -2
  13. package/dist/cli-delegate/{chunk-ORJPBER2.js → chunk-X6LZ7W5A.js} +73 -10
  14. package/dist/cli-delegate/cmd_conformance.js +175 -183
  15. package/dist/cli-delegate/cmd_doctor.js +5 -5
  16. package/dist/cli-delegate/cmd_doctor_neighbours.js +3 -3
  17. package/dist/cli-delegate/cmd_explain.js +1 -1
  18. package/dist/cli-delegate/cmd_handoff.js +2 -2
  19. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  20. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  21. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  22. package/dist/cli-delegate/cmd_update.js +24 -13
  23. package/dist/cli-delegate/cmd_upgrade.js +2 -2
  24. package/dist/cli-delegate/dist-25AZAHOP.js +3240 -0
  25. package/dist/cli-delegate/dist-P7MFPMF3.js +411 -0
  26. package/dist/cli-delegate/dist-XAS5ZLQB.js +6 -0
  27. package/dist/cli-delegate/{upgrade_unattended-VVL6JLSX.js → upgrade_unattended-QCWOKTUN.js} +178 -163
  28. package/dist/discovery/deprecation-report.md +1 -1
  29. package/dist/discovery/discovery-manifest.json +3 -3
  30. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  31. package/dist/discovery/discovery-manifest.summary.md +1 -1
  32. package/dist/discovery/dormancy-report.md +1 -1
  33. package/dist/discovery/orphan-report.md +1 -1
  34. package/dist/discovery/packs.json +2 -2
  35. package/dist/discovery/trust-report.md +1 -1
  36. package/dist/discovery/workspaces.json +2 -2
  37. package/dist/hooks/code_graph_refresh.chunk.cjs +56 -27
  38. package/dist/hooks/code_graph_refresh.chunk.cjs.sha256 +1 -1
  39. package/dist/hooks/dispatch.js +982 -929
  40. package/dist/hooks/dispatch.sha256 +1 -1
  41. package/dist/hooks/egress_engine.chunk.cjs +56 -6
  42. package/dist/hooks/egress_engine.chunk.cjs.sha256 +1 -1
  43. package/dist/hooks/update_notice.chunk.cjs +914 -0
  44. package/dist/hooks/update_notice.chunk.cjs.sha256 +1 -0
  45. package/dist/mcp/registry-manifest.json +1 -1
  46. package/dist/mcp/server.json +2 -2
  47. package/dist/mcp/server.mjs +71259 -66437
  48. package/dist/scripts/condense.js +9 -6
  49. package/package.json +4 -1
  50. package/src/config/evaluator-budgets.json +9 -8
  51. package/src/config/gate-violation-baselines.json +2 -2
  52. package/src/config/hook-bundle-budget.json +31 -6
  53. package/src/config/packed-binary-manifest.json +9 -0
  54. package/src/config/sigstore/npm_keys.json +50 -0
  55. package/src/config/sigstore/trust-material.json +9 -0
  56. package/src/config/sigstore/trusted_root.json +126 -0
  57. package/src/config/skill-body-budget.json +7 -1
  58. package/src/scripts/_cli/cmd_update.ts +48 -20
  59. package/src/scripts/_cli/cmd_upgrade.ts +7 -1
  60. package/src/scripts/_cli/upgrade_unattended.ts +90 -76
  61. package/src/scripts/_dispatch.bash +3 -2
  62. package/src/scripts/_lib/confusables.ts +0 -19
  63. package/src/scripts/_lib/egress_normalize.ts +53 -23
  64. package/src/scripts/_lib/egress_text.ts +5 -3
  65. package/src/scripts/_lib/egress_token.ts +7 -5
  66. package/src/scripts/_lib/gate_collector_experimental.ts +633 -0
  67. package/src/scripts/_lib/git_common_dir.ts +10 -0
  68. package/src/scripts/_lib/graph_feeder_record.ts +3 -2
  69. package/src/scripts/_lib/install_reach_checks.ts +32 -2
  70. package/src/scripts/_lib/lineage_backfill.ts +136 -0
  71. package/src/scripts/_lib/lineage_ledger.ts +211 -0
  72. package/src/scripts/_lib/lineage_map.ts +55 -0
  73. package/src/scripts/_lib/lineage_query.ts +140 -0
  74. package/src/scripts/_lib/lineage_seed.ts +116 -0
  75. package/src/scripts/_lib/lineage_watch.ts +249 -0
  76. package/src/scripts/_lib/link_crypto.ts +8 -4
  77. package/src/scripts/_lib/production_reach.ts +3 -3
  78. package/src/scripts/_lib/project_registry.ts +227 -0
  79. package/src/scripts/_lib/provenance_verify.ts +14 -242
  80. package/src/scripts/_lib/sigstore_provenance.ts +177 -0
  81. package/src/scripts/_lib/source_id.ts +48 -0
  82. package/src/scripts/_lib/source_shape.ts +4 -0
  83. package/src/scripts/_lib/unattended_gates.ts +6 -1
  84. package/src/scripts/advisories.ts +26 -10
  85. package/src/scripts/ai_council/council_egress.ts +5 -6
  86. package/src/scripts/bench_graph_feeder_latency.ts +10 -3
  87. package/src/scripts/check_dependency_floors.ts +7 -0
  88. package/src/scripts/check_egress_mutations.ts +1 -0
  89. package/src/scripts/code_graph/build.ts +67 -3
  90. package/src/scripts/code_graph/cli.ts +131 -28
  91. package/src/scripts/code_graph/conventions_default.ts +20 -1
  92. package/src/scripts/code_graph/detect.ts +5 -5
  93. package/src/scripts/code_graph/estate_cli.ts +135 -0
  94. package/src/scripts/code_graph/estate_index.ts +394 -0
  95. package/src/scripts/code_graph/estate_manifests.ts +223 -0
  96. package/src/scripts/code_graph/estate_map.ts +141 -0
  97. package/src/scripts/code_graph/extract.ts +4 -1
  98. package/src/scripts/code_graph/extract_more.ts +137 -2
  99. package/src/scripts/code_graph/graph_budget.ts +158 -0
  100. package/src/scripts/code_graph/graph_home.ts +80 -0
  101. package/src/scripts/code_graph/lineage_cli.ts +315 -0
  102. package/src/scripts/code_graph/map.ts +22 -13
  103. package/src/scripts/code_graph/map_model.ts +67 -12
  104. package/src/scripts/code_graph/map_verify.ts +68 -5
  105. package/src/scripts/code_graph/map_viewer/viewer.css +12 -0
  106. package/src/scripts/code_graph/map_viewer/viewer.js +45 -15
  107. package/src/scripts/code_graph/project_ref.ts +25 -0
  108. package/src/scripts/code_graph/projects_cli.ts +254 -0
  109. package/src/scripts/code_graph/providers/catalog.ts +14 -5
  110. package/src/scripts/code_graph/providers/django.ts +52 -9
  111. package/src/scripts/code_graph/providers/index.ts +2 -0
  112. package/src/scripts/code_graph/providers/rails.ts +204 -0
  113. package/src/scripts/code_graph/providers/spring.ts +38 -1
  114. package/src/scripts/code_graph/query.ts +9 -9
  115. package/src/scripts/code_graph/refresh_lock.ts +12 -8
  116. package/src/scripts/code_graph/repo_identity.ts +169 -0
  117. package/src/scripts/code_graph/sqlite_store.ts +22 -25
  118. package/src/scripts/code_graph/types.ts +6 -2
  119. package/src/scripts/condense.ts +6 -6
  120. package/src/scripts/hook_chunks.mjs +7 -0
  121. package/src/scripts/hook_manifest.json +1 -1
  122. package/src/scripts/hook_manifest.yaml +20 -17
  123. package/src/scripts/hooks/code_graph_refresh_hook.ts +10 -7
  124. package/src/scripts/hooks/dispatch_hook.ts +6 -7
  125. package/src/scripts/hooks/host_semantics.ts +72 -0
  126. package/src/scripts/hooks/lazy_concern.ts +4 -0
  127. package/src/scripts/hooks/user_notice.ts +105 -0
  128. package/src/scripts/lint_harvest_provenance.ts +137 -11
  129. package/src/scripts/mcp_server/consumer_tool_catalog.json +69 -15
  130. package/src/scripts/mcp_server/graph_tools.ts +161 -41
  131. package/src/scripts/network_sources.ts +24 -6
  132. package/src/scripts/schemas/provenance-mechanism.schema.json +46 -0
  133. package/src/scripts/update_notice_hook.ts +75 -7
  134. package/src/vendor/grammars/README.md +7 -6
  135. package/src/vendor/grammars/tree-sitter-ruby.wasm +0 -0
  136. /package/dist/cli-delegate/{chunk-FKYZOWVD.js → chunk-QBAFP67O.js} +0 -0
  137. /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() prefixes and class-based view template_name are not followed.',
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
- * is not followed, so routes of an included urls module carry no prefix; an
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
- for (const f of py.filter((x) => path.posix.basename(x.path) === 'urls.py')) {
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 || /^include\s*\(/.test(view)) continue;
70
- const routePath = m[1] === 're_path' ? literal.replace(/^\^/, '').replace(/\$$/, '') : literal;
71
- const id = kit.route(f.path, 'ANY', joinRoute(routePath), at(m.index as number));
72
- const target = resolve(view);
73
- if (target) kit.emit(id, target, 'routes-to', 'route-table');
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
- // Prefer the derived SQLite twin (ADR-129): checksum/stat-verified against
143
- // the canonical JSON, byte-identical content, ~1/90 the load cost at
144
- // consumer scale. Fallback = parse the JSON — and best-effort re-emit the
145
- // twin from it (zero-touch rebuild-on-drift, Phase 7).
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 (fromTwin === null && !foreignAdapted) {
170
- emitSqliteTwin(graph, raw, graphPath);
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
- export const LOCK_REL = path.join('agents', 'runtime', 'state', 'code-graph-refresh.lock');
24
- /** Written by `code-graph auto-refresh on`; the session concern acts only while it exists. */
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(root, path.dirname(LOCK_REL), `code-graph-refresh.${by}.json`);
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(refreshRecordPath(root, rec.by), JSON.stringify({ at: new Date().toISOString(), ...rec }));
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(path.join(root, LOCK_REL));
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 = path.join(root, LOCK_REL);
169
+ const lock = graphHome(root).lockPath;
166
170
  fs.mkdirSync(path.dirname(lock), { recursive: true });
167
171
  try {
168
172
  fs.mkdirSync(lock);