@mknrt/autotests-overkill 1.1.3 → 1.1.5
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 -5
- package/dist/src/appContext.d.ts +2 -0
- package/dist/src/appContext.js +4 -0
- package/dist/src/config/consumerConfig.d.ts +5 -0
- package/dist/src/config/consumerConfig.js +5 -0
- package/dist/src/connectors/autotests2Connector.d.ts +8 -0
- package/dist/src/connectors/autotests2Connector.js +7 -0
- package/dist/src/connectors/fisPlatformConnector.d.ts +15 -0
- package/dist/src/connectors/fisPlatformConnector.js +112 -0
- package/dist/src/domain/analyzeDiffImpact.js +66 -0
- package/dist/src/domain/findBackendTestContext.d.ts +24 -0
- package/dist/src/domain/findBackendTestContext.js +165 -0
- package/dist/src/domain/generateSpecBlueprint.js +48 -0
- package/dist/src/domain/triageFailedRun.js +97 -0
- package/dist/src/indexer/extractors/apiDiscoveryExtractor.d.ts +23 -0
- package/dist/src/indexer/extractors/apiDiscoveryExtractor.js +183 -0
- package/dist/src/indexer/extractors/apiInteractionExtractor.d.ts +23 -0
- package/dist/src/indexer/extractors/apiInteractionExtractor.js +35 -0
- package/dist/src/indexer/extractors/backendContractExtractor.d.ts +26 -0
- package/dist/src/indexer/extractors/backendContractExtractor.js +66 -0
- package/dist/src/indexer/extractors/frontendContractExtractor.js +10 -2
- package/dist/src/indexer/refreshPipeline.js +40 -0
- package/dist/src/mcp/registerTools.js +8 -0
- package/docs/operator-cookbook.md +14 -3
- package/docs/superpowers/plans/2026-04-20-fis-platform-backend-integration.md +980 -0
- package/docs/tool-catalog.md +12 -3
- package/overkill.config.example.json +2 -1
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { extractTestAssets } from '../indexer/extractors/testAssetExtractor.js';
|
|
3
|
+
import { queryDocuments } from '../knowledge/repositories.js';
|
|
3
4
|
import { safeRelative, toPosixPath } from '../utils/pathUtils.js';
|
|
4
5
|
import { evidenceFile, output } from './shared.js';
|
|
5
6
|
export async function generateSpecBlueprint(input, context) {
|
|
@@ -25,6 +26,28 @@ export async function generateSpecBlueprint(input, context) {
|
|
|
25
26
|
const selectorGuidance = selectorPaths.length > 0
|
|
26
27
|
? `Prefer selectors already defined in ${selectorPaths.join(', ')} before adding new hooks.`
|
|
27
28
|
: 'Prefer cy.getWidget(...) plus the selectors tree from cypress/support/selectors/index.js; do not add new selectors under cypress/support/selectors/elements.';
|
|
29
|
+
const apiContext = highConfidence(queryDocuments(context.database, input.feature, {
|
|
30
|
+
repoKind: 'autotests2',
|
|
31
|
+
sourceKinds: ['api-interaction'],
|
|
32
|
+
limit: 3,
|
|
33
|
+
}));
|
|
34
|
+
const backendContext = apiContext.length > 0
|
|
35
|
+
? highConfidence(queryDocuments(context.database, [
|
|
36
|
+
input.feature,
|
|
37
|
+
...apiContext.flatMap((item) => parseMetadata(item.metadata_json).normalizedPaths ?? []),
|
|
38
|
+
].join(' '), {
|
|
39
|
+
repoKind: 'fis-platform',
|
|
40
|
+
sourceKinds: ['backend-endpoint'],
|
|
41
|
+
limit: 3,
|
|
42
|
+
}))
|
|
43
|
+
: [];
|
|
44
|
+
const backendGuidance = apiContext.length > 0
|
|
45
|
+
? [
|
|
46
|
+
`API flow: ${apiContext[0]?.title ?? backendContext[0]?.title}`,
|
|
47
|
+
'Preserve request order, required query params, and async task polling from the indexed flow.',
|
|
48
|
+
'If the scenario depends on async backend processing, verify completion signals before UI assertions.',
|
|
49
|
+
]
|
|
50
|
+
: [];
|
|
28
51
|
const blueprint = buildBlueprint({
|
|
29
52
|
area,
|
|
30
53
|
feature: input.feature,
|
|
@@ -40,6 +63,17 @@ export async function generateSpecBlueprint(input, context) {
|
|
|
40
63
|
...referenceSpecs.map((spec) => evidenceFile('reference spec', spec.path)),
|
|
41
64
|
...reuseCandidates.slice(0, 4).map((candidate) => evidenceFile(candidate.label, candidate.path)),
|
|
42
65
|
...selectorPaths.slice(0, 2).map((selectorPath) => evidenceFile('selector file', selectorPath)),
|
|
66
|
+
...(backendGuidance.length > 0
|
|
67
|
+
? [{
|
|
68
|
+
type: 'symbol',
|
|
69
|
+
label: 'backend hint',
|
|
70
|
+
snippet: backendGuidance.join('\n'),
|
|
71
|
+
metadata: {
|
|
72
|
+
apiEvidence: apiContext.map((item) => item.path),
|
|
73
|
+
backendEvidence: backendContext.map((item) => item.path),
|
|
74
|
+
},
|
|
75
|
+
}]
|
|
76
|
+
: []),
|
|
43
77
|
{
|
|
44
78
|
type: 'symbol',
|
|
45
79
|
label: 'blueprint',
|
|
@@ -50,8 +84,22 @@ export async function generateSpecBlueprint(input, context) {
|
|
|
50
84
|
`Reuse the ${hookPlan.beforeSummary} before() pattern and the ${hookPlan.beforeEachSummary} beforeEach() pattern from ${referenceSpec ? toPosixPath(referenceSpec.path) : 'the closest area reference spec'}.`,
|
|
51
85
|
`Borrow commands/helpers from ${reuseCandidates.map((candidate) => candidate.name).join(', ')} before introducing new support code.`,
|
|
52
86
|
selectorGuidance,
|
|
87
|
+
...(backendGuidance.length > 0
|
|
88
|
+
? ['Preserve backend request order, required params, and completion polling from the indexed API interaction before adding UI assertions.']
|
|
89
|
+
: []),
|
|
53
90
|
], assets.warnings);
|
|
54
91
|
}
|
|
92
|
+
function highConfidence(items) {
|
|
93
|
+
return items.filter((item) => (parseMetadata(item.metadata_json).confidence ?? 0) >= 0.7);
|
|
94
|
+
}
|
|
95
|
+
function parseMetadata(metadataJson) {
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(metadataJson);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
55
103
|
function buildBlueprint(context) {
|
|
56
104
|
const tags = context.area === 'constructor'
|
|
57
105
|
? `[ '@constructor', '@regress' ]`
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import { countDocuments, queryDocuments } from '../knowledge/repositories.js';
|
|
2
3
|
import { evidenceFile, output } from './shared.js';
|
|
3
4
|
export async function triageFailedRun(input, context) {
|
|
4
5
|
const stats = context.ciArtifacts.readStats(input.statsFile);
|
|
@@ -11,6 +12,10 @@ export async function triageFailedRun(input, context) {
|
|
|
11
12
|
const apiCaptures = context.ciArtifacts.listArtifacts(input.apiCaptureRoot, '**/*.json');
|
|
12
13
|
const failedSpecs = stats.failed_specs ?? [];
|
|
13
14
|
const repeatedAttempts = screenshots.filter((filePath) => filePath.includes('attempt 2')).length;
|
|
15
|
+
const captureSignals = readApiCaptureSignals(apiCaptures);
|
|
16
|
+
const apiBackendContext = captureSignals.length > 0
|
|
17
|
+
? await matchApiCapturesToBackendContext(captureSignals, context)
|
|
18
|
+
: { interactionHits: [], backendHits: [] };
|
|
14
19
|
const warnings = failedSpecs.length === 0
|
|
15
20
|
? ['Current stats file reports zero failed specs; using persisted screenshots and artifact residue for triage context.']
|
|
16
21
|
: [];
|
|
@@ -19,6 +24,9 @@ export async function triageFailedRun(input, context) {
|
|
|
19
24
|
failedSpecs.length > 0 ? 'Rerun only the failed specs first.' : 'Start from the specs named in persisted screenshots before broad reruns.',
|
|
20
25
|
repeatedAttempts > 0 ? 'Inspect flaky or environment-sensitive failures because repeated attempts are present.' : 'Inspect first-failure artifacts before assuming flakiness.',
|
|
21
26
|
apiCaptures.length > 0 ? 'Compare API capture payloads for the affected run before editing selectors.' : 'Capture API traffic on the next rerun to reduce ambiguity.',
|
|
27
|
+
...(apiBackendContext.interactionHits.length > 0 || apiBackendContext.backendHits.length > 0
|
|
28
|
+
? ['Compare the failing API capture with the indexed API interaction flow and backend contract before editing the test flow.']
|
|
29
|
+
: []),
|
|
22
30
|
];
|
|
23
31
|
return output(`CI triage summary: stats show ${stats.total ?? 0} tests, ${stats.failed ?? 0} current failures, ${screenshots.length} screenshots, ${videos.length} videos, and ${apiCaptures.length} captured API artifacts.`, [
|
|
24
32
|
evidenceFile('stats report', input.statsFile, JSON.stringify({
|
|
@@ -30,6 +38,16 @@ export async function triageFailedRun(input, context) {
|
|
|
30
38
|
...failedSpecs.slice(0, 5).map((specPath) => ({ type: 'test', label: 'failed spec', path: specPath })),
|
|
31
39
|
...screenshots.slice(0, 5).map((filePath) => evidenceFile('failure screenshot', filePath, fs.existsSync(filePath) ? undefined : 'missing')),
|
|
32
40
|
...videos.slice(0, 3).map((filePath) => evidenceFile('failure video', filePath)),
|
|
41
|
+
...captureSignals.slice(0, 5).map((signal) => evidenceFile('matched api capture', signal.sourcePath, `${signal.method ?? 'UNKNOWN'} ${signal.path} -> ${signal.status ?? 'unknown status'}`, {
|
|
42
|
+
requestPath: signal.path,
|
|
43
|
+
status: signal.status,
|
|
44
|
+
})),
|
|
45
|
+
...apiBackendContext.interactionHits.map((item) => evidenceFile('api interaction', item.path, item.title, {
|
|
46
|
+
confidence: parseMetadata(item.metadata_json).confidence,
|
|
47
|
+
})),
|
|
48
|
+
...apiBackendContext.backendHits.map((item) => evidenceFile('backend endpoint', item.path, item.title, {
|
|
49
|
+
confidence: parseMetadata(item.metadata_json).confidence,
|
|
50
|
+
})),
|
|
33
51
|
...(reportPortal.launchLink ? [{
|
|
34
52
|
type: 'report',
|
|
35
53
|
label: reportPortal.launchStatus ? `ReportPortal launch (${reportPortal.launchStatus})` : 'ReportPortal launch',
|
|
@@ -44,3 +62,82 @@ export async function triageFailedRun(input, context) {
|
|
|
44
62
|
}] : []),
|
|
45
63
|
], actions, warnings);
|
|
46
64
|
}
|
|
65
|
+
function readApiCaptureSignals(apiCaptures) {
|
|
66
|
+
return apiCaptures.flatMap((filePath) => {
|
|
67
|
+
try {
|
|
68
|
+
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
69
|
+
const entries = Array.isArray(raw) ? raw : [raw];
|
|
70
|
+
return entries.flatMap((entry) => normalizeCaptureEntry(entry, filePath));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function normalizeCaptureEntry(entry, sourcePath) {
|
|
78
|
+
if (!entry || typeof entry !== 'object') {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
const record = entry;
|
|
82
|
+
const pathValue = record.path ?? record.url ?? record.requestUrl;
|
|
83
|
+
if (typeof pathValue !== 'string') {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
return [{
|
|
87
|
+
sourcePath,
|
|
88
|
+
method: typeof record.method === 'string' ? record.method : undefined,
|
|
89
|
+
path: normalizeRequestPath(pathValue),
|
|
90
|
+
status: typeof record.status === 'number' ? record.status : undefined,
|
|
91
|
+
}];
|
|
92
|
+
}
|
|
93
|
+
async function matchApiCapturesToBackendContext(captureSignals, context) {
|
|
94
|
+
if (countDocuments(context.database) === 0) {
|
|
95
|
+
return { interactionHits: [], backendHits: [] };
|
|
96
|
+
}
|
|
97
|
+
const signalQuery = captureSignals.map((signal) => signal.path).join(' ');
|
|
98
|
+
const interactionHits = queryDocuments(context.database, signalQuery, {
|
|
99
|
+
repoKind: 'autotests2',
|
|
100
|
+
sourceKinds: ['api-interaction'],
|
|
101
|
+
limit: 6,
|
|
102
|
+
}).filter((item) => {
|
|
103
|
+
const metadata = parseMetadata(item.metadata_json);
|
|
104
|
+
return (metadata.confidence ?? 0) >= 0.7
|
|
105
|
+
&& (metadata.normalizedPaths ?? []).some((indexedPath) => captureSignals.some((signal) => pathsOverlap(indexedPath, signal.path)));
|
|
106
|
+
});
|
|
107
|
+
const backendHits = interactionHits.length > 0
|
|
108
|
+
? queryDocuments(context.database, interactionHits.map((item) => item.title).join(' '), {
|
|
109
|
+
repoKind: 'fis-platform',
|
|
110
|
+
sourceKinds: ['backend-endpoint'],
|
|
111
|
+
limit: 6,
|
|
112
|
+
}).filter((item) => (parseMetadata(item.metadata_json).confidence ?? 0) >= 0.7)
|
|
113
|
+
: [];
|
|
114
|
+
return { interactionHits, backendHits };
|
|
115
|
+
}
|
|
116
|
+
function normalizeRequestPath(value) {
|
|
117
|
+
try {
|
|
118
|
+
const parsed = new URL(value);
|
|
119
|
+
return parsed.pathname;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return value.split('?')[0] ?? value;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function pathsOverlap(indexedPath, capturedPath) {
|
|
126
|
+
return capturedPath.includes(indexedPath)
|
|
127
|
+
|| indexedPath.includes(capturedPath)
|
|
128
|
+
|| normalizeTokens(indexedPath).filter((token) => normalizeTokens(capturedPath).includes(token)).length >= 2;
|
|
129
|
+
}
|
|
130
|
+
function normalizeTokens(value) {
|
|
131
|
+
return value
|
|
132
|
+
.toLowerCase()
|
|
133
|
+
.split(/[^a-z0-9]+/)
|
|
134
|
+
.filter((token) => token.length > 1 && !/^(platform|rs2|api|rest)$/.test(token));
|
|
135
|
+
}
|
|
136
|
+
function parseMetadata(metadataJson) {
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(metadataJson);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return {};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type ApiDiscoveryCatalog = {
|
|
2
|
+
families: ApiDiscoveryFamily[];
|
|
3
|
+
generatedAt: string;
|
|
4
|
+
warnings: string[];
|
|
5
|
+
};
|
|
6
|
+
export type ApiDiscoveryFamily = {
|
|
7
|
+
family: string;
|
|
8
|
+
normalizedPaths: string[];
|
|
9
|
+
consumerSignals: ApiConsumerSignal[];
|
|
10
|
+
score: number;
|
|
11
|
+
whySelected: string[];
|
|
12
|
+
};
|
|
13
|
+
export type ApiConsumerSignal = {
|
|
14
|
+
kind: 'endpoint-constant' | 'request-wrapper' | 'capture-support' | 'spec-reference';
|
|
15
|
+
path: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
snippet: string;
|
|
18
|
+
};
|
|
19
|
+
export declare function buildApiDiscoveryCatalog(input: {
|
|
20
|
+
root: string;
|
|
21
|
+
maxSelectedFamilies?: number;
|
|
22
|
+
}): Promise<ApiDiscoveryCatalog>;
|
|
23
|
+
export declare function scoreFamily(signals: ApiConsumerSignal[]): number;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { Autotests2Connector } from '../../connectors/autotests2Connector.js';
|
|
3
|
+
import { toPosixPath } from '../../utils/pathUtils.js';
|
|
4
|
+
export async function buildApiDiscoveryCatalog(input) {
|
|
5
|
+
const connector = new Autotests2Connector(input.root);
|
|
6
|
+
const sources = await connector.findApiSupportSources();
|
|
7
|
+
const drafts = new Map();
|
|
8
|
+
for (const source of sources) {
|
|
9
|
+
collectEndpointSignals(source, drafts);
|
|
10
|
+
}
|
|
11
|
+
for (const source of sources) {
|
|
12
|
+
collectWrapperSignals(source, drafts);
|
|
13
|
+
collectCaptureSignals(source, drafts);
|
|
14
|
+
}
|
|
15
|
+
const candidates = [...drafts.entries()]
|
|
16
|
+
.map(([family, draft]) => {
|
|
17
|
+
const score = scoreFamily(draft.consumerSignals);
|
|
18
|
+
const signalKinds = new Set(draft.consumerSignals.map((signal) => signal.kind));
|
|
19
|
+
return {
|
|
20
|
+
family,
|
|
21
|
+
normalizedPaths: [...draft.normalizedPaths].sort(),
|
|
22
|
+
consumerSignals: dedupeSignals(draft.consumerSignals),
|
|
23
|
+
score,
|
|
24
|
+
whySelected: [
|
|
25
|
+
`Selected from ${signalKinds.size} consumer signal kind(s).`,
|
|
26
|
+
`Deterministic score ${score} from endpoint constants, request wrappers, capture support, and spec references.`,
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
})
|
|
30
|
+
.filter((family) => family.score > 0 && hasEnoughEvidence(family))
|
|
31
|
+
.sort((left, right) => {
|
|
32
|
+
if (right.score !== left.score) {
|
|
33
|
+
return right.score - left.score;
|
|
34
|
+
}
|
|
35
|
+
return left.family.localeCompare(right.family);
|
|
36
|
+
});
|
|
37
|
+
const selected = candidates.slice(0, input.maxSelectedFamilies ?? 3);
|
|
38
|
+
return {
|
|
39
|
+
families: selected,
|
|
40
|
+
generatedAt: new Date().toISOString(),
|
|
41
|
+
warnings: selected.length === 0
|
|
42
|
+
? [`No API families with at least two signal kinds were discovered under ${input.root}.`]
|
|
43
|
+
: [],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function scoreFamily(signals) {
|
|
47
|
+
const count = (kind) => signals.filter((signal) => signal.kind === kind).length;
|
|
48
|
+
return count('endpoint-constant') * 4
|
|
49
|
+
+ count('request-wrapper') * 3
|
|
50
|
+
+ count('capture-support') * 2
|
|
51
|
+
+ count('spec-reference');
|
|
52
|
+
}
|
|
53
|
+
function hasEnoughEvidence(family) {
|
|
54
|
+
return new Set(family.consumerSignals.map((signal) => signal.kind)).size >= 2;
|
|
55
|
+
}
|
|
56
|
+
function collectEndpointSignals(source, drafts) {
|
|
57
|
+
for (const line of source.body.split(/\r?\n/)) {
|
|
58
|
+
const endpointMatches = [...line.matchAll(/(?:const|let|var|export\s+const)?\s*([A-Z0-9_]+)?\s*=?\s*['"`]([^'"`]*(?:\/platform\/|\/rs2\/)[^'"`]*)['"`]/gi)];
|
|
59
|
+
for (const match of endpointMatches) {
|
|
60
|
+
const rawPath = match[2];
|
|
61
|
+
if (!rawPath) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const normalizedPath = normalizeApiPath(rawPath);
|
|
65
|
+
const family = inferFamily(normalizedPath);
|
|
66
|
+
if (!family) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const draft = getDraft(drafts, family);
|
|
70
|
+
draft.normalizedPaths.add(normalizedPath);
|
|
71
|
+
draft.consumerSignals.push({
|
|
72
|
+
kind: 'endpoint-constant',
|
|
73
|
+
path: source.path,
|
|
74
|
+
name: match[1],
|
|
75
|
+
snippet: line.trim(),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function collectWrapperSignals(source, drafts) {
|
|
81
|
+
if (!/cy\.request|fetch\(|XMLHttpRequest|\brequest\s*\(/i.test(source.body)) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const commandNames = [...source.body.matchAll(/Cypress\.Commands\.add\((?:'|")([^'"]+)(?:'|")/g)]
|
|
85
|
+
.map((match) => match[1])
|
|
86
|
+
.filter((value) => Boolean(value));
|
|
87
|
+
const families = familiesReferencedBySource(source, drafts);
|
|
88
|
+
for (const family of families) {
|
|
89
|
+
getDraft(drafts, family).consumerSignals.push({
|
|
90
|
+
kind: 'request-wrapper',
|
|
91
|
+
path: source.path,
|
|
92
|
+
name: commandNames.join(', ') || path.basename(source.path),
|
|
93
|
+
snippet: firstMatchingLine(source.body, /cy\.request|fetch\(|XMLHttpRequest|\brequest\s*\(/i),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function collectCaptureSignals(source, drafts) {
|
|
98
|
+
if (!/apiCapture/i.test(source.body)) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
for (const family of familiesReferencedBySource(source, drafts)) {
|
|
102
|
+
getDraft(drafts, family).consumerSignals.push({
|
|
103
|
+
kind: 'capture-support',
|
|
104
|
+
path: source.path,
|
|
105
|
+
snippet: firstMatchingLine(source.body, /apiCapture/i),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function familiesReferencedBySource(source, drafts) {
|
|
110
|
+
const directFamilies = new Set();
|
|
111
|
+
for (const endpoint of extractApiPaths(source.body)) {
|
|
112
|
+
const family = inferFamily(endpoint);
|
|
113
|
+
if (family) {
|
|
114
|
+
directFamilies.add(family);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (directFamilies.size > 0) {
|
|
118
|
+
return [...directFamilies];
|
|
119
|
+
}
|
|
120
|
+
const importedNames = [...source.body.matchAll(/\b([A-Z][A-Z0-9_]+_ENDPOINT)\b/g)]
|
|
121
|
+
.map((match) => match[1])
|
|
122
|
+
.filter((value) => Boolean(value))
|
|
123
|
+
.map((value) => value.toLowerCase());
|
|
124
|
+
return [...drafts.entries()]
|
|
125
|
+
.filter(([family, draft]) => {
|
|
126
|
+
const familyToken = family.toLowerCase().replace(/[^a-z0-9]+/g, '_');
|
|
127
|
+
return importedNames.some((name) => name.includes(familyToken))
|
|
128
|
+
|| [...draft.normalizedPaths].some((endpoint) => source.body.includes(endpoint));
|
|
129
|
+
})
|
|
130
|
+
.map(([family]) => family);
|
|
131
|
+
}
|
|
132
|
+
function extractApiPaths(body) {
|
|
133
|
+
return [...body.matchAll(/['"`]([^'"`]*(?:\/platform\/|\/rs2\/)[^'"`]*)['"`]/gi)]
|
|
134
|
+
.map((match) => match[1])
|
|
135
|
+
.filter((value) => Boolean(value))
|
|
136
|
+
.map((value) => normalizeApiPath(value));
|
|
137
|
+
}
|
|
138
|
+
function normalizeApiPath(value) {
|
|
139
|
+
const withoutHost = value.replace(/^https?:\/\/[^/]+/i, '');
|
|
140
|
+
const withoutQuery = withoutHost.split('?')[0] ?? withoutHost;
|
|
141
|
+
return withoutQuery.startsWith('/') ? withoutQuery : `/${withoutQuery}`;
|
|
142
|
+
}
|
|
143
|
+
function inferFamily(normalizedPath) {
|
|
144
|
+
const segments = normalizedPath.split('/').filter(Boolean);
|
|
145
|
+
const rs2Index = segments.findIndex((segment) => segment.toLowerCase() === 'rs2');
|
|
146
|
+
const rs2Family = segments[rs2Index + 1];
|
|
147
|
+
if (rs2Index >= 0 && rs2Family) {
|
|
148
|
+
return rs2Family.toLowerCase();
|
|
149
|
+
}
|
|
150
|
+
const platformIndex = segments.findIndex((segment) => segment.toLowerCase() === 'platform');
|
|
151
|
+
const platformFamily = segments[platformIndex + 1];
|
|
152
|
+
if (platformIndex >= 0 && platformFamily) {
|
|
153
|
+
return platformFamily.toLowerCase();
|
|
154
|
+
}
|
|
155
|
+
return segments.find((segment) => !/^(api|rest|v\d+)$/i.test(segment))?.toLowerCase();
|
|
156
|
+
}
|
|
157
|
+
function getDraft(drafts, family) {
|
|
158
|
+
const existing = drafts.get(family);
|
|
159
|
+
if (existing) {
|
|
160
|
+
return existing;
|
|
161
|
+
}
|
|
162
|
+
const draft = {
|
|
163
|
+
normalizedPaths: new Set(),
|
|
164
|
+
consumerSignals: [],
|
|
165
|
+
};
|
|
166
|
+
drafts.set(family, draft);
|
|
167
|
+
return draft;
|
|
168
|
+
}
|
|
169
|
+
function dedupeSignals(signals) {
|
|
170
|
+
const seen = new Set();
|
|
171
|
+
const result = [];
|
|
172
|
+
for (const signal of signals) {
|
|
173
|
+
const key = `${signal.kind}:${toPosixPath(signal.path)}:${signal.name ?? ''}:${signal.snippet}`;
|
|
174
|
+
if (!seen.has(key)) {
|
|
175
|
+
seen.add(key);
|
|
176
|
+
result.push(signal);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
function firstMatchingLine(body, pattern) {
|
|
182
|
+
return body.split(/\r?\n/).find((line) => pattern.test(line))?.trim() ?? body.slice(0, 160);
|
|
183
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ApiDiscoveryCatalog } from './apiDiscoveryExtractor.js';
|
|
2
|
+
export type ApiInteractionRecord = {
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
path: string;
|
|
6
|
+
body: string;
|
|
7
|
+
sourceKind: 'api-interaction';
|
|
8
|
+
repoKind: 'autotests2';
|
|
9
|
+
metadata: {
|
|
10
|
+
apiFamily: string;
|
|
11
|
+
normalizedPaths: string[];
|
|
12
|
+
consumerSignalIds: string[];
|
|
13
|
+
evidencePaths: string[];
|
|
14
|
+
confidence: number;
|
|
15
|
+
whySelected: string[];
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
export declare function extractApiInteractions(input: {
|
|
19
|
+
catalog: ApiDiscoveryCatalog;
|
|
20
|
+
}): {
|
|
21
|
+
documents: ApiInteractionRecord[];
|
|
22
|
+
warnings: string[];
|
|
23
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function extractApiInteractions(input) {
|
|
2
|
+
const documents = input.catalog.families
|
|
3
|
+
.map((family) => {
|
|
4
|
+
const consumerSignalIds = family.consumerSignals.map((signal, index) => `${signal.kind}:${signal.path}:${signal.name ?? index}`);
|
|
5
|
+
const evidencePaths = [...new Set(family.consumerSignals.map((signal) => signal.path))].sort();
|
|
6
|
+
const confidence = Math.min(1, family.score / 10);
|
|
7
|
+
const body = [
|
|
8
|
+
`API family: ${family.family}`,
|
|
9
|
+
`Normalized paths: ${family.normalizedPaths.join(', ')}`,
|
|
10
|
+
`Consumer evidence: ${family.consumerSignals.map((signal) => `${signal.kind} ${signal.name ?? ''} ${signal.snippet}`).join('\n')}`,
|
|
11
|
+
`Why selected: ${family.whySelected.join('\n')}`,
|
|
12
|
+
].join('\n');
|
|
13
|
+
return {
|
|
14
|
+
id: `api-interaction:${family.family}`,
|
|
15
|
+
title: `API interaction: ${family.family}`,
|
|
16
|
+
path: evidencePaths[0] ?? `api-discovery/${family.family}`,
|
|
17
|
+
body,
|
|
18
|
+
sourceKind: 'api-interaction',
|
|
19
|
+
repoKind: 'autotests2',
|
|
20
|
+
metadata: {
|
|
21
|
+
apiFamily: family.family,
|
|
22
|
+
normalizedPaths: family.normalizedPaths,
|
|
23
|
+
consumerSignalIds,
|
|
24
|
+
evidencePaths,
|
|
25
|
+
confidence,
|
|
26
|
+
whySelected: family.whySelected,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
})
|
|
30
|
+
.filter((record) => record.metadata.confidence >= 0.7);
|
|
31
|
+
return {
|
|
32
|
+
documents,
|
|
33
|
+
warnings: documents.length === 0 ? ['No high-confidence API interaction documents were extracted.'] : [],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { FocusedFisSource } from '../../connectors/fisPlatformConnector.js';
|
|
2
|
+
export type { FocusedFisSource } from '../../connectors/fisPlatformConnector.js';
|
|
3
|
+
export type BackendKnowledgeRecord = {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
path: string;
|
|
7
|
+
body: string;
|
|
8
|
+
sourceKind: 'backend-endpoint';
|
|
9
|
+
repoKind: 'fis-platform';
|
|
10
|
+
metadata: {
|
|
11
|
+
httpMethod?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
12
|
+
endpointPath: string;
|
|
13
|
+
apiFamily: string;
|
|
14
|
+
paramNames: string[];
|
|
15
|
+
ruleHints: string[];
|
|
16
|
+
evidencePaths: string[];
|
|
17
|
+
confidence: number;
|
|
18
|
+
whyMatched: string[];
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
export declare function extractBackendContracts(input: {
|
|
22
|
+
sources: FocusedFisSource[];
|
|
23
|
+
}): {
|
|
24
|
+
documents: BackendKnowledgeRecord[];
|
|
25
|
+
warnings: string[];
|
|
26
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export function extractBackendContracts(input) {
|
|
2
|
+
const documents = input.sources.flatMap((source) => extractRecordsFromSource(source));
|
|
3
|
+
return {
|
|
4
|
+
documents,
|
|
5
|
+
warnings: documents.length === 0 ? ['No backend endpoint contracts were extracted from focused FIS Platform sources.'] : [],
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
function extractRecordsFromSource(source) {
|
|
9
|
+
const classPath = firstMatch(source.body, /@Path\(\s*"([^"]+)"\s*\)/);
|
|
10
|
+
const methodBlocks = [...source.body.matchAll(/@(GET|POST|PUT|DELETE)([\s\S]*?)(?=@(?:GET|POST|PUT|DELETE)|\n\s*}\s*$)/g)];
|
|
11
|
+
if (methodBlocks.length === 0 && classPath) {
|
|
12
|
+
return [createRecord(source, undefined, classPath, source.body)];
|
|
13
|
+
}
|
|
14
|
+
return methodBlocks.map((match) => {
|
|
15
|
+
const method = match[1];
|
|
16
|
+
const block = match[0];
|
|
17
|
+
const methodPath = firstMatch(block, /@Path\(\s*"([^"]+)"\s*\)/);
|
|
18
|
+
return createRecord(source, method, joinPaths(classPath, methodPath), block);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function createRecord(source, httpMethod, endpointPath, body) {
|
|
22
|
+
const resolvedEndpointPath = endpointPath ?? firstMatch(source.body, /@Path\(\s*"([^"]+)"\s*\)/) ?? '/';
|
|
23
|
+
const paramNames = [
|
|
24
|
+
...extractMatches(body, /@QueryParam\(\s*"([^"]+)"\s*\)/g),
|
|
25
|
+
...extractMatches(body, /@HeaderParam\(\s*"([^"]+)"\s*\)/g),
|
|
26
|
+
].filter((value) => Boolean(value));
|
|
27
|
+
const ruleHints = extractRuleHints(body);
|
|
28
|
+
return {
|
|
29
|
+
id: `fis-platform:${source.matchedFamily}:${httpMethod ?? 'RESOURCE'}:${source.path}:${resolvedEndpointPath}`,
|
|
30
|
+
title: `${httpMethod ? `${httpMethod} ` : ''}${resolvedEndpointPath}`,
|
|
31
|
+
path: source.path,
|
|
32
|
+
body,
|
|
33
|
+
sourceKind: 'backend-endpoint',
|
|
34
|
+
repoKind: 'fis-platform',
|
|
35
|
+
metadata: {
|
|
36
|
+
httpMethod,
|
|
37
|
+
endpointPath: resolvedEndpointPath,
|
|
38
|
+
apiFamily: source.matchedFamily,
|
|
39
|
+
paramNames,
|
|
40
|
+
ruleHints,
|
|
41
|
+
evidencePaths: [source.path],
|
|
42
|
+
confidence: source.confidence,
|
|
43
|
+
whyMatched: source.whyMatched,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function joinPaths(base, child) {
|
|
48
|
+
const parts = [base, child]
|
|
49
|
+
.filter((part) => Boolean(part))
|
|
50
|
+
.map((part) => part.replace(/^\/+|\/+$/g, ''));
|
|
51
|
+
return `/${parts.join('/')}`.replace(/\/+/g, '/');
|
|
52
|
+
}
|
|
53
|
+
function firstMatch(body, pattern) {
|
|
54
|
+
return body.match(pattern)?.[1];
|
|
55
|
+
}
|
|
56
|
+
function extractMatches(body, pattern) {
|
|
57
|
+
return [...body.matchAll(pattern)].map((match) => match[1]);
|
|
58
|
+
}
|
|
59
|
+
function extractRuleHints(body) {
|
|
60
|
+
const hints = body
|
|
61
|
+
.split(/\r?\n/)
|
|
62
|
+
.map((line) => line.trim())
|
|
63
|
+
.filter((line) => /validate|required|permission|async|task|complete|status|throw|Exception/i.test(line))
|
|
64
|
+
.slice(0, 8);
|
|
65
|
+
return hints;
|
|
66
|
+
}
|
|
@@ -4,6 +4,7 @@ import { 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);
|
|
7
8
|
const warnings = [];
|
|
8
9
|
const controlBody = files.controlFile ? connector.read(files.controlFile) : '';
|
|
9
10
|
const singletonBody = files.singletonFile ? connector.read(files.singletonFile) : '';
|
|
@@ -12,7 +13,7 @@ export async function extractFrontendContract(input) {
|
|
|
12
13
|
if (files.mode === 'widget' && !files.controlFile) {
|
|
13
14
|
warnings.push(`No control file found for widget '${input.widgetHint}'.`);
|
|
14
15
|
}
|
|
15
|
-
const sources =
|
|
16
|
+
const sources = focusedFiles.map((filePath) => ({
|
|
16
17
|
path: filePath,
|
|
17
18
|
body: connector.read(filePath),
|
|
18
19
|
kind: connector.classifyFileKind(filePath),
|
|
@@ -52,11 +53,18 @@ export async function extractFrontendContract(input) {
|
|
|
52
53
|
events: [...new Set(events)],
|
|
53
54
|
files: files.mode === 'widget'
|
|
54
55
|
? unique([files.controlFile, files.singletonFile, files.handlersFile, files.templateFile, files.dataControlFile, ...files.supportFiles].filter(Boolean))
|
|
55
|
-
:
|
|
56
|
+
: focusedFiles,
|
|
56
57
|
snippets,
|
|
57
58
|
warnings,
|
|
58
59
|
};
|
|
59
60
|
}
|
|
61
|
+
function focusFrontendFiles(filePaths, widgetHint) {
|
|
62
|
+
if (!/auth|login/i.test(widgetHint)) {
|
|
63
|
+
return filePaths;
|
|
64
|
+
}
|
|
65
|
+
const focused = filePaths.filter((filePath) => /(?:^|[\\/])auth(?:[\\/]|$)|Auth|login/i.test(filePath));
|
|
66
|
+
return focused.length > 0 ? focused : filePaths;
|
|
67
|
+
}
|
|
60
68
|
function extractNamedObjectKeys(source, objectName) {
|
|
61
69
|
const startMatch = new RegExp(`${objectName}\\s*:\\s*\\{`).exec(source);
|
|
62
70
|
if (!startMatch || startMatch.index === undefined) {
|
|
@@ -2,6 +2,9 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { syncDocumentsByRepoKinds } from '../knowledge/repositories.js';
|
|
5
|
+
import { buildApiDiscoveryCatalog } from './extractors/apiDiscoveryExtractor.js';
|
|
6
|
+
import { extractApiInteractions } from './extractors/apiInteractionExtractor.js';
|
|
7
|
+
import { extractBackendContracts } from './extractors/backendContractExtractor.js';
|
|
5
8
|
import { extractFrontendContract } from './extractors/frontendContractExtractor.js';
|
|
6
9
|
import { SnapshotStore } from './snapshotStore.js';
|
|
7
10
|
import { ensureDirectory } from '../utils/fileUtils.js';
|
|
@@ -15,6 +18,13 @@ export async function refreshKnowledge(context) {
|
|
|
15
18
|
if (fs.existsSync(context.config.repos.autotests2)) {
|
|
16
19
|
repoKindsToSync.push('autotests2');
|
|
17
20
|
const docs = context.autotests2.getAreaDocs();
|
|
21
|
+
const apiCatalog = await buildApiDiscoveryCatalog({
|
|
22
|
+
root: context.config.repos.autotests2,
|
|
23
|
+
maxSelectedFamilies: 3,
|
|
24
|
+
});
|
|
25
|
+
snapshotStore.write('api-discovery-catalog', apiCatalog);
|
|
26
|
+
const apiInteractions = extractApiInteractions({ catalog: apiCatalog });
|
|
27
|
+
snapshotStore.write('api-interactions', apiInteractions);
|
|
18
28
|
const testAssets = {
|
|
19
29
|
specs: await context.autotests2.findSpecs(),
|
|
20
30
|
commands: await context.autotests2.findCommands(),
|
|
@@ -83,6 +93,36 @@ export async function refreshKnowledge(context) {
|
|
|
83
93
|
}));
|
|
84
94
|
}
|
|
85
95
|
}
|
|
96
|
+
for (const record of apiInteractions.documents) {
|
|
97
|
+
documents.push(documentFrom({
|
|
98
|
+
sourceKind: record.sourceKind,
|
|
99
|
+
repoKind: record.repoKind,
|
|
100
|
+
path: record.path,
|
|
101
|
+
title: record.title,
|
|
102
|
+
body: record.body,
|
|
103
|
+
metadata: record.metadata,
|
|
104
|
+
updatedAt: now,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
warnings.push(...apiCatalog.warnings, ...apiInteractions.warnings);
|
|
108
|
+
if (context.fisPlatform && context.config.repos.fisPlatform && fs.existsSync(context.config.repos.fisPlatform)) {
|
|
109
|
+
repoKindsToSync.push('fis-platform');
|
|
110
|
+
const backendSources = await context.fisPlatform.listFocusedSources(apiCatalog);
|
|
111
|
+
const backendContracts = extractBackendContracts({ sources: backendSources });
|
|
112
|
+
snapshotStore.write('backend-contracts', backendContracts);
|
|
113
|
+
for (const record of backendContracts.documents) {
|
|
114
|
+
documents.push(documentFrom({
|
|
115
|
+
sourceKind: record.sourceKind,
|
|
116
|
+
repoKind: record.repoKind,
|
|
117
|
+
path: record.path,
|
|
118
|
+
title: record.title,
|
|
119
|
+
body: record.body,
|
|
120
|
+
metadata: record.metadata,
|
|
121
|
+
updatedAt: now,
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
warnings.push(...backendContracts.warnings);
|
|
125
|
+
}
|
|
86
126
|
}
|
|
87
127
|
else {
|
|
88
128
|
warnings.push(`Skipped autotests2 refresh because '${context.config.repos.autotests2}' is unavailable.`);
|