Files
msd-core/scripts/require-issue-link-policy.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

208 lines
8.9 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* Require-issue-link policy (#3211, preserving #1389).
*
* Replaces the shell-only `grep -qiE '(closes|fixes|resolves)\s+#[0-9]+'`
* step with a pure, testable `evaluateIssueLink` verdict function so a PR
* that only REFERENCES an issue (rather than closing it — e.g. a follow-up
* regression-coverage PR) is not forced to fabricate a closing keyword, while
* two pre-existing constraints are preserved exactly:
*
* 1. (#1389, anti-forgery) The backmerge exemption only fires when the
* head ref carries the backmerge prefix AND the PR is same-repo (not a
* fork) — dropping the `sameRepo` conjunct would let a fork forge a
* branch name to bypass the issue-link requirement entirely.
* 2. (#3211, truncation) `gh pr view --json files` truncates the file list
* at 100 entries with no in-band signal that it did so (see
* scripts/pr-changed-files.cjs). The reference-only carve-out below
* only applies when every changed file is a test/doc file, so a PR
* whose file list may be truncated must fail closed rather than let an
* unseen 101st+ file (which could touch `src/`) slip through.
*
* Tests assert on the typed ISSUE_LINK_REASON enum, never on free text.
*/
const { fileListIsComplete, parseChangedFilesEnv } = require('./pr-changed-files.cjs');
const { runMain } = require('./lib/cli-exit.cjs');
const ISSUE_LINK_REASON = Object.freeze({
OK_CLOSING_KEYWORD: 'ok_closing_keyword',
OK_BACKMERGE_EXEMPT: 'ok_backmerge_exempt',
OK_DEPENDABOT_EXEMPT: 'ok_dependabot_exempt',
OK_FOLLOWUP_REFERENCE: 'ok_followup_reference',
FAIL_NO_ISSUE_REFERENCE: 'fail_no_issue_reference',
FAIL_REFERENCE_NEEDS_CLOSING: 'fail_reference_needs_closing',
FAIL_FILE_LIST_INCOMPLETE: 'fail_file_list_incomplete',
});
// #1389: backmerge PRs are opened by CI against `next`, never by a human or a
// fork, so they are exempt from the issue-link requirement outright — but
// ONLY when combined with `sameRepo === true` below (see header comment).
const BACKMERGE_BRANCH_PREFIX = 'chore/backmerge-main-to-next-';
// #4196: Dependabot has no mechanism to link a PR it opens to a repo issue —
// its alerts live in the Security tab, not as issues, so there is nothing
// for it to reference. `pr.user.login` is authenticated by GitHub (not
// forgeable by a crafted title/branch), so this is safe without a sameRepo
// conjunct: no external actor can make GitHub report this login for a PR
// they opened.
const DEPENDABOT_LOGIN = 'dependabot[bot]';
// A follow-up-only PR (references an issue without closing it) is only
// allowed to skip the closing keyword when every changed file is a test or
// doc file — i.e. it cannot be the PR that actually implements the fix.
const EXEMPT_PATH_PREFIXES = ['tests/', 'docs/'];
// Root-level markdown is documentation — this mirrors the repo's own doc-only
// classifier (.claude/hooks/pre-pr-gate.sh:111), whose `[^/]+\.md` anchor is
// deliberately root-only so runtime-loaded text under a subdirectory
// (msd-core/workflows/*.md, agents/*.md, commands/**/*.md) stays gated.
// CHANGELOG.md is excluded: scripts/changeset/lint.cjs classes a direct edit to
// it as user-facing precisely to close a bypass, so it must not ride in on the
// docs carve-out either.
const EXCLUDED_ROOT_DOCS = new Set(['CHANGELOG.md']);
// Case-insensitive lookup set derived from EXCLUDED_ROOT_DOCS. The exclusion
// check below is case-insensitive because the `.md` extension test above it
// already is (`/\.md$/i`) — `changelog.md` or `CHANGELOG.MD` would otherwise
// slip past the exclusion while still passing the extension test. In
// practice this is defense in depth rather than a live bypass: the GitHub
// API always reports the real path with its actual, fixed casing (the
// filesystem is case-sensitive on the runners this executes on), so a PR
// cannot rename CHANGELOG.md to bypass the check by casing alone.
const EXCLUDED_ROOT_DOCS_UPPER = new Set(
Array.from(EXCLUDED_ROOT_DOCS, (name) => name.toUpperCase()),
);
function isRootLevelDoc(normalizedPath) {
if (!normalizedPath) return false;
if (normalizedPath.includes('/')) return false;
if (!/\.md$/i.test(normalizedPath)) return false;
if (EXCLUDED_ROOT_DOCS_UPPER.has(normalizedPath.toUpperCase())) return false;
return true;
}
// Mirrors the shipped `grep -qiE '(closes|fixes|resolves)\s+#[0-9]+'` exactly:
// no additional keywords, no `\b` anchors (the shell grep it replaces has
// none either) — see the corpus-parity test in
// tests/require-issue-link-policy.test.cjs.
const CLOSING_KEYWORD_REGEX = /(?:closes|fixes|resolves)\s+#[0-9]+/i;
// Accepts a soft "this PR relates to #N" reference without claiming to close
// it. The leading `\b` prevents matching inside a longer word (e.g. `xref#1`,
// `prefs #1`) because there is no word-boundary transition between the
// preceding word character and the start of the alternative — verified
// explicitly for `preferences #1` / `unreferenced #1` in the test suite.
const FOLLOWUP_REFERENCE_REGEX = /\b(?:refs?|references?|relates\s+to|related\s+to|follow[-\s]?up\s+to)\s+#[0-9]+/i;
function hasClosingKeyword(body) {
return CLOSING_KEYWORD_REGEX.test(String(body || ''));
}
function hasFollowUpReference(body) {
return FOLLOWUP_REFERENCE_REGEX.test(String(body || ''));
}
// Path separator normalization — unconditional, per repo convention (see
// CLAUDE.md "Path-sep normalization"), so a Windows-style diff entry like
// `tests\windows\a.test.cjs` is still recognized as tests/-prefixed.
function normalizePath(p) {
return String(p).replace(/\\/g, '/');
}
/**
* Returns true iff every changed file qualifies as a test/doc file, i.e. each
* one is one of:
* 1. under one of EXEMPT_PATH_PREFIXES (`tests/`, `docs/`) — the trailing
* slash on each prefix makes this directory-boundary aware, so
* lookalikes like `tests-e2e/`, `testsuite/`, or `docsite/` are
* correctly rejected (they are not `tests/` or `docs/`); or
* 2. a root-level markdown file (no `/`, `.md` extension, case-insensitive)
* that is not in EXCLUDED_ROOT_DOCS — see isRootLevelDoc.
*/
function allPathsAreTestsOrDocs(changedFiles) {
if (!Array.isArray(changedFiles) || changedFiles.length === 0) return false;
return changedFiles.every((file) => {
const normalized = normalizePath(file);
if (EXEMPT_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return true;
return isRootLevelDoc(normalized);
});
}
function evaluateIssueLink({ prBody, headRef, sameRepo, authorLogin, changedFiles, changedFilesTotal }) {
if (authorLogin === DEPENDABOT_LOGIN) {
return { ok: true, reason: ISSUE_LINK_REASON.OK_DEPENDABOT_EXEMPT };
}
if (hasClosingKeyword(prBody)) {
return { ok: true, reason: ISSUE_LINK_REASON.OK_CLOSING_KEYWORD };
}
// #1389: `sameRepo === true` is required, not just the branch-name prefix —
// a fork PR could otherwise name its branch to forge this exemption.
if (String(headRef || '').startsWith(BACKMERGE_BRANCH_PREFIX) && sameRepo === true) {
return { ok: true, reason: ISSUE_LINK_REASON.OK_BACKMERGE_EXEMPT };
}
if (!hasFollowUpReference(prBody)) {
return { ok: false, reason: ISSUE_LINK_REASON.FAIL_NO_ISSUE_REFERENCE };
}
// #3211: a truncated file list cannot be trusted to prove "tests/docs
// only" — fail closed rather than risk approving on an unseen file.
if (!fileListIsComplete(changedFiles, changedFilesTotal)) {
return { ok: false, reason: ISSUE_LINK_REASON.FAIL_FILE_LIST_INCOMPLETE };
}
if (allPathsAreTestsOrDocs(changedFiles)) {
return { ok: true, reason: ISSUE_LINK_REASON.OK_FOLLOWUP_REFERENCE };
}
return { ok: false, reason: ISSUE_LINK_REASON.FAIL_REFERENCE_NEEDS_CLOSING };
}
function main() {
const changedFiles = parseChangedFilesEnv(process.env.CHANGED_FILES);
const parsedTotal = Number.parseInt(process.env.CHANGED_FILES_TOTAL, 10);
const changedFilesTotal = Number.isNaN(parsedTotal) ? undefined : parsedTotal;
const result = evaluateIssueLink({
prBody: process.env.PR_BODY || '',
headRef: process.env.HEAD_REF || '',
sameRepo: process.env.SAME_REPO === 'true',
authorLogin: process.env.PR_AUTHOR_LOGIN || '',
changedFiles,
changedFilesTotal,
});
process.stdout.write(`${JSON.stringify(result)}\n`);
if (process.env.GITHUB_OUTPUT) {
const fs = require('node:fs');
fs.appendFileSync(process.env.GITHUB_OUTPUT, `ok=${result.ok ? 'true' : 'false'}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `reason=${result.reason}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `result=${JSON.stringify(result)}\n`);
}
return result.ok ? 0 : 1;
}
if (require.main === module) runMain(main);
module.exports = {
ISSUE_LINK_REASON,
BACKMERGE_BRANCH_PREFIX,
DEPENDABOT_LOGIN,
EXEMPT_PATH_PREFIXES,
EXCLUDED_ROOT_DOCS,
CLOSING_KEYWORD_REGEX,
FOLLOWUP_REFERENCE_REGEX,
hasClosingKeyword,
hasFollowUpReference,
normalizePath,
isRootLevelDoc,
allPathsAreTestsOrDocs,
evaluateIssueLink,
};