@skyramp/mcp 0.4.2 → 0.4.3-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.
Files changed (119) hide show
  1. package/build/execution/wrapperConfig.d.ts +4 -0
  2. package/build/execution/wrapperConfig.js +14 -2
  3. package/build/prompts/code-reuse.d.ts +5 -1
  4. package/build/prompts/code-reuse.js +16 -10
  5. package/build/prompts/modularization/ui-test-modularization.js +9 -2
  6. package/build/prompts/pom-aware-code-reuse.d.ts +4 -1
  7. package/build/prompts/pom-aware-code-reuse.js +38 -6
  8. package/build/prompts/reuse-hand-off.d.ts +4 -0
  9. package/build/prompts/reuse-hand-off.js +10 -5
  10. package/build/prompts/shared-helper-policy.d.ts +5 -0
  11. package/build/prompts/shared-helper-policy.js +5 -0
  12. package/build/prompts/test-maintenance/driftAnalysisShared.js +2 -0
  13. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +2 -1
  14. package/build/prompts/testbot/testbot-prompts.js +33 -16
  15. package/build/recommendation/fakeVerifyContext.d.ts +3 -0
  16. package/build/recommendation/fakeVerifyContext.js +16 -0
  17. package/build/recommendation/runVerifiers.js +2 -0
  18. package/build/recommendation/types.d.ts +12 -0
  19. package/build/recommendation/verifierContracts.d.ts +24 -2
  20. package/build/recommendation/verifierContracts.js +32 -4
  21. package/build/recommendation/verifiers/changedLinesClaimed.d.ts +17 -0
  22. package/build/recommendation/verifiers/changedLinesClaimed.js +88 -0
  23. package/build/recommendation/verifiers/expectedValueSourced.js +8 -0
  24. package/build/recommendation/verifiers/requirementSourced.js +141 -11
  25. package/build/services/TestGenerationService.js +4 -0
  26. package/build/tools/code-refactor/caller-gate.d.ts +105 -0
  27. package/build/tools/code-refactor/caller-gate.js +174 -0
  28. package/build/tools/code-refactor/changed-helper-report.d.ts +62 -0
  29. package/build/tools/code-refactor/changed-helper-report.js +114 -0
  30. package/build/tools/code-refactor/codeReuseTool.js +131 -62
  31. package/build/tools/code-refactor/enhanceAssertionsTool.js +2 -2
  32. package/build/tools/code-refactor/helper-callers.d.ts +65 -0
  33. package/build/tools/code-refactor/helper-callers.js +85 -0
  34. package/build/tools/code-refactor/modularizationTool.js +10 -0
  35. package/build/tools/code-refactor/pom-pass-state.d.ts +51 -0
  36. package/build/tools/code-refactor/pom-pass-state.js +115 -0
  37. package/build/tools/code-refactor/retrofit-state.d.ts +3 -2
  38. package/build/tools/code-refactor/retrofit-state.js +11 -12
  39. package/build/tools/code-refactor/reuse-outcome.d.ts +6 -0
  40. package/build/tools/code-refactor/reuse-record-store.d.ts +52 -0
  41. package/build/tools/code-refactor/reuse-record-store.js +126 -0
  42. package/build/tools/code-refactor/reuse-state.d.ts +18 -6
  43. package/build/tools/code-refactor/reuse-state.js +78 -131
  44. package/build/tools/code-refactor/utils-verify-gates.js +3 -3
  45. package/build/tools/code-refactor/verify-gates.d.ts +4 -0
  46. package/build/tools/code-refactor/verify-gates.js +8 -1
  47. package/build/tools/generate-tests/generateContractRestTool.js +7 -7
  48. package/build/tools/generate-tests/generateIntegrationRestTool.js +8 -8
  49. package/build/tools/generate-tests/generateUIRestTool.js +3 -3
  50. package/build/tools/submitReportTool.js +91 -29
  51. package/build/tools/test-management/actionsTool.js +7 -0
  52. package/build/tools/test-management/registerTestPlanTool.d.ts +1 -0
  53. package/build/tools/test-management/registerTestPlanTool.js +70 -22
  54. package/build/types/RepositoryAnalysis.d.ts +20 -20
  55. package/build/types/ReuseOutcome.d.ts +96 -0
  56. package/build/types/TestExecution.d.ts +11 -0
  57. package/build/types/TestExecution.js +19 -0
  58. package/build/types/index.d.ts +1 -1
  59. package/build/utils/branchDiff.d.ts +1 -1
  60. package/build/utils/branchDiff.js +1 -1
  61. package/build/utils/changedRuns.d.ts +13 -0
  62. package/build/utils/changedRuns.js +56 -0
  63. package/build/utils/featureFlags.d.ts +8 -10
  64. package/build/utils/featureFlags.js +23 -21
  65. package/build/utils/normalizeSkyrampImports.d.ts +4 -1
  66. package/build/utils/normalizeSkyrampImports.js +24 -17
  67. package/build/utils/pom-scope/ownership.d.ts +19 -0
  68. package/build/utils/pom-scope/ownership.js +31 -0
  69. package/build/utils/pom-scope/pom-files.d.ts +8 -0
  70. package/build/utils/pom-scope/pom-files.js +8 -0
  71. package/build/utils/pom-scope/scoring.js +13 -1
  72. package/build/utils/pom-scope/strip.d.ts +10 -0
  73. package/build/utils/pom-scope/strip.js +9 -0
  74. package/build/utils/pom-verify/verify.js +13 -2
  75. package/build/utils/reuseRouting.d.ts +30 -2
  76. package/build/utils/reuseRouting.js +36 -15
  77. package/build/utils/testDependencyPolicy.js +4 -16
  78. package/build/utils/utils-verify/action-sites.d.ts +2 -2
  79. package/build/utils/utils-verify/body-reach.d.ts +2 -2
  80. package/build/utils/utils-verify/body-reach.js +4 -1
  81. package/build/utils/utils-verify/call-sites.d.ts +16 -6
  82. package/build/utils/utils-verify/call-sites.js +12 -6
  83. package/build/utils/utils-verify/changed-helpers.d.ts +38 -0
  84. package/build/utils/utils-verify/changed-helpers.js +91 -0
  85. package/build/utils/utils-verify/head.d.ts +48 -0
  86. package/build/utils/utils-verify/head.js +115 -0
  87. package/build/utils/utils-verify/importers.d.ts +59 -10
  88. package/build/utils/utils-verify/importers.js +121 -31
  89. package/build/utils/utils-verify/in-house.js +26 -31
  90. package/build/utils/utils-verify/index.d.ts +3 -0
  91. package/build/utils/utils-verify/index.js +3 -0
  92. package/build/utils/utils-verify/language-spec.js +10 -2
  93. package/build/utils/utils-verify/locate.d.ts +3 -0
  94. package/build/utils/utils-verify/locate.js +6 -6
  95. package/build/utils/utils-verify/parse.d.ts +13 -1
  96. package/build/utils/utils-verify/parse.js +122 -49
  97. package/build/utils/utils-verify/retrofit-equivalence.d.ts +0 -5
  98. package/build/utils/utils-verify/retrofit-equivalence.js +2 -34
  99. package/build/utils/utils-verify/stage.js +4 -16
  100. package/build/utils/utils-verify/verify.d.ts +9 -3
  101. package/build/utils/utils-verify/verify.js +26 -12
  102. package/node_modules/playwright/lib/dom-analyzer/blueprint.js +1 -0
  103. package/node_modules/playwright/lib/dom-analyzer/dynamicId.js +1 -0
  104. package/node_modules/playwright/lib/dom-analyzer/dynamicId.test.js +6 -0
  105. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +303 -150
  106. package/node_modules/playwright/node_modules/playwright-core/lib/generated/pollingRecorderSource.js +1 -1
  107. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-LNgEKtdV.js → codeMirrorModule-Bou8kKzE.js} +1 -1
  108. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-Bwr1eMKC.js → defaultSettingsView-DTp2-8Si.js} +3 -3
  109. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.BAkLd5DX.js → index.ZYuEFHsi.js} +1 -1
  110. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  111. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.BPopbasy.js → uiMode.GLqsx5cI.js} +1 -1
  112. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  113. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  114. package/node_modules/playwright/node_modules/playwright-core/src/generated/pollingRecorderSource.ts +1 -1
  115. package/node_modules/playwright/package.json +1 -1
  116. package/package.json +2 -2
  117. package/plugin/prompts/code-reuse/hand-off.md +13 -1
  118. package/plugin/prompts/plan-tests.md +2 -2
  119. package/plugin/skills/enhance-assertions/reference/shared-rules.md +1 -0
