@opengsd/gsd-core 1.19.0 → 1.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-code-fixer.compact.md +5 -5
- package/agents/gsd-code-fixer.md +9 -4
- package/agents/gsd-code-reviewer.compact.md +49 -8
- package/agents/gsd-code-reviewer.md +53 -14
- package/agents/gsd-executor.md +8 -4
- package/bin/install.js +36 -40
- package/commands/gsd/new-milestone.md +2 -2
- package/commands/gsd/new-project.md +2 -2
- package/commands/gsd/update.md +5 -2
- package/gsd-core/bin/gsd-tools.cjs +177 -93
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
- package/gsd-core/bin/lib/capability-registry.cjs +284 -93
- package/gsd-core/bin/lib/capability-validator.cjs +15 -0
- package/gsd-core/bin/lib/commands.cjs +46 -17
- package/gsd-core/bin/lib/config-loader.cjs +3 -3
- package/gsd-core/bin/lib/core-utils.cjs +11 -0
- package/gsd-core/bin/lib/frontmatter.cjs +21 -3
- package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
- package/gsd-core/bin/lib/gate-context-drift.cjs +1 -1
- package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
- package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
- package/gsd-core/bin/lib/git-info-exclude.cjs +1 -6
- package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +13 -9
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
- package/gsd-core/bin/lib/init.cjs +181 -68
- package/gsd-core/bin/lib/install-engine.cjs +9 -9
- package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
- package/gsd-core/bin/lib/install-profiles.cjs +4 -2
- package/gsd-core/bin/lib/io.cjs +6 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +34 -1
- package/gsd-core/bin/lib/markdown-table.cjs +23 -0
- package/gsd-core/bin/lib/mcp-server.cjs +8 -5
- package/gsd-core/bin/lib/milestone.cjs +18 -50
- package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
- package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
- package/gsd-core/bin/lib/phase-id.cjs +627 -36
- package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -74
- package/gsd-core/bin/lib/phase-locator.cjs +328 -19
- package/gsd-core/bin/lib/phase.cjs +3039 -666
- package/gsd-core/bin/lib/planning-document.cjs +26 -5
- package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
- package/gsd-core/bin/lib/planning-snapshot.cjs +12 -7
- package/gsd-core/bin/lib/planning-workspace.cjs +3 -3
- package/gsd-core/bin/lib/read-only-git-env.cjs +23 -2
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
- package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
- package/gsd-core/bin/lib/roadmap-parser.cjs +759 -144
- package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +8 -5
- package/gsd-core/bin/lib/roadmap.cjs +358 -106
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +310 -130
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +36 -9
- package/gsd-core/bin/lib/smart-entry.cjs +22 -11
- package/gsd-core/bin/lib/state-contract.cjs +11 -7
- package/gsd-core/bin/lib/state-document.cjs +332 -23
- package/gsd-core/bin/lib/state-transition.cjs +51 -15
- package/gsd-core/bin/lib/state.cjs +499 -180
- package/gsd-core/bin/lib/template.cjs +2 -2
- package/gsd-core/bin/lib/validate.cjs +3 -2
- package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
- package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
- package/gsd-core/bin/lib/verify.cjs +2 -2
- package/gsd-core/bin/lib/workstream-inventory.cjs +27 -21
- package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
- package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
- package/gsd-core/references/cicd-review-patterns.md +45 -0
- package/gsd-core/references/necessity-review.md +48 -0
- package/gsd-core/workflows/code-review-fix.md +46 -5
- package/gsd-core/workflows/code-review.md +3 -0
- package/gsd-core/workflows/execute-phase.md +1 -1
- package/gsd-core/workflows/execute-plan.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
- package/gsd-core/workflows/plan-phase.md +1 -1
- package/gsd-core/workflows/pr-branch.md +2 -0
- package/gsd-core/workflows/reapply-patches.md +104 -0
- package/gsd-core/workflows/review.md +54 -141
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/settings-advanced.md +57 -9
- package/gsd-core/workflows/ship.md +12 -0
- package/hooks/dist/gsd-agent-isolation-guard.js +1 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +1 -1
- package/hooks/dist/gsd-graphify-update.sh +6 -0
- package/hooks/dist/gsd-statusline.js +3 -2
- package/hooks/dist/gsd-windsurf-pre-write.js +4 -4
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/gsd-worktree-path-guard.js +4 -4
- package/hooks/dist/lib/git-probe.js +16 -0
- package/hooks/dist/lib/isolation-sentinel.js +77 -2
- package/hooks/gsd-agent-isolation-guard.js +1 -1
- package/hooks/gsd-cursor-subagent-start.js +1 -1
- package/hooks/gsd-graphify-update.sh +6 -0
- package/hooks/gsd-statusline.js +3 -2
- package/hooks/gsd-windsurf-pre-write.js +4 -4
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/gsd-worktree-path-guard.js +4 -4
- package/hooks/lib/git-probe.js +16 -0
- package/hooks/lib/isolation-sentinel.js +77 -2
- package/package.json +3 -3
- package/pi/package-agents/gsd-code-fixer.md +9 -4
- package/pi/package-agents/gsd-code-reviewer.md +53 -14
- package/pi/package-agents/gsd-executor.md +8 -4
- package/pi/package-agents/manifest.json +3 -3
- package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
- package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
- package/pi/package-skills/gsd-update/SKILL.md +4 -1
- package/scripts/changeset/lint.cjs +28 -3
- package/scripts/ci-next-health.cjs +246 -23
- package/scripts/docs-guard-registry.cjs +2 -0
- package/scripts/gen-features.cjs +14 -2
- package/scripts/lib/drift-scan.cjs +27 -0
- package/scripts/lib/macos-conformance-tier.generated.cjs +10 -0
- package/scripts/lib/platform-conformance-tier.generated.cjs +15 -0
- package/scripts/lint-phase-enumeration-drift.cjs +8 -4
- package/scripts/lint-roadmap-progress-drift.cjs +421 -0
- package/scripts/merge-commit-pr.cjs +186 -0
- package/scripts/mutation-matrix.cjs +23 -1
- package/scripts/release-ancestry.cjs +86 -0
- package/scripts/release-pipeline-pr.cjs +92 -0
- package/scripts/require-issue-link-policy.cjs +18 -9
- package/scripts/sync-next-version.cjs +48 -7
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-new-project/SKILL.md +1 -1
- package/skills/gsd-update/SKILL.md +5 -2
- package/vscode/package.json +1 -1
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Anti-divergence drift guard for the ROADMAP Progress-table and phase-entry
|
|
6
|
+
* seams (epic #5692, issue #5765, ADR-2143 section 7).
|
|
7
|
+
*
|
|
8
|
+
* Two modules are the only legitimate home of two questions:
|
|
9
|
+
* - `src/roadmap-progress.cts` "which table is the Progress table, and what
|
|
10
|
+
* is its milestone slice" (Phases 1-2: readers and writers).
|
|
11
|
+
* - `src/phase-entry-grammar.cts` "what is a phase-list entry" (bullet marker,
|
|
12
|
+
* checkbox, bold token, anchoring) (Phases 3-4: readers and writers).
|
|
13
|
+
* The census in #5703 found 12 hand-written Progress locators and 26 hand-written
|
|
14
|
+
* phase-entry patterns that had drifted apart. After Phases 1-4 none remain
|
|
15
|
+
* outside the owners. This guard keeps it that way: the next local `## Progress`
|
|
16
|
+
* regex, Progress-table `.replace()` or `- [ ] Phase` pattern fails `lint:ci`.
|
|
17
|
+
*
|
|
18
|
+
* Four classes are detected, per source line, with comments stripped first (a
|
|
19
|
+
* comment documenting an owner is not a re-derivation of it):
|
|
20
|
+
* progress-heading a regex literal, or a string literal, that spells a
|
|
21
|
+
* level-N heading followed by `Progress` using regex
|
|
22
|
+
* syntax (`/^##[ \t]+Progress\b/im`, `#{1,3}\s*Progress`).
|
|
23
|
+
* progress-title the bare title test (`/^Progress\b/i`, `'Progress\\b'`)
|
|
24
|
+
* that the bracket writers used to share.
|
|
25
|
+
* progress-table-replace `.replace(` / `.replaceAll(` whose FIRST argument is a
|
|
26
|
+
* Progress-table pattern: a regex naming `Progress` or
|
|
27
|
+
* `Plans Complete`, a table-row regex (two or more
|
|
28
|
+
* escaped pipes) on a line that names ROADMAP content, or
|
|
29
|
+
* an identifier such as `progressRowRe` (ADR-2143
|
|
30
|
+
* section 7(b)). It looks at the first argument only, so
|
|
31
|
+
* `roadmap.title.replace(/\|/g, '\\|')` is not flagged.
|
|
32
|
+
* phase-entry-checkbox a literal that carries an escaped checkbox class
|
|
33
|
+
* (`\[[ xX]\]`, `\[( |x)\]`, `\[x\]`, `\[.\]`) AND the word
|
|
34
|
+
* `Phase` (`- [ ] **Phase 1: Name**`).
|
|
35
|
+
*
|
|
36
|
+
* What is NOT flagged, on purpose: a heading BUILT for output (`'## Progress\n'`,
|
|
37
|
+
* `'## **Progress**\n'`: no regex syntax), the STATE.md `Progress:` field, status
|
|
38
|
+
* vocabulary (`IN PROGRESS`, `/^(done|progress\b)/`), the heading bracket tag
|
|
39
|
+
* (`### [tag] Phase N:`) and requirement / plan-row checkboxes (no `Phase`). Those
|
|
40
|
+
* answer different questions owned elsewhere.
|
|
41
|
+
*
|
|
42
|
+
* Owner exemption is per class, never per file: `roadmap-progress.cts` defines the
|
|
43
|
+
* first three classes and `phase-entry-grammar.cts` defines the fourth. Each owner
|
|
44
|
+
* is still scanned for the classes it does not own, so a stray pattern added to an
|
|
45
|
+
* owner is caught (ADR-3180 Decision 4(d)). There is no allowlist file and no
|
|
46
|
+
* function-scoped exemption: Phases 1-4 left nothing to allow.
|
|
47
|
+
*
|
|
48
|
+
* The guard carries its own positive control. Every run first feeds a fixture
|
|
49
|
+
* violation of each class through the same detector and requires it to be flagged,
|
|
50
|
+
* and feeds clean fixtures and requires silence. A guard that cannot go red must
|
|
51
|
+
* not report green, so a detector that stops matching fails `lint:ci` here, in the
|
|
52
|
+
* same run, not only in a test file nobody reads. The tree-walk, root confinement,
|
|
53
|
+
* literal tokenizers and report sanitizer are shared with the sibling drift guards
|
|
54
|
+
* through `scripts/lib/drift-scan.cjs`.
|
|
55
|
+
*
|
|
56
|
+
* KNOWN, ACCEPTED limits of a per-line textual scan (same tradeoff the sibling
|
|
57
|
+
* guards document): a pattern whose tokens sit on different lines, one assembled
|
|
58
|
+
* from several literals, one routed through dynamic dispatch, or a `.replace()` on a
|
|
59
|
+
* variable whose name says neither ROADMAP nor Progress is not caught. The detector
|
|
60
|
+
* is name-sensitive on purpose: it prefers a missed exotic shape to a false
|
|
61
|
+
* positive on ordinary new code, because a guard that cries wolf gets exempted. The
|
|
62
|
+
* cross-site property tests of Phases 1-4 and code review cover the rest.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
const path = require('node:path');
|
|
66
|
+
const driftScan = require('./lib/drift-scan.cjs');
|
|
67
|
+
const { readRegexLiteralAt, readStringLiteralAt, MAX_REGEX_LITERAL_LEN, sanitizeForReport, scanTree } = driftScan;
|
|
68
|
+
|
|
69
|
+
// Authored source and hooks. Generated mirrors (`gsd-core/bin/lib/*.cjs`) are
|
|
70
|
+
// build output of `src/`; `scripts/` carries these tokens as detector text and
|
|
71
|
+
// `tests/` builds ROADMAP fixtures on purpose.
|
|
72
|
+
const SCAN_DIRS = ['src', 'hooks'];
|
|
73
|
+
const SCAN_EXT = new Set(['.cts', '.ts', '.mts', '.cjs', '.js', '.mjs']);
|
|
74
|
+
|
|
75
|
+
const CLASS = Object.freeze({
|
|
76
|
+
PROGRESS_HEADING: 'progress-heading',
|
|
77
|
+
PROGRESS_TITLE: 'progress-title',
|
|
78
|
+
PROGRESS_TABLE_REPLACE: 'progress-table-replace',
|
|
79
|
+
PHASE_ENTRY_CHECKBOX: 'phase-entry-checkbox',
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const PROGRESS_OWNER = 'src/roadmap-progress.cts';
|
|
83
|
+
const GRAMMAR_OWNER = 'src/phase-entry-grammar.cts';
|
|
84
|
+
|
|
85
|
+
// Which classes each owner DEFINES (and so may spell). Everything else about an
|
|
86
|
+
// owner file is scanned like any other file.
|
|
87
|
+
const OWNED_CLASSES = new Map([
|
|
88
|
+
[PROGRESS_OWNER, new Set([CLASS.PROGRESS_HEADING, CLASS.PROGRESS_TITLE, CLASS.PROGRESS_TABLE_REPLACE])],
|
|
89
|
+
[GRAMMAR_OWNER, new Set([CLASS.PHASE_ENTRY_CHECKBOX])],
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
// A level-N heading marker (`#`, `##`, `#+`, `#*`, `#{1,3}`) followed by whitespace
|
|
93
|
+
// tokens, classes or a quantified gap (`\s*`, `[ \t]+`, ` {1,3}`, a space) and then
|
|
94
|
+
// the word `Progress`. The gap group is a bounded repeat of alternatives that
|
|
95
|
+
// cannot consume the same character two ways (a quantifier belongs to the token
|
|
96
|
+
// before it, never to the group), so matching is linear in the literal length: a
|
|
97
|
+
// repeat of `*` or `+` as its own alternative would make `\s*\s*\s*...` ambiguous
|
|
98
|
+
// and exponential (CodeQL js/redos runs on `lint:ci`).
|
|
99
|
+
const HEADING_THEN_PROGRESS_RE =
|
|
100
|
+
/#(?:\{\d{1,2},\d{1,2}\}|#{0,5}|[+*])(?:\\{1,2}[sSh][*+?]?|\[[ \\t]{1,6}\][*+?]?|\{\d{1,2}(?:,\d{0,2})?\}| ){0,12}[*_]{0,2}(?:\(\?:|\()?Progress/i;
|
|
101
|
+
// The tail the bold marker and the group opener add (`**Progress`, `(?:Progress`):
|
|
102
|
+
// not regex syntax in the gap, so it is cut before the syntax test.
|
|
103
|
+
const PROGRESS_TAIL_RE = /[*_]{0,2}(?:\(\?:|\()?Progress$/i;
|
|
104
|
+
// Regex syntax inside the heading gap: an escape, a class, a quantifier brace, a
|
|
105
|
+
// repeat. A plain `## Progress` (or `## **Progress**`) is a heading someone is WRITING.
|
|
106
|
+
const REGEX_SYNTAX_RE = /[\\[\]{}+*]/;
|
|
107
|
+
const CARET_BEFORE_HASH_RE = /\^[ \t]*#/;
|
|
108
|
+
|
|
109
|
+
// The bare Progress title test: `Progress` followed by a word boundary, right after
|
|
110
|
+
// a quote, a slash or an anchor. Not after `|`: `/^(done|progress\b)/` is status
|
|
111
|
+
// vocabulary, not a heading test.
|
|
112
|
+
const PROGRESS_TITLE_RE = /(?:^['"`/]|\^)(?:\\{1,2}s[*+]?)?Progress\\{1,2}b/i;
|
|
113
|
+
|
|
114
|
+
const REPLACE_CALL_G = /\.replace(?:All)?\s*\(/g;
|
|
115
|
+
const ROADMAP_WORD_RE = /roadmap/i;
|
|
116
|
+
// A first-argument identifier that names a Progress table pattern: `progressRowRe`,
|
|
117
|
+
// `PROGRESS_TABLE_RE`, `progressSectionPattern`.
|
|
118
|
+
const PROGRESS_PATTERN_IDENT_RE = /progress[_\w$]{0,40}?(?:table|row|section|heading)/i;
|
|
119
|
+
const PROGRESS_WORD_RE = /progress/i;
|
|
120
|
+
// The Progress table's own header text (`Plans Complete`), with a literal or escaped space.
|
|
121
|
+
const PLANS_COMPLETE_RE = /Plans(?: |\\{1,2}s[*+]?)Complete/i;
|
|
122
|
+
const ESCAPED_PIPE_G = /\\{1,2}\|/g;
|
|
123
|
+
|
|
124
|
+
// A checkbox written as regex syntax: `\[` + a box interior + `\]`. The interior is
|
|
125
|
+
// 1 to 12 characters drawn from the box alphabet: a space, `x`/`X`, `.`, `\s`, class
|
|
126
|
+
// and group punctuation and the quantifiers (`[ xX]`, `[x ]`, `( |x)`, `([xX ])`,
|
|
127
|
+
// ` ?[xX]? ?`, `.`). Letters other than x/X, digits and `^` are outside it, so a
|
|
128
|
+
// markdown link such as `\[Phase 1\]` and the heading bracket tag
|
|
129
|
+
// `\[[^\]]{1,200}\]` (a different seam: `### [tag] Phase N:`) are not checkboxes. A
|
|
130
|
+
// checkbox literal in a template that EMITS an entry (`- [ ] **Phase 1**`) has no
|
|
131
|
+
// backslashes and is not a pattern.
|
|
132
|
+
const ESCAPED_CHECKBOX_RE = /\\{1,2}\[[ xX.\\s?*+|()[\]-]{1,12}\\{1,2}\]/;
|
|
133
|
+
// The word `Phase`, case-insensitive, not inside a longer word or a hyphenated name
|
|
134
|
+
// (`Phase-gate`), and also right after a regex class escape (`\sPhase`).
|
|
135
|
+
const PHASE_WORD_RE = /(?:^|[^A-Za-z]|\\[sSwWdDbB])Phase(?![A-Za-z-])/i;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Strip comment text before detection: a guard that fires on a comment trains
|
|
139
|
+
* readers to add exemptions for documentation. Whole-line `//` comments and block
|
|
140
|
+
* comment continuations are dropped, a block comment opened and closed on the line
|
|
141
|
+
* is cut out, and a trailing `//` after code is cut. Quote- and regex-aware, so
|
|
142
|
+
* `https://` in a string or `//` inside a regex literal does not cut the line (the
|
|
143
|
+
* older guards use `indexOf('//')` and over-cut). Kept local rather than shared
|
|
144
|
+
* because each guard applies comment stripping at a different point of its
|
|
145
|
+
* pipeline and the variants differ on purpose.
|
|
146
|
+
*/
|
|
147
|
+
function stripComments(line) {
|
|
148
|
+
const trimmed = line.trim();
|
|
149
|
+
if (trimmed.startsWith('/*')) {
|
|
150
|
+
const close = line.indexOf('*/');
|
|
151
|
+
return close === -1 ? '' : stripComments(line.slice(close + 2));
|
|
152
|
+
}
|
|
153
|
+
if (trimmed.startsWith('*') || trimmed.startsWith('//')) return '';
|
|
154
|
+
let quote = null;
|
|
155
|
+
for (let i = 0; i < line.length; i++) {
|
|
156
|
+
const ch = line[i];
|
|
157
|
+
if (quote) {
|
|
158
|
+
if (ch === '\\') i++;
|
|
159
|
+
else if (ch === quote) quote = null;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (ch === "'" || ch === '"' || ch === '`') quote = ch;
|
|
163
|
+
else if (ch === '/' && line[i + 1] === '/') return line.slice(0, i);
|
|
164
|
+
else if (ch === '/' && line[i + 1] !== '*') {
|
|
165
|
+
// A `/` may open a regex literal or be a division. A real literal is skipped so
|
|
166
|
+
// a `//` inside it is not read as a comment; a division reads as a "literal"
|
|
167
|
+
// that closes at the next `/`, which only ever skips code, never a comment
|
|
168
|
+
// opener that follows it on the same line (that case is re-checked below).
|
|
169
|
+
const re = readRegexLiteralAt(line, i);
|
|
170
|
+
if (re && !re.text.slice(1, -1).includes('//')) i = re.end - 1;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return line;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Every regex literal and quoted/backtick string literal on `line`, in order. A `/`
|
|
178
|
+
* is either a regex opener or a division, and the two cannot be told apart without
|
|
179
|
+
* a parser, so each `/` yields a CANDIDATE literal and scanning resumes just after
|
|
180
|
+
* the opening `/` (candidates overlap): `done / total, re = /^##\s+Progress/m`
|
|
181
|
+
* yields the division pseudo-literal and the real regex. Strings are exact, so a
|
|
182
|
+
* string literal is consumed whole.
|
|
183
|
+
*/
|
|
184
|
+
function literalsOn(line) {
|
|
185
|
+
const out = [];
|
|
186
|
+
for (let i = 0; i < line.length; i++) {
|
|
187
|
+
const ch = line[i];
|
|
188
|
+
if (ch === '/') {
|
|
189
|
+
const literal = readRegexLiteralAt(line, i);
|
|
190
|
+
if (literal) out.push(literal.text);
|
|
191
|
+
} else if (ch === "'" || ch === '"' || ch === '`') {
|
|
192
|
+
const literal = readStringLiteralAt(line, i);
|
|
193
|
+
if (literal) {
|
|
194
|
+
out.push(literal.text);
|
|
195
|
+
i = literal.end - 1;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isProgressHeadingLiteral(text) {
|
|
203
|
+
const match = HEADING_THEN_PROGRESS_RE.exec(text);
|
|
204
|
+
if (!match) return false;
|
|
205
|
+
const gap = match[0].slice(1).replace(PROGRESS_TAIL_RE, '');
|
|
206
|
+
return REGEX_SYNTAX_RE.test(gap) || CARET_BEFORE_HASH_RE.test(text);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The first argument of the `.replace(` / `.replaceAll(` call that opens at
|
|
211
|
+
* `code[open]` (the index just past the `(`): a regex literal, a string literal or
|
|
212
|
+
* an identifier path. Null when the argument is anything else.
|
|
213
|
+
*/
|
|
214
|
+
function firstReplaceArgument(code, open) {
|
|
215
|
+
let i = open;
|
|
216
|
+
while (code[i] === ' ' || code[i] === '\t') i++;
|
|
217
|
+
const ch = code[i];
|
|
218
|
+
if (ch === '/') {
|
|
219
|
+
const literal = readRegexLiteralAt(code, i);
|
|
220
|
+
return literal ? { kind: 'regex', text: literal.text } : null;
|
|
221
|
+
}
|
|
222
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
223
|
+
const literal = readStringLiteralAt(code, i);
|
|
224
|
+
return literal ? { kind: 'string', text: literal.text } : null;
|
|
225
|
+
}
|
|
226
|
+
const ident = /^[A-Za-z_$][\w$.]*/.exec(code.slice(i, i + 120));
|
|
227
|
+
return ident ? { kind: 'identifier', text: ident[0] } : null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isProgressTableReplace(code) {
|
|
231
|
+
const namesRoadmap = ROADMAP_WORD_RE.test(code);
|
|
232
|
+
for (const call of code.matchAll(REPLACE_CALL_G)) {
|
|
233
|
+
const arg = firstReplaceArgument(code, call.index + call[0].length);
|
|
234
|
+
if (!arg) continue;
|
|
235
|
+
if (arg.kind === 'identifier') {
|
|
236
|
+
if (PROGRESS_PATTERN_IDENT_RE.test(arg.text) || (namesRoadmap && PROGRESS_WORD_RE.test(arg.text))) return true;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (PLANS_COMPLETE_RE.test(arg.text)) return true;
|
|
240
|
+
if (namesRoadmap && (PROGRESS_WORD_RE.test(arg.text) || (arg.text.match(ESCAPED_PIPE_G) || []).length >= 2)) return true;
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Pure: every unsanctioned Progress / phase-entry pattern in `text`.
|
|
247
|
+
* `relPath` is the repo-relative POSIX path, used to report file:line and to apply
|
|
248
|
+
* the per-class owner exemption. Returns [{ line, cls, found }], where `found` is
|
|
249
|
+
* the matched literal (or the offending line for the replace class).
|
|
250
|
+
*/
|
|
251
|
+
function findProgressDrift(text, relPath) {
|
|
252
|
+
const owned = OWNED_CLASSES.get(relPath) || new Set();
|
|
253
|
+
const out = [];
|
|
254
|
+
const lines = text.split('\n');
|
|
255
|
+
for (let i = 0; i < lines.length; i++) {
|
|
256
|
+
const code = stripComments(lines[i]);
|
|
257
|
+
if (!code.trim()) continue;
|
|
258
|
+
const literals = literalsOn(code);
|
|
259
|
+
const report = (cls, found) => {
|
|
260
|
+
if (!owned.has(cls)) out.push({ line: i + 1, cls, found: found.slice(0, MAX_REGEX_LITERAL_LEN) });
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const headingLiteral = literals.find(isProgressHeadingLiteral);
|
|
264
|
+
if (headingLiteral !== undefined) report(CLASS.PROGRESS_HEADING, headingLiteral);
|
|
265
|
+
|
|
266
|
+
const titleLiteral = literals.find((t) => PROGRESS_TITLE_RE.test(t));
|
|
267
|
+
if (titleLiteral !== undefined) report(CLASS.PROGRESS_TITLE, titleLiteral);
|
|
268
|
+
|
|
269
|
+
if (isProgressTableReplace(code)) report(CLASS.PROGRESS_TABLE_REPLACE, code.trim());
|
|
270
|
+
|
|
271
|
+
const checkboxLiteral = literals.find((t) => ESCAPED_CHECKBOX_RE.test(t) && PHASE_WORD_RE.test(t));
|
|
272
|
+
if (checkboxLiteral !== undefined) report(CLASS.PHASE_ENTRY_CHECKBOX, checkboxLiteral);
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const toPosix = (rel) => rel.split(path.sep).join('/');
|
|
278
|
+
|
|
279
|
+
function scanRepo(root) {
|
|
280
|
+
return scanTree({
|
|
281
|
+
root,
|
|
282
|
+
scanDirs: SCAN_DIRS,
|
|
283
|
+
scanExt: SCAN_EXT,
|
|
284
|
+
onFile(rel, text) {
|
|
285
|
+
const posix = toPosix(rel);
|
|
286
|
+
return findProgressDrift(text, posix).map((d) => ({ file: posix, ...d }));
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// Positive control. One fixture per class that MUST be flagged, one per class
|
|
293
|
+
// that must NOT, and the owner-exemption boundary. The fixtures are source text
|
|
294
|
+
// handed to the same detector; nothing here touches the tree.
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
const FIXTURE_PATH = 'src/fixture-violation.cts';
|
|
297
|
+
|
|
298
|
+
const MUST_FLAG = [
|
|
299
|
+
{ cls: CLASS.PROGRESS_HEADING, text: 'const m = content.match(/^##[ \\t]+Progress\\b/im);' },
|
|
300
|
+
{ cls: CLASS.PROGRESS_HEADING, text: 'const re = new RegExp(`^#{1,3}\\\\s*Progress`);' },
|
|
301
|
+
{ cls: CLASS.PROGRESS_HEADING, text: 'const m = /^##\\s+(Progress)/m.exec(content);' },
|
|
302
|
+
{ cls: CLASS.PROGRESS_HEADING, text: 'const pct = done / total, re = /^##\\s+Progress/m;' },
|
|
303
|
+
{ cls: CLASS.PROGRESS_HEADING, text: '/* gate */ const re = /^##\\s+Progress/m;' },
|
|
304
|
+
{ cls: CLASS.PROGRESS_TITLE, text: 'const isProgress = (t) => /^Progress\\b/i.test(t);' },
|
|
305
|
+
{ cls: CLASS.PROGRESS_TITLE, text: "const SRC = 'Progress\\\\b';" },
|
|
306
|
+
{ cls: CLASS.PROGRESS_TABLE_REPLACE, text: 'const out = roadmapContent.replace(progressRowRe, row);' },
|
|
307
|
+
{ cls: CLASS.PROGRESS_TABLE_REPLACE, text: 'const out = rm.replace(PROGRESS_TABLE_RE, row);' },
|
|
308
|
+
{ cls: CLASS.PROGRESS_TABLE_REPLACE, text: 'const out = roadmap.replaceAll(/\\|\\s*3\\.\\s*Three\\s*\\|/g, "| 3. Three |");' },
|
|
309
|
+
{ cls: CLASS.PROGRESS_TABLE_REPLACE, text: 'const out = text.replace(/\\|\\s*Plans Complete\\s*\\|/, "| Plans |");' },
|
|
310
|
+
{ cls: CLASS.PHASE_ENTRY_CHECKBOX, text: 'const re = /^- \\[ \\] \\*\\*Phase (\\d+)/m;' },
|
|
311
|
+
{ cls: CLASS.PHASE_ENTRY_CHECKBOX, text: 'const re = /^\\s*-\\s*\\[ ?[xX]? ?\\]\\s*\\*\\*Phase/;' },
|
|
312
|
+
{ cls: CLASS.PHASE_ENTRY_CHECKBOX, text: 'const re = /^- \\[.\\] \\*\\*Phase/;' },
|
|
313
|
+
{ cls: CLASS.PHASE_ENTRY_CHECKBOX, text: 'const re = /^-\\s\\[\\s\\]\\sPhase/;' },
|
|
314
|
+
{ cls: CLASS.PHASE_ENTRY_CHECKBOX, text: 'const re = new RegExp(`-\\\\s*\\\\[[ xX]\\\\]\\\\s*\\\\*\\\\*Phase ${n}`);' },
|
|
315
|
+
];
|
|
316
|
+
|
|
317
|
+
const MUST_STAY_CLEAN = [
|
|
318
|
+
// A heading being WRITTEN: no regex syntax.
|
|
319
|
+
{ name: 'heading builder', text: "const heading = '## Progress\\n\\n';" },
|
|
320
|
+
{ name: 'bold heading builder', text: "const heading = '## **Progress**\\n';" },
|
|
321
|
+
// STATE.md field, status vocabulary.
|
|
322
|
+
{ name: 'STATE progress field', text: 'const m = /^Progress: (.*)$/m.exec(body);' },
|
|
323
|
+
{ name: 'status vocabulary', text: 'const m = content.match(/##\\s*Status:\\s*(OPEN|IN PROGRESS)/i);' },
|
|
324
|
+
{ name: 'status alternation', text: 'const m = /^(done|progress\\b)/i.exec(word);' },
|
|
325
|
+
// Other checkboxes and a different seam: the heading bracket tag, a markdown link.
|
|
326
|
+
{ name: 'requirement checkbox', text: 'const re = /^- \\[[ xX]\\] \\*\\*REQ-\\d+\\*\\*/m;' },
|
|
327
|
+
{ name: 'hyphenated Phase', text: 'const re = /^- \\[[ x]\\] .*Phase-gate/;' },
|
|
328
|
+
{ name: 'emitted entry', text: 'const line = `- [ ] **Phase ${n}: ${name}**`;' },
|
|
329
|
+
{ name: 'heading bracket tag', text: 'const re = /^#{2,4}\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+(\\d+)/;' },
|
|
330
|
+
{ name: 'markdown link', text: 'const re = /\\[Phase (\\d+)\\]\\(#phase-\\1\\)/;' },
|
|
331
|
+
{ name: 'comment', text: '// the old /^##[ \\t]+Progress\\b/im and /^- \\[ \\] \\*\\*Phase/ shapes' },
|
|
332
|
+
// A replace whose first argument is not a Progress table pattern.
|
|
333
|
+
{ name: 'unrelated replace', text: 'const out = roadmapContent.replace(/\\r\\n/g, "\\n");' },
|
|
334
|
+
{ name: 'pipe escape for output', text: "const cell = roadmap.title.replace(/\\|/g, '\\\\|');" },
|
|
335
|
+
{ name: 'progress variable receiver', text: "const pct = roadmapProgress.replace(/%$/, '');" },
|
|
336
|
+
{ name: 'replace inside a template', text: 'const label = `roadmap progress ${name.replace(/-/g, " ")}`;' },
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Run the positive and negative controls. Returns a list of human-readable
|
|
341
|
+
* failures; empty means the detector can go red where it must and stays quiet where
|
|
342
|
+
* it must.
|
|
343
|
+
*/
|
|
344
|
+
function runPositiveControl() {
|
|
345
|
+
const failures = [];
|
|
346
|
+
for (const fixture of MUST_FLAG) {
|
|
347
|
+
const found = findProgressDrift(fixture.text, FIXTURE_PATH).filter((d) => d.cls === fixture.cls);
|
|
348
|
+
if (found.length === 0) failures.push(`not flagged (${fixture.cls}): ${fixture.text}`);
|
|
349
|
+
}
|
|
350
|
+
for (const fixture of MUST_STAY_CLEAN) {
|
|
351
|
+
const found = findProgressDrift(fixture.text, FIXTURE_PATH);
|
|
352
|
+
if (found.length > 0) failures.push(`flagged but clean (${fixture.name}): ${found.map((d) => d.cls).join(',')}`);
|
|
353
|
+
}
|
|
354
|
+
// Owner boundary: each owner may spell its own classes and nothing else.
|
|
355
|
+
const progressSpelling = MUST_FLAG.find((f) => f.cls === CLASS.PROGRESS_HEADING).text;
|
|
356
|
+
const checkboxSpelling = MUST_FLAG.find((f) => f.cls === CLASS.PHASE_ENTRY_CHECKBOX).text;
|
|
357
|
+
const boundary = [
|
|
358
|
+
[PROGRESS_OWNER, progressSpelling, 0, 'progress owner may spell a Progress heading'],
|
|
359
|
+
[PROGRESS_OWNER, checkboxSpelling, 1, 'progress owner may NOT spell a phase-entry checkbox'],
|
|
360
|
+
[GRAMMAR_OWNER, checkboxSpelling, 0, 'grammar owner may spell a phase-entry checkbox'],
|
|
361
|
+
[GRAMMAR_OWNER, progressSpelling, 1, 'grammar owner may NOT spell a Progress heading'],
|
|
362
|
+
];
|
|
363
|
+
for (const [rel, text, expected, label] of boundary) {
|
|
364
|
+
const count = findProgressDrift(text, rel).length;
|
|
365
|
+
if (count !== expected) failures.push(`owner boundary (${label}): expected ${expected}, got ${count}`);
|
|
366
|
+
}
|
|
367
|
+
return failures;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The failure report as lines: a header, the pointer to the two owners, then one
|
|
372
|
+
* `file:line [class] fragment` line per violation. `found` is the matched
|
|
373
|
+
* literal (or, for the replace class, the offending line), cut to
|
|
374
|
+
* MAX_REGEX_LITERAL_LEN. The path is as attacker-controlled as the matched text,
|
|
375
|
+
* so both pass through `sanitizeForReport`.
|
|
376
|
+
*/
|
|
377
|
+
function formatViolations(violations) {
|
|
378
|
+
return [
|
|
379
|
+
'roadmap-progress-drift: hand-written Progress-table or phase-entry pattern(s) found.',
|
|
380
|
+
'Locate and edit the Progress table through src/roadmap-progress.cts (`locateRoadmapProgress`, `findProgressSection`,',
|
|
381
|
+
'`editRoadmapProgress`) and compose phase-entry patterns from src/phase-entry-grammar.cts (`phaseEntryRegExp`,',
|
|
382
|
+
'`phaseEntrySplitRegExp`) instead of spelling them again:',
|
|
383
|
+
...violations.map((d) => ` ${sanitizeForReport(d.file)}:${d.line} [${d.cls}] ${sanitizeForReport(d.found)}`),
|
|
384
|
+
];
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function main() {
|
|
388
|
+
const controlFailures = runPositiveControl();
|
|
389
|
+
if (controlFailures.length > 0) {
|
|
390
|
+
process.stderr.write('roadmap-progress-drift: the guard failed its own positive control; a detector that cannot go red is not a guard.\n');
|
|
391
|
+
for (const f of controlFailures) process.stderr.write(` ${f}\n`);
|
|
392
|
+
process.exitCode = 1;
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const root = path.join(__dirname, '..');
|
|
396
|
+
const violations = scanRepo(root);
|
|
397
|
+
if (violations.length === 0) {
|
|
398
|
+
process.stdout.write('ok roadmap-progress-drift: no hand-written Progress-table or phase-entry pattern outside roadmap-progress.cts and phase-entry-grammar.cts (positive control passed)\n');
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
for (const line of formatViolations(violations)) process.stderr.write(`${line}\n`);
|
|
402
|
+
process.exitCode = 1;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (require.main === module) main();
|
|
406
|
+
|
|
407
|
+
module.exports = {
|
|
408
|
+
findProgressDrift,
|
|
409
|
+
scanRepo,
|
|
410
|
+
runPositiveControl,
|
|
411
|
+
formatViolations,
|
|
412
|
+
CLASS,
|
|
413
|
+
PROGRESS_OWNER,
|
|
414
|
+
GRAMMAR_OWNER,
|
|
415
|
+
OWNED_CLASSES,
|
|
416
|
+
SCAN_DIRS,
|
|
417
|
+
MUST_FLAG,
|
|
418
|
+
MUST_STAY_CLEAN,
|
|
419
|
+
stripComments,
|
|
420
|
+
literalsOn,
|
|
421
|
+
};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Land one of the release pipeline's history-carrying PRs as a merge commit (#5894).
|
|
5
|
+
*
|
|
6
|
+
* Two PRs must keep a parent edge, or main and next stop sharing history and
|
|
7
|
+
* the next release -> main merge conflicts in every version field:
|
|
8
|
+
*
|
|
9
|
+
* release-merge release/X.Y.0 | hotfix/X.Y.Z -> main keeps the v<version> tag in main
|
|
10
|
+
* main-to-next-backmerge chore/backmerge-main-to-next-<sha> -> next keeps main an ancestor of next
|
|
11
|
+
*
|
|
12
|
+
* v1.19.0 broke both (#5729 and #5730 were squash-merged). This script is the
|
|
13
|
+
* one sanctioned way to merge them, from auto-backmerge.yml, release.yml and a
|
|
14
|
+
* maintainer's shell alike:
|
|
15
|
+
*
|
|
16
|
+
* 1. it merges through `PUT /repos/{repo}/pulls/{n}/merge` with
|
|
17
|
+
* merge_method=merge: never the merge queue, which squashes, and not
|
|
18
|
+
* `gh pr merge --admin`, which does not honor a ruleset bypass;
|
|
19
|
+
* 2. the merge is pinned to the head sha it read (`sha=`), so a push after
|
|
20
|
+
* the read is refused rather than merged unseen;
|
|
21
|
+
* 3. afterwards it proves the result: the merge commit has two parents, one
|
|
22
|
+
* of them the PR head, and the base branch now contains the PR head.
|
|
23
|
+
*
|
|
24
|
+
* Usage: node scripts/merge-commit-pr.cjs <pr-number> [--repo owner/name]
|
|
25
|
+
* Prints one JSON result line; exits 0 when merged and verified, else 1.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const { execFileSync } = require('node:child_process');
|
|
29
|
+
const { RELEASE_PIPELINE_PR_KIND, releasePipelinePrKind } = require('./release-pipeline-pr.cjs');
|
|
30
|
+
const { runMain } = require('./lib/cli-exit.cjs');
|
|
31
|
+
|
|
32
|
+
const MERGE_COMMIT_PR_REASON = Object.freeze({
|
|
33
|
+
MERGED: 'merged',
|
|
34
|
+
USAGE: 'usage',
|
|
35
|
+
PR_UNREADABLE: 'pr-unreadable',
|
|
36
|
+
NOT_OPEN: 'not-open',
|
|
37
|
+
NOT_A_HISTORY_PR: 'not-a-history-pr',
|
|
38
|
+
MERGE_REFUSED: 'merge-refused',
|
|
39
|
+
NOT_A_MERGE_COMMIT: 'not-a-merge-commit',
|
|
40
|
+
ANCESTRY_BROKEN: 'ancestry-broken',
|
|
41
|
+
TAG_NOT_IN_BASE: 'tag-not-in-base',
|
|
42
|
+
VERIFY_UNREADABLE: 'verify-unreadable',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const HISTORY_KINDS = Object.freeze([
|
|
46
|
+
RELEASE_PIPELINE_PR_KIND.RELEASE_MERGE,
|
|
47
|
+
RELEASE_PIPELINE_PR_KIND.MAIN_TO_NEXT_BACKMERGE,
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const PR_NUMBER_RE = /^[1-9][0-9]*$/;
|
|
51
|
+
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
52
|
+
const GH_TIMEOUT_MS = 30000;
|
|
53
|
+
const GH_MERGE_TIMEOUT_MS = 60000;
|
|
54
|
+
|
|
55
|
+
/** The release tag a release/hotfix head branch publishes: release/1.20.0 -> v1.20.0. */
|
|
56
|
+
function releaseTagFor(headRef) {
|
|
57
|
+
const match = /^(?:release|hotfix)\/((?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))$/.exec(String(headRef || ''));
|
|
58
|
+
return match ? `v${match[1]}` : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Parse argv. Returns { prNumber, repo } or { error }. */
|
|
62
|
+
function parseArgs(argv, env = process.env) {
|
|
63
|
+
let prNumber = null;
|
|
64
|
+
let repo = env.GITHUB_REPOSITORY || 'open-gsd/gsd-core';
|
|
65
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
66
|
+
const arg = argv[i];
|
|
67
|
+
if (arg === '--repo') {
|
|
68
|
+
const value = argv[i + 1];
|
|
69
|
+
if (typeof value !== 'string' || !REPO_RE.test(value)) return { error: '--repo needs owner/name' };
|
|
70
|
+
repo = value;
|
|
71
|
+
i += 1;
|
|
72
|
+
} else if (prNumber === null && PR_NUMBER_RE.test(arg)) {
|
|
73
|
+
prNumber = Number(arg);
|
|
74
|
+
} else {
|
|
75
|
+
return { error: `unexpected argument: ${arg}` };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (prNumber === null) return { error: 'usage: merge-commit-pr.cjs <pr-number> [--repo owner/name]' };
|
|
79
|
+
if (!REPO_RE.test(repo)) return { error: `invalid repository: ${repo}` };
|
|
80
|
+
return { prNumber, repo };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Default gh runner: returns stdout; throws on non-zero exit (err.stderr carries gh's text). */
|
|
84
|
+
function defaultGh(args, { timeout = GH_TIMEOUT_MS } = {}) {
|
|
85
|
+
return execFileSync('gh', args, { encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function errorText(err) {
|
|
89
|
+
return String((err && (err.stderr || err.message)) || err).trim();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Merge PR `prNumber` of `repo` with a merge commit and verify the history.
|
|
94
|
+
* `gh(args, opts)` returns gh's stdout (JSON for `gh api`) and throws on failure.
|
|
95
|
+
*
|
|
96
|
+
* @returns {{ ok: boolean, reason: string, kind?: string, mergeSha?: string, detail?: string }}
|
|
97
|
+
*/
|
|
98
|
+
function mergeWithMergeCommit({ prNumber, repo, gh = defaultGh }) {
|
|
99
|
+
let pr;
|
|
100
|
+
try {
|
|
101
|
+
pr = JSON.parse(gh(['api', `repos/${repo}/pulls/${prNumber}`]));
|
|
102
|
+
} catch (err) {
|
|
103
|
+
return { ok: false, reason: MERGE_COMMIT_PR_REASON.PR_UNREADABLE, detail: errorText(err) };
|
|
104
|
+
}
|
|
105
|
+
if (!pr || pr.state !== 'open' || pr.merged === true) {
|
|
106
|
+
return { ok: false, reason: MERGE_COMMIT_PR_REASON.NOT_OPEN };
|
|
107
|
+
}
|
|
108
|
+
const head = pr.head || {};
|
|
109
|
+
const base = pr.base || {};
|
|
110
|
+
const headRepo = head.repo && typeof head.repo.full_name === 'string' ? head.repo.full_name : null;
|
|
111
|
+
const kind = releasePipelinePrKind({
|
|
112
|
+
headRef: head.ref,
|
|
113
|
+
baseRef: base.ref,
|
|
114
|
+
sameRepo: headRepo !== null && headRepo.toLowerCase() === repo.toLowerCase(),
|
|
115
|
+
});
|
|
116
|
+
if (!HISTORY_KINDS.includes(kind)) {
|
|
117
|
+
return { ok: false, reason: MERGE_COMMIT_PR_REASON.NOT_A_HISTORY_PR, kind: kind || undefined };
|
|
118
|
+
}
|
|
119
|
+
const headSha = head.sha;
|
|
120
|
+
|
|
121
|
+
let merge;
|
|
122
|
+
try {
|
|
123
|
+
merge = JSON.parse(gh(
|
|
124
|
+
['api', '-X', 'PUT', `repos/${repo}/pulls/${prNumber}/merge`, '-f', 'merge_method=merge', '-f', `sha=${headSha}`],
|
|
125
|
+
{ timeout: GH_MERGE_TIMEOUT_MS },
|
|
126
|
+
));
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return { ok: false, reason: MERGE_COMMIT_PR_REASON.MERGE_REFUSED, kind, detail: errorText(err) };
|
|
129
|
+
}
|
|
130
|
+
if (!merge || merge.merged !== true || typeof merge.sha !== 'string') {
|
|
131
|
+
return { ok: false, reason: MERGE_COMMIT_PR_REASON.MERGE_REFUSED, kind, detail: JSON.stringify(merge) };
|
|
132
|
+
}
|
|
133
|
+
const mergeSha = merge.sha;
|
|
134
|
+
|
|
135
|
+
let commit;
|
|
136
|
+
let compare;
|
|
137
|
+
try {
|
|
138
|
+
commit = JSON.parse(gh(['api', `repos/${repo}/commits/${mergeSha}`]));
|
|
139
|
+
compare = JSON.parse(gh(['api', `repos/${repo}/compare/${headSha}...${base.ref}`]));
|
|
140
|
+
} catch (err) {
|
|
141
|
+
return { ok: false, reason: MERGE_COMMIT_PR_REASON.VERIFY_UNREADABLE, kind, mergeSha, detail: errorText(err) };
|
|
142
|
+
}
|
|
143
|
+
const parents = commit && Array.isArray(commit.parents) ? commit.parents.map((p) => p && p.sha) : [];
|
|
144
|
+
if (parents.length !== 2 || !parents.includes(headSha)) {
|
|
145
|
+
return { ok: false, reason: MERGE_COMMIT_PR_REASON.NOT_A_MERGE_COMMIT, kind, mergeSha, detail: `parents=${parents.join(',')}` };
|
|
146
|
+
}
|
|
147
|
+
if (!compare || compare.behind_by !== 0) {
|
|
148
|
+
return { ok: false, reason: MERGE_COMMIT_PR_REASON.ANCESTRY_BROKEN, kind, mergeSha, detail: `behind_by=${compare && compare.behind_by}` };
|
|
149
|
+
}
|
|
150
|
+
if (kind === RELEASE_PIPELINE_PR_KIND.RELEASE_MERGE) {
|
|
151
|
+
const tag = releaseTagFor(head.ref);
|
|
152
|
+
if (tag === null) {
|
|
153
|
+
return { ok: false, reason: MERGE_COMMIT_PR_REASON.VERIFY_UNREADABLE, kind, mergeSha, detail: 'no release tag for ' + head.ref };
|
|
154
|
+
}
|
|
155
|
+
let tagCompare;
|
|
156
|
+
try {
|
|
157
|
+
tagCompare = JSON.parse(gh(['api', `repos/${repo}/compare/${tag}...${base.ref}`]));
|
|
158
|
+
} catch (err) {
|
|
159
|
+
return { ok: false, reason: MERGE_COMMIT_PR_REASON.VERIFY_UNREADABLE, kind, mergeSha, detail: errorText(err) };
|
|
160
|
+
}
|
|
161
|
+
if (!tagCompare || tagCompare.behind_by !== 0) {
|
|
162
|
+
return { ok: false, reason: MERGE_COMMIT_PR_REASON.TAG_NOT_IN_BASE, kind, mergeSha, detail: `${tag} behind_by=${tagCompare && tagCompare.behind_by}` };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { ok: true, reason: MERGE_COMMIT_PR_REASON.MERGED, kind, mergeSha };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function main(argv = process.argv.slice(2), { gh = defaultGh, env = process.env, stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
169
|
+
const args = parseArgs(argv, env);
|
|
170
|
+
if (args.error) {
|
|
171
|
+
stderr.write(`::error::${args.error}\n`);
|
|
172
|
+
stdout.write(`${JSON.stringify({ ok: false, reason: MERGE_COMMIT_PR_REASON.USAGE })}\n`);
|
|
173
|
+
return 1;
|
|
174
|
+
}
|
|
175
|
+
const result = mergeWithMergeCommit({ prNumber: args.prNumber, repo: args.repo, gh });
|
|
176
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
177
|
+
if (!result.ok) {
|
|
178
|
+
stderr.write(`::error::PR #${args.prNumber} was not landed as a verified merge commit (${result.reason})${result.detail ? `: ${result.detail}` : ''}. `
|
|
179
|
+
+ 'Never put a release or back-merge PR in the merge queue or squash it: main and next must keep sharing history (#5894).\n');
|
|
180
|
+
}
|
|
181
|
+
return result.ok ? 0 : 1;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (require.main === module) runMain(main);
|
|
185
|
+
|
|
186
|
+
module.exports = { MERGE_COMMIT_PR_REASON, HISTORY_KINDS, GH_TIMEOUT_MS, GH_MERGE_TIMEOUT_MS, releaseTagFor, parseArgs, mergeWithMergeCommit, main };
|
|
@@ -657,7 +657,29 @@ const COVERED = {
|
|
|
657
657
|
'state-contract': {
|
|
658
658
|
cjs: 'gsd-core/bin/lib/state-contract.cjs',
|
|
659
659
|
excludeTests: ['state-contract.test.cjs'],
|
|
660
|
-
|
|
660
|
+
// #5764: CI run 40800623930, job 123207881000, `Stryker (state-contract)`: measured 70.43%
|
|
661
|
+
// once the checklist fallback ran through the phase-entry grammar; floor(70.43)-1.
|
|
662
|
+
minScore: 69,
|
|
663
|
+
},
|
|
664
|
+
// #4998 review (trek-e 5563202317 Nit 6, 5608265301 Minor 4): the STATE.md and
|
|
665
|
+
// planning-document field readers and writers #4998 grew.
|
|
666
|
+
// state-document: CI run 39742269013 (job 119702274448) measured 50.47% in a 94-minute
|
|
667
|
+
// job. floor(50.47) - 1 = 49 is below the range minimum, so minScore stays at 50.
|
|
668
|
+
// 140 is about 1.5x the measured job time.
|
|
669
|
+
'state-document': {
|
|
670
|
+
cjs: 'gsd-core/bin/lib/state-document.cjs',
|
|
671
|
+
minScore: 50,
|
|
672
|
+
timeoutMinutes: 140,
|
|
673
|
+
},
|
|
674
|
+
// planning-document.test.cjs requires the module through a MODULE_PATH constant, which
|
|
675
|
+
// the require derivation cannot follow; frontmatter-fence.test.cjs requires it directly
|
|
676
|
+
// but sits outside the naming rule. Both run in-process, with no subprocess.
|
|
677
|
+
'planning-document': {
|
|
678
|
+
cjs: 'gsd-core/bin/lib/planning-document.cjs',
|
|
679
|
+
extraTests: ['frontmatter-fence.test.cjs', 'planning-document.test.cjs'],
|
|
680
|
+
// CI run 39736529871, job 119683571231 (#5234, head 43f1bfa93): 75.14%, Stryker in
|
|
681
|
+
// about 3 minutes. Floor = floor(75.14) - 1 = 74.
|
|
682
|
+
minScore: 74,
|
|
661
683
|
},
|
|
662
684
|
};
|
|
663
685
|
|