@opengsd/gsd-core 1.20.0 → 1.21.0

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 (154) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +33 -2
  4. package/agents/gsd-executor.md +1 -1
  5. package/agents/gsd-plan-checker.md +1 -1
  6. package/agents/gsd-planner.md +3 -3
  7. package/agents/gsd-verifier.md +2 -2
  8. package/bin/install.js +265 -161
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/commands/gsd/new-milestone.md +2 -2
  11. package/commands/gsd/new-project.md +2 -2
  12. package/gsd-core/bin/gsd-tools.cjs +554 -823
  13. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  14. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  15. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  16. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  17. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  18. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  19. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  20. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  21. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  22. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  23. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  24. package/gsd-core/bin/lib/commands.cjs +99 -2
  25. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  26. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  27. package/gsd-core/bin/lib/config.cjs +139 -101
  28. package/gsd-core/bin/lib/configuration.cjs +41 -2
  29. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  30. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  31. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  32. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  33. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  34. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  35. package/gsd-core/bin/lib/graphify.cjs +19 -2
  36. package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
  37. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  38. package/gsd-core/bin/lib/init.cjs +20 -18
  39. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  40. package/gsd-core/bin/lib/install-engine.cjs +20 -28
  41. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  42. package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
  43. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  44. package/gsd-core/bin/lib/install-profiles.cjs +4 -2
  45. package/gsd-core/bin/lib/io.cjs +6 -0
  46. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  47. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  48. package/gsd-core/bin/lib/milestone.cjs +8 -6
  49. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  50. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  51. package/gsd-core/bin/lib/phase-id.cjs +26 -9
  52. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  53. package/gsd-core/bin/lib/phase.cjs +170 -45
  54. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  55. package/gsd-core/bin/lib/planning-snapshot.cjs +10 -6
  56. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  57. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  58. package/gsd-core/bin/lib/project-context.cjs +439 -0
  59. package/gsd-core/bin/lib/project-root.cjs +9 -230
  60. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  61. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +357 -50
  63. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  64. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  65. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +317 -134
  66. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  67. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
  68. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  69. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  71. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  72. package/gsd-core/bin/lib/state-transition.cjs +103 -46
  73. package/gsd-core/bin/lib/state.cjs +448 -63
  74. package/gsd-core/bin/lib/surface.cjs +4 -7
  75. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  76. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  77. package/gsd-core/bin/lib/verify.cjs +49 -6
  78. package/gsd-core/bin/lib/workstream-inventory.cjs +4 -4
  79. package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
  80. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  81. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  82. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  83. package/gsd-core/references/checkpoints.md +1 -1
  84. package/gsd-core/references/planner-guidance.md +11 -0
  85. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  86. package/gsd-core/references/planner-reversibility.md +2 -0
  87. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  88. package/gsd-core/workflows/autonomous.md +4 -4
  89. package/gsd-core/workflows/code-review-fix.md +5 -7
  90. package/gsd-core/workflows/execute-phase.md +21 -7
  91. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  92. package/gsd-core/workflows/plan-phase.md +18 -19
  93. package/hooks/dist/gsd-agent-isolation-guard.js +4 -3
  94. package/hooks/dist/gsd-config-reload.js +2 -2
  95. package/hooks/dist/gsd-context-monitor.js +2 -1
  96. package/hooks/dist/gsd-cursor-subagent-start.js +9 -6
  97. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  98. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  99. package/hooks/dist/gsd-statusline.js +3 -2
  100. package/hooks/dist/gsd-workflow-guard.js +2 -1
  101. package/hooks/dist/lib/config-read.js +49 -0
  102. package/hooks/dist/lib/git-cmd.js +3 -1
  103. package/hooks/dist/lib/git-head-tree.js +474 -0
  104. package/hooks/dist/lib/isolation-sentinel.js +168 -13
  105. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  106. package/hooks/gsd-agent-isolation-guard.js +4 -3
  107. package/hooks/gsd-config-reload.js +2 -2
  108. package/hooks/gsd-context-monitor.js +2 -1
  109. package/hooks/gsd-cursor-subagent-start.js +9 -6
  110. package/hooks/gsd-read-injection-scanner.js +2 -2
  111. package/hooks/gsd-secret-read-guard.js +141 -6
  112. package/hooks/gsd-statusline.js +3 -2
  113. package/hooks/gsd-workflow-guard.js +2 -1
  114. package/hooks/lib/config-read.js +49 -0
  115. package/hooks/lib/git-cmd.js +3 -1
  116. package/hooks/lib/git-head-tree.js +474 -0
  117. package/hooks/lib/isolation-sentinel.js +168 -13
  118. package/hooks/lib/secret-read-exemptions.js +161 -0
  119. package/package.json +7 -2
  120. package/pi/package-agents/gsd-executor.md +1 -1
  121. package/pi/package-agents/gsd-plan-checker.md +1 -1
  122. package/pi/package-agents/gsd-planner.md +3 -3
  123. package/pi/package-agents/gsd-verifier.md +2 -2
  124. package/pi/package-agents/manifest.json +4 -4
  125. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  126. package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
  127. package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
  128. package/scripts/changeset/cli.cjs +2 -2
  129. package/scripts/changeset/lint.cjs +28 -3
  130. package/scripts/changeset/serialize.cjs +12 -2
  131. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  132. package/scripts/ci-fix-next-rerun.cjs +149 -0
  133. package/scripts/ci-next-health.cjs +52 -9
  134. package/scripts/ci-test-scope.cjs +77 -71
  135. package/scripts/command-contract-helpers.cjs +56 -7
  136. package/scripts/docs-guard-registry.cjs +3 -0
  137. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  138. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  139. package/scripts/lib/macos-conformance-tier.generated.cjs +59 -47
  140. package/scripts/lib/platform-conformance-tier.generated.cjs +23 -64
  141. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  142. package/scripts/lint-removed-but-needed.cjs +562 -50
  143. package/scripts/merge-commit-pr.cjs +186 -0
  144. package/scripts/release-ancestry.cjs +86 -0
  145. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  146. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  147. package/scripts/release-pipeline-pr.cjs +92 -0
  148. package/scripts/require-issue-link-policy.cjs +18 -9
  149. package/scripts/sync-next-version.cjs +11 -2
  150. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  151. package/skills/gsd-autonomous/SKILL.md +1 -1
  152. package/skills/gsd-new-milestone/SKILL.md +1 -1
  153. package/skills/gsd-new-project/SKILL.md +1 -1
  154. package/vscode/package.json +1 -1
