@saptools/service-flow 0.1.51 → 0.1.53

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 (56) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +16 -14
  3. package/TECHNICAL-NOTE.md +18 -6
  4. package/dist/{chunk-YZJKE5UX.js → chunk-LFH7C46B.js} +4290 -1261
  5. package/dist/chunk-LFH7C46B.js.map +1 -0
  6. package/dist/cli.js +435 -515
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +45 -7
  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/001-doctor-projection.ts +140 -0
  13. package/src/cli/doctor.ts +20 -3
  14. package/src/cli.ts +75 -57
  15. package/src/db/connection.ts +1 -1
  16. package/src/db/migrations.ts +5 -3
  17. package/src/db/repositories.ts +130 -24
  18. package/src/db/schema.ts +7 -2
  19. package/src/indexer/repository-indexer.ts +57 -29
  20. package/src/indexer/workspace-indexer.ts +84 -6
  21. package/src/linker/000-implementation-candidates.ts +641 -0
  22. package/src/linker/001-implementation-evidence-projection.ts +119 -0
  23. package/src/linker/002-call-evidence.ts +226 -0
  24. package/src/linker/cross-repo-linker.ts +27 -441
  25. package/src/linker/dynamic-edge-resolver.ts +35 -0
  26. package/src/linker/external-http-target.ts +24 -3
  27. package/src/linker/helper-package-linker.ts +18 -2
  28. package/src/linker/service-resolver.ts +45 -2
  29. package/src/output/doctor-output.ts +13 -4
  30. package/src/output/table-output.ts +33 -5
  31. package/src/parsers/cds-parser.ts +8 -2
  32. package/src/parsers/decorator-parser.ts +382 -48
  33. package/src/parsers/handler-registration-parser.ts +38 -21
  34. package/src/parsers/imported-wrapper-parser.ts +18 -5
  35. package/src/parsers/outbound-call-parser.ts +25 -8
  36. package/src/parsers/package-json-parser.ts +36 -11
  37. package/src/parsers/service-binding-parser-helpers.ts +8 -1
  38. package/src/parsers/service-binding-parser.ts +6 -3
  39. package/src/parsers/symbol-parser.ts +13 -3
  40. package/src/parsers/ts-project.ts +54 -0
  41. package/src/trace/000-dynamic-target-types.ts +96 -0
  42. package/src/trace/001-dynamic-identity.ts +308 -0
  43. package/src/trace/002-trace-diagnostics.ts +54 -0
  44. package/src/trace/003-dynamic-references.ts +283 -0
  45. package/src/trace/004-dynamic-candidate-sources.ts +155 -0
  46. package/src/trace/005-implementation-selection.ts +187 -0
  47. package/src/trace/006-contextual-projection.ts +30 -0
  48. package/src/trace/007-implementation-start-diagnostic.ts +61 -0
  49. package/src/trace/dynamic-targets.ts +582 -306
  50. package/src/trace/evidence.ts +331 -46
  51. package/src/trace/implementation-hints.ts +148 -8
  52. package/src/trace/selectors.ts +551 -3
  53. package/src/trace/trace-engine.ts +129 -135
  54. package/src/types.ts +27 -1
  55. package/src/utils/000-bounded-projection.ts +161 -0
  56. package/dist/chunk-YZJKE5UX.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  import type { Db } from './connection.js';
