chore: drop upstream gsd issue and pr governance
Some checks failed
Tests / lint-tests (push) Has been cancelled
Tests / plugin-validate (push) Has been cancelled
Tests / test (ubuntu-latest, 24, shard 1/3) (push) Has been cancelled
Tests / test (ubuntu-latest, 24, shard 2/3) (push) Has been cancelled
Tests / test (ubuntu-latest, 24, shard 3/3) (push) Has been cancelled
Tests / test (ubuntu-latest, 24) (push) Has been cancelled
Tests / test (inert CI) (push) Has been cancelled
Tests / conformance test (macos-latest, 24) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 1/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 2/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 3/3) (push) Has been cancelled
Tests / Coverage gate (merged shards) (push) Has been cancelled
Tests / QA loop walk (smell ratchet) (push) Has been cancelled
Tests / Required tests (push) Has been cancelled
Tests / Publish emitted-baseline artifact (push) Has been cancelled
Tests / PR mergeability (push) Has been cancelled
Tests / Base branch health (push) Has been cancelled
Tests / Detect test scope (push) Has been cancelled

MSD does not use an issue tracker, and the inherited GSD community gates
auto-closed PR #1 for lacking an issue-numbered title. Remove them:

- workflows: require-issue-link, pr-title-validator,
  auto-close-unsolicited-prs, pr-template-format, close-draft-prs(-sweep),
  auto-label-issues, auto-branch, duplicate-check, duplicate-sweep,
  remove-duplicate-label
- GSD PR and issue templates, plus the scripts only they used
  (require-issue-link-policy, pr-template-policy, pr-changed-files) and
  their tests
- workflow lists in ci-test-scope, ci-pr-mergeability,
  workflow-maintainer-skip, lint-pr-check-project-dir, the docs-guard
  baseline and the conformance tiers
- CONTRIBUTING: PR-against-next flow with no issue or template; PR titles
  are conventional without an issue ref; ADR/PRD files use the PR number
- CONTEXT.md: drop the PR-template predicates (k328, templates-mandatory)
This commit is contained in:
Jakub Zych
2026-10-09 10:53:54 +02:00
parent b7e36d5d38
commit bb581803ad
40 changed files with 44 additions and 4294 deletions

View File

