@mknrt/autotests-overkill 1.1.5 → 1.2.1
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/README.md +17 -3
- package/dist/src/cli/indexKnowledge.js +8 -1
- package/dist/src/cli/runAcceptance.d.ts +2 -1
- package/dist/src/cli/runAcceptance.js +33 -2
- package/dist/src/cli/runMcpServer.js +8 -1
- package/dist/src/cli/runtimeStartupError.d.ts +3 -0
- package/dist/src/cli/runtimeStartupError.js +60 -0
- package/dist/src/contracts/toolOutput.d.ts +6 -6
- package/dist/src/contracts/toolOutput.js +8 -4
- package/dist/src/domain/analyzeDiffImpact.d.ts +1 -1
- package/dist/src/domain/analyzeDiffImpact.js +14 -4
- package/dist/src/domain/analyzeTestGaps.d.ts +1 -1
- package/dist/src/domain/analyzeTestGaps.js +13 -1
- package/dist/src/domain/buildRerunScope.d.ts +1 -1
- package/dist/src/domain/buildRerunScope.js +6 -1
- package/dist/src/domain/buildSetupTeardownPlan.d.ts +1 -1
- package/dist/src/domain/findBackendTestContext.d.ts +1 -1
- package/dist/src/domain/findExistingTestAssets.d.ts +1 -1
- package/dist/src/domain/findFrontendContract.d.ts +1 -1
- package/dist/src/domain/findFrontendRuntimeLogic.d.ts +1 -1
- package/dist/src/domain/findTestAreaContext.d.ts +1 -1
- package/dist/src/domain/generateSpecBlueprint.d.ts +1 -1
- package/dist/src/domain/mapFeatureToExistingCoverage.d.ts +1 -1
- package/dist/src/domain/recommendSelectorStrategy.d.ts +1 -1
- package/dist/src/domain/reviewTestDraft.d.ts +26 -0
- package/dist/src/domain/reviewTestDraft.js +226 -0
- package/dist/src/domain/shared.js +15 -2
- package/dist/src/domain/summarizeCiContext.d.ts +1 -1
- package/dist/src/domain/triageFailedRun.d.ts +1 -1
- package/dist/src/domain/triageFailedRun.js +14 -3
- package/dist/src/indexer/extractors/backendContractExtractor.js +9 -1
- package/dist/src/indexer/extractors/frontendContractExtractor.js +44 -8
- package/dist/src/indexer/refreshPipeline.d.ts +10 -0
- package/dist/src/indexer/refreshPipeline.js +46 -9
- package/dist/src/knowledge/database.d.ts +2 -2
- package/dist/src/knowledge/database.js +10 -3
- package/dist/src/knowledge/repositories.js +34 -22
- package/dist/src/mcp/registerTools.js +12 -0
- package/dist/src/utils/pathUtils.d.ts +3 -0
- package/dist/src/utils/pathUtils.js +29 -0
- package/docs/audits/2026-04-20-iteration-1.md +251 -0
- package/docs/audits/2026-04-20-iteration-2-comprehensive.md +563 -0
- package/docs/superpowers/plans/2026-04-19-autotests-overkill-v1.md +1 -1
- package/docs/superpowers/plans/2026-04-20-fis-platform-backend-integration.md +1 -1
- package/package.json +3 -4
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { unique } from '../utils/pathUtils.js';
|
|
2
|
+
import { analyzeTestGaps } from './analyzeTestGaps.js';
|
|
3
|
+
import { buildSetupTeardownPlan } from './buildSetupTeardownPlan.js';
|
|
4
|
+
import { findBackendTestContext } from './findBackendTestContext.js';
|
|
5
|
+
import { findExistingTestAssets } from './findExistingTestAssets.js';
|
|
6
|
+
import { findTestAreaContext } from './findTestAreaContext.js';
|
|
7
|
+
import { output } from './shared.js';
|
|
8
|
+
import { recommendSelectorStrategy } from './recommendSelectorStrategy.js';
|
|
9
|
+
export async function reviewTestDraft(input, context) {
|
|
10
|
+
const area = input.area ?? inferArea(input.feature, input.draft);
|
|
11
|
+
const backendContextPromise = shouldQueryBackendContext(input.feature, input.draft, area)
|
|
12
|
+
? findBackendTestContext({ query: input.feature }, context)
|
|
13
|
+
: Promise.resolve(emptyToolOutput(`No backend context review was needed for '${input.feature}'.`));
|
|
14
|
+
const [assets, selectorStrategy, areaContext, setupPlan, gaps, backendContext] = await Promise.all([
|
|
15
|
+
findExistingTestAssets({ query: input.feature }, context),
|
|
16
|
+
recommendSelectorStrategy({ feature: input.feature }, context),
|
|
17
|
+
findTestAreaContext({ area, feature: input.feature }, context),
|
|
18
|
+
buildSetupTeardownPlan({ area, feature: input.feature }, context),
|
|
19
|
+
analyzeTestGaps({ widget: input.feature }, context),
|
|
20
|
+
backendContextPromise,
|
|
21
|
+
]);
|
|
22
|
+
const issues = reviewDraft({
|
|
23
|
+
draft: input.draft,
|
|
24
|
+
area,
|
|
25
|
+
gaps,
|
|
26
|
+
backendContext,
|
|
27
|
+
});
|
|
28
|
+
const evidence = [
|
|
29
|
+
...issues.map((issue) => ({
|
|
30
|
+
type: 'symbol',
|
|
31
|
+
label: `draft issue ${issue.kind}`,
|
|
32
|
+
snippet: issue.message,
|
|
33
|
+
metadata: { action: issue.action, area, severity: issue.severity },
|
|
34
|
+
})),
|
|
35
|
+
...supportingEvidence(assets, 'find_existing_test_assets', 3),
|
|
36
|
+
...supportingEvidence(selectorStrategy, 'recommend_selector_strategy', 2),
|
|
37
|
+
...supportingEvidence(areaContext, 'find_test_area_context', 2),
|
|
38
|
+
...supportingEvidence(setupPlan, 'build_setup_teardown_plan', 2),
|
|
39
|
+
...supportingEvidence(gaps, 'analyze_test_gaps', 2),
|
|
40
|
+
...supportingEvidence(backendContext, 'find_backend_test_context', 3),
|
|
41
|
+
];
|
|
42
|
+
const recommendedActions = unique([
|
|
43
|
+
...issues.map((issue) => issue.action),
|
|
44
|
+
...selectorStrategy.recommended_actions.slice(0, 1),
|
|
45
|
+
...setupPlan.recommended_actions.slice(0, 2),
|
|
46
|
+
...backendContext.recommended_actions.slice(0, 2),
|
|
47
|
+
...(gaps.evidence.length > 0
|
|
48
|
+
? ['Cross-check uncovered runtime signals before considering the draft complete.']
|
|
49
|
+
: []),
|
|
50
|
+
]);
|
|
51
|
+
const warnings = unique([
|
|
52
|
+
'Draft review is heuristic and checks Cypress patterns by string analysis; validate the final edits against the referenced project files.',
|
|
53
|
+
...assets.warnings,
|
|
54
|
+
...selectorStrategy.warnings,
|
|
55
|
+
...areaContext.warnings,
|
|
56
|
+
...setupPlan.warnings,
|
|
57
|
+
...gaps.warnings,
|
|
58
|
+
...backendContext.warnings,
|
|
59
|
+
]);
|
|
60
|
+
const repoPaths = unique([
|
|
61
|
+
...assets.repo_paths,
|
|
62
|
+
...selectorStrategy.repo_paths,
|
|
63
|
+
...areaContext.repo_paths,
|
|
64
|
+
...setupPlan.repo_paths,
|
|
65
|
+
...gaps.repo_paths,
|
|
66
|
+
...backendContext.repo_paths,
|
|
67
|
+
]);
|
|
68
|
+
return output(buildReviewSummary(input.feature, area, issues), evidence, recommendedActions, warnings, repoPaths);
|
|
69
|
+
}
|
|
70
|
+
function reviewDraft(input) {
|
|
71
|
+
const issues = [];
|
|
72
|
+
const draft = input.draft;
|
|
73
|
+
if (input.area === 'constructor') {
|
|
74
|
+
if (!/\binitConf\s*\(/.test(draft)) {
|
|
75
|
+
issues.push({
|
|
76
|
+
kind: 'bootstrap',
|
|
77
|
+
message: 'Constructor draft does not bootstrap with initConf().',
|
|
78
|
+
action: 'Switch bootstrap to initConf() and expose appNameConf at the describe level.',
|
|
79
|
+
severity: 'high',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (!/cy\.selectAppInConstructor\s*\(/.test(draft)) {
|
|
83
|
+
issues.push({
|
|
84
|
+
kind: 'bootstrap',
|
|
85
|
+
message: 'Constructor draft bypasses cy.selectAppInConstructor(...) navigation reuse.',
|
|
86
|
+
action: 'Reuse cy.selectAppInConstructor(...) instead of rebuilding constructor navigation.',
|
|
87
|
+
severity: 'high',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
if (!/\binit\s*\(/.test(draft)) {
|
|
93
|
+
issues.push({
|
|
94
|
+
kind: 'bootstrap',
|
|
95
|
+
message: 'formRunner draft does not call init().',
|
|
96
|
+
action: 'Add init() at the describe level before reviewing assertions.',
|
|
97
|
+
severity: 'high',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (!/cy\.selectForm\s*\(/.test(draft)) {
|
|
101
|
+
issues.push({
|
|
102
|
+
kind: 'bootstrap',
|
|
103
|
+
message: 'formRunner draft does not reuse cy.selectForm(...) navigation.',
|
|
104
|
+
action: 'Reuse cy.selectForm(...) from project patterns instead of custom navigation steps.',
|
|
105
|
+
severity: 'high',
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (!/cy\.waitVcmForm\s*\(/.test(draft)) {
|
|
109
|
+
issues.push({
|
|
110
|
+
kind: 'bootstrap',
|
|
111
|
+
message: 'formRunner draft does not reuse cy.waitVcmForm() around form navigation.',
|
|
112
|
+
action: 'Reuse cy.waitVcmForm() around form navigation instead of relying on incidental readiness.',
|
|
113
|
+
severity: 'medium',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (/cy\.wait\(\s*\d+\s*\)/.test(draft)) {
|
|
118
|
+
issues.push({
|
|
119
|
+
kind: 'wait',
|
|
120
|
+
message: 'Draft uses ad-hoc cy.wait(number) sleeps.',
|
|
121
|
+
action: 'Replace cy.wait(number) with project wait helpers or explicit readiness assertions.',
|
|
122
|
+
severity: 'high',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (hasBrittleSelector(draft) || !/cy\.getWidget\s*\(/.test(draft)) {
|
|
126
|
+
issues.push({
|
|
127
|
+
kind: 'selector',
|
|
128
|
+
message: 'Draft leans on brittle selectors or skips cy.getWidget(...) reuse.',
|
|
129
|
+
action: 'Prefer cy.getWidget(...) plus selector-tree or testguid-backed hooks over DOM-order selectors.',
|
|
130
|
+
severity: 'medium',
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (/click\(\s*\{\s*force\s*:\s*true/.test(draft) || hasSilentMutation(draft)) {
|
|
134
|
+
issues.push({
|
|
135
|
+
kind: 'mutation',
|
|
136
|
+
message: 'Draft relies on force clicks or direct DOM mutation patterns.',
|
|
137
|
+
action: 'Remove force clicks and silent DOM mutation patterns until widget readiness is proven explicitly.',
|
|
138
|
+
severity: 'high',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
appendBackendIssues(issues, input.backendContext, draft);
|
|
142
|
+
const gapSignal = input.gaps.evidence
|
|
143
|
+
.find((item) => item.label.startsWith('uncovered branch') || item.label.startsWith('uncovered method') || item.label.startsWith('uncovered event'));
|
|
144
|
+
if (gapSignal) {
|
|
145
|
+
const gapHint = gapSignal.snippet ?? gapSignal.label.replace(/^uncovered (?:branch|method|event)\s+/i, '');
|
|
146
|
+
if (gapHint && !draft.includes(gapHint)) {
|
|
147
|
+
issues.push({
|
|
148
|
+
kind: 'coverage',
|
|
149
|
+
message: `Draft does not mention the current gap signal '${gapHint}'.`,
|
|
150
|
+
action: `Add an assertion or scenario covering '${gapHint}' before considering the draft complete.`,
|
|
151
|
+
severity: 'medium',
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return issues;
|
|
156
|
+
}
|
|
157
|
+
function supportingEvidence(result, sourceTool, limit) {
|
|
158
|
+
return result.evidence.slice(0, limit).map((item) => ({
|
|
159
|
+
...item,
|
|
160
|
+
metadata: {
|
|
161
|
+
...item.metadata,
|
|
162
|
+
sourceTool,
|
|
163
|
+
},
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
function inferArea(feature, draft) {
|
|
167
|
+
if (/\binitConf\s*\(|cy\.selectAppInConstructor\s*\(|@constructor/i.test(draft) || /constructor|auth|repository|application/i.test(feature)) {
|
|
168
|
+
return 'constructor';
|
|
169
|
+
}
|
|
170
|
+
return 'formRunner';
|
|
171
|
+
}
|
|
172
|
+
function shouldQueryBackendContext(feature, draft, area) {
|
|
173
|
+
return area === 'constructor'
|
|
174
|
+
|| /\bcy\.request\s*\(|\bfetch\s*\(|\/platform\/|\/rs2\/|api-heavy|backend|request flow|taskId|poll/i.test(`${feature}\n${draft}`);
|
|
175
|
+
}
|
|
176
|
+
function emptyToolOutput(summary) {
|
|
177
|
+
return {
|
|
178
|
+
summary,
|
|
179
|
+
evidence: [],
|
|
180
|
+
recommended_actions: [],
|
|
181
|
+
repo_paths: [],
|
|
182
|
+
confidence: {
|
|
183
|
+
score: 0,
|
|
184
|
+
level: 'low',
|
|
185
|
+
rationale: ['Backend review branch was intentionally skipped for this draft.'],
|
|
186
|
+
},
|
|
187
|
+
warnings: [],
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function buildReviewSummary(feature, area, issues) {
|
|
191
|
+
const highCount = issues.filter((issue) => issue.severity === 'high').length;
|
|
192
|
+
const mediumCount = issues.filter((issue) => issue.severity === 'medium').length;
|
|
193
|
+
return `Reviewed draft for ${feature} in ${area}: found ${issues.length} issue(s) with ${highCount} high severity and ${mediumCount} medium severity findings across project reuse, selector stability, setup patterns, and runtime gap hints.`;
|
|
194
|
+
}
|
|
195
|
+
function appendBackendIssues(issues, backendContext, draft) {
|
|
196
|
+
const hasBackendEvidence = backendContext.evidence.some((item) => item.label === 'api interaction' || item.label === 'backend endpoint');
|
|
197
|
+
if (!hasBackendEvidence) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const usesRawRequest = /\bcy\.request\s*\(|\bfetch\s*\(/.test(draft);
|
|
201
|
+
const reusesKnownFlow = /\bcy\.(sampleAction|waitForSampleAction|authConf|getCurrentAuthUser|logoutFromConstructor)\s*\(/.test(draft);
|
|
202
|
+
const hasCompletionCheck = /\bwaitFor[A-Z]|poll|status\b|its\(\s*['"]status['"]\s*\)|should\(\s*['"]eq['"]\s*,\s*(200|201|202)/.test(draft);
|
|
203
|
+
if (usesRawRequest && !reusesKnownFlow) {
|
|
204
|
+
issues.push({
|
|
205
|
+
kind: 'backend',
|
|
206
|
+
message: 'Draft calls backend requests directly instead of reusing the indexed project request flow.',
|
|
207
|
+
action: 'Reuse the linked autotests2 request flow instead of rebuilding API assumptions from scratch.',
|
|
208
|
+
severity: 'high',
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (!hasCompletionCheck) {
|
|
212
|
+
issues.push({
|
|
213
|
+
kind: 'backend',
|
|
214
|
+
message: 'Draft does not verify backend completion, response status, or polling for the indexed API-heavy flow.',
|
|
215
|
+
action: 'Preserve backend request order, required params, and completion polling from the indexed API interaction before adding UI assertions.',
|
|
216
|
+
severity: 'high',
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function hasBrittleSelector(draft) {
|
|
221
|
+
return /nth-child|\.eq\(|\.first\(|\.last\(|\.children\(|>\s*div|>\s*span/.test(draft);
|
|
222
|
+
}
|
|
223
|
+
function hasSilentMutation(draft) {
|
|
224
|
+
return /\.invoke\(\s*['"](show|hide|remove|removeAttr|attr|prop|val|html)['"]/.test(draft)
|
|
225
|
+
|| /\.trigger\(\s*['"](input|change|blur)['"]/.test(draft);
|
|
226
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
1
2
|
import { buildToolOutput } from '../contracts/toolOutput.js';
|
|
2
3
|
import { confidenceFromEvidence } from '../contracts/confidence.js';
|
|
3
|
-
import { refreshKnowledge } from '../indexer/refreshPipeline.js';
|
|
4
|
+
import { getRefreshSignature, isRefreshStateExpired, readRefreshState, refreshKnowledge } from '../indexer/refreshPipeline.js';
|
|
4
5
|
import { countDocuments } from '../knowledge/repositories.js';
|
|
5
6
|
import { unique } from '../utils/pathUtils.js';
|
|
6
7
|
let indexedContexts = new WeakSet();
|
|
@@ -8,13 +9,25 @@ export async function ensureIndexed(context) {
|
|
|
8
9
|
if (indexedContexts.has(context)) {
|
|
9
10
|
return;
|
|
10
11
|
}
|
|
11
|
-
|
|
12
|
+
const refreshState = readRefreshState(context.config.cacheDir);
|
|
13
|
+
const currentSignature = getRefreshSignature(context);
|
|
14
|
+
if (countDocuments(context.database) > 0
|
|
15
|
+
&& ((refreshState && refreshState.repoSignature === currentSignature && !isRefreshStateExpired(refreshState))
|
|
16
|
+
|| (!refreshState && configuredReposUnavailable(context)))) {
|
|
12
17
|
indexedContexts.add(context);
|
|
13
18
|
return;
|
|
14
19
|
}
|
|
15
20
|
await refreshKnowledge(context);
|
|
16
21
|
indexedContexts.add(context);
|
|
17
22
|
}
|
|
23
|
+
function configuredReposUnavailable(context) {
|
|
24
|
+
const repoRoots = [
|
|
25
|
+
context.config.repos.autotests2,
|
|
26
|
+
context.config.repos.caseplatformWeb,
|
|
27
|
+
context.config.repos.fisPlatform,
|
|
28
|
+
].filter((value) => Boolean(value));
|
|
29
|
+
return repoRoots.every((repoRoot) => !existsSync(repoRoot));
|
|
30
|
+
}
|
|
18
31
|
export function evidenceFile(label, filePath, snippet, metadata) {
|
|
19
32
|
return {
|
|
20
33
|
type: 'file',
|
|
@@ -5,7 +5,7 @@ export declare function summarizeCiContext(input: {
|
|
|
5
5
|
warnings: string[];
|
|
6
6
|
summary: string;
|
|
7
7
|
evidence: {
|
|
8
|
-
type: "symbol" | "
|
|
8
|
+
type: "symbol" | "test" | "config" | "file" | "artifact" | "report" | "diff";
|
|
9
9
|
label: string;
|
|
10
10
|
path?: string | undefined;
|
|
11
11
|
locator?: string | undefined;
|
|
@@ -8,7 +8,7 @@ export declare function triageFailedRun(input: {
|
|
|
8
8
|
warnings: string[];
|
|
9
9
|
summary: string;
|
|
10
10
|
evidence: {
|
|
11
|
-
type: "symbol" | "
|
|
11
|
+
type: "symbol" | "test" | "config" | "file" | "artifact" | "report" | "diff";
|
|
12
12
|
label: string;
|
|
13
13
|
path?: string | undefined;
|
|
14
14
|
locator?: string | undefined;
|
|
@@ -44,9 +44,15 @@ export async function triageFailedRun(input, context) {
|
|
|
44
44
|
})),
|
|
45
45
|
...apiBackendContext.interactionHits.map((item) => evidenceFile('api interaction', item.path, item.title, {
|
|
46
46
|
confidence: parseMetadata(item.metadata_json).confidence,
|
|
47
|
+
normalizedPaths: parseMetadata(item.metadata_json).normalizedPaths,
|
|
48
|
+
whySelected: parseMetadata(item.metadata_json).whySelected,
|
|
47
49
|
})),
|
|
48
50
|
...apiBackendContext.backendHits.map((item) => evidenceFile('backend endpoint', item.path, item.title, {
|
|
49
51
|
confidence: parseMetadata(item.metadata_json).confidence,
|
|
52
|
+
endpointPath: parseMetadata(item.metadata_json).endpointPath,
|
|
53
|
+
whyMatched: parseMetadata(item.metadata_json).whyMatched,
|
|
54
|
+
paramNames: parseMetadata(item.metadata_json).paramNames,
|
|
55
|
+
ruleHints: parseMetadata(item.metadata_json).ruleHints,
|
|
50
56
|
})),
|
|
51
57
|
...(reportPortal.launchLink ? [{
|
|
52
58
|
type: 'report',
|
|
@@ -104,12 +110,17 @@ async function matchApiCapturesToBackendContext(captureSignals, context) {
|
|
|
104
110
|
return (metadata.confidence ?? 0) >= 0.7
|
|
105
111
|
&& (metadata.normalizedPaths ?? []).some((indexedPath) => captureSignals.some((signal) => pathsOverlap(indexedPath, signal.path)));
|
|
106
112
|
});
|
|
107
|
-
const
|
|
108
|
-
|
|
113
|
+
const interactionPaths = [...new Set(interactionHits.flatMap((item) => parseMetadata(item.metadata_json).normalizedPaths ?? []))];
|
|
114
|
+
const backendHits = interactionPaths.length > 0
|
|
115
|
+
? queryDocuments(context.database, interactionPaths.join(' '), {
|
|
109
116
|
repoKind: 'fis-platform',
|
|
110
117
|
sourceKinds: ['backend-endpoint'],
|
|
111
118
|
limit: 6,
|
|
112
|
-
}).filter((item) =>
|
|
119
|
+
}).filter((item) => {
|
|
120
|
+
const metadata = parseMetadata(item.metadata_json);
|
|
121
|
+
return (metadata.confidence ?? 0) >= 0.7
|
|
122
|
+
&& interactionPaths.some((candidate) => pathsOverlap(metadata.endpointPath ?? item.title, candidate));
|
|
123
|
+
})
|
|
113
124
|
: [];
|
|
114
125
|
return { interactionHits, backendHits };
|
|
115
126
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
1
2
|
export function extractBackendContracts(input) {
|
|
2
3
|
const documents = input.sources.flatMap((source) => extractRecordsFromSource(source));
|
|
3
4
|
return {
|
|
@@ -26,7 +27,14 @@ function createRecord(source, httpMethod, endpointPath, body) {
|
|
|
26
27
|
].filter((value) => Boolean(value));
|
|
27
28
|
const ruleHints = extractRuleHints(body);
|
|
28
29
|
return {
|
|
29
|
-
id:
|
|
30
|
+
id: [
|
|
31
|
+
'fis-platform',
|
|
32
|
+
source.matchedFamily,
|
|
33
|
+
httpMethod ?? 'RESOURCE',
|
|
34
|
+
source.path,
|
|
35
|
+
resolvedEndpointPath,
|
|
36
|
+
crypto.createHash('sha1').update(body).digest('hex').slice(0, 12),
|
|
37
|
+
].join(':'),
|
|
30
38
|
title: `${httpMethod ? `${httpMethod} ` : ''}${resolvedEndpointPath}`,
|
|
31
39
|
path: source.path,
|
|
32
40
|
body,
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { CaseplatformConnector } from '../../connectors/caseplatformConnector.js';
|
|
2
2
|
import { extractMatches, truncate } from '../../utils/textUtils.js';
|
|
3
|
-
import { unique } from '../../utils/pathUtils.js';
|
|
3
|
+
import { featureIntentTokens, pathIntentTokens, textIntentTokens, unique } from '../../utils/pathUtils.js';
|
|
4
4
|
export async function extractFrontendContract(input) {
|
|
5
5
|
const connector = new CaseplatformConnector(input.root);
|
|
6
6
|
const files = await connector.resolveFeatureFiles(input.widgetHint);
|
|
7
|
-
const focusedFiles = focusFrontendFiles(files.allFiles, input.widgetHint);
|
|
8
7
|
const warnings = [];
|
|
9
8
|
const controlBody = files.controlFile ? connector.read(files.controlFile) : '';
|
|
10
9
|
const singletonBody = files.singletonFile ? connector.read(files.singletonFile) : '';
|
|
@@ -13,11 +12,15 @@ export async function extractFrontendContract(input) {
|
|
|
13
12
|
if (files.mode === 'widget' && !files.controlFile) {
|
|
14
13
|
warnings.push(`No control file found for widget '${input.widgetHint}'.`);
|
|
15
14
|
}
|
|
16
|
-
const
|
|
15
|
+
const allSources = files.allFiles.map((filePath) => ({
|
|
17
16
|
path: filePath,
|
|
18
17
|
body: connector.read(filePath),
|
|
19
18
|
kind: connector.classifyFileKind(filePath),
|
|
20
19
|
}));
|
|
20
|
+
const sources = files.mode === 'conf'
|
|
21
|
+
? focusFrontendSources(allSources, input.widgetHint)
|
|
22
|
+
: allSources;
|
|
23
|
+
const focusedFiles = sources.map((source) => source.path);
|
|
21
24
|
const properties = files.mode === 'conf'
|
|
22
25
|
? unique(sources.flatMap((source) => extractConfProperties(source.body)))
|
|
23
26
|
: extractMatches(controlBody, /qname:\s*'([^']+)'/g);
|
|
@@ -58,12 +61,45 @@ export async function extractFrontendContract(input) {
|
|
|
58
61
|
warnings,
|
|
59
62
|
};
|
|
60
63
|
}
|
|
61
|
-
function
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
function focusFrontendSources(sources, widgetHint) {
|
|
65
|
+
const intentTokens = featureIntentTokens(widgetHint);
|
|
66
|
+
if (intentTokens.length === 0) {
|
|
67
|
+
return sources;
|
|
64
68
|
}
|
|
65
|
-
const
|
|
66
|
-
|
|
69
|
+
const scoredSources = sources.map((source) => ({
|
|
70
|
+
source,
|
|
71
|
+
pathTokens: pathIntentTokens(source.path),
|
|
72
|
+
score: scoreSourceIntent(source, intentTokens),
|
|
73
|
+
}));
|
|
74
|
+
const matchedSources = scoredSources.filter((item) => item.score > 0);
|
|
75
|
+
if (matchedSources.length === 0) {
|
|
76
|
+
return sources;
|
|
77
|
+
}
|
|
78
|
+
const viewIntentDirectories = new Set(matchedSources
|
|
79
|
+
.filter((item) => item.source.kind === 'view')
|
|
80
|
+
.flatMap((item) => directoryIntentTokens(item.source.path))
|
|
81
|
+
.filter((token) => intentTokens.includes(token)));
|
|
82
|
+
const focused = matchedSources
|
|
83
|
+
.filter((item) => {
|
|
84
|
+
if (item.source.kind !== 'view' || viewIntentDirectories.size === 0) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
return directoryIntentTokens(item.source.path).some((token) => viewIntentDirectories.has(token));
|
|
88
|
+
})
|
|
89
|
+
.map((item) => item.source);
|
|
90
|
+
return focused.length > 0 ? focused : sources;
|
|
91
|
+
}
|
|
92
|
+
function scoreSourceIntent(source, intentTokens) {
|
|
93
|
+
const pathTokens = pathIntentTokens(source.path);
|
|
94
|
+
const bodyTokens = new Set(textIntentTokens(source.body));
|
|
95
|
+
return intentTokens.reduce((score, token) => {
|
|
96
|
+
const pathScore = pathTokens.includes(token) ? 4 : pathTokens.some((pathToken) => pathToken.includes(token)) ? 2 : 0;
|
|
97
|
+
const bodyScore = bodyTokens.has(token) ? 1 : 0;
|
|
98
|
+
return score + pathScore + bodyScore;
|
|
99
|
+
}, 0);
|
|
100
|
+
}
|
|
101
|
+
function directoryIntentTokens(filePath) {
|
|
102
|
+
return pathIntentTokens(filePath.replace(/[\\/][^\\/]+$/, ''));
|
|
67
103
|
}
|
|
68
104
|
function extractNamedObjectKeys(source, objectName) {
|
|
69
105
|
const startMatch = new RegExp(`${objectName}\\s*:\\s*\\{`).exec(source);
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import type { AppContext } from '../appContext.js';
|
|
2
|
+
export declare const REFRESH_STATE_FILE = "refresh-state.json";
|
|
3
|
+
export declare const REFRESH_STATE_TTL_MS: number;
|
|
4
|
+
type RefreshState = {
|
|
5
|
+
repoSignature: string;
|
|
6
|
+
updatedAt: string;
|
|
7
|
+
};
|
|
2
8
|
export declare function refreshKnowledge(context: AppContext): Promise<{
|
|
3
9
|
documentsIndexed: number;
|
|
4
10
|
stats: {
|
|
@@ -10,3 +16,7 @@ export declare function refreshKnowledge(context: AppContext): Promise<{
|
|
|
10
16
|
};
|
|
11
17
|
warnings: string[];
|
|
12
18
|
}>;
|
|
19
|
+
export declare function getRefreshSignature(context: AppContext): string;
|
|
20
|
+
export declare function readRefreshState(cacheDir: string): RefreshState | null;
|
|
21
|
+
export declare function isRefreshStateExpired(refreshState: RefreshState): boolean;
|
|
22
|
+
export {};
|
|
@@ -7,16 +7,17 @@ import { extractApiInteractions } from './extractors/apiInteractionExtractor.js'
|
|
|
7
7
|
import { extractBackendContracts } from './extractors/backendContractExtractor.js';
|
|
8
8
|
import { extractFrontendContract } from './extractors/frontendContractExtractor.js';
|
|
9
9
|
import { SnapshotStore } from './snapshotStore.js';
|
|
10
|
-
import { ensureDirectory } from '../utils/fileUtils.js';
|
|
10
|
+
import { ensureDirectory, readJson, writeJson } from '../utils/fileUtils.js';
|
|
11
|
+
export const REFRESH_STATE_FILE = 'refresh-state.json';
|
|
12
|
+
export const REFRESH_STATE_TTL_MS = 5 * 60 * 1000;
|
|
11
13
|
export async function refreshKnowledge(context) {
|
|
12
14
|
ensureDirectory(context.config.cacheDir);
|
|
13
15
|
const snapshotStore = new SnapshotStore(path.join(context.config.cacheDir, 'snapshots'));
|
|
14
16
|
const documents = [];
|
|
15
17
|
const now = new Date().toISOString();
|
|
16
|
-
const repoKindsToSync = [];
|
|
18
|
+
const repoKindsToSync = ['autotests2', 'caseplatform-web', 'fis-platform'];
|
|
17
19
|
const warnings = [];
|
|
18
20
|
if (fs.existsSync(context.config.repos.autotests2)) {
|
|
19
|
-
repoKindsToSync.push('autotests2');
|
|
20
21
|
const docs = context.autotests2.getAreaDocs();
|
|
21
22
|
const apiCatalog = await buildApiDiscoveryCatalog({
|
|
22
23
|
root: context.config.repos.autotests2,
|
|
@@ -95,6 +96,7 @@ export async function refreshKnowledge(context) {
|
|
|
95
96
|
}
|
|
96
97
|
for (const record of apiInteractions.documents) {
|
|
97
98
|
documents.push(documentFrom({
|
|
99
|
+
id: record.id,
|
|
98
100
|
sourceKind: record.sourceKind,
|
|
99
101
|
repoKind: record.repoKind,
|
|
100
102
|
path: record.path,
|
|
@@ -106,12 +108,12 @@ export async function refreshKnowledge(context) {
|
|
|
106
108
|
}
|
|
107
109
|
warnings.push(...apiCatalog.warnings, ...apiInteractions.warnings);
|
|
108
110
|
if (context.fisPlatform && context.config.repos.fisPlatform && fs.existsSync(context.config.repos.fisPlatform)) {
|
|
109
|
-
repoKindsToSync.push('fis-platform');
|
|
110
111
|
const backendSources = await context.fisPlatform.listFocusedSources(apiCatalog);
|
|
111
112
|
const backendContracts = extractBackendContracts({ sources: backendSources });
|
|
112
113
|
snapshotStore.write('backend-contracts', backendContracts);
|
|
113
114
|
for (const record of backendContracts.documents) {
|
|
114
115
|
documents.push(documentFrom({
|
|
116
|
+
id: record.id,
|
|
115
117
|
sourceKind: record.sourceKind,
|
|
116
118
|
repoKind: record.repoKind,
|
|
117
119
|
path: record.path,
|
|
@@ -128,7 +130,6 @@ export async function refreshKnowledge(context) {
|
|
|
128
130
|
warnings.push(`Skipped autotests2 refresh because '${context.config.repos.autotests2}' is unavailable.`);
|
|
129
131
|
}
|
|
130
132
|
if (fs.existsSync(context.config.repos.caseplatformWeb)) {
|
|
131
|
-
repoKindsToSync.push('caseplatform-web');
|
|
132
133
|
const frontendContract = await extractFrontendContract({
|
|
133
134
|
root: context.config.repos.caseplatformWeb,
|
|
134
135
|
widgetHint: context.config.projectHints?.defaultWidget ?? 'caption',
|
|
@@ -152,9 +153,8 @@ export async function refreshKnowledge(context) {
|
|
|
152
153
|
else {
|
|
153
154
|
warnings.push(`Skipped caseplatform-web refresh because '${context.config.repos.caseplatformWeb}' is unavailable.`);
|
|
154
155
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
156
|
+
syncDocumentsByRepoKinds(context.database, documents, repoKindsToSync);
|
|
157
|
+
writeRefreshState(context, now);
|
|
158
158
|
const uniqueDocuments = dedupeDocuments(documents);
|
|
159
159
|
const stats = {
|
|
160
160
|
attemptedDocuments: documents.length,
|
|
@@ -169,9 +169,46 @@ export async function refreshKnowledge(context) {
|
|
|
169
169
|
warnings,
|
|
170
170
|
};
|
|
171
171
|
}
|
|
172
|
+
export function getRefreshSignature(context) {
|
|
173
|
+
return JSON.stringify({
|
|
174
|
+
autotests2: repoState(context.config.repos.autotests2),
|
|
175
|
+
caseplatformWeb: repoState(context.config.repos.caseplatformWeb),
|
|
176
|
+
fisPlatform: context.config.repos.fisPlatform ? repoState(context.config.repos.fisPlatform) : null,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
export function readRefreshState(cacheDir) {
|
|
180
|
+
const filePath = path.join(cacheDir, REFRESH_STATE_FILE);
|
|
181
|
+
try {
|
|
182
|
+
return readJson(filePath);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
export function isRefreshStateExpired(refreshState) {
|
|
189
|
+
return Date.now() - Date.parse(refreshState.updatedAt) > REFRESH_STATE_TTL_MS;
|
|
190
|
+
}
|
|
191
|
+
function writeRefreshState(context, updatedAt) {
|
|
192
|
+
const filePath = path.join(context.config.cacheDir, REFRESH_STATE_FILE);
|
|
193
|
+
writeJson(filePath, {
|
|
194
|
+
repoSignature: getRefreshSignature(context),
|
|
195
|
+
updatedAt,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
function repoState(root) {
|
|
199
|
+
return {
|
|
200
|
+
root: path.resolve(root),
|
|
201
|
+
exists: fs.existsSync(root),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
172
204
|
function documentFrom(input) {
|
|
173
205
|
return {
|
|
174
|
-
id: crypto.createHash('sha1').update(
|
|
206
|
+
id: input.id ?? crypto.createHash('sha1').update([
|
|
207
|
+
input.repoKind,
|
|
208
|
+
input.sourceKind,
|
|
209
|
+
input.path,
|
|
210
|
+
input.title,
|
|
211
|
+
].join(':')).digest('hex'),
|
|
175
212
|
sourceKind: input.sourceKind,
|
|
176
213
|
repoKind: input.repoKind,
|
|
177
214
|
path: input.path,
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import
|
|
2
|
-
export type KnowledgeDatabase =
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
export type KnowledgeDatabase = DatabaseSync;
|
|
3
3
|
export declare function createKnowledgeDatabase(filePath: string): KnowledgeDatabase;
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
2
|
import { baseMigrations } from './migrations.js';
|
|
3
3
|
export function createKnowledgeDatabase(filePath) {
|
|
4
|
-
const db = new
|
|
5
|
-
|
|
4
|
+
const db = new DatabaseSync(filePath);
|
|
5
|
+
// This database is a rebuildable cache, so we optimize for fast refreshes over crash durability.
|
|
6
|
+
db.exec(`
|
|
7
|
+
PRAGMA journal_mode = MEMORY;
|
|
8
|
+
PRAGMA synchronous = OFF;
|
|
9
|
+
PRAGMA temp_store = MEMORY;
|
|
10
|
+
PRAGMA cache_size = -20000;
|
|
11
|
+
PRAGMA busy_timeout = 5000;
|
|
12
|
+
`);
|
|
6
13
|
db.exec(baseMigrations);
|
|
7
14
|
return db;
|
|
8
15
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
const INSERT_DOCUMENT_SQL = `
|
|
2
|
+
INSERT INTO documents (id, source_kind, repo_kind, path, title, body, metadata_json, updated_at)
|
|
3
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
4
|
+
`;
|
|
5
|
+
const INSERT_FTS_SQL = 'INSERT INTO documents_fts (id, path, title, body) VALUES (?, ?, ?, ?)';
|
|
1
6
|
export function upsertDocuments(db, documents) {
|
|
2
7
|
const upsertDocument = db.prepare(`
|
|
3
|
-
|
|
4
|
-
VALUES (@id, @sourceKind, @repoKind, @path, @title, @body, @metadataJson, @updatedAt)
|
|
8
|
+
${INSERT_DOCUMENT_SQL}
|
|
5
9
|
ON CONFLICT(id) DO UPDATE SET
|
|
6
10
|
source_kind = excluded.source_kind,
|
|
7
11
|
repo_kind = excluded.repo_kind,
|
|
@@ -12,35 +16,32 @@ export function upsertDocuments(db, documents) {
|
|
|
12
16
|
updated_at = excluded.updated_at
|
|
13
17
|
`);
|
|
14
18
|
const deleteFts = db.prepare('DELETE FROM documents_fts WHERE id = ?');
|
|
15
|
-
const insertFts = db.prepare(
|
|
16
|
-
|
|
17
|
-
for (const record of
|
|
18
|
-
upsertDocument.run(record);
|
|
19
|
+
const insertFts = db.prepare(INSERT_FTS_SQL);
|
|
20
|
+
runInTransaction(db, () => {
|
|
21
|
+
for (const record of documents) {
|
|
22
|
+
upsertDocument.run(record.id, record.sourceKind, record.repoKind, record.path, record.title, record.body, record.metadataJson, record.updatedAt);
|
|
19
23
|
deleteFts.run(record.id);
|
|
20
24
|
insertFts.run(record.id, record.path, record.title, record.body);
|
|
21
25
|
}
|
|
22
26
|
});
|
|
23
|
-
transaction(documents);
|
|
24
27
|
}
|
|
25
28
|
export function syncDocumentsByRepoKinds(db, documents, repoKinds) {
|
|
26
|
-
upsertDocuments(db, documents);
|
|
27
29
|
const normalizedRepoKinds = [...new Set(repoKinds)];
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
30
|
+
const insertDocument = db.prepare(INSERT_DOCUMENT_SQL);
|
|
31
|
+
const insertFts = db.prepare(INSERT_FTS_SQL);
|
|
32
|
+
runInTransaction(db, () => {
|
|
33
|
+
if (normalizedRepoKinds.length > 0) {
|
|
34
|
+
const placeholders = normalizedRepoKinds.map(() => '?').join(', ');
|
|
35
|
+
db.prepare(`DELETE FROM documents_fts WHERE id IN (SELECT id FROM documents WHERE repo_kind IN (${placeholders}))`)
|
|
36
|
+
.run(...normalizedRepoKinds);
|
|
37
|
+
db.prepare(`DELETE FROM documents WHERE repo_kind IN (${placeholders})`)
|
|
38
|
+
.run(...normalizedRepoKinds);
|
|
39
|
+
}
|
|
40
|
+
for (const record of documents) {
|
|
41
|
+
insertDocument.run(record.id, record.sourceKind, record.repoKind, record.path, record.title, record.body, record.metadataJson, record.updatedAt);
|
|
42
|
+
insertFts.run(record.id, record.path, record.title, record.body);
|
|
41
43
|
}
|
|
42
44
|
});
|
|
43
|
-
transaction();
|
|
44
45
|
}
|
|
45
46
|
export function countDocuments(db) {
|
|
46
47
|
const row = db.prepare('SELECT COUNT(*) AS count FROM documents').get();
|
|
@@ -76,3 +77,14 @@ function normalizeFtsQuery(query) {
|
|
|
76
77
|
.map((token) => `"${token.replaceAll('"', '""')}"`)
|
|
77
78
|
.join(' OR ');
|
|
78
79
|
}
|
|
80
|
+
function runInTransaction(db, operation) {
|
|
81
|
+
db.exec('BEGIN');
|
|
82
|
+
try {
|
|
83
|
+
operation();
|
|
84
|
+
db.exec('COMMIT');
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
db.exec('ROLLBACK');
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|