@saptools/service-flow 0.1.50 → 0.1.52

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 (43) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +16 -3
  3. package/TECHNICAL-NOTE.md +10 -1
  4. package/dist/{chunk-52OUS3MO.js → chunk-PTLDSHRC.js} +2663 -363
  5. package/dist/chunk-PTLDSHRC.js.map +1 -0
  6. package/dist/cli.js +318 -510
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +59 -17
  9. package/dist/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/cli/000-clean.ts +82 -0
  12. package/src/cli.ts +97 -57
  13. package/src/db/connection.ts +1 -1
  14. package/src/db/migrations.ts +5 -3
  15. package/src/db/repositories.ts +120 -22
  16. package/src/db/schema.ts +7 -2
  17. package/src/index.ts +1 -1
  18. package/src/indexer/repository-indexer.ts +57 -29
  19. package/src/indexer/workspace-indexer.ts +84 -6
  20. package/src/linker/cross-repo-linker.ts +22 -2
  21. package/src/linker/service-resolver.ts +15 -4
  22. package/src/output/table-output.ts +56 -3
  23. package/src/parsers/cds-parser.ts +8 -2
  24. package/src/parsers/decorator-parser.ts +382 -48
  25. package/src/parsers/handler-registration-parser.ts +38 -21
  26. package/src/parsers/imported-wrapper-parser.ts +18 -5
  27. package/src/parsers/outbound-call-parser.ts +25 -8
  28. package/src/parsers/package-json-parser.ts +36 -11
  29. package/src/parsers/service-binding-parser-helpers.ts +8 -1
  30. package/src/parsers/service-binding-parser.ts +6 -3
  31. package/src/parsers/symbol-parser.ts +13 -3
  32. package/src/parsers/ts-project.ts +54 -0
  33. package/src/trace/000-dynamic-target-types.ts +84 -0
  34. package/src/trace/001-dynamic-identity.ts +280 -0
  35. package/src/trace/002-trace-diagnostics.ts +54 -0
  36. package/src/trace/003-dynamic-references.ts +82 -0
  37. package/src/trace/dynamic-branches.ts +45 -0
  38. package/src/trace/dynamic-targets.ts +654 -0
  39. package/src/trace/evidence.ts +391 -22
  40. package/src/trace/selectors.ts +483 -0
  41. package/src/trace/trace-engine.ts +101 -94
  42. package/src/types.ts +39 -1
  43. package/dist/chunk-52OUS3MO.js.map +0 -1
@@ -3,6 +3,7 @@ import type {
3
3
  CdsRequire,
4
4
  CdsServiceFact,
5
5
  HandlerClassFact,
6
+ HandlerMethodFact,
6
7
  HandlerRegistrationFact,
7
8
  OutboundCallFact,
8
9
  ServiceBindingFact,
@@ -85,15 +86,29 @@ export function upsertRepository(
85
86
  .get(workspaceId, r.absolutePath)?.id,
86
87
  );
87
88
  }
