@skyramp/mcp 0.3.6 → 0.3.8

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 (112) hide show
  1. package/build/prompts/code-reuse.js +17 -2
  2. package/build/prompts/enhance-assertions/sharedAssertionRules.js +1 -1
  3. package/build/prompts/modularization/ui-test-modularization.js +9 -6
  4. package/build/prompts/pom-aware-code-reuse.js +1 -1
  5. package/build/prompts/shared-helper-policy.js +5 -5
  6. package/build/prompts/test-recommendation/diffExecutionPlan.js +40 -9
  7. package/build/prompts/test-recommendation/fullRepoCatalog.js +3 -2
  8. package/build/prompts/test-recommendation/recommendationSections.js +3 -3
  9. package/build/prompts/test-recommendation/scopeAssessment.d.ts +2 -2
  10. package/build/prompts/test-recommendation/scopeAssessment.js +58 -76
  11. package/build/prompts/test-recommendation/test-recommendation-prompt.js +14 -1
  12. package/build/prompts/testbot/testbot-prompts.js +25 -8
  13. package/build/recommendation/budgeters/diversityBalancedBudgeter.js +39 -5
  14. package/build/recommendation/budgeters/shared.d.ts +21 -8
  15. package/build/recommendation/budgeters/shared.js +134 -56
  16. package/build/recommendation/planRanker.d.ts +5 -3
  17. package/build/recommendation/planRanker.js +1 -1
  18. package/build/services/TestGenerationService.js +15 -1
  19. package/build/tools/code-refactor/assertion-state.d.ts +91 -0
  20. package/build/tools/code-refactor/assertion-state.js +375 -0
  21. package/build/tools/code-refactor/codeReuseTool.js +6 -4
  22. package/build/tools/code-refactor/enhanceAssertionsTool.js +73 -18
  23. package/build/tools/code-refactor/retrofit-state.d.ts +53 -0
  24. package/build/tools/code-refactor/retrofit-state.js +162 -0
  25. package/build/tools/code-refactor/reuse-outcome.d.ts +7 -0
  26. package/build/tools/code-refactor/reuse-state.d.ts +9 -0
  27. package/build/tools/code-refactor/reuse-state.js +42 -4
  28. package/build/tools/code-refactor/utils-verify-gates.js +69 -15
  29. package/build/tools/executeSkyrampTestTool.js +19 -14
  30. package/build/tools/submitReportTool.js +87 -9
  31. package/build/tools/test-management/registerTestPlanTool.d.ts +3 -3
  32. package/build/tools/test-management/registerTestPlanTool.js +29 -8
  33. package/build/types/AssertionOutcome.d.ts +68 -0
  34. package/build/types/AssertionOutcome.js +1 -0
  35. package/build/types/RepositoryAnalysis.d.ts +10 -10
  36. package/build/types/ReuseOutcome.d.ts +16 -0
  37. package/build/types/TestRecommendation.d.ts +11 -1
  38. package/build/types/TestRecommendation.js +34 -0
  39. package/build/types/TestTypes.d.ts +4 -0
  40. package/build/types/TestTypes.js +8 -0
  41. package/build/types/TestbotReport.d.ts +13 -0
  42. package/build/types/index.d.ts +1 -1
  43. package/build/utils/AnalysisStateManager.d.ts +20 -7
  44. package/build/utils/assertion-verify/api-shared-lints.d.ts +5 -0
  45. package/build/utils/assertion-verify/api-shared-lints.js +315 -0
  46. package/build/utils/assertion-verify/contract-lints.d.ts +3 -0
  47. package/build/utils/assertion-verify/contract-lints.js +87 -0
  48. package/build/utils/assertion-verify/format.d.ts +5 -0
  49. package/build/utils/assertion-verify/format.js +65 -0
  50. package/build/utils/assertion-verify/helper-imports.d.ts +6 -0
  51. package/build/utils/assertion-verify/helper-imports.js +178 -0
  52. package/build/utils/assertion-verify/index.d.ts +3 -0
  53. package/build/utils/assertion-verify/index.js +7 -0
  54. package/build/utils/assertion-verify/integration-lints.d.ts +3 -0
  55. package/build/utils/assertion-verify/integration-lints.js +36 -0
  56. package/build/utils/assertion-verify/js-regex-blank.d.ts +1 -0
  57. package/build/utils/assertion-verify/js-regex-blank.js +153 -0
  58. package/build/utils/assertion-verify/lint-types.d.ts +33 -0
  59. package/build/utils/assertion-verify/lint-types.js +57 -0
  60. package/build/utils/assertion-verify/marker.d.ts +27 -0
  61. package/build/utils/assertion-verify/marker.js +61 -0
  62. package/build/utils/assertion-verify/metrics.d.ts +30 -0
  63. package/build/utils/assertion-verify/metrics.js +341 -0
  64. package/build/utils/assertion-verify/python-strip.d.ts +6 -0
  65. package/build/utils/assertion-verify/python-strip.js +75 -0
  66. package/build/utils/assertion-verify/strip-dispatch.d.ts +19 -0
  67. package/build/utils/assertion-verify/strip-dispatch.js +42 -0
  68. package/build/utils/assertion-verify/ui-lints.d.ts +8 -0
  69. package/build/utils/assertion-verify/ui-lints.js +244 -0
  70. package/build/utils/assertion-verify/verify.d.ts +61 -0
  71. package/build/utils/assertion-verify/verify.js +215 -0
  72. package/build/utils/featureFlags.d.ts +12 -2
  73. package/build/utils/featureFlags.js +33 -3
  74. package/build/utils/pathSignatures.d.ts +4 -1
  75. package/build/utils/pathSignatures.js +14 -2
  76. package/build/utils/planOnlyMode.d.ts +33 -0
  77. package/build/utils/planOnlyMode.js +40 -0
  78. package/build/utils/reportVerification.d.ts +9 -0
  79. package/build/utils/reportVerification.js +39 -6
  80. package/build/utils/scenarioDrafting.d.ts +1 -1
  81. package/build/utils/scenarioDrafting.js +1 -1
  82. package/build/utils/utils-verify/allow.d.ts +22 -4
  83. package/build/utils/utils-verify/allow.js +8 -2
  84. package/build/utils/utils-verify/call-sites.d.ts +40 -1
  85. package/build/utils/utils-verify/call-sites.js +196 -30
  86. package/build/utils/utils-verify/importers.d.ts +31 -0
  87. package/build/utils/utils-verify/importers.js +78 -0
  88. package/build/utils/utils-verify/index.d.ts +1 -0
  89. package/build/utils/utils-verify/index.js +1 -0
  90. package/build/utils/utils-verify/language-spec.d.ts +13 -2
  91. package/build/utils/utils-verify/language-spec.js +12 -2
  92. package/build/utils/utils-verify/parse.d.ts +31 -3
  93. package/build/utils/utils-verify/parse.js +190 -9
  94. package/build/utils/utils-verify/retrofit-equivalence.d.ts +43 -0
  95. package/build/utils/utils-verify/retrofit-equivalence.js +218 -0
  96. package/build/utils/utils-verify/stage.d.ts +6 -0
  97. package/build/utils/utils-verify/stage.js +12 -2
  98. package/build/utils/utils-verify/verify.d.ts +54 -4
  99. package/build/utils/utils-verify/verify.js +224 -12
  100. package/node_modules/playwright/node_modules/playwright-core/lib/generated/injectedScriptSource.js +1 -1
  101. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-CZfp96qZ.js → codeMirrorModule-LNgEKtdV.js} +1 -1
  102. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-gpLo02E0.js → defaultSettingsView-Bwr1eMKC.js} +135 -135
  103. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.Bq1r1URj.js → index.-Id052Lr.js} +1 -1
  104. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  105. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.VEfqi1qN.js → uiMode.BPopbasy.js} +1 -1
  106. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  107. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  108. package/node_modules/playwright/node_modules/playwright-core/src/generated/injectedScriptSource.ts +1 -1
  109. package/node_modules/playwright/package.json +1 -1
  110. package/package.json +2 -2
  111. package/build/tools/code-refactor/enhance-state.d.ts +0 -49
  112. package/build/tools/code-refactor/enhance-state.js +0 -109
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Plan-only mode capture for `skyramp_submit_report` (SKYR-4250).
3
+ *
4
+ * A plan-only eval run (SKYR-3879) writes no test files: `newTestsCreated`
5
+ * DECLARES the approved GENERATE selection instead of recording generated
6
+ * files, and the declaration itself is the deliverable. The SKYR-3883 guard
7
+ * (`findUnchangedFileClaims`) verifies every `newTestsCreated` claim against
8
+ * the working tree, so in that lane EVERY declaration is unbacked by
9
+ * construction and the report is rejected.
10
+ *
11
+ * That rejection is not evenly distributed. `findUnchangedFileClaims` exempts a
12
+ * claim attributed to a non-primary repo, so on a cross-repo fixture the
13
+ * related repo's declarations pass and only the primary repo's are checked.
14
+ * On a frontend-primary pair that makes the mandatory UI item (SKYR-4204's
15
+ * floor) the one claim that always fails, and the agent's only compliant move
16
+ * is to demote it to `additionalRecommendations` — which is exactly the
17
+ * under-declaration SKYR-4250 exists to fix (run 32903633247).
18
+ *
19
+ * `planOnly` reaches the server only as a testbot prompt/resource arg, so this
20
+ * mirrors the SKYR-4185 report-language capture: the prompt render is the
21
+ * single capture point, and the report tool reads the captured mode later in
22
+ * the same process.
23
+ *
24
+ * Capture is fail-open by design: if the testbot prompt was never served, the
25
+ * mode stays false and the guard behaves exactly as it does today.
26
+ */
27
+ /** Capture plan-only mode at prompt-serve time. Last render wins: a later
28
+ * non-plan-only render disarms a mode captured earlier in a long-lived server
29
+ * process, so an ordinary run never silently skips the guard. */
30
+ export declare function setPlanOnlyMode(planOnly: boolean): void;
31
+ export declare function isPlanOnlyMode(): boolean;
32
+ /** Test isolation only — module state persists across tests in one process. */
33
+ export declare function resetPlanOnlyMode(): void;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Plan-only mode capture for `skyramp_submit_report` (SKYR-4250).
3
+ *
4
+ * A plan-only eval run (SKYR-3879) writes no test files: `newTestsCreated`
5
+ * DECLARES the approved GENERATE selection instead of recording generated
6
+ * files, and the declaration itself is the deliverable. The SKYR-3883 guard
7
+ * (`findUnchangedFileClaims`) verifies every `newTestsCreated` claim against
8
+ * the working tree, so in that lane EVERY declaration is unbacked by
9
+ * construction and the report is rejected.
10
+ *
11
+ * That rejection is not evenly distributed. `findUnchangedFileClaims` exempts a
12
+ * claim attributed to a non-primary repo, so on a cross-repo fixture the
13
+ * related repo's declarations pass and only the primary repo's are checked.
14
+ * On a frontend-primary pair that makes the mandatory UI item (SKYR-4204's
15
+ * floor) the one claim that always fails, and the agent's only compliant move
16
+ * is to demote it to `additionalRecommendations` — which is exactly the
17
+ * under-declaration SKYR-4250 exists to fix (run 32903633247).
18
+ *
19
+ * `planOnly` reaches the server only as a testbot prompt/resource arg, so this
20
+ * mirrors the SKYR-4185 report-language capture: the prompt render is the
21
+ * single capture point, and the report tool reads the captured mode later in
22
+ * the same process.
23
+ *
24
+ * Capture is fail-open by design: if the testbot prompt was never served, the
25
+ * mode stays false and the guard behaves exactly as it does today.
26
+ */
27
+ let sessionPlanOnly = false;
28
+ /** Capture plan-only mode at prompt-serve time. Last render wins: a later
29
+ * non-plan-only render disarms a mode captured earlier in a long-lived server
30
+ * process, so an ordinary run never silently skips the guard. */
31
+ export function setPlanOnlyMode(planOnly) {
32
+ sessionPlanOnly = planOnly;
33
+ }
34
+ export function isPlanOnlyMode() {
35
+ return sessionPlanOnly;
36
+ }
37
+ /** Test isolation only — module state persists across tests in one process. */
38
+ export function resetPlanOnlyMode() {
39
+ sessionPlanOnly = false;
40
+ }
@@ -24,6 +24,10 @@ import { DriftAction } from "../types/TestAnalysis.js";
24
24
  * nothing rather than failing the whole check.
