@saptools/service-flow 0.1.51 → 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 (40) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +10 -5
  3. package/TECHNICAL-NOTE.md +8 -4
  4. package/dist/{chunk-YZJKE5UX.js → chunk-PTLDSHRC.js} +2412 -553
  5. package/dist/chunk-PTLDSHRC.js.map +1 -0
  6. package/dist/cli.js +280 -506
  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.ts +75 -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/indexer/repository-indexer.ts +57 -29
  18. package/src/indexer/workspace-indexer.ts +84 -6
  19. package/src/linker/cross-repo-linker.ts +13 -1
  20. package/src/output/table-output.ts +29 -3
  21. package/src/parsers/cds-parser.ts +8 -2
  22. package/src/parsers/decorator-parser.ts +382 -48
  23. package/src/parsers/handler-registration-parser.ts +38 -21
  24. package/src/parsers/imported-wrapper-parser.ts +18 -5
  25. package/src/parsers/outbound-call-parser.ts +25 -8
  26. package/src/parsers/package-json-parser.ts +36 -11
  27. package/src/parsers/service-binding-parser-helpers.ts +8 -1
  28. package/src/parsers/service-binding-parser.ts +6 -3
  29. package/src/parsers/symbol-parser.ts +13 -3
  30. package/src/parsers/ts-project.ts +54 -0
  31. package/src/trace/000-dynamic-target-types.ts +84 -0
  32. package/src/trace/001-dynamic-identity.ts +280 -0
  33. package/src/trace/002-trace-diagnostics.ts +54 -0
  34. package/src/trace/003-dynamic-references.ts +82 -0
  35. package/src/trace/dynamic-targets.ts +459 -229
  36. package/src/trace/evidence.ts +305 -46
  37. package/src/trace/selectors.ts +483 -0
  38. package/src/trace/trace-engine.ts +90 -83
  39. package/src/types.ts +27 -1
  40. package/dist/chunk-YZJKE5UX.js.map +0 -1
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
  }
@@ -453,7 +453,7 @@ function implementationCandidates(db: Db, workspaceId: number, operation: Record
453
453
  CASE WHEN appRepo.id=handlerRepo.id THEN 1 ELSE 0 END sameRepoRegistration,
454
454
  CASE WHEN EXISTS (SELECT 1 FROM cds_services localService WHERE localService.repo_id=appRepo.id AND localService.service_path=?) THEN 1 ELSE 0 END localServicePathMatch,
455
455
  CASE WHEN EXISTS (SELECT 1 FROM cds_services localService WHERE localService.repo_id=appRepo.id) THEN 1 ELSE 0 END applicationHasLocalServices,
456
- CASE WHEN EXISTS (SELECT 1 FROM handler_registrations localReg JOIN handler_classes localClass ON ((localReg.handler_class_id IS NOT NULL AND localClass.id=localReg.handler_class_id) OR (localReg.handler_class_id IS NULL AND localClass.class_name=localReg.class_name AND localClass.repo_id=localReg.repo_id)) JOIN handler_methods localMethod ON localMethod.handler_class_id=localClass.id WHERE localReg.repo_id=appRepo.id AND (localMethod.decorator_value=? OR localMethod.decorator_value=? OR localMethod.method_name=? OR localMethod.decorator_raw_expression LIKE ?)) THEN 1 ELSE 0 END applicationHasLocalRegistrationForOperation,
456
+ CASE WHEN EXISTS (SELECT 1 FROM handler_registrations localReg JOIN handler_classes localClass ON ((localReg.handler_class_id IS NOT NULL AND localClass.id=localReg.handler_class_id) OR (localReg.handler_class_id IS NULL AND localClass.class_name=localReg.class_name AND localClass.repo_id=localReg.repo_id)) JOIN handler_methods localMethod ON localMethod.handler_class_id=localClass.id WHERE localReg.repo_id=appRepo.id AND COALESCE(json_extract(localMethod.decorator_resolution_json,'$.handlerKind'),CASE WHEN localMethod.decorator_kind='Event' THEN 'event' WHEN localMethod.decorator_kind IN ('Action','Func','On') THEN 'operation' ELSE 'unsupported' END)='operation' AND COALESCE(json_extract(localMethod.decorator_resolution_json,'$.executable'),CASE WHEN localMethod.decorator_kind IN ('Action','Func','On') THEN 1 ELSE 0 END)=1 AND (localMethod.decorator_value=? OR localMethod.decorator_value=? OR localMethod.method_name=? OR localMethod.decorator_raw_expression LIKE ?)) THEN 1 ELSE 0 END applicationHasLocalRegistrationForOperation,
457
457
  CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(appRepo.id AS TEXT) AND dep.to_id=?) THEN 1 ELSE 0 END appDependsOnModel,
