Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Each rule below names the check that fails when it breaks. A rule marked *text o
| The suite gives the same result on any terminal | environment normalization, guard test, and a hostile `TERM` in CI | `test/setup.ts`, `test/terminal-env.test.ts`, `.github/workflows/ci.yml` |
| Tests pass a relative `--since` and read a pinned clock | `no-literal-since`, `no-ambient-clock`, restricted date imports; `TZ` and `MDCLI_TODAY` pinned | `eslint-rules/`, `eslint.config.js`, `test/setup.ts` |
| Strings become numbers only through the strict helpers | `no-loose-number` | `eslint-rules/no-loose-number.js` |
| `scripts/` spawns git, gh and bun only through `scripts/git.ts`, which also owns the `--base` default; diffs are parsed only by `scripts/behavior-change.ts` | `no-raw-spawn` | `eslint-rules/no-raw-spawn.js`, `scripts/git.ts` |
| A category name resolves through its single owner (active wins, ambiguous and inactive refused) | `no-category-name-resolution` | `eslint-rules/no-category-name-resolution.js` |
| Review notes never reach a commit (item 19) | `git ls-files 'REVIEW-*.md'` must be empty | `scripts/verify.sh`, `.gitignore` |
| The private-data guard covers commit messages and the PR body, not only the tracked tree | `private messages` step (CI `verify`, rerun on a PR body edit) | `scripts/check-private-messages.ts`, `scripts/verify.sh`, `.github/workflows/ci.yml` |
Expand All @@ -58,7 +59,7 @@ Each rule below names the check that fails when it breaks. A rule marked *text o

