@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
package/dist/cli.js CHANGED
@@ -1,10 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  classifyODataPathIntent,
4
+ clearRepoFacts,
4
5
  containsSupportedOutboundCall,
5
6
  discoverRepositories,
7
+ getWorkspace,
8
+ handlerMethodIsExecutable,
6
9
  implementationHintSuggestions,
10
+ insertBindings,
11
+ insertCalls,
12
+ insertExecutableSymbols,
13
+ insertHandler,
14
+ insertRegistrations,
15
+ insertRequires,
16
+ insertService,
17
+ insertSymbolCalls,
7
18
  linkWorkspace,
19
+ listRepositories,
20
+ loadPackageJsonSnapshot,
21
+ loadRepositorySourceContext,
8
22
  normalizeODataOperationInvocationPath,
9
23
  normalizePath,
10
24
  parseCdsFile,
@@ -15,13 +29,15 @@ import {
15
29
  parsePackageJson,
16
30
  parseServiceBindings,
17
31
  parseVars,
18
- trace
19
- } from "./chunk-52OUS3MO.js";
32
+ reposByName,
33
+ selectorRepoAmbiguousDiagnostic,
34
+ trace,
35
+ upsertRepository,
36
+ upsertWorkspace
37
+ } from "./chunk-PTLDSHRC.js";
20
38
 
21
39
  // src/cli.ts
22
40
  import { Command } from "commander";
23
- import path6 from "path";
24
- import fs6 from "fs/promises";
25
41
 
26
42
  // src/config/defaults.ts
27
43
  var DEFAULT_IGNORES = [
@@ -93,7 +109,7 @@ import fs2 from "fs";
93
109
  import path2 from "path";
94
110
 
95
111
  // src/db/schema.ts
96
- var schemaSql = `
112
+ var schemaTablesSql = `
97
113
  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);
98
114
  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);
99
115
  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);
@@ -108,8 +124,10 @@ CREATE TABLE IF NOT EXISTS service_bindings (id INTEGER PRIMARY KEY, repo_id INT
108
124
  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);
109
125
  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);
110
126
  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);
111
- 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);
127
+ 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);
112
128
  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);
129
+ `;
130
+ var schemaIndexesSql = `
113
131
  CREATE INDEX IF NOT EXISTS idx_repo_name ON repositories(name);
114
132
  CREATE INDEX IF NOT EXISTS idx_service_path ON cds_services(service_path);
115
133
  CREATE INDEX IF NOT EXISTS idx_extension_import ON cds_services(extension_module_specifier, extension_imported_symbol, is_extend);
@@ -119,9 +137,11 @@ CREATE INDEX IF NOT EXISTS idx_calls_repo ON outbound_calls(repo_id, call_type);
119
137
  CREATE INDEX IF NOT EXISTS idx_symbol_calls_caller ON symbol_calls(repo_id, caller_symbol_id);
120
138
  CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(kind, name, path, repo);