@@ -20,6 +20,9 @@ const LINT_REASON = Object.freeze({
20
20
  OK_FRAGMENT_PRESENT: 'ok_fragment_present',
21
21
  OK_OPT_OUT_LABEL: 'ok_opt_out_label',
22
22
  OK_NO_USER_FACING_CHANGES: 'ok_no_user_facing_changes',
23
+ // #5903: the release pipeline's own PRs consume every fragment into
24
+ // CHANGELOG.md, so they carry none by design.
25
+ OK_RELEASE_PIPELINE: 'ok_release_pipeline',
23
26
  FAIL_MISSING_FRAGMENT: 'fail_missing_fragment',
24
27
  FAIL_INVALID_FRAGMENT: 'fail_invalid_fragment',
25
28
  FAIL_PR_FIELD_DRIFT: 'fail_pr_field_drift',
@@ -86,13 +89,18 @@ function findPrFieldDrift(prEntries, realPrNumber) {
86
89
  return drift;
87
90
  }
88
91
 
89
- function evaluateLint({ changedFiles, labels, fragmentFailures = [], prFieldDrift = [] }) {
92
+ function evaluateLint({
93
+ changedFiles, labels, fragmentFailures = [], prFieldDrift = [], releasePipelineKind = null,
94
+ }) {
90
95
  if (fragmentFailures.length > 0) {
91
96
  return { ok: false, reason: LINT_REASON.FAIL_INVALID_FRAGMENT, failures: fragmentFailures };
92
97
  }
93
98
  if (prFieldDrift.length > 0) {
94
99
  return { ok: false, reason: LINT_REASON.FAIL_PR_FIELD_DRIFT, drift: prFieldDrift };
95
100
  }
101
+ if (releasePipelineKind) {
102
+ return { ok: true, reason: LINT_REASON.OK_RELEASE_PIPELINE, releasePipelineKind };
103
+ }
96
104
  if (changedFiles.some(isFragment)) {
97
105
  return { ok: true, reason: LINT_REASON.OK_FRAGMENT_PRESENT };
98
106
  }
@@ -107,6 +115,7 @@ function evaluateLint({ changedFiles, labels, fragmentFailures = [], prFieldDrif
107
115
 
108
116
  const { ExitError, runMain } = require('../lib/cli-exit.cjs');
109
117
  const { parseFragment } = require('./parse.cjs');
118
+ const { releasePipelinePrKind } = require('../release-pipeline-pr.cjs');
110
119
 
111
120
  function main() {
112
121
  const fs = require('node:fs');
@@ -119,6 +128,12 @@ function main() {
119
128
  // `pull_request` in the payload, or no payload at all) — the drift check
120
129
  // below is a no-op in that case, it never fails a push run.
121
130
  let realPrNumber = null;
131
+ // #5903: head/base refs and whether the head lives in this repository, for
132
+ // the release-pipeline exemption. sameRepo is false when anything is missing
133
+ // (a deleted fork has head.repo === null).
134
+ let headRef;
135
+ let baseRef;
136
+ let sameRepo = false;
122
137
  if (eventPath && fs.existsSync(eventPath)) {
123
138
  try {
124
139
  const event = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
@@ -126,6 +141,12 @@ function main() {
126
141
  if (event.pull_request && Number.isInteger(event.pull_request.number)) {
127
142
  realPrNumber = event.pull_request.number;
128
143
  }
144
+ headRef = event.pull_request?.head?.ref;
145
+ baseRef = event.pull_request?.base?.ref;
146
+ const headRepo = event.pull_request?.head?.repo?.full_name;
147
+ const thisRepo = process.env.GITHUB_REPOSITORY;
148
+ sameRepo = typeof headRepo === 'string' && typeof thisRepo === 'string'
149
+ && headRepo !== '' && headRepo.toLowerCase() === thisRepo.toLowerCase();
129
150
  } catch { /* fall through */ }
130
151
  }
131
152
  // #2988: local fallback must match the repo's integration branch (`next`),
@@ -176,11 +197,15 @@ function main() {
176
197
  }
177
198
 
178
199
  const prFieldDrift = findPrFieldDrift(prEntries, realPrNumber);
179
- const verdict = evaluateLint({ changedFiles, labels, fragmentFailures, prFieldDrift });
200
+ const releasePipelineKind = releasePipelinePrKind({ headRef, baseRef, sameRepo });
201
+ const verdict = evaluateLint({
202
+ changedFiles, labels, fragmentFailures, prFieldDrift, releasePipelineKind,
203
+ });
180
204
  if (process.argv.includes('--json')) {
181
205
  process.stdout.write(JSON.stringify({ ...verdict, changedFiles, labels }, null, 2) + '\n');
182
206
  } else if (verdict.ok) {
183
- process.stdout.write(`ok changeset-lint: ${verdict.reason}\n`);
207
+ const kind = verdict.releasePipelineKind ? ` (${verdict.releasePipelineKind})` : '';
208
+ process.stdout.write(`ok changeset-lint: ${verdict.reason}${kind}\n`);
184
209
  } else if (verdict.reason === LINT_REASON.FAIL_INVALID_FRAGMENT) {
185
210
  process.stderr.write(`\nERROR changeset-lint: ${verdict.reason}\n`);
186
211
  process.stderr.write(`The following .changeset fragment(s) failed content validation:\n`);
@@ -1,5 +1,15 @@
1
1
  'use strict';
2
2
 
3
+ // Body of a dated release rendered by `render --allow-empty` when no fragment
4
+ // exists (cli.cjs injectEmptyPlaceholder). Shared so consumers recognise it.
5
+ const EMPTY_RELEASE_PLACEHOLDER = '_No notable changes._';
6
+
7
+ // F3: matches linked headers: ## [1.42.1](url) - 2026-05-15. The
8
+ // (?:\([^)]*\))? group skips an optional (url) after the closing ] before the
9
+ // optional date suffix. Exported so line-slicing consumers (release-notes
10
+ // builder, #5922) locate headings with the exact regex parseChangelog uses.
11
+ const RELEASE_HEADING_RE = /^##\s+\[([^\]]+)\](?:\([^)]*\))?\s*(?:-\s*(\S+))?/;
12
+
3
13
  /**
4
14
  * Markdown serializer + parser for the changelog IR. The two are inverses
5
15
  * over the well-formed subset; tests assert via round-trip (parse(serialize(ir)))
@@ -87,7 +97,7 @@ function parseChangelog(text) {
87
97
  // before looking for the optional date suffix.
88
98
  // F6: strip a leading `v` from the captured version so `## [v1.0.0]`
89
99
  // parses as version "1.0.0" instead of "v1.0.0".
90
- const releaseMatch = line.match(/^##\s+\[([^\]]+)\](?:\([^)]*\))?\s*(?:-\s*(\S+))?/);
100
+ const releaseMatch = line.match(RELEASE_HEADING_RE);
91
101
  if (releaseMatch) {
92
102
  flushBullet();
93
103
  const rawVersion = releaseMatch[1];
@@ -131,4 +141,4 @@ function parseChangelog(text) {
131
141
  return { releases };
132
142
  }
133
143
 
134
- module.exports = { serializeChangelog, parseChangelog };
144
+ module.exports = { serializeChangelog, parseChangelog, RELEASE_HEADING_RE, EMPTY_RELEASE_PLACEHOLDER };
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Compacts one CI shard's raw V8 coverage dumps into a single merged file.
6
+ *
7
+ * Why: the `coverage-gate` job used to download every shard's raw dumps
8
+ * (~1.68 GB across 3 shards, mostly node internals, tests/ and
9
+ * node_modules scripts that c8 discards at report time) and make
10
+ * `c8 report --merge-async` parse and merge all of it twice (lib report +
11
+ * scripts floor). Doing the same filter + merge on each shard, in parallel
12
+ * with the other shards, leaves the gate three small files to merge.
13
+ *
14
+ * Exactness: this applies the SAME steps c8's Report applies before it
15
+ * converts to istanbul — `_normalizeProcessCov` (node: urls, file:// urls,
16
+ * include/exclude filter), `_normalizeSourceMapCache` (file-url keys; a
17
+ * non-file key throws, as it does in c8) and then `@bcoe/v8-coverage`
18
+ * `mergeProcessCovs`. Merging is applied in batches of MERGE_BATCH_SIZE
19
+ * (`mergeProcessCovs([acc, ...batch])`) so peak memory does not grow with the
20
+ * number of dumps (#6140). Merge grouping preserves every per-offset execution
21
+ * count (what line/statement coverage reads), but @bcoe/v8-coverage
22
+ * normalization is not associative in range structure: a block range can be
23
+ * wrapped or joined differently than in a one-pass merge, so istanbul branch
24
+ * totals may differ by merge grouping, the same variance the shard -> gate
25
+ * merge already has (pinned by test). The merged output is a valid c8
26
+ * temp-directory dump, so the gate's unchanged
27
+ * `c8 report` commands read it as if it were one more raw dump. Retained urls
28
+ * are the UNION of every include glob the gate uses, so both
29
+ * `test:coverage:report` and `test:coverage:scripts-floor` see all of the
30
+ * data they would have seen before. The one deliberate deviation from raw c8
31
+ * is `normalizeBlockFlags`, which makes a never-called function's block flag
32
+ * independent of dump count (#5985).
33
+ *
34
+ * Guard: a shard that produced no V8 coverage dumps at all throws, restoring
35
+ * the `if-no-files-found: error` guarantee the raw-dump upload step gave.
36
+ */
37
+
38
+ const fs = require('fs');
39
+ const path = require('path');
40
+ const { createRequire } = require('module');
41
+ const { fileURLToPath, pathToFileURL } = require('url');
42
+
43
+ // Single source of truth for what the gate may report on. Must stay equal to
44
+ // the union of the --include globs in package.json's test:coverage:report and
45
+ // test:coverage:scripts-floor (tests/ci-compact-v8-coverage.test.cjs pins it).
46
+ const INCLUDE_GLOBS = ['gsd-core/bin/lib/**/*.cjs', 'scripts/**/*.cjs'];
47
+ const EXCLUDE_GLOBS = ['tests/**'];
48
+ // Bounds how many filtered dumps are held before folding them into the accumulator (#6140).
49
+ const MERGE_BATCH_SIZE = 64;
50
+
51
+ function loadDeps(cwd) {
52
+ // Resolve through c8 so the versions are exactly the ones c8 itself uses.
53
+ const c8Require = createRequire(require.resolve('c8/package.json', { paths: [cwd, __dirname] }));
54
+ return {
55
+ mergeProcessCovs: c8Require('@bcoe/v8-coverage').mergeProcessCovs,
56
+ Exclude: c8Require('test-exclude'),
57
+ };
58
+ }
59
+
60
+ // Normalized href for a retained script url, or null when c8 would discard it.
61
+ function retainedHref(shouldInstrument, url) {
62
+ if (typeof url !== 'string' || !/^file:\/\//.test(url)) return null; // node: and non-file urls
63
+ const filePath = fileURLToPath(url);
64
+ return shouldInstrument(filePath) ? pathToFileURL(filePath).href : null;
65
+ }
66
+
67
+ // The same rule @bcoe/v8-coverage mergeFunctionCovs applies on a real merge
68
+ // (merge.js:158). Its single-input fast path skips it, so under c8's pairwise
69
+ // merge a never-called function adds a 0-count branch only when it appears in
70
+ // an odd number of dumps. Applying the rule unconditionally makes the gate's
71
+ // branch count independent of how dumps are split into shards (#5985).
72
+ function normalizeBlockFlags(result) {
73
+ for (const script of result) {
74
+ for (const fn of script.functions) {
75
+ if (fn.ranges.length === 1 && fn.ranges[0].count === 0) fn.isBlockCoverage = false;
76
+ }
77
+ }
78
+ }
79
+
80
+ // `mergeProcessCovs` is an injection point for tests; main() never passes it.
81
+ function compact({ tempDirectory, outFile, cwd = process.cwd(), batchSize = MERGE_BATCH_SIZE, mergeProcessCovs: mergeOverride }) {
82
+ if (!Number.isInteger(batchSize) || batchSize < 1) {
83
+ throw new RangeError(`ci-compact-v8-coverage: batchSize must be a positive integer, got ${batchSize}`);
84
+ }
85
+ const deps = loadDeps(cwd);
86
+ const mergeProcessCovs = mergeOverride || deps.mergeProcessCovs;
87
+ const { Exclude } = deps;
88
+ const exclude = new Exclude({
89
+ cwd,
90
+ include: INCLUDE_GLOBS,
91
+ // Exactly c8's set: an explicit --exclude REPLACES istanbul's default exclude
92
+ // (c8 lib/parse-args.js `exclude` default), so defaultExclude must not apply.
93
+ exclude: [...EXCLUDE_GLOBS],
94
+ relativePath: true,
95
+ excludeNodeModules: true,
96
+ });
97
+
98
+ // Memoized per path, as c8 does (report.js shouldInstrumentCache): the same
99
+ // node_modules scripts repeat across every dump.
100
+ const instrumentCache = new Map();
101
+ const shouldInstrument = (filePath) => {
102
+ let decision = instrumentCache.get(filePath);
103
+ if (decision === undefined) {
104
+ decision = exclude.shouldInstrument(filePath);
105
+ instrumentCache.set(filePath, decision);
106
+ }
107
+ return decision;
108
+ };
109
+ const hrefCache = new Map();
110
+ const hrefFor = (url) => {
111
+ if (typeof url !== 'string') return null;
112
+ let href = hrefCache.get(url);
113
+ if (href === undefined) {
114
+ href = retainedHref(shouldInstrument, url);
115
+ hrefCache.set(url, href);
116
+ }
117
+ return href;
118
+ };
119
+
120
+ let merged = null;
121
+ let pending = [];
122
+ // Count equality across merge groupings relies on a V8 invariant: detailed
123
+ // (NODE_V8_COVERAGE) coverage reports isBlockCoverage:false only for a
124
+ // never-compiled function (one range, count 0). @bcoe/v8-coverage drops
125
+ // function-level entries when block-level ones exist, so a function-level
126
+ // entry with count > 0 would merge differently by grouping.
127
+ const flush = () => {
128
+ if (pending.length === 0) return;
129
+ merged = mergeProcessCovs(merged ? [merged, ...pending] : pending);
130
+ pending = [];
131
+ };
132
+ const sourceMapCache = {};
133
+ let inputFiles = 0;
134
+ for (const name of fs.readdirSync(tempDirectory).sort()) {
135
+ let dump;
136
+ try {
137
+ dump = JSON.parse(fs.readFileSync(path.join(tempDirectory, name), 'utf8'));
138
+ } catch {
139
+ continue; // c8 skips unparsable dumps too
140
+ }
141
+ if (!dump || !Array.isArray(dump.result)) continue;
142
+ inputFiles++;
143
+ const result = [];
144
+ for (const script of dump.result) {
145
+ const href = hrefFor(script.url);
146
+ if (href === null) continue;
147
+ script.url = href;
148
+ result.push(script);
149
+ }
150
+ const cache = dump['source-map-cache'];
151
+ if (cache) {
152
+ for (const key of Object.keys(cache)) {
153
+ // c8's _normalizeSourceMapCache calls fileURLToPath(key) unguarded: a
154
+ // non-file key throws there, so it throws here too.
155
+ const keyPath = fileURLToPath(key);
156
+ if (shouldInstrument(keyPath)) sourceMapCache[pathToFileURL(keyPath).href] = cache[key];
157
+ }
158
+ }
159
+ pending.push({ result });
160
+ if (pending.length >= batchSize) flush();
161
+ }
162
+
163
+ if (inputFiles === 0) {
164
+ throw new Error(`ci-compact-v8-coverage: no V8 coverage dumps in ${tempDirectory}`);
165
+ }
166
+
167
+ flush();
168
+ const out = { result: merged ? merged.result : [], 'source-map-cache': sourceMapCache };
169
+ normalizeBlockFlags(out.result);
170
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
171
+ fs.writeFileSync(outFile, JSON.stringify(out));
172
+ return { inputFiles, scripts: out.result.length };
173
+ }
174
+
175
+ const USAGE = 'usage: ci-compact-v8-coverage.cjs --out <file> [--temp-directory <dir>]';
176
+
177
+ function parseArgs(argv) {
178
+ // undefined: flag absent; null: flag present without a usable value.
179
+ const value = (flag) => {
180
+ const i = argv.indexOf(flag);
181
+ if (i === -1) return undefined;
182
+ const v = argv[i + 1];
183
+ return v === undefined || v.startsWith('--') ? null : v;
184
+ };
185
+ const outFile = value('--out');
186
+ const tempDirectory = value('--temp-directory');
187
+ if (!outFile || tempDirectory === null) return { error: USAGE };
188
+ return { tempDirectory: tempDirectory === undefined ? path.join('coverage', 'tmp') : tempDirectory, outFile };
189
+ }
190
+
191
+ function main(argv) {
192
+ const parsed = parseArgs(argv);
193
+ if (parsed.error) {
194
+ console.error(parsed.error);
195
+ process.exitCode = 2;
196
+ return;
197
+ }
198
+ const stats = compact({ tempDirectory: parsed.tempDirectory, outFile: parsed.outFile });
199
+ console.log(`compacted ${stats.inputFiles} dumps -> ${stats.scripts} scripts in ${parsed.outFile}`);
200
+ }
201
+
202
+ if (require.main === module) main(process.argv.slice(2));
203
+
204
+ module.exports = { compact, parseArgs, main, INCLUDE_GLOBS, EXCLUDE_GLOBS, MERGE_BATCH_SIZE };
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // ci-fix-next-rerun.cjs — #6076: re-run ONLY the `Base branch health` job of a
5
+ // PR's latest Tests run after the `fix-next` label was added or removed.
6
+ //
7
+ // Runs from .github/workflows/fix-next-rerun.yml (pull_request_target) against
8
+ // the BASE sha's copy of this file, never the PR's. The core is dependency
9
+ // injected (`gh`, `now`, `sleep`) so every branch is tested without a network.
10
+
11
+ const { execFile } = require('node:child_process');
12
+ const { runMain } = require('./lib/cli-exit.cjs');
13
+
14
+ const BASE_HEALTH_JOB_NAME = 'Base branch health';
15
+ const TESTS_WORKFLOW_FILE = 'test.yml';
16
+ const GH_TIMEOUT_MS = 30000;
17
+ const DEFAULT_DEADLINE_MS = 85 * 60 * 1000;
18
+ const DEFAULT_POLL_MS = 30 * 1000;
19
+ const PR_NUMBER_PATTERN = /^[1-9]\d*$/;
20
+ const HEAD_SHA_PATTERN = /^[0-9a-f]{40}$/i;
21
+
22
+ /**
23
+ * @param {object} opts
24
+ * @param {(args:string[]) => Promise<string>} opts.gh stdout of `gh <args>`; throws on failure
25
+ * @param {string} opts.repo owner/repo
26
+ * @param {string} opts.headSha head sha from the label event payload
27
+ * @param {string} opts.prNumber PR number
28
+ * @param {() => number} opts.now ms clock (read once at start, once per incomplete poll)
29
+ * @param {(ms:number) => Promise<void>} opts.sleep
30
+ * @param {number} opts.deadlineMs how long to wait for the run to complete
31
+ * @param {number} opts.pollMs
32
+ * @returns {Promise<{action:'rerun'|'skip'|'error', reason:string, runId?:string, jobId?:string}>}
33
+ */
34
+ async function rerunBaseHealth({
35
+ gh, repo, headSha, prNumber, now, sleep, deadlineMs = DEFAULT_DEADLINE_MS, pollMs = DEFAULT_POLL_MS,
36
+ }) {
37
+ try {
38
+ const deadline = now() + deadlineMs;
39
+ let run;
40
+ for (;;) {
41
+ const listed = JSON.parse(await gh([
42
+ 'run', 'list', '--workflow', TESTS_WORKFLOW_FILE, '--commit', headSha,
43
+ '--event', 'pull_request', '--limit', '1', '--json', 'databaseId,status,conclusion',
44
+ ]));
45
+ if (!Array.isArray(listed) || listed.length === 0) return { action: 'skip', reason: 'no-run' };
46
+ [run] = listed;
47
+ if (run.status === 'completed') break;
48
+ if (now() >= deadline) return { action: 'error', reason: 'timeout', runId: String(run.databaseId) };
49
+ await sleep(pollMs);
50
+ }
51
+ const runId = String(run.databaseId);
52
+
53
+ // A cancelled run was superseded by a newer one, which reads the label from
54
+ // the API; re-running a job here would rejoin the Tests concurrency group
55
+ // and could cancel that newer run.
56
+ if (run.conclusion === 'cancelled') return { action: 'skip', reason: 'run-cancelled', runId };
57
+
58
+ // A run waiting for approval has not executed; there is nothing to re-run.
59
+ if (run.conclusion === 'action_required') return { action: 'skip', reason: 'run-awaiting-approval', runId };
60
+
61
+ const readHead = async () => (await gh(['api', `repos/${repo}/pulls/${prNumber}`, '--jq', '.head.sha'])).trim();
62
+ if ((await readHead()) !== headSha) return { action: 'skip', reason: 'head-moved', runId };
63
+
64
+ const view = JSON.parse(await gh(['run', 'view', runId, '--json', 'jobs']));
65
+ const jobs = view && Array.isArray(view.jobs) ? view.jobs : [];
66
+ const job = jobs.find((j) => j && j.name === BASE_HEALTH_JOB_NAME);
67
+ if (!job) return { action: 'error', reason: 'no-job', runId };
68
+ const jobId = String(job.databaseId);
69
+
70
+ // Second read immediately before the re-run, to shrink the stale-run window.
71
+ if ((await readHead()) !== headSha) return { action: 'skip', reason: 'head-moved', runId };
72
+
73
+ await gh(['run', 'rerun', runId, '--job', jobId]);
74
+ return { action: 'rerun', reason: 'rerun', runId, jobId };
75
+ } catch (err) {
76
+ return { action: 'error', reason: `gh-failed: ${err && err.message ? err.message : String(err)}` };
77
+ }
78
+ }
79
+
80
+ function realGh(args) {
81
+ return new Promise((resolve, reject) => {
82
+ execFile('gh', args, { timeout: GH_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, encoding: 'utf8' }, (err, stdout) => {
83
+ if (err) reject(err);
84
+ else resolve(stdout);
85
+ });
86
+ });
87
+ }
88
+
89
+ /**
90
+ * @param {object} [deps] test seams: env, gh, now, sleep, stdout, stderr, deadlineMs, pollMs
91
+ * @returns {Promise<number>} exit code
92
+ */
93
+ async function main(deps = {}) {
94
+ const env = deps.env || process.env;
95
+ const out = deps.stdout || ((s) => process.stdout.write(s));
96
+ const errOut = deps.stderr || ((s) => process.stderr.write(s));
97
+
98
+ const prNumber = (env.PR_NUMBER || '').trim();
99
+ if (!PR_NUMBER_PATTERN.test(prNumber)) {
100
+ errOut(`::error::PR_NUMBER must be a positive integer, got ${JSON.stringify(prNumber)}\n`);
101
+ return 1;
102
+ }
103
+ const headSha = (env.HEAD_SHA || '').trim();
104
+ if (!HEAD_SHA_PATTERN.test(headSha)) {
105
+ errOut('::error::HEAD_SHA must be a 40-character hex sha\n');
106
+ return 1;
107
+ }
108
+ const repo = (env.GH_REPO || '').trim();
109
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) {
110
+ errOut('::error::GH_REPO must be owner/repo\n');
111
+ return 1;
112
+ }
113
+
114
+ const result = await rerunBaseHealth({
115
+ gh: deps.gh || realGh,
116
+ repo,
117
+ headSha,
118
+ prNumber,
119
+ now: deps.now || Date.now,
120
+ sleep: deps.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
121
+ deadlineMs: deps.deadlineMs,
122
+ pollMs: deps.pollMs,
123
+ });
124
+
125
+ if (result.action === 'error') {
126
+ errOut(`::error::fix-next re-run failed (${result.reason})${result.runId ? ` on Tests run ${result.runId}` : ''}; re-run "${BASE_HEALTH_JOB_NAME}" by hand.\n`);
127
+ return 1;
128
+ }
129
+ if (result.action === 'skip') {
130
+ out(`::notice::fix-next re-run skipped (${result.reason}); the newer or upcoming run reads the label from the API.\n`);
131
+ return 0;
132
+ }
133
+ out(`Re-ran "${BASE_HEALTH_JOB_NAME}" (job ${result.jobId}) on Tests run ${result.runId}.\n`);
134
+ return 0;
135
+ }
136
+
137
+ if (require.main === module) {
138
+ runMain(main);
139
+ }
140
+
141
+ module.exports = {
142
+ BASE_HEALTH_JOB_NAME,
143
+ TESTS_WORKFLOW_FILE,
144
+ DEFAULT_DEADLINE_MS,
145
+ DEFAULT_POLL_MS,
146
+ GH_TIMEOUT_MS,
147
+ rerunBaseHealth,
148
+ main,
149
+ };
@@ -17,7 +17,8 @@
17
17
  // with a non-success conclusion for the base branch's last push-triggered Tests
18
18
  // run, that is treated as authoritative and blocks — with one explicit, visible,
19
19
  // human-operated escape hatch (the `fix-next` label) for the PR that is itself
20
- // the fix-forward.
20
+ // the fix-forward. On a pull request the label is read from the API (#6076), so
21
+ // after it is added the fix-next-rerun workflow re-runs this check and sees it.
21
22
  //
22
23
  // Every non-success conclusion (`failure`, `cancelled`, `timed_out`,
23
24
  // `action_required`, ...) is treated as RED, not just `failure` — a cancelled or
@@ -205,11 +206,14 @@ async function evaluateQueueBypass({ headRef, baseRef, baseSha, headSha, repo, g
205
206
  * (or throwing) — injected so tests never touch the network.
206
207
  * `evaluateQueueBypass` (merge_group only) is an async function returning
207
208
  * `{authorized, reason, actor}`; it is how a queued fix-forward is recognised.
209
+ * `fetchPrLabels` (pull_request only, RED base only) is an async function
210
+ * returning the PR's current label names (or throwing, which keeps the gate
211
+ * RED with reason `label-read-failed`); when absent, `labels` decides (#6076).
208
212
  *
209
213
  * @returns {Promise<{verdict:string, payload:*, reason:string}>}
210
214
  */
211
215
  async function resolveNextHealth({
212
- fetchLatestRun, eventName, baseRef, labels, evaluateQueueBypass: queueBypass,
216
+ fetchLatestRun, eventName, baseRef, labels, fetchPrLabels, evaluateQueueBypass: queueBypass,
213
217
  } = {}) {
214
218
  if (!APPLICABLE_EVENTS.has(eventName)) {
215
219
  return { verdict: VERDICT.SKIPPED_NOT_APPLICABLE, payload: null, reason: 'not-applicable-event' };
@@ -235,10 +239,25 @@ async function resolveNextHealth({
235
239
  return { verdict: assessed.verdict, payload: evidence, reason };
236
240
  }
237
241
 
238
- // Escape hatch for the PR that is itself the fix-forward. pull_request runs
239
- // read the label from the event (`labels`). merge_group events carry no
240
- // labels, so the queue run asks the API instead (evaluateQueueBypass).
241
- const labelList = Array.isArray(labels) ? labels : [];
242
+ // Escape hatch for the PR that is itself the fix-forward. A pull_request run
243
+ // with `fetchPrLabels` reads the PR's CURRENT labels from the API (#6076), so
244
+ // a re-run sees a label added or removed after the run started; a failed read
245
+ // keeps the gate RED. Without it, the event's `labels` decide (bootstrap).
246
+ // merge_group events carry no labels, so the queue run asks the API instead
247
+ // (evaluateQueueBypass).
248
+ let labelList = Array.isArray(labels) ? labels : [];
249
+ if (eventName === 'pull_request' && typeof fetchPrLabels === 'function') {
250
+ try {
251
+ const current = await fetchPrLabels();
252
+ labelList = Array.isArray(current) ? current : [];
253
+ } catch (err) {
254
+ return {
255
+ verdict: VERDICT.RED,
256
+ payload: evidence,
257
+ reason: `${assessed.reason === 'resolved' ? 'red' : assessed.reason}; label-read-failed: ${err.message}`,
258
+ };
259
+ }
260
+ }
242
261
  if (labelList.includes(BYPASS_LABEL)) {
243
262
  return { verdict: VERDICT.BYPASSED, payload: evidence, reason: 'bypass-label' };
244
263
  }
@@ -281,6 +300,9 @@ function usage() {
281
300
  'newest settled run decides. Every other case (wrong event, no',
282
301
  'resolvable base ref, an unreadable API read) fails open (exit 0).',
283
302
  '',
303
+ 'On pull_request with PR_NUMBER set, the label is read from the API, so a',
304
+ 're-run sees the PR\'s current labels (#6076); a failed read keeps the gate RED.',
305
+ '',
284
306
  'On merge_group the label is read back from the API: the queue ref must',
285
307
  'name a PR, the group must hold exactly one commit, the PR must carry the',
286
308
  'label, and whoever applied it must have write or admin permission.',
@@ -300,7 +322,15 @@ function usage() {
300
322
  ' PR_LABELS comma-separated PR label names (pull_request events;',
301
323
  ' the caller workflow must populate this from',
302
324
  ' github.event.pull_request.labels.*.name); checked',
303
- ' for the literal "fix-next" bypass label',
325
+ ' for the literal "fix-next" bypass label; the fallback',
326
+ ' when PR_NUMBER is unset or malformed',
327
+ ' PR_NUMBER PR number (pull_request events; from',
328
+ ' github.event.pull_request.number); trimmed, then it',
329
+ ' must match /^[1-9]\\d*$/. When valid, the label is',
330
+ ' read from the API so a re-run sees the current',
331
+ ' label; a failed read keeps the gate RED. A',
332
+ ' malformed value (0, 07, -1, abc, empty) falls back',
333
+ ' to PR_LABELS',
304
334
  ' MERGE_GROUP_HEAD_REF merge-group queue branch',
305
335
  ' (github.event.merge_group.head_ref; falls back to',
306
336
  ' GITHUB_REF)',
@@ -437,8 +467,19 @@ async function main(argv = process.argv.slice(2)) {
437
467
  ghGet,
438
468
  });
439
469
 
470
+ // #6076: with a valid PR number, read the PR's current labels from the API.
471
+ const prNumber = (process.env.PR_NUMBER || '').trim();
472
+ const fetchPrLabels = /^[1-9]\d*$/.test(prNumber)
473
+ ? async () => {
474
+ const body = await ghGet(`/repos/${repo}/issues/${prNumber}/labels?per_page=100`);
475
+ return Array.isArray(body)
476
+ ? body.map((l) => l && l.name).filter((name) => typeof name === 'string')
477
+ : [];
478
+ }
479
+ : undefined;
480
+
440
481
  const result = await resolveNextHealth({
441
- fetchLatestRun, eventName, baseRef, labels, evaluateQueueBypass: evaluateQueue,
482
+ fetchLatestRun, eventName, baseRef, labels, fetchPrLabels, evaluateQueueBypass: evaluateQueue,
442
483
  });
443
484
 
444
485
  writeOutput([`verdict=${result.verdict}`]);
@@ -450,7 +491,9 @@ async function main(argv = process.argv.slice(2)) {
450
491
  `::error::the base branch "${baseRef}"'s own last Tests run is red or still re-running after a failure: ${runUrl} (${result.reason}) — `
451
492
  + 'wait for a fix-forward merge, or if THIS pull request is the fix, ask a '
452
493
  + `maintainer with write access to apply the "${BYPASS_LABEL}" label and re-queue it `
453
- + '(the queue run reads the label from the API; it must be the only PR in its merge group).\n',
494
+ + '(the queue run reads the label from the API; it must be the only PR in its merge group). '
495
+ + 'On a pull request the label is read from the API too, so after it is added the '
496
+ + 'fix-next-rerun workflow re-runs this check.\n',
454
497
  );
455
498
  return 1;
456
499
  }