@@ -17,14 +17,9 @@ const INERT_WORKFLOWS = new Set([
'stale.yml',
'branch-cleanup.yml',
'branch-naming.yml',
'auto-label-issues.yml',
'auto-branch.yml',
'auto-backmerge.yml',
'close-draft-prs.yml',
'dismiss-unauthorized-pr-approvals.yml',
'pr-target-validator.yml',
'pr-template-format.yml',
'require-issue-link.yml',
'changeset-required.yml',
'docs-required.yml',
'discord-changelog.yml',
@@ -65,7 +60,6 @@ function isInertCi(filePath) {
// Tests shared by both the 'workflow automation' and 'inert CI' rules.
const WORKFLOW_LINT_TESTS = [
'tests/workflow-shell-pinning.test.cjs',
'tests/pr-template-policy.test.cjs',
'tests/lint-pr-check-project-dir.test.cjs',
];

View File

@@ -163,7 +163,6 @@ module.exports = {
"tests/quick-research.test.cjs",
"tests/reapply-verify-hunks.test.cjs",
"tests/refactor-1390-t3-characterization.test.cjs",
"tests/require-issue-link-policy.test.cjs",
"tests/response-language-coverage.test.cjs",
"tests/retired-artifact-cleanup.test.cjs",
"tests/review-build-prompt-optional-sections.test.cjs",

View File

@@ -75,7 +75,6 @@ const DOCS_GUARD_EXEMPT_BASELINE = [
'precommit-alias-drift-hook.test.cjs',
'removed-but-needed-lint.test.cjs',
'repo-invariants.test.cjs',
'require-issue-link-policy.test.cjs',
'reviewer-manifest-body.test.cjs',
'reviewer-step-dispatch.test.cjs',
'run-tests-harness.test.cjs',
@@ -211,7 +210,6 @@ const DOCS_GUARD_EXEMPT_DOCS_PATHS = {
'docs/README.md', 'docs/getting-started.md', 'docs/msd-new-workspace.md', 'docs/setup.md', 'docs/some-doc.md',
],
'repo-invariants.test.cjs': ['docs/FEATURES.md', 'docs/workflows/README'],
'require-issue-link-policy.test.cjs': ['docs/-prefixed', 'docs/CONFIGURATION.md', 'docs/a.md', 'docs/b.md', 'docs/guide.md'],
'reviewer-manifest-body.test.cjs': ['docs/how-to/ship-a-reviewer-lane.md'],
// #4209: 'docs/spec.md' is a synthetic, never-read fake path proving dispatchReviewerLanes
// has no code-review-specific special-casing; 'docs/adr/456-test-rigor-architecture.md' is a

View File

@@ -10,13 +10,11 @@ const ROOT = path.join(__dirname, '..');
const DEFAULT_RELATIVE_FILES = [
'.github/workflows/test.yml',
'.github/workflows/pr-template-format.yml',
'.github/workflows/changeset-required.yml',
'scripts/lint-command-contract.cjs',
'scripts/lint-skill-deps.cjs',
'scripts/lint-descriptions.cjs',
'scripts/lint-shell-command-projection-drift.cjs',
'scripts/pr-template-policy.cjs',
'scripts/changeset/lint.cjs',
];

View File

@@ -1,63 +0,0 @@
'use strict';
/**
* Shared truncation-detection helper for `gh pr view --json files` (#3211).
*
* GitHub's GraphQL `files` connection on a pull request is a paginated
* connection. `gh pr view --json files` requests it with a single page at
* `first: 100` and does NOT paginate through the rest — so any PR touching
* more than 100 files silently returns only the first 100, with no error and
* no indication in the `files` array itself that entries are missing.
* `changedFiles` (a separate, non-paginated scalar field) reports the true
* total. A policy that relaxes a gate based on "every changed file matches
* pattern X" must treat a `files` list it cannot confirm is complete as
* incomplete — i.e. fail closed — rather than silently approving a PR whose
* 101st+ file might violate the policy.
*
* This file lives at the top level of scripts/ rather than in scripts/lib/
* because scripts/lib/** is enumerated in bin/install.js and ships to users
* on install; this is CI-only tooling with no reason to be installed.
*/
// GitHub's GraphQL `files` connection is requested at `first: 100` by
// `gh pr view --json files`; `gh` does not paginate this field.
const FILE_LIST_PAGE_LIMIT = 100;
/**
* Returns true iff `changedFiles` can be trusted as the COMPLETE list of
* files changed in the PR (i.e. it was not silently truncated).
*
* - An empty/non-array list cannot confirm anything about the PR — mirror
* the fail-closed stance `allPathsAreTooling` already takes on an empty
* list in scripts/pr-template-policy.cjs.
* - When the true total is known it is the AUTHORITY, at every list size —
* not just when the list has hit the page cap. The 100-entry page cap is
* only ONE way a list can be short of the truth; a `$GITHUB_OUTPUT`
* heredoc terminated early by an attacker-named file, or a path
* containing a newline, truncates or inflates the list just as
* effectively, and at any length. Comparing against the total catches all
* of them without enumerating the mechanisms.
* - Only when no total is supplied do we fall back to the page-cap
* heuristic: a list below the cap cannot have been truncated BY THE CAP,
* which is the only mechanism a caller without a total can rule out.
*/
function fileListIsComplete(changedFiles, changedFilesTotal) {
if (!Array.isArray(changedFiles) || changedFiles.length === 0) return false;
if (Number.isInteger(changedFilesTotal)) return changedFilesTotal === changedFiles.length;
return changedFiles.length < FILE_LIST_PAGE_LIMIT;
}
/**
* Parses a newline-delimited env var (as produced by `git diff --name-only`
* or `gh pr view --json files -q '.files[].path'`) into a trimmed,
* empty-line-free array. Returns [] for null/undefined/empty input.
*/
function parseChangedFilesEnv(value) {
if (!value) return [];
return String(value)
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
module.exports = { fileListIsComplete, FILE_LIST_PAGE_LIMIT, parseChangedFilesEnv };

View File

@@ -1,278 +0,0 @@
#!/usr/bin/env node
const { matchesGlob } = require('path');
const { fileListIsComplete, parseChangedFilesEnv } = require('./pr-changed-files.cjs');
const TRUSTED_AUTHOR_ASSOCIATIONS = new Set([
'CONTRIBUTOR',
'COLLABORATOR',
'MEMBER',
'OWNER',
]);
const DEFAULT_TEMPLATE_MARKERS = [
'Wrong template',
'Every PR must use a typed template',
'Select the template that matches your PR',
];
/**
* Glob patterns for files that are considered CI/tooling/docs scope.
* If ALL changed files in a PR match at least one of these patterns,
* template enforcement is skipped automatically.
*/
const TOOLING_PATH_ALLOWLIST = [
'.github/**',
'scripts/**',
'docs/**',
'*.md',
'.changeset/**',
'package.json',
'package-lock.json',
'pnpm-lock.yaml',
'yarn.lock',
'Pipfile',
'Pipfile.lock',
'requirements.txt',
'requirements*.txt',
'poetry.lock',
'Gemfile',
'Gemfile.lock',
'go.sum',
'go.mod',
];
/**
* Matches an explicit PR-template exemption marker of the form:
* <!-- pr-template-exempt: <non-empty reason> -->
*
* Capture group 1 is the reason (trimmed). Empty/whitespace-only reasons
* do NOT match because the pattern requires at least one non-whitespace
* character in the captured body.
*/
const EXEMPT_MARKER_REGEX = /<!--\s*pr-template-exempt:\s*([\s\S]*?\S)\s*-->/;
const TEMPLATES = [
{
name: 'fix',
heading: 'Fix PR',
requiredHeadings: [
'Fix PR',
'Linked Issue',
'What was broken',
'What this fix does',
'Testing',
'Checklist',
],
},
{
name: 'enhancement',
heading: 'Enhancement PR',
requiredHeadings: [
'Enhancement PR',
'Linked Issue',
'What this enhancement improves',
'Before / After',
'How it was implemented',
'Testing',
'Scope confirmation',
'Checklist',
],
},
{
name: 'feature',
heading: 'Feature PR',
requiredHeadings: [
'Feature PR',
'Linked Issue',
'Feature summary',
'What changed',
'Implementation notes',
'Spec compliance',
'Testing',
'Scope confirmation',
'Checklist',
],
},
];
function stripMarkdownDecoration(value) {
return value
.replace(/^\s*#+\s*/, '')
.replace(/\s*#+\s*$/, '')
.replace(/\*\*/g, '')
.trim()
.toLowerCase();
}
function extractHeadings(body) {
const headings = new Set();
for (const line of String(body || '').split(/\r?\n/)) {
if (/^\s*#{1,6}\s+\S/.test(line)) {
headings.add(stripMarkdownDecoration(line));
}
}
return headings;
}
function includesDefaultTemplate(body) {
const text = String(body || '').toLowerCase();
return DEFAULT_TEMPLATE_MARKERS.some((marker) => text.includes(marker.toLowerCase()));
}
function matchingTemplate(body) {
const headings = extractHeadings(body);
for (const template of TEMPLATES) {
if (!headings.has(stripMarkdownDecoration(template.heading))) continue;
const missingHeadings = template.requiredHeadings.filter((heading) => {
return !headings.has(stripMarkdownDecoration(heading));
});
return {
template: template.name,
missingHeadings,
};
}
return {
template: null,
missingHeadings: [],
};
}
/**
* Returns true iff every path in changedFiles matches at least one glob
* pattern in the allowlist. Returns false for an empty file list (no
* files means we cannot confirm it is a tooling-only PR).
*/
function allPathsAreTooling(changedFiles, allowlist, changedFilesTotal) {
if (!Array.isArray(changedFiles) || changedFiles.length === 0) return false;
// #3211: `gh pr view --json files` truncates at 100 entries with no
// in-band signal. A truncated list must not relax enforcement — an unseen
// 101st+ file could fall outside the tooling allowlist entirely.
if (!fileListIsComplete(changedFiles, changedFilesTotal)) return false;
return changedFiles.every((file) =>
allowlist.some((pattern) => matchesGlob(file, pattern)),
);
}
/**
* Returns true iff the body contains an explicit exemption marker with a
* non-empty (non-whitespace) reason.
*/
function hasExemptMarker(body, regex) {
const match = regex.exec(String(body || ''));
if (!match) return false;
return match[1].trim().length > 0;
}
function evaluatePrTemplate(body, authorAssociation, changedFiles, changedFilesTotal) {
const association = String(authorAssociation || '').toUpperCase();
const trusted = TRUSTED_AUTHOR_ASSOCIATIONS.has(association);
const normalizedBody = String(body || '').trim();
// --- Carve-out 1: all changed files are in the tooling allowlist ---
if (allPathsAreTooling(changedFiles, TOOLING_PATH_ALLOWLIST, changedFilesTotal)) {
return {
valid: true,
action: 'pass',
trusted,
authorAssociation: association || 'UNKNOWN',
template: null,
reason: null,
missingHeadings: [],
skipped: 'tooling-paths',
};
}
// --- Carve-out 2: explicit exemption marker in the PR body ---
if (hasExemptMarker(normalizedBody, EXEMPT_MARKER_REGEX)) {
return {
valid: true,
action: 'pass',
trusted,
authorAssociation: association || 'UNKNOWN',
template: null,
reason: null,
missingHeadings: [],
skipped: 'exempt-marker',
};
}
let valid = true;
let reason = 'PR body uses a typed pull request template.';
let template = null;
let missingHeadings = [];
if (!normalizedBody) {
valid = false;
reason = 'PR body is empty; a typed pull request template is required.';
} else {
const match = matchingTemplate(normalizedBody);
template = match.template;
missingHeadings = match.missingHeadings;
if (template && missingHeadings.length === 0) {
valid = true;
} else if (template && missingHeadings.length > 0) {
valid = false;
reason = `PR body appears to use the ${template} template but is missing required headings.`;
} else if (includesDefaultTemplate(normalizedBody)) {
valid = false;
reason = 'PR body still contains the default wrong-template guidance.';
} else {
valid = false;
reason = 'PR body does not match the fix, enhancement, or feature template.';
}
}
let action = 'pass';
if (!valid) {
action = trusted ? 'warn' : 'close';
}
return {
valid,
action,
trusted,
authorAssociation: association || 'UNKNOWN',
template,
reason,
missingHeadings,
};
}
function main() {
// Preserve the existing distinction between "unset" (undefined) and
// "set but empty" ([]) — allPathsAreTooling treats them differently.
const changedFiles = process.env.CHANGED_FILES
? parseChangedFilesEnv(process.env.CHANGED_FILES)
: undefined;
const parsedTotal = Number.parseInt(process.env.CHANGED_FILES_TOTAL, 10);
const changedFilesTotal = Number.isNaN(parsedTotal) ? undefined : parsedTotal;
const result = evaluatePrTemplate(
process.env.PR_BODY || '',
process.env.AUTHOR_ASSOCIATION || '',
changedFiles,
changedFilesTotal,
);
process.stdout.write(`${JSON.stringify(result)}\n`);
if (process.env.GITHUB_OUTPUT) {
const fs = require('fs');
fs.appendFileSync(process.env.GITHUB_OUTPUT, `result=${JSON.stringify(result)}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `action=${result.action}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `valid=${result.valid ? 'true' : 'false'}\n`);
}
}
if (require.main === module) {
main();
}
module.exports = {
evaluatePrTemplate,
extractHeadings,
includesDefaultTemplate,
matchingTemplate,
allPathsAreTooling,
hasExemptMarker,
TOOLING_PATH_ALLOWLIST,
EXEMPT_MARKER_REGEX,
};

View File

@@ -3,15 +3,11 @@
/**
* Single source of truth for conventional-commit PR-title parsing.
*
* Consumed by BOTH:
* - the release-notes changelog classifier
* (scripts/release-notes/format-github-release-notes.cjs), and
* - the PR-title CI gate (.github/workflows/pr-title-validator.yml, via
* evaluatePrTitle).
*
* Keeping one matcher here is the point of #1549: a forked copy of the regex
* would let the gate accept a title that the changelog then mis-buckets. Both
* the bucket anchors and the gate must read the title the same way.
* Consumed by the release-notes changelog classifier
* (scripts/release-notes/format-github-release-notes.cjs). `evaluatePrTitle`
* backed the upstream PR-title CI gate (#1549), which MSD does not run; it is
* kept so a title can still be checked against the same matcher the
* changelog buckets on.
*/
// Bucket anchors. The leading `^` is load-bearing: the changelog buckets on the

View File

@@ -1,207 +0,0 @@
#!/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,
};