gitnexus 1.6.0 → 1.6.2-rc.1

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 (145) hide show
  1. package/README.md +73 -0
  2. package/dist/cli/analyze.js +50 -3
  3. package/dist/core/group/extractors/fs-utils.d.ts +10 -0
  4. package/dist/core/group/extractors/fs-utils.js +24 -0
  5. package/dist/core/group/extractors/grpc-extractor.d.ts +17 -8
  6. package/dist/core/group/extractors/grpc-extractor.js +328 -191
  7. package/dist/core/group/extractors/grpc-patterns/go.d.ts +2 -0
  8. package/dist/core/group/extractors/grpc-patterns/go.js +97 -0
  9. package/dist/core/group/extractors/grpc-patterns/index.d.ts +19 -0
  10. package/dist/core/group/extractors/grpc-patterns/index.js +46 -0
  11. package/dist/core/group/extractors/grpc-patterns/java.d.ts +2 -0
  12. package/dist/core/group/extractors/grpc-patterns/java.js +173 -0
  13. package/dist/core/group/extractors/grpc-patterns/node.d.ts +4 -0
  14. package/dist/core/group/extractors/grpc-patterns/node.js +290 -0
  15. package/dist/core/group/extractors/grpc-patterns/proto.d.ts +9 -0
  16. package/dist/core/group/extractors/grpc-patterns/proto.js +134 -0
  17. package/dist/core/group/extractors/grpc-patterns/python.d.ts +2 -0
  18. package/dist/core/group/extractors/grpc-patterns/python.js +67 -0
  19. package/dist/core/group/extractors/grpc-patterns/types.d.ts +50 -0
  20. package/dist/core/group/extractors/grpc-patterns/types.js +1 -0
  21. package/dist/core/group/extractors/http-patterns/go.d.ts +2 -0
  22. package/dist/core/group/extractors/http-patterns/go.js +215 -0
  23. package/dist/core/group/extractors/http-patterns/index.d.ts +17 -0
  24. package/dist/core/group/extractors/http-patterns/index.js +44 -0
  25. package/dist/core/group/extractors/http-patterns/java.d.ts +2 -0
  26. package/dist/core/group/extractors/http-patterns/java.js +253 -0
  27. package/dist/core/group/extractors/http-patterns/node.d.ts +4 -0
  28. package/dist/core/group/extractors/http-patterns/node.js +354 -0
  29. package/dist/core/group/extractors/http-patterns/php.d.ts +2 -0
  30. package/dist/core/group/extractors/http-patterns/php.js +70 -0
  31. package/dist/core/group/extractors/http-patterns/python.d.ts +2 -0
  32. package/dist/core/group/extractors/http-patterns/python.js +133 -0
  33. package/dist/core/group/extractors/http-patterns/types.d.ts +61 -0
  34. package/dist/core/group/extractors/http-patterns/types.js +1 -0
  35. package/dist/core/group/extractors/http-route-extractor.d.ts +10 -13
  36. package/dist/core/group/extractors/http-route-extractor.js +231 -238
  37. package/dist/core/group/extractors/manifest-extractor.d.ts +54 -0
  38. package/dist/core/group/extractors/manifest-extractor.js +277 -0
  39. package/dist/core/group/extractors/topic-extractor.d.ts +0 -1
  40. package/dist/core/group/extractors/topic-extractor.js +55 -192
  41. package/dist/core/group/extractors/topic-patterns/go.d.ts +2 -0
  42. package/dist/core/group/extractors/topic-patterns/go.js +120 -0
  43. package/dist/core/group/extractors/topic-patterns/index.d.ts +14 -0
  44. package/dist/core/group/extractors/topic-patterns/index.js +38 -0
  45. package/dist/core/group/extractors/topic-patterns/java.d.ts +2 -0
  46. package/dist/core/group/extractors/topic-patterns/java.js +80 -0
  47. package/dist/core/group/extractors/topic-patterns/node.d.ts +4 -0
  48. package/dist/core/group/extractors/topic-patterns/node.js +155 -0
  49. package/dist/core/group/extractors/topic-patterns/python.d.ts +2 -0
  50. package/dist/core/group/extractors/topic-patterns/python.js +116 -0
  51. package/dist/core/group/extractors/topic-patterns/types.d.ts +25 -0
  52. package/dist/core/group/extractors/topic-patterns/types.js +10 -0
  53. package/dist/core/group/extractors/tree-sitter-scanner.d.ts +113 -0
  54. package/dist/core/group/extractors/tree-sitter-scanner.js +94 -0
  55. package/dist/core/ingestion/binding-accumulator.d.ts +22 -17
  56. package/dist/core/ingestion/binding-accumulator.js +29 -25
  57. package/dist/core/ingestion/cobol-processor.d.ts +1 -1
  58. package/dist/core/ingestion/import-processor.js +1 -1
  59. package/dist/core/ingestion/language-config.js +1 -1
  60. package/dist/core/ingestion/language-provider.d.ts +32 -5
  61. package/dist/core/ingestion/languages/c-cpp.js +2 -2
  62. package/dist/core/ingestion/languages/dart.d.ts +1 -1
  63. package/dist/core/ingestion/languages/dart.js +2 -2
  64. package/dist/core/ingestion/languages/go.d.ts +1 -1
  65. package/dist/core/ingestion/languages/go.js +2 -2
  66. package/dist/core/ingestion/languages/ruby.js +16 -1
  67. package/dist/core/ingestion/languages/swift.d.ts +1 -1
  68. package/dist/core/ingestion/languages/swift.js +2 -2
  69. package/dist/core/ingestion/markdown-processor.d.ts +1 -1
  70. package/dist/core/ingestion/method-extractors/configs/jvm.js +1 -0
  71. package/dist/core/ingestion/method-extractors/configs/ruby.js +1 -0
  72. package/dist/core/ingestion/method-extractors/generic.d.ts +6 -0
  73. package/dist/core/ingestion/method-extractors/generic.js +48 -4
  74. package/dist/core/ingestion/method-types.d.ts +4 -0
  75. package/dist/core/ingestion/model/resolve.js +103 -48
  76. package/dist/core/ingestion/model/semantic-model.d.ts +1 -1
  77. package/dist/core/ingestion/model/semantic-model.js +1 -1
  78. package/dist/core/ingestion/model/symbol-table.d.ts +7 -7
  79. package/dist/core/ingestion/model/symbol-table.js +7 -7
  80. package/dist/core/ingestion/mro-processor.d.ts +1 -1
  81. package/dist/core/ingestion/mro-processor.js +1 -1
  82. package/dist/core/ingestion/parsing-processor.js +54 -42
  83. package/dist/core/ingestion/pipeline-phases/cobol.d.ts +16 -0
  84. package/dist/core/ingestion/pipeline-phases/cobol.js +45 -0
  85. package/dist/core/ingestion/pipeline-phases/communities.d.ts +16 -0
  86. package/dist/core/ingestion/pipeline-phases/communities.js +62 -0
  87. package/dist/core/ingestion/pipeline-phases/cross-file-impl.d.ts +17 -0
  88. package/dist/core/ingestion/pipeline-phases/cross-file-impl.js +156 -0
  89. package/dist/core/ingestion/pipeline-phases/cross-file.d.ts +37 -0
  90. package/dist/core/ingestion/pipeline-phases/cross-file.js +63 -0
  91. package/dist/core/ingestion/pipeline-phases/index.d.ts +21 -0
  92. package/dist/core/ingestion/pipeline-phases/index.js +22 -0
  93. package/dist/core/ingestion/pipeline-phases/markdown.d.ts +17 -0
  94. package/dist/core/ingestion/pipeline-phases/markdown.js +33 -0
  95. package/dist/core/ingestion/pipeline-phases/mro.d.ts +18 -0
  96. package/dist/core/ingestion/pipeline-phases/mro.js +36 -0
  97. package/dist/core/ingestion/pipeline-phases/orm-extraction.d.ts +22 -0
  98. package/dist/core/ingestion/pipeline-phases/orm-extraction.js +92 -0
  99. package/dist/core/ingestion/pipeline-phases/orm.d.ts +15 -0
  100. package/dist/core/ingestion/pipeline-phases/orm.js +74 -0
  101. package/dist/core/ingestion/pipeline-phases/parse-impl.d.ts +47 -0
  102. package/dist/core/ingestion/pipeline-phases/parse-impl.js +437 -0
  103. package/dist/core/ingestion/pipeline-phases/parse.d.ts +49 -0
  104. package/dist/core/ingestion/pipeline-phases/parse.js +33 -0
  105. package/dist/core/ingestion/pipeline-phases/processes.d.ts +16 -0
  106. package/dist/core/ingestion/pipeline-phases/processes.js +143 -0
  107. package/dist/core/ingestion/pipeline-phases/routes.d.ts +21 -0
  108. package/dist/core/ingestion/pipeline-phases/routes.js +243 -0
  109. package/dist/core/ingestion/pipeline-phases/runner.d.ts +22 -0
  110. package/dist/core/ingestion/pipeline-phases/runner.js +203 -0
  111. package/dist/core/ingestion/pipeline-phases/scan.d.ts +21 -0
  112. package/dist/core/ingestion/pipeline-phases/scan.js +46 -0
  113. package/dist/core/ingestion/pipeline-phases/structure.d.ts +27 -0
  114. package/dist/core/ingestion/pipeline-phases/structure.js +35 -0
  115. package/dist/core/ingestion/pipeline-phases/tools.d.ts +20 -0
  116. package/dist/core/ingestion/pipeline-phases/tools.js +79 -0
  117. package/dist/core/ingestion/pipeline-phases/types.d.ts +79 -0
  118. package/dist/core/ingestion/pipeline-phases/types.js +37 -0
  119. package/dist/core/ingestion/pipeline-phases/wildcard-synthesis.d.ts +70 -0
  120. package/dist/core/ingestion/pipeline-phases/wildcard-synthesis.js +312 -0
  121. package/dist/core/ingestion/pipeline.d.ts +16 -10
  122. package/dist/core/ingestion/pipeline.js +66 -1534
  123. package/dist/core/ingestion/process-processor.js +1 -1
  124. package/dist/core/ingestion/tree-sitter-queries.d.ts +2 -2
  125. package/dist/core/ingestion/tree-sitter-queries.js +69 -0
  126. package/dist/core/ingestion/utils/ast-helpers.d.ts +1 -3
  127. package/dist/core/ingestion/utils/ast-helpers.js +48 -21
  128. package/dist/core/ingestion/utils/env.d.ts +10 -0
  129. package/dist/core/ingestion/utils/env.js +10 -0
  130. package/dist/core/ingestion/utils/graph-sort.d.ts +58 -0
  131. package/dist/core/ingestion/utils/graph-sort.js +100 -0
  132. package/dist/core/ingestion/workers/parse-worker.js +12 -8
  133. package/dist/core/lbug/lbug-adapter.d.ts +28 -0
  134. package/dist/core/lbug/lbug-adapter.js +162 -57
  135. package/package.json +3 -3
  136. package/vendor/tree-sitter-proto/binding.gyp +30 -0
  137. package/vendor/tree-sitter-proto/bindings/node/binding.cc +20 -0
  138. package/vendor/tree-sitter-proto/bindings/node/index.d.ts +28 -0
  139. package/vendor/tree-sitter-proto/bindings/node/index.js +7 -0
  140. package/vendor/tree-sitter-proto/package.json +18 -0
  141. package/vendor/tree-sitter-proto/src/node-types.json +1145 -0
  142. package/vendor/tree-sitter-proto/src/parser.c +10149 -0
  143. package/vendor/tree-sitter-proto/src/tree_sitter/alloc.h +54 -0
  144. package/vendor/tree-sitter-proto/src/tree_sitter/array.h +291 -0
  145. package/vendor/tree-sitter-proto/src/tree_sitter/parser.h +266 -0