@@ -1,12 +1,40 @@
1
1
  export declare function isBrowserTestType(testType?: string): boolean;
2
2
  export declare function isModularizeFirstTarget(testType?: string, language?: string): boolean;
3
+ /**
4
+ * Which pass one reuse call serves, resolved ONCE per call and handed to every
5
+ * branch — discovery, verify, the prompt and the modularization check — so none
6
+ * of them re-derives it. `pass` is the page-object pass or the SkyrampUtils flow;
7
+ * `composed` says the target takes both (isPomFirstTarget), so the utils flow
8
+ * follows the page-object pass and one verify checks both halves.
9
+ */
10
+ export interface ReuseRoute {
11
+ pass: "pom" | "utils";
12
+ composed: boolean;
13
+ }
14
+ /** The route for a target. `pomPassFinished` is the one run-state fact it turns on:
15
+ * with both settings on, a finished page-object pass hands the spec to the utils flow. */
16
+ export declare function reuseRouteFor(language: string, framework: string | undefined, testType: string | undefined, pomPassFinished?: boolean): ReuseRoute;
17
+ /**
18
+ * Both reuse settings on, for a test both paths serve: a UI test in TypeScript or
19
+ * JavaScript. Such a test takes the page-object pass first — it owns every
20
+ * page-object class its detection returns — and then the seeded SkyrampUtils flow
21
+ * over what that pass left inline. `isPomAwareTarget` alone still routes the
22
+ * page-object pass; this names the composition, so e2e keeps its single path.
23
+ *
24
+ * No framework parameter: a UI test in TypeScript or JavaScript is a Playwright
25
+ * test — the generator derives that framework for it (deriveEffectiveFramework), and
26
+ * the tools that read this predicate after generation (modularization, the
27
+ * shared-helper verifier, the maintenance step) carry no framework to pass.
28
+ */
29
+ export declare function isPomFirstTarget(language: string | undefined, testType: string | undefined): boolean;
3
30
  export declare function isPomAwareTarget(language: string, framework?: string, testType?: string): boolean;
4
31
  /**
5
32
  * Which family's inline call-site rule and sibling scan a test gets — the ONE gate
6
33
  * for both. `api` for integration, whatever the flags (the rule is stated in API
7
34
  * terms and predates the flag); `browser` for the seeded UI flow, where a Skyramp
8
- * browser module exists to call into; `undefined` everywhere else (`contract`, `load`,
9
- * e2e, an absent type, UI with the flag off or page-object reuse on), where an
35
+ * browser module exists to call into — with page-object reuse on as well, since the
36
+ * seeded flow runs after the page-object pass; `undefined` everywhere else (`contract`,
37
+ * `load`, e2e, an absent type, UI with the flag off), where an
10
38
  * advisory would describe reuse the feature could not have taken. The prompt renders
11
39
  * the rule and STEP 5c on it; the verify pass runs the scan on it.
12
40
  */