25
25
  */
26
26
  export declare function listChangedFilesAcross(roots: Array<string | undefined>): Promise<string[]>;
27
+ /** Like listChangedFilesAcross, but ABSOLUTE: each changed path resolved against
28
+ * the root it came from, so a consumer never has to guess which checkout a
29
+ * repo-relative path belongs to. */
30
+ export declare function listChangedFilesAbs(roots: Array<string | undefined>): Promise<string[]>;
27
31
  export declare function listChangedFiles(repoRoot: string): Promise<string[]>;
28
32
  export interface UnchangedFileClaimsInput {
29
33
  /** Absolute path of the primary repo checkout the report pertains to. */
@@ -46,6 +50,11 @@ export interface UnchangedFileClaimsInput {
46
50
  /** owner/repo of the primary repo — used to exempt cross-repo newTest claims
47
51
  * whose files live in a different checkout. */
48
52
  primaryRepository?: string;
53
+ /** SKYR-4250: a plan-only eval run generates nothing — `newTestsCreated`
54
+ * declares the approved GENERATE list — so no declaration can be backed by
55
+ * a working-tree change. Exempts the declaration half only; a maintenance
56
+ * UPDATE still claims a real edit and is still verified. */
57
+ planOnly?: boolean;
49
58
  }
50
59
  /**
51
60
  * Cross-check report claims against files that actually changed in the working
@@ -51,6 +51,26 @@ export async function listChangedFilesAcross(roots) {
51
51
  }
52
52
  return files;
53
53
  }
54
+ /** Like listChangedFilesAcross, but ABSOLUTE: each changed path resolved against
55
+ * the root it came from, so a consumer never has to guess which checkout a
56
+ * repo-relative path belongs to. */
57
+ export async function listChangedFilesAbs(roots) {
58
+ const seenRoots = new Set();
59
+ const files = [];
60
+ for (const root of roots) {
61
+ if (!root || seenRoots.has(root))
62
+ continue;
63
+ seenRoots.add(root);
64
+ try {
65
+ for (const f of await listChangedFiles(root))
66
+ files.push(path.resolve(root, f));
67
+ }
68
+ catch {
69
+ // Non-git or missing tree — nothing to contribute.
70
+ }
71
+ }
72
+ return files;
73
+ }
54
74
  export async function listChangedFiles(repoRoot) {
55
75
  const { stdout } = await execFileAsync("git", ["status", "--porcelain", "-z", "--untracked-files=all"], { cwd: repoRoot });
56
76
  const files = [];
@@ -104,7 +124,7 @@ export async function listChangedFiles(repoRoot) {
104
124
  * absolute path resolving outside `repoRoot`) — a different checkout owns them.
105
125
  */
106
126
  export function findUnchangedFileClaims(input) {
107
- const { repoRoot, changedFiles, newTests, verdicts, primaryRepository } = input;
127
+ const { repoRoot, changedFiles, newTests, verdicts, primaryRepository, planOnly, } = input;
108
128
  const unbacked = [];
109
129
  const isBacked = (claimedPath) => {
110
130
  let candidate = claimedPath;
@@ -117,7 +137,12 @@ export function findUnchangedFileClaims(input) {
117
137
  }
118
138
  return changedFiles.some((c) => testFileMatches(c, candidate));
119
139
  };
120
- for (const t of newTests) {
140
+ // A plan-only run declares its GENERATE list instead of writing files, so
141
+ // skip the claim scan wholesale rather than per-claim: checking it rejects
142
+ // every declaration the guard can see, and the ones it CANNOT see (cross-repo
143
+ // claims, exempted below) would pass — demoting the primary repo's items
144
+ // alone. Uneven enforcement is worse than none here.
145
+ for (const t of planOnly ? [] : newTests) {
121
146
  if (!t.fileName)
122
147
  continue;
123
148
  // Cross-repo finding — its file lives in a different checkout, not this tree.
@@ -273,7 +298,9 @@ export async function findInvalidSourceCitations(input) {
273
298
  continue; // Could not look — accept.
274
299
  const suggestions = await suggestSimilarPaths(roots, cited);
275
300
  invalid.push(`issuesFound[${i}] cites ${cited} but that file does not exist in the repository` +
276
- (suggestions.length > 0 ? ` (closest matches: ${suggestions.join(", ")})` : "") +
301
+ (suggestions.length > 0
302
+ ? ` (closest matches: ${suggestions.join(", ")})`
303
+ : "") +
277
304
  `. ${CITATION_REMEDY}`);
278
305
  continue;
279
306
  }
@@ -322,7 +349,9 @@ export async function findInvalidSourceCitations(input) {
322
349
  * A checkout the run could not name contributes nothing: it can be neither
323
350
  * stamped nor offered as a candidate. */
324
351
  function namesOf(resolved) {
325
- return [...new Set(resolved.map((c) => c.repository).filter((r) => !!r))];
352
+ return [
353
+ ...new Set(resolved.map((c) => c.repository).filter((r) => !!r)),
354
+ ];
326
355
  }
327
356
  /**
328
357
  * Reconcile where a citation resolved with what the entry says about it, giving
@@ -339,7 +368,9 @@ function attributeCitation(args) {
339
368
  // than sent back for a rewrite that would change nothing.
340
369
  const match = resolved.find((c) => c.repository && c.repository.toLowerCase() === declared.toLowerCase());
341
370
  if (match)
342
- return match.repository === declared ? {} : { repository: match.repository };
371
+ return match.repository === declared
372
+ ? {}
373
+ : { repository: match.repository };
343
374
  // It resolved somewhere else. Only say so when the run can name where —
344
375
  // otherwise there is nothing to correct the attribution to.
345
376
  if (names.length === 0)
@@ -362,5 +393,7 @@ function attributeCitation(args) {
362
393
  // Exactly one checkout. Stamp it unless it is the primary, whose findings are
363
394
  // identified downstream by having NO repository — see SourceCitationCheck.
364
395
  const [only] = resolved;
365
- return only.primary || !only.repository ? {} : { repository: only.repository };
396
+ return only.primary || !only.repository
397
+ ? {}
398
+ : { repository: only.repository };
366
399
  }
@@ -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 (bug_caught) scenarios prioritized first.
49
+ * 1. CRITICAL (bug_caught, requirement_conflict) 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.
@@ -226,7 +226,7 @@ const TIER_ORDER = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
226
226
  /**
227
227
  * Enforce a global cap on drafted scenarios while preserving category diversity.
228
228
  *
229
- * 1. CRITICAL (bug_caught) scenarios prioritized first.
229
+ * 1. CRITICAL (bug_caught, requirement_conflict) scenarios prioritized first.
230
230
  * 2. One scenario per non-empty category guaranteed (breadth).
231
231
  * 3. Remaining budget filled by priority tier (HIGH > MEDIUM > LOW).
232
232
  * 4. Hard cap at MAX_TOTAL_SCENARIOS — applied to the combined output.
@@ -1,13 +1,31 @@
1
1
  import type { UtilsLanguageSpec } from "./language-spec.js";
2
- export type UtilsViolationKind = "duplicate-helper" | "body-assertion" | "scenario-name";
3
- export declare const UTILS_VIOLATION_KINDS: readonly UtilsViolationKind[];
2
+ export type UtilsViolationKind = "duplicate-helper" | "body-assertion" | "scenario-name"
3
+ /** The delivered spec (plus the helpers it imports) holds fewer assertions than at
4
+ * the modularization hand-out. Marker-eligible: a deliberate consolidation is
5
+ * legitimate once documented. */
6
+ | "assertion-loss"
7
+ /** A page-error/console guard present at hand-out is gone. Not marker-eligible. */
8
+ | "guard-removed"
9
+ /** Python only: the spec imports the shared module in a form that cannot resolve
10
+ * in the customer's repo. Not marker-eligible — a broken import has no reason to
11
+ * stay. */
12
+ | "import-form";
13
+ /** Kinds that only ever advise. Kept OUT of UtilsViolationKind so that type keeps
14
+ * meaning "a kind the allow grammar and the gate know about" — an advisory can
15
+ * never fail a verify and never needs (or accepts) an allow marker. */
16
+ export type UtilsAdvisoryKind = "untyped-parameter";
17
+ export declare const MARKERABLE_UTILS_KINDS: readonly ["duplicate-helper", "body-assertion", "scenario-name", "assertion-loss"];
18
+ /** A kind the allow grammar accepts. `guard-removed` is a gate kind but never
19
+ * markerable — a guard is restored, not documented away — so the grammar itself
20
+ * cannot spell it; the regex below is built from this list. */
21
+ export type MarkerableUtilsKind = (typeof MARKERABLE_UTILS_KINDS)[number];
4
22
  export interface UtilsAllow {
5
- kind: UtilsViolationKind;
23
+ kind: MarkerableUtilsKind;
6
24
  helper: string;
7
25
  reason: string;
8
26
  }
9
27
  /** The exact line to paste for a violation — what the failure text hands the agent. */
10
- export declare function allowMarkerLine(spec: UtilsLanguageSpec, kind: UtilsViolationKind, helper: string): string;
28
+ export declare function allowMarkerLine(spec: UtilsLanguageSpec, kind: MarkerableUtilsKind, helper: string): string;
11
29
  export declare function parseUtilsAllows(content: string, spec: UtilsLanguageSpec): {
12
30
  allows: UtilsAllow[];
13
31
  /** Marker lines the loose pattern recognises but the grammar cannot read — a decline
@@ -1,8 +1,9 @@
1
1
  import { blankStrings } from "./parse.js";
2
- export const UTILS_VIOLATION_KINDS = [
2
+ export const MARKERABLE_UTILS_KINDS = [
3
3
  "duplicate-helper",
4
4
  "body-assertion",
5
5
  "scenario-name",
6
+ "assertion-loss",
6
7
  ];
7
8
  /**
8
9
  * The documented-decline marker for a utils-file invariant, written in the utils file:
@@ -21,7 +22,7 @@ export const UTILS_VIOLATION_KINDS = [
21
22
  * paste-ready line from the failure text when it was pasted unedited: publishing the
22
23
  * placeholder as the stated reason is worse than no marker.
23
24
  */
24
- const ALLOW_RE = /reuse-verify:\s*allow\s+(duplicate-helper|body-assertion|scenario-name)\s+([A-Za-z_$][\w$]*)\s*(?:[—–]+|\s+-+\s+)\s*(?!<reason)(\S.*?)\s*$/;
25
+ const ALLOW_RE = new RegExp(`reuse-verify:\\s*allow\\s+(${MARKERABLE_UTILS_KINDS.join("|")})\\s+([A-Za-z_$][\\w$]*)\\s*(?:[—–]+|\\s+-+\\s+)\\s*(?!<reason)(\\S.*?)\\s*$`);
25
26
  const ALLOW_LOOSE_RE = /reuse-verify:\s*allow\b/;
26
27
  /** The exact line to paste for a violation — what the failure text hands the agent. */
27
28
  export function allowMarkerLine(spec, kind, helper) {
@@ -55,6 +56,11 @@ export function parseUtilsAllows(content, spec) {
55
56
  .trim();
56
57
  const m = ALLOW_RE.exec(line);
57
58
  if (!m) {
59
+ // A marker naming an advisory kind is inert, not malformed: advisories never
60
+ // block, so "documenting" one must not fail the gate either (the agent sees
61
+ // the kind in a kind-shaped slot and reasonably tries).
62
+ if (/allow\s+untyped-parameter\b/.test(code))
63
+ continue;
58
64
  malformed.push(raw.trim());
59
65
  continue;
60
66
  }
@@ -4,6 +4,23 @@ import { type UtilsLanguageSpec } from "./language-spec.js";
4
4
  * Skyramp v<version>`), as opposed to the utils header (`Generated by Skyramp on …`).
5
5
  * The same discriminator STEP 4 of the reuse prompt hands the agent. */
6
6
  export declare const CODEGEN_MARKER_RE: RegExp;
7
+ /** One Skyramp-generated test beside the spec, read once. */
8
+ export interface SiblingFile {
9
+ /** Absolute path. */
10
+ file: string;
11
+ content: string;
12
+ }
13
+ /**
14
+ * The Skyramp-generated tests in the spec's directory, each read once — THE sibling
15
+ * walk, shared by the inline-call-site scan (which excludes the spec itself) and the
16
+ * importer count (which includes it). Utils files carry no codegen version marker,
17
+ * so the marker check alone excludes them. Fails open to [] on any read error.
18
+ */
19
+ export declare function generatedSiblings(testFile: string, spec: UtilsLanguageSpec, opts: {
20
+ includeSelf: boolean;
21
+ }): Promise<SiblingFile[]>;
22
+ /** An SDK request call. Global: clone with `new RegExp(...)` before `matchAll`. */
23
+ export declare const REQUEST_CALL_RE: RegExp;
7
24
  export interface InlineCallSite {
8
25
  /** Absolute path of the sibling test. */
9
26
  file: string;
@@ -28,7 +45,29 @@ export interface InlineCallSite {
28
45
  * Advisory by design: it names a change to a PRE-EXISTING test, which the maintenance
29
46
  * flow owns, so it informs rather than gates. Never throws.
30
47
  */
31
- export declare function findSiblingInlineCallSites(testFile: string, utilsFiles: string[], _helpers: UtilsHelper[], spec: UtilsLanguageSpec): Promise<InlineCallSite[]>;
48
+ export declare function findSiblingInlineCallSites(testFile: string, utilsFiles: string[], _helpers: UtilsHelper[], spec: UtilsLanguageSpec, preReadSiblings?: SiblingFile[]): Promise<InlineCallSite[]>;
49
+ /** Lines after a call scanned for its schema assertion — the generated shape puts it
50
+ * immediately after the status-code assertion. */
51
+ export declare const SCHEMA_SCAN_LINES = 12;
52
+ /** True when a `check_schema` / `checkSchema` assertion sits in the given 1-based line
53
+ * range — the response-shape contract a status-only helper does not carry. */
54
+ export declare function hasSchemaCheck(content: string, from: number, to: number): boolean;
55
+ /**
56
+ * The request's body/override FIELD names — leaf names only, so a dotted override key
57
+ * (`posts.0.publish_at`) and the body literal it targets (`"publish_at":`) are one
58
+ * field. Reads the body through its variable: generated code assigns the template to
59
+ * an identifier and passes `body=<ident>`, so the fields live in the assignment, not
60
+ * in the call.
61
+ *
62
+ * `callAt` is the call's offset in `content`. The body variable resolves to the
63
+ * assignment NEAREST BEFORE it, because generated code reuses the name `body` per
64
+ * request: a file-wide first match gives every later request the first one's fields,
65
+ * so two different payloads compare equal. Omit it only when `content` holds a single
66
+ * request.
67
+ */
68
+ export declare function requestFields(content: string, call: string, callAt?: number): Set<string>;
32
69
  /** The keyword names of a request call (`path=`, `method=`, `query_params=` … / TS
33
70
  * object keys), less the liftable ones — a cheap normalised call shape. */
34
71
  export declare function shapeKeys(call: string): Set<string>;
72
+ /** Text of the parenthesised argument list starting at the `(` at `open`. */
73
+ export declare function callText(content: string, open: number): string;
@@ -4,12 +4,52 @@ import { SKYRAMP_UTILS_HEADER } from "../utils.js";
4
4
  import { parseUtilsFile } from "./parse.js";
5
5
  import { normalizePath } from "./language-spec.js";
6
6
  import { realpath } from "./locate.js";
7
+ import { escapeRegExp } from "../regex.js";
7
8
  /** The codegen marker every Skyramp-GENERATED TEST carries on line 1 (`Generated by
8
9
  * Skyramp v<version>`), as opposed to the utils header (`Generated by Skyramp on …`).
9
10
  * The same discriminator STEP 4 of the reuse prompt hands the agent. */
10
11
  export const CODEGEN_MARKER_RE = new RegExp(`${SKYRAMP_UTILS_HEADER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s+v\\d`);
11
12
  const HEADER_SCAN_LINES = 5;
12
- const REQUEST_CALL_RE = /\b(?:send_request|sendRequest)\s*\(/g;
13
+ /**
14
+ * The Skyramp-generated tests in the spec's directory, each read once — THE sibling
15
+ * walk, shared by the inline-call-site scan (which excludes the spec itself) and the
16
+ * importer count (which includes it). Utils files carry no codegen version marker,
17
+ * so the marker check alone excludes them. Fails open to [] on any read error.
18
+ */
19
+ export async function generatedSiblings(testFile, spec, opts) {
20
+ let dir;
21
+ let entries;
22
+ let self;
23
+ try {
24
+ self = await realpath(testFile);
25
+ dir = path.dirname(self);
26
+ entries = await fs.promises.readdir(dir);
27
+ }
28
+ catch {
29
+ return [];
30
+ }
31
+ const out = [];
32
+ for (const name of entries.sort()) {
33
+ const file = path.join(dir, name);
34
+ if (!spec.extensions.some((e) => name.toLowerCase().endsWith(e)))
35
+ continue;
36
+ if (!opts.includeSelf && path.resolve(file) === self)
37
+ continue;
38
+ let content;
39
+ try {
40
+ content = await fs.promises.readFile(file, "utf8");
41
+ }
42
+ catch {
43
+ continue;
44
+ }
45
+ if (!CODEGEN_MARKER_RE.test(content.split("\n").slice(0, HEADER_SCAN_LINES).join("\n")))
46
+ continue;
47
+ out.push({ file, content });
48
+ }
49
+ return out;
50
+ }
51
+ /** An SDK request call. Global: clone with `new RegExp(...)` before `matchAll`. */
52
+ export const REQUEST_CALL_RE = /\b(?:send_request|sendRequest)\s*\(/g;
13
53
  /**
14
54
  * Inline request calls in OTHER Skyramp-generated tests beside `testFile` that a
15
55
  * utils helper already wraps — the reuse the prompt asks for and the agent does not
@@ -24,10 +64,12 @@ const REQUEST_CALL_RE = /\b(?:send_request|sendRequest)\s*\(/g;
24
64
  * Advisory by design: it names a change to a PRE-EXISTING test, which the maintenance
25
65
  * flow owns, so it informs rather than gates. Never throws.
26
66
  */
27
- export async function findSiblingInlineCallSites(testFile, utilsFiles, _helpers, spec) {
67
+ export async function findSiblingInlineCallSites(testFile, utilsFiles, _helpers, spec, preReadSiblings) {
28
68
  // route → helper name + the keyword set of the helper's own request call, so a
29
69
  // sibling call is reported only when its SHAPE matches too (same keywords once the
30
70
  // liftable ones are set aside) — criterion 2 of the API near-duplicate definition.
71
+ // `fields` and `schemaChecked` carry the two things keyword shape cannot see; see
72
+ // substitutable().
31
73
  const byRoute = new Map();
32
74
  for (const utilsFile of utilsFiles) {
33
75
  let content;
@@ -50,39 +92,20 @@ export async function findSiblingInlineCallSites(testFile, utilsFiles, _helpers,
50
92
  byRoute.set(`${owner.method} ${owner.normalizedPath}`, {
51
93
  helper: owner.name,
52
94
  keys: shapeKeys(call),
95
+ fields: requestFields(content, call, start),
96
+ schemaChecked: hasSchemaCheck(content, owner.line, owner.endLine),
53
97
  });
54
98
  }
55
99
  }
56
100
  if (byRoute.size === 0)
57
101
  return [];
58
- // Canonical, like the utils paths: otherwise a tool-supplied /tmp path and a
59
- // realpath'd /private/tmp utils file look like different directories to the renderer.
60
- const dir = path.dirname(await realpath(testFile));
61
- // Canonical like `dir`, or a spec reached through a symlink is reported as its
62
- // own sibling.
63
- const exclude = new Set(await Promise.all([testFile, ...utilsFiles].map((f) => realpath(f))));
64
- let entries;
65
- try {
66
- entries = await fs.promises.readdir(dir);
67
- }
68
- catch {
69
- return [];
70
- }
102
+ const utilsSet = new Set(await Promise.all(utilsFiles.map((f) => realpath(f))));
103
+ const siblings = (preReadSiblings ??
104
+ (await generatedSiblings(testFile, spec, { includeSelf: false }))).filter((sib) => !utilsSet.has(path.resolve(sib.file)));
105
+ const self = await realpath(testFile);
71
106
  const out = [];
72
- for (const name of entries.sort()) {
73
- const file = path.join(dir, name);
74
- if (exclude.has(path.resolve(file)))
75
- continue;
76
- if (!spec.extensions.some((e) => name.toLowerCase().endsWith(e)))
77
- continue;
78
- let content;
79
- try {
80
- content = await fs.promises.readFile(file, "utf8");
81
- }
82
- catch {
83
- continue;
84
- }
85
- if (!CODEGEN_MARKER_RE.test(content.split("\n").slice(0, HEADER_SCAN_LINES).join("\n")))
107
+ for (const { file, content } of siblings) {
108
+ if (path.resolve(file) === self)
86
109
  continue;
87
110
  // Line ranges of the sibling's own non-test helpers: calls inside them are not inline.
88
111
  const localHelpers = parseUtilsFile(content, spec).filter((h) => !/^test/i.test(h.name));
@@ -102,11 +125,154 @@ export async function findSiblingInlineCallSites(testFile, utilsFiles, _helpers,
102
125
  continue;
103
126
  if (!sameShape(shapeKeys(call), match.keys))
104
127
  continue;
128
+ if (!substitutable(match, {
129
+ fields: requestFields(content, call, start),
130
+ schemaChecked: hasSchemaCheck(content, line, line + SCHEMA_SCAN_LINES),
131
+ }))
132
+ continue;
105
133
  out.push({ file, line, method, path: p, helper: match.helper });
106
134
  }
107
135
  }
108
136
  return out;
109
137
  }
138
+ /**
139
+ * Whether the helper can stand in for this call site with LITERALS as the only
140
+ * difference — the condition the advisory itself states. Keyword shape (`sameShape`)
141
+ * cannot see either of these, and both were measured naming sites an agent then
142
+ * correctly declined, which trains it to ignore the advisory:
143
+ *
144
+ * - **Fields the site does not carry.** A helper whose body/override sets
145
+ * `publish_at` cannot replace a POST whose body has only `status` and `title`:
146
+ * substituting ADDS a field, changing the sibling's request. `body` and
147
+ * `dataOverride` are liftable *keywords*, so their mere presence matched; their
148
+ * contents never got compared. (ghost `pr-scheduled-publishing`: a scheduled-post
149
+ * helper offered for a draft-post call, declined for exactly this reason.)
150
+ * - **A schema-checked site.** When the site feeds its response to `check_schema`
151
+ * against a per-file expected body and the helper wraps no such check, replacing
152
+ * the block either drops the contract assertion or redesigns the file. That is a
153
+ * structural difference, not a literal one. (demoshop `P08`: the contract-file
154
+ * sites, declined as "a design change beyond this PR's scope".)
155
+ *
156
+ * Both directions are conservative — they only ever drop a candidate — so precision
157
+ * rises and the advisory cannot start over-reporting because of them.
158
+ */
159
+ function substitutable(helper, site) {
160
+ for (const f of helper.fields)
161
+ if (!site.fields.has(f))
162
+ return false;
163
+ return helper.schemaChecked === site.schemaChecked;
164
+ }
165
+ /** Lines after a call scanned for its schema assertion — the generated shape puts it
166
+ * immediately after the status-code assertion. */
167
+ export const SCHEMA_SCAN_LINES = 12;
168
+ /** True when a `check_schema` / `checkSchema` assertion sits in the given 1-based line
169
+ * range — the response-shape contract a status-only helper does not carry. */
170
+ export function hasSchemaCheck(content, from, to) {
171
+ const lines = content.split("\n").slice(Math.max(0, from - 1), to);
172
+ return /\bcheck_?[Ss]chema\s*\(/.test(lines.join("\n"));
173
+ }
174
+ /**
175
+ * The request's body/override FIELD names — leaf names only, so a dotted override key
176
+ * (`posts.0.publish_at`) and the body literal it targets (`"publish_at":`) are one
177
+ * field. Reads the body through its variable: generated code assigns the template to
178
+ * an identifier and passes `body=<ident>`, so the fields live in the assignment, not
179
+ * in the call.
180
+ *
181
+ * `callAt` is the call's offset in `content`. The body variable resolves to the
182
+ * assignment NEAREST BEFORE it, because generated code reuses the name `body` per
183
+ * request: a file-wide first match gives every later request the first one's fields,
184
+ * so two different payloads compare equal. Omit it only when `content` holds a single
185
+ * request.
186
+ */
187
+ export function requestFields(content, call, callAt) {
188
+ const out = new Set();
189
+ const add = (raw) => {
190
+ const leaf = raw.split(".").pop() ?? "";
191
+ // Array indices are positions, not fields.
192
+ if (leaf && !/^\d+$/.test(leaf))
193
+ out.add(leaf);
194
+ };
195
+ // FIELDS COME FROM THE PAYLOAD ONLY. Every other liftable argument carries quoted
196
+ // keys of its own that are not body fields: `path_params={"orders": id}` names a
197
+ // placeholder (the same `{}` in the same normalised path), `headers={"Authorization":
198
+ // t}` names a header, `query_params={"page": 1}` a query key. Reading any of them as
199
+ // a field reports a correct retrofit as changing the request — measured on a demoshop
200
+ // DELETE whose helper labelled the path parameter `orders` where the baseline said
201
+ // `order_id`. shapeKeys already ignores these keywords; ignoring the keyword while
202
+ // reading its contents was the incoherence. `body` and `data_override` stay, because
203
+ // their keys ARE payload paths.
204
+ const body = call.replace(/\b(?:path_params|pathParams|query_params|queryParams|headers|url)\s*[=:]\s*\{[^{}]*\}/g, " ");
205
+ // Dotted override keys, and any body literal written inline in the call.
206
+ for (const m of body.matchAll(/["'`]([A-Za-z_][\w.]*)["'`]\s*:/g))
207
+ add(m[1]);
208
+ // TS shorthand inside an override object — `dataOverride: { status, title }` names
209
+ // the same fields as `{ "status": status }` but carries neither quotes nor a colon,
210
+ // so the pattern above cannot see it and the field would compare as absent.
211
+ for (const region of overrideObjects(body))
212
+ for (const m of region.matchAll(/(?:^|[{,])\s*([A-Za-z_$][\w$]*)\s*(?=[,}])/g))
213
+ add(m[1]);
214
+ // `body=IDENT` / `body: IDENT` → the identifier's assignment elsewhere in the file.
215
+ const ref = /\bbody\s*[=:]\s*([A-Za-z_$][\w$]*)/.exec(call);
216
+ if (ref) {
217
+ const assignRe = new RegExp(`\\b${escapeRegExp(ref[1])}\\b\\s*(?::[^=]*)?=`, "g");
218
+ // The last assignment before the call; the first in the file when the offset is
219
+ // unknown or nothing precedes it.
220
+ let assign = null;
221
+ for (const m of content.matchAll(assignRe)) {
222
+ if (callAt !== undefined && (m.index ?? 0) > callAt)
223
+ break;
224
+ assign = m;
225
+ if (callAt === undefined)
226
+ break;
227
+ }
228
+ if (assign) {
229
+ // From the assignment to the end of its braced literal — the template is one
230
+ // string, so the first balanced `{`…`}` after it is the body.
231
+ const tail = content.slice(assign.index + assign[0].length);
232
+ const open = tail.indexOf("{");
233
+ if (open !== -1) {
234
+ let depth = 0;
235
+ let end = tail.length;
236
+ for (let i = open; i < tail.length; i++) {
237
+ if (tail[i] === "{")
238
+ depth++;
239
+ else if (tail[i] === "}") {
240
+ depth--;
241
+ if (depth === 0) {
242
+ end = i + 1;
243
+ break;
244
+ }
245
+ }
246
+ }
247
+ for (const m of tail
248
+ .slice(open, end)
249
+ .matchAll(/["'`]([A-Za-z_][\w.]*)["'`]\s*:/g))
250
+ add(m[1]);
251
+ }
252
+ }
253
+ }
254
+ return out;
255
+ }
256
+ /** The `{…}` text of each `data_override` / `dataOverride` argument in a call. */
257
+ function overrideObjects(call) {
258
+ const out = [];
259
+ for (const m of call.matchAll(/\b(?:data_override|dataOverride)\s*[=:]\s*\{/g)) {
260
+ const open = (m.index ?? 0) + m[0].length - 1;
261
+ let depth = 0;
262
+ for (let i = open; i < call.length; i++) {
263
+ if (call[i] === "{")
264
+ depth++;
265
+ else if (call[i] === "}") {
266
+ depth--;
267
+ if (depth === 0) {
268
+ out.push(call.slice(open, i + 1));
269
+ break;
270
+ }
271
+ }
272
+ }
273
+ }
274
+ return out;
275
+ }
110
276
  /** Keywords whose presence differs legitimately between two versions of one request:
111
277
  * the literals the merge rule lifts to parameters. Everything else is call shape. */
112
278
  const LIFTABLE_KEYS = new Set([
@@ -138,7 +304,7 @@ function sameShape(a, b) {
138
304
  return true;
139
305
  }
140
306
  /** Text of the parenthesised argument list starting at the `(` at `open`. */
141
- function callText(content, open) {
307
+ export function callText(content, open) {
142
308
  let depth = 0;
143
309
  for (let i = open; i < content.length; i++) {
144
310
  const ch = content[i];
@@ -0,0 +1,31 @@
1
+ import { type SiblingFile } from "./call-sites.js";
2
+ import type { UtilsHelper } from "./parse.js";
3
+ import type { UtilsLanguageSpec } from "./language-spec.js";
4
+ /** A helper imported by at most one delivered test. */
5
+ export interface SingleImporterHelper {
6
+ /** Absolute path of the utils file. */
7
+ file: string;
8
+ helper: string;
9
+ line: number;
10
+ /** Delivered tests importing it (0 or 1). */
11
+ importers: number;
12
+ }
13
+ /** The strings a sibling's source would contain if it imports the module at
14
+ * `modulePath` (relative to the sibling's directory): the path form for TS/JS
15
+ * specifiers, and the dotted Python form — `../shared/X` → `..shared.X`,
16
+ * `shared/X` → `shared.X` / `.shared.X`. */
17
+ export declare function moduleMentionCandidates(modulePath: string): string[];
18
+ /**
19
+ * Count, per utils helper, how many Skyramp-generated tests in the spec's directory
20
+ * import it — the spec under verification included.
21
+ *
22
+ * Findings:
23
+ * - 0 importers: nothing imports the helper, not even the spec that wrote it — a
24
+ * dead helper, actionable on any run.
25
+ * - 1 importer: not (yet) shared. Reported only when the directory holds at least
26
+ * TWO delivered tests: on a first run every helper has exactly one importer by
27
+ * construction (it seeds the utils file), so listing them would be guaranteed
28
+ * noise on the most common path.
29
+ * Fails open to "no findings" on any read error.
30
+ */
31
+ export declare function singleImporterHelpers(testFile: string, utilsFiles: string[], helpersByFile: ReadonlyMap<string, UtilsHelper[]>, spec: UtilsLanguageSpec, preReadSiblings?: SiblingFile[]): Promise<SingleImporterHelper[]>;