@@ -1,6 +1,30 @@
1
- import * as fs from 'node:fs';
2
1
  import * as path from 'node:path';
3
2
  import { glob } from 'glob';
3
+ import Parser from 'tree-sitter';
4
+ import { readSafe } from './fs-utils.js';
5
+ import { getPluginForFile, HTTP_SCAN_GLOB } from './http-patterns/index.js';
6
+ /**
7
+ * Language-agnostic orchestrator for HTTP route (provider + consumer)
8
+ * contract extraction. Two strategies, in order of preference per role:
9
+ *
10
+ * 1. **Graph-assisted (Strategy A)** — if a per-repo LadybugDB executor
11
+ * is available, read `HANDLES_ROUTE` / `FETCHES` Cypher edges that
12
+ * the ingestion pipeline already produced via tree-sitter. This is
13
+ * the preferred path because the graph has richer symbol metadata
14
+ * (real uids, class/method structure, etc.).
15
+ *
16
+ * 2. **Source-scan fallback (Strategy B)** — parse files directly with
17
+ * the per-language plugin registry in `./http-patterns/`. Used when
18
+ * the graph has no routes/fetches for this repo (e.g. a repo that
19
+ * hasn't been indexed yet, or whose indexer doesn't know the
20
+ * framework). Each plugin owns its tree-sitter grammar and query
21
+ * sources — this orchestrator imports NO grammars or query strings.
22
+ *
23
+ * Adding a new language for Strategy B is a one-file edit in
24
+ * `http-patterns/index.ts`: register a new `HttpLanguagePlugin` and
25
+ * widen `HTTP_SCAN_GLOB` if needed.
26
+ */
27
+ // ─── Graph-assisted queries ──────────────────────────────────────────
4
28
  const HANDLES_ROUTE_QUERY = `
5
29
  MATCH (handlerFile:File)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route)
6
30
  RETURN handlerFile.id AS fileId, handlerFile.filePath AS filePath,
@@ -17,13 +41,52 @@ MATCH (file:File {id: $fileId})<-[:CodeRelation {type: 'CONTAINS'}]-(sym)
17
41
  WHERE sym.startLine IS NOT NULL
18
42
  RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath, labels(sym) AS labels
19
43
  ORDER BY sym.startLine`;