@@ -19,30 +19,50 @@ export function isBrowserTestType(testType) {
19
19
  // maintenance step) read isModularizeFirstHandOffTarget in prompts/reuse-hand-off.ts,
20
20
  // which is THIS predicate plus a utils spec for the language: a language the
21
21
  // verifier does not cover has no payable reuse half, so no hand-off is owed for
22
- // it. Integration qualifies whenever the flag is on;
23
- // UI only while the POM path is off (POM refactoring supersedes modularization,
24
- // and the testbot UI flow asks for codeReuse unconditionally, so the flag is what
25
- // keeps the default-off behavior a no-op reuse pass). e2e and load are not
26
- // routed here: load has no modularization routing at all, e2e is deferred.
27
- // Deliberately keyed on the POM *flag*, not on isPomAwareTarget: with the flag
28
- // on, a UI target the POM path does not cover keeps today's plain reuse pass.
29
- // Composing utils reuse with the POM path is a separate change; this predicate
30
- // only decides the flag-off UI flow. The modularization prompts read it too, so
31
- // extraction and seeding agree for every codeReuse flow; a modularizeCode-only
32
- // call (no codeReuse) still receives the shared-helper rules — harmless without
33
- // a reuse pass, and the tool cannot see codeReuse.
22
+ // it. Integration qualifies whenever the flag is on; UI in TypeScript or
23
+ // JavaScript too, whatever the page-object flag says. With page-object reuse
24
+ // also on, the page-object pass runs FIRST and the seeded flow takes what it
25
+ // left inline (see isPomFirstTarget). e2e and load are not routed here: load has
26
+ // no modularization routing at all, e2e is deferred.
34
27
  export function isModularizeFirstTarget(testType, language) {
35
28
  if (!isUtilsReuseEnabled())
36
29
  return false;
37
30
  if (testType === "integration")
38
31
  return true;
39
- if (testType !== "ui" || isPomReuseEnabled())
32
+ if (testType !== "ui")
40
33
  return false;
41
34
  // Shared browser helpers are Playwright TS/JS; other UI languages have no
42
35
  // utils-file convention here and would fall through to a Python file name.
43
36
  const lang = language?.toLowerCase();
44
37
  return lang === "typescript" || lang === "javascript";
45
38
  }
39
+ /** The route for a target. `pomPassFinished` is the one run-state fact it turns on:
40
+ * with both settings on, a finished page-object pass hands the spec to the utils flow. */
41
+ export function reuseRouteFor(language, framework, testType, pomPassFinished = false) {
42
+ const composed = isPomFirstTarget(language, testType);
43
+ const pomAware = isPomAwareTarget(language, framework, testType);
44
+ return {
45
+ pass: pomAware && !(composed && pomPassFinished) ? "pom" : "utils",
46
+ composed,
47
+ };
48
+ }
49
+ /**
50
+ * Both reuse settings on, for a test both paths serve: a UI test in TypeScript or
51
+ * JavaScript. Such a test takes the page-object pass first — it owns every
52
+ * page-object class its detection returns — and then the seeded SkyrampUtils flow
53
+ * over what that pass left inline. `isPomAwareTarget` alone still routes the
54
+ * page-object pass; this names the composition, so e2e keeps its single path.
55
+ *
56
+ * No framework parameter: a UI test in TypeScript or JavaScript is a Playwright
57
+ * test — the generator derives that framework for it (deriveEffectiveFramework), and
58
+ * the tools that read this predicate after generation (modularization, the
59
+ * shared-helper verifier, the maintenance step) carry no framework to pass.
60
+ */
61
+ export function isPomFirstTarget(language, testType) {
62
+ return (isPomReuseEnabled() &&
63
+ testType === "ui" &&
64
+ isModularizeFirstTarget(testType, language));
65
+ }
46
66
  export function isPomAwareTarget(language, framework, testType) {
47
67
  if (!isPomReuseEnabled())
48
68
  return false;
@@ -56,8 +76,9 @@ export function isPomAwareTarget(language, framework, testType) {
56
76
  * Which family's inline call-site rule and sibling scan a test gets — the ONE gate
57
77
  * for both. `api` for integration, whatever the flags (the rule is stated in API
58
78
  * terms and predates the flag); `browser` for the seeded UI flow, where a Skyramp
59
- * browser module exists to call into; `undefined` everywhere else (`contract`, `load`,
60
- * e2e, an absent type, UI with the flag off or page-object reuse on), where an
79
+ * browser module exists to call into — with page-object reuse on as well, since the
80
+ * seeded flow runs after the page-object pass; `undefined` everywhere else (`contract`,
81
+ * `load`, e2e, an absent type, UI with the flag off), where an
61
82
  * advisory would describe reuse the feature could not have taken. The prompt renders
62
83
  * the rule and STEP 5c on it; the verify pass runs the scan on it.
63
84
  */
@@ -6,6 +6,7 @@ import { promisify } from "util";
6
6
  import fg from "fast-glob";
7
7
  import { canonicalJson as canonical, isPlainObject as isObject, } from "./canonicalJson.js";
8
8
  import { listChangedFiles } from "./reportVerification.js";
9
+ import { contentAtHead } from "./utils-verify/head.js";
9
10
  import { SKYRAMP_SDK_VERSION } from "./skyrampSdkVersion.js";
10
11
  const execFileAsync = promisify(execFile);
11
12
  export const NPM_TEST_PACKAGES = new Set([
@@ -96,7 +97,7 @@ export async function verifyTestDependencyChanges(repositoryPath) {
96
97
  }
97
98
  continue;
98
99
  }
99
- const committedBaseline = await baselineFile(repositoryPath, file);
100
+ const committedBaseline = await contentAtHead(path.join(repositoryPath, file), repositoryPath);
100
101
  const baseline = committedBaseline ??
101
102
  (await baselineForNewManifest(repositoryPath, file, name, violations));
102
103
  if (baseline === undefined)
@@ -203,19 +204,6 @@ function verifyJavaManifest(file, name, baseline, current, violations) {
203
204
  return verifyMaven(file, baseline, current, violations);
204
205
  return verifyGradle(file, baseline, current, violations);
205
206
  }
206
- async function baselineFile(repositoryPath, file) {
207
- try {
208
- const { stdout } = await execFileAsync("git", ["show", `HEAD:${file}`], {
209
- cwd: repositoryPath,
210
- encoding: "utf8",
211
- maxBuffer: 10 * 1024 * 1024,
212
- });
213
- return stdout;
214
- }
215
- catch {
216
- return undefined;
217
- }
218
- }
219
207
  async function baselineFileExists(repositoryPath, file) {
220
208
  try {
221
209
  await execFileAsync("git", ["cat-file", "-e", `HEAD:${file}`], {
@@ -540,7 +528,7 @@ async function correspondingManifests(repositoryPath, file, lockName) {
540
528
  const rootManifest = at("package.json");
541
529
  // Membership comes from the committed root manifest, not the edited lockfile.
542
530
  // A workspace addition is an unrelated manifest edit and remains disallowed.
543
- const baseline = await baselineFile(repositoryPath, rootManifest);
531
+ const baseline = await contentAtHead(path.join(repositoryPath, rootManifest), repositoryPath);
544
532
  if (baseline === undefined)
545
533
  return [rootManifest];
546
534
  const manifest = JSON.parse(baseline);
@@ -575,7 +563,7 @@ async function correspondingManifests(repositoryPath, file, lockName) {
575
563
  return [];
576
564
  }
577
565
  async function verifyLockfile(repositoryPath, file, name, pairedManifests, validManifests, violations) {
578
- const committedBaseline = await baselineFile(repositoryPath, file);
566
+ const committedBaseline = await contentAtHead(path.join(repositoryPath, file), repositoryPath);
579
567
  const baseline = committedBaseline ?? '{"packages":{}}\n';
580
568
  let current;
581
569
  try {
@@ -1,6 +1,6 @@
1
1
  import { type UtilsHelper } from "./parse.js";
2
2
  import { type UtilsLanguageSpec } from "./language-spec.js";
3
- import { type InlineCallSite, type SiblingFile } from "./call-sites.js";
3
+ import { type InlineCallSite, type SiblingScan } from "./call-sites.js";
4
4
  /**
5
5
  * The browser family's sibling scan: a contiguous run of actions in OTHER
6
6
  * Skyramp-generated tests beside `testFile` whose action sequence equals a utils
@@ -29,4 +29,4 @@ import { type InlineCallSite, type SiblingFile } from "./call-sites.js";
29
29
  * consumed and the scan continues after it. A run inside the sibling's own local
30
30
  * helper is reported with that helper's name, as the API scan does.
31
31
  */
32
- export declare function findSiblingInlineActionSites(testFile: string, helpersByFile: ReadonlyMap<string, UtilsHelper[]>, spec: UtilsLanguageSpec, preReadSiblings: SiblingFile[] | undefined): Promise<InlineCallSite[]>;
32
+ export declare function findSiblingInlineActionSites(testFile: string, helpersByFile: ReadonlyMap<string, UtilsHelper[]>, spec: UtilsLanguageSpec, preReadSiblings: SiblingScan | undefined): Promise<InlineCallSite[]>;
@@ -1,5 +1,5 @@
1
1
  import type { UtilsLanguageSpec } from "./language-spec.js";
2
- import { type SiblingFile } from "./call-sites.js";
2
+ import { type SiblingScan } from "./call-sites.js";
3
3
  /**
4
4
  * A shared helper whose request body no parameter of its signature reaches, beside a
5
5
  * spec that defines its OWN helper for the same route.
@@ -117,4 +117,4 @@ export declare function findDuplicateBodyReach(utilsFiles: string[], spec: Utils
117
117
  * Shared helpers whose body no parameter reaches, each paired with a sibling spec
118
118
  * that defines its own helper for the same route. Never throws; fails open to [].
119
119
  */
120
- export declare function findUnreachableBodies(testFile: string, utilsFiles: string[], spec: UtilsLanguageSpec, preReadSiblings?: SiblingFile[]): Promise<UnreachableBody[]>;
120
+ export declare function findUnreachableBodies(testFile: string, utilsFiles: string[], spec: UtilsLanguageSpec, preReadSiblings?: SiblingScan): Promise<UnreachableBody[]>;
@@ -319,7 +319,10 @@ export async function findUnreachableBodies(testFile, utilsFiles, spec, preReadS
319
319
  const siblings = preReadSiblings ??
320
320
  (await generatedSiblings(testFile, spec, { includeSelf: true }));
321
321
  const out = [];
322
- for (const { file, content } of siblings) {
322
+ // `.files`: the walk reports what it read alongside whether it read the whole
323
+ // directory. This scan acts only on siblings it HAS, so an incomplete walk costs it
324
+ // a finding and never invents one.
325
+ for (const { file, content } of siblings.files) {
323
326
  for (const h of localHelpersOf(content, spec)) {
324
327
  if (!h.method || !h.normalizedPath)
325
328
  continue;
@@ -10,16 +10,26 @@ export interface SiblingFile {
10
10
  file: string;
11
11
  content: string;
12
12
  }
13
+ /** The sibling walk's result. `complete` is the walk's own verdict on whether it saw
14
+ * the whole directory: the listing succeeded AND every candidate file was read. An
15
+ * incomplete walk still returns what it found — the findings stand — but a caller
16
+ * that blocks or asserts on the ABSENCE of a sibling must not treat the list as the
17
+ * full set. A silently short list is indistinguishable from an honest one. */
18
+ export interface SiblingScan {
19
+ files: SiblingFile[];
20
+ complete: boolean;
21
+ }
13
22
  /**
14
23
  * The Skyramp-generated tests in the spec's directory, each read once — THE sibling
15
24
  * walk, shared by the two sibling call-site scans and the importer count (the inline
16
25
  * scan excludes the spec itself; the other two include it). Utils files carry no
17
- * codegen version marker, so the marker check alone excludes them. Fails open to []
18
- * on any read error.
26
+ * codegen version marker, so the marker check alone excludes them. Fails open to an
27
+ * empty, INCOMPLETE scan on a listing failure, and drops an unreadable file from a
28
+ * scan it then reports as incomplete.
19
29
  */
20
30
  export declare function generatedSiblings(testFile: string, spec: UtilsLanguageSpec, opts: {
21
31
  includeSelf: boolean;
22
- }): Promise<SiblingFile[]>;
32
+ }): Promise<SiblingScan>;
23
33
  /** An SDK request call. Global: clone with `new RegExp(...)` before `matchAll`. */
24
34
  export declare const REQUEST_CALL_RE: RegExp;
25
35
  interface InlineSiteBase {
@@ -70,7 +80,7 @@ export type InlineCallSite = (InlineSiteBase & {
70
80
  * findSiblingInlineActionSites is the browser family's, and inlineCallSiteFamily
71
81
  * decides which runs.
72
82
  */
73
- export declare function findSiblingInlineCallSites(testFile: string, utilsFiles: string[], _helpers: UtilsHelper[], spec: UtilsLanguageSpec, preReadSiblings?: SiblingFile[]): Promise<InlineCallSite[]>;
83
+ export declare function findSiblingInlineCallSites(testFile: string, utilsFiles: string[], _helpers: UtilsHelper[], spec: UtilsLanguageSpec, preReadSiblings?: SiblingScan): Promise<InlineCallSite[]>;
74
84
  /** One operation written in two or more sibling generated specs that no shared
75
85
  * helper wraps — the extraction the reuse pass exists to perform and did not. */
76
86
  export interface UnextractedDuplicate {
@@ -108,11 +118,11 @@ export interface UnextractedSite {
108
118
  * A group must span two or more FILES: a spec repeating one request twice is a
109
119
  * different question. Advisory by design — consolidation is a judgement. Never throws.
110
120
  */
111
- export declare function findUnextractedDuplicates(testFile: string, utilsFiles: string[], spec: UtilsLanguageSpec, preReadSiblings?: SiblingFile[]): Promise<UnextractedDuplicate[]>;
121
+ export declare function findUnextractedDuplicates(testFile: string, utilsFiles: string[], spec: UtilsLanguageSpec, preReadSiblings?: SiblingScan): Promise<UnextractedDuplicate[]>;
112
122
  /** The generated siblings less the utils files themselves (a utils file carries no
113
123
  * codegen marker, so this is belt and braces). Pre-read siblings are used as given —
114
124
  * the caller's walk decides whether the spec itself is among them. */
115
- export declare function nonUtilsSiblings(testFile: string, utilsFiles: string[], spec: UtilsLanguageSpec, preReadSiblings: SiblingFile[] | undefined, includeSelf: boolean): Promise<SiblingFile[]>;
125
+ export declare function nonUtilsSiblings(testFile: string, utilsFiles: string[], spec: UtilsLanguageSpec, preReadSiblings: SiblingScan | undefined, includeSelf: boolean): Promise<SiblingFile[]>;
116
126
  /** Lines after a call scanned for its schema assertion — the generated shape puts it
117
127
  * immediately after the status-code assertion. */
118
128
  export declare const SCHEMA_SCAN_LINES = 12;
@@ -14,8 +14,9 @@ const HEADER_SCAN_LINES = 5;
14
14
  * The Skyramp-generated tests in the spec's directory, each read once — THE sibling
15
15
  * walk, shared by the two sibling call-site scans and the importer count (the inline
16
16
  * scan excludes the spec itself; the other two include it). Utils files carry no
17
- * codegen version marker, so the marker check alone excludes them. Fails open to []
18
- * on any read error.
17
+ * codegen version marker, so the marker check alone excludes them. Fails open to an
18
+ * empty, INCOMPLETE scan on a listing failure, and drops an unreadable file from a
19
+ * scan it then reports as incomplete.
19
20
  */
20
21
  export async function generatedSiblings(testFile, spec, opts) {
21
22
  let dir;
@@ -27,8 +28,9 @@ export async function generatedSiblings(testFile, spec, opts) {
27
28
  entries = await fs.promises.readdir(dir);
28
29
  }
29
30
  catch {
30
- return [];
31
+ return { files: [], complete: false };
31
32
  }
33
+ let complete = true;
32
34
  const out = [];
33
35
  for (const name of entries.sort()) {
34
36
  const file = path.join(dir, name);
@@ -41,13 +43,16 @@ export async function generatedSiblings(testFile, spec, opts) {
41
43
  content = await fs.promises.readFile(file, "utf8");
42
44
  }
43
45
  catch {
46
+ // A candidate this walk could not read may or may not have been a generated
47
+ // test importing the module — so the scan is short by an unknown amount.
48
+ complete = false;
44
49
  continue;
45
50
  }
46
51
  if (!CODEGEN_MARKER_RE.test(content.split("\n").slice(0, HEADER_SCAN_LINES).join("\n")))
47
52
  continue;
48
53
  out.push({ file, content });
49
54
  }
50
- return out;
55
+ return { files: out, complete };
51
56
  }
52
57
  /** An SDK request call. Global: clone with `new RegExp(...)` before `matchAll`. */
53
58
  export const REQUEST_CALL_RE = /\b(?:send_request|sendRequest)\s*\(/g;
@@ -183,8 +188,9 @@ async function helperRoutes(utilsFiles, spec) {
183
188
  * the caller's walk decides whether the spec itself is among them. */
184
189
  export async function nonUtilsSiblings(testFile, utilsFiles, spec, preReadSiblings, includeSelf) {
185
190
  const utilsSet = new Set(await Promise.all(utilsFiles.map((f) => realpath(f))));
186
- return (preReadSiblings ??
187
- (await generatedSiblings(testFile, spec, { includeSelf }))).filter((sib) => !utilsSet.has(path.resolve(sib.file)));
191
+ const scan = preReadSiblings ??
192
+ (await generatedSiblings(testFile, spec, { includeSelf }));
193
+ return scan.files.filter((sib) => !utilsSet.has(path.resolve(sib.file)));
188
194
  }
189
195
  /** Every keyable request call in a generated test. Calls whose method or path cannot
190
196
  * be read are skipped: they have no route to compare. Calls are located, and their
@@ -0,0 +1,38 @@
1
+ import type { UtilsLanguageSpec } from "./language-spec.js";
2
+ import type { HelperChangeKind } from "../../types/ReuseOutcome.js";
3
+ export interface ChangedHelper {
4
+ helper: string;
5
+ /** 1-based line of the definition as the module stands now; `0` for a removed
6
+ * helper, which has no line in the current file. */
7
+ line: number;
8
+ change: HelperChangeKind;
9
+ }
10
+ /**
11
+ * The helpers a run CHANGED, by comparing the module as it stands against the same
12
+ * module at the incumbent revision.
13
+ *
14
+ * Both sides are parsed with the same reader the verifier uses, so each body is taken
15
+ * to its structural terminator on a comments-and-strings-blanked copy — a brace typed
16
+ * into a selector or a `#` inside a Python string cannot move a helper's end and
17
+ * swallow the helpers after it.
18
+ *
19
+ * Comparison is deliberately INSENSITIVE to formatting and SENSITIVE to literals.
20
+ * Comments are blanked and whitespace OUTSIDE string literals is removed, so a
21
+ * Prettier or Black rewrap — which also inserts a trailing comma where it wraps —
22
+ * reads as no change; the comma before a closing bracket is dropped for the same
23
+ * reason.
24
+ *
25
+ * A string literal is copied through byte for byte — its own whitespace AND its own
26
+ * punctuation. Every normalising rule runs on the non-literal runs only.
27
+ * Whitespace inside a literal is not formatting, it is the value: `.menu .item`
28
+ * matches a descendant and `.menu.item` matches one element carrying both classes,
29
+ * and a filled `"Acme Corp"` is not `"AcmeCorp"`. A normaliser that strips whitespace
30
+ * everywhere reads all three pairs as unchanged, which silently disables this check
31
+ * on the browser selector case it exists for.
32
+ *
33
+ * The residual error is one-directional and chosen: two genuinely different bodies
34
+ * can normalise equal (Python's indentation carries meaning outside literals, and
35
+ * this drops it), which costs a report line. Nothing here can report a helper as
36
+ * changed that is not, which is the error that would cost a false block.
37
+ */
38
+ export declare function changedHelpersBetween(headContent: string, currentContent: string, spec: UtilsLanguageSpec): ChangedHelper[];
@@ -0,0 +1,91 @@
1
+ import { blankComments, parseUtilsFile, stringSpansOf, } from "./parse.js";
2
+ /**
3
+ * The helpers a run CHANGED, by comparing the module as it stands against the same
4
+ * module at the incumbent revision.
5
+ *
6
+ * Both sides are parsed with the same reader the verifier uses, so each body is taken
7
+ * to its structural terminator on a comments-and-strings-blanked copy — a brace typed
8
+ * into a selector or a `#` inside a Python string cannot move a helper's end and
9
+ * swallow the helpers after it.
10
+ *
11
+ * Comparison is deliberately INSENSITIVE to formatting and SENSITIVE to literals.
12
+ * Comments are blanked and whitespace OUTSIDE string literals is removed, so a
13
+ * Prettier or Black rewrap — which also inserts a trailing comma where it wraps —
14
+ * reads as no change; the comma before a closing bracket is dropped for the same
15
+ * reason.
16
+ *
17
+ * A string literal is copied through byte for byte — its own whitespace AND its own
18
+ * punctuation. Every normalising rule runs on the non-literal runs only.
19
+ * Whitespace inside a literal is not formatting, it is the value: `.menu .item`
20
+ * matches a descendant and `.menu.item` matches one element carrying both classes,
21
+ * and a filled `"Acme Corp"` is not `"AcmeCorp"`. A normaliser that strips whitespace
22
+ * everywhere reads all three pairs as unchanged, which silently disables this check
23
+ * on the browser selector case it exists for.
24
+ *
25
+ * The residual error is one-directional and chosen: two genuinely different bodies
26
+ * can normalise equal (Python's indentation carries meaning outside literals, and
27
+ * this drops it), which costs a report line. Nothing here can report a helper as
28
+ * changed that is not, which is the error that would cost a false block.
29
+ */
30
+ export function changedHelpersBetween(headContent, currentContent, spec) {
31
+ const head = byName(parseUtilsFile(headContent, spec), headContent, spec);
32
+ const now = byName(parseUtilsFile(currentContent, spec), currentContent, spec);
33
+ const out = [];
34
+ for (const [name, was] of head) {
35
+ const is = now.get(name);
36
+ if (!is) {
37
+ out.push({ helper: name, line: 0, change: "removed" });
38
+ continue;
39
+ }
40
+ if (was.params !== is.params)
41
+ out.push({ helper: name, line: is.line, change: "signature" });
42
+ else if (was.body !== is.body)
43
+ out.push({ helper: name, line: is.line, change: "body" });
44
+ }
45
+ return out;
46
+ }
47
+ function byName(helpers, content, spec) {
48
+ const norm = (text) => normalize(text, spec);
49
+ // Comments blanked, strings kept, offsets preserved: a reworded comment is not a
50
+ // change to what a caller runs, and a selector literal is.
51
+ const lines = blankComments(content, spec).split("\n");
52
+ const out = new Map();
53
+ for (const h of helpers) {
54
+ // A module that defines one name twice is already a blocking `helper-redefined`
55
+ // finding; keep the first definition rather than letting the second decide.
56
+ if (out.has(h.name))
57
+ continue;
58
+ out.set(h.name, {
59
+ line: h.line,
60
+ params: (h.params ?? []).map(norm).join(","),
61
+ // From startLine, not line: a decorator is part of what the helper does.
62
+ body: norm(lines.slice(h.startLine - 1, h.endLine).join("\n")),
63
+ });
64
+ }
65
+ return out;
66
+ }
67
+ /**
68
+ * Whitespace removed OUTSIDE string literals — the one textual difference a
69
+ * formatter introduces that this treats as no change. Literals are copied through
70
+ * unchanged: their whitespace is the value, not formatting.
71
+ *
72
+ * A formatter's trailing comma is NOT normalised away, deliberately. Four versions
73
+ * of a comma rule were tried and each produced the next defect (`("x",)` is a tuple
74
+ * and `("x")` a string; `[1,,]` has length 2), and what the rule bought was one
75
+ * fewer execution round on a rewrap. Under this feature's own trade — an unneeded
76
+ * run of a caller that is runnable by construction, over a missed change — that is
77
+ * the cheaper side. A rewrapped helper reads as changed and its callers run once.
78
+ */
79
+ function normalize(text, spec) {
80
+ const spans = stringSpansOf(text, spec);
81
+ let out = "";
82
+ let at = 0;
83
+ for (const { start, end } of spans) {
84
+ if (start < at)
85
+ continue; // nested or overlapping: the outer span already carried it
86
+ out += text.slice(at, start).replace(/\s+/g, "");
87
+ out += text.slice(start, end);
88
+ at = end;
89
+ }
90
+ return out + text.slice(at).replace(/\s+/g, "");
91
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The repository's committed state, asked of git. ONE reader of a file at HEAD and
3
+ * ONE lister of what changed since, so that every gate comparing a delivered file
4
+ * against its baseline agrees on what "at HEAD" means — and so a fix to the
5
+ * canonicalisation reaches all of them at once. Four readers had grown up
6
+ * independently, and the one that took `path.relative(root, file)` uncanonicalised
7
+ * still carried the symlink fault the others had fixed.
8
+ */
9
+ /** git's own wording for "this path is not in HEAD". Everything else — git missing,
10
+ * a corrupt object store, a permission error — is git NOT ANSWERING, and the two are
11
+ * never treated alike: absence is the ordinary, silent case (this run wrote the
12
+ * file), and a malfunction must neither become a finding nor vanish. Stated once,
13
+ * so two readers cannot drift on which errors mean which. */
14
+ export declare function isAbsentFromHead(err: unknown): boolean;
15
+ /**
16
+ * The file's content at HEAD, or `undefined` when there is none to read.
17
+ *
18
+ * `undefined` covers three cases that must all stay silent: outside a repository, the
19
+ * file absent from HEAD (this run created it), and git failing to answer. Reporting
20
+ * anything about a file on any of them would be a claim the evidence does not carry.
21
+ * Only the third is logged, as a warning — a malfunction must not vanish either.
22
+ *
23
+ * `root` is any directory of the checkout, and an optimisation for a caller that
24
+ * already holds one. The repository's top level is resolved from it — or from the
25
+ * file's directory, which is still there for a file this run deleted — because
26
+ * `HEAD:<path>` speaks in paths from the top.
27
+ */
28
+ export declare function contentAtHead(file: string, root?: string): Promise<string | undefined>;
29
+ /**
30
+ * Absolute, CANONICAL paths of the files that differ from HEAD in the repository
31
+ * `root` is in, by git's `--diff-filter` letters (`M` modified, `D` deleted, `DM`
32
+ * both …). Canonical for a deleted file too — its parent resolves — so a caller may
33
+ * key on them directly; that is part of the contract, not an accident.
34
+ *
35
+ * `--no-renames` is load-bearing, not tidiness. With rename detection on — the
36
+ * default since git 2.9 — moving a file reports one `R` and NO `D`, so a caller
37
+ * asking for deletions never hears of a module that was renamed away while every
38
+ * test still imports it by its old name. A rename is a deletion here, because a
39
+ * caller's import does not move with the file.
40
+ *
41
+ * `-z`: NUL-separated and unquoted, so a non-ASCII path stays resolvable.
42
+ *
43
+ * Throws when git cannot answer: each caller says in its own words what it will not
44
+ * do as a result, and none of them may read a failed listing as an empty one.
45
+ */
46
+ export declare function changedSinceHead(root: string, opts: {
47
+ filter: string;
48
+ }): Promise<string[]>;
@@ -0,0 +1,115 @@
1
+ import { execFile } from "child_process";
2
+ import { promisify } from "util";
3
+ import * as path from "path";
4
+ import { detectGitRoot } from "../gitStaging.js";
5
+ import { logger } from "../logger.js";
6
+ import { realpath } from "./locate.js";
7
+ const execFileAsync = promisify(execFile);
8
+ /**
9
+ * The repository's committed state, asked of git. ONE reader of a file at HEAD and
10
+ * ONE lister of what changed since, so that every gate comparing a delivered file
11
+ * against its baseline agrees on what "at HEAD" means — and so a fix to the
12
+ * canonicalisation reaches all of them at once. Four readers had grown up
13
+ * independently, and the one that took `path.relative(root, file)` uncanonicalised
14
+ * still carried the symlink fault the others had fixed.
15
+ */
16
+ /** git's own wording for "this path is not in HEAD". Everything else — git missing,
17
+ * a corrupt object store, a permission error — is git NOT ANSWERING, and the two are
18
+ * never treated alike: absence is the ordinary, silent case (this run wrote the
19
+ * file), and a malfunction must neither become a finding nor vanish. Stated once,
20
+ * so two readers cannot drift on which errors mean which. */
21
+ export function isAbsentFromHead(err) {
22
+ const e = err;
23
+ return /does not exist|exists on disk, but not in|Not a valid object name|bad revision|unknown revision/i.test(`${e?.stderr ?? ""} ${String(err)}`);
24
+ }
25
+ /** Canonical absolute path, for a file that may be GONE: `realpath` falls back to
26
+ * `path.resolve` on a missing file, which leaves a symlinked parent (macOS `/tmp` →
27
+ * `/private/tmp`) unresolved and relativises the path to `../../…` against a
28
+ * canonical root. The parent directory is still there to resolve. */
29
+ async function canonicalEvenIfGone(p) {
30
+ const resolved = await realpath(p);
31
+ if (resolved !== path.resolve(p))
32
+ return resolved; // it exists, and moved
33
+ // Already canonical, or gone: resolving the parent answers both.
34
+ return path.join(await realpath(path.dirname(p)), path.basename(p));
35
+ }
36
+ /** `file` relative to the repository's top level as git spells it (posix, both
37
+ * sides canonical), or undefined when the file is outside it. */
38
+ async function gitPath(file, top) {
39
+ const rel = path.relative(await realpath(top), await canonicalEvenIfGone(file));
40
+ if (rel === "" ||
41
+ rel === ".." ||
42
+ rel.startsWith(`..${path.sep}`) ||
43
+ path.isAbsolute(rel))
44
+ return undefined;
45
+ return rel.split(path.sep).join("/");
46
+ }
47
+ /**
48
+ * The file's content at HEAD, or `undefined` when there is none to read.
49
+ *
50
+ * `undefined` covers three cases that must all stay silent: outside a repository, the
51
+ * file absent from HEAD (this run created it), and git failing to answer. Reporting
52
+ * anything about a file on any of them would be a claim the evidence does not carry.
53
+ * Only the third is logged, as a warning — a malfunction must not vanish either.
54
+ *
55
+ * `root` is any directory of the checkout, and an optimisation for a caller that
56
+ * already holds one. The repository's top level is resolved from it — or from the
57
+ * file's directory, which is still there for a file this run deleted — because
58
+ * `HEAD:<path>` speaks in paths from the top.
59
+ */
60
+ export async function contentAtHead(file, root) {
61
+ const top = detectGitRoot(root ?? path.dirname(file));
62
+ if (!top)
63
+ return undefined;
64
+ const rel = await gitPath(file, top);
65
+ if (rel === undefined)
66
+ return undefined;
67
+ try {
68
+ const { stdout } = await execFileAsync("git", ["show", `HEAD:${rel}`], {
69
+ cwd: top,
70
+ encoding: "utf8",
71
+ maxBuffer: 16 * 1024 * 1024,
72
+ });
73
+ return stdout;
74
+ }
75
+ catch (err) {
76
+ if (!isAbsentFromHead(err))
77
+ logger.warning("Could not read a file at HEAD — whatever compares against it will not run", { file, error: String(err) });
78
+ return undefined;
79
+ }
80
+ }
81
+ /**
82
+ * Absolute, CANONICAL paths of the files that differ from HEAD in the repository
83
+ * `root` is in, by git's `--diff-filter` letters (`M` modified, `D` deleted, `DM`
84
+ * both …). Canonical for a deleted file too — its parent resolves — so a caller may
85
+ * key on them directly; that is part of the contract, not an accident.
86
+ *
87
+ * `--no-renames` is load-bearing, not tidiness. With rename detection on — the
88
+ * default since git 2.9 — moving a file reports one `R` and NO `D`, so a caller
89
+ * asking for deletions never hears of a module that was renamed away while every
90
+ * test still imports it by its old name. A rename is a deletion here, because a
91
+ * caller's import does not move with the file.
92
+ *
93
+ * `-z`: NUL-separated and unquoted, so a non-ASCII path stays resolvable.
94
+ *
95
+ * Throws when git cannot answer: each caller says in its own words what it will not
96
+ * do as a result, and none of them may read a failed listing as an empty one.
97
+ */
98
+ export async function changedSinceHead(root, opts) {
99
+ const top = detectGitRoot(root);
100
+ if (!top)
101
+ throw new Error(`${root} is not inside a git repository`);
102
+ const { stdout } = await execFileAsync("git", [
103
+ "diff",
104
+ "-z",
105
+ "--name-only",
106
+ `--diff-filter=${opts.filter}`,
107
+ "--no-renames",
108
+ "HEAD",
109
+ ], { cwd: top, encoding: "utf8" });
110
+ const realTop = await realpath(top);
111
+ const out = [];
112
+ for (const rel of stdout.split("\0").filter(Boolean))
113
+ out.push(await canonicalEvenIfGone(path.resolve(realTop, rel)));
114
+ return out;
115
+ }