@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.
- package/CHANGELOG.md +13 -0
- package/README.md +16 -14
- package/TECHNICAL-NOTE.md +18 -6
- package/dist/{chunk-YZJKE5UX.js → chunk-LFH7C46B.js} +4290 -1261
- package/dist/chunk-LFH7C46B.js.map +1 -0
- package/dist/cli.js +435 -515
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +45 -7
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/cli/000-clean.ts +82 -0
- package/src/cli/001-doctor-projection.ts +140 -0
- package/src/cli/doctor.ts +20 -3
- package/src/cli.ts +75 -57
- package/src/db/connection.ts +1 -1
- package/src/db/migrations.ts +5 -3
- package/src/db/repositories.ts +130 -24
- package/src/db/schema.ts +7 -2
- package/src/indexer/repository-indexer.ts +57 -29
- package/src/indexer/workspace-indexer.ts +84 -6
- package/src/linker/000-implementation-candidates.ts +641 -0
- package/src/linker/001-implementation-evidence-projection.ts +119 -0
- package/src/linker/002-call-evidence.ts +226 -0
- package/src/linker/cross-repo-linker.ts +27 -441
- package/src/linker/dynamic-edge-resolver.ts +35 -0
- package/src/linker/external-http-target.ts +24 -3
- package/src/linker/helper-package-linker.ts +18 -2
- package/src/linker/service-resolver.ts +45 -2
- package/src/output/doctor-output.ts +13 -4
- package/src/output/table-output.ts +33 -5
- package/src/parsers/cds-parser.ts +8 -2
- package/src/parsers/decorator-parser.ts +382 -48
- package/src/parsers/handler-registration-parser.ts +38 -21
- package/src/parsers/imported-wrapper-parser.ts +18 -5
- package/src/parsers/outbound-call-parser.ts +25 -8
- package/src/parsers/package-json-parser.ts +36 -11
- package/src/parsers/service-binding-parser-helpers.ts +8 -1
- package/src/parsers/service-binding-parser.ts +6 -3
- package/src/parsers/symbol-parser.ts +13 -3
- package/src/parsers/ts-project.ts +54 -0
- package/src/trace/000-dynamic-target-types.ts +96 -0
- package/src/trace/001-dynamic-identity.ts +308 -0
- package/src/trace/002-trace-diagnostics.ts +54 -0
- package/src/trace/003-dynamic-references.ts +283 -0
- package/src/trace/004-dynamic-candidate-sources.ts +155 -0
- package/src/trace/005-implementation-selection.ts +187 -0
- package/src/trace/006-contextual-projection.ts +30 -0
- package/src/trace/007-implementation-start-diagnostic.ts +61 -0
- package/src/trace/dynamic-targets.ts +582 -306
- package/src/trace/evidence.ts +331 -46
- package/src/trace/implementation-hints.ts +148 -8
- package/src/trace/selectors.ts +551 -3
- package/src/trace/trace-engine.ts +129 -135
- package/src/types.ts +27 -1
- package/src/utils/000-bounded-projection.ts +161 -0
- 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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
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
|
-
|
|
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
|
);
|
package/src/db/connection.ts
CHANGED
|
@@ -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');
|