Every PR that changes `src/` fills the **Prova** section of the PR template. It shows the CLI commands run against the mock (`scripts/mock-api.ts`, fake config in `/tmp`), never against a real account, on `main` (before) and on the branch (after), with their outputs, proving the behavior the issue asks for. When it changes `src/` or `bin/`, the proof also shows the output of the 3 runs of `bun scripts/new-tests-fail-on-main.ts --base <pre-fix> --files <test>`; one red run under load is not proof (issue #235). A behavior-preserving PR that skips the gate cites the exemption (the `refactor`/`test`/`chore` title or the body reason) instead of the failing runs. The `prova` CI check reads the PR body and fails when the section, the `Web:` line or one of the two blocks is missing; it runs on `opened`, `synchronize`, `reopened` and `edited`. The `Web:` line carries a snippet from the web bundle (`$HOME/.mdcli-team/ref/mdb.js`) that shows the behavior, or `sem equivalente na web`. Paraphrasing the web inside the proof without that line no longer counts.

`bun scripts/risk.ts` classifies the diff against `origin/main` and prints `{ "risky": bool, "reasons": [...] }`; it exits 2 when the PR touches authentication, session or credentials, data writes (create, update, delete, `entries import run`, or any POST/PUT/DELETE call to the API), or a security surface: `scripts/private-terms.ts`, `scripts/private-terms-hashes.ts`, `scripts/private-terms-history.ts`, `scripts/private-terms-cli.ts`, `scripts/private-terms-corpus.ts`, `test/private-terms.test.ts`, `test/private-terms-corpus.test.ts`, `scripts/check-private-messages.ts`, `test/check-private-messages.test.ts`, `scripts/verify.sh`, `src/utils/parse.ts` (the guard parses git output with it), or anything under `eslint-rules/`. A comment-only change in a security surface stays safe. A risky PR:
`bun scripts/risk.ts` classifies the diff against `origin/main` and prints `{ "risky": bool, "reasons": [...] }`; it exits 2 when the PR touches authentication, session or credentials, data writes (create, update, delete, `entries import run`, or any POST/PUT/DELETE call to the API), or a security surface: `scripts/private-terms.ts`, `scripts/private-terms-hashes.ts`, `scripts/private-terms-history.ts`, `scripts/private-terms-cli.ts`, `scripts/private-terms-corpus.ts`, `test/private-terms.test.ts`, `test/private-terms-corpus.test.ts`, `scripts/check-private-messages.ts`, `test/check-private-messages.test.ts`, `scripts/verify.sh`, `src/utils/parse.ts` (the guard parses git output with it), `scripts/git.ts` and `scripts/behavior-change.ts` (the guard spawns git and parses its diffs through them), or anything under `eslint-rules/`. A comment-only change in a security surface stays safe. A risky PR:

- runs `/blast-radius` and proves the security fact in a test or in `scripts/verify.sh`;
- waits for the maintainer's decision before merge.
Expand Down
2 changes: 2 additions & 0 deletions eslint-rules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import noDueAnchorFallback from "./no-due-anchor-fallback.js";
import noLiteralSince from "./no-literal-since.js";
import noLooseNumber from "./no-loose-number.js";
import noRawDash from "./no-raw-dash.js";
import noRawSpawn from "./no-raw-spawn.js";
import noRawCents from "./no-raw-cents.js";
import noRawCurrency from "./no-raw-currency.js";
import noShortOptionEquals from "./no-short-option-equals.js";
Expand All @@ -30,6 +31,7 @@ export default {
"no-raw-cents": noRawCents,
"no-raw-currency": noRawCurrency,
"no-raw-dash": noRawDash,
"no-raw-spawn": noRawSpawn,
"no-short-option-equals": noShortOptionEquals,
"no-tautological-expect": noTautologicalExpect,
},
Expand Down
54 changes: 54 additions & 0 deletions eslint-rules/no-raw-spawn.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Every gate in scripts/ grew its own Bun.spawnSync wrapper around git, gh or
// bun, each with its own error text and `origin/main` default, and the fix for
// the oven-sh/bun#34069 hang reached only one of them (#260). scripts/git.ts
// owns child processes; the rest of scripts/ goes through it.
const SPAWNERS = new Set(["spawn", "spawnSync"]);
const CHILD_PROCESS = new Set(["child_process", "node:child_process"]);

export default {
meta: {
type: "problem",
docs: { description: "Spawn child processes in scripts/ only through scripts/git.ts" },
messages: {
rawSpawn:
"Spawn through scripts/git.ts (git, gitBytes, gitResult, gitQuiet, spawn, spawnAsync) instead of Bun.{{name}}; it owns the error text, the path quoting and the oven-sh/bun#34069 workaround.",
bunShell: "Spawn through scripts/git.ts instead of the Bun shell ($); it owns child processes in scripts/.",
childProcess: "Spawn through scripts/git.ts instead of {{source}}; it owns child processes in scripts/.",
},
schema: [],
},
create(context) {
const checkSource = (node) => {
if (node.source?.type === "Literal" && CHILD_PROCESS.has(node.source.value)) {
context.report({ node, messageId: "childProcess", data: { source: node.source.value } });
}
};
return {
MemberExpression(node) {
const name = node.property.type === "Identifier" && !node.computed ? node.property.name : node.property.value;
if (node.object.type === "Identifier" && node.object.name === "Bun" && SPAWNERS.has(name)) {
context.report({ node, messageId: "rawSpawn", data: { name } });
}
},
ImportDeclaration(node) {
checkSource(node);
if (node.source.value === "bun" && node.specifiers.some((s) => s.type === "ImportSpecifier" && s.imported.name === "$")) {
context.report({ node, messageId: "bunShell" });
}
},
ExportAllDeclaration: checkSource,
ExportNamedDeclaration: checkSource,
ImportExpression(node) {
if (node.source.type === "Literal" && CHILD_PROCESS.has(node.source.value)) {
context.report({ node, messageId: "childProcess", data: { source: node.source.value } });
}
},
CallExpression(node) {
const [arg] = node.arguments;
if (node.callee.type === "Identifier" && node.callee.name === "require" && arg?.type === "Literal" && CHILD_PROCESS.has(arg.value)) {
context.report({ node, messageId: "childProcess", data: { source: arg.value } });
}
},
};
},
};
4 changes: 4 additions & 0 deletions eslint-rules/scope-exemptions.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
"no-literal-since": {
"src/**": "The rule guards fixed dates written in tests; commands read the --since value the user passed."
},
"no-raw-spawn": {
"src/**": "The rule guards the gate infrastructure in scripts/; the CLI's own children (self-update, browser login, the import editor) belong to src/.",
"test/**": "Tests drive the scripts as a user does (bun scripts/x.ts) and build throwaway git repositories; scripts/git.ts owns only the gates' children."
},
"no-short-option-equals": {
"src/**": "preprocessShortOptions runs only in bin/cli.ts, before commander parses argv.",
"test/**": "Tests exercise the preprocessing through the real CLI (test/cli-short-option-equals.test.ts), not by importing it."
Expand Down
7 changes: 7 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ export default tseslint.config(
files: ["src/**/*.ts", "test/**/*.ts", "bin/**/*.ts"],
rules: { "local/no-bare-process-exit": "error" },
},
{
// scripts/ spawns git, gh and bun only through scripts/git.ts (#260).
// src/** and test/** are exempt in eslint-rules/scope-exemptions.json.
files: ["scripts/**/*.ts"],
ignores: ["scripts/git.ts"],
rules: { "local/no-raw-spawn": "error" },
},
{
ignores: ["node_modules", "dist"],
}
Expand Down
49 changes: 37 additions & 12 deletions scripts/behavior-change.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
// Single owner for "does this diff change behavior?" (#236). prova, the
// fails-on-main gate and risk each ask that question with their own path
// slice; the diff parsing, the comment lexer and the type-only detection live
// here so a false positive is fixed once instead of in three copies.
// Single owner for "does this diff change behavior?" (#236) and for parsing a
// unified diff (#260). prova, the fails-on-main gate and risk each ask that
// question with their own path slice; check-eslint-disables and the private
// terms corpus read the numbered lines. The diff parsing, the comment lexer and
// the type-only detection live here so a false positive is fixed once.

import { parseInteger } from '../src/utils/parse.js';

type LineKind = 'add' | 'remove' | 'context';

export interface DiffLine {
kind: LineKind;
text: string;
/** The line number on the side that holds it: old for a removal, new otherwise. */
line: number;
}

export interface DiffFile {
Expand All @@ -31,29 +36,49 @@ export interface BehaviorScope extends BehaviorOptions {
const TYPE_DECLARATION = /^\s*(?:export\s+)?(?:declare\s+)?(?:interface\s+\w|type\s+\w+\s*[=<]|import\s+type\b)/;
const TYPE_OPEN = /^\s*(?:export\s+)?(?:declare\s+)?(?:interface|type)\s+\w+/;

/** The unified diff split into files and hunks, as the question needs it. */
function hunkStart(digits: string, header: string): number {
const line = parseInteger(digits);
if (line === undefined) throw new Error(`unreadable diff hunk header: ${header}`);
return line;
}

/**
* The unified diff split into files and hunks, as the question needs it. A
* header git quotes (a path with a tab or a quote) still opens a file, so its
* lines are never dropped: the raw header stands in for the path.
*/
export function parseDiff(text: string): DiffFile[] {
const files: DiffFile[] = [];
let file: DiffFile | undefined;
let hunk: DiffLine[] | undefined;
let oldLine = 0;
let newLine = 0;
for (const raw of text.split('\n')) {
const header = /^diff --git a\/(.+) b\/(.+)$/.exec(raw);
if (header) {
file = { path: header[2] === '/dev/null' ? header[1] : header[2], hunks: [] };
if (raw.startsWith('diff --git ')) {
const header = /^diff --git a\/(.+) b\/(.+)$/.exec(raw);
file = { path: header ? header[2] : raw.slice('diff --git '.length), hunks: [] };
files.push(file);
hunk = undefined;
continue;
}
if (!file) continue;
if (raw.startsWith('@@')) {
const start = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
if (start) {
hunk = [];
file.hunks.push(hunk);
oldLine = hunkStart(start[1], raw);
newLine = hunkStart(start[2], raw);
continue;
}
if (!hunk) continue;
if (raw.startsWith('+')) hunk.push({ kind: 'add', text: raw.slice(1) });
else if (raw.startsWith('-')) hunk.push({ kind: 'remove', text: raw.slice(1) });
else if (raw.startsWith(' ')) hunk.push({ kind: 'context', text: raw.slice(1) });
if (raw.startsWith('+')) {
hunk.push({ kind: 'add', text: raw.slice(1), line: newLine++ });
} else if (raw.startsWith('-')) {
hunk.push({ kind: 'remove', text: raw.slice(1), line: oldLine++ });
} else if (raw.startsWith(' ')) {
hunk.push({ kind: 'context', text: raw.slice(1), line: newLine++ });
oldLine++;
}
}
return files;
}
Expand Down
59 changes: 13 additions & 46 deletions scripts/check-eslint-disables.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs';
import tseslint from 'typescript-eslint';
import { parseInteger } from '../src/utils/parse.js';
import { parseDiff } from './behavior-change.js';
import { DEFAULT_BASE, git, gitResult } from './git.js';

// A copied eslint-disable is how the bare-exit pattern spread (#155, #187).
// Compare the branch with main and fail on any added directive without a
Expand All @@ -12,12 +13,6 @@ const DIRECTIVE = /^\s*eslint-disable(?:-next-line|-line)?\b/;
const REASON = /\s--\s+\S/;
const LINTED = /\.(?:[cm]?[jt]sx?)$/;

function hunkStart(digits: string, header: string): number {
const line = parseInteger(digits);
if (line === undefined) throw new Error(`unreadable diff hunk header: ${header}`);
return line;
}

interface AddedDisable {
path: string;
line: number;
Expand All @@ -41,38 +36,14 @@ interface DiffLine {
type Side = 'old' | 'new';
export type ContentReader = (path: string, side: Side) => string | undefined;

function parseDiff(diff: string): { added: DiffLine[]; removed: DiffLine[] } {
function changedLines(diff: string): { added: DiffLine[]; removed: DiffLine[] } {
const added: DiffLine[] = [];
const removed: DiffLine[] = [];
let path = '';
let oldLine = 0;
let newLine = 0;
for (const raw of diff.split('\n')) {
if (raw.startsWith('diff --git ')) {
// `diff --git a/<path> b/<path>`: the b/ side names the file after the change.
path = raw.split(' b/', 2)[1] ?? '';
continue;
}
if (raw.startsWith('+++ b/')) {
path = raw.slice('+++ b/'.length);
continue;
}
const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
if (hunk) {
oldLine = hunkStart(hunk[1], raw);
newLine = hunkStart(hunk[2], raw);
continue;
}
if (path === '' || !LINTED.test(path)) continue;
if (raw.startsWith('+')) {
added.push({ path, line: newLine, text: raw.slice(1) });
newLine += 1;
} else if (raw.startsWith('-')) {
removed.push({ path, line: oldLine, text: raw.slice(1) });
oldLine += 1;
} else if (raw.startsWith(' ')) {
oldLine += 1;
newLine += 1;
for (const file of parseDiff(diff)) {
if (!LINTED.test(file.path)) continue;
for (const { kind, line, text } of file.hunks.flat()) {
if (kind === 'add') added.push({ path: file.path, line, text });
else if (kind === 'remove') removed.push({ path: file.path, line, text });
}
}
return { added, removed };
Expand Down Expand Up @@ -107,7 +78,7 @@ export function directiveLines(content: string): Set<number> {

/** Counts the directives a diff adds and removes, reading the file at each side. */
export function countDisables(diff: string, read: ContentReader): DiffCount {
const { added, removed } = parseDiff(diff);
const { added, removed } = changedLines(diff);
const cache = new Map<string, Map<number, Directive>>();
const directiveAt = (path: string, line: number, side: Side): Directive | undefined => {
const key = `${side}:${path}`;
Expand All @@ -131,7 +102,7 @@ export function countDisables(diff: string, read: ContentReader): DiffCount {
}

function gitShow(ref: string, path: string): string | undefined {
const result = Bun.spawnSync(['git', 'show', `${ref}:${path}`], { stdout: 'pipe', stderr: 'pipe' });
const result = gitResult(['show', `${ref}:${path}`]);
return result.exitCode === 0 ? result.stdout.toString() : undefined;
}

Expand Down Expand Up @@ -171,19 +142,15 @@ function parseOptions(argv: string[]): Options {
}

function gitDiff(base: string, head: string | undefined): string {
const args = ['git', 'diff', '--unified=0', '--no-renames', '--no-color', base];
const args = ['diff', '--unified=0', '--no-renames', '--no-color', base];
if (head !== undefined) args.push(head);
const result = Bun.spawnSync(args, { stdout: 'pipe', stderr: 'pipe' });
if (result.exitCode !== 0) {
throw new Error(`git diff against ${base}${head ? `..${head}` : ''} failed: ${result.stderr.toString().trim()}`);
}
return result.stdout.toString();
return git(args);
}

function main(): void {
try {
const options = parseOptions(process.argv.slice(2));
const base = options.base ?? 'origin/main';
const base = options.base ?? DEFAULT_BASE;
const range = `${base}${options.head ? `..${options.head}` : ''}`;
const { added, removed, undocumented } = countDisables(gitDiff(base, options.head), reader(base, options.head));
if (undocumented.length > 0) {
Expand Down
35 changes: 9 additions & 26 deletions scripts/check-private-messages.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { DEFAULT_BASE, spawnAsync } from './git.js';
import { KEYED_TERM_HASHES, requirePrivateTermsKey, scanText } from './private-terms.js';

export interface MessageSource {
Expand Down Expand Up @@ -27,31 +28,13 @@ export function messageHits(
return hits;
}

interface RunResult {
stdout: string;
stderr: string;
code: number | null;
}

// Bun 1.4.2 can hang a spawnSync call under `bun test --parallel` (oven-sh/bun#34069),
// so every child here goes through Bun.spawn, which the same reports clear.
async function run(command: string[], cwd: string, timeout?: number): Promise<RunResult> {
const options = { cwd, stdout: 'pipe' as const, stderr: 'pipe' as const, ...(timeout === undefined ? {} : { timeout }) };
const proc = Bun.spawn(command, options);
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { stdout, stderr, code };
}

export async function commitSources(base: string, root: string = join(import.meta.dirname, '..')): Promise<MessageSource[]> {
const { stdout, stderr, code } = await run(
// Async: this runs inside `bun test --parallel`, where spawnSync can hang (see spawnAsync).
const { stdout, stderr, exitCode } = await spawnAsync(
['git', '--no-pager', 'log', `${base}..HEAD`, '--format=%H%x00%B%x00'],
root
{ cwd: root }
);
if (code !== 0) throw new Error(`git log ${base}..HEAD failed: ${stderr.trim()}`);
if (exitCode !== 0) throw new Error(`git log ${base}..HEAD failed: ${stderr.trim()}`);
const parts = stdout.split('\0');
const sources: MessageSource[] = [];
// git joins records with a newline, so the next record's SHA carries it.
Expand All @@ -72,9 +55,9 @@ export async function pullRequestSource(): Promise<MessageSource | undefined> {
if (body !== undefined) return body.trim() === '' ? undefined : { label: 'PR body', text: body };
const command = ['gh', 'pr', 'view', '--json', 'body'];
try {
const { stdout, stderr, code } = await run(command, process.cwd(), GH_TIMEOUT_MS);
if (code !== 0) {
console.error(`private-data: gh pr view failed (${stderr.trim() || `exit ${code ?? 'unknown'}`}); PR body not checked`);
const { stdout, stderr, exitCode } = await spawnAsync(command, { timeout: GH_TIMEOUT_MS });
if (exitCode !== 0) {
console.error(`private-data: gh pr view failed (${stderr.trim() || `exit ${exitCode}`}); PR body not checked`);
return undefined;
}
const parsed = JSON.parse(stdout) as { body?: unknown };
Expand All @@ -92,7 +75,7 @@ interface Options {
}

function parseOptions(argv: string[]): Options {
const options: Options = { base: 'origin/main' };
const options: Options = { base: DEFAULT_BASE };
for (let index = 0; index < argv.length; index++) {
const arg = argv[index];
if (arg === '--base') options.base = argv[++index] ?? options.base;
Expand Down
Loading
Loading