diff --git a/docs/trajectories/typescript-bun-migration/RESUME.md b/docs/trajectories/typescript-bun-migration/RESUME.md index e906e1950..00eecf90c 100644 --- a/docs/trajectories/typescript-bun-migration/RESUME.md +++ b/docs/trajectories/typescript-bun-migration/RESUME.md @@ -1,7 +1,7 @@ # Trajectory — TypeScript / Bun migration **Status**: Active (Lane B slice 6 merged — [#876](https://github.com/Lucent-Financial-Group/Zeta/pull/876), commit `02baabc`) -**Milestone**: 19 hygiene scripts ported (2 from #849 + 3 from #866 + 3 from #868 + 3 from #870 + 2 from #872 + 3 from #874 + 3 from #876). **Cluster F-3-of-4 complete: 3 read-only check scripts ported; append-tick-history-row.sh deferred to slice 7+ as write-side script.** +**Milestone**: 22 hygiene/lint scripts ported (2 from #849 + 3 from #866 + 3 from #868 + 3 from #870 + 2 from #872 + 3 from #874 + 3 from #876 + 3 from PR #878). **Cluster H-3-of-5 complete in PR #878 (no-empty-dirs, safety-clause-audit, doc-comment-history-audit); 2 remain in slice-8 (no-directives-otto-prose at 261 lines + runner-version-freshness at 356 lines).** This row updates atomically with the PR #878 merge — the count + PR-list are merge-stable; subsequent slices append a row, not edit this one. **Current blocker**: None. **Next concrete action**: Pick a coherent next slice from Bucket B (22 files remaining). Per Gate B: read-only scope first, then re-verify the layered baseline currency before first mutating action. **Last updated**: 2026-04-30 @@ -127,6 +127,9 @@ tools/hygiene/audit-missing-prevention-layers.sh # ported in #874 tools/hygiene/check-no-conflict-markers.sh # ported in #876 tools/hygiene/check-archive-header-section33.sh # ported in #876 tools/hygiene/check-tick-history-order.sh # ported in #876 +tools/lint/no-empty-dirs.sh # ported on slice-7 branch (in flight) +tools/lint/safety-clause-audit.sh # ported on slice-7 branch (in flight) +tools/lint/doc-comment-history-audit.sh # ported on slice-7 branch (in flight) ``` ## Recommended next slice diff --git a/docs/trajectories/typescript-bun-migration/slice-audits.md b/docs/trajectories/typescript-bun-migration/slice-audits.md index ece744caa..d5e80f7e0 100644 --- a/docs/trajectories/typescript-bun-migration/slice-audits.md +++ b/docs/trajectories/typescript-bun-migration/slice-audits.md @@ -411,6 +411,44 @@ Per-port pattern checklist: Slice 6 passes audit. No new patterns recorded — all reused from prior slices. +## Slice 7 — 3 lint-pattern ports (PR pending — `lane-b/ts-bun-slice-7-lint-scripts-2026-04-30`) + +**Slice files**: + +- `tools/lint/no-empty-dirs.{sh→ts}` +- `tools/lint/safety-clause-audit.{sh→ts}` +- `tools/lint/doc-comment-history-audit.{sh→ts}` + +**Comparison points**: identical to slice 6 (TypeScript 6.0 release notes, Bun docs, typescript-eslint v8, `../SQLSharp` at `7d3d9f6`, `../scratch` at mtime `2026-04-15`). No re-verification needed — within the 30-day Gate B window. + +### Tsconfig audit + +- Reuses repo `tsconfig.json` (no per-slice deviation). +- All 3 files compile under `tsc --noEmit` clean. + +### Eslint audit + +- All 3 files clean under `eslint` (typescript-eslint strictTypeChecked + stylisticTypeChecked + sonarjs). +- Pattern: `eslint-disable-next-line sonarjs/no-os-command-from-path` reused on `git` invocations only (intentional — same as prior slices). + +### Code-pattern audit (per-port) + +- **`no-empty-dirs.ts`**: readdirSync recursive walk + git-check-ignore batch + Set-based allowlist match. The bash trailing-whitespace strip regex (matching tab/space at end-of-line) replaced with manual char walk (`trimTrailingSpaceTab`) — no slow-regex flag. Comment-or-blank line check (bash `^[[:space:]]*(#|$)`) replaced with manual char walk (`isCommentOrBlankLine`) — same. +- **`safety-clause-audit.ts`**: H1/H2/H3 regex sets split into pattern arrays where each individual regex stays under sonarjs/regex-complexity threshold (20). H1 array uses `/i` flag + non-capturing optional `(?:[\t ]+do)?` to collapse NOT/not + does/do alternatives without inflating per-regex complexity. Argument parsing extracted into `parseFailOverArg()` so the main loop stays under cognitive-complexity (15). +- **`doc-comment-history-audit.ts`**: in-bash awk script (60+ lines) replaced with TS `extractTokens()` doing per-line `RegExp.exec` loop + manual leading/trailing word-boundary check (`isBoundary`). Tokens-ending-in-`:` skip the trailing boundary check, mirroring the bash logic exactly. Tree walk extracted into `walkRoot` + `processEntry` + `readDirEntries` for cognitive-complexity compliance. + +### Equivalence audit + +Diff'd against bash output on this repo state (2026-04-30 main): + +- **`no-empty-dirs`**: bash original errors with "unbound variable" on macOS bash 3.2 + empty FILTERED array (pre-existing bug); TS produces correct output `OK (0 allowlisted, 0 flagged)`. Behavioural improvement, not divergence. +- **`safety-clause-audit`**: byte-equivalent across all 3 modes (`summary` / `--list-missing` / `--verbose`). +- **`doc-comment-history-audit`**: byte-equivalent across all 4 modes (`check` / `--list` / `--fail-any` / `--regenerate-baseline`). Sole intentional diff: error-message self-reference is `.ts` instead of `.sh`, since each invocation tells users to use the same form they ran. + +### Outcome + +Slice 7 passes audit. No new patterns recorded — all reused from prior slices. The two remaining Cluster H scripts (`no-directives-otto-prose.sh` at 261 lines + `runner-version-freshness.sh` at 356 lines) are deferred to slice 8 — natural size boundary, plus `no-directives-otto-prose` has Task #350 (extend scope) so port-then-extend likely needs paired review. + ## Slice template (for future slices) Copy this section header and fill out before merging the slice's PR: diff --git a/tools/lint/doc-comment-history-audit.ts b/tools/lint/doc-comment-history-audit.ts new file mode 100644 index 000000000..683697dc9 --- /dev/null +++ b/tools/lint/doc-comment-history-audit.ts @@ -0,0 +1,366 @@ +#!/usr/bin/env bun +// doc-comment-history-audit.ts — scan source doc comments for +// factory-process tokens that belong in PR descriptions, history +// files, or round-notes rather than in code. +// +// TypeScript+Bun port of doc-comment-history-audit.sh, slice 7 of +// the TS+Bun migration. See docs/best-practices/repo-scripting.md. +// +// The rule: a code-file comment (`///`, `//`, `#`) should explain +// what the code DOES — math, invariants, input contracts, +// composition guidance. It should not carry process-lineage tags +// (round shipped, external collaborator who formalised it, +// correction number, persona attribution). That content belongs in +// the PR description, the commit message, `docs/hygiene-history/**`, +// or memory files. +// +// Scope: +// src/**/*.fs, src/**/*.cs +// tests/**/*.fs, tests/**/*.cs +// bench/**/*.fs +// tools/**/*.sh, tools/**/*.ts, tools/**/*.fs +// +// NOT scanned (these legitimately carry history): +// docs/hygiene-history/**, docs/DECISIONS/**, docs/ROUND-HISTORY.md +// openspec/** (spec files — history is part of the spec) +// memory/** (memory is by design historical) +// .git/, bin/, obj/, vendored mirrors +// +// Usage: +// bun tools/lint/doc-comment-history-audit.ts +// # check mode (vs baseline) +// bun tools/lint/doc-comment-history-audit.ts --list +// # all violations, exit 0 +// bun tools/lint/doc-comment-history-audit.ts --fail-any +// # exit 1 on ANY violation +// bun tools/lint/doc-comment-history-audit.ts --regenerate-baseline +// +// Exit codes: +// 0 no new violations vs baseline (or --list / --regenerate) +// 1 violations +// 2 baseline missing or unknown mode + +import { readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { spawnSync } from "node:child_process"; + +type ExitCode = 0 | 1 | 2; +type Mode = "check" | "list" | "fail-any" | "regenerate-baseline"; + +const SPAWN_MAX_BUFFER = 64 * 1024 * 1024; + +const BASELINE_REL = "tools/lint/doc-comment-history-audit.baseline"; + +const SCAN_ROOTS: readonly string[] = ["src", "tests", "bench", "tools"]; + +const SCAN_EXTS: readonly string[] = [".fs", ".cs", ".sh", ".ts"]; + +const SCAN_EXCLUDE_SEGMENTS: readonly string[] = [ + "bin", + "obj", + ".venv", + "node_modules", + // tools/lean4/.lake is the Lake build-output tree under tools/ + // — pulled in via SCAN_ROOTS, but contains a heavy mathlib + // checkout that's not in scope. Excluded for both correctness + // (mathlib references would dominate findings) and performance + // (the directory walk gets very slow if .lake exists locally). + ".lake", +]; + +const TOKEN_RE = + /(Otto-\d+|Amara|Aaron|ferry|courier|graduation|Provenance:|Attribution:)/g; + +// Comment-line patterns. `///` and `//` are F#/C# comment markers +// (and the F#-XML-doc shape is `///`). `#!` is a shell shebang (NOT +// a comment to scan). `#` is the shell-comment marker. Note: in +// F#/C# `#` typically denotes preprocessor / module directives +// rather than comments — but those still get scanned here, with +// the rationale that any `#`-prefixed line in a code file is a +// candidate for the same factory-process-token check (a bash +// shebang precondition + an F# `#nowarn` directive both legitimately +// describe what the code DOES, not its history). +const COMMENT_TRIPLE_SLASH_RE = /^[\t ]*\/\/\//; +const COMMENT_DOUBLE_SLASH_RE = /^[\t ]*\/\//; +const COMMENT_SHEBANG_RE = /^[\t ]*#!/; +const COMMENT_HASH_RE = /^[\t ]*#/; + +const HELP_MEMORY_REF = + " memory/feedback_code_comments_explain_code_not_history_otto_220_2026_04_24.md"; + +function repoRoot(): string { + // eslint-disable-next-line sonarjs/no-os-command-from-path + const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + maxBuffer: SPAWN_MAX_BUFFER, + }); + if (result.status !== 0) return process.cwd(); + return result.stdout.trim(); +} + +function parseMode(argv: readonly string[]): Mode | null { + const arg = argv[0]; + if (arg === undefined || arg === "check" || arg === "") return "check"; + if (arg === "--list") return "list"; + if (arg === "--fail-any") return "fail-any"; + if (arg === "--regenerate-baseline") return "regenerate-baseline"; + return null; +} + +function isCommentLine(line: string): boolean { + if (COMMENT_TRIPLE_SLASH_RE.test(line)) return true; + if (COMMENT_DOUBLE_SLASH_RE.test(line)) return true; + if (COMMENT_SHEBANG_RE.test(line)) return false; + if (COMMENT_HASH_RE.test(line)) return true; + return false; +} + +function isWordChar(ch: string | undefined): boolean { + if (ch === undefined) return false; + return /\w/.test(ch); +} + +function isBoundary(line: string, pos: number): boolean { + if (pos < 0 || pos >= line.length) return true; + return !isWordChar(line[pos]); +} + +function extractTokens(line: string): readonly string[] { + const seen = new Set(); + const order: string[] = []; + TOKEN_RE.lastIndex = 0; + let match: RegExpExecArray | null = TOKEN_RE.exec(line); + while (match !== null) { + const tok = match[0]; + const start = match.index; + const end = start + tok.length; + const trailingNeedsBoundary = !tok.endsWith(":"); + const leadingOk = isBoundary(line, start - 1); + const trailingOk = !trailingNeedsBoundary || isBoundary(line, end); + if (leadingOk && trailingOk && !seen.has(tok)) { + seen.add(tok); + order.push(tok); + } + match = TOKEN_RE.exec(line); + } + return order.sort((a, b) => a.localeCompare(b)); +} + +function sortLines(lines: readonly string[]): readonly string[] { + return [...lines].sort((a, b) => a.localeCompare(b)); +} + +function shouldExcludeDir(rel: string): boolean { + for (const seg of SCAN_EXCLUDE_SEGMENTS) { + if (rel === seg || rel.endsWith(`/${seg}`) || rel.includes(`/${seg}/`)) { + return true; + } + } + return false; +} + +function hasMatchingExt(name: string): boolean { + for (const ext of SCAN_EXTS) { + if (name.endsWith(ext)) return true; + } + return false; +} + +// Normalize Node-style relative() paths to forward slashes — on +// Windows `relative()` returns `\\`-separated paths, but the bash +// original emits `/` paths and the baseline file uses `/` paths. +// Posix-relative-path everywhere, including baseline-diff comparisons. +function toPosixRel(p: string): string { + return p.replace(/\\/g, "/"); +} + +function readDirEntries( + dir: string, +): readonly import("node:fs").Dirent[] { + try { + return readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } +} + +function processEntry( + e: import("node:fs").Dirent, + dir: string, + root: string, + stack: string[], + out: string[], +): void { + const full = join(dir, e.name); + const rel = toPosixRel(relative(root, full)); + if (e.isDirectory()) { + if (!shouldExcludeDir(rel)) stack.push(full); + } else if (e.isFile() && hasMatchingExt(e.name)) { + out.push(rel); + } +} + +function walkRoot(root: string, top: string, out: string[]): void { + const stack: string[] = [join(root, top)]; + while (stack.length > 0) { + const dir = stack.pop(); + if (dir === undefined) continue; + for (const e of readDirEntries(dir)) { + processEntry(e, dir, root, stack, out); + } + } +} + +function listScanFiles(root: string): readonly string[] { + const out: string[] = []; + for (const top of SCAN_ROOTS) walkRoot(root, top, out); + return out; +} + +function collectViolationsForFile( + rel: string, + absPath: string, +): readonly string[] { + let content: string; + try { + content = readFileSync(absPath, "utf8"); + } catch { + return []; + } + const lines = content.split("\n"); + const out: string[] = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ""; + if (!isCommentLine(line)) continue; + const tokens = extractTokens(line); + if (tokens.length === 0) continue; + out.push(`${rel}:${String(i + 1)}:${tokens.join(",")}`); + } + return out; +} + +function collectAllViolations(root: string): readonly string[] { + const files = listScanFiles(root); + const out: string[] = []; + for (const rel of files) { + out.push(...collectViolationsForFile(rel, join(root, rel))); + } + return sortLines(out); +} + +function loadBaseline(path: string): readonly string[] | null { + try { + const content = readFileSync(path, "utf8"); + return sortLines(content.split("\n").filter((s) => s.length > 0)); + } catch { + return null; + } +} + +function diffNew( + current: readonly string[], + baseline: readonly string[], +): readonly string[] { + const baseSet = new Set(baseline); + return current.filter((row) => !baseSet.has(row)); +} + +function emitListMode(violations: readonly string[]): ExitCode { + for (const row of violations) process.stdout.write(`${row}\n`); + return 0; +} + +function emitFailAny(violations: readonly string[]): ExitCode { + if (violations.length === 0) { + process.stdout.write( + "doc-comment-history-audit: no violations (strict mode clean)\n", + ); + return 0; + } + process.stderr.write( + "doc-comment-history-audit: violations found (strict mode):\n", + ); + for (const row of violations) process.stderr.write(`${row}\n`); + process.stderr.write( + `doc-comment-history-audit: ${String(violations.length)} violation(s); see\n`, + ); + process.stderr.write(`${HELP_MEMORY_REF}\n`); + return 1; +} + +function emitRegenerate( + violations: readonly string[], + baselinePath: string, +): ExitCode { + const content = + violations.length === 0 ? "" : `${violations.join("\n")}\n`; + writeFileSync(baselinePath, content); + process.stderr.write( + `doc-comment-history-audit: baseline regenerated with ${String(violations.length)} entries\n`, + ); + process.stderr.write(` -> ${baselinePath}\n`); + return 0; +} + +function emitCheck( + violations: readonly string[], + baselinePath: string, + scriptName: string, +): ExitCode { + const baseline = loadBaseline(baselinePath); + if (baseline === null) { + process.stderr.write( + `doc-comment-history-audit: baseline missing at ${baselinePath}\n`, + ); + process.stderr.write(` regenerate with: ${scriptName} --regenerate-baseline\n`); + return 2; + } + const newViolations = diffNew(violations, baseline); + if (newViolations.length === 0) { + process.stdout.write( + `doc-comment-history-audit: no new violations (${String(baseline.length)} entries in baseline)\n`, + ); + return 0; + } + process.stderr.write("doc-comment-history-audit: new violations not in baseline:\n"); + for (const row of newViolations) process.stderr.write(`${row}\n`); + process.stderr.write( + `doc-comment-history-audit: ${String(newViolations.length)} new violation(s); see\n`, + ); + process.stderr.write(`${HELP_MEMORY_REF}\n`); + process.stderr.write( + ` to legitimize a moved line, run: ${scriptName} --regenerate-baseline\n`, + ); + return 1; +} + +export function main(argv: readonly string[]): ExitCode { + const root = repoRoot(); + process.chdir(root); + const baselinePath = BASELINE_REL; + const scriptName = "tools/lint/doc-comment-history-audit.ts"; + + const mode = parseMode(argv); + if (mode === null) { + const arg0 = argv[0] ?? ""; + process.stderr.write( + `doc-comment-history-audit: unknown mode '${arg0}'\n`, + ); + process.stderr.write( + `usage: ${scriptName} [--list|--fail-any|--regenerate-baseline]\n`, + ); + return 2; + } + + if (mode === "regenerate-baseline") { + return emitRegenerate(collectAllViolations(root), baselinePath); + } + const violations = collectAllViolations(root); + if (mode === "list") return emitListMode(violations); + if (mode === "fail-any") return emitFailAny(violations); + return emitCheck(violations, baselinePath, scriptName); +} + +if (import.meta.main) { + process.exit(main(process.argv.slice(2))); +} diff --git a/tools/lint/no-empty-dirs.ts b/tools/lint/no-empty-dirs.ts new file mode 100644 index 000000000..76988eee4 --- /dev/null +++ b/tools/lint/no-empty-dirs.ts @@ -0,0 +1,268 @@ +#!/usr/bin/env bun +// no-empty-dirs.ts — fail the build if an empty directory exists in +// the repo that is (a) not git-ignored and (b) not on the allowlist. +// +// TypeScript+Bun port of no-empty-dirs.sh, slice 7 of the TS+Bun +// migration. See docs/best-practices/repo-scripting.md. +// +// Usage: +// bun tools/lint/no-empty-dirs.ts # check mode +// bun tools/lint/no-empty-dirs.ts --list # list mode +// +// Exit codes: +// 0 no flagged empty dirs (or --list mode) +// 1 one or more flagged empty dirs +// 2 allowlist missing + +import { readFileSync, readdirSync } from "node:fs"; +import { join, relative } from "node:path"; +import { spawnSync } from "node:child_process"; + +type ExitCode = 0 | 1 | 2; +type Mode = "check" | "list"; + +const SPAWN_MAX_BUFFER = 64 * 1024 * 1024; +const ALLOWLIST_REL = "tools/lint/no-empty-dirs.allowlist"; +const SPACE = 0x20; +const TAB = 0x09; +const CR = 0x0d; +const HASH = 0x23; + +const HARD_EXCLUDE_PREFIXES: readonly string[] = [ + ".git", + "references/upstreams", + "tools/lean4/.lake", + ".claude/plugins", + "artifacts", +]; + +const HARD_EXCLUDE_SEGMENTS: readonly string[] = [ + "bin", + "obj", + ".vs", + ".venv", + "node_modules", + "__pycache__", + "TestResults", +]; + +function repoRoot(): string { + // eslint-disable-next-line sonarjs/no-os-command-from-path + const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + maxBuffer: SPAWN_MAX_BUFFER, + }); + if (result.status !== 0) return process.cwd(); + return result.stdout.trim(); +} + +function gitCheckIgnore(paths: readonly string[]): readonly string[] { + if (paths.length === 0) return []; + // eslint-disable-next-line sonarjs/no-os-command-from-path + const result = spawnSync("git", ["check-ignore", "--stdin"], { + encoding: "utf8", + maxBuffer: SPAWN_MAX_BUFFER, + input: `${paths.join("\n")}\n`, + }); + return result.stdout.split("\n").filter((s) => s.length > 0); +} + +function isHardExcluded(rel: string): boolean { + for (const prefix of HARD_EXCLUDE_PREFIXES) { + if (rel === prefix || rel.startsWith(`${prefix}/`)) return true; + } + for (const segment of HARD_EXCLUDE_SEGMENTS) { + if ( + rel === segment || + rel.endsWith(`/${segment}`) || + rel.includes(`/${segment}/`) + ) { + return true; + } + } + return false; +} + +function pushChildDirs( + dir: string, + root: string, + entries: readonly import("node:fs").Dirent[], + stack: string[], +): void { + for (const e of entries) { + if (!e.isDirectory()) continue; + const full = join(dir, e.name); + const rel = toPosixRel(relative(root, full)); + if (!isHardExcluded(rel)) stack.push(full); + } +} + +function readDirSafe( + dir: string, +): readonly import("node:fs").Dirent[] | null { + try { + return readdirSync(dir, { withFileTypes: true }); + } catch { + return null; + } +} + +// Normalize a Node-style relative() path to forward slashes — on +// Windows, `relative()` returns `\\`-separated paths, but the bash +// original emits `/` paths and the allowlist + git-check-ignore +// comparisons assume forward slashes. Posix-relative-path everywhere. +function toPosixRel(p: string): string { + return p.replace(/\\/g, "/"); +} + +function byteCompare(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} + +function findEmptyDirs(root: string): readonly string[] { + const out: string[] = []; + const stack: string[] = [root]; + while (stack.length > 0) { + const dir = stack.pop(); + if (dir === undefined) continue; + const entries = readDirSafe(dir); + if (entries === null) continue; + if (entries.length === 0) { + const rel = toPosixRel(relative(root, dir)); + if (rel !== "" && !isHardExcluded(rel)) out.push(rel); + continue; + } + pushChildDirs(dir, root, entries, stack); + } + return out.sort(byteCompare); +} + +function trimTrailingSpaceTab(s: string): string { + // Trim space, tab, and CR — CR matters when the allowlist is + // checked out with CRLF endings (Otto-235 4-shell portability + + // git autocrlf=true on Windows). Bash's `[[:space:]]*$` regex + // also matches CR. + let end = s.length; + while (end > 0) { + const c = s.charCodeAt(end - 1); + if (c !== SPACE && c !== TAB && c !== CR) break; + end--; + } + return s.slice(0, end); +} + +function isCommentOrBlankLine(line: string): boolean { + let i = 0; + while (i < line.length) { + const c = line.charCodeAt(i); + if (c !== SPACE && c !== TAB) break; + i++; + } + return i === line.length || line.charCodeAt(i) === HASH; +} + +function loadAllowlist(path: string): readonly string[] { + const content = readFileSync(path, "utf8"); + const out: string[] = []; + for (const line of content.split("\n")) { + if (isCommentOrBlankLine(line)) continue; + out.push(trimTrailingSpaceTab(line)); + } + return out; +} + +function partitionEmpty( + empty: readonly string[], + allowed: readonly string[], +): { + flagged: readonly string[]; + allowlisted: readonly string[]; +} { + const allowedSet = new Set(allowed); + const flagged: string[] = []; + const allowlisted: string[] = []; + for (const dir of empty) { + if (allowedSet.has(dir)) allowlisted.push(dir); + else flagged.push(dir); + } + return { flagged, allowlisted }; +} + +function emitList( + allowlisted: readonly string[], + flagged: readonly string[], +): void { + process.stdout.write("=== Empty directories (allowlisted) ===\n"); + if (allowlisted.length === 0) { + process.stdout.write(" (none)\n"); + } else { + for (const d of allowlisted) process.stdout.write(` ${d}\n`); + } + process.stdout.write("\n"); + process.stdout.write("=== Empty directories (flagged) ===\n"); + if (flagged.length === 0) { + process.stdout.write(" (none)\n"); + } else { + for (const d of flagged) process.stdout.write(` ${d}\n`); + } +} + +function emitFailure( + flagged: readonly string[], + allowlistFile: string, +): void { + process.stderr.write( + `no-empty-dirs: FAIL — ${String(flagged.length)} unexpected empty director(y/ies):\n`, + ); + for (const d of flagged) process.stderr.write(` ${d}\n`); + process.stderr.write("\n"); + process.stderr.write("Fix options:\n"); + process.stderr.write(" 1. Populate the directory with its intended file(s).\n"); + process.stderr.write(" 2. Delete the directory if it was created in error.\n"); + process.stderr.write(" 3. If it is a legitimate scratch/output dir, add it to\n"); + process.stderr.write(` ${allowlistFile} with a reason comment.\n`); +} + +export function main(argv: readonly string[]): ExitCode { + const root = repoRoot(); + process.chdir(root); + + const mode: Mode = argv[0] === "--list" ? "list" : "check"; + + const allowlistPath = ALLOWLIST_REL; + let allowed: readonly string[]; + try { + allowed = loadAllowlist(allowlistPath); + } catch { + process.stderr.write( + `no-empty-dirs: allowlist missing at ${allowlistPath}\n`, + ); + return 2; + } + + const candidates = findEmptyDirs(root); + const ignored = new Set(gitCheckIgnore(candidates)); + const filtered = candidates.filter((d) => !ignored.has(d)); + const { flagged, allowlisted } = partitionEmpty(filtered, allowed); + + if (mode === "list") { + emitList(allowlisted, flagged); + return 0; + } + + if (flagged.length === 0) { + process.stdout.write( + `no-empty-dirs: OK (${String(allowlisted.length)} allowlisted, 0 flagged)\n`, + ); + return 0; + } + + emitFailure(flagged, allowlistPath); + return 1; +} + +if (import.meta.main) { + process.exit(main(process.argv.slice(2))); +} diff --git a/tools/lint/safety-clause-audit.ts b/tools/lint/safety-clause-audit.ts new file mode 100644 index 000000000..94267170b --- /dev/null +++ b/tools/lint/safety-clause-audit.ts @@ -0,0 +1,366 @@ +#!/usr/bin/env bun +// safety-clause-audit.ts — counts how many .claude/skills/*/SKILL.md +// files carry an explicit scope-limiting ("what this skill does NOT +// do" or equivalent) section. +// +// TypeScript+Bun port of safety-clause-audit.sh, slice 7 of the +// TS+Bun migration. See docs/best-practices/repo-scripting.md. +// +// Promotes the `every-skill-has-safety-clause` invariant in +// .claude/skills/prompt-protector/skill.yaml from `guess` to +// `observed` — we now have a mechanical count, not a subjective +// impression. +// +// Matches three heading patterns in order of decreasing confidence: +// H1 — explicit "NOT do" heading +// H2 — scope heading (## Scope, ## Out of scope) +// H3 — authority-boundary heading (## does-not-audit) +// +// Usage: +// bun tools/lint/safety-clause-audit.ts # summary +// bun tools/lint/safety-clause-audit.ts --list-missing # names only +// bun tools/lint/safety-clause-audit.ts --verbose # per-skill +// bun tools/lint/safety-clause-audit.ts --fail-over N # exit 1 if +// # MISSING > N +// +// Exit codes: +// 0 audit ran (irrespective of MISSING count, unless --fail-over) +// 1 MISSING > N when --fail-over N specified, OR usage error +// (invalid / missing --fail-over value). Behaviour-improvement- +// over-bash: bash printed "integer expression expected" and +// exited 0 from the failed `[` test; TS port surfaces the +// usage error explicitly. + +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import { spawnSync } from "node:child_process"; + +type ExitCode = 0 | 1; +type Mode = "summary" | "list-missing" | "verbose"; +type Tier = "H1" | "H2" | "H3" | "MISSING"; + +const SPAWN_MAX_BUFFER = 64 * 1024 * 1024; + +// H1 patterns split into a small pattern list to keep each individual +// regex under sonarjs/regex-complexity threshold (20). The bash +// original packed all alternatives into one pattern; here we test +// each variant separately and OR via .some(). +// Each H1 regex stays under sonarjs/regex-complexity threshold (20) +// by using a non-capturing optional `do` suffix and the /i flag for +// NOT/not equivalence (matches the bash original's case-insensitive +// `grep -iqE`). +const H1_PATTERNS: readonly RegExp[] = [ + /^#+[\t ]+What[\t ]+this[\t ]+skill[\t ]+does[\t ]+not(?:[\t ]+do)?\b/im, + /^#+[\t ]+What[\t ]+this[\t ]+skill[\t ]+(?:don't|doesn't)[\t ]+do\b/im, + /^#+[\t ]+What[\t ]+this[\t ]+agent[\t ]+does[\t ]+not(?:[\t ]+do)?\b/im, + /^#+[\t ]+What[\t ]+this[\t ]+agent[\t ]+(?:don't|doesn't)[\t ]+do\b/im, + /^#+[\t ]+What[\t ]+(?:he|she|they|I)[\t ]+does[\t ]+not(?:[\t ]+do)?\b/im, + /^#+[\t ]+What[\t ]+(?:he|she|they|I)[\t ]+(?:don't|doesn't)[\t ]+do\b/im, + /^#+[\t ]+What[\t ]+it[\t ]+does[\t ]+not(?:[\t ]+do)?\b/im, + /^#+[\t ]+What[\t ]+it[\t ]+(?:don't|doesn't)[\t ]+do\b/im, +]; + +const H2_PATTERNS: readonly RegExp[] = [ + /^#+[\t ]+Scope\b/im, + /^#+[\t ]+What[\t ]+this[\t ]+skill[\t ]+does[\t ]+not[\t ]+cover\b/im, + /^#+[\t ]+Out[\t ]+of[\t ]+scope\b/im, +]; + +const H3_PATTERNS: readonly RegExp[] = [ + /^#+[\t ]+does-not-audit\b/im, + /^#+[\t ]+What[\t ]+this[\t ]+skill[\t ]+does[\t ]+not[\t ]+audit\b/im, +]; + +type ParsedArgs = + | { readonly kind: "ok"; readonly mode: Mode; readonly failOver: number | null } + | { readonly kind: "help" } + | { readonly kind: "usage-error"; readonly message: string }; + +interface SkillResult { + readonly name: string; + readonly tier: Tier; +} + +interface AuditCounts { + readonly total: number; + readonly h1: number; + readonly h2: number; + readonly h3: number; + readonly missing: number; + readonly h1List: readonly string[]; + readonly h2List: readonly string[]; + readonly h3List: readonly string[]; + readonly missingList: readonly string[]; +} + +function repoRoot(): string { + // eslint-disable-next-line sonarjs/no-os-command-from-path + const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + maxBuffer: SPAWN_MAX_BUFFER, + }); + if (result.status !== 0) return process.cwd(); + return result.stdout.trim(); +} + +type FailOverParse = + | { readonly kind: "found"; readonly value: number; readonly consumeNext: boolean } + | { readonly kind: "invalid"; readonly raw: string } + | { readonly kind: "missing-value" } + | { readonly kind: "no-match" }; + +function parseFailOverArg( + arg: string, + next: string | undefined, +): FailOverParse { + if (arg === "--fail-over") { + if (next === undefined) return { kind: "missing-value" }; + const parsed = Number.parseInt(next, 10); + if (Number.isNaN(parsed) || !/^-?\d+$/.test(next)) { + return { kind: "invalid", raw: next }; + } + return { kind: "found", value: parsed, consumeNext: true }; + } + if (arg.startsWith("--fail-over=")) { + const raw = arg.slice("--fail-over=".length); + const parsed = Number.parseInt(raw, 10); + if (Number.isNaN(parsed) || !/^-?\d+$/.test(raw)) { + return { kind: "invalid", raw }; + } + return { kind: "found", value: parsed, consumeNext: false }; + } + return { kind: "no-match" }; +} + +type ArgStep = + | { readonly kind: "set-mode"; readonly mode: Mode; readonly skip: 0 } + | { readonly kind: "set-fail-over"; readonly value: number; readonly skip: 0 | 1 } + | { readonly kind: "help"; readonly skip: 0 } + | { readonly kind: "error"; readonly message: string } + | { readonly kind: "skip"; readonly skip: 0 }; + +function classifyArg(arg: string, next: string | undefined): ArgStep { + if (arg === "--list-missing") return { kind: "set-mode", mode: "list-missing", skip: 0 }; + if (arg === "--verbose") return { kind: "set-mode", mode: "verbose", skip: 0 }; + if (arg === "-h" || arg === "--help") return { kind: "help", skip: 0 }; + const fo = parseFailOverArg(arg, next); + if (fo.kind === "found") { + return { kind: "set-fail-over", value: fo.value, skip: fo.consumeNext ? 1 : 0 }; + } + if (fo.kind === "invalid") { + return { + kind: "error", + message: `--fail-over requires an integer value, got: ${fo.raw}`, + }; + } + if (fo.kind === "missing-value") { + return { + kind: "error", + message: "--fail-over requires an integer value (e.g. --fail-over 5)", + }; + } + return { kind: "skip", skip: 0 }; +} + +function parseArgs(argv: readonly string[]): ParsedArgs { + let mode: Mode = "summary"; + let failOver: number | null = null; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === undefined) continue; + const step = classifyArg(arg, argv[i + 1]); + if (step.kind === "help") return { kind: "help" }; + if (step.kind === "error") return { kind: "usage-error", message: step.message }; + if (step.kind === "set-mode") mode = step.mode; + else if (step.kind === "set-fail-over") { + failOver = step.value; + i += step.skip; + } + } + return { kind: "ok", mode, failOver }; +} + +function emitHelp(): void { + process.stdout.write( + "Usage: bun tools/lint/safety-clause-audit.ts [options]\n", + ); + process.stdout.write("\n"); + process.stdout.write("Options:\n"); + process.stdout.write(" (no flag) summary table (default)\n"); + process.stdout.write(" --list-missing print one skill name per missing entry\n"); + process.stdout.write(" --verbose per-skill table with tier classification\n"); + process.stdout.write(" --fail-over N exit 1 if MISSING count > N\n"); + process.stdout.write(" -h, --help print this help and exit 0\n"); +} + +function listSkillFiles(skillsDir: string): readonly string[] { + let entries: readonly import("node:fs").Dirent[]; + try { + entries = readdirSync(skillsDir, { withFileTypes: true }); + } catch { + return []; + } + const out: string[] = []; + for (const e of entries) { + if (!e.isDirectory()) continue; + const skillMd = join(skillsDir, e.name, "SKILL.md"); + try { + if (statSync(skillMd).isFile()) out.push(skillMd); + } catch { + continue; + } + } + return out.sort((a, b) => a.localeCompare(b)); +} + +function matchesAny(content: string, patterns: readonly RegExp[]): boolean { + return patterns.some((re) => re.test(content)); +} + +function classifySkill(content: string): Tier { + if (matchesAny(content, H1_PATTERNS)) return "H1"; + if (matchesAny(content, H2_PATTERNS)) return "H2"; + if (matchesAny(content, H3_PATTERNS)) return "H3"; + return "MISSING"; +} + +function auditSkills(files: readonly string[]): AuditCounts { + const results: SkillResult[] = []; + for (const file of files) { + let content: string; + try { + content = readFileSync(file, "utf8"); + } catch { + continue; + } + const name = basename(dirname(file)); + results.push({ name, tier: classifySkill(content) }); + } + const h1List = results.filter((r) => r.tier === "H1").map((r) => r.name); + const h2List = results.filter((r) => r.tier === "H2").map((r) => r.name); + const h3List = results.filter((r) => r.tier === "H3").map((r) => r.name); + const missingList = results + .filter((r) => r.tier === "MISSING") + .map((r) => r.name); + return { + total: results.length, + h1: h1List.length, + h2: h2List.length, + h3: h3List.length, + missing: missingList.length, + h1List, + h2List, + h3List, + missingList, + }; +} + +function formatPercent(numerator: number, total: number): string { + if (total === 0) return "0.0"; + return ((100 * numerator) / total).toFixed(1); +} + +function emitListMissing(c: AuditCounts): void { + // Intentional behaviour-improvement-over-bash: emit zero bytes + // when the missing list is empty. Bash's + // `printf '%s\n' "${missing_list[@]:-}"` actually emits one + // newline on empty (verified empirically 2026-04-30 in macOS + // bash 3.2 + Linux bash 5.x — the :- expansion produces "", + // then printf emits "%s\n" = "\n"). Reviewer flagged this as + // a piping ergonomics wart on PR #878; the TS port emits the + // expected zero bytes when there's nothing to list. Same + // category as the no-empty-dirs empty-FILTERED behaviour-fix + // documented in slice-7 audit (the bash original was + // unintentional, byte-equivalence wasn't a contract). + for (const name of c.missingList) process.stdout.write(`${name}\n`); +} + +function emitVerboseRow(name: string, tier: string): void { + if (name.length > 0) process.stdout.write(`| \`${name}\` | ${tier} |\n`); +} + +function emitVerbose(c: AuditCounts): void { + process.stdout.write("# Safety-clause audit — per skill\n"); + process.stdout.write("\n"); + process.stdout.write("| Skill | Tier |\n"); + process.stdout.write("|---|---|\n"); + for (const n of c.h1List) emitVerboseRow(n, "H1 explicit"); + for (const n of c.h2List) emitVerboseRow(n, "H2 scope"); + for (const n of c.h3List) emitVerboseRow(n, "H3 authority"); + for (const n of c.missingList) emitVerboseRow(n, "MISSING"); + process.stdout.write("\n"); +} + +function emitSummary(c: AuditCounts): void { + const covered = c.h1 + c.h2 + c.h3; + const pctCov = formatPercent(covered, c.total); + const pctH1 = formatPercent(c.h1, c.total); + process.stdout.write("# Safety-clause audit — summary\n"); + process.stdout.write("\n"); + process.stdout.write( + "Counts of `.claude/skills/*/SKILL.md` files carrying a\n", + ); + process.stdout.write("scope-limiting clause. Promotes the\n"); + process.stdout.write("`every-skill-has-safety-clause` invariant in\n"); + process.stdout.write("`.claude/skills/prompt-protector/skill.yaml` from `guess`\n"); + process.stdout.write("to `observed`.\n"); + process.stdout.write("\n"); + process.stdout.write("| Tier | Pattern | Count |\n"); + process.stdout.write("|---|---|---:|\n"); + process.stdout.write( + `| H1 | explicit "does NOT do" heading | ${String(c.h1)} |\n`, + ); + process.stdout.write( + `| H2 | "Scope" / "Out of scope" heading | ${String(c.h2)} |\n`, + ); + process.stdout.write( + `| H3 | "does-not-audit" / equivalent | ${String(c.h3)} |\n`, + ); + process.stdout.write( + `| MISSING | no scope-limiting heading | ${String(c.missing)} |\n`, + ); + process.stdout.write(`| **total** | | **${String(c.total)}** |\n`); + process.stdout.write("\n"); + process.stdout.write( + `Covered (H1+H2+H3): **${String(covered)} / ${String(c.total)}** — **${pctCov}%**.\n`, + ); + process.stdout.write( + `H1-only (strongest): **${String(c.h1)} / ${String(c.total)}** — **${pctH1}%**.\n`, + ); + process.stdout.write("\n"); + process.stdout.write(`Missing count: **${String(c.missing)}**.\n`); +} + +export function main(argv: readonly string[]): ExitCode { + const parsed = parseArgs(argv); + if (parsed.kind === "help") { + emitHelp(); + return 0; + } + if (parsed.kind === "usage-error") { + process.stderr.write(`safety-clause-audit: ${parsed.message}\n`); + return 1; + } + const { mode, failOver } = parsed; + + const root = repoRoot(); + const skillsDir = join(root, ".claude", "skills"); + const files = listSkillFiles(skillsDir); + const counts = auditSkills(files); + + if (mode === "list-missing") emitListMissing(counts); + else if (mode === "verbose") emitVerbose(counts); + else emitSummary(counts); + + if (failOver !== null && counts.missing > failOver) { + process.stderr.write( + `FAIL: missing=${String(counts.missing)} exceeds threshold ${String(failOver)}\n`, + ); + return 1; + } + return 0; +} + +if (import.meta.main) { + process.exit(main(process.argv.slice(2))); +}