458
458
  CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(appRepo.id AS TEXT) AND dep.to_id=CAST(handlerRepo.id AS TEXT)) THEN 1 ELSE 0 END appDependsOnHandler,
459
459
  CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(handlerRepo.id AS TEXT) AND dep.to_id=?) THEN 1 ELSE 0 END handlerDependsOnModel
@@ -485,6 +485,13 @@ function implementationCandidates(db: Db, workspaceId: number, operation: Record
485
485
  )
486
486
  JOIN repositories appRepo ON appRepo.id=hr.repo_id
487
487
  WHERE appRepo.workspace_id=?
488
+ AND COALESCE(json_extract(hm.decorator_resolution_json,'$.handlerKind'),
489
+ CASE WHEN hm.decorator_kind='Event' THEN 'event'
490
+ WHEN hm.decorator_kind IN ('Action','Func','On') THEN 'operation'
491
+ ELSE 'unsupported' END)='operation'
492
+ AND COALESCE(json_extract(hm.decorator_resolution_json,'$.executable'),
493
+ CASE WHEN hm.decorator_kind IN ('Action','Func','On')
494
+ THEN 1 ELSE 0 END)=1
488
495
  AND (hm.decorator_value=? OR hm.decorator_value=? OR hm.method_name=? OR hm.decorator_raw_expression LIKE ?)`).all(
489
496
  operation.modelRepoId,
490
497
  operation.modelRepo,
@@ -613,6 +620,11 @@ function candidateEvidence(candidate: ImplementationCandidate, rank: number): Re
613
620
  };
614
621
  }
615
622
  function implementationMethodSignal(row: Record<string, unknown>, operation: Record<string, unknown>): { matches: boolean; contradicted: boolean; acceptedReasons: string[]; rejectedReasons: string[] } {
623
+ const resolution = objectJson(row.decoratorResolutionJson) ?? {};
624
+ if (resolution.handlerKind && resolution.handlerKind !== 'operation')
625
+ return { matches: false, contradicted: true, acceptedReasons: [], rejectedReasons: ['non_operation_handler_kind'] };
626
+ if (resolution.executable === false)
627
+ return { matches: false, contradicted: true, acceptedReasons: [], rejectedReasons: ['handler_method_not_executable'] };
616
628
  const op = normalizedOperationName(String(operation.operationPath ?? operation.operationName ?? ''));
617
629
  const decorator = normalizeDecoratorOperationSignal(typeof row.decoratorValue === 'string' ? row.decoratorValue : undefined, typeof row.decoratorRawExpression === 'string' ? row.decoratorRawExpression : undefined, op);
618
630
  if (decorator.status === 'resolved' && decorator.operationName === op) return { matches: true, contradicted: false, acceptedReasons: ['decorator targets operation'], rejectedReasons: [] };
@@ -24,7 +24,30 @@ export function renderTraceTable(result: TraceResult): string {
24
24
 
25
25
  function diagnosticLines(diagnostic: Record<string, unknown>): string[] {
26
26
  const first = `${String(diagnostic.severity ?? 'info')} ${String(diagnostic.code ?? 'diagnostic')} ${String(diagnostic.message ?? '')}`;
27
- return [first, ...hintLines(diagnostic).map((hint) => ` ${hint}`)];
27
+ const details = diagnosticDetailLines(diagnostic);
28
+ return [first, ...[...details, ...hintLines(diagnostic)]
29
+ .map((hint) => ` ${hint}`)];
30
+ }
31
+
32
+ function diagnosticDetailLines(diagnostic: Record<string, unknown>): string[] {
33
+ const lines: string[] = [];
34
+ if (diagnostic.sourceFile || diagnostic.sourceLine)
35
+ lines.push(`at ${String(diagnostic.sourceFile ?? '')}:${String(diagnostic.sourceLine ?? '')}`);
36
+ const unsupported = stringList(diagnostic.unsupportedDecoratorNames);
37
+ const observed = stringList(diagnostic.observedDecoratorNames);
38
+ if (unsupported.length > 0)
39
+ lines.push(`unsupported decorators: ${unsupported.join(', ')}`);
40
+ else if (observed.length > 0)
41
+ lines.push(`observed decorators: ${observed.join(', ')}`);
42
+ if (typeof diagnostic.remediation === 'string')
43
+ lines.push(`hint: ${diagnostic.remediation}`);
44
+ return lines;
45
+ }
46
+
47
+ function stringList(value: unknown): string[] {
48
+ return Array.isArray(value)
49
+ ? value.filter((item): item is string => typeof item === 'string')
50
+ : [];
28
51
  }
29
52
 
30
53
  function hintLines(evidence: Record<string, unknown>): string[] {
@@ -50,9 +73,12 @@ function dynamicHintLines(evidence: Record<string, unknown>): string[] {
50
73
  if (count === 0) return [];
51
74
  const shown = numberValue(exploration.shownCandidateCount);
52
75
  const omitted = numberValue(exploration.omittedCandidateCount);
53
- const lines = [`candidates: ${shown} shown, ${omitted} omitted`];
76
+ const rejected = numberValue(exploration.rejectedCandidateCount);
77
+ const lines = [
78
+ `viable candidates: ${shown} shown, ${omitted} omitted; rejected: ${rejected}`,
79
+ ];
54
80
  lines.push(...varSetHints(exploration.suggestedVarSets));
55
- if (omitted > 0 || shown < count)
81
+ if (omitted > 0 || rejected > 0 || shown < count)
56
82
  lines.push('use --dynamic-mode candidates --max-dynamic-candidates 20 to explore candidate branches');
57
83
  return lines;
58
84
  }
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import type { CdsOperationFact, CdsServiceFact } from '../types.js';
4
4
  import { ensureLeadingSlash, normalizePath } from '../utils/path-utils.js';
5
+ import type { RepositorySourceContext } from './ts-project.js';
5
6
 
6
7
  function lineOf(text: string, index: number): number {
7
8
  return text.slice(0, index).split('\n').length;
@@ -136,9 +137,14 @@ function operationsFromBody(text: string, maskedBody: string, bodyOffset: number
136
137
  }));
137
138
  }
138
139
 
139
- export async function parseCdsFile(repoPath: string, filePath: string): Promise<CdsServiceFact[]> {
140
+ export async function parseCdsFile(
141
+ repoPath: string,
142
+ filePath: string,
143
+ context?: RepositorySourceContext,
144
+ ): Promise<CdsServiceFact[]> {
140
145
  const absolute = path.join(repoPath, filePath);
141
- const text = await fs.readFile(absolute, 'utf8');
146
+ const text = context?.get(filePath)?.text
147
+ ?? await fs.readFile(absolute, 'utf8');
142
148
  const masked = maskCommentsAndStrings(text);
143
149
  const namespace = /namespace\s+([\w.]+)\s*;/.exec(masked)?.[1];
144
150
  const services: CdsServiceFact[] = [];