Files
msd-core/scripts/lint-portable-grep.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

177 lines
7.6 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* lint-portable-grep.cjs — ban GNU-only `grep -P`/`--perl-regexp` in msd
* workflow / agent / reference / command markdown (#4112 macOS regression).
*
* ## Why
*
* `-P`/`--perl-regexp` selects GNU grep's PCRE engine. Stock macOS ships BSD
* grep, which does not implement `-P` at all — the call errors out ("grep:
* invalid option -- P" or "unknown option") on that host, and a pipeline like
* `... | grep -oP '...' || true` swallows that error and silently resolves to
* an empty string instead of failing loudly.
*
* This is exactly what happened in `msd-core/workflows/pause-work.md`'s
* Context Detection step (#4112, merged as #4140): the fix for the `$((`
* shell-syntax bug left `grep -oP 'phases/\K[^/]+'` in place. That construct
* had never been exercised on macOS before (the syntax error masked it), so
* fixing the syntax bug newly exposed a second, pre-existing macOS-only
* defect — phase/spike/sketch detection silently resolving to "" on macOS —
* which the PR's own new regression test then caught, but only in the
* post-merge `full test (macos-latest, ...)` matrix. That lane runs on push
* to the base branch, not on the pull_request event, so nothing pre-merge
* (msd-test's Linux-only benches included) could have caught it before `next`
* went red.
*
* This is the same class of defect `lint-portable-timeout.cjs` (#2351) exists
* for: a GNU-coreutils assumption baked into workflow markdown that every
* pre-merge gate is blind to because none of them run on macOS. The fix here
* is the same shape — a static ratchet that catches the assumption in the
* markdown itself, before a shell ever executes it on the platform that lacks
* the flag.
*
* ## What PASSES
*
* - `grep -o '...'`, `grep -E '...'`, `egrep`/`fgrep` without `-P`.
* - `-P` appearing as part of an unrelated long option (`--path`, `--perl`) —
* only a `-P`/short-cluster-containing-`P` token or the literal
* `--perl-regexp` counts.
* - Prose that merely mentions "grep -P" outside of a fenced command context
* is still flagged (this lint does not parse fences) — the roots below are
* restricted to directive markdown where a `grep -P` mention is always
* either a real invocation or a specimen to fix, never neutral prose.
*
* ## What FAILS
*
* A `grep`/`egrep`/`fgrep` invocation whose flag cluster includes `P`
* (`-P`, `-oP`, `-Po`, `-iP`, ...) or the long form `--perl-regexp`.
*
* ## Portable replacements
*
* - Extracting a path component: `basename "$(dirname "$path")")` instead of
* `grep -oP 'seam/\K[^/]+'`.
* - Extracting a token after a flag: `sed -E 's#.*--flag[[:space:]]+([^[:space:]]+).*#\1#'`
* instead of `grep -oP '(?<=--flag )\S+'` — POSIX ERE (`-E`) has no
* lookbehind, so capture the group and let `sed` emit just it. `-E` (not
* GNU-only `-r`) is supported by both BSD and GNU `sed`. (A `/`-delimited
* sed command ending in a star-quantifier would place a star immediately
* before a slash, closing this very JSDoc block comment early — hence the
* `#`-delimited form used above.)
*/
const fs = require('fs');
const path = require('path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const ROOT = path.join(__dirname, '..');
// Surfaces whose markdown carries agent-executed bash. Mirrors
// lint-portable-timeout.cjs's roots — the same files that can ship shell
// snippets which a bash/zsh host on macOS actually executes.
const DEFAULT_ROOTS = ['msd-core/workflows', 'msd-core/references', 'agents', 'commands'];
// A `grep`/`egrep`/`fgrep` token, anchored to a command position (line start,
// right after `| & ; ( \` {`, or right after a shell keyword that opens a new
// command — `then`/`do`/`else`/`elif`) so prose mentioning "grep -P" in
// running text is still caught in these directive-markdown roots (see file
// header) without also matching an unrelated word ending in "grep". The
// keyword alternative is `\b`-bounded on both sides, so a bare mention of the
// word "then"/"do"/etc. with no following grep never matches on its own.
const GREP_INVOCATION_RE = /(?:^|[|&;(`{]|\b(?:then|do|else|elif)\b)[ \t]*(?:e|f)?grep\b/g;
// A `-P` short-flag cluster (e.g. `-P`, `-oP`, `-Po`, `-iP`) or the long form
// `--perl-regexp`, as a standalone token.
const PERL_FLAG_RE = /(?:^|\s)-[a-zA-Z]*P[a-zA-Z]*(?=\s|$)|--perl-regexp\b/;
/**
* Locate `grep -P`/`--perl-regexp` invocations in a block of text.
*
* Pure (no I/O): callers pass file contents; the caller reads files. For each
* `grep`-family invocation on a line, inspects only the segment from that
* invocation to the next `|`/`;`/end-of-line, so a `-P`-bearing token in a
* later, unrelated command on the same line is never mistaken for a grep flag.
*
* @param {string} text file contents
* @returns {{ line: number, snippet: string }[]} findings (empty array = clean)
*/
function findPerlGrepInvocations(text) {
const findings = [];
const lines = String(text).split(/\r?\n/);
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
GREP_INVOCATION_RE.lastIndex = 0;
let match;
while ((match = GREP_INVOCATION_RE.exec(line)) !== null) {
const startAt = match.index + match[0].length;
const rest = line.slice(startAt);
const nextPipe = rest.search(/[|;`]/);
const segment = nextPipe === -1 ? rest : rest.slice(0, nextPipe);
if (PERL_FLAG_RE.test(segment)) {
findings.push({ line: i + 1, snippet: line.trim() });
break; // one finding per line is enough context to fix it
}
}
}
return findings;
}
function walkMarkdown(dir) {
const out = [];
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return out; // a missing root is not an error — some surfaces are optional
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...walkMarkdown(full));
else if (entry.isFile() && entry.name.endsWith('.md')) out.push(full);
}
return out;
}
/**
* Scan the given roots (repo-relative) for `grep -P`/`--perl-regexp` invocations.
* @param {string[]} roots
* @returns {{ file: string, line: number, snippet: string }[]}
*/
function scan(roots = DEFAULT_ROOTS) {
const offenders = [];
for (const rel of roots) {
const abs = path.isAbsolute(rel) ? rel : path.join(ROOT, rel);
for (const file of walkMarkdown(abs)) {
const findings = findPerlGrepInvocations(fs.readFileSync(file, 'utf8'));
for (const f of findings) {
offenders.push({ file: path.relative(ROOT, file), line: f.line, snippet: f.snippet });
}
}
}
return offenders;
}
function main() {
const rootsEnv = process.env.MSD_LINT_PORTABLE_GREP_ROOTS;
const roots = rootsEnv ? rootsEnv.split(path.delimiter).filter(Boolean) : DEFAULT_ROOTS;
const offenders = scan(roots);
if (offenders.length > 0) {
const detail = offenders.map((o) => ` ${o.file}:${o.line} ${o.snippet}`).join('\n');
throw new ExitError(
1,
'lint-portable-grep: `grep -P`/`--perl-regexp` is not portable — stock macOS ships\n' +
'BSD grep, which has no `-P`, so the call errors and a `|| true`/`$(...)` fallback\n' +
'silently resolves to empty output instead of failing loudly (#4112). Use\n' +
'`basename "$(dirname "$path")")` for path-component extraction, or\n' +
'`sed -E \'s/.../\\1/\'` (POSIX ERE, no lookbehind needed) for token extraction:\n' +
detail,
);
}
console.log(`ok lint-portable-grep: no GNU-only grep -P invocations in ${roots.length} root(s)`);
}
module.exports = { findPerlGrepInvocations, scan, DEFAULT_ROOTS };
if (require.main === module) runMain(main);