Spaces:
Runtime error
Runtime error
File size: 5,768 Bytes
a6b96c2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 | #!/usr/bin/env node
'use strict';
/**
* Changeset-fragment lint (#2975).
*
* Pure verdict function evaluateLint({ changedFiles, labels }) returns
* { ok, reason } using the LINT_REASON enum. The CLI wrapper calls it with
* the PR diff (via `git diff --name-only origin/main...HEAD` or the GitHub
* Actions event payload) and the labels list (via the GitHub event).
*
* Tests assert on the typed verdict, never on free text.
*/
const LINT_REASON = Object.freeze({
OK_FRAGMENT_PRESENT: 'ok_fragment_present',
OK_OPT_OUT_LABEL: 'ok_opt_out_label',
OK_NO_USER_FACING_CHANGES: 'ok_no_user_facing_changes',
FAIL_MISSING_FRAGMENT: 'fail_missing_fragment',
FAIL_INVALID_FRAGMENT: 'fail_invalid_fragment',
});
const OPT_OUT_LABEL = 'no-changelog';
// Files counted as "user-facing" — touching any of these requires either a
// fragment or an explicit opt-out label. Test/CI/docs/lock files do not.
const USER_FACING_PREFIXES = [
'bin/',
'gsd-core/',
'agents/',
'commands/',
'hooks/',
'sdk/src/',
'sdk/prompts/',
];
// Exact-match user-facing files. Any direct edit to one of these without a
// fragment also fails the lint — closes the bypass where a contributor edits
// CHANGELOG.md directly to sneak past the new workflow.
const USER_FACING_FILES = new Set(['CHANGELOG.md']);
function isUserFacing(file) {
if (USER_FACING_FILES.has(file)) return true;
return USER_FACING_PREFIXES.some((p) => file.startsWith(p));
}
function isFragment(file) {
return /^\.changeset\/[^/]+\.md$/.test(file) && !file.endsWith('/README.md');
}
function evaluateLint({ changedFiles, labels, fragmentFailures = [] }) {
if (fragmentFailures.length > 0) {
return { ok: false, reason: LINT_REASON.FAIL_INVALID_FRAGMENT, failures: fragmentFailures };
}
if (changedFiles.some(isFragment)) {
return { ok: true, reason: LINT_REASON.OK_FRAGMENT_PRESENT };
}
if (labels.includes(OPT_OUT_LABEL)) {
return { ok: true, reason: LINT_REASON.OK_OPT_OUT_LABEL };
}
if (!changedFiles.some(isUserFacing)) {
return { ok: true, reason: LINT_REASON.OK_NO_USER_FACING_CHANGES };
}
return { ok: false, reason: LINT_REASON.FAIL_MISSING_FRAGMENT };
}
const { ExitError, runMain } = require('../lib/cli-exit.cjs');
const { parseFragment } = require('./parse.cjs');
function main() {
const fs = require('node:fs');
const cp = require('node:child_process');
// GitHub Actions event payload path
const eventPath = process.env.GITHUB_EVENT_PATH;
let labels = [];
if (eventPath && fs.existsSync(eventPath)) {
try {
const event = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
labels = (event.pull_request?.labels || []).map((l) => l.name);
} catch { /* fall through */ }
}
const base = process.env.GITHUB_BASE_REF || 'main';
let changedFiles = [];
try {
// Use execFileSync with an argv array — the base ref is interpolated
// into a refspec argument, but execFileSync does not invoke a shell, so
// even a malicious GITHUB_BASE_REF cannot inject shell syntax. The
// refspec-bound metacharacters that git itself rejects (e.g. spaces in
// ref names) are caught by git's own arg parser.
const out = cp.execFileSync(
'git',
['diff', '--name-only', `origin/${base}...HEAD`],
{ encoding: 'utf8' },
);
changedFiles = out.split('\n').filter(Boolean);
} catch (e) {
throw new ExitError(2, `could not compute diff: ${e.message}`);
}
// Validate the content of every changed fragment file.
const fragmentFailures = [];
for (const file of changedFiles) {
if (!isFragment(file)) continue;
// A fragment path in the diff that no longer exists on disk was deleted in
// this PR — a deletion can't be malformed, so skip it.
if (!fs.existsSync(file)) continue;
let src;
try {
src = fs.readFileSync(file, 'utf8');
} catch (e) {
// Present in the diff but unreadable (broken symlink, permissions). A
// changed fragment we cannot read is suspect — fail closed rather than
// letting it slip through to the release-time CHANGELOG render.
fragmentFailures.push({ file, reason: 'unreadable', detail: e.code || 'read_error' });
continue;
}
const result = parseFragment(src);
if (!result.ok) {
fragmentFailures.push({ file, reason: result.reason, detail: result.detail });
}
}
const verdict = evaluateLint({ changedFiles, labels, fragmentFailures });
if (process.argv.includes('--json')) {
process.stdout.write(JSON.stringify({ ...verdict, changedFiles, labels }, null, 2) + '\n');
} else if (verdict.ok) {
process.stdout.write(`ok changeset-lint: ${verdict.reason}\n`);
} else if (verdict.reason === LINT_REASON.FAIL_INVALID_FRAGMENT) {
process.stderr.write(`\nERROR changeset-lint: ${verdict.reason}\n`);
process.stderr.write(`The following .changeset fragment(s) failed content validation:\n`);
for (const f of verdict.failures) {
const detail = f.detail !== undefined ? ` (${f.detail})` : '';
process.stderr.write(` ${f.file}: ${f.reason}${detail}\n`);
}
process.stderr.write(`Fix the fragment(s) above before merging.\n`);
} else {
process.stderr.write(`\nERROR changeset-lint: ${verdict.reason}\n`);
process.stderr.write(`PR touches user-facing files but does not include a .changeset/*.md fragment.\n`);
process.stderr.write(`Run \`npm run changeset\` to create one, or add the \`${OPT_OUT_LABEL}\` label\n`);
process.stderr.write(`if this PR genuinely has no user-facing impact (test refactor, CI tweak, etc.).\n`);
}
return verdict.ok ? 0 : 1;
}
if (require.main === module) runMain(main);
module.exports = { evaluateLint, LINT_REASON, OPT_OUT_LABEL, isUserFacing, isFragment };
|