88
- export function listRepositories(db: Db): RepoRow[] {
89
+ export function listRepositories(db: Db, workspaceId?: number): RepoRow[] {
89
90
  return db
90
- .prepare('SELECT * FROM repositories ORDER BY name')
91
- .all() as unknown as RepoRow[];
91
+ .prepare('SELECT * FROM repositories WHERE (? IS NULL OR workspace_id=?) ORDER BY name,absolute_path,id')
92
+ .all(workspaceId, workspaceId) as unknown as RepoRow[];
92
93
  }
93
- export function repoByName(db: Db, name: string): RepoRow | undefined {
94
+ export function repoByName(
95
+ db: Db,
96
+ name: string,
97
+ workspaceId?: number,
98
+ ): RepoRow | undefined {
99
+ const matches = reposByName(db, name, workspaceId);
100
+ return matches.length === 1 ? matches[0] : undefined;
101
+ }
102
+ export function reposByName(
103
+ db: Db,
104
+ name: string,
105
+ workspaceId?: number,
106
+ ): RepoRow[] {
94
107
  return db
95
- .prepare('SELECT * FROM repositories WHERE name=? OR package_name=?')
96
- .get(name, name) as RepoRow | undefined;
108
+ .prepare(`SELECT * FROM repositories
109
+ WHERE (? IS NULL OR workspace_id=?) AND (name=? OR package_name=?)
110
+ ORDER BY name,absolute_path,id`)
111
+ .all(workspaceId, workspaceId, name, name) as unknown as RepoRow[];
97
112
  }
98
113
  export function clearRepoFacts(db: Db, repoId: number): void {
99
114
  for (const t of [
@@ -188,21 +203,7 @@ export function insertHandler(
188
203
  repoId: number,
189
204
  h: HandlerClassFact,
190
205
  ): number {
191
- const sid = Number(
192
- db
193
- .prepare(
194
- 'INSERT INTO symbols(repo_id,kind,name,qualified_name,exported,start_line,end_line) VALUES(?,?,?,?,?,?,?) RETURNING id',
195
- )
196
- .get(
197
- repoId,
198
- 'class',
199
- h.className,
200
- h.className,
201
- 1,
202
- h.sourceLine,
203
- h.sourceLine,
204
- )?.id,
205
- );
206
+ const sid = insertHandlerClassSymbol(db, repoId, h);
206
207
  const hid = Number(
207
208
  db
208
209
  .prepare(
@@ -220,12 +221,109 @@ export function insertHandler(
220
221
  m.decoratorKind,
221
222
  m.decoratorValue,
222
223
  m.decoratorRawExpression,
223
- JSON.stringify(m.decoratorResolution),
224
+ JSON.stringify(canonicalHandlerMethodResolution(m)),
224
225
  m.sourceFile,
225
226
  m.sourceLine,
226
227
  );
228
+ insertHandlerIndexDiagnostic(db, repoId, h);
227
229
  return hid;
228
230
  }
231
+ function insertHandlerClassSymbol(
232
+ db: Db,
233
+ repoId: number,
234
+ h: HandlerClassFact,
235
+ ): number {
236
+ const classEvidence = {
237
+ hasHandlerDecorator: h.hasHandlerDecorator ?? false,
238
+ classDecoratorNames: h.classDecoratorNames ?? [],
239
+ observedDecoratorNames: h.observedDecoratorNames ?? [],
240
+ unsupportedDecoratorNames: h.unsupportedDecoratorNames ?? [],
241
+ unsupportedMethods: h.methods
242
+ .filter((method) => !handlerMethodIsExecutable(method))
243
+ .map((method) => ({
244
+ methodName: method.methodName,
245
+ decoratorKind: method.decoratorKind,
246
+ sourceFile: method.sourceFile,
247
+ sourceLine: method.sourceLine,
248
+ reason: method.decoratorResolution.unresolvedReason,
249
+ })),
250
+ };
251
+ return Number(
252
+ db
253
+ .prepare(
254
+ 'INSERT INTO symbols(repo_id,kind,name,qualified_name,exported,start_line,end_line,source_file,evidence_json) VALUES(?,?,?,?,?,?,?,?,?) RETURNING id',
255
+ )
256
+ .get(
257
+ repoId,
258
+ 'class',
259
+ h.className,
260
+ h.className,
261
+ 1,
262
+ h.sourceLine,
263
+ h.sourceLine,
264
+ h.sourceFile,
265
+ JSON.stringify(classEvidence),
266
+ )?.id,
267
+ );
268
+ }
269
+ function insertHandlerIndexDiagnostic(
270
+ db: Db,
271
+ repoId: number,
272
+ h: HandlerClassFact,
273
+ ): void {
274
+ if (!h.hasHandlerDecorator) return;
275
+ const hasExecutable = h.methods.some(handlerMethodIsExecutable);
276
+ const unsupported = h.methods.filter((method) =>
277
+ !handlerMethodIsExecutable(method));
278
+ if (hasExecutable && unsupported.length === 0) return;
279
+ const code = hasExecutable
280
+ ? 'handler_decorators_not_indexed'
281
+ : 'handler_methods_not_indexed';
282
+ const names = unsupported.map((method) => method.decoratorKind).sort();
283
+ const detail = names.length > 0
284
+ ? ` Unsupported decorators: ${[...new Set(names)].join(', ')}.`
285
+ : '';
286
+ db.prepare(
287
+ 'INSERT INTO diagnostics(repo_id,severity,code,message,source_file,source_line) VALUES(?,?,?,?,?,?)',
288
+ ).run(
289
+ repoId,
290
+ 'warning',
291
+ code,
292
+ hasExecutable
293
+ ? `Handler class ${h.className} contains methods that were not indexed.${detail}`
294
+ : `Handler class ${h.className} has no indexed executable methods; use a supported CAP handler decorator and re-index.${detail}`,
295
+ h.sourceFile,
296
+ h.sourceLine,
297
+ );
298
+ }
299
+ export function canonicalHandlerMethodResolution(
300
+ method: HandlerMethodFact,
301
+ ): HandlerMethodFact['decoratorResolution'] {
302
+ const handlerKind = method.handlerKind
303
+ ?? method.decoratorResolution.handlerKind
304
+ ?? legacyHandlerKind(method.decoratorKind);
305
+ const executable = method.executable
306
+ ?? method.decoratorResolution.executable
307
+ ?? (handlerKind === 'operation' || handlerKind === 'event'
308
+ || handlerKind === 'entity_lifecycle');
309
+ return {
310
+ ...method.decoratorResolution,
311
+ handlerKind,
312
+ executable,
313
+ lifecyclePhase: method.lifecyclePhase
314
+ ?? method.decoratorResolution.lifecyclePhase,
315
+ lifecycleEvent: method.lifecycleEvent
316
+ ?? method.decoratorResolution.lifecycleEvent,
317
+ };
318
+ }
319
+ export function handlerMethodIsExecutable(method: HandlerMethodFact): boolean {
320
+ return canonicalHandlerMethodResolution(method).executable === true;
321
+ }
322
+ function legacyHandlerKind(kind: string): HandlerMethodFact['handlerKind'] {
323
+ if (kind === 'Event') return 'event';
324
+ if (['Action', 'Func', 'On'].includes(kind)) return 'operation';
325
+ return 'unsupported_decorator';
326
+ }
229
327
  export function insertRegistrations(
230
328
  db: Db,
231
329
  repoId: number,
package/src/db/schema.ts CHANGED
@@ -1,4 +1,4 @@
1
- export const schemaSql = `
1
+ export const schemaTablesSql = `
2
2
  CREATE TABLE IF NOT EXISTS workspaces (id INTEGER PRIMARY KEY, root_path TEXT UNIQUE NOT NULL, db_path TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
3
3
  CREATE TABLE IF NOT EXISTS repositories (id INTEGER PRIMARY KEY, workspace_id INTEGER NOT NULL, name TEXT NOT NULL, absolute_path TEXT NOT NULL, relative_path TEXT NOT NULL, package_name TEXT, package_version TEXT, dependencies_json TEXT DEFAULT '{}', kind TEXT NOT NULL, is_git_repo INTEGER NOT NULL, last_indexed_at TEXT, index_status TEXT DEFAULT 'pending', error_count INTEGER DEFAULT 0, fingerprint TEXT, fact_generation INTEGER NOT NULL DEFAULT 0, graph_generation INTEGER NOT NULL DEFAULT 0, graph_stale_reason TEXT, graph_stale_at TEXT, fact_analyzer_version TEXT, UNIQUE(workspace_id, absolute_path), FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE);
4
4
  CREATE TABLE IF NOT EXISTS files (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, relative_path TEXT NOT NULL, extension TEXT NOT NULL, sha256 TEXT NOT NULL, size_bytes INTEGER NOT NULL, last_indexed_at TEXT NOT NULL, UNIQUE(repo_id, relative_path), FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE);
@@ -13,8 +13,11 @@ CREATE TABLE IF NOT EXISTS service_bindings (id INTEGER PRIMARY KEY, repo_id INT
13
13
  CREATE TABLE IF NOT EXISTS outbound_calls (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, source_symbol_id INTEGER, call_type TEXT NOT NULL, service_binding_id INTEGER, method TEXT, operation_path_expr TEXT, query_entity TEXT, event_name_expr TEXT, payload_summary TEXT, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, confidence REAL NOT NULL, unresolved_reason TEXT, local_service_name TEXT, local_service_lookup TEXT, alias_chain_json TEXT, evidence_json TEXT, external_target_kind TEXT, external_target_id TEXT, external_target_label TEXT, external_target_dynamic INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(source_symbol_id) REFERENCES symbols(id) ON DELETE SET NULL, FOREIGN KEY(service_binding_id) REFERENCES service_bindings(id) ON DELETE SET NULL);
14
14
  CREATE TABLE IF NOT EXISTS symbol_calls (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, caller_symbol_id INTEGER NOT NULL, callee_symbol_id INTEGER, callee_expression TEXT NOT NULL, import_source TEXT, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, status TEXT NOT NULL, confidence REAL NOT NULL, evidence_json TEXT NOT NULL, unresolved_reason TEXT, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(caller_symbol_id) REFERENCES symbols(id) ON DELETE CASCADE, FOREIGN KEY(callee_symbol_id) REFERENCES symbols(id) ON DELETE SET NULL);
15
15
  CREATE TABLE IF NOT EXISTS graph_edges (id INTEGER PRIMARY KEY, workspace_id INTEGER NOT NULL, edge_type TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'unresolved', from_kind TEXT NOT NULL, from_id TEXT NOT NULL, to_kind TEXT NOT NULL, to_id TEXT NOT NULL, confidence REAL NOT NULL, evidence_json TEXT NOT NULL, is_dynamic INTEGER NOT NULL, unresolved_reason TEXT, generation INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE);
16
- CREATE TABLE IF NOT EXISTS index_runs (id INTEGER PRIMARY KEY, workspace_id INTEGER NOT NULL, started_at TEXT NOT NULL, finished_at TEXT, status TEXT NOT NULL, repo_count INTEGER NOT NULL, file_count INTEGER NOT NULL, diagnostic_count INTEGER NOT NULL, error_message TEXT, FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE);
16
+ CREATE TABLE IF NOT EXISTS index_runs (id INTEGER PRIMARY KEY, workspace_id INTEGER NOT NULL, started_at TEXT NOT NULL, finished_at TEXT, status TEXT NOT NULL, repo_count INTEGER NOT NULL, file_count INTEGER NOT NULL, diagnostic_count INTEGER NOT NULL, error_message TEXT, owner_pid INTEGER, FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE);
17
17
  CREATE TABLE IF NOT EXISTS diagnostics (id INTEGER PRIMARY KEY, repo_id INTEGER, file_id INTEGER, severity TEXT NOT NULL, code TEXT NOT NULL, message TEXT NOT NULL, source_file TEXT, source_line INTEGER, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(file_id) REFERENCES files(id) ON DELETE SET NULL);
18
+ `;
19
+
20
+ export const schemaIndexesSql = `
18
21
  CREATE INDEX IF NOT EXISTS idx_repo_name ON repositories(name);
19
22
  CREATE INDEX IF NOT EXISTS idx_service_path ON cds_services(service_path);
20
23
  CREATE INDEX IF NOT EXISTS idx_extension_import ON cds_services(extension_module_specifier, extension_imported_symbol, is_extend);
@@ -24,3 +27,5 @@ CREATE INDEX IF NOT EXISTS idx_calls_repo ON outbound_calls(repo_id, call_type);
24
27
  CREATE INDEX IF NOT EXISTS idx_symbol_calls_caller ON symbol_calls(repo_id, caller_symbol_id);
25
28
  CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(kind, name, path, repo);
26
29
  `;
30
+
31
+ export const schemaSql = `${schemaTablesSql}\n${schemaIndexesSql}`;
package/src/index.ts CHANGED
@@ -11,5 +11,5 @@ export { applyVariables, extractPlaceholders, substituteVariables } from './link
11
11
  export type { RuntimeSubstitution } from './linker/dynamic-edge-resolver.js';
12
12
  export { trace } from './trace/trace-engine.js';
13
13
  export { parseImplementationHint } from './trace/implementation-hints.js';
14
- export type { ImplementationHint } from './types.js';
14
+ export type { DynamicMode, ImplementationHint, TraceOptions } from './types.js';
15
15
  export { redactValue, redactText } from './utils/redaction.js';
@@ -7,6 +7,7 @@ import {
7
7
  insertCalls,
8
8
  insertExecutableSymbols,
9
9
  insertHandler,
10
+ handlerMethodIsExecutable,
10
11
  insertRegistrations,
11
12
  insertSymbolCalls,
12
13
  insertRequires,
@@ -19,14 +20,20 @@ import { parseDecorators } from '../parsers/decorator-parser.js';
19
20
  import { parseHandlerRegistrations } from '../parsers/handler-registration-parser.js';
20
21
  import { parseOutboundCalls } from '../parsers/outbound-call-parser.js';
21
22
  import { parseExecutableSymbols } from '../parsers/symbol-parser.js';
22
- import { parsePackageJson } from '../parsers/package-json-parser.js';
23
+ import {
24
+ loadPackageJsonSnapshot,
25
+ } from '../parsers/package-json-parser.js';
23
26
  import { parseServiceBindings } from '../parsers/service-binding-parser.js';
24
- import { sha256File } from '../utils/hashing.js';
25
27
  import { normalizePath } from '../utils/path-utils.js';
26
28
  import { errorMessage } from '../utils/diagnostics.js';
27
29
  import { sha256Text } from '../utils/hashing.js';
28
30
  import { ANALYZER_VERSION } from '../version.js';
29
- import type { CdsServiceFact, HandlerClassFact, HandlerRegistrationFact, OutboundCallFact, ServiceBindingFact, ExecutableSymbolFact, SymbolCallFact } from '../types.js';
31
+ import {
32
+ loadRepositorySourceContext,
33
+ type RepositorySourceContext,
34
+ type SourceContextInstrumentation,
35
+ } from '../parsers/ts-project.js';
36
+ import type { CdsServiceFact, HandlerClassFact, HandlerRegistrationFact, OutboundCallFact, PackageFacts, ServiceBindingFact, ExecutableSymbolFact, SymbolCallFact } from '../types.js';
30
37
  export interface IndexRepoResult {
31
38
  fileCount: number;
32
39
  diagnosticCount: number;
@@ -44,7 +51,7 @@ interface ParsedFacts {
44
51
  }
45
52
  export interface PreparedRepositoryIndex extends IndexRepoResult {
46
53
  repo: RepoRow;
47
- packageFacts?: Awaited<ReturnType<typeof parsePackageJson>>;
54
+ packageFacts?: PackageFacts;
48
55
  fingerprint?: string;
49
56
  kind?: string;
50
57
  parsed?: ParsedFacts;
@@ -53,9 +60,10 @@ export async function indexRepository(
53
60
  db: Db,
54
61
  repo: RepoRow,
55
62
  force: boolean,
63
+ instrumentation?: SourceContextInstrumentation,
56
64
  ): Promise<IndexRepoResult> {
57
65
  try {
58
- const prepared = await prepareRepositoryIndex(repo, force);
66
+ const prepared = await prepareRepositoryIndex(repo, force, instrumentation);
59
67
  if (!prepared.skipped) db.transaction(() => publishPreparedRepositoryIndex(db, prepared));
60
68
  return { fileCount: prepared.fileCount, diagnosticCount: prepared.diagnosticCount, skipped: prepared.skipped };
61
69
  } catch (error) {
@@ -63,19 +71,36 @@ export async function indexRepository(
63
71
  return { fileCount: 0, diagnosticCount: 1, skipped: false };
64
72
  }
65
73
  }
66
- export async function prepareRepositoryIndex(repo: RepoRow, force: boolean): Promise<PreparedRepositoryIndex> {
74
+ export async function prepareRepositoryIndex(
75
+ repo: RepoRow,
76
+ force: boolean,
77
+ instrumentation?: SourceContextInstrumentation,
78
+ ): Promise<PreparedRepositoryIndex> {
67
79
  const sourceFiles = await findSourceFiles(repo.absolute_path);
68
- const packageFacts = await parsePackageJson(repo.absolute_path);
69
- const fingerprint = await repositoryFingerprint(repo.absolute_path, sourceFiles, packageFacts);
80
+ const packageSnapshot = await loadPackageJsonSnapshot(repo.absolute_path, {
81
+ strict: true,
82
+ allowMissing: repo.package_name === null,
83
+ });
84
+ const packageFacts = packageSnapshot.facts;
85
+ const sources = await loadRepositorySourceContext(
86
+ repo.absolute_path, sourceFiles, instrumentation,
87
+ );
88
+ const fingerprint = repositoryFingerprint(
89
+ sources, packageFacts, packageSnapshot.rawText,
90
+ );
70
91
  if (!force && repo.fingerprint === fingerprint) return { repo, fileCount: 0, diagnosticCount: 0, skipped: true };
92
+ const parsed = await parseAllSourceFacts(repo.absolute_path, sources);
71
93
  return {
72
94
  repo,
73
95
  packageFacts,
74
96
  fingerprint,
75
97
  kind: await classifyRepository(repo.absolute_path, packageFacts),
76
- parsed: await parseAllSourceFacts(repo.absolute_path, sourceFiles),
98
+ parsed,
77
99
  fileCount: sourceFiles.length,
78
- diagnosticCount: 0,
100
+ diagnosticCount: parsed.handlers.filter((handler) =>
101
+ handler.hasHandlerDecorator
102
+ && (handler.methods.length === 0
103
+ || handler.methods.some((method) => !handlerMethodIsExecutable(method)))).length,
79
104
  skipped: false,
80
105
  };
81
106
  }
@@ -104,21 +129,23 @@ export function recordIndexFailure(db: Db, repoId: number, error: unknown): void
104
129
  db.prepare("DELETE FROM diagnostics WHERE repo_id=? AND code IN ('index_failed_snapshot_preserved','source_read_failed')").run(repoId);
105
130
  db.prepare('INSERT INTO diagnostics(repo_id,severity,code,message) VALUES(?,?,?,?)').run(repoId, 'error', 'source_read_failed', `Index failed before publication; previous facts and fingerprint were preserved. ${message}`);
106
131
  }
107
- async function parseAllSourceFacts(root: string, files: string[]): Promise<ParsedFacts> {
132
+ async function parseAllSourceFacts(
133
+ root: string,
134
+ sources: RepositorySourceContext,
135
+ ): Promise<ParsedFacts> {
108
136
  const facts: ParsedFacts = { services: [], handlers: [], registrations: [], bindings: [], calls: [], symbols: [], symbolCalls: [], fileRecords: [] };
109
- for (const file of files) {
110
- const abs = path.join(root, file);
111
- const stat = await fs.stat(abs);
112
- facts.fileRecords.push({ relativePath: normalizePath(file), extension: path.extname(file), sha256: await sha256File(abs), sizeBytes: stat.size });
113
- if (file.endsWith('.cds')) facts.services.push(...(await parseCdsFile(root, file)));
137
+ for (const snapshot of sources.entries()) {
138
+ const file = snapshot.filePath;
139
+ facts.fileRecords.push({ relativePath: normalizePath(file), extension: path.extname(file), sha256: sha256Text(snapshot.text), sizeBytes: snapshot.sizeBytes });
140
+ if (file.endsWith('.cds')) facts.services.push(...(await parseCdsFile(root, file, sources)));
114
141
  if (/\.[jt]s$/.test(file)) {
115
- facts.handlers.push(...(await parseDecorators(root, file)));
116
- facts.registrations.push(...(await parseHandlerRegistrations(root, file)));
117
- facts.bindings.push(...(await parseServiceBindings(root, file)));
118
- const symbolFacts = await parseExecutableSymbols(root, file);
142
+ facts.handlers.push(...(await parseDecorators(root, file, sources)));
143
+ facts.registrations.push(...(await parseHandlerRegistrations(root, file, sources)));
144
+ facts.bindings.push(...(await parseServiceBindings(root, file, sources)));
145
+ const symbolFacts = await parseExecutableSymbols(root, file, sources);
119
146
  facts.symbols.push(...symbolFacts.symbols);
120
147
  facts.symbolCalls.push(...symbolFacts.calls);
121
- facts.calls.push(...(await parseOutboundCalls(root, file)));
148
+ facts.calls.push(...(await parseOutboundCalls(root, file, sources)));
122
149
  }
123
150
  }
124
151
  return facts;
@@ -126,7 +153,7 @@ async function parseAllSourceFacts(root: string, files: string[]): Promise<Parse
126
153
  async function findSourceFiles(root: string): Promise<string[]> {
127
154
  const out: string[] = [];
128
155
  async function walk(dir: string, prefix = ''): Promise<void> {
129
- const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
156
+ const entries = await fs.readdir(dir, { withFileTypes: true });
130
157
  for (const e of entries) {
131
158
  const rel = prefix ? `${prefix}/${e.name}` : e.name;
132
159
  if (e.isDirectory()) {
@@ -142,8 +169,11 @@ function isDefaultTestFile(relativeFile: string): boolean {
142
169
  if (parts.some((part) => ['test', 'tests', '__tests__'].includes(part))) return true;
143
170
  return /\.(test|spec)\.[jt]s$/.test(parts.at(-1) ?? '');
144
171
  }
145
- async function repositoryFingerprint(root: string, files: string[], facts: Awaited<ReturnType<typeof parsePackageJson>>): Promise<string> {
146
- const packageJson = await fs.readFile(path.join(root, 'package.json'), 'utf8').catch(() => '');
172
+ function repositoryFingerprint(
173
+ sources: RepositorySourceContext,
174
+ facts: PackageFacts,
175
+ packageJsonText: string,
176
+ ): string {
147
177
  const normalizedFacts = {
148
178
  analyzerVersion: ANALYZER_VERSION,
149
179
  packageName: facts.packageName,
@@ -152,12 +182,10 @@ async function repositoryFingerprint(root: string, files: string[], facts: Await
152
182
  cdsRequires: [...facts.cdsRequires].sort((a, b) => a.alias.localeCompare(b.alias)),
153
183
  scripts: Object.fromEntries(Object.entries(facts.scripts).sort()),
154
184
  includeTests: false,
155
- packageJsonHash: sha256Text(packageJson),
185
+ packageJsonHash: sha256Text(packageJsonText),
156
186
  };
157
187
  const entries: string[] = [`facts:${JSON.stringify(normalizedFacts)}`];
158
- for (const file of files) {
159
- const content = await fs.readFile(path.join(root, file), 'utf8');
160
- entries.push(`${file}:${sha256Text(content)}`);
161
- }
188
+ for (const snapshot of sources.entries())
189
+ entries.push(`${snapshot.filePath}:${sha256Text(snapshot.text)}`);
162
190
  return sha256Text(entries.join('\n'));
163
191
  }
@@ -1,16 +1,94 @@
1
1
  import type { Db } from '../db/connection.js';
2
- import { listRepositories, repoByName } from '../db/repositories.js';
2
+ import { listRepositories, reposByName } from '../db/repositories.js';
3
3
  import { errorMessage } from '../utils/diagnostics.js';
4
4
  import { prepareRepositoryIndex, publishPreparedRepositoryIndex, recordIndexFailure, type PreparedRepositoryIndex } from './repository-indexer.js';
5
5
  import { materializeCdsExtensionOperations } from './cds-extension-resolver.js';
6
+ // Ownerless rows predate PID coordination; this matches doctor's stale-run threshold without taking over a recent legacy writer.
7
+ const LEGACY_OWNER_RECOVERY_MS = 60 * 60 * 1_000;
8
+ type RunningIndexRow = Record<string, unknown>;
9
+ function ownerPid(value: unknown): number | undefined {
10
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0
11
+ ? value
12
+ : undefined;
13
+ }
14
+ function processIsAlive(pid: number): boolean {
15
+ try {
16
+ process.kill(pid, 0);
17
+ return true;
18
+ } catch (error) {
19
+ // Only ESRCH proves that ownership ended; permission and platform errors must fail closed.
20
+ const ownerIsMissing = typeof error === 'object'
21
+ && error !== null
22
+ && 'code' in error
23
+ && error.code === 'ESRCH';
24
+ return !ownerIsMissing;
25
+ }
26
+ }
27
+ function isRecoverableRun(row: RunningIndexRow, now: number): boolean {
28
+ const pid = ownerPid(row.ownerPid);
29
+ if (pid !== undefined) return !processIsAlive(pid);
30
+ if (typeof row.startedAt !== 'string') return false;
31
+ const startedAt = Date.parse(row.startedAt);
32
+ return Number.isFinite(startedAt) && now - startedAt >= LEGACY_OWNER_RECOVERY_MS;
33
+ }
34
+ function recoveredOwnerMessage(row: RunningIndexRow): string {
35
+ const pid = ownerPid(row.ownerPid);
36
+ return pid === undefined
37
+ ? 'Recovered stale legacy index writer without owner process metadata.'
38
+ : `Recovered stale index writer because owner process ${pid} is no longer running.`;
39
+ }
40
+ export function claimIndexRun(
41
+ db: Db,
42
+ workspaceId: number,
43
+ repoCount: number,
44
+ ): number {
45
+ // The short write transaction serializes claims without holding a SQLite writer lock during source preparation.
46
+ try {
47
+ return db.transaction(() => {
48
+ const now = Date.now();
49
+ const rows = db
50
+ .prepare("SELECT id,workspace_id workspaceId,owner_pid ownerPid,started_at startedAt FROM index_runs WHERE status='running' ORDER BY id")
51
+ .all();
52
+ const active = rows.find((row) => !isRecoverableRun(row, now));
53
+ if (active) {
54
+ const pid = ownerPid(active.ownerPid);
55
+ const owner = pid === undefined ? 'an unknown owner' : `process ${pid}`;
56
+ throw new Error(`index_writer_active: this database is already being indexed for workspace ${String(active.workspaceId ?? 'unknown')} by ${owner}; wait for that writer to finish.`);
57
+ }
58
+ const finish = db.prepare(
59
+ "UPDATE index_runs SET finished_at=?,status='failed',error_message=? WHERE id=?",
60
+ );
61
+ for (const row of rows)
62
+ finish.run(new Date(now).toISOString(), recoveredOwnerMessage(row), row.id);
63
+ const inserted = db
64
+ .prepare('INSERT INTO index_runs(workspace_id,started_at,status,repo_count,file_count,diagnostic_count,owner_pid) VALUES(?,?,?,?,?,?,?) RETURNING id')
65
+ .get(workspaceId, new Date(now).toISOString(), 'running', repoCount, 0, 0, process.pid);
66
+ const runId = Number(inserted?.id);
67
+ if (!Number.isSafeInteger(runId)) throw new Error('index_writer_claim_failed: SQLite did not return an index run identifier.');
68
+ return runId;
69
+ });
70
+ } catch (error) {
71
+ if (/\b(?:locked|busy)\b/i.test(errorMessage(error)))
72
+ throw new Error(
73
+ 'index_writer_coordination_failed: SQLite remained busy beyond the bounded writer-claim interval; wait for the active publication to finish.',
74
+ { cause: error },
75
+ );
76
+ throw error;
77
+ }
78
+ }
6
79
  export async function indexWorkspace(
7
80
  db: Db,
8
81
  workspaceId: number,
9
82
  options: { repo?: string; force: boolean; injectDerivedMaterializationFailure?: boolean },
10
83
  ): Promise<{ repoCount: number; indexedCount: number; skippedCount: number; fileCount: number; diagnosticCount: number }> {
11
- const started = new Date().toISOString();
12
- const repos = options.repo ? [repoByName(db, options.repo)].filter((r) => r !== undefined) : listRepositories(db);
13
- const runId = Number(db.prepare('INSERT INTO index_runs(workspace_id,started_at,status,repo_count,file_count,diagnostic_count) VALUES(?,?,?,?,?,?) RETURNING id').get(workspaceId, started, 'running', repos.length, 0, 0)?.id);
84
+ const repos = options.repo
85
+ ? reposByName(db, options.repo, workspaceId)
86
+ : listRepositories(db, workspaceId);
87
+ if (options.repo && repos.length === 0)
88
+ throw new Error(`selector_repo_not_found: no indexed repository matched ${options.repo}.`);
89
+ if (options.repo && repos.length > 1)
90
+ throw new Error(`selector_repo_ambiguous: repository selector ${options.repo} matched ${repos.length} repositories; use a unique repository name.`);
91
+ const runId = claimIndexRun(db, workspaceId, repos.length);
14
92
  let fileCount = 0;
15
93
  let diagnosticCount = 0;
16
94
  let skippedCount = 0;
@@ -32,12 +110,12 @@ export async function indexWorkspace(
32
110
  }
33
111
  if (options.injectDerivedMaterializationFailure) throw new Error('Injected derived materialization failure');
34
112
  materializeCdsExtensionOperations(db, workspaceId);
35
- db.prepare('UPDATE index_runs SET finished_at=?, status=?, file_count=?, diagnostic_count=? WHERE id=?').run(new Date().toISOString(), diagnosticCount ? 'failed' : 'success', fileCount, diagnosticCount, runId);
113
+ db.prepare("UPDATE index_runs SET finished_at=?, status='success', file_count=?, diagnostic_count=? WHERE id=?").run(new Date().toISOString(), fileCount, diagnosticCount, runId);
36
114
  });
37
115
  return { repoCount: repos.length, indexedCount: repos.length - skippedCount, skippedCount, fileCount, diagnosticCount };
38
116
  } catch (error) {
39
- if (activeRepoId && preparedRows.length < repos.length) recordIndexFailure(db, activeRepoId, error);
40
117
  db.prepare("UPDATE index_runs SET finished_at=?, status='failed', file_count=?, diagnostic_count=?, error_message=? WHERE id=?").run(new Date().toISOString(), fileCount, diagnosticCount + 1, errorMessage(error), runId);
118
+ if (activeRepoId && preparedRows.length < repos.length) recordIndexFailure(db, activeRepoId, error);
41
119
  throw error;
42
120
  }
43
121
  }
@@ -224,7 +224,15 @@ function compactCandidateScores(candidates: unknown[]): Array<Record<string, unk
224
224
  return candidates.flatMap((candidate): Array<Record<string, unknown>> => {
225
225
  const row = objectValue(candidate);
226
226
  if (!row) return [];
227
- return [{ repo: row.repoName, servicePath: row.servicePath, operationPath: row.operationPath, score: row.score, reasons: row.reasons }];
227
+ return [{
228
+ repo: row.repoName,
229
+ servicePath: row.servicePath,
230
+ operationPath: row.operationPath,
231
+ score: row.score,
232
+ reasons: Array.isArray(row.reasons)
233
+ ? row.reasons.filter((reason): reason is string => typeof reason === 'string')
234
+ : ['operation_path_match'],
235
+ }];
228
236
  });
229
237
  }
230
238
 
@@ -445,7 +453,7 @@ function implementationCandidates(db: Db, workspaceId: number, operation: Record
445
453
  CASE WHEN appRepo.id=handlerRepo.id THEN 1 ELSE 0 END sameRepoRegistration,
446
454
  CASE WHEN EXISTS (SELECT 1 FROM cds_services localService WHERE localService.repo_id=appRepo.id AND localService.service_path=?) THEN 1 ELSE 0 END localServicePathMatch,
447
455
  CASE WHEN EXISTS (SELECT 1 FROM cds_services localService WHERE localService.repo_id=appRepo.id) THEN 1 ELSE 0 END applicationHasLocalServices,
448
- CASE WHEN EXISTS (SELECT 1 FROM handler_registrations localReg JOIN handler_classes localClass ON ((localReg.handler_class_id IS NOT NULL AND localClass.id=localReg.handler_class_id) OR (localReg.handler_class_id IS NULL AND localClass.class_name=localReg.class_name AND localClass.repo_id=localReg.repo_id)) JOIN handler_methods localMethod ON localMethod.handler_class_id=localClass.id WHERE localReg.repo_id=appRepo.id AND (localMethod.decorator_value=? OR localMethod.decorator_value=? OR localMethod.method_name=? OR localMethod.decorator_raw_expression LIKE ?)) THEN 1 ELSE 0 END applicationHasLocalRegistrationForOperation,
456
+ CASE WHEN EXISTS (SELECT 1 FROM handler_registrations localReg JOIN handler_classes localClass ON ((localReg.handler_class_id IS NOT NULL AND localClass.id=localReg.handler_class_id) OR (localReg.handler_class_id IS NULL AND localClass.class_name=localReg.class_name AND localClass.repo_id=localReg.repo_id)) JOIN handler_methods localMethod ON localMethod.handler_class_id=localClass.id WHERE localReg.repo_id=appRepo.id AND COALESCE(json_extract(localMethod.decorator_resolution_json,'$.handlerKind'),CASE WHEN localMethod.decorator_kind='Event' THEN 'event' WHEN localMethod.decorator_kind IN ('Action','Func','On') THEN 'operation' ELSE 'unsupported' END)='operation' AND COALESCE(json_extract(localMethod.decorator_resolution_json,'$.executable'),CASE WHEN localMethod.decorator_kind IN ('Action','Func','On') THEN 1 ELSE 0 END)=1 AND (localMethod.decorator_value=? OR localMethod.decorator_value=? OR localMethod.method_name=? OR localMethod.decorator_raw_expression LIKE ?)) THEN 1 ELSE 0 END applicationHasLocalRegistrationForOperation,
449
457
  CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(appRepo.id AS TEXT) AND dep.to_id=?) THEN 1 ELSE 0 END appDependsOnModel,
450
458
  CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(appRepo.id AS TEXT) AND dep.to_id=CAST(handlerRepo.id AS TEXT)) THEN 1 ELSE 0 END appDependsOnHandler,
451
459
  CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(handlerRepo.id AS TEXT) AND dep.to_id=?) THEN 1 ELSE 0 END handlerDependsOnModel
@@ -477,6 +485,13 @@ function implementationCandidates(db: Db, workspaceId: number, operation: Record
477
485
  )
478
486
  JOIN repositories appRepo ON appRepo.id=hr.repo_id
479
487
  WHERE appRepo.workspace_id=?
488
+ AND COALESCE(json_extract(hm.decorator_resolution_json,'$.handlerKind'),
489
+ CASE WHEN hm.decorator_kind='Event' THEN 'event'
490
+ WHEN hm.decorator_kind IN ('Action','Func','On') THEN 'operation'
491
+ ELSE 'unsupported' END)='operation'
492
+ AND COALESCE(json_extract(hm.decorator_resolution_json,'$.executable'),
493
+ CASE WHEN hm.decorator_kind IN ('Action','Func','On')
494
+ THEN 1 ELSE 0 END)=1
480
495
  AND (hm.decorator_value=? OR hm.decorator_value=? OR hm.method_name=? OR hm.decorator_raw_expression LIKE ?)`).all(
481
496
  operation.modelRepoId,
482
497
  operation.modelRepo,
@@ -605,6 +620,11 @@ function candidateEvidence(candidate: ImplementationCandidate, rank: number): Re
605
620
  };
606
621
  }
607
622
  function implementationMethodSignal(row: Record<string, unknown>, operation: Record<string, unknown>): { matches: boolean; contradicted: boolean; acceptedReasons: string[]; rejectedReasons: string[] } {
623
+ const resolution = objectJson(row.decoratorResolutionJson) ?? {};
624
+ if (resolution.handlerKind && resolution.handlerKind !== 'operation')
625
+ return { matches: false, contradicted: true, acceptedReasons: [], rejectedReasons: ['non_operation_handler_kind'] };
626
+ if (resolution.executable === false)
627
+ return { matches: false, contradicted: true, acceptedReasons: [], rejectedReasons: ['handler_method_not_executable'] };
608
628
  const op = normalizedOperationName(String(operation.operationPath ?? operation.operationName ?? ''));
609
629
  const decorator = normalizeDecoratorOperationSignal(typeof row.decoratorValue === 'string' ? row.decoratorValue : undefined, typeof row.decoratorRawExpression === 'string' ? row.decoratorRawExpression : undefined, op);
610
630
  if (decorator.status === 'resolved' && decorator.operationName === op) return { matches: true, contradicted: false, acceptedReasons: ['decorator targets operation'], rejectedReasons: [] };
@@ -26,9 +26,9 @@ function rows(
26
26
  workspaceId?: number,
27
27
  ): OperationTarget[] {
28
28
  const names = operationLookupNames(operationPath);
29
- return db
29
+ const result = db
30
30
  .prepare(
31
- `SELECT o.id operationId,r.id repoId,r.name repoName,r.package_name packageName,s.service_name serviceName,s.qualified_name qualifiedName,s.service_path servicePath,o.operation_path operationPath,o.operation_name operationName,o.source_file sourceFile,o.source_line sourceLine,0 score,'' reasons
31
+ `SELECT o.id operationId,r.id repoId,r.name repoName,r.package_name packageName,s.service_name serviceName,s.qualified_name qualifiedName,s.service_path servicePath,o.operation_path operationPath,o.operation_name operationName,o.source_file sourceFile,o.source_line sourceLine,0 score
32
32
  FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id
33
33
  WHERE (? IS NULL OR r.workspace_id=?) AND (o.operation_path IN (?,?) OR o.operation_name IN (?,?)) ORDER BY r.name,s.service_path,o.operation_name`,
34
34
  )
@@ -39,7 +39,12 @@ function rows(
39
39
  names.simplePath,
40
40
  names.name,
41
41
  names.simpleName,
42
- ) as unknown as OperationTarget[];
42
+ ) as Array<Omit<OperationTarget, 'reasons'>>;
43
+ return result.map((row) => ({
44
+ ...row,
45
+ score: Number(row.score ?? 0),
46
+ reasons: [],
47
+ }));
43
48
  }
44
49
  function operationLookupNames(operationPath: string): { path: string; simplePath: string; name: string; simpleName: string } {
45
50
  const name = operationPath.replace(/^\//, '');
@@ -70,7 +75,13 @@ export function resolveOperation(
70
75
  if (missing.length > 0)
71
76
  return {
72
77
  status: 'dynamic',
73
- candidates: signals.operationPath ? rows(db, signals.operationPath, workspaceId) : [],
78
+ candidates: signals.operationPath
79
+ ? rows(db, signals.operationPath, workspaceId).map((candidate) => ({
80
+ ...candidate,
81
+ score: 0.2,
82
+ reasons: ['operation_path_match'],
83
+ }))
84
+ : [],
74
85
  reasons: [...new Set(missing)].map((name) => `missing_variable:${name}`),
75
86
  };
76
87
  if (!signals.operationPath)