@skyramp/mcp 0.3.1 → 0.3.2-rc.pom-2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/build/index.js +2 -1
  2. package/build/prompts/code-reuse.d.ts +7 -1
  3. package/build/prompts/code-reuse.js +8 -5
  4. package/build/prompts/code-reuse.test.d.ts +1 -0
  5. package/build/prompts/code-reuse.test.js +62 -0
  6. package/build/prompts/pom-aware-code-reuse.d.ts +6 -1
  7. package/build/prompts/pom-aware-code-reuse.js +100 -53
  8. package/build/prompts/pom-aware-code-reuse.test.d.ts +1 -0
  9. package/build/prompts/pom-aware-code-reuse.test.js +11 -0
  10. package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +4 -2
  11. package/build/prompts/test-recommendation/diffExecutionPlan.js +11 -65
  12. package/build/prompts/test-recommendation/fullRepoCatalog.d.ts +2 -2
  13. package/build/prompts/test-recommendation/recommendationSections.js +5 -2
  14. package/build/prompts/test-recommendation/scopeAssessment.js +1 -1
  15. package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +26 -1
  16. package/build/prompts/test-recommendation/test-recommendation-prompt.js +68 -56
  17. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +48 -11
  18. package/build/prompts/testbot/testbot-prompts.d.ts +1 -1
  19. package/build/prompts/testbot/testbot-prompts.js +67 -21
  20. package/build/prompts/testbot/testbot-prompts.test.js +44 -0
  21. package/build/recommendation/budgeters/diversityBalancedBudgeter.d.ts +7 -0
  22. package/build/recommendation/budgeters/diversityBalancedBudgeter.js +71 -0
  23. package/build/recommendation/budgeters/diversityBalancedBudgeter.test.d.ts +1 -0
  24. package/build/recommendation/budgeters/diversityBalancedBudgeter.test.js +75 -0
  25. package/build/recommendation/budgeters/fixedNBudgeter.d.ts +7 -0
  26. package/build/recommendation/budgeters/fixedNBudgeter.js +11 -0
  27. package/build/recommendation/budgeters/fixedNBudgeter.test.d.ts +1 -0
  28. package/build/recommendation/budgeters/fixedNBudgeter.test.js +66 -0
  29. package/build/recommendation/budgeters/shared.d.ts +19 -0
  30. package/build/recommendation/budgeters/shared.js +66 -0
  31. package/build/recommendation/discriminators.d.ts +31 -0
  32. package/build/recommendation/discriminators.js +355 -0
  33. package/build/recommendation/discriminators.test.d.ts +1 -0
  34. package/build/recommendation/discriminators.test.js +324 -0
  35. package/build/recommendation/diversity.d.ts +47 -0
  36. package/build/recommendation/diversity.js +101 -0
  37. package/build/recommendation/diversity.test.d.ts +1 -0
  38. package/build/recommendation/diversity.test.js +77 -0
  39. package/build/recommendation/planRanker.d.ts +50 -0
  40. package/build/recommendation/planRanker.js +67 -0
  41. package/build/recommendation/planRanker.test.d.ts +1 -0
  42. package/build/recommendation/planRanker.test.js +110 -0
  43. package/build/recommendation/testFixtures.d.ts +25 -0
  44. package/build/recommendation/testFixtures.js +45 -0
  45. package/build/resources/testbotResource.js +4 -1
  46. package/build/services/ScenarioGenerationService.d.ts +5 -0
  47. package/build/services/ScenarioGenerationService.js +16 -1
  48. package/build/services/ScenarioGenerationService.test.js +44 -0
  49. package/build/services/TestExecutionService.d.ts +15 -1
  50. package/build/services/TestExecutionService.js +210 -55
  51. package/build/services/TestExecutionService.test.js +397 -0
  52. package/build/services/TestGenerationService.js +19 -1
  53. package/build/services/TestGenerationService.test.js +58 -0
  54. package/build/tool-phases.js +1 -0
  55. package/build/toolNames.d.ts +19 -0
  56. package/build/toolNames.js +19 -0
  57. package/build/tools/code-refactor/codeReuseTool.d.ts +7 -0
  58. package/build/tools/code-refactor/codeReuseTool.js +130 -4
  59. package/build/tools/code-refactor/codeReuseTool.test.d.ts +1 -0
  60. package/build/tools/code-refactor/codeReuseTool.test.js +290 -0
  61. package/build/tools/executeSkyrampTestTool.js +8 -2
  62. package/build/tools/generate-tests/generateBatchScenarioRestTool.d.ts +6 -1
  63. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +110 -17
  64. package/build/tools/generate-tests/generateBatchScenarioRestTool.test.js +147 -0
  65. package/build/tools/generate-tests/generateContractRestTool.js +11 -1
  66. package/build/tools/generate-tests/generateIntegrationRestTool.js +24 -1
  67. package/build/tools/generate-tests/generateIntegrationRestTool.test.d.ts +1 -0
  68. package/build/tools/generate-tests/generateIntegrationRestTool.test.js +159 -0
  69. package/build/tools/generate-tests/planGuard.d.ts +13 -0
  70. package/build/tools/generate-tests/planGuard.js +78 -0
  71. package/build/tools/generate-tests/planGuard.test.d.ts +1 -0
  72. package/build/tools/generate-tests/planGuard.test.js +185 -0
  73. package/build/tools/generate-tests/scenarioFileIdentity.d.ts +10 -0
  74. package/build/tools/generate-tests/scenarioFileIdentity.js +46 -0
  75. package/build/tools/generate-tests/scenarioLint.d.ts +30 -0
  76. package/build/tools/generate-tests/scenarioLint.js +150 -0
  77. package/build/tools/generate-tests/scenarioLint.test.d.ts +1 -0
  78. package/build/tools/generate-tests/scenarioLint.test.js +100 -0
  79. package/build/tools/submitReportTool.js +78 -0
  80. package/build/tools/submitReportTool.test.js +255 -0
  81. package/build/tools/test-management/analyzeChangesTool.js +55 -2
  82. package/build/tools/test-management/analyzeChangesTool.test.js +12 -0
  83. package/build/tools/test-management/index.d.ts +1 -0
  84. package/build/tools/test-management/index.js +1 -0
  85. package/build/tools/test-management/registerTestPlanTool.d.ts +2 -0
  86. package/build/tools/test-management/registerTestPlanTool.js +329 -0
  87. package/build/tools/test-management/registerTestPlanTool.test.d.ts +1 -0
  88. package/build/tools/test-management/registerTestPlanTool.test.js +296 -0
  89. package/build/types/Recommendation.d.ts +97 -0
  90. package/build/types/Recommendation.js +48 -0
  91. package/build/types/RepositoryAnalysis.d.ts +14 -14
  92. package/build/types/TestExecution.d.ts +2 -0
  93. package/build/types/TestRecommendation.d.ts +12 -1
  94. package/build/types/TestRecommendation.js +26 -11
  95. package/build/types/TestTypes.js +1 -1
  96. package/build/utils/AnalysisStateManager.d.ts +47 -0
  97. package/build/utils/docker.test.js +1 -1
  98. package/build/utils/planMatchKeys.d.ts +61 -0
  99. package/build/utils/planMatchKeys.js +125 -0
  100. package/build/utils/pom-scope/import-expansion.d.ts +5 -0
  101. package/build/utils/pom-scope/import-expansion.js +32 -0
  102. package/build/utils/pom-scope/index.d.ts +39 -0
  103. package/build/utils/pom-scope/index.js +120 -0
  104. package/build/utils/pom-scope/index.test.d.ts +1 -0
  105. package/build/utils/pom-scope/index.test.js +239 -0
  106. package/build/utils/pom-scope/pom-files.d.ts +3 -0
  107. package/build/utils/pom-scope/pom-files.js +48 -0
  108. package/build/utils/pom-scope/pom-files.test.d.ts +1 -0
  109. package/build/utils/pom-scope/pom-files.test.js +29 -0
  110. package/build/utils/pom-scope/scoring.d.ts +20 -0
  111. package/build/utils/pom-scope/scoring.js +45 -0
  112. package/build/utils/pom-scope/scoring.test.d.ts +1 -0
  113. package/build/utils/pom-scope/scoring.test.js +39 -0
  114. package/build/utils/pom-scope/selector-extractor.d.ts +7 -0
  115. package/build/utils/pom-scope/selector-extractor.js +57 -0
  116. package/build/utils/pom-scope/selector-extractor.test.d.ts +1 -0
  117. package/build/utils/pom-scope/selector-extractor.test.js +67 -0
  118. package/build/utils/pom-verify/__fixtures__/af-style/asset-list.page.d.ts +5 -0
  119. package/build/utils/pom-verify/__fixtures__/af-style/asset-list.page.js +5 -0
  120. package/build/utils/pom-verify/__fixtures__/af-style/pageobjects/asset-list-page.d.ts +5 -0
  121. package/build/utils/pom-verify/__fixtures__/af-style/pageobjects/asset-list-page.js +9 -0
  122. package/build/utils/pom-verify/__fixtures__/af-style/report.iframe.page.d.ts +4 -0
  123. package/build/utils/pom-verify/__fixtures__/af-style/report.iframe.page.js +4 -0
  124. package/build/utils/pom-verify/__fixtures__/af-style/workflow-footer.page.d.ts +4 -0
  125. package/build/utils/pom-verify/__fixtures__/af-style/workflow-footer.page.js +4 -0
  126. package/build/utils/pom-verify/bindings.d.ts +19 -0
  127. package/build/utils/pom-verify/bindings.js +161 -0
  128. package/build/utils/pom-verify/bindings.test.d.ts +1 -0
  129. package/build/utils/pom-verify/bindings.test.js +164 -0
  130. package/build/utils/pom-verify/calls.d.ts +16 -0
  131. package/build/utils/pom-verify/calls.js +42 -0
  132. package/build/utils/pom-verify/calls.test.d.ts +1 -0
  133. package/build/utils/pom-verify/calls.test.js +61 -0
  134. package/build/utils/pom-verify/index.d.ts +4 -0
  135. package/build/utils/pom-verify/index.js +4 -0
  136. package/build/utils/pom-verify/resolve.d.ts +7 -0
  137. package/build/utils/pom-verify/resolve.js +27 -0
  138. package/build/utils/pom-verify/resolve.test.d.ts +1 -0
  139. package/build/utils/pom-verify/resolve.test.js +68 -0
  140. package/build/utils/pom-verify/strip.d.ts +9 -0
  141. package/build/utils/pom-verify/strip.js +89 -0
  142. package/build/utils/pom-verify/verify.d.ts +14 -0
  143. package/build/utils/pom-verify/verify.js +158 -0
  144. package/build/utils/pom-verify/verify.test.d.ts +1 -0
  145. package/build/utils/pom-verify/verify.test.js +325 -0
  146. package/build/utils/reportVerification.d.ts +61 -0
  147. package/build/utils/reportVerification.js +104 -0
  148. package/build/utils/reportVerification.test.d.ts +1 -0
  149. package/build/utils/reportVerification.test.js +185 -0
  150. package/build/utils/scenarioDrafting.js +5 -5
  151. package/build/utils/versions.d.ts +3 -3
  152. package/build/utils/versions.js +1 -1
  153. package/build/utils/workspaceAuth.d.ts +9 -1
  154. package/build/utils/workspaceAuth.js +25 -5
  155. package/build/utils/workspaceAuth.test.js +48 -0
  156. package/build/workspace/workspace.d.ts +20 -0
  157. package/build/workspace/workspace.js +4 -0
  158. package/build/workspace/workspace.test.js +10 -0
  159. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +77 -8
  160. package/node_modules/playwright/node_modules/playwright-core/lib/generated/injectedScriptSource.js +1 -1
  161. package/node_modules/playwright/node_modules/playwright-core/lib/generated/pollingRecorderSource.js +1 -1
  162. package/node_modules/playwright/node_modules/playwright-core/lib/utils/isomorphic/volatileDate.js +101 -0
  163. package/node_modules/playwright/node_modules/playwright-core/lib/utils.js +2 -0
  164. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-aszq5EdG.js → codeMirrorModule-Bzd72-bG.js} +1 -1
  165. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-BxS7Jm4s.js → defaultSettingsView-DzxTioTK.js} +101 -101
  166. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.D4JTTy4R.js → index.BGc30U3S.js} +1 -1
  167. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  168. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.DaRMQKOI.js → uiMode.IaDrb29A.js} +1 -1
  169. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  170. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  171. package/node_modules/playwright/node_modules/playwright-core/src/generated/injectedScriptSource.ts +1 -1
  172. package/node_modules/playwright/node_modules/playwright-core/src/generated/pollingRecorderSource.ts +1 -1
  173. package/node_modules/playwright/node_modules/playwright-core/src/utils/isomorphic/volatileDate.ts +131 -0
  174. package/node_modules/playwright/node_modules/playwright-core/src/utils.ts +1 -0
  175. package/node_modules/playwright/package.json +1 -1
  176. package/package.json +3 -3
  177. package/node_modules/playwright/node_modules/playwright-core/.DS_Store +0 -0
