Files
msd-core/scripts/lint-docs-command-form.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

196 lines
7.4 KiB
JavaScript

#!/usr/bin/env node
/**
* lint-docs-command-form.cjs
*
* Enforces the human-facing command form in docs/ (#2903).
*
* `/msd:<cmd>` (and bare `msd:<cmd>`) is a SOURCE-AUTHORING token: install-time
* converters (`transformContentToHyphen`, `convertSlashCommandsTo<Runtime>SkillMentions`)
* rewrite it to `/msd-<cmd>` per-runtime. It is correct in `commands/msd/**`,
* `msd-core/workflows/**`, and `agents/**` — but docs are never passed through a
* converter, so a doc telling a reader to type `/msd:<cmd>` names a command no
* runtime registers. The real user-facing form is `/msd-<cmd>`.
*
* This guard fails any `docs/**\/*.md` file that still contains `/msd:<cmd>` or
* bare `msd:<cmd>` where `<cmd>` is a real command name (drawn from the
* `commands/msd/*.md` roster). It explicitly permits `/msd-core:<cmd>` (the
* Claude Code plugin namespace) and any `msd:<token>` whose `<token>` is not a
* real command (e.g. the `msd:section` / `msd:loop-host` workflow-fragment
* marker syntax documented in docs/reference/workflow-fragments.md, or
* `msd:command-name` used as a placeholder while explaining the Gemini CLI
* colon-form convention).
*
* Exclusions (never checked):
* - docs/adr/** (historical record)
* - docs/RELEASE-NOTES-LEGACY.md (maintainer question still open)
* - commands/msd/**, msd-core/workflows/**, agents/** — never touched by this
* guard at all; the colon form is correct there.
*
* Exemption — `name:` frontmatter key citations: source command files
* (`commands/msd/*.md`) carry the colon form in their `name:` YAML frontmatter
* key (e.g. `name: msd:next`). A doc that quotes that key verbatim — e.g.
* `` `name: msd:next` `` or `name: msd:next` — is citing the real source file,
* not telling a reader what to type. Rewriting that citation to `msd-next`
* would make the doc lie about the source it's quoting, so a `msd:<cmd>` token
* immediately preceded by `name:` (optionally with a backtick/whitespace in
* between) is permitted. This is narrow: it does not exempt `msd:<cmd>`
* anywhere else on the line or file, including the reader-facing `/msd-<cmd>`
* form that may appear later in the same sentence.
*
* Detection is case-insensitive (`/MSD:next`, `Msd:Next`, etc. are all
* flagged) since the install-time converters and runtimes treat command names
* case-insensitively in practice, and a doc typo in casing is still a lie
* about the real command form.
*
* Exit 0 if no violations; exit 1 if any are found (with stderr diagnostics).
*/
'use strict';
const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const SELF_PATH = path.resolve(__filename);
// MSD_LINT_DOCS_COMMAND_FORM_REPO_ROOT is used by tests to redirect the guard to
// a temporary fixture git repo without touching the real working tree.
const REPO_ROOT = process.env.MSD_LINT_DOCS_COMMAND_FORM_REPO_ROOT
? path.resolve(process.env.MSD_LINT_DOCS_COMMAND_FORM_REPO_ROOT)
: path.resolve(__dirname, '..');
const DOCS_PREFIX = 'docs/';
const ADR_PREFIX = 'docs/adr/';
const RELEASE_NOTES_LEGACY = 'docs/RELEASE-NOTES-LEGACY.md';
const COMMANDS_DIR = path.join(REPO_ROOT, 'commands/msd');
// Matches `/msd:<cmd>` and bare `msd:<cmd>` (not part of `/msd-core:<cmd>`,
// which does not contain the substring `msd:` — the hyphen breaks it).
// Case-insensitive so `/MSD:next` / `Msd:Next` are also caught.
const COMMAND_FORM_RE = /(^|[^A-Za-z0-9_-])(\/)?msd:([A-Za-z0-9_-]+)/gi;
// A `msd:<cmd>` token is exempt when it is a citation of a source file's
// YAML `name:` frontmatter key — i.e. the text immediately before the match
// (ending exactly where the match begins) is `name:` followed by optional
// whitespace and/or a backtick. Only applies to the bare (non-`/`) form,
// since the real frontmatter key never carries a leading slash.
const NAME_KEY_CITATION_RE = /name:\s*`?\s*$/i;
function loadRoster() {
let entries;
try {
entries = fs.readdirSync(COMMANDS_DIR);
} catch (err) {
throw new ExitError(1, 'ERROR lint-docs-command-form: could not read commands/msd: ' + err.message);
}
return new Set(
entries.filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, '')),
);
}
function isCheckedDocsFile(relPath) {
if (!relPath.startsWith(DOCS_PREFIX)) return false;
if (relPath.startsWith(ADR_PREFIX)) return false;
if (relPath === RELEASE_NOTES_LEGACY) return false;
return relPath.endsWith('.md');
}
/**
* Pure scan — no fs, no git. Returns violations for a single file's content.
*
* @param {string} relPath file path (used only in violation records)
* @param {string} content file contents
* @param {Set<string>} roster valid command names
* @returns {Array<{file:string, line:number, col:number, text:string}>}
*/
function scanContent(relPath, content, roster) {
const violations = [];
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
COMMAND_FORM_RE.lastIndex = 0;
let match;
while ((match = COMMAND_FORM_RE.exec(line)) !== null) {
const [, pre, slash, cmd] = match;
if (!roster.has(cmd.toLowerCase())) continue;
if (!slash) {
const preContext = line.slice(0, match.index + pre.length);
if (NAME_KEY_CITATION_RE.test(preContext)) continue;
}
const matchedToken = (slash || '') + 'msd:' + cmd;
violations.push({
file: relPath,
line: i + 1,
col: match.index + pre.length + 1,
text: matchedToken,
});
}
}
return violations;
}
function main() {
const roster = loadRoster();
let trackedFiles;
try {
trackedFiles = execFileSync('git', ['ls-files'], { cwd: REPO_ROOT, encoding: 'utf8' })
.split('\n')
.map((f) => f.trim())
.filter(Boolean);
} catch (err) {
throw new ExitError(1, 'ERROR lint-docs-command-form: git ls-files failed: ' + err.message);
}
const docsFiles = trackedFiles.filter(isCheckedDocsFile);
const violations = [];
for (const relPath of docsFiles) {
const fullPath = path.join(REPO_ROOT, relPath);
if (path.resolve(fullPath) === SELF_PATH) continue;
let content;
try {
content = fs.readFileSync(fullPath, 'utf8');
} catch {
// Unreadable/deleted files — skip silently.
continue;
}
violations.push(...scanContent(relPath, content, roster));
}
if (violations.length === 0) {
process.stdout.write(
'ok lint-docs-command-form: ' + docsFiles.length + ' file(s) checked, 0 violations\n',
);
return 0;
}
process.stderr.write('\nERROR lint-docs-command-form: ' + violations.length + ' violation(s) found\n\n');
for (const v of violations) {
process.stderr.write(' ' + v.file + ':' + v.line + ':' + v.col + ' — ' + JSON.stringify(v.text) + '\n');
}
process.stderr.write('\n');
process.stderr.write(
'Fix: docs are never passed through the install-time slash-form converters, so the\n',
);
process.stderr.write(
' colon form names a command no runtime registers. Rewrite to the hyphen form\n',
);
process.stderr.write(
' (`/msd-<cmd>`), or `/msd-core:<cmd>` if this is genuinely the Claude Code\n',
);
process.stderr.write(' plugin namespace.\n\n');
return 1;
}
if (require.main === module) runMain(main);
module.exports = {
scanContent,
isCheckedDocsFile,
loadRoster,
COMMAND_FORM_RE,
};