121
139
  `;
140
+ var schemaSql = `${schemaTablesSql}
141
+ ${schemaIndexesSql}`;
122
142
 
123
143
  // src/db/migrations.ts
124
- var CURRENT_SCHEMA_VERSION = 10;
144
+ var CURRENT_SCHEMA_VERSION = 11;
125
145
  var columns = {
126
146
  handler_methods: [
127
147
  { name: "decorator_resolution_json", ddl: "ALTER TABLE handler_methods ADD COLUMN decorator_resolution_json TEXT NOT NULL DEFAULT '{}'" }
@@ -177,7 +197,8 @@ var columns = {
177
197
  { name: "external_target_dynamic", ddl: "ALTER TABLE outbound_calls ADD COLUMN external_target_dynamic INTEGER NOT NULL DEFAULT 0" }
178
198
  ],
179
199
  index_runs: [
180
- { name: "error_message", ddl: "ALTER TABLE index_runs ADD COLUMN error_message TEXT" }
200
+ { name: "error_message", ddl: "ALTER TABLE index_runs ADD COLUMN error_message TEXT" },
201
+ { name: "owner_pid", ddl: "ALTER TABLE index_runs ADD COLUMN owner_pid INTEGER" }
181
202
  ]
182
203
  };
183
204
  function hasColumn(db, table, column) {
@@ -202,8 +223,9 @@ function migrate(db) {
202
223
  db.transaction(() => {
203
224
  const version = userVersion(db);
204
225
  if (version > CURRENT_SCHEMA_VERSION) throw new Error(`Unsupported future service-flow schema version ${version}`);
205
- db.exec(schemaSql);
226
+ db.exec(schemaTablesSql);
206
227
  addMissingColumns(db);
228
+ db.exec(schemaIndexesSql);
207
229
  normalizeLegacyStatus(db);
208
230
  const violations = db.pragma("foreign_key_check");
209
231
  if (violations.length > 0) throw new Error("SQLite foreign_key_check failed during migration");
@@ -214,7 +236,7 @@ function migrate(db) {
214
236
  // package.json
215
237
  var package_default = {
216
238
  name: "@saptools/service-flow",
217
- version: "0.1.50",
239
+ version: "0.1.52",
218
240
  description: "Trace SAP CAP service-to-service flows across multi-repository workspaces with runtime-aware graph resolution",
219
241
  type: "module",
220
242
  publishConfig: {
@@ -356,8 +378,8 @@ function openDatabase(dbPath, options = {}) {
356
378
  },
357
379
  transaction(fn) {
358
380
  if (inTransaction) return fn();
359
- inTransaction = true;
360
381
  native.exec("BEGIN IMMEDIATE");
382
+ inTransaction = true;
361
383
  try {
362
384
  const result = fn();
363
385
  native.exec("COMMIT");
@@ -385,410 +407,6 @@ function openReadOnlyDatabase(dbPath) {
385
407
  return openDatabase(dbPath, { readonly: true, migrate: false });
386
408
  }
387
409
 
388
- // src/db/repositories.ts
389
- function upsertWorkspace(db, rootPath, dbPath) {
390
- const now = (/* @__PURE__ */ new Date()).toISOString();
391
- db.prepare(
392
- "INSERT INTO workspaces(root_path,db_path,created_at,updated_at) VALUES(?,?,?,?) ON CONFLICT(root_path) DO UPDATE SET db_path=excluded.db_path,updated_at=excluded.updated_at"
393
- ).run(rootPath, dbPath, now, now);
394
- return Number(
395
- db.prepare("SELECT id FROM workspaces WHERE root_path=?").get(rootPath)?.id
396
- );
397
- }
398
- function getWorkspace(db, rootPath) {
399
- return db.prepare("SELECT * FROM workspaces WHERE root_path=?").get(rootPath);
400
- }
401
- function upsertRepository(db, workspaceId, r) {
402
- db.prepare(
403
- `INSERT INTO repositories(workspace_id,name,absolute_path,relative_path,package_name,package_version,dependencies_json,kind,is_git_repo) VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(workspace_id,absolute_path) DO UPDATE SET name=excluded.name,relative_path=excluded.relative_path,package_name=excluded.package_name,package_version=excluded.package_version,dependencies_json=excluded.dependencies_json,kind=excluded.kind`
404
- ).run(
405
- workspaceId,
406
- r.name,
407
- r.absolutePath,
408
- r.relativePath,
409
- r.packageName,
410
- r.packageVersion,
411
- JSON.stringify(r.dependencies ?? {}),
412
- r.kind ?? "unknown",
413
- r.isGitRepo ? 1 : 0
414
- );
415
- return Number(
416
- db.prepare(
417
- "SELECT id FROM repositories WHERE workspace_id=? AND absolute_path=?"
418
- ).get(workspaceId, r.absolutePath)?.id
419
- );
420
- }
421
- function listRepositories(db) {
422
- return db.prepare("SELECT * FROM repositories ORDER BY name").all();
423
- }
424
- function repoByName(db, name) {
425
- return db.prepare("SELECT * FROM repositories WHERE name=? OR package_name=?").get(name, name);
426
- }
427
- function clearRepoFacts(db, repoId) {
428
- for (const t of [
429
- "cds_requires",
430
- "cds_services",
431
- "handler_classes",
432
- "outbound_calls",
433
- "symbol_calls",
434
- "handler_registrations",
435
- "service_bindings",
436
- "symbols",
437
- "diagnostics",
438
- "files"
439
- ])
440
- db.prepare(`DELETE FROM ${t} WHERE repo_id=?`).run(repoId);
441
- db.prepare("DELETE FROM search_index WHERE repo=?").run(String(repoId));
442
- }
443
- function insertRequires(db, repoId, rows) {
444
- const stmt = db.prepare(
445
- "INSERT INTO cds_requires(repo_id,alias,kind,model,destination,service_path,request_timeout,raw_json) VALUES(?,?,?,?,?,?,?,?)"
446
- );
447
- for (const r of rows)
448
- stmt.run(
449
- repoId,
450
- r.alias,
451
- r.kind,
452
- r.model,
453
- r.destination,
454
- r.servicePath,
455
- r.requestTimeout,
456
- r.rawJson
457
- );
458
- }
459
- function insertService(db, repoId, s) {
460
- const id = Number(
461
- db.prepare(
462
- "INSERT INTO cds_services(repo_id,namespace,service_name,qualified_name,service_path,is_extend,source_file,source_line,extension_local_ref,extension_imported_symbol,extension_local_alias,extension_module_specifier,extension_import_kind) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id"
463
- ).get(
464
- repoId,
465
- s.namespace,
466
- s.serviceName,
467
- s.qualifiedName,
468
- s.servicePath,
469
- s.isExtend ? 1 : 0,
470
- s.sourceFile,
471
- s.sourceLine,
472
- s.extension?.localReference,
473
- s.extension?.importedSymbol,
474
- s.extension?.localAlias,
475
- s.extension?.moduleSpecifier,
476
- s.extension?.importKind
477
- )?.id
478
- );
479
- const stmt = db.prepare(
480
- "INSERT INTO cds_operations(service_id,operation_type,operation_name,operation_path,params_json,return_type,source_file,source_line,provenance,base_operation_id) VALUES(?,?,?,?,?,?,?,?,?,?)"
481
- );
482
- db.prepare(
483
- "INSERT INTO search_index(kind,name,path,repo) VALUES(?,?,?,?)"
484
- ).run("service", s.qualifiedName, s.servicePath, String(repoId));
485
- for (const o of s.operations)
486
- stmt.run(
487
- id,
488
- o.operationType,
489
- o.operationName,
490
- o.operationPath,
491
- o.paramsJson,
492
- o.returnType,
493
- o.sourceFile,
494
- o.sourceLine,
495
- o.provenance ?? "direct",
496
- o.baseOperationId ?? null
497
- );
498
- const search = db.prepare(
499
- "INSERT INTO search_index(kind,name,path,repo) VALUES(?,?,?,?)"
500
- );
501
- for (const o of s.operations)
502
- search.run("operation", o.operationName, o.operationPath, String(repoId));
503
- return id;
504
- }
505
- function insertHandler(db, repoId, h) {
506
- const sid = Number(
507
- db.prepare(
508
- "INSERT INTO symbols(repo_id,kind,name,qualified_name,exported,start_line,end_line) VALUES(?,?,?,?,?,?,?) RETURNING id"
509
- ).get(
510
- repoId,
511
- "class",
512
- h.className,
513
- h.className,
514
- 1,
515
- h.sourceLine,
516
- h.sourceLine
517
- )?.id
518
- );
519
- const hid = Number(
520
- db.prepare(
521
- "INSERT INTO handler_classes(repo_id,symbol_id,class_name,source_file,source_line) VALUES(?,?,?,?,?) RETURNING id"
522
- ).get(repoId, sid, h.className, h.sourceFile, h.sourceLine)?.id
523
- );
524
- const stmt = db.prepare(
525
- "INSERT INTO handler_methods(handler_class_id,method_name,decorator_kind,decorator_value,decorator_raw_expression,decorator_resolution_json,source_file,source_line) VALUES(?,?,?,?,?,?,?,?)"
526
- );
527
- for (const m of h.methods)
528
- stmt.run(
529
- hid,
530
- m.methodName,
531
- m.decoratorKind,
532
- m.decoratorValue,
533
- m.decoratorRawExpression,
534
- JSON.stringify(m.decoratorResolution),
535
- m.sourceFile,
536
- m.sourceLine
537
- );
538
- return hid;
539
- }
540
- function insertRegistrations(db, repoId, rows) {
541
- const stmt = db.prepare(
542
- "INSERT INTO handler_registrations(repo_id,handler_class_id,class_name,import_source,registration_file,registration_line,registration_kind,confidence) VALUES(?,?,?,?,?,?,?,?)"
543
- );
544
- for (const r of rows) {
545
- const handlerClass = r.className ? db.prepare(
546
- "SELECT id FROM handler_classes WHERE repo_id=? AND class_name=? ORDER BY id"
547
- ).all(repoId, r.className) : [];
548
- stmt.run(
549
- repoId,
550
- handlerClass.length === 1 ? handlerClass[0]?.id : null,
551
- r.className,
552
- r.importSource,
553
- r.registrationFile,
554
- r.registrationLine,
555
- r.registrationKind,
556
- r.confidence
557
- );
558
- }
559
- }
560
- function insertBindings(db, repoId, rows) {
561
- const stmt = db.prepare(
562
- "INSERT INTO service_bindings(repo_id,symbol_id,variable_name,alias,alias_expr,destination_expr,service_path_expr,is_dynamic,placeholders_json,source_file,source_line,helper_chain_json) VALUES(?,(SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND start_line<=? AND end_line>=? ORDER BY (end_line-start_line),id LIMIT 1),?,?,?,?,?,?,?,?,?,?)"
563
- );
564
- for (const r of rows)
565
- stmt.run(
566
- repoId,
567
- repoId,
568
- r.sourceFile,
569
- r.sourceLine,
570
- r.sourceLine,
571
- r.variableName,
572
- r.alias,
573
- r.aliasExpr,
574
- r.destinationExpr,
575
- r.servicePathExpr,
576
- r.isDynamic ? 1 : 0,
577
- JSON.stringify(r.placeholders),
578
- r.sourceFile,
579
- r.sourceLine,
580
- r.helperChain ? JSON.stringify(r.helperChain) : null
581
- );
582
- }
583
- function insertExecutableSymbols(db, repoId, rows) {
584
- const stmt = db.prepare("INSERT INTO symbols(repo_id,file_id,kind,name,qualified_name,exported,start_line,end_line,start_offset,end_offset,source_file,exported_name,evidence_json) VALUES(?,(SELECT id FROM files WHERE repo_id=? AND relative_path=?),?,?,?,?,?,?,?,?,?,?,?)");
585
- for (const r of rows) stmt.run(repoId, repoId, r.sourceFile, r.kind, r.localName, r.qualifiedName, r.exported ? 1 : 0, r.startLine, r.endLine, r.startOffset, r.endOffset, r.sourceFile, r.exportedName, r.importExportEvidence ? JSON.stringify(r.importExportEvidence) : null);
586
- }
587
- function insertSymbolCalls(db, repoId, rows) {
588
- const callerStmt = db.prepare("SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND qualified_name=? ORDER BY id LIMIT 1");
589
- const insertStmt = db.prepare("INSERT INTO symbol_calls(repo_id,caller_symbol_id,callee_symbol_id,callee_expression,import_source,source_file,source_line,status,confidence,evidence_json,unresolved_reason) VALUES(?,?,?,?,?,?,?,?,?,?,?)");
590
- for (const r of rows) {
591
- const caller = callerStmt.get(repoId, r.sourceFile, r.callerQualifiedName);
592
- const target = resolveSymbolCallTarget(db, repoId, r);
593
- insertStmt.run(repoId, caller?.id, target.id, r.calleeExpression, r.importSource, r.sourceFile, r.sourceLine, target.status, 0.8, JSON.stringify({ ...r.evidence, candidateStrategy: target.strategy, candidateCount: target.candidateCount }), target.reason);
594
- }
595
- }
596
- function isRelativeImportedSymbolCall(r) {
597
- return Boolean(r.importSource?.startsWith("."));
598
- }
599
- function resolveSymbolCallTarget(db, repoId, r) {
600
- const evidence = r.evidence;
601
- const localRows = db.prepare("SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND (name=? OR qualified_name=?) ORDER BY id").all(repoId, r.sourceFile, r.calleeLocalName, r.calleeLocalName);
602
- if (localRows.length === 1) return { id: localRows[0]?.id ?? null, status: "resolved", reason: null, strategy: "same_file_exact", candidateCount: 1 };
603
- if (localRows.length > 1) return { id: null, status: "ambiguous", reason: "Multiple same-file symbol targets matched exactly", strategy: "same_file_exact", candidateCount: localRows.length };
604
- if (evidence.relation === "class_instance_method" && isRelativeImportedSymbolCall(r)) {
605
- const classRows = db.prepare("SELECT id FROM symbols WHERE repo_id=? AND source_file<>? AND qualified_name=? ORDER BY id").all(repoId, r.sourceFile, r.calleeLocalName);
606
- if (classRows.length === 1) return { id: classRows[0]?.id ?? null, status: "resolved", reason: null, strategy: "relative_import_class_instance_method", candidateCount: 1 };
607
- if (classRows.length > 1) return { id: null, status: "ambiguous", reason: "Multiple relative class instance method targets matched exactly", strategy: "relative_import_class_instance_method", candidateCount: classRows.length };
608
- }
609
- const rows = db.prepare("SELECT id,kind,evidence_json evidenceJson FROM symbols WHERE repo_id=? AND source_file<>? AND exported=1 AND (exported_name=? OR name=? OR qualified_name=?) ORDER BY id").all(repoId, r.sourceFile, r.calleeLocalName, r.calleeLocalName, r.calleeLocalName);
610
- if (evidence.relation === "relative_import_proxy_member" && rows.length > 1) {
611
- const objectMapRows = rows.filter((row) => String(row.evidenceJson ?? "").includes("exported_object_shorthand") || String(row.evidenceJson ?? "").includes("exported_object_literal"));
612
- if (objectMapRows.length > 0) {
613
- const concrete = rows.find((row) => row.kind !== "object_alias") ?? objectMapRows[0];
614
- return { id: concrete?.id ?? null, status: "resolved", reason: null, strategy: "proxy_member_exported_object_map", candidateCount: rows.length };
615
- }
616
- return { id: null, status: "ambiguous", reason: "Proxy member target requires explicit factory/module/type evidence; global member name is ambiguous", strategy: "proxy_member_no_global_name_fallback", candidateCount: rows.length };
617
- }
618
- if (rows.length === 1) return { id: rows[0]?.id ?? null, status: "resolved", reason: null, strategy: evidence.relation === "relative_import_proxy_member" ? "proxy_member_unique_exported_candidate" : "relative_import_exported_exact", candidateCount: 1 };
619
- if (rows.length > 1) return { id: null, status: "ambiguous", reason: "Multiple exported symbol targets matched exactly", strategy: "exported_exact", candidateCount: rows.length };
620
- return { id: null, status: "unresolved", reason: "No local symbol target matched exactly", strategy: evidence.relation === "relative_import_proxy_member" ? "proxy_member_no_global_name_fallback" : "exact_symbol_match", candidateCount: 0 };
621
- }
622
- function insertCalls(db, repoId, rows) {
623
- const stmt = db.prepare(
624
- "INSERT INTO outbound_calls(repo_id,source_symbol_id,call_type,method,operation_path_expr,query_entity,event_name_expr,payload_summary,source_file,source_line,confidence,unresolved_reason,local_service_name,local_service_lookup,alias_chain_json,evidence_json,external_target_kind,external_target_id,external_target_label,external_target_dynamic,service_binding_id) VALUES(?,COALESCE((SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND qualified_name=? ORDER BY id LIMIT 1),(SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND start_line<=? AND end_line>=? ORDER BY (end_line-start_line),id LIMIT 1)),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
625
- );
626
- for (const r of rows) {
627
- const binding = resolvePersistedBinding(db, repoId, r);
628
- const evidence = {
629
- ...r.evidence ?? {},
630
- serviceBindingResolution: binding.evidence
631
- };
632
- stmt.run(
633
- repoId,
634
- repoId,
635
- r.sourceFile,
636
- r.sourceSymbolQualifiedName,
637
- repoId,
638
- r.sourceFile,
639
- r.sourceLine,
640
- r.sourceLine,
641
- r.callType,
642
- r.method,
643
- r.operationPathExpr,
644
- r.queryEntity,
645
- r.eventNameExpr,
646
- r.payloadSummary,
647
- r.sourceFile,
648
- r.sourceLine,
649
- r.confidence,
650
- r.unresolvedReason ?? binding.unresolvedReason,
651
- r.localServiceName,
652
- r.localServiceLookup,
653
- r.aliasChain ? JSON.stringify(r.aliasChain) : null,
654
- JSON.stringify(evidence),
655
- r.externalTarget?.kind ?? null,
656
- r.externalTarget?.stableId ?? null,
657
- r.externalTarget?.label ?? null,
658
- r.externalTarget?.dynamic ? 1 : 0,
659
- binding.bindingId
660
- );
661
- }
662
- }
663
- function resolvePersistedBinding(db, repoId, call) {
664
- if (!call.serviceVariableName)
665
- return { bindingId: null, evidence: { status: "not_applicable", candidateCount: 0 } };
666
- const candidates = bindingCandidates(db, repoId, call);
667
- const prior = candidates.filter((candidate) => candidate.sourceLine <= call.sourceLine);
668
- const families = new Set(prior.map(bindingSignature));
669
- if (prior.length > 0 && families.size === 1) {
670
- const selected = prior.at(-1);
671
- return {
672
- bindingId: selected?.id ?? null,
673
- evidence: bindingEvidence("selected", prior, selected)
674
- };
675
- }
676
- if (prior.length > 1) {
677
- return {
678
- bindingId: null,
679
- unresolvedReason: "ambiguous_service_binding_candidates",
680
- evidence: bindingEvidence("ambiguous", prior)
681
- };
682
- }
683
- if (candidates.length > 0) {
684
- return {
685
- bindingId: null,
686
- unresolvedReason: "service_binding_declared_after_call",
687
- evidence: bindingEvidence("rejected_future_binding", candidates)
688
- };
689
- }
690
- return {
691
- bindingId: null,
692
- evidence: bindingEvidence("unrecoverable", [])
693
- };
694
- }
695
- function bindingCandidates(db, repoId, call) {
696
- const ownerId = callSymbolId(db, repoId, call);
697
- const rows = db.prepare(`
698
- SELECT id,symbol_id symbolId,variable_name variableName,alias,alias_expr aliasExpr,
699
- destination_expr destinationExpr,service_path_expr servicePathExpr,
700
- source_file sourceFile,source_line sourceLine,helper_chain_json helperChainJson
701
- FROM service_bindings
702
- WHERE repo_id=? AND variable_name=? AND source_file=?
703
- AND (? IS NULL OR symbol_id IS NULL OR symbol_id=?)
704
- ORDER BY source_line,id
705
- `).all(
706
- repoId,
707
- call.serviceVariableName,
708
- call.sourceFile,
709
- ownerId,
710
- ownerId
711
- );
712
- return rows.flatMap((row) => {
713
- if (typeof row.id !== "number" || typeof row.variableName !== "string" || typeof row.sourceFile !== "string" || typeof row.sourceLine !== "number")
714
- return [];
715
- return [{
716
- id: row.id,
717
- symbolId: nullableNumber(row.symbolId),
718
- variableName: row.variableName,
719
- alias: nullableString(row.alias),
720
- aliasExpr: nullableString(row.aliasExpr),
721
- destinationExpr: nullableString(row.destinationExpr),
722
- servicePathExpr: nullableString(row.servicePathExpr),
723
- sourceFile: row.sourceFile,
724
- sourceLine: row.sourceLine,
725
- helperChainJson: nullableString(row.helperChainJson)
726
- }];
727
- });
728
- }
729
- function nullableString(value) {
730
- return value === null || typeof value === "string" ? value : void 0;
731
- }
732
- function nullableNumber(value) {
733
- return value === null || typeof value === "number" ? value : void 0;
734
- }
735
- function callSymbolId(db, repoId, call) {
736
- const row = db.prepare(`
737
- SELECT id FROM symbols
738
- WHERE repo_id=? AND source_file=?
739
- AND ((? IS NOT NULL AND qualified_name=?)
740
- OR (start_line<=? AND end_line>=?))
741
- ORDER BY CASE WHEN qualified_name=? THEN 0 ELSE 1 END,
742
- (end_line-start_line),id
743
- LIMIT 1
744
- `).get(
745
- repoId,
746
- call.sourceFile,
747
- call.sourceSymbolQualifiedName,
748
- call.sourceSymbolQualifiedName,
749
- call.sourceLine,
750
- call.sourceLine,
751
- call.sourceSymbolQualifiedName
752
- );
753
- return typeof row?.id === "number" ? row.id : void 0;
754
- }
755
- function bindingEvidence(status, candidates, selected) {
756
- return {
757
- status,
758
- candidateCount: candidates.length,
759
- selectedBindingId: selected?.id,
760
- sourceOrderRule: "binding_source_line_must_not_follow_call",
761
- candidates: candidates.map((candidate) => ({
762
- bindingId: candidate.id,
763
- symbolId: candidate.symbolId,
764
- variableName: candidate.variableName,
765
- alias: candidate.alias,
766
- aliasExpr: candidate.aliasExpr,
767
- destinationExpr: candidate.destinationExpr,
768
- servicePathExpr: candidate.servicePathExpr,
769
- sourceFile: candidate.sourceFile,
770
- sourceLine: candidate.sourceLine,
771
- helperChain: parseBindingChain(candidate.helperChainJson)
772
- }))
773
- };
774
- }
775
- function bindingSignature(candidate) {
776
- return JSON.stringify([
777
- candidate.alias,
778
- candidate.aliasExpr,
779
- candidate.destinationExpr,
780
- candidate.servicePathExpr
781
- ]);
782
- }
783
- function parseBindingChain(value) {
784
- if (!value) return void 0;
785
- try {
786
- return JSON.parse(value);
787
- } catch {
788
- return void 0;
789
- }
790
- }
791
-
792
410
  // src/discovery/classify-repository.ts
793
411
  import fs3 from "fs/promises";
794
412
  import path3 from "path";
@@ -1021,9 +639,16 @@ function parameterBindings(params) {
1021
639
  return [];
1022
640
  });
1023
641
  }
1024
- async function parseExecutableSymbols(repoPath, filePath) {
1025
- const text = await fs4.readFile(path4.join(repoPath, filePath), "utf8");
1026
- const source = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, filePath.endsWith(".ts") ? ts.ScriptKind.TS : ts.ScriptKind.JS);
642
+ async function parseExecutableSymbols(repoPath, filePath, context) {
643
+ const snapshot = context?.get(filePath);
644
+ const text = snapshot?.text ?? await fs4.readFile(path4.join(repoPath, filePath), "utf8");
645
+ const source = snapshot?.sourceFile() ?? ts.createSourceFile(
646
+ filePath,
647
+ text,
648
+ ts.ScriptTarget.Latest,
649
+ true,
650
+ filePath.endsWith(".ts") ? ts.ScriptKind.TS : ts.ScriptKind.JS
651
+ );
1027
652
  const sourceFile = normalizePath(filePath);
1028
653
  const symbols = [];
1029
654
  const calls = [];
@@ -1199,27 +824,38 @@ async function parseExecutableSymbols(repoPath, filePath) {
1199
824
  // src/utils/hashing.ts
1200
825
  import { createHash } from "crypto";
1201
826
  import { readFile } from "fs/promises";
1202
- async function sha256File(filePath) {
1203
- return createHash("sha256").update(await readFile(filePath)).digest("hex");
1204
- }
1205
827
  function sha256Text(text) {
1206
828
  return createHash("sha256").update(text).digest("hex");
1207
829
  }
1208
830
 
1209
831
  // src/indexer/repository-indexer.ts
1210
- async function prepareRepositoryIndex(repo, force) {
832
+ async function prepareRepositoryIndex(repo, force, instrumentation) {
1211
833
  const sourceFiles = await findSourceFiles(repo.absolute_path);
1212
- const packageFacts = await parsePackageJson(repo.absolute_path);
1213
- const fingerprint = await repositoryFingerprint(repo.absolute_path, sourceFiles, packageFacts);
834
+ const packageSnapshot = await loadPackageJsonSnapshot(repo.absolute_path, {
835
+ strict: true,
836
+ allowMissing: repo.package_name === null
837
+ });
838
+ const packageFacts = packageSnapshot.facts;
839
+ const sources = await loadRepositorySourceContext(
840
+ repo.absolute_path,
841
+ sourceFiles,
842
+ instrumentation
843
+ );
844
+ const fingerprint = repositoryFingerprint(
845
+ sources,
846
+ packageFacts,
847
+ packageSnapshot.rawText
848
+ );
1214
849
  if (!force && repo.fingerprint === fingerprint) return { repo, fileCount: 0, diagnosticCount: 0, skipped: true };
850
+ const parsed = await parseAllSourceFacts(repo.absolute_path, sources);
1215
851
  return {
1216
852
  repo,
1217
853
  packageFacts,
1218
854
  fingerprint,
1219
855
  kind: await classifyRepository(repo.absolute_path, packageFacts),
1220
- parsed: await parseAllSourceFacts(repo.absolute_path, sourceFiles),
856
+ parsed,
1221
857
  fileCount: sourceFiles.length,
1222
- diagnosticCount: 0,
858
+ diagnosticCount: parsed.handlers.filter((handler) => handler.hasHandlerDecorator && (handler.methods.length === 0 || handler.methods.some((method) => !handlerMethodIsExecutable(method)))).length,
1223
859
  skipped: false
1224
860
  };
1225
861
  }
@@ -1248,21 +884,20 @@ function recordIndexFailure(db, repoId, error) {
1248
884
  db.prepare("DELETE FROM diagnostics WHERE repo_id=? AND code IN ('index_failed_snapshot_preserved','source_read_failed')").run(repoId);
1249
885
  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}`);
