@saptools/service-flow 0.1.51 → 0.1.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +16 -14
  3. package/TECHNICAL-NOTE.md +18 -6
  4. package/dist/{chunk-YZJKE5UX.js → chunk-LFH7C46B.js} +4290 -1261
  5. package/dist/chunk-LFH7C46B.js.map +1 -0
  6. package/dist/cli.js +435 -515
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +45 -7
  9. package/dist/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/cli/000-clean.ts +82 -0
  12. package/src/cli/001-doctor-projection.ts +140 -0
  13. package/src/cli/doctor.ts +20 -3
  14. package/src/cli.ts +75 -57
  15. package/src/db/connection.ts +1 -1
  16. package/src/db/migrations.ts +5 -3
  17. package/src/db/repositories.ts +130 -24
  18. package/src/db/schema.ts +7 -2
  19. package/src/indexer/repository-indexer.ts +57 -29
  20. package/src/indexer/workspace-indexer.ts +84 -6
  21. package/src/linker/000-implementation-candidates.ts +641 -0
  22. package/src/linker/001-implementation-evidence-projection.ts +119 -0
  23. package/src/linker/002-call-evidence.ts +226 -0
  24. package/src/linker/cross-repo-linker.ts +27 -441
  25. package/src/linker/dynamic-edge-resolver.ts +35 -0
  26. package/src/linker/external-http-target.ts +24 -3
  27. package/src/linker/helper-package-linker.ts +18 -2
  28. package/src/linker/service-resolver.ts +45 -2
  29. package/src/output/doctor-output.ts +13 -4
  30. package/src/output/table-output.ts +33 -5
  31. package/src/parsers/cds-parser.ts +8 -2
  32. package/src/parsers/decorator-parser.ts +382 -48
  33. package/src/parsers/handler-registration-parser.ts +38 -21
  34. package/src/parsers/imported-wrapper-parser.ts +18 -5
  35. package/src/parsers/outbound-call-parser.ts +25 -8
  36. package/src/parsers/package-json-parser.ts +36 -11
  37. package/src/parsers/service-binding-parser-helpers.ts +8 -1
  38. package/src/parsers/service-binding-parser.ts +6 -3
  39. package/src/parsers/symbol-parser.ts +13 -3
  40. package/src/parsers/ts-project.ts +54 -0
  41. package/src/trace/000-dynamic-target-types.ts +96 -0
  42. package/src/trace/001-dynamic-identity.ts +308 -0
  43. package/src/trace/002-trace-diagnostics.ts +54 -0
  44. package/src/trace/003-dynamic-references.ts +283 -0
  45. package/src/trace/004-dynamic-candidate-sources.ts +155 -0
  46. package/src/trace/005-implementation-selection.ts +187 -0
  47. package/src/trace/006-contextual-projection.ts +30 -0
  48. package/src/trace/007-implementation-start-diagnostic.ts +61 -0
  49. package/src/trace/dynamic-targets.ts +582 -306
  50. package/src/trace/evidence.ts +331 -46
  51. package/src/trace/implementation-hints.ts +148 -8
  52. package/src/trace/selectors.ts +551 -3
  53. package/src/trace/trace-engine.ts +129 -135
  54. package/src/types.ts +27 -1
  55. package/src/utils/000-bounded-projection.ts +161 -0
  56. package/dist/chunk-YZJKE5UX.js.map +0 -1
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import ts from 'typescript';
2
+
1
3
  type CallType = 'remote_action' | 'remote_query' | 'remote_entity_read' | 'remote_entity_mutation' | 'remote_entity_delete' | 'remote_entity_media' | 'remote_entity_candidate' | 'local_db_query' | 'external_http' | 'async_emit' | 'async_subscribe' | 'local_service_call' | 'unknown';
