@skyramp/mcp 0.3.4 → 0.3.6-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/playwright/registerPlaywrightTools.js +92 -30
- package/build/playwright/traceRecordingPrompt.d.ts +6 -0
- package/build/playwright/traceRecordingPrompt.js +6 -2
- package/build/prompts/code-reuse.d.ts +1 -2
- package/build/prompts/code-reuse.js +182 -77
- package/build/prompts/modularization/integration-test-modularization.d.ts +2 -0
- package/build/prompts/modularization/integration-test-modularization.js +83 -41
- package/build/prompts/modularization/render.d.ts +18 -0
- package/build/prompts/modularization/render.js +12 -0
- package/build/prompts/modularization/ui-test-modularization.d.ts +3 -1
- package/build/prompts/modularization/ui-test-modularization.js +89 -47
- package/build/prompts/pom-aware-code-reuse.js +3 -1
- package/build/prompts/shared-helper-policy.d.ts +57 -0
- package/build/prompts/shared-helper-policy.js +135 -0
- package/build/prompts/test-recommendation/diffExecutionPlan.js +62 -56
- package/build/prompts/test-recommendation/fullRepoCatalog.js +19 -8
- package/build/prompts/test-recommendation/recommendationShared.d.ts +28 -6
- package/build/prompts/test-recommendation/recommendationShared.js +90 -16
- package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +22 -0
- package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +2 -2
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +3 -3
- package/build/prompts/testbot/testbot-prompts.js +88 -33
- package/build/recommendation/budgeters/shared.js +105 -27
- package/build/recommendation/discriminators.js +13 -2
- package/build/recommendation/planRanker.d.ts +6 -6
- package/build/recommendation/planRanker.js +6 -61
- package/build/services/AnalyticsService.d.ts +7 -0
- package/build/services/AnalyticsService.js +7 -1
- package/build/services/ModularizationService.js +1 -3
- package/build/services/TestDiscoveryService.d.ts +0 -2
- package/build/services/TestDiscoveryService.js +2 -37
- package/build/services/TestGenerationService.d.ts +16 -0
- package/build/services/TestGenerationService.js +86 -10
- package/build/services/containerEnv.js +13 -12
- package/build/tools/code-refactor/codeReuseTool.js +279 -93
- package/build/tools/code-refactor/enhance-state.d.ts +49 -0
- package/build/tools/code-refactor/enhance-state.js +109 -0
- package/build/tools/code-refactor/enhanceAssertionsTool.js +34 -1
- package/build/tools/code-refactor/modularizationTool.js +9 -2
- package/build/tools/code-refactor/reuse-outcome.d.ts +23 -1
- package/build/tools/code-refactor/reuse-outcome.js +14 -4
- package/build/tools/code-refactor/reuse-state.d.ts +127 -5
- package/build/tools/code-refactor/reuse-state.js +628 -16
- package/build/tools/code-refactor/utils-verify-gates.d.ts +26 -0
- package/build/tools/code-refactor/utils-verify-gates.js +100 -0
- package/build/tools/code-refactor/verify-gates.d.ts +2 -1
- package/build/tools/code-refactor/verify-gates.js +90 -25
- package/build/tools/executeSkyrampTestTool.d.ts +19 -0
- package/build/tools/executeSkyrampTestTool.js +158 -8
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +2 -2
- package/build/tools/generate-tests/generateE2ERestTool.js +16 -0
- package/build/tools/generate-tests/generateUIRestTool.d.ts +1 -0
- package/build/tools/generate-tests/generateUIRestTool.js +22 -0
- package/build/tools/generate-tests/scenarioLint.d.ts +2 -0
- package/build/tools/generate-tests/scenarioLint.js +127 -19
- package/build/tools/generate-tests/trace-reuse-guard.d.ts +20 -0
- package/build/tools/generate-tests/trace-reuse-guard.js +93 -0
- package/build/tools/submitReportTool.d.ts +38 -38
- package/build/tools/submitReportTool.js +487 -104
- package/build/tools/test-management/analyzeChangesTool.d.ts +24 -1
- package/build/tools/test-management/analyzeChangesTool.js +75 -12
- package/build/tools/test-management/analyzeTestHealthTool.js +7 -7
- package/build/tools/test-management/registerTestPlanTool.d.ts +203 -0
- package/build/tools/test-management/registerTestPlanTool.js +149 -23
- package/build/types/Recommendation.d.ts +34 -5
- package/build/types/RepositoryAnalysis.d.ts +133 -114
- package/build/types/RepositoryAnalysis.js +1 -1
- package/build/types/ReuseOutcome.d.ts +102 -6
- package/build/types/ReuseOutcome.js +16 -2
- package/build/types/TestRecommendation.js +21 -3
- package/build/types/TestTypes.js +14 -8
- package/build/types/TestbotReport.d.ts +25 -3
- package/build/types/index.d.ts +2 -2
- package/build/types/index.js +1 -1
- package/build/utils/AnalysisStateManager.d.ts +69 -1
- package/build/utils/AnalysisStateManager.js +69 -5
- package/build/utils/branchDiff.d.ts +10 -0
- package/build/utils/branchDiff.js +28 -0
- package/build/utils/changedRoutes.d.ts +29 -0
- package/build/utils/changedRoutes.js +87 -0
- package/build/utils/featureFlags.d.ts +21 -0
- package/build/utils/featureFlags.js +23 -0
- package/build/utils/frontendIntegration.js +34 -4
- package/build/utils/importerHop.d.ts +2 -8
- package/build/utils/importerHop.js +15 -53
- package/build/utils/pathMatching.d.ts +38 -0
- package/build/utils/pathMatching.js +71 -0
- package/build/utils/pathSignatures.d.ts +22 -0
- package/build/utils/pathSignatures.js +57 -0
- package/build/utils/planMatchKeys.d.ts +16 -3
- package/build/utils/planMatchKeys.js +26 -10
- package/build/utils/pluralization.d.ts +10 -0
- package/build/utils/pluralization.js +18 -0
- package/build/utils/pom-catalog-parse.d.ts +52 -0
- package/build/utils/pom-catalog-parse.js +141 -0
- package/build/utils/pom-scope/selector-extractor.d.ts +12 -0
- package/build/utils/pom-scope/selector-extractor.js +34 -8
- package/build/utils/pom-verify/verify.d.ts +6 -5
- package/build/utils/pom-verify/verify.js +8 -6
- package/build/utils/reportLanguage.d.ts +43 -0
- package/build/utils/reportLanguage.js +125 -0
- package/build/utils/reportVerification.d.ts +74 -4
- package/build/utils/reportVerification.js +259 -3
- package/build/utils/reuseRouting.d.ts +3 -0
- package/build/utils/reuseRouting.js +50 -0
- package/build/utils/routeParsers.d.ts +2 -0
- package/build/utils/routeParsers.js +65 -8
- package/build/utils/scenarioDrafting.d.ts +1 -1
- package/build/utils/scenarioDrafting.js +57 -45
- package/build/utils/subjectEndpoints.d.ts +19 -0
- package/build/utils/subjectEndpoints.js +98 -0
- package/build/utils/testFileClassification.d.ts +11 -0
- package/build/utils/testFileClassification.js +47 -0
- package/build/utils/uiPageEnumerator.d.ts +45 -19
- package/build/utils/uiPageEnumerator.js +95 -51
- package/build/utils/utils-verify/allow.d.ts +16 -0
- package/build/utils/utils-verify/allow.js +68 -0
- package/build/utils/utils-verify/call-sites.d.ts +34 -0
- package/build/utils/utils-verify/call-sites.js +154 -0
- package/build/utils/utils-verify/index.d.ts +7 -0
- package/build/utils/utils-verify/index.js +7 -0
- package/build/utils/utils-verify/language-spec.d.ts +91 -0
- package/build/utils/utils-verify/language-spec.js +210 -0
- package/build/utils/utils-verify/locate.d.ts +39 -0
- package/build/utils/utils-verify/locate.js +199 -0
- package/build/utils/utils-verify/parse.d.ts +34 -0
- package/build/utils/utils-verify/parse.js +177 -0
- package/build/utils/utils-verify/stage.d.ts +24 -0
- package/build/utils/utils-verify/stage.js +107 -0
- package/build/utils/utils-verify/verify.d.ts +63 -0
- package/build/utils/utils-verify/verify.js +168 -0
- package/build/utils/utils.d.ts +3 -1
- package/build/utils/utils.js +3 -1
- package/build/workspace/workspace.d.ts +32 -32
- package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +9 -5
- package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +16 -0
- package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +2 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +115 -14
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +13 -1
- package/node_modules/playwright/node_modules/playwright-core/.DS_Store +0 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +2 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/htmlReport/index.html +27 -253
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/{codeMirrorModule-DtudTj_v.js → codeMirrorModule-DJMC4zNo.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-BW82eAUI.js +196 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/index.html +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-FNMuBzX1.js → codeMirrorModule-CZfp96qZ.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-gpLo02E0.js +809 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.Bq1r1URj.js +2 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.VEfqi1qN.js +5 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
- package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
- package/node_modules/playwright/package.json +1 -1
- package/package.json +2 -2
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-BpDwp16L.js +0 -422
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-Co9upU5h.js +0 -1035
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.DXNIQ_dx.js +0 -2
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CIKB3XSv.js +0 -5
|
@@ -14,8 +14,18 @@ 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
|
-
export interface
|
|
28
|
+
export interface UnchangedFileClaimsInput {
|
|
19
29
|
/** Absolute path of the primary repo checkout the report pertains to. */
|
|
20
30
|
repoRoot: string;
|
|
21
31
|
/** Repo-relative paths that have a working-tree change (from listChangedFiles). */
|
|
@@ -39,8 +49,14 @@ export interface UnbackedClaimsInput {
|
|
|
39
49
|
}
|
|
40
50
|
/**
|
|
41
51
|
* Cross-check report claims against files that actually changed in the working
|
|
42
|
-
* tree, returning a human-readable description for each claim
|
|
43
|
-
*
|
|
52
|
+
* tree, returning a human-readable description for each claim whose file has no
|
|
53
|
+
* change. An empty result means every claim is backed.
|
|
54
|
+
*
|
|
55
|
+
* Not to be confused with `findInvalidSourceCitations` below. This one is about
|
|
56
|
+
* files the agent says IT wrote — a created test, a maintained test — so the test
|
|
57
|
+
* is "did this file change in this run". That one is about application code the
|
|
58
|
+
* agent POINTS AT as the home of a defect, which it never edits, so the test is
|
|
59
|
+
* "does this file and symbol exist in the checkout at all".
|
|
44
60
|
*
|
|
45
61
|
* The delivery path can only ship what the agent actually edited (SKYR-3883), so
|
|
46
62
|
* a report must never claim file work the tree doesn't reflect. This is the
|
|
@@ -63,4 +79,58 @@ export interface UnbackedClaimsInput {
|
|
|
63
79
|
* - Claims for files outside this checkout (cross-repo `repository`, or an
|
|
64
80
|
* absolute path resolving outside `repoRoot`) — a different checkout owns them.
|
|
65
81
|
*/
|
|
66
|
-
export declare function
|
|
82
|
+
export declare function findUnchangedFileClaims(input: UnchangedFileClaimsInput): string[];
|
|
83
|
+
/** One checkout of the run, and the owner/repo it holds. */
|
|
84
|
+
export interface RepoCheckout {
|
|
85
|
+
/** Absolute checkout path. */
|
|
86
|
+
root: string;
|
|
87
|
+
/** owner/repo of this checkout, when the run recorded one. Undefined means the
|
|
88
|
+
* checkout cannot be named, so nothing resolved in it can be attributed. */
|
|
89
|
+
repository?: string;
|
|
90
|
+
/** True for the run's primary checkout — the repo an entry with no `repository`
|
|
91
|
+
* already means downstream, so its citations are never stamped. */
|
|
92
|
+
primary?: boolean;
|
|
93
|
+
}
|
|
94
|
+
export interface SourceCitationInput {
|
|
95
|
+
/** Checkouts to resolve a citation against: the primary repo plus every related
|
|
96
|
+
* repo in the run. Empty means no checkout is known — see below. */
|
|
97
|
+
checkouts: RepoCheckout[];
|
|
98
|
+
/** LLM-supplied issuesFound entries in submission order. The index is quoted back
|
|
99
|
+
* to the agent so it can find the entry it has to fix. */
|
|
100
|
+
issues: {
|
|
101
|
+
sourceFile?: string;
|
|
102
|
+
sourceSymbol?: string;
|
|
103
|
+
repository?: string;
|
|
104
|
+
}[];
|
|
105
|
+
}
|
|
106
|
+
export interface SourceCitationCheck {
|
|
107
|
+
/** One message per DEFINITE mismatch. Empty means the citations may ship. */
|
|
108
|
+
invalid: string[];
|
|
109
|
+
/** owner/repo to stamp on the entry at the same index of `issues`, or undefined
|
|
110
|
+
* to ship the attribution exactly as submitted. */
|
|
111
|
+
repository: (string | undefined)[];
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Check every `sourceFile`/`sourceSymbol` citation on the submitted issuesFound
|
|
115
|
+
* entries against the repository, and say which repo each one belongs to.
|
|
116
|
+
* Returns one message per DEFINITE mismatch — an empty `invalid` means the report
|
|
117
|
+
* may be written — plus, per entry, the owner/repo the citation resolved in.
|
|
118
|
+
*
|
|
119
|
+
* The attribution is the reason this resolves against every checkout instead of
|
|
120
|
+
* the first hit: the consumer must not have to guess a cited file's repo from the
|
|
121
|
+
* path, which is what makes it link the primary repo's copy of a related repo's
|
|
122
|
+
* file. Where the citation resolves is the answer, so it is returned rather than
|
|
123
|
+
* discarded. Only a related repo is stamped: an absent `repository` already means
|
|
124
|
+
* the primary repo downstream, so a primary-repo citation ships as submitted.
|
|
125
|
+
*
|
|
126
|
+
* Rejects four things: a path that no known checkout contains, a symbol absent
|
|
127
|
+
* from the file that was found, an attribution that names a different repo than
|
|
128
|
+
* the one holding the code, and a file that resolves in several named checkouts
|
|
129
|
+
* with no attribution to choose between them. Everything uncertain is ACCEPTED —
|
|
130
|
+
* no checkout, an unreadable file, a checkout with no owner/repo, a directory
|
|
131
|
+
* listing that fails. The check must behave the same on any repo, in any
|
|
132
|
+
* language, at any size, and it must never become a loop the agent cannot exit.
|
|
133
|
+
* Symbol matching is a plain substring search for the same reason: no parser, so
|
|
134
|
+
* no language it silently mishandles.
|
|
135
|
+
*/
|
|
136
|
+
export declare function findInvalidSourceCitations(input: SourceCitationInput): Promise<SourceCitationCheck>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "path";
|
|
2
|
+
import * as fs from "fs/promises";
|
|
2
3
|
import { execFile } from "child_process";
|
|
3
4
|
import { promisify } from "util";
|
|
4
5
|
import { testFileMatches } from "./utils.js";
|
|
@@ -19,6 +20,37 @@ const execFileAsync = promisify(execFile);
|
|
|
19
20
|
* `--untracked-files=all` lists each untracked file individually (otherwise git
|
|
20
21
|
* collapses a brand-new directory to "dir/", which would defeat path matching).
|
|
21
22
|
*/
|
|
23
|
+
/**
|
|
24
|
+
* Union of listChangedFiles over every tree a run legitimately writes to —
|
|
25
|
+
* the primary checkout, the tests-repo checkout (testRepoPath), and related
|
|
26
|
+
* repos (SKYR-4204 reopen: the backing check scanned only the primary, so a
|
|
27
|
+
* UI test generated into the tests-repo checkout was rejected as unbacked and
|
|
28
|
+
* demoted out of the report — run 32510028428). Undefined and duplicate roots
|
|
29
|
+
* are skipped; a root where git fails (missing dir, not a repo) contributes
|
|
30
|
+
* nothing rather than failing the whole check.
|
|
31
|
+
*/
|
|
32
|
+
export async function listChangedFilesAcross(roots) {
|
|
33
|
+
const seenRoots = new Set();
|
|
34
|
+
const seenFiles = new Set();
|
|
35
|
+
const files = [];
|
|
36
|
+
for (const root of roots) {
|
|
37
|
+
if (!root || seenRoots.has(root))
|
|
38
|
+
continue;
|
|
39
|
+
seenRoots.add(root);
|
|
40
|
+
try {
|
|
41
|
+
for (const f of await listChangedFiles(root)) {
|
|
42
|
+
if (seenFiles.has(f))
|
|
43
|
+
continue;
|
|
44
|
+
seenFiles.add(f);
|
|
45
|
+
files.push(f);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Non-git or missing tree — nothing to contribute.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return files;
|
|
53
|
+
}
|
|
22
54
|
export async function listChangedFiles(repoRoot) {
|
|
23
55
|
const { stdout } = await execFileAsync("git", ["status", "--porcelain", "-z", "--untracked-files=all"], { cwd: repoRoot });
|
|
24
56
|
const files = [];
|
|
@@ -41,8 +73,14 @@ export async function listChangedFiles(repoRoot) {
|
|
|
41
73
|
}
|
|
42
74
|
/**
|
|
43
75
|
* Cross-check report claims against files that actually changed in the working
|
|
44
|
-
* tree, returning a human-readable description for each claim
|
|
45
|
-
*
|
|
76
|
+
* tree, returning a human-readable description for each claim whose file has no
|
|
77
|
+
* change. An empty result means every claim is backed.
|
|
78
|
+
*
|
|
79
|
+
* Not to be confused with `findInvalidSourceCitations` below. This one is about
|
|
80
|
+
* files the agent says IT wrote — a created test, a maintained test — so the test
|
|
81
|
+
* is "did this file change in this run". That one is about application code the
|
|
82
|
+
* agent POINTS AT as the home of a defect, which it never edits, so the test is
|
|
83
|
+
* "does this file and symbol exist in the checkout at all".
|
|
46
84
|
*
|
|
47
85
|
* The delivery path can only ship what the agent actually edited (SKYR-3883), so
|
|
48
86
|
* a report must never claim file work the tree doesn't reflect. This is the
|
|
@@ -65,7 +103,7 @@ export async function listChangedFiles(repoRoot) {
|
|
|
65
103
|
* - Claims for files outside this checkout (cross-repo `repository`, or an
|
|
66
104
|
* absolute path resolving outside `repoRoot`) — a different checkout owns them.
|
|
67
105
|
*/
|
|
68
|
-
export function
|
|
106
|
+
export function findUnchangedFileClaims(input) {
|
|
69
107
|
const { repoRoot, changedFiles, newTests, verdicts, primaryRepository } = input;
|
|
70
108
|
const unbacked = [];
|
|
71
109
|
const isBacked = (claimedPath) => {
|
|
@@ -108,3 +146,221 @@ export function findUnbackedClaims(input) {
|
|
|
108
146
|
}
|
|
109
147
|
return unbacked;
|
|
110
148
|
}
|
|
149
|
+
/** Both ways out of a citation rejection, appended to every message so the agent
|
|
150
|
+
* never has to guess that dropping the citation is allowed. */
|
|
151
|
+
const CITATION_REMEDY = "Read the file you mean, correct sourceFile/sourceSymbol, and resubmit — or remove the citation if you cannot confirm it in source.";
|
|
152
|
+
/** How many same-basename paths to offer for a missing file. Enough to converge in
|
|
153
|
+
* one retry, few enough that the message stays readable. */
|
|
154
|
+
const MAX_PATH_SUGGESTIONS = 3;
|
|
155
|
+
/** True when `abs` names something strictly below `root`. */
|
|
156
|
+
function isInsideRoot(root, abs) {
|
|
157
|
+
const rel = path.relative(root, abs);
|
|
158
|
+
return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Resolve a cited path under one checkout, or return null when it lands outside.
|
|
162
|
+
* A citation names a file INSIDE the repo, so `../../etc/passwd` and an absolute
|
|
163
|
+
* path from another tree are invalid citations — not files to go looking for.
|
|
164
|
+
*/
|
|
165
|
+
function resolveInRoot(root, cited) {
|
|
166
|
+
// The contract is a repository-relative path. An absolute one would slip past
|
|
167
|
+
// the containment check whenever it happens to sit under root, because
|
|
168
|
+
// path.resolve() discards root — and it is unusable as a review anchor.
|
|
169
|
+
if (path.isAbsolute(cited))
|
|
170
|
+
return null;
|
|
171
|
+
const abs = path.resolve(root, cited);
|
|
172
|
+
return isInsideRoot(root, abs) ? abs : null;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Repo-relative paths whose basename equals the cited file's basename, so a
|
|
176
|
+
* rejection can name the file the agent probably meant. `git ls-files` does the
|
|
177
|
+
* filtering (a pathspec, not a full listing) to stay cheap on a large repo; a
|
|
178
|
+
* non-git directory or missing git yields no suggestions rather than an error.
|
|
179
|
+
*/
|
|
180
|
+
async function suggestSimilarPaths(roots, cited) {
|
|
181
|
+
const base = path.basename(cited);
|
|
182
|
+
if (!base)
|
|
183
|
+
return [];
|
|
184
|
+
const matches = [];
|
|
185
|
+
for (const root of roots) {
|
|
186
|
+
try {
|
|
187
|
+
const { stdout } = await execFileAsync("git", ["ls-files", "-z", "--", `*${base}`, base], {
|
|
188
|
+
cwd: root,
|
|
189
|
+
});
|
|
190
|
+
for (const p of stdout.split("\0")) {
|
|
191
|
+
// The pathspec also matches "myproducts.py" for "products.py"; keep only
|
|
192
|
+
// an exact basename hit so a suggestion is a real alternative spelling.
|
|
193
|
+
if (p && path.basename(p) === base)
|
|
194
|
+
matches.push(p);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// Not a git checkout, or git unavailable — no suggestions to offer.
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return matches.slice(0, MAX_PATH_SUGGESTIONS);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Check every `sourceFile`/`sourceSymbol` citation on the submitted issuesFound
|
|
205
|
+
* entries against the repository, and say which repo each one belongs to.
|
|
206
|
+
* Returns one message per DEFINITE mismatch — an empty `invalid` means the report
|
|
207
|
+
* may be written — plus, per entry, the owner/repo the citation resolved in.
|
|
208
|
+
*
|
|
209
|
+
* The attribution is the reason this resolves against every checkout instead of
|
|
210
|
+
* the first hit: the consumer must not have to guess a cited file's repo from the
|
|
211
|
+
* path, which is what makes it link the primary repo's copy of a related repo's
|
|
212
|
+
* file. Where the citation resolves is the answer, so it is returned rather than
|
|
213
|
+
* discarded. Only a related repo is stamped: an absent `repository` already means
|
|
214
|
+
* the primary repo downstream, so a primary-repo citation ships as submitted.
|
|
215
|
+
*
|
|
216
|
+
* Rejects four things: a path that no known checkout contains, a symbol absent
|
|
217
|
+
* from the file that was found, an attribution that names a different repo than
|
|
218
|
+
* the one holding the code, and a file that resolves in several named checkouts
|
|
219
|
+
* with no attribution to choose between them. Everything uncertain is ACCEPTED —
|
|
220
|
+
* no checkout, an unreadable file, a checkout with no owner/repo, a directory
|
|
221
|
+
* listing that fails. The check must behave the same on any repo, in any
|
|
222
|
+
* language, at any size, and it must never become a loop the agent cannot exit.
|
|
223
|
+
* Symbol matching is a plain substring search for the same reason: no parser, so
|
|
224
|
+
* no language it silently mishandles.
|
|
225
|
+
*/
|
|
226
|
+
export async function findInvalidSourceCitations(input) {
|
|
227
|
+
const invalid = [];
|
|
228
|
+
const repository = [];
|
|
229
|
+
const checkouts = input.checkouts.filter((c) => c.root && c.root !== "unknown");
|
|
230
|
+
if (checkouts.length === 0)
|
|
231
|
+
return { invalid, repository }; // Cannot verify anything — accept.
|
|
232
|
+
const roots = checkouts.map((c) => c.root);
|
|
233
|
+
for (let i = 0; i < input.issues.length; i++) {
|
|
234
|
+
const cited = input.issues[i].sourceFile?.trim();
|
|
235
|
+
if (!cited)
|
|
236
|
+
continue; // No citation on this entry — nothing to check.
|
|
237
|
+
const candidates = [];
|
|
238
|
+
for (const checkout of checkouts) {
|
|
239
|
+
const abs = resolveInRoot(checkout.root, cited);
|
|
240
|
+
if (abs)
|
|
241
|
+
candidates.push({ checkout, abs });
|
|
242
|
+
}
|
|
243
|
+
if (candidates.length === 0) {
|
|
244
|
+
invalid.push(`issuesFound[${i}] cites ${cited}, which resolves outside every repository checkout in this run. ` +
|
|
245
|
+
`Cite a path inside the checkout, relative to the repository root. ${CITATION_REMEDY}`);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
// One relative path can exist in several checkouts of a multi-repo run, and
|
|
249
|
+
// the citation does not say which one it means. Keep every hit, so the symbol
|
|
250
|
+
// below is looked for in all of them instead of only the first.
|
|
251
|
+
const found = [];
|
|
252
|
+
let unreadable = false;
|
|
253
|
+
for (const candidate of candidates) {
|
|
254
|
+
try {
|
|
255
|
+
if (!(await fs.stat(candidate.abs)).isFile())
|
|
256
|
+
continue;
|
|
257
|
+
// stat() and readFile() both follow symlinks, so a link out of the tree
|
|
258
|
+
// would let a citation resolve against content outside the checkout.
|
|
259
|
+
// Compare the real paths before the file counts as found.
|
|
260
|
+
if (isInsideRoot(await fs.realpath(candidate.checkout.root), await fs.realpath(candidate.abs))) {
|
|
261
|
+
found.push(candidate);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
// "Not there" is the answer we want. Any other error (permissions, I/O)
|
|
266
|
+
// means the check could not run, which is never grounds to reject.
|
|
267
|
+
if (err.code !== "ENOENT")
|
|
268
|
+
unreadable = true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (found.length === 0) {
|
|
272
|
+
if (unreadable)
|
|
273
|
+
continue; // Could not look — accept.
|
|
274
|
+
const suggestions = await suggestSimilarPaths(roots, cited);
|
|
275
|
+
invalid.push(`issuesFound[${i}] cites ${cited} but that file does not exist in the repository` +
|
|
276
|
+
(suggestions.length > 0 ? ` (closest matches: ${suggestions.join(", ")})` : "") +
|
|
277
|
+
`. ${CITATION_REMEDY}`);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
// The citation resolves in every checkout of `found`; a symbol narrows that to
|
|
281
|
+
// the copies that actually contain it, which is what the attribution below is
|
|
282
|
+
// taken from.
|
|
283
|
+
let resolved = found;
|
|
284
|
+
const symbol = input.issues[i].sourceSymbol?.trim();
|
|
285
|
+
if (symbol) {
|
|
286
|
+
const withSymbol = [];
|
|
287
|
+
let readAny = false;
|
|
288
|
+
for (const hit of found) {
|
|
289
|
+
try {
|
|
290
|
+
const content = await fs.readFile(hit.abs, "utf8");
|
|
291
|
+
readAny = true;
|
|
292
|
+
if (content.includes(symbol))
|
|
293
|
+
withSymbol.push(hit);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
// Could not read this copy — try the next one.
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (!readAny)
|
|
300
|
+
continue; // Could not read any of them — accept.
|
|
301
|
+
if (withSymbol.length === 0) {
|
|
302
|
+
invalid.push(`issuesFound[${i}] cites ${symbol} in ${cited}, but that text does not appear anywhere in the file. ` +
|
|
303
|
+
CITATION_REMEDY);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
resolved = withSymbol;
|
|
307
|
+
}
|
|
308
|
+
const attribution = attributeCitation({
|
|
309
|
+
entry: `issuesFound[${i}]`,
|
|
310
|
+
cited,
|
|
311
|
+
symbol,
|
|
312
|
+
declared: input.issues[i].repository?.trim(),
|
|
313
|
+
resolved: resolved.map((hit) => hit.checkout),
|
|
314
|
+
});
|
|
315
|
+
if (attribution.message)
|
|
316
|
+
invalid.push(attribution.message);
|
|
317
|
+
repository[i] = attribution.repository;
|
|
318
|
+
}
|
|
319
|
+
return { invalid, repository };
|
|
320
|
+
}
|
|
321
|
+
/** Distinct owner/repo names of the checkouts a citation resolved in, in order.
|
|
322
|
+
* A checkout the run could not name contributes nothing: it can be neither
|
|
323
|
+
* stamped nor offered as a candidate. */
|
|
324
|
+
function namesOf(resolved) {
|
|
325
|
+
return [...new Set(resolved.map((c) => c.repository).filter((r) => !!r))];
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Reconcile where a citation resolved with what the entry says about it, giving
|
|
329
|
+
* back the owner/repo to stamp, a rejection message, or neither.
|
|
330
|
+
*/
|
|
331
|
+
function attributeCitation(args) {
|
|
332
|
+
const { entry, cited, symbol, declared, resolved } = args;
|
|
333
|
+
const names = namesOf(resolved);
|
|
334
|
+
const where = symbol ? `${cited} (${symbol})` : cited;
|
|
335
|
+
if (declared) {
|
|
336
|
+
// The entry names its repo, so the citation is checked against THAT checkout.
|
|
337
|
+
// owner/repo is case-insensitive on GitHub, so a differently-cased attribution
|
|
338
|
+
// is the same repo — accepted, and re-stamped in the run's own spelling rather
|
|
339
|
+
// than sent back for a rewrite that would change nothing.
|
|
340
|
+
const match = resolved.find((c) => c.repository && c.repository.toLowerCase() === declared.toLowerCase());
|
|
341
|
+
if (match)
|
|
342
|
+
return match.repository === declared ? {} : { repository: match.repository };
|
|
343
|
+
// It resolved somewhere else. Only say so when the run can name where —
|
|
344
|
+
// otherwise there is nothing to correct the attribution to.
|
|
345
|
+
if (names.length === 0)
|
|
346
|
+
return {};
|
|
347
|
+
return {
|
|
348
|
+
message: `${entry} is attributed to ${declared}, but ${where} resolves in ${names.join(", ")} — not in ${declared}. ` +
|
|
349
|
+
`Set repository to the repo that holds the code, or cite a file that exists in ${declared}.`,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
if (resolved.length > 1) {
|
|
353
|
+
// Several checkouts hold this path, and the entry does not say which is meant.
|
|
354
|
+
if (names.length > 1) {
|
|
355
|
+
return {
|
|
356
|
+
message: `${entry} cites ${where}, which exists in more than one repository of this run (${names.join(", ")}), ` +
|
|
357
|
+
`and the entry does not say which one it is about. Set repository to the owner/repo whose file you read.`,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
return {}; // Cannot name the alternatives — accept, and stamp nothing.
|
|
361
|
+
}
|
|
362
|
+
// Exactly one checkout. Stamp it unless it is the primary, whose findings are
|
|
363
|
+
// identified downstream by having NO repository — see SourceCitationCheck.
|
|
364
|
+
const [only] = resolved;
|
|
365
|
+
return only.primary || !only.repository ? {} : { repository: only.repository };
|
|
366
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare function isBrowserTestType(testType?: string): boolean;
|
|
2
|
+
export declare function isModularizeFirstTarget(testType?: string, language?: string): boolean;
|
|
3
|
+
export declare function isPomAwareTarget(language: string, framework?: string, testType?: string): boolean;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routing predicates for the code-reuse flow. Kept apart from the prompt
|
|
3
|
+
* modules so the generation services can route on them without importing the
|
|
4
|
+
* prompt text and its POM-catalog dependencies.
|
|
5
|
+
*/
|
|
6
|
+
import { isPomReuseEnabled, isUtilsReuseEnabled } from "./featureFlags.js";
|
|
7
|
+
// Page objects model browser pages — mapping an API-level test onto them is never
|
|
8
|
+
// meaningful, so only browser test types (or an unspecified type, for backwards
|
|
9
|
+
// compatibility) may take the POM path regardless of the feature flag.
|
|
10
|
+
export function isBrowserTestType(testType) {
|
|
11
|
+
return testType === undefined || testType === "ui" || testType === "e2e";
|
|
12
|
+
}
|
|
13
|
+
// Modularize-first flows: fresh codegen output carries no helper functions, so
|
|
14
|
+
// skyramp_modularization runs BEFORE skyramp_reuse_code and the reuse pass seeds
|
|
15
|
+
// the shared utils file from the helpers it created. The utils flag is the
|
|
16
|
+
// single gate for every test type — the modularization prompts' per-step
|
|
17
|
+
// extraction reads the same predicate, so the hand-off, the extraction and the
|
|
18
|
+
// seeding step agree on every codeReuse flow. Integration qualifies whenever the flag is on;
|
|
19
|
+
// UI only while the POM path is off (POM refactoring supersedes modularization,
|
|
20
|
+
// and the testbot UI flow asks for codeReuse unconditionally, so the flag is what
|
|
21
|
+
// keeps the default-off behavior a no-op reuse pass). e2e and load are not
|
|
22
|
+
// routed here: load has no modularization routing at all, e2e is deferred.
|
|
23
|
+
// Deliberately keyed on the POM *flag*, not on isPomAwareTarget: with the flag
|
|
24
|
+
// on, a UI target the POM path does not cover keeps today's plain reuse pass.
|
|
25
|
+
// Composing utils reuse with the POM path is a separate change; this predicate
|
|
26
|
+
// only decides the flag-off UI flow. The modularization prompts read it too, so
|
|
27
|
+
// extraction and seeding agree for every codeReuse flow; a modularizeCode-only
|
|
28
|
+
// call (no codeReuse) still receives the shared-helper rules — harmless without
|
|
29
|
+
// a reuse pass, and the tool cannot see codeReuse.
|
|
30
|
+
export function isModularizeFirstTarget(testType, language) {
|
|
31
|
+
if (!isUtilsReuseEnabled())
|
|
32
|
+
return false;
|
|
33
|
+
if (testType === "integration")
|
|
34
|
+
return true;
|
|
35
|
+
if (testType !== "ui" || isPomReuseEnabled())
|
|
36
|
+
return false;
|
|
37
|
+
// Shared browser helpers are Playwright TS/JS; other UI languages have no
|
|
38
|
+
// utils-file convention here and would fall through to a Python file name.
|
|
39
|
+
const lang = language?.toLowerCase();
|
|
40
|
+
return lang === "typescript" || lang === "javascript";
|
|
41
|
+
}
|
|
42
|
+
export function isPomAwareTarget(language, framework, testType) {
|
|
43
|
+
if (!isPomReuseEnabled())
|
|
44
|
+
return false;
|
|
45
|
+
if (!isBrowserTestType(testType))
|
|
46
|
+
return false;
|
|
47
|
+
const lang = language.toLowerCase();
|
|
48
|
+
return ((lang === "typescript" || lang === "javascript") &&
|
|
49
|
+
framework?.toLowerCase() === "playwright");
|
|
50
|
+
}
|
|
@@ -35,6 +35,8 @@ export declare function parseRouteLine(line: string, sourceFile: string): Parsed
|
|
|
35
35
|
* aggregator's `APIRouter(prefix="/api")` yields `/api/recipes`, not `/recipes`.
|
|
36
36
|
*/
|
|
37
37
|
export declare function parseFileEndpoints(content: string, sourceFile: string, mountPrefixes?: Map<string, string>): ParsedDiffEndpoint[];
|
|
38
|
+
export declare function isParamSegment(segment: string): boolean;
|
|
39
|
+
export declare function isOpaqueIdSegment(segment: string): boolean;
|
|
38
40
|
export declare const SKIP_PATH_SEGMENTS: Set<string>;
|
|
39
41
|
/**
|
|
40
42
|
* True for version/prefix path segments — "v1".."v99" and the literal "api".
|
|
@@ -127,10 +127,27 @@ export function parseRouteLine(line, sourceFile) {
|
|
|
127
127
|
};
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
|
-
// Go (Gin, Echo, Chi): <ident>.GET/POST/...("
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
130
|
+
// Go (Gin, Echo, Chi): <ident>.GET/POST/...("path", handler)
|
|
131
|
+
//
|
|
132
|
+
// An all-caps method token (Gin/Echo convention: `g.GET(...)`, `r.POST(...)`)
|
|
133
|
+
// is unambiguously a route registration — nobody writes `query.GET(...)` — so
|
|
134
|
+
// it's accepted even without a leading slash (real Gin routes like
|
|
135
|
+
// `g.GET("ping", ...)` omit it). A CamelCase token (Chi's `r.Get`/`r.Post`)
|
|
136
|
+
// shares call syntax with a plain getter — `query.Get("term")`,
|
|
137
|
+
// `Header.Get("Accept-Version")` — so it only counts as a route when the
|
|
138
|
+
// captured path starts with "/", which every Chi route does and a getter's
|
|
139
|
+
// argument never does.
|
|
140
|
+
const ginUpperMatch = stripped.match(/\w+\.(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s*\(\s*["']([^"'?#]+)/);
|
|
141
|
+
if (ginUpperMatch && sourceFile.endsWith(".go")) {
|
|
142
|
+
return { method: ginUpperMatch[1], path: ginUpperMatch[2], sourceFile };
|
|
143
|
+
}
|
|
144
|
+
const ginCamelMatch = stripped.match(/\w+\.(Get|Post|Put|Patch|Delete|Head|Options)\s*\(\s*["'](\/[^"'?#]*)/);
|
|
145
|
+
if (ginCamelMatch && sourceFile.endsWith(".go")) {
|
|
146
|
+
return {
|
|
147
|
+
method: ginCamelMatch[1].toUpperCase(),
|
|
148
|
+
path: ginCamelMatch[2],
|
|
149
|
+
sourceFile,
|
|
150
|
+
};
|
|
134
151
|
}
|
|
135
152
|
// Go stdlib: http.HandleFunc/Handle
|
|
136
153
|
const goHandleMatch = stripped.match(/(?:HandleFunc|Handle)\s*\(\s*["']([^"'?#]+)/);
|
|
@@ -490,6 +507,45 @@ export function parseFileEndpoints(content, sourceFile, mountPrefixes) {
|
|
|
490
507
|
path: ep.path && !ep.path.startsWith("/") ? `/${ep.path}` : ep.path,
|
|
491
508
|
}));
|
|
492
509
|
}
|
|
510
|
+
/**
|
|
511
|
+
* One path segment that names a parameter rather than a resource, in any route
|
|
512
|
+
* syntax the scanners read: OpenAPI "{id}", Express/NestJS ":id", and Next.js
|
|
513
|
+
* "[id]" / "[...id]" / "[[...id]]" (the double-bracket form needs its own
|
|
514
|
+
* alternative — it contains an inner "]", which the single-bracket form's
|
|
515
|
+
* [^\]]+ can never span).
|
|
516
|
+
*/
|
|
517
|
+
// The colon form may carry an Express/Fastify constraint or modifier —
|
|
518
|
+
// `:id(\d+)`, `:id?`, `:id*`, `:id+`. Those are still parameters, and a
|
|
519
|
+
// resource name is never one, so the predicate has to accept them or
|
|
520
|
+
// extractResourceFromPath returns the whole segment as the resource.
|
|
521
|
+
const PARAM_SEGMENT_RE = /^\{[^}]+\}$|^:[A-Za-z_]\w*(?:\([^)]*\))?[?*+]?$|^\[\[[^\]]+\]\]$|^\[[^\]]+\]$/;
|
|
522
|
+
export function isParamSegment(segment) {
|
|
523
|
+
return PARAM_SEGMENT_RE.test(segment);
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* One path segment that is an id VALUE rather than a name: a UUID, digits only,
|
|
527
|
+
* or a long hex run (a Mongo ObjectId is 24). A caller writes a literal id in a
|
|
528
|
+
* path where the route declares a parameter, and an id can never be a resource
|
|
529
|
+
* name, so resource derivation must skip it exactly as it skips a parameter.
|
|
530
|
+
* SKYR-4214: an accepted UUID produced the key
|
|
531
|
+
* "DELETE::a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11::integration", which matches
|
|
532
|
+
* nothing, so the candidate escaped dedup entirely.
|
|
533
|
+
*
|
|
534
|
+
* The 16-character floor on the hex rule keeps real resource names safe: a name
|
|
535
|
+
* built only from the letters a-f and digits, 16 characters or longer, is not a
|
|
536
|
+
* word.
|
|
537
|
+
*/
|
|
538
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
539
|
+
export function isOpaqueIdSegment(segment) {
|
|
540
|
+
// The sign belongs to the value: an edge-case step writes `/orders/-1` to
|
|
541
|
+
// probe a negative id, and without the sign that segment became the resource
|
|
542
|
+
// name, so the key could never match real order coverage.
|
|
543
|
+
return UUID_RE.test(segment) || /^-?\d+$/.test(segment) || /^[0-9a-f]{16,}$/i.test(segment);
|
|
544
|
+
}
|
|
545
|
+
/** A segment that names neither a resource nor a sub-resource. */
|
|
546
|
+
function isNonResourceSegment(segment) {
|
|
547
|
+
return isParamSegment(segment) || isOpaqueIdSegment(segment);
|
|
548
|
+
}
|
|
493
549
|
export const SKIP_PATH_SEGMENTS = new Set(["api", "v1", "v2", "v3", "public"]);
|
|
494
550
|
/**
|
|
495
551
|
* True for version/prefix path segments — "v1".."v99" and the literal "api".
|
|
@@ -510,14 +566,15 @@ export function isVersionLikeSegment(segment) {
|
|
|
510
566
|
*/
|
|
511
567
|
export function extractResourceFromPath(endpointPath) {
|
|
512
568
|
const segments = endpointPath.split("/").filter(Boolean);
|
|
513
|
-
const meaningful = segments.filter((s) => !s
|
|
569
|
+
const meaningful = segments.filter((s) => !isNonResourceSegment(s) && !SKIP_PATH_SEGMENTS.has(s));
|
|
514
570
|
if (meaningful.length === 0)
|
|
515
571
|
return "unknown";
|
|
516
|
-
// Sub-resource pattern: /…/<parent
|
|
517
|
-
// Detected when the last raw segment
|
|
572
|
+
// Sub-resource pattern: /…/<parent>/<id>/<child>
|
|
573
|
+
// Detected when the last raw segment names a resource and the one before it
|
|
574
|
+
// does not — an id in any spelling, not only "{param}".
|
|
518
575
|
const last = segments[segments.length - 1];
|
|
519
576
|
const secondLast = segments.length >= 2 ? segments[segments.length - 2] : "";
|
|
520
|
-
if (!last
|
|
577
|
+
if (!isNonResourceSegment(last) && isNonResourceSegment(secondLast) && meaningful.length >= 2) {
|
|
521
578
|
return `${meaningful[meaningful.length - 2]}_${meaningful[meaningful.length - 1]}`;
|
|
522
579
|
}
|
|
523
580
|
return meaningful[meaningful.length - 1];
|
|
@@ -46,7 +46,7 @@ export declare function draftScenariosFromEndpoints(endpoints: Array<EndpointInp
|
|
|
46
46
|
/**
|
|
47
47
|
* Enforce a global cap on drafted scenarios while preserving category diversity.
|
|
48
48
|
*
|
|
49
|
-
* 1. CRITICAL (
|
|
49
|
+
* 1. CRITICAL (bug_caught) scenarios prioritized first.
|
|
50
50
|
* 2. One scenario per non-empty category guaranteed (breadth).
|
|
51
51
|
* 3. Remaining budget filled by priority tier (HIGH > MEDIUM > LOW).
|
|
52
52
|
* 4. Hard cap at MAX_TOTAL_SCENARIOS — applied to the combined output.
|