@skyramp/mcp 0.3.4 → 0.3.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/build/prompts/testbot/testbot-prompts.js +10 -1
- package/build/tools/submitReportTool.js +89 -3
- package/build/tools/test-management/analyzeChangesTool.js +4 -2
- package/build/tools/test-management/registerTestPlanTool.js +79 -11
- package/build/types/TestbotReport.d.ts +15 -2
- package/build/utils/AnalysisStateManager.d.ts +12 -0
- package/build/utils/AnalysisStateManager.js +15 -0
- package/build/utils/reportLanguage.d.ts +43 -0
- package/build/utils/reportLanguage.js +125 -0
- package/build/utils/reportVerification.d.ts +10 -0
- package/build/utils/reportVerification.js +31 -0
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@ import { MAX_TESTS_TO_GENERATE, MAX_RECOMMENDATIONS, MAX_CRITICAL_TESTS, PATH_PA
|
|
|
5
5
|
import { TASK_ANALYZE_MAINTAIN, TASK_GENERATE, TASK_SUBMIT, taskRef } from "../test-recommendation/recommendationShared.js";
|
|
6
6
|
import { getTraceRecordingPromptText } from "../../playwright/traceRecordingPrompt.js";
|
|
7
7
|
import { isContractConsumerModeEnabled, isPomReuseEnabled } from "../../utils/featureFlags.js";
|
|
8
|
+
import { setReportLanguage } from "../../utils/reportLanguage.js";
|
|
8
9
|
import { resolveServiceDetailsRef } from "../../utils/utils.js";
|
|
9
10
|
import { buildServiceContext, readWorkspaceServices, } from "../prompt-utils.js";
|
|
10
11
|
// Cached at module-load — flags are process-wide and cannot change per call.
|
|
@@ -77,6 +78,14 @@ export function getTestbotPrompt(opts) {
|
|
|
77
78
|
// SKYR-4023: user-facing report language. English display names improve
|
|
78
79
|
// instruction-following vs bare codes; Intl.DisplayNames (Node >=18) names any
|
|
79
80
|
// code correctly, falling back to the raw code only if it can't be resolved.
|
|
81
|
+
// SKYR-4185: rendering the prompt is the single capture point for the
|
|
82
|
+
// report language — the prompt registration AND the testbot resource both
|
|
83
|
+
// come through here, so skyramp_submit_report's language guardrail sees it
|
|
84
|
+
// regardless of which surface served the prompt (no dual wiring to forget).
|
|
85
|
+
// Set on EVERY render, not just non-English ones: last render wins, so an
|
|
86
|
+
// en/argless render disarms a language captured earlier in a long-lived
|
|
87
|
+
// server process instead of falsely rejecting an English report.
|
|
88
|
+
setReportLanguage(language && language !== 'en' ? language : undefined);
|
|
80
89
|
let reportLanguageBlock = '';
|
|
81
90
|
if (language && language !== 'en') {
|
|
82
91
|
const reportLanguageName = new Intl.DisplayNames(['en'], { type: 'language' }).of(language) ?? language;
|
|
@@ -408,7 +417,7 @@ This is a plan-only evaluation run: the application under test is NOT running, a
|
|
|
408
417
|
|
|
409
418
|
${userPrompt ? "Generate only the tests that the user requested from the Additional Recommendations. The rules below still apply." : "Drift-based maintenance (Task 1) is complete. This step only processes the GENERATE list. Exception: if a GENERATE item targets a resource with an existing `[skyramp]` contract test, UPDATE that test file (see covered-resource handling below) — a new test case added to an existing file counts toward the budget and is reported in `newTestsCreated`."}
|
|
410
419
|
|
|
411
|
-
- **MANDATORY — use the plan returned by \`skyramp_register_test_plan\` as-is**: Before generating anything, call \`skyramp_register_test_plan\` (\`stateFile\` required) with your complete candidate list — every test you would generate OR recommend, including the Execution Plan's own pre-ranked GENERATE/ADDITIONAL items and any candidate you drafted yourself, with a \`discriminator\` claim \`{kind, changedCodeAnchor}\` for candidates probing changed logic. Its returned GENERATE list — not the Execution Plan's raw pre-ranked GENERATE section — governs ADD actions from this point on. You MUST generate exactly those scenarios in the exact order listed, keeping each item's \`scenarioName\` exactly as registered — the generation tools match on it and reject renamed or substituted scenarios. If parameter grounding uncovers a distinct bug-catching scenario not already registered, generate it after all planned GENERATE items are complete and report it in \`newTestsCreated\` — this is an additional test driven by source-code analysis and does not count against the GENERATE budget.${hasRelatedRepos ? `\n - **Multi-repo exception:** this run has related repositories, so the per-repo GENERATE lists are NOT final — they are candidates re-selected by the cross-repo round-robin described in Task 1's "Cross-repo test generation". Register the pooled, type-distributed selection instead of any single repo's GENERATE list. (In single-repo runs, register the GENERATE list exactly as-is.)` : ""}
|
|
420
|
+
- **MANDATORY — use the plan returned by \`skyramp_register_test_plan\` as-is**: Before generating anything, call \`skyramp_register_test_plan\` (\`stateFile\` required) with your complete candidate list — every test you would generate OR recommend, including the Execution Plan's own pre-ranked GENERATE/ADDITIONAL items and any candidate you drafted yourself, with a \`discriminator\` claim \`{kind, changedCodeAnchor}\` for candidates probing changed logic. Its returned GENERATE list — not the Execution Plan's raw pre-ranked GENERATE section — governs ADD actions from this point on. You MUST generate exactly those scenarios in the exact order listed, keeping each item's \`scenarioName\` exactly as registered — the generation tools match on it and reject renamed or substituted scenarios. If parameter grounding uncovers a distinct bug-catching scenario not already registered, generate it after all planned GENERATE items are complete and report it in \`newTestsCreated\` — this is an additional test driven by source-code analysis and does not count against the GENERATE budget.${hasRelatedRepos ? `\n - **Multi-repo exception:** this run has related repositories, so the per-repo GENERATE lists are NOT final — they are candidates re-selected by the cross-repo round-robin described in Task 1's "Cross-repo test generation". Register the pooled, type-distributed selection instead of any single repo's GENERATE list — call \`skyramp_register_test_plan\` ONCE for the whole run, with candidates from EVERY repo pooled into one list, including at least one UI candidate when any repo changed frontend files (the tool rejects a pooled registration without one). (In single-repo runs, register the GENERATE list exactly as-is.)` : ""}
|
|
412
421
|
- **Do not fabricate tests outside the GENERATE list provided by \`skyramp_analyze_changes\`.** Changes that only modify, delete, or add fields to an EXISTING covered endpoint or component are maintenance: handle them in ${taskRef(TASK_ANALYZE_MAINTAIN)} by UPDATE/DELETE of the existing test, never by creating a new spec. If the GENERATE list is empty, create zero new tests and proceed to ${taskRef(TASK_SUBMIT)}.
|
|
413
422
|
- Scenario JSON files are always new files — always generate them for new methods. Every generated scenario JSON must have a corresponding new integration test generated from it via \`skyramp_integration_test_generation\`.
|
|
414
423
|
- Covered-resource handling (aligns with Execution Plan Step 0): When a GENERATE item targets a resource that already has an existing test file covering the same endpoint:
|
|
@@ -8,11 +8,12 @@ import { TestType, HttpMethod } from "../types/TestTypes.js";
|
|
|
8
8
|
import { DriftAction } from "../types/TestAnalysis.js";
|
|
9
9
|
import { TestExecutionStatus } from "../types/TestExecution.js";
|
|
10
10
|
import { IssueFoundCategory } from "../types/TestbotReport.js";
|
|
11
|
-
import { StateManager, runArtifactDir } from "../utils/AnalysisStateManager.js";
|
|
11
|
+
import { StateManager, runArtifactDir, getTestsRepoDir } from "../utils/AnalysisStateManager.js";
|
|
12
12
|
import { toolError, testFileMatches } from "../utils/utils.js";
|
|
13
13
|
import { matchesApprovedPlan } from "../utils/planMatchKeys.js";
|
|
14
14
|
import { isTestbotEnabled } from "../utils/featureFlags.js";
|
|
15
|
-
import { findUnbackedClaims, listChangedFiles } from "../utils/reportVerification.js";
|
|
15
|
+
import { findUnbackedClaims, listChangedFiles, listChangedFilesAcross } from "../utils/reportVerification.js";
|
|
16
|
+
import { getReportLanguage, isEnforcedReportLanguage, findLanguageViolations, findLanguageNearMisses, reportLanguageDisplayName, } from "../utils/reportLanguage.js";
|
|
16
17
|
import { rederiveReuseOutcome } from "./code-refactor/reuse-state.js";
|
|
17
18
|
// SKYR-3879 Path B: which testTypes the register-plan checkpoint gates. Mirrors
|
|
18
19
|
// the generation tools actually wired to planGuard (batch-scenario/integration,
|
|
@@ -543,6 +544,10 @@ export function registerSubmitReportTool(server) {
|
|
|
543
544
|
}, async (params) => {
|
|
544
545
|
const startTime = Date.now();
|
|
545
546
|
let errorResult;
|
|
547
|
+
// Residual-leak telemetry (SKYR-4185): filled by the language guardrail
|
|
548
|
+
// below when a report language is enforced, spread into the analytics
|
|
549
|
+
// event in the finally. Empty (no properties) for non-enforced runs.
|
|
550
|
+
const languageTelemetry = {};
|
|
546
551
|
// The report goes next to the state file, so in a Testbot run the state file has to
|
|
547
552
|
// be in the run directory — that is where the action reads the report back from.
|
|
548
553
|
// Two ways that breaks, both ending in a report nobody reads while this tool says
|
|
@@ -567,6 +572,70 @@ export function registerSubmitReportTool(server) {
|
|
|
567
572
|
`earlier run or one typed by hand.`);
|
|
568
573
|
}
|
|
569
574
|
}
|
|
575
|
+
// SKYR-4185: report-language guardrail. The language was captured when the
|
|
576
|
+
// testbot prompt was rendered (see reportLanguage.ts) — the instruction to
|
|
577
|
+
// write free-text fields in it is prose the agent sometimes ignores
|
|
578
|
+
// (letsramp/api-insight#215 shipped English testResults[].details in an
|
|
579
|
+
// otherwise-Japanese report). Reject rather than ship a half-translated
|
|
580
|
+
// report; the listed paths tell the agent exactly what to rewrite. Fields
|
|
581
|
+
// the agent does not author here (testMaintenance description from
|
|
582
|
+
// skyramp_actions verdicts, the server default commitMessage) are exempt —
|
|
583
|
+
// rejecting those would loop, since resubmitting cannot change them.
|
|
584
|
+
const reportLanguage = getReportLanguage();
|
|
585
|
+
if (reportLanguage && !isEnforcedReportLanguage(reportLanguage)) {
|
|
586
|
+
logger.warning(`Report language '${reportLanguage}' has no enforcement rules — skipping the language guardrail`);
|
|
587
|
+
}
|
|
588
|
+
if (isEnforcedReportLanguage(reportLanguage)) {
|
|
589
|
+
const textFields = [
|
|
590
|
+
{ path: "businessCaseAnalysis", text: params.businessCaseAnalysis },
|
|
591
|
+
];
|
|
592
|
+
params.newTestsCreated.forEach((t, i) => {
|
|
593
|
+
textFields.push({ path: `newTestsCreated[${i}].description`, text: t.description });
|
|
594
|
+
textFields.push({ path: `newTestsCreated[${i}].reasoning`, text: t.reasoning });
|
|
595
|
+
});
|
|
596
|
+
(params.additionalRecommendations ?? []).forEach((r, i) => {
|
|
597
|
+
textFields.push({ path: `additionalRecommendations[${i}].description`, text: r.description });
|
|
598
|
+
textFields.push({ path: `additionalRecommendations[${i}].reasoning`, text: r.reasoning });
|
|
599
|
+
r.steps.forEach((s, j) => {
|
|
600
|
+
textFields.push({ path: `additionalRecommendations[${i}].steps[${j}].description`, text: s.description });
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
params.testResults.forEach((t, i) => {
|
|
604
|
+
textFields.push({ path: `testResults[${i}].details`, text: t.details });
|
|
605
|
+
});
|
|
606
|
+
params.issuesFound.forEach((f, i) => {
|
|
607
|
+
textFields.push({ path: `issuesFound[${i}].description`, text: f.description });
|
|
608
|
+
});
|
|
609
|
+
(params.nextSteps ?? []).forEach((s, i) => {
|
|
610
|
+
textFields.push({ path: `nextSteps[${i}]`, text: s });
|
|
611
|
+
});
|
|
612
|
+
(params.testMaintenanceDetails ?? []).forEach((d, i) => {
|
|
613
|
+
textFields.push({ path: `testMaintenanceDetails[${i}].beforeDetails`, text: d.beforeDetails });
|
|
614
|
+
textFields.push({ path: `testMaintenanceDetails[${i}].afterDetails`, text: d.afterDetails });
|
|
615
|
+
});
|
|
616
|
+
if (params.commitMessage && params.commitMessage !== DEFAULT_COMMIT_MESSAGE) {
|
|
617
|
+
textFields.push({ path: "commitMessage", text: params.commitMessage });
|
|
618
|
+
}
|
|
619
|
+
const languageViolations = findLanguageViolations(textFields, reportLanguage);
|
|
620
|
+
if (languageViolations.length > 0) {
|
|
621
|
+
const languageName = reportLanguageDisplayName(reportLanguage);
|
|
622
|
+
errorResult = toolError(`This run's report language is ${languageName}, but ${languageViolations.length} free-text field(s) are written in English:\n` +
|
|
623
|
+
languageViolations.map((v) => ` - ${v}`).join("\n") +
|
|
624
|
+
`\nRewrite ONLY the listed fields in ${languageName} and resubmit the report. ` +
|
|
625
|
+
`Keep code identifiers, endpoint paths, file names, test IDs, enum values ` +
|
|
626
|
+
`(Pass/Fail/Skipped, severity values, testType), and anything inside backticks untranslated.`);
|
|
627
|
+
return errorResult;
|
|
628
|
+
}
|
|
629
|
+
// The sub-threshold band (a single stray English word) is accepted by
|
|
630
|
+
// design; count what ships so the leniency's real-world leak rate is
|
|
631
|
+
// visible in Amplitude before anyone tightens the threshold.
|
|
632
|
+
const nearMisses = findLanguageNearMisses(textFields, reportLanguage);
|
|
633
|
+
languageTelemetry.reportLanguage = reportLanguage;
|
|
634
|
+
languageTelemetry.languageNearMissCount = String(nearMisses.length);
|
|
635
|
+
if (nearMisses.length > 0) {
|
|
636
|
+
logger.info(`Report language ${reportLanguage}: accepting ${nearMisses.length} field(s) with a single stray English word`, { fields: nearMisses });
|
|
637
|
+
}
|
|
638
|
+
}
|
|
570
639
|
const dedupedNewTests = deduplicateById([...params.newTestsCreated]);
|
|
571
640
|
const dedupedRecommendations = deduplicateById([...(params.additionalRecommendations ?? [])]);
|
|
572
641
|
const stateManager = StateManager.fromStatePath(params.stateFile);
|
|
@@ -710,7 +779,23 @@ export function registerSubmitReportTool(server) {
|
|
|
710
779
|
const repoRoot = fullState?.metadata?.repositoryPath;
|
|
711
780
|
if (repoRoot && repoRoot !== "unknown") {
|
|
712
781
|
try {
|
|
713
|
-
|
|
782
|
+
// A run writes tests into up to three kinds of trees: the primary
|
|
783
|
+
// checkout, the tests-repo checkout (testRepoPath), and related
|
|
784
|
+
// repos. Scanning only the primary rejected a real, executed UI
|
|
785
|
+
// test in the tests-repo checkout as "unbacked", forcing the agent
|
|
786
|
+
// to demote it to additionalRecommendations (SKYR-4204 reopen,
|
|
787
|
+
// run 32510028428).
|
|
788
|
+
// The primary scan keeps its throwing form: an unscannable primary
|
|
789
|
+
// degrades the whole check to a pass (SKYR-3883 semantics) via the
|
|
790
|
+
// catch below. The extra trees are best-effort — a missing or
|
|
791
|
+
// non-git one contributes nothing.
|
|
792
|
+
const changedFiles = [
|
|
793
|
+
...(await listChangedFiles(repoRoot)),
|
|
794
|
+
...(await listChangedFilesAcross([
|
|
795
|
+
getTestsRepoDir(),
|
|
796
|
+
...Object.values(fullState?.relatedRepos ?? {}).map((section) => section.repositoryPath),
|
|
797
|
+
])),
|
|
798
|
+
];
|
|
714
799
|
const unbacked = findUnbackedClaims({
|
|
715
800
|
repoRoot,
|
|
716
801
|
changedFiles,
|
|
@@ -815,6 +900,7 @@ export function registerSubmitReportTool(server) {
|
|
|
815
900
|
testResultCount: String(params.testResults.length),
|
|
816
901
|
payloadBytes: String(reportJson.length),
|
|
817
902
|
...computeReportMetrics({ ...params, testMaintenance }),
|
|
903
|
+
...languageTelemetry,
|
|
818
904
|
}).catch(() => { });
|
|
819
905
|
}
|
|
820
906
|
});
|
|
@@ -10,7 +10,7 @@ import { dualChannelResult } from "../../utils/utils.js";
|
|
|
10
10
|
import { parseWorkspaceAuthType, getDefaultAuthHeader, WorkspaceAuthType, readWorkspaceConfigRaw } from "../../utils/workspaceAuth.js";
|
|
11
11
|
import { AnalyticsService } from "../../services/AnalyticsService.js";
|
|
12
12
|
import { makeProgressReporter } from "../../utils/progress.js";
|
|
13
|
-
import { StateManager, registerSession, storeSessionData,
|
|
13
|
+
import { StateManager, registerSession, storeSessionData, rememberTestsRepoDir, getActiveRunStatePath, setActiveRunStatePath, } from "../../utils/AnalysisStateManager.js";
|
|
14
14
|
import { buildRecommendationPrompt, computeScoredCandidates } from "../../prompts/test-recommendation/test-recommendation-prompt.js";
|
|
15
15
|
import { hasFlutterSdkDep, isFrontendFile, isTestFile } from "../../prompts/test-recommendation/scopeAssessment.js";
|
|
16
16
|
import { buildExternalCoverageSet } from "../../prompts/test-recommendation/recommendationShared.js";
|
|
@@ -736,7 +736,9 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
736
736
|
let discoveredRelevantExternalPaths = [];
|
|
737
737
|
try {
|
|
738
738
|
const testDiscoveryService = new TestDiscoveryService();
|
|
739
|
-
|
|
739
|
+
// Run-scoped: only the primary call carries testsRepoDir, so never
|
|
740
|
+
// let a related repo's call clear it (SKYR-4204).
|
|
741
|
+
rememberTestsRepoDir(params.testsRepoDir);
|
|
740
742
|
const discoveryResult = await testDiscoveryService.discoverTests(testDir ?? params.repositoryPath, { changedResources, changedSymbols, preciseResources: (changedSymbols?.length ?? 0) > 0, changedFrontendFiles, changedSelectors });
|
|
741
743
|
existingTests = discoveryResult.tests.map((test) => ({
|
|
742
744
|
testFile: test.testFile,
|
|
@@ -10,6 +10,8 @@ import { SCENARIO_CATEGORIES, CATEGORY_PRIORITY, Novelty, PriorityTier } from ".
|
|
|
10
10
|
import { HttpMethod, TestType } from "../../types/TestTypes.js";
|
|
11
11
|
import { CandidateSource, computeCandidateId, scenarioMergeKey, DiscriminatorKind } from "../../types/Recommendation.js";
|
|
12
12
|
import { selectPlan } from "../../recommendation/planRanker.js";
|
|
13
|
+
import { reservedUISlots } from "../../recommendation/budgeters/shared.js";
|
|
14
|
+
import { inferScenarioType } from "../../recommendation/diversity.js";
|
|
13
15
|
import { validateDiscriminator } from "../../recommendation/discriminators.js";
|
|
14
16
|
import { isAttackSurfaceSecurityBoundary } from "../../prompts/test-recommendation/recommendationShared.js";
|
|
15
17
|
import { MAX_RECOMMENDATIONS, MAX_TESTS_TO_GENERATE } from "../../prompts/test-recommendation/recommendationSections.js";
|
|
@@ -84,10 +86,12 @@ const registerTestPlanSchema = {
|
|
|
84
86
|
.string()
|
|
85
87
|
.refine((p) => path.isAbsolute(p), { message: "stateFile must be an absolute path" })
|
|
86
88
|
.describe("Path to state file from skyramp_analyze_changes"),
|
|
87
|
-
repository
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
// No `repository` argument, deliberately (SKYR-4204 reopen). The approved
|
|
90
|
+
// plan is run-wide — one pooled registration covers every repository in the
|
|
91
|
+
// run — so unlike the per-repo tools (analyze_test_health, actions,
|
|
92
|
+
// execute_test) there is no section to select: the tool reads the root and
|
|
93
|
+
// derives run-wide facts (diff union, budget flags, scenario lookup) itself.
|
|
94
|
+
// A legacy caller still passing `repository` has it stripped harmlessly.
|
|
91
95
|
candidates: z
|
|
92
96
|
.array(registerCandidateSchema)
|
|
93
97
|
.default([])
|
|
@@ -245,12 +249,17 @@ function resolveBudgetContext(stateData, fullState) {
|
|
|
245
249
|
const hasFrontendChanges = sections.some((s) => s?.planBudgetContext?.hasFrontendChanges ?? false);
|
|
246
250
|
const hasApiChanges = sections.some(sectionHasApiChanges);
|
|
247
251
|
const rootPbc = fullState?.planBudgetContext;
|
|
252
|
+
// SKYR-4211: external-test coverage is also a per-section fact — a backend
|
|
253
|
+
// candidate already covered by an external test recorded in a RELATED repo's
|
|
254
|
+
// section must dedup the same as one covered in the primary. Union the keys
|
|
255
|
+
// across all sections, like the flags above.
|
|
256
|
+
const externalCoverage = new Set(sections.flatMap((s) => s?.planBudgetContext?.externalCoverageKeys ?? []));
|
|
248
257
|
return {
|
|
249
258
|
maxGenerate: rootPbc?.maxGenerate ?? base.maxGenerate,
|
|
250
259
|
maxTotal: rootPbc?.maxTotal ?? base.maxTotal,
|
|
251
260
|
isUIOnlyPR: hasFrontendChanges && !hasApiChanges,
|
|
252
261
|
hasFrontendChanges,
|
|
253
|
-
externalCoverage
|
|
262
|
+
externalCoverage,
|
|
254
263
|
};
|
|
255
264
|
}
|
|
256
265
|
function describeGenerationCall(item) {
|
|
@@ -359,12 +368,16 @@ export function registerRegisterTestPlanTool(server) {
|
|
|
359
368
|
}, async (params) => {
|
|
360
369
|
let errorResult;
|
|
361
370
|
try {
|
|
362
|
-
logger.info(
|
|
371
|
+
logger.info("Registering run-wide test plan", {
|
|
363
372
|
candidateCount: params.candidates?.length ?? 0,
|
|
364
373
|
});
|
|
365
374
|
const stateManager = StateManager.fromStatePath(params.stateFile);
|
|
366
|
-
|
|
367
|
-
|
|
375
|
+
// The plan is run-wide, so register always reads the ROOT (primary)
|
|
376
|
+
// section — there is no section selector (routing by one is what let a
|
|
377
|
+
// related-repo registration run with that section's context —
|
|
378
|
+
// SKYR-4204 reopen). Run-wide facts (diff union, budget flags,
|
|
379
|
+
// scenario lookup) come from fullState below.
|
|
380
|
+
const stateData = await stateManager.readData();
|
|
368
381
|
if (!stateData) {
|
|
369
382
|
errorResult = toolError(`State file is empty or invalid: ${params.stateFile}. Call skyramp_analyze_changes first to generate a valid state file.`);
|
|
370
383
|
return errorResult;
|
|
@@ -388,9 +401,19 @@ export function registerRegisterTestPlanTool(server) {
|
|
|
388
401
|
];
|
|
389
402
|
const diffText = diffSections.map((s) => s.diff).filter(Boolean).join("\n");
|
|
390
403
|
const reposWithoutDiff = diffSections.filter((s) => !s.diff).map((s) => s.repo);
|
|
391
|
-
|
|
404
|
+
// Scenario lookup for server-candidate recovery spans ALL sections —
|
|
405
|
+
// the primary's drafted scenarios must resolve even when `repository`
|
|
406
|
+
// targets a related repo (SKYR-4204 reopen: the same single-section
|
|
407
|
+
// shape as the diff union above).
|
|
408
|
+
const allScenarios = [
|
|
409
|
+
...(fullState?.repositoryAnalysis?.scenarios ?? []),
|
|
410
|
+
...Object.values(fullState?.relatedRepos ?? {}).flatMap((section) => section.data?.repositoryAnalysis?.scenarios ?? []),
|
|
411
|
+
];
|
|
392
412
|
const agentCandidates = buildAgentCandidates(params.candidates ?? []);
|
|
393
|
-
|
|
413
|
+
// The approved plan is run-wide and persists at the ROOT (see the
|
|
414
|
+
// persist step below), so prior-plan recovery reads the root first;
|
|
415
|
+
// the section fallback covers state written by older builds.
|
|
416
|
+
const serverCandidates = recoverServerCandidates(fullState?.approvedPlan ?? stateData.approvedPlan, allScenarios);
|
|
394
417
|
// Merge by scenario-name identity, NOT the full content-hashed candidateId:
|
|
395
418
|
// the server (analyze_changes) and the agent frequently draft their own
|
|
396
419
|
// independent steps[] for "the same" scenario (identical scenarioName),
|
|
@@ -433,7 +456,44 @@ export function registerRegisterTestPlanTool(server) {
|
|
|
433
456
|
"Submit your complete candidate list (every test you would generate or recommend) in `candidates` — an empty plan would block all generation.");
|
|
434
457
|
return errorResult;
|
|
435
458
|
}
|
|
459
|
+
// Review (mcp#774): enforce ONE run-wide plan at the tool boundary. A
|
|
460
|
+
// re-registration whose pool shares no scenario with the active plan
|
|
461
|
+
// is the per-repo-partial signature (the demoshop rig's second,
|
|
462
|
+
// backend-only call) — accepting it would silently REPLACE the run's
|
|
463
|
+
// plan with a subset and drop every earlier agent candidate. Genuine
|
|
464
|
+
// full-list updates overlap the active plan and replace it as before.
|
|
465
|
+
// The overlap test spans GENERATE ∪ ADDITIONAL: with maxGenerate=0 the
|
|
466
|
+
// active plan's GENERATE is empty while ADDITIONAL carries every
|
|
467
|
+
// candidate, and a disjoint partial could otherwise still replace it
|
|
468
|
+
// silently (Copilot review on #774).
|
|
469
|
+
const priorItems = [
|
|
470
|
+
...(fullState?.approvedPlan?.generate ?? []),
|
|
471
|
+
...(fullState?.approvedPlan?.additional ?? []),
|
|
472
|
+
];
|
|
473
|
+
if (priorItems.length > 0) {
|
|
474
|
+
const poolKeys = new Set(allCandidates.map(mergeKey));
|
|
475
|
+
const overlaps = priorItems.some((item) => poolKeys.has(scenarioMergeKey(item.scenarioName) || item.candidateId));
|
|
476
|
+
if (!overlaps) {
|
|
477
|
+
errorResult = toolError(`A run-wide plan is already active (${fullState.approvedPlan.planId}: ` +
|
|
478
|
+
`${priorItems.map((i) => i.scenarioName).join(", ")}) and this submission shares none of its ` +
|
|
479
|
+
"scenarios. skyramp_register_test_plan REPLACES the whole run's plan — resubmit ONE pooled candidate " +
|
|
480
|
+
"list for the entire run (your previous candidates plus these changes), not a per-repo subset.");
|
|
481
|
+
return errorResult;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
436
484
|
const budgetContext = resolveBudgetContext(stateData, fullState);
|
|
485
|
+
// SKYR-4204 reopen: a reserved UI slot is only fillable from the
|
|
486
|
+
// candidate pool. A pool with no UI candidate on a run with frontend
|
|
487
|
+
// changes would silently plan zero UI tests (runs 32419025266,
|
|
488
|
+
// 32431042133, 32431106864) — reject so the agent self-corrects in one
|
|
489
|
+
// step, exactly like the empty-candidates rejection above.
|
|
490
|
+
if (reservedUISlots(budgetContext) > 0 &&
|
|
491
|
+
!allCandidates.some((c) => inferScenarioType(c.scenario) === TestType.UI)) {
|
|
492
|
+
errorResult = toolError("This run has frontend changes, so the plan reserves a UI slot — but no submitted or recovered " +
|
|
493
|
+
"candidate has testType 'ui'. Resubmit your full candidate list including at least one UI candidate " +
|
|
494
|
+
"for the changed frontend (see uiContext.changedFrontendFiles).");
|
|
495
|
+
return errorResult;
|
|
496
|
+
}
|
|
437
497
|
const result = selectPlan(allCandidates, { ...budgetContext, demotions, diffText });
|
|
438
498
|
const approvedPlan = {
|
|
439
499
|
planId: crypto.randomUUID(),
|
|
@@ -442,8 +502,16 @@ export function registerRegisterTestPlanTool(server) {
|
|
|
442
502
|
additional: result.additional.map(buildApprovedPlanItem),
|
|
443
503
|
demotions: result.demotions,
|
|
444
504
|
};
|
|
505
|
+
// The approved plan is the ONE run-wide authority (the prompt mandates
|
|
506
|
+
// a single pooled registration on multi-repo runs), so it persists at
|
|
507
|
+
// the state-file ROOT regardless of `repository`. Per-section plans
|
|
508
|
+
// blinded every root-reading consumer — planGuard let generation run
|
|
509
|
+
// unguarded, submitReportTool's reconciliation was inert, and the eval
|
|
510
|
+
// extractor saw "no plan" (SKYR-4204 reopen). writeData preserves the
|
|
511
|
+
// relatedRepos sections and the root metadata.
|
|
445
512
|
try {
|
|
446
|
-
|
|
513
|
+
const { metadata: _meta, relatedRepos: _sections, ...rootData } = fullState ?? {};
|
|
514
|
+
await stateManager.writeData({ ...rootData, approvedPlan }, { step: TOOL_NAME });
|
|
447
515
|
}
|
|
448
516
|
catch (error) {
|
|
449
517
|
errorResult = toolError(`Failed to persist approved test plan to state file: ${error.message}. Retry this call.`);
|
|
@@ -14,7 +14,9 @@ export declare enum IssueFoundCategory {
|
|
|
14
14
|
/**
|
|
15
15
|
* Shape of the JSON report written by skyramp_submit_report and read by testbot
|
|
16
16
|
* for rendering as Markdown. All fields mirror the corresponding Zod schemas in
|
|
17
|
-
* submitReportTool.ts — keep the two in sync.
|
|
17
|
+
* submitReportTool.ts — keep the two in sync. This type describes what a READER
|
|
18
|
+
* may encounter across MCP versions; the Zod schemas define what the current
|
|
19
|
+
* producer must submit, so a field can be required on submit but optional here.
|
|
18
20
|
*/
|
|
19
21
|
export interface TestbotReport {
|
|
20
22
|
businessCaseAnalysis: string;
|
|
@@ -25,6 +27,9 @@ export interface TestbotReport {
|
|
|
25
27
|
fileName: string;
|
|
26
28
|
reasoning: string;
|
|
27
29
|
description: string;
|
|
30
|
+
/** `owner/repo` attribution in multi-repo runs (SKYR-3786). Absent = the
|
|
31
|
+
* primary repo, or a single-repo run. */
|
|
32
|
+
repository?: string;
|
|
28
33
|
scenarioFile?: string;
|
|
29
34
|
traceFile?: string;
|
|
30
35
|
frontendTrace?: string;
|
|
@@ -53,6 +58,8 @@ export interface TestbotReport {
|
|
|
53
58
|
status: "Pass" | "Fail" | "Skipped";
|
|
54
59
|
details: string;
|
|
55
60
|
videoPath?: string;
|
|
61
|
+
/** See newTestsCreated[].repository. */
|
|
62
|
+
repository?: string;
|
|
56
63
|
}[];
|
|
57
64
|
additionalRecommendations?: {
|
|
58
65
|
testId: string;
|
|
@@ -74,11 +81,17 @@ export interface TestbotReport {
|
|
|
74
81
|
openApiSpec?: string;
|
|
75
82
|
backendTrace?: string;
|
|
76
83
|
frontendTrace?: string;
|
|
84
|
+
/** See newTestsCreated[].repository. */
|
|
85
|
+
repository?: string;
|
|
77
86
|
}[];
|
|
78
87
|
issuesFound: {
|
|
79
88
|
description: string;
|
|
80
89
|
severity?: "critical" | "high" | "medium" | "low";
|
|
81
|
-
|
|
90
|
+
/** Required by the submit_report schema since 0.3.4; absent in reports
|
|
91
|
+
* written by older MCP versions. Readers treat absence as Bug. */
|
|
92
|
+
category?: IssueFoundCategory;
|
|
93
|
+
/** See newTestsCreated[].repository. */
|
|
94
|
+
repository?: string;
|
|
82
95
|
}[];
|
|
83
96
|
nextSteps: string[];
|
|
84
97
|
commitMessage: string;
|
|
@@ -10,6 +10,18 @@ import type { ExternalTestRunRecord } from "../types/ExternalTestExecution.js";
|
|
|
10
10
|
import type { VideoRecord } from "../types/TestExecution.js";
|
|
11
11
|
export type { CandidateUiPage } from "./uiPageEnumerator.js";
|
|
12
12
|
export declare function setTestsRepoDir(dir: string | undefined): void;
|
|
13
|
+
/**
|
|
14
|
+
* Record the run's tests repo without ever clearing a known value.
|
|
15
|
+
*
|
|
16
|
+
* The tests repo is a RUN-scoped fact, but analyze_changes is called once per
|
|
17
|
+
* repository and only the PRIMARY call carries `testsRepoDir` — a related
|
|
18
|
+
* repo's call omits it. An unconditional set therefore wiped the primary's
|
|
19
|
+
* value on the second call of every multi-repo run, and by report time the
|
|
20
|
+
* tests repo was unknown: a UI test generated there looked unbacked by any
|
|
21
|
+
* working tree and the agent demoted it out of newTestsCreated (SKYR-4204,
|
|
22
|
+
* run 32536569467). Callers that mean to clear it use setTestsRepoDir.
|
|
23
|
+
*/
|
|
24
|
+
export declare function rememberTestsRepoDir(dir: string | undefined): void;
|
|
13
25
|
export declare function getTestsRepoDir(): string | undefined;
|
|
14
26
|
/** Filename of the run-scoped analysis state file under `runArtifactDir()`.
|
|
15
27
|
* Single-sourced so the constructor that WRITES there and `resolveRunStatePath`
|
|
@@ -19,6 +19,21 @@ let _testsRepoDir;
|
|
|
19
19
|
export function setTestsRepoDir(dir) {
|
|
20
20
|
_testsRepoDir = dir;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Record the run's tests repo without ever clearing a known value.
|
|
24
|
+
*
|
|
25
|
+
* The tests repo is a RUN-scoped fact, but analyze_changes is called once per
|
|
26
|
+
* repository and only the PRIMARY call carries `testsRepoDir` — a related
|
|
27
|
+
* repo's call omits it. An unconditional set therefore wiped the primary's
|
|
28
|
+
* value on the second call of every multi-repo run, and by report time the
|
|
29
|
+
* tests repo was unknown: a UI test generated there looked unbacked by any
|
|
30
|
+
* working tree and the agent demoted it out of newTestsCreated (SKYR-4204,
|
|
31
|
+
* run 32536569467). Callers that mean to clear it use setTestsRepoDir.
|
|
32
|
+
*/
|
|
33
|
+
export function rememberTestsRepoDir(dir) {
|
|
34
|
+
if (dir)
|
|
35
|
+
_testsRepoDir = dir;
|
|
36
|
+
}
|
|
22
37
|
export function getTestsRepoDir() {
|
|
23
38
|
return _testsRepoDir;
|
|
24
39
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report-language enforcement for `skyramp_submit_report` (SKYR-4185).
|
|
3
|
+
*
|
|
4
|
+
* The report language (SKYR-4023) reaches the MCP server only as a testbot
|
|
5
|
+
* prompt/resource arg, and the instruction to write free-text report fields in
|
|
6
|
+
* that language is prose the agent sometimes ignores — the customer-visible
|
|
7
|
+
* failure this module exists to close. The prompt render captures the language
|
|
8
|
+
* here (same server process serves the prompt and later handles the report
|
|
9
|
+
* tool), and the report tool rejects submissions whose free-text fields leaked
|
|
10
|
+
* back into English.
|
|
11
|
+
*
|
|
12
|
+
* Capture is fail-open by design: if the testbot prompt was never served
|
|
13
|
+
* (local/IDE flows, or a run broken enough that the agent has no instructions),
|
|
14
|
+
* the language stays unset and no report is ever falsely rejected.
|
|
15
|
+
*/
|
|
16
|
+
/** Capture the report language at prompt-serve time. Last render wins:
|
|
17
|
+
* `undefined` clears any earlier capture, so a long-lived server (IDE use)
|
|
18
|
+
* that renders a ja prompt and later an English/argless one disarms the
|
|
19
|
+
* guardrail rather than falsely rejecting a legitimately-English report. */
|
|
20
|
+
export declare function setReportLanguage(language: string | undefined): void;
|
|
21
|
+
export declare function getReportLanguage(): string | undefined;
|
|
22
|
+
/** Test isolation only — module state persists across tests in one process. */
|
|
23
|
+
export declare function resetReportLanguage(): void;
|
|
24
|
+
export declare function isEnforcedReportLanguage(language: string | undefined): language is string;
|
|
25
|
+
/** English display name for a language code, falling back to the raw code when
|
|
26
|
+
* Intl rejects it as a structurally invalid tag (e.g. "ja_JP") — the code is
|
|
27
|
+
* ultimately caller-supplied, and a name lookup must never throw. */
|
|
28
|
+
export declare function reportLanguageDisplayName(language: string): string;
|
|
29
|
+
export interface ReportTextField {
|
|
30
|
+
/** Where the text came from, in the tool's input terms (e.g. "testResults[2].details"). */
|
|
31
|
+
path: string;
|
|
32
|
+
text: string;
|
|
33
|
+
}
|
|
34
|
+
/** Field paths whose text leaked English under an enforced report language.
|
|
35
|
+
* Empty for non-enforced languages — unknown languages are skipped, never guessed. */
|
|
36
|
+
export declare function findLanguageViolations(fields: ReportTextField[], language: string): string[];
|
|
37
|
+
/** Field paths in the deliberately-lenient band: some English prose, but under
|
|
38
|
+
* the rejection threshold (i.e. exactly one stray word at the current
|
|
39
|
+
* MIN_PROSE_WORDS of 2). These SHIP — the guardrail accepts them so a lone
|
|
40
|
+
* technical word ("flaky", "timeout") never blocks a report. Counted into the
|
|
41
|
+
* submit-report analytics event so the residual leak rate is measurable
|
|
42
|
+
* before anyone tightens the threshold. */
|
|
43
|
+
export declare function findLanguageNearMisses(fields: ReportTextField[], language: string): string[];
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report-language enforcement for `skyramp_submit_report` (SKYR-4185).
|
|
3
|
+
*
|
|
4
|
+
* The report language (SKYR-4023) reaches the MCP server only as a testbot
|
|
5
|
+
* prompt/resource arg, and the instruction to write free-text report fields in
|
|
6
|
+
* that language is prose the agent sometimes ignores — the customer-visible
|
|
7
|
+
* failure this module exists to close. The prompt render captures the language
|
|
8
|
+
* here (same server process serves the prompt and later handles the report
|
|
9
|
+
* tool), and the report tool rejects submissions whose free-text fields leaked
|
|
10
|
+
* back into English.
|
|
11
|
+
*
|
|
12
|
+
* Capture is fail-open by design: if the testbot prompt was never served
|
|
13
|
+
* (local/IDE flows, or a run broken enough that the agent has no instructions),
|
|
14
|
+
* the language stays unset and no report is ever falsely rejected.
|
|
15
|
+
*/
|
|
16
|
+
let sessionReportLanguage;
|
|
17
|
+
/** Capture the report language at prompt-serve time. Last render wins:
|
|
18
|
+
* `undefined` clears any earlier capture, so a long-lived server (IDE use)
|
|
19
|
+
* that renders a ja prompt and later an English/argless one disarms the
|
|
20
|
+
* guardrail rather than falsely rejecting a legitimately-English report. */
|
|
21
|
+
export function setReportLanguage(language) {
|
|
22
|
+
sessionReportLanguage = language;
|
|
23
|
+
}
|
|
24
|
+
export function getReportLanguage() {
|
|
25
|
+
return sessionReportLanguage;
|
|
26
|
+
}
|
|
27
|
+
/** Test isolation only — module state persists across tests in one process. */
|
|
28
|
+
export function resetReportLanguage() {
|
|
29
|
+
sessionReportLanguage = undefined;
|
|
30
|
+
}
|
|
31
|
+
// Languages the guardrail can enforce: detection is script-presence-based, so a
|
|
32
|
+
// language is enforceable only when its expected script is disjoint from Latin.
|
|
33
|
+
// English ('en') is deliberately absent — it is the default, not enforced.
|
|
34
|
+
// A Map, not a plain object: the key is caller-supplied, and object indexing
|
|
35
|
+
// would resolve prototype keys ("toString") to inherited members whose later
|
|
36
|
+
// .test() call crashes the tool.
|
|
37
|
+
const TARGET_SCRIPTS = new Map([
|
|
38
|
+
// Hiragana, Katakana (incl. halfwidth), CJK ideographs (incl. ext A / compat).
|
|
39
|
+
["ja", /[-ヿ㐀-䶿一-鿿豈-ヲ-゚]/],
|
|
40
|
+
]);
|
|
41
|
+
export function isEnforcedReportLanguage(language) {
|
|
42
|
+
return language !== undefined && TARGET_SCRIPTS.has(language);
|
|
43
|
+
}
|
|
44
|
+
/** English display name for a language code, falling back to the raw code when
|
|
45
|
+
* Intl rejects it as a structurally invalid tag (e.g. "ja_JP") — the code is
|
|
46
|
+
* ultimately caller-supplied, and a name lookup must never throw. */
|
|
47
|
+
export function reportLanguageDisplayName(language) {
|
|
48
|
+
try {
|
|
49
|
+
return new Intl.DisplayNames(["en"], { type: "language" }).of(language) ?? language;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return language;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Tokens that are legitimate untranslated content in any report language — the
|
|
56
|
+
// prompt's own "do NOT translate" list: enum values, HTTP verbs, test types.
|
|
57
|
+
const UNTRANSLATED_WORDS = new Set([
|
|
58
|
+
// Exact enum values only. NOT "passed"/"failed" — those are ordinary English
|
|
59
|
+
// prose and are exactly what leaks in pytest summaries ("1 passed in 0.88s",
|
|
60
|
+
// "failed: count was 13"; see letsramp/api-insight#215).
|
|
61
|
+
"pass", "fail", "skipped", "error", "unknown",
|
|
62
|
+
"critical", "high", "medium", "low",
|
|
63
|
+
"get", "post", "put", "patch", "delete", "head", "options",
|
|
64
|
+
"ui", "e2e", "api", "contract", "integration", "smoke", "fuzz", "load", "mock",
|
|
65
|
+
"http", "https", "json", "yaml", "ok",
|
|
66
|
+
]);
|
|
67
|
+
/** A field must contain at least this many plain English prose words (after the
|
|
68
|
+
* stripping below) to be flagged — so identifier-and-timing-only details like
|
|
69
|
+
* "10.8s, products_contract_test.py" stay legal, and one stray word ("flaky")
|
|
70
|
+
* never rejects a report. Two is deliberate: the shortest real leak observed
|
|
71
|
+
* ("1 passed in 0.88s", api-insight#215) has exactly two prose words. */
|
|
72
|
+
const MIN_PROSE_WORDS = 2;
|
|
73
|
+
/** Count the plain English prose words in `text`, or return 0 when the target
|
|
74
|
+
* language's script is present (the field is compliant regardless of any
|
|
75
|
+
* English around it). Backticked spans, file names/paths, identifiers
|
|
76
|
+
* (snake_case/camelCase/ALL-CAPS), digit-bearing tokens, and the prompt's
|
|
77
|
+
* untranslated vocabulary are all excluded from the count. */
|
|
78
|
+
function countProseWords(text, script) {
|
|
79
|
+
if (script.test(text))
|
|
80
|
+
return 0;
|
|
81
|
+
const withoutCode = text.replace(/`[^`]*`/g, " ");
|
|
82
|
+
let proseWords = 0;
|
|
83
|
+
for (const raw of withoutCode.split(/\s+/)) {
|
|
84
|
+
const token = raw.replace(/^[^A-Za-z0-9]+/, "").replace(/[^A-Za-z0-9]+$/, "");
|
|
85
|
+
// Pure alphabetic words only: anything with digits or interior punctuation
|
|
86
|
+
// (paths, file names, snake_case, versions, "10.8s") is not prose.
|
|
87
|
+
if (token.length < 2 || !/^[A-Za-z]+$/.test(token))
|
|
88
|
+
continue;
|
|
89
|
+
if (/[a-z][A-Z]/.test(token))
|
|
90
|
+
continue; // camelCase identifier
|
|
91
|
+
if (token === token.toUpperCase())
|
|
92
|
+
continue; // acronym / enum shout (GET, FAILED)
|
|
93
|
+
if (UNTRANSLATED_WORDS.has(token.toLowerCase()))
|
|
94
|
+
continue;
|
|
95
|
+
proseWords++;
|
|
96
|
+
}
|
|
97
|
+
return proseWords;
|
|
98
|
+
}
|
|
99
|
+
/** Field paths whose text leaked English under an enforced report language.
|
|
100
|
+
* Empty for non-enforced languages — unknown languages are skipped, never guessed. */
|
|
101
|
+
export function findLanguageViolations(fields, language) {
|
|
102
|
+
const script = TARGET_SCRIPTS.get(language);
|
|
103
|
+
if (!script)
|
|
104
|
+
return [];
|
|
105
|
+
return fields
|
|
106
|
+
.filter((f) => countProseWords(f.text, script) >= MIN_PROSE_WORDS)
|
|
107
|
+
.map((f) => f.path);
|
|
108
|
+
}
|
|
109
|
+
/** Field paths in the deliberately-lenient band: some English prose, but under
|
|
110
|
+
* the rejection threshold (i.e. exactly one stray word at the current
|
|
111
|
+
* MIN_PROSE_WORDS of 2). These SHIP — the guardrail accepts them so a lone
|
|
112
|
+
* technical word ("flaky", "timeout") never blocks a report. Counted into the
|
|
113
|
+
* submit-report analytics event so the residual leak rate is measurable
|
|
114
|
+
* before anyone tightens the threshold. */
|
|
115
|
+
export function findLanguageNearMisses(fields, language) {
|
|
116
|
+
const script = TARGET_SCRIPTS.get(language);
|
|
117
|
+
if (!script)
|
|
118
|
+
return [];
|
|
119
|
+
return fields
|
|
120
|
+
.filter((f) => {
|
|
121
|
+
const words = countProseWords(f.text, script);
|
|
122
|
+
return words > 0 && words < MIN_PROSE_WORDS;
|
|
123
|
+
})
|
|
124
|
+
.map((f) => f.path);
|
|
125
|
+
}
|
|
@@ -14,6 +14,16 @@ import { DriftAction } from "../types/TestAnalysis.js";
|
|
|
14
14
|
* `--untracked-files=all` lists each untracked file individually (otherwise git
|
|
15
15
|
* collapses a brand-new directory to "dir/", which would defeat path matching).
|
|
16
16
|
*/
|
|
17
|
+
/**
|
|
18
|
+
* Union of listChangedFiles over every tree a run legitimately writes to —
|
|
19
|
+
* the primary checkout, the tests-repo checkout (testRepoPath), and related
|
|
20
|
+
* repos (SKYR-4204 reopen: the backing check scanned only the primary, so a
|
|
21
|
+
* UI test generated into the tests-repo checkout was rejected as unbacked and
|
|
22
|
+
* demoted out of the report — run 32510028428). Undefined and duplicate roots
|
|
23
|
+
* are skipped; a root where git fails (missing dir, not a repo) contributes
|
|
24
|
+
* nothing rather than failing the whole check.
|
|
25
|
+
*/
|
|
26
|
+
export declare function listChangedFilesAcross(roots: Array<string | undefined>): Promise<string[]>;
|
|
17
27
|
export declare function listChangedFiles(repoRoot: string): Promise<string[]>;
|
|
18
28
|
export interface UnbackedClaimsInput {
|
|
19
29
|
/** Absolute path of the primary repo checkout the report pertains to. */
|
|
@@ -19,6 +19,37 @@ const execFileAsync = promisify(execFile);
|
|
|
19
19
|
* `--untracked-files=all` lists each untracked file individually (otherwise git
|
|
20
20
|
* collapses a brand-new directory to "dir/", which would defeat path matching).
|
|
21
21
|
*/
|
|
22
|
+
/**
|
|
23
|
+
* Union of listChangedFiles over every tree a run legitimately writes to —
|
|
24
|
+
* the primary checkout, the tests-repo checkout (testRepoPath), and related
|
|
25
|
+
* repos (SKYR-4204 reopen: the backing check scanned only the primary, so a
|
|
26
|
+
* UI test generated into the tests-repo checkout was rejected as unbacked and
|
|
27
|
+
* demoted out of the report — run 32510028428). Undefined and duplicate roots
|
|
28
|
+
* are skipped; a root where git fails (missing dir, not a repo) contributes
|
|
29
|
+
* nothing rather than failing the whole check.
|
|
30
|
+
*/
|
|
31
|
+
export async function listChangedFilesAcross(roots) {
|
|
32
|
+
const seenRoots = new Set();
|
|
33
|
+
const seenFiles = new Set();
|
|
34
|
+
const files = [];
|
|
35
|
+
for (const root of roots) {
|
|
36
|
+
if (!root || seenRoots.has(root))
|
|
37
|
+
continue;
|
|
38
|
+
seenRoots.add(root);
|
|
39
|
+
try {
|
|
40
|
+
for (const f of await listChangedFiles(root)) {
|
|
41
|
+
if (seenFiles.has(f))
|
|
42
|
+
continue;
|
|
43
|
+
seenFiles.add(f);
|
|
44
|
+
files.push(f);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Non-git or missing tree — nothing to contribute.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return files;
|
|
52
|
+
}
|
|
22
53
|
export async function listChangedFiles(repoRoot) {
|
|
23
54
|
const { stdout } = await execFileAsync("git", ["status", "--porcelain", "-z", "--untracked-files=all"], { cwd: repoRoot });
|
|
24
55
|
const files = [];
|