44
+ // ─── Path normalization (shared between provider / consumer paths) ──
45
+ /**
46
+ * Canonicalize a provider-side HTTP path for contract-id generation:
47
+ * - strip query string
48
+ * - lower-case
49
+ * - drop trailing slash
50
+ * - collapse `:id`, `{id}`, `[id]` path params into a single `{param}`
51
+ */
20
52
  export function normalizeHttpPath(p) {
21
53
  let s = p.trim().split('?')[0].toLowerCase().replace(/\/+$/, '');
22
54
  s = s.replace(/:\w+/g, '{param}');
23
55
  s = s.replace(/\{[^}]+\}/g, '{param}');
24
56
  s = s.replace(/\[[^\]]+\]/g, '{param}');
25
- return s;
57
+ // Preserve root: after stripping trailing slashes, the root "/"
58
+ // collapses to "" which would produce malformed contract ids like
59
+ // `http::GET::`. Restore a single slash for the root case.
60
+ return s === '' ? '/' : s;
26
61
  }
62
+ /**
63
+ * Consumer-side normalization is more aggressive:
64
+ * - template literals (`${x}`) → `{param}`
65
+ * - strip protocol + host if the URL is absolute
66
+ * - numeric segments → `{param}` (so `/api/orders/42` → `/api/orders/{param}`)
67
+ */
68
+ function normalizeConsumerPath(url) {
69
+ const templated = url.replace(/\$\{[^}]+\}/g, '{param}').trim();
70
+ let pathOnly = templated;
71
+ if (/^https?:\/\//i.test(templated)) {
72
+ try {
73
+ pathOnly = new URL(templated).pathname;
74
+ }
75
+ catch {
76
+ pathOnly = templated.replace(/^https?:\/\/[^/]+/i, '');
77
+ }
78
+ }
79
+ const normalized = normalizeHttpPath(pathOnly || '/');
80
+ const segments = normalized
81
+ .split('/')
82
+ .filter(Boolean)
83
+ .map((segment) => (/^\d+$/.test(segment) ? '{param}' : segment));
84
+ return `/${segments.join('/')}`.replace(/\/+$/, '') || '/';
85
+ }
86
+ function contractIdFor(method, pathNorm) {
87
+ return `http::${method.toUpperCase()}::${pathNorm}`;
88
+ }
89
+ // ─── Graph row helpers ───────────────────────────────────────────────
27
90
  function methodFromRouteReason(reason) {
28
91
  const r = reason || '';
29
92
  if (/GetMapping|decorator-Get/i.test(r))
@@ -38,47 +101,6 @@ function methodFromRouteReason(reason) {
38
101
  return 'PATCH';
39
102
  return null;
40
103
  }
41
- function contractIdFor(method, pathNorm) {
42
- return `http::${method.toUpperCase()}::${pathNorm}`;
43
- }
44
- function readSafe(repoPath, rel) {
45
- const abs = path.resolve(repoPath, rel);
46
- const base = path.resolve(repoPath);
47
- const relToBase = path.relative(base, abs);
48
- if (relToBase.startsWith('..') || path.isAbsolute(relToBase))
49
- return null;
50
- try {
51
- return fs.readFileSync(abs, 'utf-8');
52
- }
53
- catch {
54
- return null;
55
- }
56
- }
57
- function pickJavaHandlerName(content, routePath, httpMethod) {
58
- const tail = routePath.split('/').filter(Boolean).pop() || '';
59
- const mapNames = {
60
- GET: 'GetMapping',
61
- POST: 'PostMapping',
62
- PUT: 'PutMapping',
63
- DELETE: 'DeleteMapping',
64
- PATCH: 'PatchMapping',
65
- };
66
- const ann = mapNames[httpMethod] || 'GetMapping';
67
- const lines = content.split(/\r?\n/);
68
- for (let i = 0; i < lines.length; i++) {
69
- const line = lines[i];
70
- if (!line.includes(`@${ann}`))
71
- continue;
72
- if (!line.includes(`"${tail}"`) && !line.includes(`'${tail}'`) && tail && !line.includes(tail))
73
- continue;
74
- for (let j = i + 1; j < Math.min(i + 8, lines.length); j++) {
75
- const m = lines[j].match(/(?:public|protected|private)\s+[\w<>,\s\[\]]+\s+(\w+)\s*\(/);
76
- if (m)
77
- return m[1];
78
- }
79
- }
80
- return null;
81
- }
82
104
  function pickSymbolUid(rows, preferredName) {
83
105
  const norm = (x) => String(x ?? '');
84
106
  const labeled = rows.filter((r) => {
@@ -104,19 +126,71 @@ function pickSymbolUid(rows, preferredName) {
104
126
  filePath: norm(first?.filePath ?? first?.[2]),
105
127
  };
106
128
  }
129
+ // ─── Orchestrator ────────────────────────────────────────────────────
107
130
  export class HttpRouteExtractor {
108
131
  type = 'http';
109
132
  async canExtract(_repo) {
110
133
  return true;
111
134
  }
112
- async extract(dbExecutor, repoPath, repo) {
113
- const graphP = dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, repoPath) : [];
114
- const providers = graphP.length > 0 ? graphP : await this.extractProvidersSourceScan(repoPath);
115
- const graphC = dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, repoPath) : [];
116
- const consumers = graphC.length > 0 ? graphC : await this.extractConsumersSourceScan(repoPath);
135
+ async extract(dbExecutor, repoPath, _repo) {
136
+ // Parse each file at most once and reuse the plugin results across
137
+ // both graph-assisted enrichment and source-scan emission.
138
+ const parser = new Parser();
139
+ const cachedDetections = new Map();
140
+ const getDetections = (rel) => {
141
+ const cached = cachedDetections.get(rel);
142
+ if (cached)
143
+ return cached;
144
+ const plugin = getPluginForFile(rel);
145
+ if (!plugin) {
146
+ cachedDetections.set(rel, []);
147
+ return [];
148
+ }
149
+ const content = readSafe(repoPath, rel);
150
+ if (!content) {
151
+ cachedDetections.set(rel, []);
152
+ return [];
153
+ }
154
+ try {
155
+ parser.setLanguage(plugin.language);
156
+ const tree = parser.parse(content);
157
+ const detections = plugin.scan(tree);
158
+ cachedDetections.set(rel, detections);
159
+ return detections;
160
+ }
161
+ catch {
162
+ cachedDetections.set(rel, []);
163
+ return [];
164
+ }
165
+ };
166
+ // Glob the source-scan file list at most once per extract() —
167
+ // both provider and consumer fallback paths share the same list.
168
+ let scannedFiles = null;
169
+ const getScannedFiles = async () => {
170
+ if (scannedFiles)
171
+ return scannedFiles;
172
+ scannedFiles = await this.scanFiles(repoPath);
173
+ return scannedFiles;
174
+ };
175
+ const graphProviders = dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, getDetections) : [];
176
+ const providers = graphProviders.length > 0
177
+ ? graphProviders
178
+ : this.extractProvidersSourceScan(await getScannedFiles(), getDetections);
179
+ const graphConsumers = dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, getDetections) : [];
180
+ const consumers = graphConsumers.length > 0
181
+ ? graphConsumers
182
+ : this.extractConsumersSourceScan(await getScannedFiles(), getDetections);
117
183
  return [...providers, ...consumers];
118
184
  }
119
- async extractProvidersGraph(db, repoPath) {
185
+ async scanFiles(repoPath) {
186
+ return glob(HTTP_SCAN_GLOB, {
187
+ cwd: repoPath,
188
+ ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**', '**/vendor/**'],
189
+ nodir: true,
190
+ });
191
+ }
192
+ // ─── Graph-assisted providers ──────────────────────────────────────
193
+ async extractProvidersGraph(db, getDetections) {
120
194
  const out = [];
121
195
  let rows;
122
196
  try {
@@ -130,20 +204,53 @@ export class HttpRouteExtractor {
130
204
  const routePath = String(row.routePath ?? '');
131
205
  const routeSource = String(row.routeSource ?? row.routeReason ?? '');
132
206
  let method = methodFromRouteReason(routeSource);
133
- const content = readSafe(repoPath, filePath);
134
- if (!method && content) {
135
- method = this.inferMethodFromFileScan(content, routePath, 'provider');
207
+ // Look up handler name (and backfill method if missing) from the
208
+ // plugin's scan of the handler file. This replaces the old
209
+ // regex-based `inferMethodFromFileScan` and `pickJavaHandlerName`
210
+ // helpers — tree-sitter gives both pieces of information
211
+ // structurally. Always run the lookup: even when method is set by
212
+ // `methodFromRouteReason`, we still need the handler name.
213
+ const detections = filePath ? getDetections(filePath) : [];
214
+ const providerDetections = detections.filter((d) => d.role === 'provider');
215
+ let handlerName = null;
216
+ const normalizedRoute = normalizeHttpPath(routePath);
217
+ // Candidates share the same normalized path. When multiple
218
+ // detections at the same path exist (e.g. GET + POST /api/orders
219
+ // in one router), a blind `.find()` silently returned the first
220
+ // verb — attaching the wrong handler and, when method was not
221
+ // already pinned by the route reason, the wrong method too.
222
+ // Disambiguate by method when we know it; refuse to guess when
223
+ // we don't.
224
+ const candidates = providerDetections.filter((d) => normalizeHttpPath(d.path) === normalizedRoute);
225
+ let match;
226
+ const ambiguousCandidates = !method && candidates.length > 1;
227
+ if (method) {
228
+ match = candidates.find((d) => d.method === method);
229
+ }
230
+ else if (candidates.length === 1) {
231
+ match = candidates[0];
232
+ }
233
+ // else: multiple candidates + unknown method → leave match
234
+ // undefined so handlerName stays null and skip symbol
235
+ // enrichment below, keeping the file-basename fallback instead
236
+ // of letting pickSymbolUid silently pick the first Function /
237
+ // Method in the file (which reintroduces the mis-attribution
238
+ // we were trying to avoid). Method stays at the conservative
239
+ // 'GET' default set below.
240
+ if (match) {
241
+ if (!method)
242
+ method = match.method;
243
+ handlerName = match.name;
136
244
  }
137
245
  if (!method)
138
246
  method = 'GET';
139
247
  const pathNorm = normalizeHttpPath(routePath);
140
248
  const cid = contractIdFor(method, pathNorm);
141
- const handlerName = content && routePath ? pickJavaHandlerName(content, routePath, method) : null;
142
249
  let symbolUid = '';
143
250
  let symbolName = path.basename(filePath) || 'handler';
144
251
  let symPath = filePath;
145
252
  const fileId = row.fileId ?? row[0];
146
- if (fileId) {
253
+ if (fileId && !ambiguousCandidates) {
147
254
  try {
148
255
  const syms = await db(CONTAINS_QUERY, { fileId });
149
256
  if (syms.length > 0) {
@@ -176,134 +283,37 @@ export class HttpRouteExtractor {
176
283
  }
177
284
  return out;
178
285
  }
179
- inferMethodFromFileScan(content, routePath, _role) {
180
- const tail = routePath.split('/').filter(Boolean).pop() || '';
181
- for (const m of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']) {
182
- const mapNames = {
183
- GET: 'GetMapping',
184
- POST: 'PostMapping',
185
- PUT: 'PutMapping',
186
- DELETE: 'DeleteMapping',
187
- PATCH: 'PatchMapping',
188
- };
189
- if (content.includes(`@${mapNames[m]}`) &&
190
- (content.includes(tail) || routePath.includes(tail))) {
191
- return m;
192
- }
193
- }
194
- return null;
195
- }
196
- async extractProvidersSourceScan(repoPath) {
197
- const files = await glob('**/*.{ts,tsx,js,jsx,java,vue,svelte,php,py}', {
198
- cwd: repoPath,
199
- ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'],
200
- nodir: true,
201
- });
286
+ // ─── Source-scan providers ─────────────────────────────────────────
287
+ extractProvidersSourceScan(files, getDetections) {
202
288
  const out = [];
203
289
  for (const rel of files) {
204
- const content = readSafe(repoPath, rel);
205
- if (!content)
206
- continue;
207
- out.push(...this.scanSpringProviders(content, rel));
208
- out.push(...this.scanExpressProviders(content, rel));
209
- out.push(...this.scanLaravelProviders(content, rel));
210
- out.push(...this.scanFastApiProviders(content, rel));
290
+ const detections = getDetections(rel);
291
+ for (const d of detections) {
292
+ if (d.role !== 'provider')
293
+ continue;
294
+ const pathNorm = normalizeHttpPath(d.path);
295
+ out.push({
296
+ contractId: contractIdFor(d.method, pathNorm),
297
+ type: 'http',
298
+ role: 'provider',
299
+ symbolUid: '',
300
+ symbolRef: { filePath: rel, name: d.name ?? 'handler' },
301
+ symbolName: d.name ?? 'handler',
302
+ confidence: d.confidence,
303
+ meta: {
304
+ method: d.method,
305
+ path: pathNorm,
306
+ pathSegments: pathNorm.split('/').filter(Boolean),
307
+ extractionStrategy: 'source_scan',
308
+ framework: d.framework,
309
+ },
310
+ });
311
+ }
211
312
  }
212
313
  return this.dedupeContracts(out);
213
314
  }
214
- dedupeContracts(items) {
215
- const seen = new Set();
216
- const out = [];
217
- for (const c of items) {
218
- const k = `${c.contractId}|${c.symbolRef.filePath}|${c.symbolRef.name}`;
219
- if (seen.has(k))
220
- continue;
221
- seen.add(k);
222
- out.push(c);
223
- }
224
- return out;
225
- }
226
- scanSpringProviders(content, filePath) {
227
- const out = [];
228
- // Skip Feign/client interfaces — annotated methods in interfaces are
229
- // consumers (Feign, JAX-RS proxies), not provider endpoints.
230
- // Anchored to line start (with optional access modifier) so we do not
231
- // match "interface" inside comments or string literals.
232
- if (/^\s*(?:public\s+)?interface\s+\w+/m.test(content) &&
233
- !/@(?:Rest)?Controller\b/.test(content)) {
234
- return out;
235
- }
236
- let classPrefix = '';
237
- const classRm = content.match(/@RequestMapping\s*\(\s*"([^"]+)"/);
238
- if (classRm)
239
- classPrefix = classRm[1].replace(/\/+$/, '');
240
- const re = /@(Get|Post|Put|Delete|Patch)Mapping\s*\(\s*"([^"]+)"/gi;
241
- let m;
242
- while ((m = re.exec(content)) !== null) {
243
- const method = m[1].toUpperCase();
244
- let p = m[2];
245
- if (classPrefix)
246
- p = `${classPrefix}/${p.replace(/^\//, '')}`;
247
- const pathNorm = normalizeHttpPath(p);
248
- const sub = content.slice(m.index);
249
- const nameM = sub.match(/(?:public|protected|private)\s+[\w<>,\s\[\]]+\s+(\w+)\s*\(/);
250
- const name = nameM ? nameM[1] : m[0];
251
- out.push(this.makeProvider(filePath, method, pathNorm, name, 0.8));
252
- }
253
- return out;
254
- }
255
- scanExpressProviders(content, filePath) {
256
- const out = [];
257
- const re = /(?:router|app)\.(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi;
258
- let m;
259
- while ((m = re.exec(content)) !== null) {
260
- const method = m[1].toUpperCase();
261
- const pathNorm = normalizeHttpPath(m[2]);
262
- out.push(this.makeProvider(filePath, method, pathNorm, 'handler', 0.8));
263
- }
264
- return out;
265
- }
266
- scanLaravelProviders(content, filePath) {
267
- const out = [];
268
- const re = /Route::(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi;
269
- let m;
270
- while ((m = re.exec(content)) !== null) {
271
- const method = m[1].toUpperCase();
272
- const pathNorm = normalizeHttpPath(m[2]);
273
- out.push(this.makeProvider(filePath, method, pathNorm, 'route', 0.8));
274
- }
275
- return out;
276
- }
277
- scanFastApiProviders(content, filePath) {
278
- const out = [];
279
- const re = /@app\.(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi;
280
- let m;
281
- while ((m = re.exec(content)) !== null) {
282
- const method = m[1].toUpperCase();
283
- const pathNorm = normalizeHttpPath(m[2]);
284
- out.push(this.makeProvider(filePath, method, pathNorm, 'handler', 0.8));
285
- }
286
- return out;
287
- }
288
- makeProvider(filePath, method, pathNorm, name, confidence) {
289
- const cid = contractIdFor(method, pathNorm);
290
- return {
291
- contractId: cid,
292
- type: 'http',
293
- role: 'provider',
294
- symbolUid: '',
295
- symbolRef: { filePath, name },
296
- symbolName: name,
297
- confidence,
298
- meta: {
299
- method,
300
- path: pathNorm,
301
- pathSegments: pathNorm.split('/').filter(Boolean),
302
- extractionStrategy: 'source_scan',
303
- },
304
- };
305
- }
306
- async extractConsumersGraph(db, repoPath) {
315
+ // ─── Graph-assisted consumers ──────────────────────────────────────
316
+ async extractConsumersGraph(db, getDetections) {
307
317
  const out = [];
308
318
  let rows;
309
319
  try {
@@ -317,11 +327,19 @@ export class HttpRouteExtractor {
317
327
  const routePath = String(row.routePath ?? '');
318
328
  const pathNorm = normalizeHttpPath(routePath);
319
329
  let method = 'GET';
320
- const content = readSafe(repoPath, filePath);
321
- if (content) {
322
- const inferred = this.inferFetchMethod(content, pathNorm);
323
- if (inferred)
324
- method = inferred;
330
+ // Prefer the plugin's detected method if we can find a matching
331
+ // fetch/axios call in the same file.
332
+ const detections = filePath ? getDetections(filePath) : [];
333
+ // Symmetric to the provider path: if multiple consumer calls in
334
+ // the same file share the same normalized path (e.g. a GET
335
+ // fetch AND a POST fetch to `/api/orders`), `.find()` silently
336
+ // picked the first verb and keyed the contract id on the wrong
337
+ // method. With no upstream method signal here, refuse to guess
338
+ // when candidates are ambiguous — leave `method` at its
339
+ // conservative 'GET' default.
340
+ const consumerCandidates = detections.filter((d) => d.role === 'consumer' && normalizeConsumerPath(d.path) === pathNorm);
341
+ if (consumerCandidates.length === 1) {
342
+ method = consumerCandidates[0].method;
325
343
  }
326
344
  const cid = contractIdFor(method, pathNorm);
327
345
  let symbolUid = '';
@@ -360,69 +378,44 @@ export class HttpRouteExtractor {
360
378
  }
361
379
  return out;
362
380
  }
363
- inferFetchMethod(content, pathNorm) {
364
- const esc = pathNorm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
365
- const fetchRe = new RegExp(`fetch\\s*\\(\\s*['"\`]([^'"\`]*${esc}[^'"\`]*)['"\`]\\s*,\\s*\\{[^}]*method:\\s*['"](\\w+)['"]`, 'i');
366
- const m = content.match(fetchRe);
367
- if (m)
368
- return m[2].toUpperCase();
369
- return null;
370
- }
371
- async extractConsumersSourceScan(repoPath) {
372
- const files = await glob('**/*.{ts,tsx,js,jsx,vue,svelte}', {
373
- cwd: repoPath,
374
- ignore: ['**/node_modules/**', '**/.git/**'],
375
- nodir: true,
376
- });
381
+ // ─── Source-scan consumers ─────────────────────────────────────────
382
+ extractConsumersSourceScan(files, getDetections) {
377
383
  const out = [];
378
384
  for (const rel of files) {
379
- const content = readSafe(repoPath, rel);
380
- if (!content)
381
- continue;
382
- out.push(...this.scanFetchConsumers(content, rel));
383
- out.push(...this.scanAxiosConsumers(content, rel));
385
+ const detections = getDetections(rel);
386
+ for (const d of detections) {
387
+ if (d.role !== 'consumer')
388
+ continue;
389
+ const pathNorm = normalizeConsumerPath(d.path);
390
+ out.push({
391
+ contractId: contractIdFor(d.method, pathNorm),
392
+ type: 'http',
393
+ role: 'consumer',
394
+ symbolUid: '',
395
+ symbolRef: { filePath: rel, name: 'fetch' },
396
+ symbolName: 'fetch',
397
+ confidence: d.confidence,
398
+ meta: {
399
+ method: d.method,
400
+ path: pathNorm,
401
+ extractionStrategy: 'source_scan',
402
+ framework: d.framework,
403
+ },
404
+ });
405
+ }
384
406
  }
385
407
  return this.dedupeContracts(out);
386
408
  }
387
- scanFetchConsumers(content, filePath) {
388
- const out = [];
389
- const re = /fetch\s*\(\s*['"`]([^'"`]+)['"`](?:\s*,\s*\{[^}]*method:\s*['"](\w+)['"][^}]*\})?\s*\)/gi;
390
- let m;
391
- while ((m = re.exec(content)) !== null) {
392
- const pathNorm = normalizeHttpPath(this.templateToPattern(m[1]));
393
- const method = (m[2] || 'GET').toUpperCase();
394
- out.push(this.makeConsumer(filePath, method, pathNorm, 0.7));
395
- }
396
- return out;
397
- }
398
- templateToPattern(url) {
399
- return url.replace(/\$\{[^}]+\}/g, '{param}');
400
- }
401
- scanAxiosConsumers(content, filePath) {
409
+ dedupeContracts(items) {
410
+ const seen = new Set();
402
411
  const out = [];
403
- const re = /axios\.(get|post|put|delete|patch)\s*\(\s*[`'"]([^`'"]+)[`'"]/gi;
404
- let m;
405
- while ((m = re.exec(content)) !== null) {
406
- const method = m[1].toUpperCase();
407
- const pathNorm = normalizeHttpPath(this.templateToPattern(m[2]));
408
- out.push(this.makeConsumer(filePath, method, pathNorm, 0.7));
412
+ for (const c of items) {
413
+ const k = `${c.contractId}|${c.symbolRef.filePath}|${c.symbolRef.name}`;
414
+ if (seen.has(k))
415
+ continue;
416
+ seen.add(k);
417
+ out.push(c);
409
418
  }
410
419
  return out;
411
420
  }
412
- makeConsumer(filePath, method, pathNorm, confidence) {
413
- return {
414
- contractId: contractIdFor(method, pathNorm),
415
- type: 'http',
416
- role: 'consumer',
417
- symbolUid: '',
418
- symbolRef: { filePath, name: 'fetch' },
419
- symbolName: 'fetch',
420
- confidence,
421
- meta: {
422
- method,
423
- path: pathNorm,
424
- extractionStrategy: 'source_scan',
425
- },
426
- };
427
- }
428
421
  }
@@ -0,0 +1,54 @@
1
+ import type { CrossLink, GroupManifestLink, StoredContract } from '../types.js';
2
+ import type { CypherExecutor } from '../contract-extractor.js';
3
+ export interface ManifestExtractResult {
4
+ contracts: StoredContract[];
5
+ crossLinks: CrossLink[];
6
+ }
7
+ /**
8
+ * Stable synthetic symbolUid for a manifest-declared contract whose target
9
+ * symbol could not be resolved against the per-repo graph (resolveSymbol
10
+ * returned null). Two reasons we don't leave the uid empty:
11
+ *
12
+ * 1. The bridge stores Contract nodes keyed in part by symbolUid; an empty
13
+ * uid means downstream Cypher queries that anchor on `provider.symbolUid`
14
+ * can't tell two different unresolved manifest contracts apart.
15
+ * 2. The cross-impact bridge query in cross-impact.ts joins local impact
16
+ * results to bridge contracts via `WHERE provider.symbolUid IN $localUids`.
17
+ * If the local impact engine produces a deterministic identifier for the
18
+ * unresolved target, it must agree with the value the bridge stored. A
19
+ * synthetic uid keyed off (repo, contractId) is the only thing both sides
20
+ * can derive without knowing about each other.
21
+ *
22
+ * Format: `manifest::<repo>::<contractId>`. Stable across syncs, scoped to a
23
+ * single repo within a group, and never collides with real indexer uids
24
+ * (which never start with `manifest::`).
25
+ */
26
+ export declare function manifestSymbolUid(repo: string, contractId: string): string;
27
+ export declare class ManifestExtractor {
28
+ extractFromManifest(links: GroupManifestLink[], dbExecutors?: Map<string, CypherExecutor>): Promise<ManifestExtractResult>;
29
+ private resolveSymbol;
30
+ /**
31
+ * Build a canonical contract id for a manifest link.
32
+ *
33
+ * HTTP is the only type with two valid forms:
34
+ * - Explicit method: `"GET::/api/orders"` → `"http::GET::/api/orders"`
35
+ * (matches exactly against `HttpRouteExtractor` provider/consumer
36
+ * contracts, which are also keyed by `http::<METHOD>::<path>`).
37
+ * - Method-agnostic: `"/api/orders"` → `"http::*::/api/orders"`
38
+ * — the `*` is a wildcard and is intended to match any concrete
39
+ * HTTP method on that path. Wildcard-aware matching is the
40
+ * responsibility of the sync / cross-impact layer (see #793);
41
+ * downstream code should treat `http::*::<path>` as matching
42
+ * every `http::<METHOD>::<path>` for the same path.
43
+ *
44
+ * Recommend the explicit-method form in group.yaml whenever the
45
+ * manifest author knows the method — it round-trips through exact
46
+ * equality matching without requiring wildcard logic downstream.
47
+ *
48
+ * NOTE on exhaustiveness: the switch covers every current
49
+ * `ContractType` variant and falls through to a `never` assertion so
50
+ * TypeScript fails the build if a new variant is added without a
51
+ * corresponding case.
52
+ */
53
+ private buildContractId;
54
+ }