@@ -0,0 +1,355 @@
1
+ import { DiscriminatorKind } from "../types/Recommendation.js";
2
+ const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
3
+ const MIN_ANCHOR_LENGTH = 8;
4
+ const MIN_NEAR_MISS_LENGTH = 3;
5
+ /**
6
+ * Verify a declared discriminator against the candidate's `steps[]` and the PR
7
+ * diff. Mirrors the classification precedent (`recommendationShared.ts`): the
8
+ * claim is checked STRUCTURALLY, never by fuzzy keyword scans of prose. A failed
9
+ * check returns `verified: false` with a one-sentence reason (the claim is
10
+ * demoted, never used to reject the candidate). NEVER throws — malformed or
11
+ * partial input is treated as unverified.
12
+ *
13
+ * Two gates, both required to verify:
14
+ * 1. `changedCodeAnchor` must be a non-trivial string (>= 8 chars after trim)
15
+ * occurring verbatim in `diffText` — grounds the claim in the real change.
16
+ * 2. The `kind`-specific structural predicate must hold over `steps[]`.
17
+ */
18
+ export function validateDiscriminator(scenario, declared, diffText) {
19
+ try {
20
+ const anchorResult = verifyAnchor(declared?.changedCodeAnchor, diffText);
21
+ if (!anchorResult.verified)
22
+ return anchorResult;
23
+ const steps = Array.isArray(scenario?.steps) ? scenario.steps : [];
24
+ switch (declared?.kind) {
25
+ case DiscriminatorKind.BOUNDARY_EQUALITY:
26
+ return verifyBoundaryEquality(steps);
27
+ case DiscriminatorKind.MULTI_RECORD_AGGREGATE:
28
+ return verifyMultiRecordAggregate(steps);
29
+ case DiscriminatorKind.NEGATIVE_MATCH:
30
+ return verifyNegativeMatch(steps);
31
+ case DiscriminatorKind.STATE_TRANSITION:
32
+ return verifyStateTransition(steps);
33
+ default:
34
+ return {
35
+ verified: false,
36
+ reason: `Unknown discriminator kind "${String(declared?.kind)}"; expected one of boundary_equality, multi_record_aggregate, negative_match, state_transition.`,
37
+ };
38
+ }
39
+ }
40
+ catch {
41
+ // Defensive: any malformed input is treated as unverified, never a throw.
42
+ return { verified: false, reason: "Discriminator claim could not be verified against the scenario steps." };
43
+ }
44
+ }
45
+ // ── Anchor grounding ──
46
+ function verifyAnchor(rawAnchor, diffText) {
47
+ const anchor = typeof rawAnchor === "string" ? rawAnchor.trim() : "";
48
+ if (anchor.length < MIN_ANCHOR_LENGTH) {
49
+ return {
50
+ verified: false,
51
+ reason: `changedCodeAnchor "${String(rawAnchor ?? "")}" is too short (needs >= ${MIN_ANCHOR_LENGTH} non-whitespace chars) to ground the claim in the diff.`,
52
+ };
53
+ }
54
+ const diff = typeof diffText === "string" ? diffText : "";
55
+ // A statement wrapped across diff lines carries +/- prefixes and indentation
56
+ // between its fragments, so a multi-line anchor can never match the raw diff
57
+ // text — compare whitespace-collapsed forms with diff markers stripped, or
58
+ // legitimate multi-line anchors are systematically false-demoted.
59
+ if (!diff.includes(anchor) && !normalizeForAnchorMatch(diff).includes(normalizeForAnchorMatch(anchor))) {
60
+ return {
61
+ verified: false,
62
+ reason: `changedCodeAnchor "${anchor}" does not occur verbatim in the PR diff, so the discriminator claim is not grounded in the actual change.`,
63
+ };
64
+ }
65
+ return { verified: true };
66
+ }
67
+ /** Strip per-line diff +/- markers and collapse all whitespace runs to single
68
+ * spaces so anchors spanning wrapped/indented lines still match. */
69
+ function normalizeForAnchorMatch(text) {
70
+ return text
71
+ .split("\n")
72
+ .map((line) => line.replace(/^[+\- ]/, ""))
73
+ .join(" ")
74
+ .replace(/\s+/g, " ")
75
+ .trim();
76
+ }
77
+ // ── Structural predicates ──
78
+ /**
79
+ * boundary_equality: some step carries two DISTINCT fields holding EQUAL values
80
+ * in requestBody/queryParams (probing an on-boundary case, e.g.
81
+ * install_date == termination_date) AND that step is either an accept-side probe
82
+ * (2xx) or a mirrored reject-side probe (4xx with interactionType "error").
83
+ */
84
+ function verifyBoundaryEquality(steps) {
85
+ for (const step of steps) {
86
+ const leaves = [];
87
+ collectLeaves(step?.requestBody, leaves);
88
+ collectLeaves(step?.queryParams, leaves);
89
+ if (hasDistinctFieldEqualValue(leaves) && satisfiesBoundaryStatus(step)) {
90
+ return { verified: true };
91
+ }
92
+ }
93
+ return {
94
+ verified: false,
95
+ reason: "boundary_equality unverified: no step sets two distinct fields to an equal value on an accept-side (2xx) or mirrored reject-side (4xx error) request — the on-boundary case is not exercised.",
96
+ };
97
+ }
98
+ /**
99
+ * multi_record_aggregate: >= 2 POST steps on the same (param-normalized) resource
100
+ * path occurring before an asserting step, OR any step asserting a numeric value
101
+ * >= 2 in expectedResponseFields/bodyMustInclude (e.g. account_count == 2).
102
+ */
103
+ function verifyMultiRecordAggregate(steps) {
104
+ const ordered = orderedSteps(steps);
105
+ // (b) any step asserting a numeric value >= 2.
106
+ for (const step of ordered) {
107
+ const assertions = [...toStringArray(step?.expectedResponseFields), ...toStringArray(step?.bodyMustInclude)];
108
+ if (assertions.some((s) => maxNumberIn(s) >= 2))
109
+ return { verified: true };
110
+ }
111
+ // (a) >= 2 same-path POSTs before an asserting step.
112
+ for (let i = 0; i < ordered.length; i++) {
113
+ if (!isAssertStep(ordered[i]))
114
+ continue;
115
+ const counts = new Map();
116
+ for (let j = 0; j < i; j++) {
117
+ const prior = ordered[j];
118
+ if ((prior?.method ?? "").toUpperCase() !== "POST")
119
+ continue;
120
+ const key = normalizePath(prior?.path ?? "");
121
+ const next = (counts.get(key) ?? 0) + 1;
122
+ counts.set(key, next);
123
+ if (next >= 2)
124
+ return { verified: true };
125
+ }
126
+ }
127
+ return {
128
+ verified: false,
129
+ reason: "multi_record_aggregate unverified: found neither >= 2 same-resource POST steps before an assertion nor an assertion of a numeric count >= 2 — the aggregation over multiple records is not exercised.",
130
+ };
131
+ }
132
+ /**
133
+ * negative_match: a step asserting empty/zero/404 whose query/body carries a
134
+ * value that is a strict substring or superstring of a value seeded in an
135
+ * earlier step (a near-miss that must NOT match).
136
+ */
137
+ function verifyNegativeMatch(steps) {
138
+ const ordered = orderedSteps(steps);
139
+ for (let i = 0; i < ordered.length; i++) {
140
+ const step = ordered[i];
141
+ if (!isNegativeAssertStep(step))
142
+ continue;
143
+ const nearMissValues = [];
144
+ collectStringValues(step?.requestBody, nearMissValues);
145
+ collectStringValues(step?.queryParams, nearMissValues);
146
+ const seeded = [];
147
+ for (let j = 0; j < i; j++) {
148
+ collectStringValues(ordered[j]?.requestBody, seeded);
149
+ collectStringValues(ordered[j]?.queryParams, seeded);
150
+ }
151
+ if (nearMissValues.some((q) => seeded.some((s) => isStrictNearMiss(q, s)))) {
152
+ return { verified: true };
153
+ }
154
+ }
155
+ return {
156
+ verified: false,
157
+ reason: "negative_match unverified: no empty/zero/404 assertion queries a strict near-miss (substring or superstring) of a value seeded in an earlier step — the false-positive boundary is not probed.",
158
+ };
159
+ }
160
+ /**
161
+ * state_transition: >= 2 mutating steps (POST/PUT/PATCH/DELETE) on the same
162
+ * resource — same param-normalized path, or linked via chainsFrom — with a
163
+ * DIFFERENT expectedStatusCode or interactionType.
164
+ */
165
+ function verifyStateTransition(steps) {
166
+ const mutating = steps.filter((s) => MUTATING_METHODS.has((s?.method ?? "").toUpperCase()));
167
+ // Same normalized path.
168
+ const groups = new Map();
169
+ for (const step of mutating) {
170
+ const key = normalizePath(step?.path ?? "");
171
+ if (!groups.has(key))
172
+ groups.set(key, []);
173
+ groups.get(key).push(step);
174
+ }
175
+ for (const group of groups.values()) {
176
+ if (group.length >= 2 && groupHasDivergentOutcome(group))
177
+ return { verified: true };
178
+ }
179
+ // Linked via chainsFrom (a mutating step chaining off another mutating step).
180
+ const byOrder = new Map();
181
+ for (const step of steps) {
182
+ if (typeof step?.order === "number")
183
+ byOrder.set(step.order, step);
184
+ }
185
+ for (const step of mutating) {
186
+ for (const sourceStep of toChainSourceSteps(step?.chainsFrom)) {
187
+ const src = byOrder.get(sourceStep);
188
+ if (src &&
189
+ MUTATING_METHODS.has((src.method ?? "").toUpperCase()) &&
190
+ (src.expectedStatusCode !== step.expectedStatusCode || src.interactionType !== step.interactionType)) {
191
+ return { verified: true };
192
+ }
193
+ }
194
+ }
195
+ return {
196
+ verified: false,
197
+ reason: "state_transition unverified: no two mutating steps on the same resource diverge in expectedStatusCode or interactionType — the state machine's differing outcomes are not exercised.",
198
+ };
199
+ }
200
+ /** Recursively collect leaf (field, value) pairs for string/number values. */
201
+ function collectLeaves(value, out) {
202
+ if (value === null || value === undefined || typeof value !== "object")
203
+ return;
204
+ if (Array.isArray(value)) {
205
+ for (const item of value)
206
+ collectLeaves(item, out);
207
+ return;
208
+ }
209
+ for (const [key, v] of Object.entries(value)) {
210
+ if (v === null || v === undefined)
211
+ continue;
212
+ if (typeof v === "object") {
213
+ collectLeaves(v, out);
214
+ }
215
+ else if (typeof v === "string" || typeof v === "number") {
216
+ out.push({ field: key, value: v });
217
+ }
218
+ }
219
+ }
220
+ /** Recursively collect leaf string values (used for near-miss substring scans). */
221
+ function collectStringValues(value, out) {
222
+ if (value === null || value === undefined)
223
+ return;
224
+ if (typeof value === "string") {
225
+ if (value.length > 0)
226
+ out.push(value);
227
+ return;
228
+ }
229
+ if (typeof value !== "object")
230
+ return;
231
+ if (Array.isArray(value)) {
232
+ for (const item of value)
233
+ collectStringValues(item, out);
234
+ return;
235
+ }
236
+ for (const v of Object.values(value))
237
+ collectStringValues(v, out);
238
+ }
239
+ /** True if two leaves have different field names but an equal (non-trivial) value. */
240
+ function hasDistinctFieldEqualValue(leaves) {
241
+ for (let a = 0; a < leaves.length; a++) {
242
+ for (let b = a + 1; b < leaves.length; b++) {
243
+ const la = leaves[a];
244
+ const lb = leaves[b];
245
+ if (la.field === lb.field)
246
+ continue;
247
+ if (la.value !== lb.value)
248
+ continue;
249
+ if (typeof la.value === "string" && la.value.length === 0)
250
+ continue;
251
+ return true;
252
+ }
253
+ }
254
+ return false;
255
+ }
256
+ function satisfiesBoundaryStatus(step) {
257
+ const status = typeof step?.expectedStatusCode === "number" ? step.expectedStatusCode : 0;
258
+ const accept = status >= 200 && status < 300;
259
+ const reject = status >= 400 && status < 500 && step?.interactionType === "error";
260
+ return accept || reject;
261
+ }
262
+ function isAssertStep(step) {
263
+ return (toStringArray(step?.expectedResponseFields).length > 0 ||
264
+ toStringArray(step?.bodyMustInclude).length > 0 ||
265
+ (step?.responseBody !== null && typeof step?.responseBody === "object" && Object.keys(step.responseBody).length > 0));
266
+ }
267
+ function isNegativeAssertStep(step) {
268
+ if (step?.expectedStatusCode === 404)
269
+ return true;
270
+ const assertions = [...toStringArray(step?.expectedResponseFields), ...toStringArray(step?.bodyMustInclude)];
271
+ return assertsEmptyOrZero(assertions);
272
+ }
273
+ function assertsEmptyOrZero(strings) {
274
+ return strings.some((raw) => {
275
+ const s = raw.toLowerCase();
276
+ if (/\bcount\b/.test(s) && /\b0\b/.test(s))
277
+ return true;
278
+ if (/(^|\D)0\s+results?\b/.test(s))
279
+ return true;
280
+ if (s.includes("empty"))
281
+ return true;
282
+ if (s.includes("no results") || s.includes("no result"))
283
+ return true;
284
+ if (s.includes("[]"))
285
+ return true;
286
+ return false;
287
+ });
288
+ }
289
+ /** Strict near-miss: one string strictly contains the other (different length). */
290
+ function isStrictNearMiss(a, b) {
291
+ if (a === b)
292
+ return false;
293
+ if (Math.min(a.length, b.length) < MIN_NEAR_MISS_LENGTH)
294
+ return false;
295
+ return a.includes(b) || b.includes(a);
296
+ }
297
+ function groupHasDivergentOutcome(group) {
298
+ const statuses = new Set(group.map((s) => s?.expectedStatusCode));
299
+ const interactions = new Set(group.map((s) => s?.interactionType));
300
+ return statuses.size > 1 || interactions.size > 1;
301
+ }
302
+ /** Largest number appearing in a string, or -Infinity when none. */
303
+ function maxNumberIn(text) {
304
+ const matches = text.match(/-?\d+(?:\.\d+)?/g);
305
+ if (!matches)
306
+ return Number.NEGATIVE_INFINITY;
307
+ return Math.max(...matches.map(Number));
308
+ }
309
+ /**
310
+ * Normalize a path so param segments collapse: numeric ids, `{brace}` params,
311
+ * `:colon` params and long hex/uuid segments all become `:p`.
312
+ */
313
+ function normalizePath(path) {
314
+ const pathPart = (path ?? "").split("?")[0];
315
+ return pathPart
316
+ .split("/")
317
+ .map((seg) => {
318
+ if (seg === "")
319
+ return seg;
320
+ if (/^\{.*\}$/.test(seg))
321
+ return ":p";
322
+ if (/^:.+/.test(seg))
323
+ return ":p";
324
+ if (/^\d+$/.test(seg))
325
+ return ":p";
326
+ if (/^[0-9a-fA-F-]{16,}$/.test(seg))
327
+ return ":p";
328
+ return seg.toLowerCase();
329
+ })
330
+ .join("/");
331
+ }
332
+ /** Steps sorted by their `order` field, falling back to array position. */
333
+ function orderedSteps(steps) {
334
+ return steps
335
+ .map((step, index) => ({ step, index }))
336
+ .sort((a, b) => {
337
+ const ao = typeof a.step?.order === "number" ? a.step.order : a.index;
338
+ const bo = typeof b.step?.order === "number" ? b.step.order : b.index;
339
+ if (ao !== bo)
340
+ return ao - bo;
341
+ return a.index - b.index;
342
+ })
343
+ .map((entry) => entry.step);
344
+ }
345
+ function toStringArray(value) {
346
+ if (!Array.isArray(value))
347
+ return [];
348
+ return value.filter((v) => typeof v === "string");
349
+ }
350
+ function toChainSourceSteps(chainsFrom) {
351
+ if (!chainsFrom)
352
+ return [];
353
+ const refs = Array.isArray(chainsFrom) ? chainsFrom : [chainsFrom];
354
+ return refs.map((r) => r?.sourceStep).filter((s) => typeof s === "number");
355
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,324 @@
1
+ import { validateDiscriminator } from "./discriminators.js";
2
+ import { DiscriminatorKind } from "../types/Recommendation.js";
3
+ // A diff containing the verbatim anchors each valid claim below points at.
4
+ const DIFF = [
5
+ "@@ netbox/circuits/models.py",
6
+ "+ if install_date == termination_date:",
7
+ "+ raise ValidationError('same-day circuit')",
8
+ "+ provider.account_count = len(accounts)",
9
+ "+ results = Provider.objects.filter(physical_address=query)",
10
+ '+ circuit.status = "active"',
11
+ ].join("\n");
12
+ function step(over = {}) {
13
+ return {
14
+ order: 0,
15
+ method: "GET",
16
+ path: "/r",
17
+ description: "",
18
+ interactionType: "success",
19
+ expectedStatusCode: 200,
20
+ ...over,
21
+ };
22
+ }
23
+ function scenario(steps) {
24
+ return {
25
+ scenarioName: "s",
26
+ description: "s",
27
+ category: "business_rule",
28
+ priority: "high",
29
+ steps,
30
+ chainingKeys: [],
31
+ requiresAuth: false,
32
+ estimatedComplexity: "moderate",
33
+ };
34
+ }
35
+ const claim = (kind, anchor) => ({
36
+ kind,
37
+ changedCodeAnchor: anchor,
38
+ });
39
+ describe("validateDiscriminator — boundary_equality", () => {
40
+ const anchor = "install_date == termination_date";
41
+ it("verifies a same-day accept-side boundary (two distinct fields, equal value, 2xx)", () => {
42
+ const s = scenario([
43
+ step({
44
+ method: "POST",
45
+ path: "/api/circuits",
46
+ interactionType: "success",
47
+ expectedStatusCode: 201,
48
+ requestBody: { install_date: "2024-01-15", termination_date: "2024-01-15" },
49
+ }),
50
+ ]);
51
+ expect(validateDiscriminator(s, claim(DiscriminatorKind.BOUNDARY_EQUALITY, anchor), DIFF).verified).toBe(true);
52
+ });
53
+ it("verifies the mirrored reject side (4xx + interactionType error)", () => {
54
+ const s = scenario([
55
+ step({
56
+ method: "POST",
57
+ path: "/api/circuits",
58
+ interactionType: "error",
59
+ expectedStatusCode: 422,
60
+ requestBody: { install_date: "2024-01-15", termination_date: "2024-01-15" },
61
+ }),
62
+ ]);
63
+ expect(validateDiscriminator(s, claim(DiscriminatorKind.BOUNDARY_EQUALITY, anchor), DIFF).verified).toBe(true);
64
+ });
65
+ it("demotes a gaming attempt (happy-path GET, no equal-value fields)", () => {
66
+ const s = scenario([step({ method: "GET", path: "/api/circuits", expectedStatusCode: 200 })]);
67
+ const res = validateDiscriminator(s, claim(DiscriminatorKind.BOUNDARY_EQUALITY, anchor), DIFF);
68
+ expect(res.verified).toBe(false);
69
+ expect(res.reason).toMatch(/boundary_equality/);
70
+ });
71
+ it("returns unverified (no throw) on malformed input with no steps", () => {
72
+ const res = validateDiscriminator({}, claim(DiscriminatorKind.BOUNDARY_EQUALITY, anchor), DIFF);
73
+ expect(res.verified).toBe(false);
74
+ expect(res.reason).toBeDefined();
75
+ });
76
+ it("demotes when the anchor is absent from the diff", () => {
77
+ const s = scenario([
78
+ step({
79
+ method: "POST",
80
+ path: "/api/circuits",
81
+ expectedStatusCode: 201,
82
+ requestBody: { install_date: "2024-01-15", termination_date: "2024-01-15" },
83
+ }),
84
+ ]);
85
+ const res = validateDiscriminator(s, claim(DiscriminatorKind.BOUNDARY_EQUALITY, "absent_anchor_symbol"), DIFF);
86
+ expect(res.verified).toBe(false);
87
+ expect(res.reason).toMatch(/absent_anchor_symbol/);
88
+ expect(res.reason).toMatch(/diff/);
89
+ });
90
+ });
91
+ describe("validateDiscriminator — multi_record_aggregate", () => {
92
+ const anchor = "provider.account_count = len(accounts)";
93
+ it("verifies an explicit numeric-count assertion (account_count == 2)", () => {
94
+ const s = scenario([
95
+ step({ order: 0, method: "POST", path: "/api/providers", expectedStatusCode: 201 }),
96
+ step({
97
+ order: 1,
98
+ method: "GET",
99
+ path: "/api/providers/summary",
100
+ expectedStatusCode: 200,
101
+ expectedResponseFields: ["account_count == 2"],
102
+ }),
103
+ ]);
104
+ expect(validateDiscriminator(s, claim(DiscriminatorKind.MULTI_RECORD_AGGREGATE, anchor), DIFF).verified).toBe(true);
105
+ });
106
+ it("verifies >= 2 same-path POSTs before an asserting step", () => {
107
+ const s = scenario([
108
+ step({ order: 0, method: "POST", path: "/api/providers/1/accounts", expectedStatusCode: 201 }),
109
+ step({ order: 1, method: "POST", path: "/api/providers/1/accounts", expectedStatusCode: 201 }),
110
+ step({
111
+ order: 2,
112
+ method: "GET",
113
+ path: "/api/providers/1/summary",
114
+ expectedStatusCode: 200,
115
+ expectedResponseFields: ["accounts"],
116
+ }),
117
+ ]);
118
+ expect(validateDiscriminator(s, claim(DiscriminatorKind.MULTI_RECORD_AGGREGATE, anchor), DIFF).verified).toBe(true);
119
+ });
120
+ it("demotes a gaming attempt (single create + read, count 1)", () => {
121
+ const s = scenario([
122
+ step({ order: 0, method: "POST", path: "/api/providers", expectedStatusCode: 201 }),
123
+ step({
124
+ order: 1,
125
+ method: "GET",
126
+ path: "/api/providers/1",
127
+ expectedStatusCode: 200,
128
+ expectedResponseFields: ["name"],
129
+ }),
130
+ ]);
131
+ const res = validateDiscriminator(s, claim(DiscriminatorKind.MULTI_RECORD_AGGREGATE, anchor), DIFF);
132
+ expect(res.verified).toBe(false);
133
+ expect(res.reason).toMatch(/multi_record_aggregate/);
134
+ });
135
+ it("returns unverified (no throw) on malformed input with null steps", () => {
136
+ const res = validateDiscriminator({ steps: null }, claim(DiscriminatorKind.MULTI_RECORD_AGGREGATE, anchor), DIFF);
137
+ expect(res.verified).toBe(false);
138
+ });
139
+ it("demotes when the anchor is absent from the diff", () => {
140
+ const s = scenario([
141
+ step({ method: "GET", path: "/x", expectedStatusCode: 200, expectedResponseFields: ["account_count == 2"] }),
142
+ ]);
143
+ expect(validateDiscriminator(s, claim(DiscriminatorKind.MULTI_RECORD_AGGREGATE, "not_in_diff_anchor"), DIFF).verified).toBe(false);
144
+ });
145
+ });
146
+ describe("validateDiscriminator — negative_match", () => {
147
+ const anchor = "physical_address=query";
148
+ it("verifies a substring near-miss asserting empty results", () => {
149
+ const s = scenario([
150
+ step({
151
+ order: 0,
152
+ method: "POST",
153
+ path: "/api/providers",
154
+ expectedStatusCode: 201,
155
+ requestBody: { physical_address: "123 Main Street, Building A" },
156
+ }),
157
+ step({
158
+ order: 1,
159
+ method: "GET",
160
+ path: "/api/providers",
161
+ expectedStatusCode: 200,
162
+ queryParams: { physical_address: "123 Main Street" },
163
+ expectedResponseFields: ["count == 0"],
164
+ }),
165
+ ]);
166
+ expect(validateDiscriminator(s, claim(DiscriminatorKind.NEGATIVE_MATCH, anchor), DIFF).verified).toBe(true);
167
+ });
168
+ it("verifies a 404 near-miss lookup", () => {
169
+ const s = scenario([
170
+ step({
171
+ order: 0,
172
+ method: "POST",
173
+ path: "/api/providers",
174
+ expectedStatusCode: 201,
175
+ requestBody: { slug: "acme-corp-provider" },
176
+ }),
177
+ step({
178
+ order: 1,
179
+ method: "GET",
180
+ path: "/api/providers/acme-corp",
181
+ expectedStatusCode: 404,
182
+ interactionType: "error",
183
+ queryParams: { slug: "acme-corp" },
184
+ }),
185
+ ]);
186
+ expect(validateDiscriminator(s, claim(DiscriminatorKind.NEGATIVE_MATCH, anchor), DIFF).verified).toBe(true);
187
+ });
188
+ it("demotes a gaming attempt (exact match, non-empty 200 result)", () => {
189
+ const s = scenario([
190
+ step({
191
+ order: 0,
192
+ method: "POST",
193
+ path: "/api/providers",
194
+ expectedStatusCode: 201,
195
+ requestBody: { physical_address: "123 Main Street" },
196
+ }),
197
+ step({
198
+ order: 1,
199
+ method: "GET",
200
+ path: "/api/providers",
201
+ expectedStatusCode: 200,
202
+ queryParams: { physical_address: "123 Main Street" },
203
+ expectedResponseFields: ["results"],
204
+ }),
205
+ ]);
206
+ const res = validateDiscriminator(s, claim(DiscriminatorKind.NEGATIVE_MATCH, anchor), DIFF);
207
+ expect(res.verified).toBe(false);
208
+ expect(res.reason).toMatch(/negative_match/);
209
+ });
210
+ it("returns unverified (no throw) on malformed input with no steps", () => {
211
+ const res = validateDiscriminator({}, claim(DiscriminatorKind.NEGATIVE_MATCH, anchor), DIFF);
212
+ expect(res.verified).toBe(false);
213
+ });
214
+ it("demotes when the anchor is absent from the diff", () => {
215
+ const s = scenario([
216
+ step({ method: "GET", path: "/x", expectedStatusCode: 404, queryParams: { q: "abc" } }),
217
+ ]);
218
+ expect(validateDiscriminator(s, claim(DiscriminatorKind.NEGATIVE_MATCH, "missing_anchor_xyz"), DIFF).verified).toBe(false);
219
+ });
220
+ });
221
+ describe("validateDiscriminator — state_transition", () => {
222
+ const anchor = 'circuit.status = "active"';
223
+ it("verifies two same-resource mutations with divergent outcomes", () => {
224
+ const s = scenario([
225
+ step({
226
+ order: 0,
227
+ method: "PATCH",
228
+ path: "/api/circuits/5",
229
+ interactionType: "success",
230
+ expectedStatusCode: 200,
231
+ requestBody: { status: "active" },
232
+ }),
233
+ step({
234
+ order: 1,
235
+ method: "DELETE",
236
+ path: "/api/circuits/5",
237
+ interactionType: "error",
238
+ expectedStatusCode: 409,
239
+ }),
240
+ ]);
241
+ expect(validateDiscriminator(s, claim(DiscriminatorKind.STATE_TRANSITION, anchor), DIFF).verified).toBe(true);
242
+ });
243
+ it("verifies divergent mutations linked via chainsFrom", () => {
244
+ const s = scenario([
245
+ step({
246
+ order: 0,
247
+ method: "POST",
248
+ path: "/api/circuits",
249
+ interactionType: "success",
250
+ expectedStatusCode: 201,
251
+ }),
252
+ step({
253
+ order: 1,
254
+ method: "PATCH",
255
+ path: "/api/circuits/{id}",
256
+ interactionType: "success",
257
+ expectedStatusCode: 200,
258
+ chainsFrom: { sourceStep: 0, sourceField: "id", sourceLocation: "body", targetParam: "id", targetLocation: "path" },
259
+ }),
260
+ ]);
261
+ expect(validateDiscriminator(s, claim(DiscriminatorKind.STATE_TRANSITION, anchor), DIFF).verified).toBe(true);
262
+ });
263
+ it("demotes a gaming attempt (two identical-outcome POSTs)", () => {
264
+ const s = scenario([
265
+ step({ order: 0, method: "POST", path: "/api/circuits", interactionType: "success", expectedStatusCode: 201 }),
266
+ step({ order: 1, method: "POST", path: "/api/circuits", interactionType: "success", expectedStatusCode: 201 }),
267
+ ]);
268
+ const res = validateDiscriminator(s, claim(DiscriminatorKind.STATE_TRANSITION, anchor), DIFF);
269
+ expect(res.verified).toBe(false);
270
+ expect(res.reason).toMatch(/state_transition/);
271
+ });
272
+ it("returns unverified (no throw) on malformed input with no steps", () => {
273
+ const res = validateDiscriminator({}, claim(DiscriminatorKind.STATE_TRANSITION, anchor), DIFF);
274
+ expect(res.verified).toBe(false);
275
+ });
276
+ it("demotes when the anchor is absent from the diff", () => {
277
+ const s = scenario([
278
+ step({ method: "PATCH", path: "/api/circuits/5", expectedStatusCode: 200 }),
279
+ step({ method: "DELETE", path: "/api/circuits/5", expectedStatusCode: 409, interactionType: "error" }),
280
+ ]);
281
+ expect(validateDiscriminator(s, claim(DiscriminatorKind.STATE_TRANSITION, "anchor_not_present"), DIFF).verified).toBe(false);
282
+ });
283
+ });
284
+ describe("validateDiscriminator — anchor gate", () => {
285
+ it("demotes a too-short anchor before any structural check", () => {
286
+ const s = scenario([step()]);
287
+ const res = validateDiscriminator(s, claim(DiscriminatorKind.BOUNDARY_EQUALITY, "short"), DIFF);
288
+ expect(res.verified).toBe(false);
289
+ expect(res.reason).toMatch(/too short/);
290
+ });
291
+ it("never throws on wholly malformed input", () => {
292
+ expect(() => validateDiscriminator(undefined, undefined, DIFF)).not.toThrow();
293
+ const res = validateDiscriminator(undefined, undefined, DIFF);
294
+ expect(res.verified).toBe(false);
295
+ });
296
+ });
297
+ describe("verifyAnchor whitespace normalization (seed-2 multi-line anchor regression)", () => {
298
+ const multiLineDiff = [
299
+ "--- a/circuits.py",
300
+ "+++ b/circuits.py",
301
+ "+ raise ValidationError({",
302
+ "+ 'termination_date': _(",
303
+ "+ \"Termination date cannot precede install date.\")",
304
+ "+ })",
305
+ ].join("\n");
306
+ it("verifies an anchor that spans wrapped diff lines (collapsed whitespace, markers stripped)", () => {
307
+ const s = scenario([
308
+ step({ requestBody: { install_date: "2024-06-01", termination_date: "2024-06-01" }, expectedStatusCode: 201, interactionType: "success" }),
309
+ ]);
310
+ const result = validateDiscriminator(s, {
311
+ kind: DiscriminatorKind.BOUNDARY_EQUALITY,
312
+ changedCodeAnchor: "raise ValidationError({ 'termination_date': _( \"Termination date cannot precede install date.\") })",
313
+ }, multiLineDiff);
314
+ expect(result.verified).toBe(true);
315
+ });
316
+ it("still rejects an anchor that occurs nowhere in the diff", () => {
317
+ const s = scenario([
318
+ step({ requestBody: { install_date: "2024-06-01", termination_date: "2024-06-01" }, expectedStatusCode: 201, interactionType: "success" }),
319
+ ]);
320
+ const result = validateDiscriminator(s, { kind: DiscriminatorKind.BOUNDARY_EQUALITY, changedCodeAnchor: "this snippet is not in the diff at all" }, multiLineDiff);
321
+ expect(result.verified).toBe(false);
322
+ expect(result.reason).toContain("does not occur verbatim");
323
+ });
324
+ });