1250
886
  }
1251
- async function parseAllSourceFacts(root, files) {
887
+ async function parseAllSourceFacts(root, sources) {
1252
888
  const facts = { services: [], handlers: [], registrations: [], bindings: [], calls: [], symbols: [], symbolCalls: [], fileRecords: [] };
1253
- for (const file of files) {
1254
- const abs = path5.join(root, file);
1255
- const stat = await fs5.stat(abs);
1256
- facts.fileRecords.push({ relativePath: normalizePath(file), extension: path5.extname(file), sha256: await sha256File(abs), sizeBytes: stat.size });
1257
- if (file.endsWith(".cds")) facts.services.push(...await parseCdsFile(root, file));
889
+ for (const snapshot of sources.entries()) {
890
+ const file = snapshot.filePath;
891
+ facts.fileRecords.push({ relativePath: normalizePath(file), extension: path5.extname(file), sha256: sha256Text(snapshot.text), sizeBytes: snapshot.sizeBytes });
892
+ if (file.endsWith(".cds")) facts.services.push(...await parseCdsFile(root, file, sources));
1258
893
  if (/\.[jt]s$/.test(file)) {
1259
- facts.handlers.push(...await parseDecorators(root, file));
1260
- facts.registrations.push(...await parseHandlerRegistrations(root, file));
1261
- facts.bindings.push(...await parseServiceBindings(root, file));
1262
- const symbolFacts = await parseExecutableSymbols(root, file);
894
+ facts.handlers.push(...await parseDecorators(root, file, sources));
895
+ facts.registrations.push(...await parseHandlerRegistrations(root, file, sources));
896
+ facts.bindings.push(...await parseServiceBindings(root, file, sources));
897
+ const symbolFacts = await parseExecutableSymbols(root, file, sources);
1263
898
  facts.symbols.push(...symbolFacts.symbols);
1264
899
  facts.symbolCalls.push(...symbolFacts.calls);
1265
- facts.calls.push(...await parseOutboundCalls(root, file));
900
+ facts.calls.push(...await parseOutboundCalls(root, file, sources));
1266
901
  }
1267
902
  }
1268
903
  return facts;
@@ -1270,7 +905,7 @@ async function parseAllSourceFacts(root, files) {
1270
905
  async function findSourceFiles(root) {
1271
906
  const out = [];
1272
907
  async function walk(dir, prefix = "") {
1273
- const entries = await fs5.readdir(dir, { withFileTypes: true }).catch(() => []);
908
+ const entries = await fs5.readdir(dir, { withFileTypes: true });
1274
909
  for (const e of entries) {
1275
910
  const rel = prefix ? `${prefix}/${e.name}` : e.name;
1276
911
  if (e.isDirectory()) {
@@ -1286,8 +921,7 @@ function isDefaultTestFile(relativeFile) {
1286
921
  if (parts.some((part) => ["test", "tests", "__tests__"].includes(part))) return true;
1287
922
  return /\.(test|spec)\.[jt]s$/.test(parts.at(-1) ?? "");
1288
923
  }
1289
- async function repositoryFingerprint(root, files, facts) {
1290
- const packageJson = await fs5.readFile(path5.join(root, "package.json"), "utf8").catch(() => "");
924
+ function repositoryFingerprint(sources, facts, packageJsonText) {
1291
925
  const normalizedFacts = {
1292
926
  analyzerVersion: ANALYZER_VERSION,
1293
927
  packageName: facts.packageName,
@@ -1296,13 +930,11 @@ async function repositoryFingerprint(root, files, facts) {
1296
930
  cdsRequires: [...facts.cdsRequires].sort((a, b) => a.alias.localeCompare(b.alias)),
1297
931
  scripts: Object.fromEntries(Object.entries(facts.scripts).sort()),
1298
932
  includeTests: false,
1299
- packageJsonHash: sha256Text(packageJson)
933
+ packageJsonHash: sha256Text(packageJsonText)
1300
934
  };
1301
935
  const entries = [`facts:${JSON.stringify(normalizedFacts)}`];
1302
- for (const file of files) {
1303
- const content = await fs5.readFile(path5.join(root, file), "utf8");
1304
- entries.push(`${file}:${sha256Text(content)}`);
1305
- }
936
+ for (const snapshot of sources.entries())
937
+ entries.push(`${snapshot.filePath}:${sha256Text(snapshot.text)}`);
1306
938
  return sha256Text(entries.join("\n"));
1307
939
  }
1308
940
 
@@ -1406,10 +1038,67 @@ function packageNameFromSpecifier(specifier) {
1406
1038
  }
1407
1039
 
1408
1040
  // src/indexer/workspace-indexer.ts
1041
+ var LEGACY_OWNER_RECOVERY_MS = 60 * 60 * 1e3;
1042
+ function ownerPid(value) {
1043
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : void 0;
1044
+ }
1045
+ function processIsAlive(pid) {
1046
+ try {
1047
+ process.kill(pid, 0);
1048
+ return true;
1049
+ } catch (error) {
1050
+ const ownerIsMissing = typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH";
1051
+ return !ownerIsMissing;
1052
+ }
1053
+ }
1054
+ function isRecoverableRun(row, now) {
1055
+ const pid = ownerPid(row.ownerPid);
1056
+ if (pid !== void 0) return !processIsAlive(pid);
1057
+ if (typeof row.startedAt !== "string") return false;
1058
+ const startedAt = Date.parse(row.startedAt);
1059
+ return Number.isFinite(startedAt) && now - startedAt >= LEGACY_OWNER_RECOVERY_MS;
1060
+ }
1061
+ function recoveredOwnerMessage(row) {
1062
+ const pid = ownerPid(row.ownerPid);
1063
+ return pid === void 0 ? "Recovered stale legacy index writer without owner process metadata." : `Recovered stale index writer because owner process ${pid} is no longer running.`;
1064
+ }
1065
+ function claimIndexRun(db, workspaceId, repoCount) {
1066
+ try {
1067
+ return db.transaction(() => {
1068
+ const now = Date.now();
1069
+ const rows = db.prepare("SELECT id,workspace_id workspaceId,owner_pid ownerPid,started_at startedAt FROM index_runs WHERE status='running' ORDER BY id").all();
1070
+ const active = rows.find((row) => !isRecoverableRun(row, now));
1071
+ if (active) {
1072
+ const pid = ownerPid(active.ownerPid);
1073
+ const owner = pid === void 0 ? "an unknown owner" : `process ${pid}`;
1074
+ 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.`);
1075
+ }
1076
+ const finish = db.prepare(
1077
+ "UPDATE index_runs SET finished_at=?,status='failed',error_message=? WHERE id=?"
1078
+ );
1079
+ for (const row of rows)
1080
+ finish.run(new Date(now).toISOString(), recoveredOwnerMessage(row), row.id);
1081
+ const inserted = db.prepare("INSERT INTO index_runs(workspace_id,started_at,status,repo_count,file_count,diagnostic_count,owner_pid) VALUES(?,?,?,?,?,?,?) RETURNING id").get(workspaceId, new Date(now).toISOString(), "running", repoCount, 0, 0, process.pid);
1082
+ const runId = Number(inserted?.id);
1083
+ if (!Number.isSafeInteger(runId)) throw new Error("index_writer_claim_failed: SQLite did not return an index run identifier.");
1084
+ return runId;
1085
+ });
1086
+ } catch (error) {
1087
+ if (/\b(?:locked|busy)\b/i.test(errorMessage(error)))
1088
+ throw new Error(
1089
+ "index_writer_coordination_failed: SQLite remained busy beyond the bounded writer-claim interval; wait for the active publication to finish.",
1090
+ { cause: error }
1091
+ );
1092
+ throw error;
1093
+ }
1094
+ }
1409
1095
  async function indexWorkspace(db, workspaceId, options) {
1410
- const started = (/* @__PURE__ */ new Date()).toISOString();
1411
- const repos = options.repo ? [repoByName(db, options.repo)].filter((r) => r !== void 0) : listRepositories(db);
1412
- 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);
1096
+ const repos = options.repo ? reposByName(db, options.repo, workspaceId) : listRepositories(db, workspaceId);
1097
+ if (options.repo && repos.length === 0)
1098
+ throw new Error(`selector_repo_not_found: no indexed repository matched ${options.repo}.`);
1099
+ if (options.repo && repos.length > 1)
1100
+ throw new Error(`selector_repo_ambiguous: repository selector ${options.repo} matched ${repos.length} repositories; use a unique repository name.`);
1101
+ const runId = claimIndexRun(db, workspaceId, repos.length);
1413
1102
  let fileCount = 0;
1414
1103
  let diagnosticCount = 0;
1415
1104
  let skippedCount = 0;
@@ -1431,12 +1120,12 @@ async function indexWorkspace(db, workspaceId, options) {
1431
1120
  }
1432
1121
  if (options.injectDerivedMaterializationFailure) throw new Error("Injected derived materialization failure");
1433
1122
  materializeCdsExtensionOperations(db, workspaceId);
1434
- db.prepare("UPDATE index_runs SET finished_at=?, status=?, file_count=?, diagnostic_count=? WHERE id=?").run((/* @__PURE__ */ new Date()).toISOString(), diagnosticCount ? "failed" : "success", fileCount, diagnosticCount, runId);
1123
+ db.prepare("UPDATE index_runs SET finished_at=?, status='success', file_count=?, diagnostic_count=? WHERE id=?").run((/* @__PURE__ */ new Date()).toISOString(), fileCount, diagnosticCount, runId);
1435
1124
  });
1436
1125
  return { repoCount: repos.length, indexedCount: repos.length - skippedCount, skippedCount, fileCount, diagnosticCount };
1437
1126
  } catch (error) {
1438
- if (activeRepoId && preparedRows.length < repos.length) recordIndexFailure(db, activeRepoId, error);
1439
1127
  db.prepare("UPDATE index_runs SET finished_at=?, status='failed', file_count=?, diagnostic_count=?, error_message=? WHERE id=?").run((/* @__PURE__ */ new Date()).toISOString(), fileCount, diagnosticCount + 1, errorMessage(error), runId);
1128
+ if (activeRepoId && preparedRows.length < repos.length) recordIndexFailure(db, activeRepoId, error);
1440
1129
  throw error;
1441
1130
  }
1442
1131
  }
@@ -2050,21 +1739,63 @@ function renderTraceTable(result) {
2050
1739
  }
2051
1740
  function diagnosticLines(diagnostic) {
2052
1741
  const first = `${String(diagnostic.severity ?? "info")} ${String(diagnostic.code ?? "diagnostic")} ${String(diagnostic.message ?? "")}`;
2053
- return [first, ...hintLines(diagnostic).map((hint) => ` ${hint}`)];
1742
+ const details = diagnosticDetailLines(diagnostic);
1743
+ return [first, ...[...details, ...hintLines(diagnostic)].map((hint) => ` ${hint}`)];
1744
+ }
1745
+ function diagnosticDetailLines(diagnostic) {
1746
+ const lines = [];
1747
+ if (diagnostic.sourceFile || diagnostic.sourceLine)
1748
+ lines.push(`at ${String(diagnostic.sourceFile ?? "")}:${String(diagnostic.sourceLine ?? "")}`);
1749
+ const unsupported = stringList(diagnostic.unsupportedDecoratorNames);
1750
+ const observed = stringList(diagnostic.observedDecoratorNames);
1751
+ if (unsupported.length > 0)
1752
+ lines.push(`unsupported decorators: ${unsupported.join(", ")}`);
1753
+ else if (observed.length > 0)
1754
+ lines.push(`observed decorators: ${observed.join(", ")}`);
1755
+ if (typeof diagnostic.remediation === "string")
1756
+ lines.push(`hint: ${diagnostic.remediation}`);
1757
+ return lines;
1758
+ }
1759
+ function stringList(value) {
1760
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
2054
1761
  }
2055
1762
  function hintLines(evidence) {
1763
+ const dynamicLines = dynamicHintLines(evidence);
2056
1764
  const suggestions = evidence.implementationHintSuggestions;
2057
- if (!Array.isArray(suggestions)) return [];
1765
+ if (!Array.isArray(suggestions)) return dynamicLines;
2058
1766
  const hints = suggestions.flatMap((item) => isRecord(item) && typeof item.cli === "string" ? [item.cli] : []);
2059
1767
  const unique = [...new Set(hints)];
2060
1768
  const shown = unique.slice(0, 3).map((hint) => `try ${hint}`);
2061
1769
  if (unique.length > shown.length)
2062
1770
  shown.push(`... ${unique.length - shown.length} more hint(s) available in JSON`);
2063
- return shown;
1771
+ return [...dynamicLines, ...shown];
1772
+ }
1773
+ function dynamicHintLines(evidence) {
1774
+ const exploration = isRecord(evidence.dynamicTargetExploration) ? evidence.dynamicTargetExploration : evidence;
1775
+ const count = numberValue(exploration.candidateCount);
1776
+ if (count === 0) return [];
1777
+ const shown = numberValue(exploration.shownCandidateCount);
1778
+ const omitted = numberValue(exploration.omittedCandidateCount);
1779
+ const rejected = numberValue(exploration.rejectedCandidateCount);
1780
+ const lines = [
1781
+ `viable candidates: ${shown} shown, ${omitted} omitted; rejected: ${rejected}`
1782
+ ];
1783
+ lines.push(...varSetHints(exploration.suggestedVarSets));
1784
+ if (omitted > 0 || rejected > 0 || shown < count)
1785
+ lines.push("use --dynamic-mode candidates --max-dynamic-candidates 20 to explore candidate branches");
1786
+ return lines;
1787
+ }
1788
+ function varSetHints(value) {
1789
+ if (!Array.isArray(value)) return [];
1790
+ const hints = value.flatMap((item) => isRecord(item) && typeof item.cli === "string" ? [`try ${item.cli}`] : []);
1791
+ return [...new Set(hints)].slice(0, 3);
2064
1792
  }
2065
1793
  function isRecord(value) {
2066
1794
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
2067
1795
  }
1796
+ function numberValue(value) {
1797
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
1798
+ }
2068
1799
 
2069
1800
  // src/output/json-output.ts
2070
1801
  function renderJson(value) {
@@ -2179,6 +1910,65 @@ function renderMermaid(trace2) {
2179
1910
  `;
2180
1911
  }
2181
1912
 
1913
+ // src/cli/000-clean.ts
1914
+ import fs6 from "fs/promises";
1915
+ import path6 from "path";
1916
+ async function cleanWorkspaceState(config, dbOnly) {
1917
+ const dbDir = path6.resolve(path6.dirname(config.dbPath));
1918
+ if (!dbOnly) await assertOwnedStateDirectory(dbDir, config.rootPath);
1919
+ const runId = claimCleanWriter(config);
1920
+ try {
1921
+ if (dbOnly) await removeDatabaseFiles(config.dbPath);
1922
+ else await fs6.rm(dbDir, { recursive: true, force: true });
1923
+ } catch (error) {
1924
+ await markCleanClaimFailed(config.dbPath, runId, error);
1925
+ throw error;
1926
+ }
1927
+ }
1928
+ function claimCleanWriter(config) {
1929
+ const db = openDatabase(config.dbPath);
1930
+ try {
1931
+ const workspaceId = getWorkspace(db, config.rootPath)?.id ?? upsertWorkspace(db, config.rootPath, config.dbPath);
1932
+ return claimIndexRun(db, workspaceId, 0);
1933
+ } finally {
1934
+ db.close();
1935
+ }
1936
+ }
1937
+ async function assertOwnedStateDirectory(dbDir, rootPath) {
1938
+ const marker = path6.join(dbDir, ".service-flow-state");
1939
+ const dangerous = /* @__PURE__ */ new Set([
1940
+ path6.parse(dbDir).root,
1941
+ "/tmp",
1942
+ process.env.HOME ? path6.resolve(process.env.HOME) : "",
1943
+ path6.resolve(rootPath)
1944
+ ]);
1945
+ const ownsState = await fs6.stat(marker).then((stat) => stat.isFile()).catch(() => false);
1946
+ if (!ownsState || dangerous.has(dbDir))
1947
+ throw new Error(
1948
+ `Refusing to recursively delete unowned or dangerous state directory: ${dbDir}. Use --db-only to remove only the database file.`
1949
+ );
1950
+ }
1951
+ async function removeDatabaseFiles(dbPath) {
1952
+ for (const suffix of ["-wal", "-shm", "-journal"])
1953
+ await fs6.rm(`${dbPath}${suffix}`, { force: true });
1954
+ await fs6.rm(dbPath, { force: true });
1955
+ }
1956
+ async function markCleanClaimFailed(dbPath, runId, error) {
1957
+ const exists = await fs6.stat(dbPath).then(() => true).catch(() => false);
1958
+ if (!exists) return;
1959
+ const db = openDatabase(dbPath);
1960
+ try {
1961
+ db.prepare(`UPDATE index_runs SET finished_at=?,status='failed',
1962
+ error_message=? WHERE id=? AND status='running'`).run(
1963
+ (/* @__PURE__ */ new Date()).toISOString(),
1964
+ `Clean failed after writer claim: ${errorMessage(error)}`,
1965
+ runId
1966
+ );
1967
+ } finally {
1968
+ db.close();
1969
+ }
1970
+ }
1971
+
2182
1972
  // src/cli.ts
2183
1973
  async function init(workspace, options) {
2184
1974
  const config = createWorkspaceConfig(
@@ -2233,6 +2023,27 @@ async function withReadOnlyWorkspace(workspace, fn) {
2233
2023
  db.close();
2234
2024
  }
2235
2025
  }
2026
+ function selectRepository(db, selector, workspaceId) {
2027
+ const candidates = reposByName(db, selector, workspaceId);
2028
+ if (candidates.length === 1) return { repo: candidates[0] };
2029
+ if (candidates.length === 0) return {
2030
+ diagnostic: {
2031
+ severity: "warning",
2032
+ code: "selector_repo_not_found",
2033
+ message: `Repository selector not found: ${selector}`
2034
+ }
2035
+ };
2036
+ return {
2037
+ diagnostic: selectorRepoAmbiguousDiagnostic(
2038
+ selector,
2039
+ candidates.map((repo) => ({
2040
+ id: repo.id,
2041
+ name: repo.name,
2042
+ packageName: repo.package_name ?? void 0
2043
+ }))
2044
+ )
2045
+ };
2046
+ }
2236
2047
  function createProgram() {
2237
2048
  const program = new Command();
2238
2049
  program.name("service-flow").description(
@@ -2264,8 +2075,8 @@ function createProgram() {
2264
2075
  );
2265
2076
  }).catch(fail)
2266
2077
  );
2267
- program.command("trace").option("--workspace <path>").option("--repo <name>").option("--operation <name>").option("--service <path>").option("--path <operationPath>").option("--handler <name>").option("--depth <n>", "trace depth", "25").option("--format <format>", "table|json|mermaid", "table").option("--include-external").option("--include-db").option("--include-async").option("--implementation-repo <name>").option("--implementation-hint <scope>", "scoped implementation hint", collect, []).option("--var <key=value>", "dynamic variable", collect, []).action(
2268
- (opts) => void withReadOnlyWorkspace(opts.workspace, (db) => {
2078
+ program.command("trace").option("--workspace <path>").option("--repo <name>").option("--operation <name>").option("--service <path>").option("--path <operationPath>").option("--handler <name>").option("--depth <n>", "trace depth", "25").option("--format <format>", "table|json|mermaid", "table").option("--include-external").option("--include-db").option("--include-async").option("--implementation-repo <name>").option("--implementation-hint <scope>", "scoped implementation hint", collect, []).option("--var <key=value>", "dynamic variable", collect, []).option("--dynamic-mode <mode>", "strict|candidates|infer", "strict").option("--max-dynamic-candidates <n>", "maximum dynamic candidates to show", "5").action(
2079
+ (opts) => void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
2269
2080
  const result = trace(
2270
2081
  db,
2271
2082
  {
@@ -2277,12 +2088,15 @@ function createProgram() {
2277
2088
  },
2278
2089
  {
2279
2090
  depth: Number(opts.depth),
2091
+ workspaceId,
2280
2092
  vars: parseVars(opts.var),
2281
2093
  includeExternal: Boolean(opts.includeExternal),
2282
2094
  includeDb: Boolean(opts.includeDb),
2283
2095
  includeAsync: Boolean(opts.includeAsync),
2284
2096
  implementationRepo: opts.implementationRepo,
2285
- implementationHints: opts.implementationHint.map(parseImplementationHint)
2097
+ implementationHints: opts.implementationHint.map(parseImplementationHint),
2098
+ dynamicMode: parseDynamicMode(opts.dynamicMode),
2099
+ maxDynamicCandidates: parsePositiveInteger(opts.maxDynamicCandidates, 5)
2286
2100
  }
2287
2101
  );
2288
2102
  process.stdout.write(
@@ -2294,9 +2108,9 @@ function createProgram() {
2294
2108
  list.command("repos").option("--workspace <path>").action(
2295
2109
  (opts) => void withReadOnlyWorkspace(
2296
2110
  opts.workspace,
2297
- (db) => process.stdout.write(
2111
+ (db, workspaceId) => process.stdout.write(
2298
2112
  renderJson(
2299
- listRepositories(db).map((r) => ({
2113
+ listRepositories(db, workspaceId).map((r) => ({
2300
2114
  name: r.name,
2301
2115
  kind: r.kind,
2302
2116
  packageName: r.package_name
@@ -2306,41 +2120,45 @@ function createProgram() {
2306
2120
  ).catch(fail)
2307
2121
  );
2308
2122
  list.command("services").option("--workspace <path>").option("--repo <name>").action(
2309
- (opts) => void withReadOnlyWorkspace(opts.workspace, (db) => {
2310
- const repo = opts.repo ? repoByName(db, opts.repo) : void 0;
2311
- if (opts.repo && !repo) {
2312
- process.stdout.write(renderJson([{ severity: "warning", code: "selector_repo_not_found", message: `Repository selector not found: ${opts.repo}` }]));
2123
+ (opts) => void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
2124
+ const selection = opts.repo ? selectRepository(db, opts.repo, workspaceId) : {};
2125
+ if (selection.diagnostic) {
2126
+ process.stdout.write(renderJson([selection.diagnostic]));
2313
2127
  return;
2314
2128
  }
2129
+ const repo = selection.repo;
2315
2130
  const rows = db.prepare(
2316
- "SELECT r.name repo,s.service_path servicePath,s.qualified_name qualifiedName FROM cds_services s JOIN repositories r ON r.id=s.repo_id WHERE (? IS NULL OR s.repo_id=?) ORDER BY r.name,s.service_path"
2317
- ).all(repo?.id, repo?.id);
2131
+ "SELECT r.name repo,s.service_path servicePath,s.qualified_name qualifiedName FROM cds_services s JOIN repositories r ON r.id=s.repo_id WHERE r.workspace_id=? AND (? IS NULL OR s.repo_id=?) ORDER BY r.name,s.service_path"
2132
+ ).all(workspaceId, repo?.id, repo?.id);
2318
2133
  process.stdout.write(renderJson(rows));
2319
2134
  }).catch(fail)
2320
2135
  );
2321
2136
  list.command("operations").option("--workspace <path>").option("--repo <name>").option("--service <path>").action(
2322
- (opts) => void withReadOnlyWorkspace(opts.workspace, (db) => {
2323
- const repo = opts.repo ? repoByName(db, opts.repo) : void 0;
2324
- if (opts.repo && !repo) {
2325
- process.stdout.write(renderJson([{ severity: "warning", code: "selector_repo_not_found", message: `Repository selector not found: ${opts.repo}` }]));
2137
+ (opts) => void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
2138
+ const selection = opts.repo ? selectRepository(db, opts.repo, workspaceId) : {};
2139
+ if (selection.diagnostic) {
2140
+ process.stdout.write(renderJson([selection.diagnostic]));
2326
2141
  return;
2327
2142
  }
2143
+ const repo = selection.repo;
2328
2144
  const rows = db.prepare(
2329
- "SELECT r.name repo,s.service_path servicePath,o.operation_name operation,o.operation_path path FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE (? IS NULL OR s.repo_id=?) AND (? IS NULL OR s.service_path=?)"
2330
- ).all(repo?.id, repo?.id, opts.service, opts.service);
2145
+ "SELECT r.name repo,s.service_path servicePath,o.operation_name operation,o.operation_path path FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE r.workspace_id=? AND (? IS NULL OR s.repo_id=?) AND (? IS NULL OR s.service_path=?)"
2146
+ ).all(workspaceId, repo?.id, repo?.id, opts.service, opts.service);
2331
2147
  process.stdout.write(renderJson(rows));
2332
2148
  }).catch(fail)
2333
2149
  );
2334
2150
  list.command("calls").option("--workspace <path>").option("--repo <name>").option("--operation <name>").action(
2335
- (opts) => void withReadOnlyWorkspace(opts.workspace, (db) => {
2336
- const repo = opts.repo ? repoByName(db, opts.repo) : void 0;
2337
- if (opts.repo && !repo) {
2338
- process.stdout.write(renderJson([{ severity: "warning", code: "selector_repo_not_found", message: `Repository selector not found: ${opts.repo}` }]));
2151
+ (opts) => void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
2152
+ const selection = opts.repo ? selectRepository(db, opts.repo, workspaceId) : {};
2153
+ if (selection.diagnostic) {
2154
+ process.stdout.write(renderJson([selection.diagnostic]));
2339
2155
  return;
2340
2156
  }
2157
+ const repo = selection.repo;
2341
2158
  const rows = db.prepare(
2342
- "SELECT r.name repo,c.call_type type,c.operation_path_expr path,c.source_file file,c.source_line line FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id WHERE (? IS NULL OR c.repo_id=?) AND (? IS NULL OR c.operation_path_expr=? OR c.operation_path_expr=? OR c.payload_summary LIKE ?)"
2159
+ "SELECT r.name repo,c.call_type type,c.operation_path_expr path,c.source_file file,c.source_line line FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id WHERE r.workspace_id=? AND (? IS NULL OR c.repo_id=?) AND (? IS NULL OR c.operation_path_expr=? OR c.operation_path_expr=? OR c.payload_summary LIKE ?)"
2343
2160
  ).all(
2161
+ workspaceId,
2344
2162
  repo?.id,
2345
2163
  repo?.id,
2346
2164
  opts.operation,
@@ -2351,8 +2169,8 @@ function createProgram() {
2351
2169
  process.stdout.write(renderJson(rows));
2352
2170
  }).catch(fail)
2353
2171
  );
2354
- program.command("graph").option("--workspace <path>").option("--repo <name>").option("--operation <name>").option("--service <path>").option("--path <operationPath>").option("--format <format>", "mermaid|json", "mermaid").option("--implementation-repo <name>").option("--implementation-hint <scope>", "scoped implementation hint", collect, []).option("--var <key=value>", "dynamic variable", collect, []).action(
2355
- (opts) => void withReadOnlyWorkspace(opts.workspace, (db) => {
2172
+ program.command("graph").option("--workspace <path>").option("--repo <name>").option("--operation <name>").option("--service <path>").option("--path <operationPath>").option("--format <format>", "mermaid|json", "mermaid").option("--implementation-repo <name>").option("--implementation-hint <scope>", "scoped implementation hint", collect, []).option("--var <key=value>", "dynamic variable", collect, []).option("--dynamic-mode <mode>", "strict|candidates|infer", "strict").option("--max-dynamic-candidates <n>", "maximum dynamic candidates to show", "5").action(
2173
+ (opts) => void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
2356
2174
  const result = trace(
2357
2175
  db,
2358
2176
  {
@@ -2363,12 +2181,15 @@ function createProgram() {
2363
2181
  },
2364
2182
  {
2365
2183
  depth: 100,
2184
+ workspaceId,
2366
2185
  includeAsync: true,
2367
2186
  includeDb: true,
2368
2187
  includeExternal: true,
2369
2188
  vars: parseVars(opts.var),
2370
2189
  implementationRepo: opts.implementationRepo,
2371
- implementationHints: opts.implementationHint.map(parseImplementationHint)
2190
+ implementationHints: opts.implementationHint.map(parseImplementationHint),
2191
+ dynamicMode: parseDynamicMode(opts.dynamicMode),
2192
+ maxDynamicCandidates: parsePositiveInteger(opts.maxDynamicCandidates, 5)
2372
2193
  }
2373
2194
  );
2374
2195
  process.stdout.write(
@@ -2378,18 +2199,18 @@ function createProgram() {
2378
2199
  );
2379
2200
  const inspect = program.command("inspect");
2380
2201
  inspect.command("repo").argument("<name>").option("--workspace <path>").action(
2381
- (name, opts) => void withReadOnlyWorkspace(
2382
- opts.workspace,
2383
- (db) => process.stdout.write(
2384
- renderJson(repoByName(db, name) ?? { error: "repo not found" })
2385
- )
2386
- ).catch(fail)
2202
+ (name, opts) => void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
2203
+ const selection = selectRepository(db, name, workspaceId);
2204
+ process.stdout.write(renderJson(
2205
+ selection.repo ?? selection.diagnostic ?? { error: "repo not found" }
2206
+ ));
2207
+ }).catch(fail)
2387
2208
  );
2388
2209
  inspect.command("operation").argument("<selector>").option("--workspace <path>").action(
2389
- (selector, opts) => void withReadOnlyWorkspace(opts.workspace, (db) => {
2210
+ (selector, opts) => void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
2390
2211
  const rows = db.prepare(
2391
- "SELECT * FROM cds_operations WHERE operation_name=? OR operation_path=?"
2392
- ).all(selector, selector);
2212
+ "SELECT o.* FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE r.workspace_id=? AND (o.operation_name=? OR o.operation_path=?)"
2213
+ ).all(workspaceId, selector, selector);
2393
2214
  process.stdout.write(renderJson(rows));
2394
2215
  }).catch(fail)
2395
2216
  );
@@ -2402,29 +2223,7 @@ function createProgram() {
2402
2223
  program.command("clean").option("--workspace <path>").option("--db-only").action(
2403
2224
  (opts) => void (async () => {
2404
2225
  const config = await loadWorkspaceConfig(opts.workspace);
2405
- const dbDir = path6.resolve(path6.dirname(config.dbPath));
2406
- const workspaceRoot = path6.resolve(config.rootPath);
2407
- await fs6.rm(config.dbPath, { force: true });
2408
- if (!opts.dbOnly) {
2409
- const marker = path6.join(dbDir, ".service-flow-state");
2410
- const dangerous = /* @__PURE__ */ new Set([
2411
- path6.parse(dbDir).root,
2412
- "/tmp",
2413
- process.env.HOME ? path6.resolve(process.env.HOME) : "",
2414
- workspaceRoot
2415
- ]);
2416
- let ownsState;
2417
- try {
2418
- ownsState = (await fs6.stat(marker)).isFile();
2419
- } catch {
2420
- ownsState = false;
2421
- }
2422
- if (!ownsState || dangerous.has(dbDir))
2423
- throw new Error(
2424
- `Refusing to recursively delete unowned or dangerous state directory: ${dbDir}. Use --db-only to remove only the database file.`
2425
- );
2426
- await fs6.rm(dbDir, { recursive: true, force: true });
2427
- }
2226
+ await cleanWorkspaceState(config, Boolean(opts.dbOnly));
2428
2227
  process.stdout.write("Cleaned service-flow state\n");
2429
2228
  })().catch(fail)
2430
2229
  );
@@ -2434,6 +2233,15 @@ function collect(value, previous) {
2434
2233
  previous.push(value);
2435
2234
  return previous;
2436
2235
  }
2236
+ function parseDynamicMode(value) {
2237
+ if (value === void 0 || value === "strict") return "strict";
2238
+ if (value === "candidates" || value === "infer") return value;
2239
+ throw new Error(`Invalid --dynamic-mode ${value}; expected strict, candidates, or infer`);
2240
+ }
2241
+ function parsePositiveInteger(value, fallback) {
2242
+ const parsed = Number(value);
2243
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
2244
+ }
2437
2245
  function fail(error) {
2438
2246
  process.stderr.write(
2439
2247
  `${error instanceof Error ? error.message : String(error)}