@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.
Files changed (45) hide show
  1. package/README.md +17 -3
  2. package/dist/src/cli/indexKnowledge.js +8 -1
  3. package/dist/src/cli/runAcceptance.d.ts +2 -1
  4. package/dist/src/cli/runAcceptance.js +33 -2
  5. package/dist/src/cli/runMcpServer.js +8 -1
  6. package/dist/src/cli/runtimeStartupError.d.ts +3 -0
  7. package/dist/src/cli/runtimeStartupError.js +60 -0
  8. package/dist/src/contracts/toolOutput.d.ts +6 -6
  9. package/dist/src/contracts/toolOutput.js +8 -4
  10. package/dist/src/domain/analyzeDiffImpact.d.ts +1 -1
  11. package/dist/src/domain/analyzeDiffImpact.js +14 -4
  12. package/dist/src/domain/analyzeTestGaps.d.ts +1 -1
  13. package/dist/src/domain/analyzeTestGaps.js +13 -1
  14. package/dist/src/domain/buildRerunScope.d.ts +1 -1
  15. package/dist/src/domain/buildRerunScope.js +6 -1
  16. package/dist/src/domain/buildSetupTeardownPlan.d.ts +1 -1
  17. package/dist/src/domain/findBackendTestContext.d.ts +1 -1
  18. package/dist/src/domain/findExistingTestAssets.d.ts +1 -1
  19. package/dist/src/domain/findFrontendContract.d.ts +1 -1
  20. package/dist/src/domain/findFrontendRuntimeLogic.d.ts +1 -1
  21. package/dist/src/domain/findTestAreaContext.d.ts +1 -1
  22. package/dist/src/domain/generateSpecBlueprint.d.ts +1 -1
  23. package/dist/src/domain/mapFeatureToExistingCoverage.d.ts +1 -1
  24. package/dist/src/domain/recommendSelectorStrategy.d.ts +1 -1
  25. package/dist/src/domain/reviewTestDraft.d.ts +26 -0
  26. package/dist/src/domain/reviewTestDraft.js +226 -0
  27. package/dist/src/domain/shared.js +15 -2
  28. package/dist/src/domain/summarizeCiContext.d.ts +1 -1
  29. package/dist/src/domain/triageFailedRun.d.ts +1 -1
  30. package/dist/src/domain/triageFailedRun.js +14 -3
  31. package/dist/src/indexer/extractors/backendContractExtractor.js +9 -1
  32. package/dist/src/indexer/extractors/frontendContractExtractor.js +44 -8
  33. package/dist/src/indexer/refreshPipeline.d.ts +10 -0
  34. package/dist/src/indexer/refreshPipeline.js +46 -9
  35. package/dist/src/knowledge/database.d.ts +2 -2
  36. package/dist/src/knowledge/database.js +10 -3
  37. package/dist/src/knowledge/repositories.js +34 -22
  38. package/dist/src/mcp/registerTools.js +12 -0
  39. package/dist/src/utils/pathUtils.d.ts +3 -0
  40. package/dist/src/utils/pathUtils.js +29 -0
  41. package/docs/audits/2026-04-20-iteration-1.md +251 -0
  42. package/docs/audits/2026-04-20-iteration-2-comprehensive.md +563 -0
  43. package/docs/superpowers/plans/2026-04-19-autotests-overkill-v1.md +1 -1
  44. package/docs/superpowers/plans/2026-04-20-fis-platform-backend-integration.md +1 -1
  45. 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
- if (countDocuments(context.database) > 0) {
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" | "config" | "file" | "test" | "artifact" | "report" | "diff";
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" | "config" | "file" | "test" | "artifact" | "report" | "diff";
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 backendHits = interactionHits.length > 0
108
- ? queryDocuments(context.database, interactionHits.map((item) => item.title).join(' '), {
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) => (parseMetadata(item.metadata_json).confidence ?? 0) >= 0.7)
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: `fis-platform:${source.matchedFamily}:${httpMethod ?? 'RESOURCE'}:${source.path}:${resolvedEndpointPath}`,
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 sources = focusedFiles.map((filePath) => ({
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 focusFrontendFiles(filePaths, widgetHint) {
62
- if (!/auth|login/i.test(widgetHint)) {
63
- return filePaths;
64
+ function focusFrontendSources(sources, widgetHint) {
65
+ const intentTokens = featureIntentTokens(widgetHint);
66
+ if (intentTokens.length === 0) {
67
+ return sources;
64
68
  }
65
- const focused = filePaths.filter((filePath) => /(?:^|[\\/])auth(?:[\\/]|$)|Auth|login/i.test(filePath));
66
- return focused.length > 0 ? focused : filePaths;
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
- if (repoKindsToSync.length > 0) {
156
- syncDocumentsByRepoKinds(context.database, documents, repoKindsToSync);
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(`${input.sourceKind}:${input.path}`).digest('hex'),
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 Database from 'better-sqlite3';
2
- export type KnowledgeDatabase = Database.Database;
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 Database from 'better-sqlite3';
1
+ import { DatabaseSync } from 'node:sqlite';
2
2
  import { baseMigrations } from './migrations.js';
3
3
  export function createKnowledgeDatabase(filePath) {
4
- const db = new Database(filePath);
5
- db.pragma('journal_mode = WAL');
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
- INSERT INTO documents (id, source_kind, repo_kind, path, title, body, metadata_json, updated_at)
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('INSERT INTO documents_fts (id, path, title, body) VALUES (?, ?, ?, ?)');
16
- const transaction = db.transaction((records) => {
17
- for (const record of records) {
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 selectIds = db.prepare('SELECT id FROM documents WHERE repo_kind = ?');
29
- const deleteDocument = db.prepare('DELETE FROM documents WHERE id = ?');
30
- const deleteFts = db.prepare('DELETE FROM documents_fts WHERE id = ?');
31
- const transaction = db.transaction(() => {
32
- for (const repoKind of normalizedRepoKinds) {
33
- const desiredIds = new Set(documents.filter((record) => record.repoKind === repoKind).map((record) => record.id));
34
- const existingIds = selectIds.all(repoKind);
35
- for (const row of existingIds) {
36
- if (!desiredIds.has(row.id)) {
37
- deleteDocument.run(row.id);
38
- deleteFts.run(row.id);
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
+ }