2
4
  interface DiscoveredRepository {
3
5
  name: string;
@@ -55,17 +57,36 @@ interface HandlerClassFact {
55
57
  sourceFile: string;
56
58
  sourceLine: number;
57
59
  methods: HandlerMethodFact[];
60
+ hasHandlerDecorator?: boolean;
61
+ classDecoratorNames?: string[];
62
+ observedDecoratorNames?: string[];
63
+ unsupportedDecoratorNames?: string[];
58
64
  }
65
+ type HandlerMethodKind = 'operation' | 'entity_lifecycle' | 'event' | 'unsupported_lifecycle' | 'unsupported_decorator';
66
+ type HandlerLifecyclePhase = 'on' | 'before' | 'after';
67
+ type HandlerLifecycleEvent = 'CREATE' | 'READ' | 'UPDATE' | 'DELETE';
59
68
  interface HandlerMethodFact {
60
69
  methodName: string;
61
70
  decoratorKind: string;
62
71
  decoratorValue?: string;
63
72
  decoratorRawExpression: string;
73
+ handlerKind?: HandlerMethodKind;
74
+ executable?: boolean;
75
+ lifecyclePhase?: HandlerLifecyclePhase;
76
+ lifecycleEvent?: HandlerLifecycleEvent;
64
77
  decoratorResolution: {
65
78
  rawExpression: string;
79
+ decoratorExpression?: string;
80
+ argumentExpression?: string;
81
+ resolvedDecoratorKind?: string;
82
+ decoratorImportSource?: string;
66
83
  resolvedValue?: string;
67
- resolutionKind: 'literal' | 'const_identifier' | 'enum_member' | 'const_object_property' | 'generated_constant_name' | 'unresolved';
84
+ resolutionKind: 'literal' | 'const_identifier' | 'enum_member' | 'const_object_property' | 'generated_constant_name' | 'lifecycle_implicit' | 'unresolved';
68
85
  unresolvedReason?: string;
86
+ handlerKind?: HandlerMethodKind;
87
+ executable?: boolean;
88
+ lifecyclePhase?: HandlerLifecyclePhase;
89
+ lifecycleEvent?: HandlerLifecycleEvent;
69
90
  };
70
91
  sourceFile: string;
71
92
  sourceLine: number;
@@ -138,6 +159,7 @@ interface ImplementationHint {
138
159
  type DynamicMode = 'strict' | 'candidates' | 'infer';
139
160
  interface TraceOptions {
140
161
  depth: number;
162
+ workspaceId?: number;
141
163
  vars?: Record<string, string>;
142
164
  includeExternal?: boolean;
143
165
  includeDb?: boolean;
@@ -165,17 +187,33 @@ interface TraceResult {
165
187
 
166
188
  declare function discoverRepositories(rootPath: string, ignore: readonly string[]): Promise<DiscoveredRepository[]>;
167
189
 
168
- declare function parsePackageJson(repoPath: string): Promise<PackageFacts>;
190
+ interface ParsePackageJsonOptions {
191
+ strict?: boolean;
192
+ allowMissing?: boolean;
193
+ }
194
+ declare function parsePackageJson(repoPath: string, options?: ParsePackageJsonOptions): Promise<PackageFacts>;
195
+
196
+ interface SourceFileSnapshot {
197
+ repoPath: string;
198
+ filePath: string;
199
+ text: string;
200
+ sizeBytes: number;
201
+ sourceFile: () => ts.SourceFile;
202
+ }
203
+ interface RepositorySourceContext {
204
+ get: (filePath: string) => SourceFileSnapshot | undefined;
205
+ entries: () => SourceFileSnapshot[];
206
+ }
169
207
 
170
- declare function parseCdsFile(repoPath: string, filePath: string): Promise<CdsServiceFact[]>;
208
+ declare function parseCdsFile(repoPath: string, filePath: string, context?: RepositorySourceContext): Promise<CdsServiceFact[]>;
171
209
 
172
- declare function parseDecorators(repoPath: string, filePath: string): Promise<HandlerClassFact[]>;
210
+ declare function parseDecorators(repoPath: string, filePath: string, context?: RepositorySourceContext): Promise<HandlerClassFact[]>;
173
211
 
174
- declare function parseHandlerRegistrations(repoPath: string, filePath: string): Promise<HandlerRegistrationFact[]>;
212
+ declare function parseHandlerRegistrations(repoPath: string, filePath: string, context?: RepositorySourceContext): Promise<HandlerRegistrationFact[]>;
175
213
 
176
- declare function parseServiceBindings(repoPath: string, filePath: string): Promise<ServiceBindingFact[]>;
214
+ declare function parseServiceBindings(repoPath: string, filePath: string, context?: RepositorySourceContext): Promise<ServiceBindingFact[]>;
177
215
 
178
- declare function parseOutboundCalls(repoPath: string, filePath: string): Promise<OutboundCallFact[]>;
216
+ declare function parseOutboundCalls(repoPath: string, filePath: string, context?: RepositorySourceContext): Promise<OutboundCallFact[]>;
179
217
 
180
218
  declare function parseGeneratedConstants(repoPath: string, filePath: string): Promise<GeneratedConstantFact[]>;
181
219
 
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  stripQuotes,
18
18
  substituteVariables,
19
19
  trace
20
- } from "./chunk-YZJKE5UX.js";
20
+ } from "./chunk-LFH7C46B.js";
21
21
 
22
22
  // src/parsers/generated-constants-parser.ts
23
23
  import fs from "fs/promises";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saptools/service-flow",
3
- "version": "0.1.51",
3
+ "version": "0.1.53",
4
4
  "description": "Trace SAP CAP service-to-service flows across multi-repository workspaces with runtime-aware graph resolution",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -0,0 +1,82 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import type { WorkspaceConfig } from '../config/workspace-config.js';
4
+ import { openDatabase } from '../db/connection.js';
5
+ import { getWorkspace, upsertWorkspace } from '../db/repositories.js';
6
+ import { claimIndexRun } from '../indexer/workspace-indexer.js';
7
+ import { errorMessage } from '../utils/diagnostics.js';
8
+
9
+ type CleanConfig = Pick<WorkspaceConfig, 'dbPath' | 'rootPath'>;
10
+
11
+ export async function cleanWorkspaceState(
12
+ config: CleanConfig,
13
+ dbOnly: boolean,
14
+ ): Promise<void> {
15
+ const dbDir = path.resolve(path.dirname(config.dbPath));
16
+ if (!dbOnly) await assertOwnedStateDirectory(dbDir, config.rootPath);
17
+ const runId = claimCleanWriter(config);
18
+ try {
19
+ if (dbOnly) await removeDatabaseFiles(config.dbPath);
20
+ else await fs.rm(dbDir, { recursive: true, force: true });
21
+ } catch (error) {
22
+ await markCleanClaimFailed(config.dbPath, runId, error);
23
+ throw error;
24
+ }
25
+ }
26
+
27
+ function claimCleanWriter(config: CleanConfig): number {
28
+ const db = openDatabase(config.dbPath);
29
+ try {
30
+ const workspaceId = getWorkspace(db, config.rootPath)?.id
31
+ ?? upsertWorkspace(db, config.rootPath, config.dbPath);
32
+ return claimIndexRun(db, workspaceId, 0);
33
+ } finally {
34
+ db.close();
35
+ }
36
+ }
37
+
38
+ async function assertOwnedStateDirectory(
39
+ dbDir: string,
40
+ rootPath: string,
41
+ ): Promise<void> {
42
+ const marker = path.join(dbDir, '.service-flow-state');
43
+ const dangerous = new Set([
44
+ path.parse(dbDir).root,
45
+ '/tmp',
46
+ process.env.HOME ? path.resolve(process.env.HOME) : '',
47
+ path.resolve(rootPath),
48
+ ]);
49
+ const ownsState = await fs.stat(marker)
50
+ .then((stat) => stat.isFile())
51
+ .catch(() => false);
52
+ if (!ownsState || dangerous.has(dbDir))
53
+ throw new Error(
54
+ `Refusing to recursively delete unowned or dangerous state directory: ${dbDir}. Use --db-only to remove only the database file.`,
55
+ );
56
+ }
57
+
58
+ async function removeDatabaseFiles(dbPath: string): Promise<void> {
59
+ for (const suffix of ['-wal', '-shm', '-journal'])
60
+ await fs.rm(`${dbPath}${suffix}`, { force: true });
61
+ await fs.rm(dbPath, { force: true });
62
+ }
63
+
64
+ async function markCleanClaimFailed(
65
+ dbPath: string,
66
+ runId: number,
67
+ error: unknown,
68
+ ): Promise<void> {
69
+ const exists = await fs.stat(dbPath).then(() => true).catch(() => false);
70
+ if (!exists) return;
71
+ const db = openDatabase(dbPath);
72
+ try {
73
+ db.prepare(`UPDATE index_runs SET finished_at=?,status='failed',
74
+ error_message=? WHERE id=? AND status='running'`).run(
75
+ new Date().toISOString(),
76
+ `Clean failed after writer claim: ${errorMessage(error)}`,
77
+ runId,
78
+ );
79
+ } finally {
80
+ db.close();
81
+ }
82
+ }
@@ -0,0 +1,140 @@
1
+ import {
2
+ projectBounded,
3
+ stableProjectionValue,
4
+ type BoundedProjection,
5
+ } from '../utils/000-bounded-projection.js';
6
+
7
+ type Diagnostic = Record<string, unknown>;
8
+
9
+ const boundedDoctorArrayKeys = new Set([
10
+ 'candidates',
11
+ 'candidateScores',
12
+ 'candidateFamilies',
13
+ 'candidateEvidence',
14
+ 'candidatePaths',
15
+ 'candidateRawPaths',
16
+ 'candidateNormalizedOperationPaths',
17
+ 'normalizedCandidateOperations',
18
+ 'candidateLiterals',
19
+ 'bindingCandidates',
20
+ 'bindingAlternatives',
21
+ 'registrations',
22
+ 'implementationHintSuggestions',
23
+ 'selectableImplementationRepositories',
24
+ 'matchedHints',
25
+ 'candidateSuggestions',
26
+ 'dynamicTargetCandidates',
27
+ 'dynamicTargetCandidateSuggestions',
28
+ 'rejectedCandidates',
29
+ 'suggestedVarSets',
30
+ 'copyableExamples',
31
+ 'examples',
32
+ 'expandedExamples',
33
+ 'selectorSuggestions',
34
+ 'serviceSuggestions',
35
+ 'repositories',
36
+ ]);
37
+
38
+ export function boundDoctorDiagnostics(diagnostics: Diagnostic[]): Diagnostic[] {
39
+ return diagnostics.map(boundDoctorDiagnostic);
40
+ }
41
+
42
+ function boundDoctorDiagnostic(diagnostic: Diagnostic): Diagnostic {
43
+ const bounded = boundDoctorValue(diagnostic);
44
+ return isDiagnostic(bounded) ? bounded : {};
45
+ }
46
+
47
+ function boundDoctorValue(value: unknown): unknown {
48
+ if (Array.isArray(value)) return value.map(boundDoctorValue);
49
+ if (!isDiagnostic(value)) return value;
50
+ const input = value;
51
+ const output: Diagnostic = {};
52
+ for (const [key, child] of Object.entries(input)) {
53
+ if (!Array.isArray(child) || !boundedDoctorArrayKeys.has(key)) {
54
+ output[key] = boundDoctorValue(child);
55
+ continue;
56
+ }
57
+ const projection = projectBounded(child.map(boundDoctorValue), compareDiagnostic);
58
+ output[key] = projection.items;
59
+ addProjectionMetadata(output, input, key, projection);
60
+ }
61
+ return output;
62
+ }
63
+
64
+ function isDiagnostic(value: unknown): value is Diagnostic {
65
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
66
+ }
67
+
68
+ function addProjectionMetadata(
69
+ output: Diagnostic,
70
+ input: Diagnostic,
71
+ key: string,
72
+ projection: BoundedProjection<unknown>,
73
+ ): void {
74
+ const names = projectionNames(key);
75
+ const total = Math.max(
76
+ numericValue(input[names.total]),
77
+ projection.totalCount,
78
+ siblingCollectionCount(input, key),
79
+ );
80
+ output[names.total] = total;
81
+ output[names.shown] = projection.shownCount;
82
+ output[names.omitted] = Math.max(0, total - projection.shownCount);
83
+ }
84
+
85
+ function siblingCollectionCount(input: Diagnostic, key: string): number {
86
+ if (key !== 'examples' || !Array.isArray(input.expandedExamples)) return 0;
87
+ return input.expandedExamples.length;
88
+ }
89
+
90
+ function projectionNames(key: string): { total: string; shown: string; omitted: string } {
91
+ const stem = projectionStem(key);
92
+ return {
93
+ total: `${stem}Count`,
94
+ shown: `shown${upperFirst(stem)}Count`,
95
+ omitted: `omitted${upperFirst(stem)}Count`,
96
+ };
97
+ }
98
+
99
+ function projectionStem(key: string): string {
100
+ const stems: Record<string, string> = {
101
+ candidates: 'candidate',
102
+ candidateScores: 'candidateScore',
103
+ candidateFamilies: 'candidateFamily',
104
+ candidateEvidence: 'candidateEvidence',
105
+ candidatePaths: 'candidatePath',
106
+ candidateRawPaths: 'candidateRawPath',
107
+ candidateNormalizedOperationPaths: 'candidateNormalizedOperationPath',
108
+ normalizedCandidateOperations: 'normalizedCandidateOperation',
109
+ candidateLiterals: 'candidateLiteral',
110
+ bindingCandidates: 'bindingCandidate',
111
+ bindingAlternatives: 'bindingAlternative',
112
+ registrations: 'registration',
113
+ implementationHintSuggestions: 'implementationHintSuggestion',
114
+ selectableImplementationRepositories: 'selectableImplementationRepository',
115
+ matchedHints: 'matchedHint',
116
+ candidateSuggestions: 'candidateSuggestion',
117
+ dynamicTargetCandidates: 'dynamicTargetCandidate',
118
+ dynamicTargetCandidateSuggestions: 'dynamicTargetCandidateSuggestion',
119
+ rejectedCandidates: 'rejectedCandidate',
120
+ suggestedVarSets: 'suggestedVarSet',
121
+ copyableExamples: 'copyableExample',
122
+ examples: 'example',
123
+ expandedExamples: 'expandedExample',
124
+ selectorSuggestions: 'selectorSuggestion',
125
+ serviceSuggestions: 'serviceSuggestion',
126
+ };
127
+ return stems[key] ?? 'repository';
128
+ }
129
+
130
+ function compareDiagnostic(left: unknown, right: unknown): number {
131
+ return stableProjectionValue(left).localeCompare(stableProjectionValue(right));
132
+ }
133
+
134
+ function numericValue(value: unknown): number {
135
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
136
+ }
137
+
138
+ function upperFirst(value: string): string {
139
+ return value ? `${value[0]?.toUpperCase() ?? ''}${value.slice(1)}` : value;
140
+ }
package/src/cli/doctor.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Db } from '../db/connection.js';
2
2
  import { classifyODataPathIntent, normalizeODataOperationInvocationPath } from '../linker/odata-path-normalizer.js';
3
3
  import { implementationHintSuggestions } from '../trace/implementation-hints.js';
4
+ import { boundDoctorDiagnostics } from './001-doctor-projection.js';
4
5
  import { ANALYZER_VERSION } from '../version.js';
5
6
 
6
7
  type Diagnostic = Record<string, unknown>;
@@ -15,7 +16,7 @@ export function linkUpgradeWarnings(db: Db): Diagnostic[] {
15
16
 
16
17
  export function doctorDiagnostics(db: Db, strict: boolean, options: DoctorOptions = {}): Diagnostic[] {
17
18
  const diagnostics = db.prepare('SELECT severity,code,message,source_file sourceFile,source_line sourceLine FROM diagnostics ORDER BY id').all() as Diagnostic[];
18
- return [
19
+ return boundDoctorDiagnostics([
19
20
  ...diagnostics,
20
21
  ...healthDiagnostics(db, strict),
21
22
  ...remoteTargetWithoutImplementationDiagnostics(db, strict, Boolean(options.detail)),
@@ -23,7 +24,7 @@ export function doctorDiagnostics(db: Db, strict: boolean, options: DoctorOption
23
24
  ...schemaDriftDiagnostics(db, strict),
24
25
  ...analyzerVersionDiagnostics(db, strict),
25
26
  ...parserQualityDiagnostics(db, strict, options),
26
- ];
27
+ ]);
27
28
  }
28
29
 
29
30
  function healthDiagnostics(db: Db, strict: boolean): Diagnostic[] {
@@ -252,6 +253,10 @@ function addImplementationCategory(grouped: Map<string, Diagnostic & { count: nu
252
253
  const current = grouped.get(key) ?? { category, baseOperation, reason, candidateFamily: family, count: 0, servicePaths: [], examples: [] };
253
254
  const hintSuggestions = implementationSuggestions(evidence);
254
255
  const candidates = asRecords(evidence.candidates);
256
+ const candidateCount = Math.max(
257
+ numericValue(evidence.candidateCount),
258
+ candidates.length,
259
+ );
255
260
  current.count += 1;
256
261
  current.servicePaths.push(String(row.servicePath ?? evidence.servicePath ?? ''));
257
262
  current.examples.push({
@@ -259,7 +264,15 @@ function addImplementationCategory(grouped: Map<string, Diagnostic & { count: nu
259
264
  operation: row.operationName,
260
265
  status: row.status,
261
266
  reason: row.unresolvedReason,
262
- candidateCount: candidates.length,
267
+ candidateCount,
268
+ shownCandidateCount: Math.min(
269
+ candidateCount,
270
+ numericValue(evidence.shownCandidateCount) || candidates.length,
271
+ ),
272
+ omittedCandidateCount: Math.max(
273
+ 0,
274
+ candidateCount - (numericValue(evidence.shownCandidateCount) || candidates.length),
275
+ ),
263
276
  candidateEvidence: candidates.slice(0, 3),
264
277
  implementationHintSuggestions: hintSuggestions,
265
278
  });
@@ -655,6 +668,10 @@ function candidateCount(value: unknown): number {
655
668
  return Number(parseObject(value).candidateCount ?? 0);
656
669
  }
657
670
 
671
+ function numericValue(value: unknown): number {
672
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
673
+ }
674
+
658
675
  function parseObject(value: unknown): Diagnostic {
659
676
  if (value && typeof value === 'object' && !Array.isArray(value)) return value as Diagnostic;
660
677
  try {
package/src/cli.ts CHANGED
@@ -1,17 +1,16 @@
1
1
  import { Command } from 'commander';
2
- import path from 'node:path';
3
- import fs from 'node:fs/promises';
4
2
  import { DEFAULT_IGNORES } from './config/defaults.js';
5
3
  import {
6
4
  createWorkspaceConfig,
7
5
  loadWorkspaceConfig,
8
6
  saveWorkspaceConfig,
9
7
  } from './config/workspace-config.js';
10
- import { openDatabase, openReadOnlyDatabase } from './db/connection.js';
8
+ import { openDatabase, openReadOnlyDatabase, type Db } from './db/connection.js';
11
9
  import {
12
10
  getWorkspace,
13
11
  listRepositories,
14
- repoByName,
12
+ reposByName,
13
+ type RepoRow,
15
14
  upsertRepository,
16
15
  upsertWorkspace,
17
16
  } from './db/repositories.js';
@@ -22,7 +21,10 @@ import { indexWorkspace } from './indexer/workspace-indexer.js';
22
21
  import { linkWorkspace } from './linker/cross-repo-linker.js';
23
22
  import { doctorDiagnostics, linkUpgradeWarnings } from './cli/doctor.js';
24
23
  import { trace } from './trace/trace-engine.js';
25
- import { parseVars } from './trace/selectors.js';
24
+ import {
25
+ parseVars,
26
+ selectorRepoAmbiguousDiagnostic,
27
+ } from './trace/selectors.js';
26
28
  import { parseImplementationHint } from './trace/implementation-hints.js';
27
29
  import { renderTraceTable } from './output/table-output.js';
28
30
  import { renderTraceJson, renderJson } from './output/json-output.js';
@@ -30,6 +32,7 @@ import { renderDoctorDiagnostics } from './output/doctor-output.js';
30
32
  import { renderMermaid } from './output/mermaid-output.js';
31
33
  import { VERSION } from './version.js';
32
34
  import type { DynamicMode } from './types.js';
35
+ import { cleanWorkspaceState } from './cli/000-clean.js';
33
36
  async function init(
34
37
  workspace: string,
35
38
  options: { db?: string; ignore?: string[] },
@@ -92,6 +95,30 @@ async function withReadOnlyWorkspace<T>(
92
95
  db.close();
93
96
  }
94
97
  }
98
+ function selectRepository(db: Db, selector: string, workspaceId?: number): {
99
+ repo?: RepoRow;
100
+ diagnostic?: Record<string, unknown>;
101
+ } {
102
+ const candidates = reposByName(db, selector, workspaceId);
103
+ if (candidates.length === 1) return { repo: candidates[0] };
104
+ if (candidates.length === 0) return {
105
+ diagnostic: {
106
+ severity: 'warning',
107
+ code: 'selector_repo_not_found',
108
+ message: `Repository selector not found: ${selector}`,
109
+ },
110
+ };
111
+ return {
112
+ diagnostic: selectorRepoAmbiguousDiagnostic(
113
+ selector,
114
+ candidates.map((repo) => ({
115
+ id: repo.id,
116
+ name: repo.name,
117
+ packageName: repo.package_name ?? undefined,
118
+ })),
119
+ ),
120
+ };
121
+ }
95
122
  export function createProgram(): Command {
96
123
  const program = new Command();
97
124
  program
@@ -177,7 +204,7 @@ export function createProgram(): Command {
177
204
  dynamicMode: string;
178
205
  maxDynamicCandidates: string;
179
206
  }) =>
180
- void withReadOnlyWorkspace(opts.workspace, (db) => {
207
+ void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
181
208
  const result = trace(
182
209
  db,
183
210
  {
@@ -189,6 +216,7 @@ export function createProgram(): Command {
189
216
  },
190
217
  {
191
218
  depth: Number(opts.depth),
219
+ workspaceId,
192
220
  vars: parseVars(opts.var),
193
221
  includeExternal: Boolean(opts.includeExternal),
194
222
  includeDb: Boolean(opts.includeDb),
@@ -214,10 +242,10 @@ export function createProgram(): Command {
214
242
  .option('--workspace <path>')
215
243
  .action(
216
244
  (opts: { workspace?: string }) =>
217
- void withReadOnlyWorkspace(opts.workspace, (db) =>
245
+ void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) =>
218
246
  process.stdout.write(
219
247
  renderJson(
220
- listRepositories(db).map((r) => ({
248
+ listRepositories(db, workspaceId).map((r) => ({
221
249
  name: r.name,
222
250
  kind: r.kind,
223
251
  packageName: r.package_name,
@@ -232,17 +260,20 @@ export function createProgram(): Command {
232
260
  .option('--repo <name>')
233
261
  .action(
234
262
  (opts: { workspace?: string; repo?: string }) =>
235
- void withReadOnlyWorkspace(opts.workspace, (db) => {
236
- const repo = opts.repo ? repoByName(db, opts.repo) : undefined;
237
- if (opts.repo && !repo) {
238
- process.stdout.write(renderJson([{ severity: 'warning', code: 'selector_repo_not_found', message: `Repository selector not found: ${opts.repo}` }]));
263
+ void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
264
+ const selection = opts.repo
265
+ ? selectRepository(db, opts.repo, workspaceId)
266
+ : {};
267
+ if (selection.diagnostic) {
268
+ process.stdout.write(renderJson([selection.diagnostic]));
239
269
  return;
240
270
  }
271
+ const repo = selection.repo;
241
272
  const rows = db
242
273
  .prepare(
243
- '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',
274
+ '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',
244
275
  )
245
- .all(repo?.id, repo?.id);
276
+ .all(workspaceId, repo?.id, repo?.id);
246
277
  process.stdout.write(renderJson(rows));
247
278
  }).catch(fail),
248
279
  );
@@ -253,17 +284,20 @@ export function createProgram(): Command {
253
284
  .option('--service <path>')
254
285
  .action(
255
286
  (opts: { workspace?: string; repo?: string; service?: string }) =>
256
- void withReadOnlyWorkspace(opts.workspace, (db) => {
257
- const repo = opts.repo ? repoByName(db, opts.repo) : undefined;
258
- if (opts.repo && !repo) {
259
- process.stdout.write(renderJson([{ severity: 'warning', code: 'selector_repo_not_found', message: `Repository selector not found: ${opts.repo}` }]));
287
+ void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
288
+ const selection = opts.repo
289
+ ? selectRepository(db, opts.repo, workspaceId)
290
+ : {};
291
+ if (selection.diagnostic) {
292
+ process.stdout.write(renderJson([selection.diagnostic]));
260
293
  return;
261
294
  }
295
+ const repo = selection.repo;
262
296
  const rows = db
263
297
  .prepare(
264
- '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=?)',
298
+ '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=?)',
265
299
  )
266
- .all(repo?.id, repo?.id, opts.service, opts.service);
300
+ .all(workspaceId, repo?.id, repo?.id, opts.service, opts.service);
267
301
  process.stdout.write(renderJson(rows));
268
302
  }).catch(fail),
269
303
  );
@@ -274,17 +308,21 @@ export function createProgram(): Command {
274
308
  .option('--operation <name>')
275
309
  .action(
276
310
  (opts: { workspace?: string; repo?: string; operation?: string }) =>
277
- void withReadOnlyWorkspace(opts.workspace, (db) => {
278
- const repo = opts.repo ? repoByName(db, opts.repo) : undefined;
279
- if (opts.repo && !repo) {
280
- process.stdout.write(renderJson([{ severity: 'warning', code: 'selector_repo_not_found', message: `Repository selector not found: ${opts.repo}` }]));
311
+ void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
312
+ const selection = opts.repo
313
+ ? selectRepository(db, opts.repo, workspaceId)
314
+ : {};
315
+ if (selection.diagnostic) {
316
+ process.stdout.write(renderJson([selection.diagnostic]));
281
317
  return;
282
318
  }
319
+ const repo = selection.repo;
283
320
  const rows = db
284
321
  .prepare(
285
- '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 ?)',
322
+ '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 ?)',
286
323
  )
287
324
  .all(
325
+ workspaceId,
288
326
  repo?.id,
289
327
  repo?.id,
290
328
  opts.operation,
@@ -322,7 +360,7 @@ export function createProgram(): Command {
322
360
  dynamicMode: string;
323
361
  maxDynamicCandidates: string;
324
362
  }) =>
325
- void withReadOnlyWorkspace(opts.workspace, (db) => {
363
+ void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
326
364
  const result = trace(
327
365
  db,
328
366
  {
@@ -333,6 +371,7 @@ export function createProgram(): Command {
333
371
  },
334
372
  {
335
373
  depth: 100,
374
+ workspaceId,
336
375
  includeAsync: true,
337
376
  includeDb: true,
338
377
  includeExternal: true,
@@ -357,11 +396,12 @@ export function createProgram(): Command {
357
396
  .option('--workspace <path>')
358
397
  .action(
359
398
  (name: string, opts: { workspace?: string }) =>
360
- void withReadOnlyWorkspace(opts.workspace, (db) =>
361
- process.stdout.write(
362
- renderJson(repoByName(db, name) ?? { error: 'repo not found' }),
363
- ),
364
- ).catch(fail),
399
+ void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
400
+ const selection = selectRepository(db, name, workspaceId);
401
+ process.stdout.write(renderJson(
402
+ selection.repo ?? selection.diagnostic ?? { error: 'repo not found' },
403
+ ));
404
+ }).catch(fail),
365
405
  );
366
406
  inspect
367
407
  .command('operation')
@@ -369,12 +409,12 @@ export function createProgram(): Command {
369
409
  .option('--workspace <path>')
370
410
  .action(
371
411
  (selector: string, opts: { workspace?: string }) =>
372
- void withReadOnlyWorkspace(opts.workspace, (db) => {
412
+ void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
373
413
  const rows = db
374
414
  .prepare(
375
- 'SELECT * FROM cds_operations WHERE operation_name=? OR operation_path=?',
415
+ '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=?)',
376
416
  )
377
- .all(selector, selector);
417
+ .all(workspaceId, selector, selector);
378
418
  process.stdout.write(renderJson(rows));
379
419
  }).catch(fail),
380
420
  );
@@ -399,29 +439,7 @@ export function createProgram(): Command {
399
439
  (opts: { workspace?: string; dbOnly?: boolean }) =>
400
440
  void (async () => {
401
441
  const config = await loadWorkspaceConfig(opts.workspace);
402
- const dbDir = path.resolve(path.dirname(config.dbPath));
403
- const workspaceRoot = path.resolve(config.rootPath);
404
- await fs.rm(config.dbPath, { force: true });
405
- if (!opts.dbOnly) {
406
- const marker = path.join(dbDir, '.service-flow-state');
407
- const dangerous = new Set([
408
- path.parse(dbDir).root,
409
- '/tmp',
410
- process.env.HOME ? path.resolve(process.env.HOME) : '',
411
- workspaceRoot,
412
- ]);
413
- let ownsState: boolean;
414
- try {
415
- ownsState = (await fs.stat(marker)).isFile();
416
- } catch {
417
- ownsState = false;
418
- }
419
- if (!ownsState || dangerous.has(dbDir))
420
- throw new Error(
421
- `Refusing to recursively delete unowned or dangerous state directory: ${dbDir}. Use --db-only to remove only the database file.`,
422
- );
423
- await fs.rm(dbDir, { recursive: true, force: true });
424
- }
442
+ await cleanWorkspaceState(config, Boolean(opts.dbOnly));
425
443
  process.stdout.write('Cleaned service-flow state\n');
426
444
  })().catch(fail),
427
445
  );
@@ -101,8 +101,8 @@ export function openDatabase(dbPath: string, options: OpenDatabaseOptions = {}):
101
101
  },
102
102
  transaction<T>(fn: () => T): T {
103
103
  if (inTransaction) return fn();
104
- inTransaction = true;
105
104
  native.exec('BEGIN IMMEDIATE');
105
+ inTransaction = true;
106
106
  try {
107
107
  const result = fn();
108
108
  native.exec('COMMIT');