2
- import { schemaSql } from './schema.js';
3
- const CURRENT_SCHEMA_VERSION = 10;
2
+ import { schemaIndexesSql, schemaTablesSql } from './schema.js';
3
+ const CURRENT_SCHEMA_VERSION = 11;
4
4
  const columns: Record<string, Array<{ name: string; ddl: string }>> = {
5
5
  handler_methods: [
6
6
  { name: 'decorator_resolution_json', ddl: "ALTER TABLE handler_methods ADD COLUMN decorator_resolution_json TEXT NOT NULL DEFAULT '{}'" },
@@ -57,6 +57,7 @@ const columns: Record<string, Array<{ name: string; ddl: string }>> = {
57
57
  ],
58
58
  index_runs: [
59
59
  { name: 'error_message', ddl: 'ALTER TABLE index_runs ADD COLUMN error_message TEXT' },
60
+ { name: 'owner_pid', ddl: 'ALTER TABLE index_runs ADD COLUMN owner_pid INTEGER' },
60
61
  ],
61
62
  };
62
63
  function hasColumn(db: Db, table: string, column: string): boolean {
@@ -81,8 +82,9 @@ export function migrate(db: Db): void {
81
82
  db.transaction(() => {
82
83
  const version = userVersion(db);
83
84
  if (version > CURRENT_SCHEMA_VERSION) throw new Error(`Unsupported future service-flow schema version ${version}`);
84
- db.exec(schemaSql);
85
+ db.exec(schemaTablesSql);
85
86
  addMissingColumns(db);
87
+ db.exec(schemaIndexesSql);
86
88
  normalizeLegacyStatus(db);
87
89
  const violations = db.pragma('foreign_key_check');
88
90
  if (violations.length > 0) throw new Error('SQLite foreign_key_check failed during migration');
@@ -1,8 +1,10 @@
1
1
  import type { Db } from './connection.js';
2
+ import { projectBounded } from '../utils/000-bounded-projection.js';
2
3
  import type {
3
4
  CdsRequire,
4
5
  CdsServiceFact,
5
6
  HandlerClassFact,
7
+ HandlerMethodFact,
6
8
  HandlerRegistrationFact,
7
9
  OutboundCallFact,
8
10
  ServiceBindingFact,
@@ -85,15 +87,29 @@ export function upsertRepository(
85
87
  .get(workspaceId, r.absolutePath)?.id,
86
88
  );
87
89
  }
88
- export function listRepositories(db: Db): RepoRow[] {
90
+ export function listRepositories(db: Db, workspaceId?: number): RepoRow[] {
89
91
  return db
90
- .prepare('SELECT * FROM repositories ORDER BY name')
91
- .all() as unknown as RepoRow[];
92
+ .prepare('SELECT * FROM repositories WHERE (? IS NULL OR workspace_id=?) ORDER BY name,absolute_path,id')
93
+ .all(workspaceId, workspaceId) as unknown as RepoRow[];
92
94
  }
93
- export function repoByName(db: Db, name: string): RepoRow | undefined {
95
+ export function repoByName(
96
+ db: Db,
97
+ name: string,
98
+ workspaceId?: number,
99
+ ): RepoRow | undefined {
100
+ const matches = reposByName(db, name, workspaceId);
101
+ return matches.length === 1 ? matches[0] : undefined;
102
+ }
103
+ export function reposByName(
104
+ db: Db,
105
+ name: string,
106
+ workspaceId?: number,
107
+ ): RepoRow[] {
94
108
  return db
95
- .prepare('SELECT * FROM repositories WHERE name=? OR package_name=?')
96
- .get(name, name) as RepoRow | undefined;
109
+ .prepare(`SELECT * FROM repositories
110
+ WHERE (? IS NULL OR workspace_id=?) AND (name=? OR package_name=?)
111
+ ORDER BY name,absolute_path,id`)
112
+ .all(workspaceId, workspaceId, name, name) as unknown as RepoRow[];
97
113
  }
98
114
  export function clearRepoFacts(db: Db, repoId: number): void {
99
115
  for (const t of [
@@ -188,21 +204,7 @@ export function insertHandler(
188
204
  repoId: number,
189
205
  h: HandlerClassFact,
190
206
  ): 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
- );
207
+ const sid = insertHandlerClassSymbol(db, repoId, h);
206
208
  const hid = Number(
207
209
  db
208
210
  .prepare(
@@ -220,12 +222,109 @@ export function insertHandler(
220
222
  m.decoratorKind,
221
223
  m.decoratorValue,
222
224
  m.decoratorRawExpression,
223
- JSON.stringify(m.decoratorResolution),
225
+ JSON.stringify(canonicalHandlerMethodResolution(m)),
224
226
  m.sourceFile,
225
227
  m.sourceLine,
226
228
  );
229
+ insertHandlerIndexDiagnostic(db, repoId, h);
227
230
  return hid;
228
231
  }
232
+ function insertHandlerClassSymbol(
233
+ db: Db,
234
+ repoId: number,
235
+ h: HandlerClassFact,
236
+ ): number {
237
+ const classEvidence = {
238
+ hasHandlerDecorator: h.hasHandlerDecorator ?? false,
239
+ classDecoratorNames: h.classDecoratorNames ?? [],
240
+ observedDecoratorNames: h.observedDecoratorNames ?? [],
241
+ unsupportedDecoratorNames: h.unsupportedDecoratorNames ?? [],
242
+ unsupportedMethods: h.methods
243
+ .filter((method) => !handlerMethodIsExecutable(method))
244
+ .map((method) => ({
245
+ methodName: method.methodName,
246
+ decoratorKind: method.decoratorKind,
247
+ sourceFile: method.sourceFile,
248
+ sourceLine: method.sourceLine,
249
+ reason: method.decoratorResolution.unresolvedReason,
250
+ })),
251
+ };
252
+ return Number(
253
+ db
254
+ .prepare(
255
+ 'INSERT INTO symbols(repo_id,kind,name,qualified_name,exported,start_line,end_line,source_file,evidence_json) VALUES(?,?,?,?,?,?,?,?,?) RETURNING id',
256
+ )
257
+ .get(
258
+ repoId,
259
+ 'class',
260
+ h.className,
261
+ h.className,
262
+ 1,
263
+ h.sourceLine,
264
+ h.sourceLine,
265
+ h.sourceFile,
266
+ JSON.stringify(classEvidence),
267
+ )?.id,
268
+ );
269
+ }
270
+ function insertHandlerIndexDiagnostic(
271
+ db: Db,
272
+ repoId: number,
273
+ h: HandlerClassFact,
274
+ ): void {
275
+ if (!h.hasHandlerDecorator) return;
276
+ const hasExecutable = h.methods.some(handlerMethodIsExecutable);
277
+ const unsupported = h.methods.filter((method) =>
278
+ !handlerMethodIsExecutable(method));
279
+ if (hasExecutable && unsupported.length === 0) return;
280
+ const code = hasExecutable
281
+ ? 'handler_decorators_not_indexed'
282
+ : 'handler_methods_not_indexed';
283
+ const names = unsupported.map((method) => method.decoratorKind).sort();
284
+ const detail = names.length > 0
285
+ ? ` Unsupported decorators: ${[...new Set(names)].join(', ')}.`
286
+ : '';
287
+ db.prepare(
288
+ 'INSERT INTO diagnostics(repo_id,severity,code,message,source_file,source_line) VALUES(?,?,?,?,?,?)',
289
+ ).run(
290
+ repoId,
291
+ 'warning',
292
+ code,
293
+ hasExecutable
294
+ ? `Handler class ${h.className} contains methods that were not indexed.${detail}`
295
+ : `Handler class ${h.className} has no indexed executable methods; use a supported CAP handler decorator and re-index.${detail}`,
296
+ h.sourceFile,
297
+ h.sourceLine,
298
+ );
299
+ }
300
+ export function canonicalHandlerMethodResolution(
301
+ method: HandlerMethodFact,
302
+ ): HandlerMethodFact['decoratorResolution'] {
303
+ const handlerKind = method.handlerKind
304
+ ?? method.decoratorResolution.handlerKind
305
+ ?? legacyHandlerKind(method.decoratorKind);
306
+ const executable = method.executable
307
+ ?? method.decoratorResolution.executable
308
+ ?? (handlerKind === 'operation' || handlerKind === 'event'
309
+ || handlerKind === 'entity_lifecycle');
310
+ return {
311
+ ...method.decoratorResolution,
312
+ handlerKind,
313
+ executable,
314
+ lifecyclePhase: method.lifecyclePhase
315
+ ?? method.decoratorResolution.lifecyclePhase,
316
+ lifecycleEvent: method.lifecycleEvent
317
+ ?? method.decoratorResolution.lifecycleEvent,
318
+ };
319
+ }
320
+ export function handlerMethodIsExecutable(method: HandlerMethodFact): boolean {
321
+ return canonicalHandlerMethodResolution(method).executable === true;
322
+ }
323
+ function legacyHandlerKind(kind: string): HandlerMethodFact['handlerKind'] {
324
+ if (kind === 'Event') return 'event';
325
+ if (['Action', 'Func', 'On'].includes(kind)) return 'operation';
326
+ return 'unsupported_decorator';
327
+ }
229
328
  export function insertRegistrations(
230
329
  db: Db,
231
330
  repoId: number,
@@ -498,12 +597,19 @@ function bindingEvidence(
498
597
  candidates: BindingCandidate[],
499
598
  selected?: BindingCandidate,
500
599
  ): Record<string, unknown> {
600
+ const projection = projectBounded(candidates, (left, right) =>
601
+ Number(right.id === selected?.id) - Number(left.id === selected?.id)
602
+ || left.sourceFile.localeCompare(right.sourceFile)
603
+ || left.sourceLine - right.sourceLine
604
+ || left.id - right.id);
501
605
  return {
502
606
  status,
503
- candidateCount: candidates.length,
607
+ candidateCount: projection.totalCount,
608
+ shownCandidateCount: projection.shownCount,
609
+ omittedCandidateCount: projection.omittedCount,
504
610
  selectedBindingId: selected?.id,
505
611
  sourceOrderRule: 'binding_source_line_must_not_follow_call',
506
- candidates: candidates.map((candidate) => ({
612
+ candidates: projection.items.map((candidate) => ({
507
613
  bindingId: candidate.id,
508
614
  symbolId: candidate.symbolId,
509
615
  variableName: candidate.variableName,
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}`;
@@ -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
  }