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 };