Enforce documentation updates via lint:docs + PR templates (#3651)
* Enforce documentation updates via lint:docs + PR templates (#3213) New scripts/lint-docs-required.cjs + Docs Required CI workflow fail any PR whose changeset fragment is typed Added / Changed / Deprecated / Removed without modifying at least one file under docs/. Mirrors scripts/changeset/lint.cjs: pure evaluateLint({ changedFiles, fragments, labels }) returning { ok, reason, triggering } over a frozen LINT_REASON enum; CLI wrapper reads the PR diff and parses each touched changeset fragment via the existing parseFragment helper. Escape hatches: - no-docs PR label (global) - per-fragment <!-- docs-exempt: <reason> --> marker, all triggering fragments must carry it for the PR to pass Fixed and Security fragments do not trigger the lint — bug fixes restore documented behavior, they do not introduce new behavior to document. PR templates (enhancement.md, feature.md) gain a Documentation checklist section pointing at the which-doc-to-update matrix. CONTRIBUTING.md adds a Documentation Updates section codifying that matrix, the English-canonical language policy for docs/ and the root README, and the two opt-out routes. Closes #3213 * Address Codex review: fail-closed on malformed fragments and strip docs-exempt marker from rendered release notes (#3213) Two P2 issues caught by `codex review --base main`: 1) Malformed fragments could silently bypass docs enforcement. parseFragment would return ok:false on a triggering Added fragment with bad frontmatter and readFragmentsFromDisk dropped it, so evaluateLint saw no triggering fragments and passed. The changeset-required lint only checks fragment _presence_ not _validity_, so the assumed fallback did not catch it. Fix: readFragmentsFromDisk now returns { fragments, malformed }; evaluateLint accepts a malformed param and emits a new FAIL_MALFORMED_FRAGMENT verdict that outranks every OK path (including the no-docs label) — a parse failure must be fixed before docs lint can decide anything else. 2) The per-fragment <!-- docs-exempt: reason --> marker lived in the fragment body, so the existing changelog (serializeChangelog) and GitHub release-notes (formatBullet) serializers published it verbatim. Worse, both serializers append `(#NNNN)` to the body's last line — with the marker as the trailing line, the PR suffix attached to the hidden comment instead of the visible bullet. Fix: parseFragment now extracts the marker into a typed `docsExempt` field and strips it from `body`, so all downstream renderers produce clean output without remembering to strip. The regex is anchored to its own line (^...$ with m flag) so inline mentions of the marker syntax in documentation (e.g. inside backticks) cannot accidentally exempt a fragment. Bounded character class [^\n>] keeps the regex linear-time. Test additions: - tests/lint-docs-required.test.cjs: FAIL_MALFORMED_FRAGMENT coverage, end-to-end "Added fragment with bad pr → malformed → fail-closed" regression test, updated readFragmentsFromDisk return-shape assertions, isExemptFragment now checks the typed docsExempt field rather than body content. - tests/changeset-parse.test.cjs: extractDocsExempt extraction cases (with/ without reason, case-insensitive, EMPTY_BODY when body is only a marker), inline-mention false-positive guard, real-marker-wins-when-also-inline test. - tests/changeset-new.test.cjs: fragment shape now includes docsExempt: null. CONTRIBUTING.md updated to clarify the "on its own line" requirement and the parse-time stripping behavior. The bootstrap fragment cleaned up so its body no longer contains a literal marker example that would have triggered the false-positive case. Full suite: 9696/9696 pass. * CRLF-safe docs-exempt marker stripping (Codex review pass 2, #3213) Second `codex review --commit` pass caught a CRLF regression in the docs-exempt extraction added in the previous commit. Repro: a Windows-authored fragment ---\r\ntype: Added\r\npr: 1\r\n---\r\nFeature.\r\n\r\n<!-- docs-exempt: x -->\r\n would parse to body `Feature.\r\n\r\n\r` because: - The previous trailing-newline slice trimmed only `\n`, leaving `\r`. - DOCS_EXEMPT_RE was anchored with `$` only — in multiline mode `$` matches before `\n` but does not consume `\r`, so the marker line's trailing `\r` was left behind after replace. - The cleanup regex stripped trailing `\n` but not `\r`. Net effect: serializeChangelog emitted - Feature.\r \r \r (#1) — the `(#1)` PR suffix landed on a blank line instead of attached to the visible bullet. Same bug surfaces in github-release-notes formatBullet. Fix: - DOCS_EXEMPT_RE: add `\r?` before `$` so the regex consumes the CR of a CRLF terminator. Switch reason character class from `[^\n>]` to `[^\r\n>]` so CRLF-authored reasons don't carry a trailing `\r`. - extractDocsExempt cleanup: `[ \t\r]+$/gm` strips trailing `\r` on each line; `(?:\r?\n){3,}` collapses CRLF triple-blank-lines; `[\r\n]+$` strips every trailing line terminator (LF or CR). - parseFragment trailing-newline slice: CRLF-aware — strips `\r\n` (2 chars) before falling through to single `\n`. Tests: two CRLF regression cases in tests/changeset-parse.test.cjs — Codex's exact repro (end-to-end through serializeChangelog) plus the no-marker CRLF passthrough case. Full suite: 9698/9698 pass. * CRLF regression test asserts on parseChangelog IR not rendered text (Codex review pass 3, #3213) Third `codex review` pass caught that the CRLF regression test added in the previous commit asserted on serializeChangelog's rendered Markdown via `out.split('\n')` + `assert.match`. That violates CONTRIBUTING.md's "Prohibited: Raw Text Matching on Test Outputs" rule and the documented serializer contract in `serialize.cjs`: > tests assert via round-trip (parse(serialize(ir))) > rather than by inspecting serialized text Replace the regex check with the established `parseChangelog(out)` round-trip and assert on the structured `{ body: 'Feature.', pr: 1 }` bullet. This is also a stronger regression check than the substring match: Codex's own probe in the review session confirmed the pre-fix buggy body shape (`Feature.\r\n\r\n\r`) breaks parseChangelog's bullet regex entirely (returns `bullets: []`), so the round-trip catches the exact failure mode end-to-end. Full suite: 9698/9698 pass. * Address CodeRabbit findings: anchor link + require non-empty docs-exempt reason (#3213) CodeRabbit's review on the PR caught two actionable issues, both quick wins. Anchor link in PR templates pointed to a heading that does not exist. The CONTRIBUTING.md heading "Documentation Updates — Update the Relevant Docs" contains an em-dash, which GitHub strips entirely when generating anchor slugs (it does NOT collapse to a hyphen). The actual anchor is #documentation-updates-update-the-relevant-docs (single hyphen between every word), not #documentation-updates--update-the-relevant-docs (double hyphen where the em-dash was). Both feature.md and enhancement.md fixed. The docs-exempt marker matched a bare `<!-- docs-exempt -->` with no reason, which defeats the entire purpose of the escape hatch — the marker exists to leave an audit trail explaining WHY a PR is exempt. Without a reason it is a silent bypass. Fix: DOCS_EXEMPT_RE now requires both the colon AND a non-whitespace first reason character. Bare `<!-- docs-exempt -->`, empty `<!-- docs-exempt: -->`, and whitespace-only `<!-- docs-exempt: -->` are all rejected as if the marker were not present (`docsExempt: null`). The lint then falls through to its normal docs-required / no-docs-label checks. `isExemptFragment` in the lint module tightened too — defense-in-depth: even if a caller constructs a fragment with `docsExempt: ''` directly, it does not count as exempt. The predicate now requires `typeof === 'string'` and non-empty after trim. Tests: - changeset-parse.test.cjs: three new explicit-rejection cases (bare marker, empty reason, whitespace-only reason). Existing DOCS_EXEMPT_RE shape test extended with negative assertions for the same three forms. - lint-docs-required.test.cjs: prior "empty reason still exempt" test inverted — empty/whitespace docsExempt now produces FAIL_DOCS_MISSING. isExemptFragment helper test extended with the same negative cases. - CONTRIBUTING.md: clarified that the reason is required and non-empty. Skipped CodeRabbit's third finding ("use `npm run lint:docs` in CI workflow instead of `node scripts/lint-docs-required.cjs`") — the existing changeset-required.yml uses the direct-node form for the equivalent changeset lint, so the new docs-required.yml is convention-consistent. Switching one without the other would create drift, and switching both is out of scope for #3213. Bootstrap fragment continues to extract cleanly under the stricter regex (verified — `docsExempt` field still contains the full bootstrap reason). Full suite: 9701/9701 pass.
This commit is contained in:
@@ -9,9 +9,15 @@
|
||||
* ---
|
||||
* <markdown body>
|
||||
*
|
||||
* Returns { ok: true, fragment: { type, pr, body } } on success,
|
||||
* Returns { ok: true, fragment: { type, pr, body, docsExempt } } on success,
|
||||
* { ok: false, reason: FRAGMENT_ERROR.X, detail } on failure.
|
||||
*
|
||||
* `docsExempt` is `null` when the body contains no docs-exempt marker, or the
|
||||
* trimmed reason string when the body contains `<!-- docs-exempt: <reason> -->`
|
||||
* (#3213). The marker is stripped from `body` at parse time so it never bleeds
|
||||
* into the CHANGELOG.md or GitHub release-notes serializers, which append the
|
||||
* `(#NNNN)` PR suffix verbatim to the body's last line.
|
||||
*
|
||||
* The reason field is a frozen enum so tests assert on stable codes,
|
||||
* not free-text error messages (CONTRIBUTING.md: "Prohibited: Raw
|
||||
* Text Matching on Test Outputs").
|
||||
@@ -27,6 +33,46 @@ const FRAGMENT_ERROR = Object.freeze({
|
||||
|
||||
const ALLOWED_TYPES = new Set(['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']);
|
||||
|
||||
// HTML comment marking a fragment as exempt from the docs-required lint (#3213).
|
||||
// Form: `<!-- docs-exempt: <reason> -->`. The reason is the *required* human
|
||||
// audit trail — without it the exemption has no paper-trail value, so a bare
|
||||
// `<!-- docs-exempt -->` or empty `<!-- docs-exempt: -->` is intentionally
|
||||
// rejected (the colon and a non-whitespace first reason char are mandatory).
|
||||
//
|
||||
// Anchored with `^...$` + `m` flag so the marker only counts when it occupies
|
||||
// its own line. Inline mentions inside paragraphs (e.g. backtick-wrapped
|
||||
// syntax examples in documentation) are not matched — they cannot
|
||||
// accidentally exempt a fragment.
|
||||
//
|
||||
// The trailing `\r?` consumes the CR character of a CRLF line terminator,
|
||||
// which the `$` boundary (multiline mode) does not — so Windows-authored
|
||||
// fragments produce the same `body` shape as LF-authored ones. The reason
|
||||
// character class `[^\r\n>]` excludes `\r` for the same reason: a CRLF
|
||||
// fragment's reason text never carries a trailing `\r`.
|
||||
//
|
||||
// Bounded character class `[^\r\n>]` keeps the regex linear-time — no
|
||||
// catastrophic backtracking on adversarial input. The leading `\S` anchor
|
||||
// inside the capture group forces at least one non-whitespace character in
|
||||
// the reason; trailing whitespace before `-->` is consumed by the outer
|
||||
// `[ \t]*-->` and is not part of the captured reason.
|
||||
const DOCS_EXEMPT_RE = /^[ \t]*<!--[ \t]*docs-exempt[ \t]*:[ \t]*(\S[^\r\n>]*?)[ \t]*-->[ \t]*\r?$/im;
|
||||
|
||||
function extractDocsExempt(body) {
|
||||
const m = body.match(DOCS_EXEMPT_RE);
|
||||
if (!m) return { docsExempt: null, body };
|
||||
const reason = (m[1] || '').trim();
|
||||
// Strip the marker line and tidy up the surrounding whitespace. The cleanup
|
||||
// is CRLF-aware so Windows-authored fragments don't leave residual `\r`
|
||||
// characters that would shift the `(#NNNN)` PR suffix to a blank line in
|
||||
// the rendered CHANGELOG.md / GitHub release-notes bullet.
|
||||
const cleaned = body
|
||||
.replace(DOCS_EXEMPT_RE, '')
|
||||
.replace(/[ \t\r]+$/gm, '') // strip trailing \r/spaces on each line
|
||||
.replace(/(?:\r?\n){3,}/g, '\n\n') // collapse 3+ blank lines (CRLF-aware)
|
||||
.replace(/[\r\n]+$/, ''); // strip every trailing line terminator
|
||||
return { docsExempt: reason, body: cleaned };
|
||||
}
|
||||
|
||||
function parseFragment(src) {
|
||||
const fmMatch = src.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
||||
if (!fmMatch) return { ok: false, reason: FRAGMENT_ERROR.MISSING_FRONTMATTER };
|
||||
@@ -49,12 +95,20 @@ function parseFragment(src) {
|
||||
}
|
||||
// Use trim() only for the emptiness check; preserve the body verbatim
|
||||
// (including significant leading/trailing whitespace, code blocks, etc.)
|
||||
// so render → serialize round-trips exactly. Strip only a single trailing
|
||||
// newline added by editors so byte-equality holds for typical fragments.
|
||||
// so render → serialize round-trips exactly. Strip the single trailing
|
||||
// line terminator added by editors so byte-equality holds for typical
|
||||
// fragments. CRLF-aware: a Windows-authored fragment trims `\r\n` so the
|
||||
// marker line in extractDocsExempt does not leave residual `\r` characters
|
||||
// for downstream serializers to attach `(#NNNN)` to (#3213).
|
||||
if (!body.trim()) return { ok: false, reason: FRAGMENT_ERROR.EMPTY_BODY };
|
||||
const verbatimBody = body.endsWith('\n') ? body.slice(0, -1) : body;
|
||||
let verbatimBody;
|
||||
if (body.endsWith('\r\n')) verbatimBody = body.slice(0, -2);
|
||||
else if (body.endsWith('\n')) verbatimBody = body.slice(0, -1);
|
||||
else verbatimBody = body;
|
||||
const { docsExempt, body: visibleBody } = extractDocsExempt(verbatimBody);
|
||||
if (!visibleBody.trim()) return { ok: false, reason: FRAGMENT_ERROR.EMPTY_BODY };
|
||||
|
||||
return { ok: true, fragment: { type: fields.type, pr, body: verbatimBody } };
|
||||
return { ok: true, fragment: { type: fields.type, pr, body: visibleBody, docsExempt } };
|
||||
}
|
||||
|
||||
module.exports = { parseFragment, FRAGMENT_ERROR, ALLOWED_TYPES };
|
||||
module.exports = { parseFragment, extractDocsExempt, FRAGMENT_ERROR, ALLOWED_TYPES, DOCS_EXEMPT_RE };
|
||||
|
||||
222
scripts/lint-docs-required.cjs
Executable file
222
scripts/lint-docs-required.cjs
Executable file
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Docs-required lint (#3213).
|
||||
*
|
||||
* Mirrors scripts/changeset/lint.cjs. Pure verdict function
|
||||
* evaluateLint({ changedFiles, fragments, labels, malformed }) returns
|
||||
* { ok, reason, triggering } using the LINT_REASON enum. The CLI wrapper
|
||||
* reads the PR diff (`git diff --name-only origin/${base}...HEAD`), parses
|
||||
* each touched `.changeset/*.md` fragment, then calls evaluateLint.
|
||||
*
|
||||
* Tests assert on the structured verdict, never on free text.
|
||||
*/
|
||||
|
||||
const { parseFragment, FRAGMENT_ERROR } = require('./changeset/parse.cjs');
|
||||
|
||||
const LINT_REASON = Object.freeze({
|
||||
OK_NO_TRIGGERING_FRAGMENTS: 'ok_no_triggering_fragments',
|
||||
OK_DOCS_UPDATED: 'ok_docs_updated',
|
||||
OK_OPT_OUT_LABEL: 'ok_opt_out_label',
|
||||
OK_FRAGMENTS_EXEMPT: 'ok_fragments_exempt',
|
||||
FAIL_DOCS_MISSING: 'fail_docs_missing',
|
||||
FAIL_MALFORMED_FRAGMENT: 'fail_malformed_fragment',
|
||||
});
|
||||
|
||||
const OPT_OUT_LABEL = 'no-docs';
|
||||
|
||||
// Fragment types that require a docs update. `Fixed` and `Security` are
|
||||
// bug-class — they describe regressions or vulnerabilities, not new
|
||||
// behavior to document.
|
||||
const TRIGGERING_TYPES = new Set(['Added', 'Changed', 'Deprecated', 'Removed']);
|
||||
|
||||
const DOCS_PREFIX = 'docs/';
|
||||
|
||||
function isFragmentPath(file) {
|
||||
return /^\.changeset\/[^/]+\.md$/.test(file) && !file.endsWith('/README.md');
|
||||
}
|
||||
|
||||
function isDocsFile(file) {
|
||||
return file.startsWith(DOCS_PREFIX);
|
||||
}
|
||||
|
||||
// Per-fragment escape hatch: parse.cjs extracts `<!-- docs-exempt: <reason> -->`
|
||||
// from the body into `fragment.docsExempt` (a non-empty reason string when the
|
||||
// marker was present and well-formed; `null` otherwise). A non-empty audit
|
||||
// trail is required — the lint defends in depth here too: even if a caller
|
||||
// constructs a fragment with `docsExempt: ''`, that does not count as exempt.
|
||||
function isExemptFragment(fragment) {
|
||||
return typeof fragment.docsExempt === 'string' && fragment.docsExempt.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure verdict — no fs, no git.
|
||||
*
|
||||
* Malformed fragments fail closed: a triggering fragment with bad frontmatter
|
||||
* cannot silently bypass docs enforcement. The changeset-required lint only
|
||||
* checks fragment _presence_, not _validity_, so docs lint takes responsibility
|
||||
* for any fragment it tries to consume.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {string[]} args.changedFiles - file paths changed in the PR
|
||||
* @param {Array<{ path: string, type: string, body: string, docsExempt: string|null }>} args.fragments
|
||||
* - parsed records for well-formed `.changeset/*.md` files in `changedFiles`
|
||||
* @param {Array<{ path: string, reason: string }>} [args.malformed]
|
||||
* - records for `.changeset/*.md` files that failed `parseFragment`
|
||||
* @param {string[]} args.labels - PR labels
|
||||
* @returns {{ ok: boolean, reason: string, triggering: string[], malformed?: Array<{path:string,reason:string}> }}
|
||||
*/
|
||||
function evaluateLint({ changedFiles, fragments, labels, malformed = [] }) {
|
||||
if (malformed.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: LINT_REASON.FAIL_MALFORMED_FRAGMENT,
|
||||
triggering: [],
|
||||
malformed,
|
||||
};
|
||||
}
|
||||
|
||||
const triggering = fragments.filter((f) => TRIGGERING_TYPES.has(f.type));
|
||||
const triggeringPaths = triggering.map((f) => f.path);
|
||||
|
||||
if (triggering.length === 0) {
|
||||
return { ok: true, reason: LINT_REASON.OK_NO_TRIGGERING_FRAGMENTS, triggering: [] };
|
||||
}
|
||||
|
||||
// Per-fragment exempt path: every triggering fragment must carry the marker.
|
||||
// Partial exemption fails closed — one un-marked Added fragment still requires docs.
|
||||
if (triggering.every(isExemptFragment)) {
|
||||
return { ok: true, reason: LINT_REASON.OK_FRAGMENTS_EXEMPT, triggering: triggeringPaths };
|
||||
}
|
||||
|
||||
if (labels.includes(OPT_OUT_LABEL)) {
|
||||
return { ok: true, reason: LINT_REASON.OK_OPT_OUT_LABEL, triggering: triggeringPaths };
|
||||
}
|
||||
|
||||
if (changedFiles.some(isDocsFile)) {
|
||||
return { ok: true, reason: LINT_REASON.OK_DOCS_UPDATED, triggering: triggeringPaths };
|
||||
}
|
||||
|
||||
return { ok: false, reason: LINT_REASON.FAIL_DOCS_MISSING, triggering: triggeringPaths };
|
||||
}
|
||||
|
||||
function readFragmentsFromDisk(changedFiles, rootDir) {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const fragments = [];
|
||||
const malformed = [];
|
||||
for (const rel of changedFiles) {
|
||||
if (!isFragmentPath(rel)) continue;
|
||||
const abs = path.join(rootDir, rel);
|
||||
if (!fs.existsSync(abs)) continue; // fragment deleted in PR — skip
|
||||
let src;
|
||||
try {
|
||||
src = fs.readFileSync(abs, 'utf8');
|
||||
} catch (e) {
|
||||
malformed.push({ path: rel, reason: 'read_error', detail: e.code || e.message });
|
||||
continue;
|
||||
}
|
||||
const parsed = parseFragment(src);
|
||||
if (!parsed.ok) {
|
||||
malformed.push({ path: rel, reason: parsed.reason, detail: parsed.detail || null });
|
||||
continue;
|
||||
}
|
||||
fragments.push({
|
||||
path: rel,
|
||||
type: parsed.fragment.type,
|
||||
body: parsed.fragment.body,
|
||||
docsExempt: parsed.fragment.docsExempt,
|
||||
});
|
||||
}
|
||||
return { fragments, malformed };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const fs = require('node:fs');
|
||||
const cp = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
|
||||
const rootDir = path.join(__dirname, '..');
|
||||
|
||||
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 {
|
||||
// execFileSync with argv — no shell, so a malicious GITHUB_BASE_REF
|
||||
// cannot inject shell syntax. Git's own ref-name validator rejects
|
||||
// any metacharacters it would otherwise interpret.
|
||||
const out = cp.execFileSync(
|
||||
'git',
|
||||
['diff', '--name-only', `origin/${base}...HEAD`],
|
||||
{ encoding: 'utf8', cwd: rootDir },
|
||||
);
|
||||
changedFiles = out.split('\n').filter(Boolean);
|
||||
} catch (e) {
|
||||
process.stderr.write(`could not compute diff: ${e.message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const { fragments, malformed } = readFragmentsFromDisk(changedFiles, rootDir);
|
||||
const verdict = evaluateLint({ changedFiles, fragments, labels, malformed });
|
||||
|
||||
if (process.argv.includes('--json')) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({ ...verdict, changedFiles, fragments, malformed, labels }, null, 2) + '\n',
|
||||
);
|
||||
} else if (verdict.ok) {
|
||||
process.stdout.write(`ok docs-lint: ${verdict.reason}\n`);
|
||||
} else if (verdict.reason === LINT_REASON.FAIL_MALFORMED_FRAGMENT) {
|
||||
process.stderr.write(`\nERROR docs-lint: ${verdict.reason}\n`);
|
||||
process.stderr.write(
|
||||
`${malformed.length} changeset fragment(s) failed to parse — docs lint cannot consume them:\n`,
|
||||
);
|
||||
for (const m of malformed) {
|
||||
process.stderr.write(` ${m.path} (reason: ${m.reason}${m.detail ? `, detail: ${m.detail}` : ''})\n`);
|
||||
}
|
||||
process.stderr.write(
|
||||
`\nFix the fragment frontmatter (\`type:\` + \`pr:\`) before this PR can pass.\n`,
|
||||
);
|
||||
} else {
|
||||
process.stderr.write(`\nERROR docs-lint: ${verdict.reason}\n`);
|
||||
process.stderr.write(
|
||||
`${verdict.triggering.length} changeset fragment(s) require documentation updates:\n`,
|
||||
);
|
||||
for (const f of fragments.filter((f) => TRIGGERING_TYPES.has(f.type))) {
|
||||
process.stderr.write(` ${f.path} (type: ${f.type})\n`);
|
||||
}
|
||||
process.stderr.write(`\nNo files under docs/ were modified in this PR.\n\n`);
|
||||
process.stderr.write(
|
||||
`Update the relevant docs/ file(s), or add the \`${OPT_OUT_LABEL}\` label if this change\n`,
|
||||
);
|
||||
process.stderr.write(
|
||||
`is genuinely internal-only (infrastructure, refactor, test-only). Per-fragment\n`,
|
||||
);
|
||||
process.stderr.write(
|
||||
`exemption via \`<!-- docs-exempt: <reason> -->\` inside the fragment body also works.\n`,
|
||||
);
|
||||
}
|
||||
process.exit(verdict.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = {
|
||||
evaluateLint,
|
||||
readFragmentsFromDisk,
|
||||
LINT_REASON,
|
||||
OPT_OUT_LABEL,
|
||||
TRIGGERING_TYPES,
|
||||
FRAGMENT_ERROR,
|
||||
isFragmentPath,
|
||||
isDocsFile,
|
||||
isExemptFragment,
|
||||
};
|
||||
Reference in New Issue
Block a user