diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 796eb788ff..0d40cead94 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -874,6 +874,29 @@ jobs: - name: Run no-empty-dirs run: bun tools/lint/no-empty-dirs.ts + lint-no-python-files: + # Fail if a committed .py file exists outside the allowlist / + # vendored-toolchain hard-excludes. Per B-0156 Phase 6 + # (Aaron 2026-05-01: "any .py" should be ported to TS or + # excluded). Script under tools/lint/ respects .gitignore and + # the explicit allowlist at tools/lint/no-python-files.allowlist; + # references/upstreams, .venv, __pycache__, site-packages, .lake + # are hard-excluded by the script itself. + # No untrusted input used in run: — only a fixed repo path. + name: lint (no python files) + timeout-minutes: 3 + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install toolchain + run: ./tools/setup/install.sh + + - name: Run no-python-files + run: bun tools/lint/no-python-files.ts + lint-markdown: # markdownlint-cli2 on every .md file outside the ignore list in # .markdownlint-cli2.jsonc. Round 33 static-analysis expansion diff --git a/docs/backlog/P1/B-0156-typescript-standardization-non-install-scripts-aaron-2026-05-01.md b/docs/backlog/P1/B-0156-typescript-standardization-non-install-scripts-aaron-2026-05-01.md index 5aa2b4d7fb..57e49bcd0a 100644 --- a/docs/backlog/P1/B-0156-typescript-standardization-non-install-scripts-aaron-2026-05-01.md +++ b/docs/backlog/P1/B-0156-typescript-standardization-non-install-scripts-aaron-2026-05-01.md @@ -4,7 +4,7 @@ priority: P1 status: open title: TypeScript standardization — port every .sh outside install graph + every .py to TS (Aaron 2026-05-01) created: 2026-05-01 -last_updated: 2026-05-08 +last_updated: 2026-05-16 decomposition: decomposed children: [B-0140] depends_on: @@ -155,11 +155,19 @@ delete the `.sh` siblings to complete the migration. Each deletion is reversible via `git revert` if regressions surface. -### Phase 6 — `.py` policy enforcement - -Add a CI lint that fails on any new `.py` file outside -`references/upstreams/`. Mechanizable as a pre-commit hook -or simple `find`-based check in `gate.yml`. +### Phase 6 — `.py` policy enforcement -- DONE (2026-05-16) + +Landed as `tools/lint/no-python-files.ts` (TS+Bun, per Rule 0) +with an explicit allowlist at +`tools/lint/no-python-files.allowlist` (starts empty) and a +unit-test suite at `tools/lint/no-python-files.test.ts` +(9 tests). Wired into `.github/workflows/gate.yml` as the +`lint-no-python-files` job, adjacent to `lint-no-empty-dirs`. +Hard-excludes `references/upstreams`, `.venv`, `__pycache__`, +`site-packages`, `tools/lean4/.lake`, `node_modules`, `bin`, +`obj`. Current repo state: 0 flagged, 0 allowlisted (the +audit baseline this row stated for "Python files in our +codebase (0)" is mechanically enforced going forward). ## Acceptance criteria diff --git a/docs/hygiene-history/ticks/2026/05/16/2157Z.md b/docs/hygiene-history/ticks/2026/05/16/2157Z.md new file mode 100644 index 0000000000..c299b5a831 --- /dev/null +++ b/docs/hygiene-history/ticks/2026/05/16/2157Z.md @@ -0,0 +1,39 @@ +--- +tick: "2026-05-16T21:57Z" +agent: otto +mode: autonomous +operative-authorization: "aaron 2026-05-14: \"- **Devil-pole** (edge-runner drive): keep pushing, discover, go hard, never-be-idle\"" +--- + +# Tick 2026-05-16T21:57Z — B-0156 Phase 6 lands + +- Cron `3b10475a` armed at session start (sentinel re-armed per + catch 43 — CronList returned empty). +- Claim acquired: `B-0156` on branch + `otto-cli/b0156-phase6-no-python-lint-2026-05-16` (envelope + `1afe3322-422d-40cc-9c1e-d7713b7b384c`). +- Substrate-drift discriminator on B-0156: all six named `.sh` + files in the row (Phases 1-4) are already deleted; their `.ts` + ports exist. Phases 1-5 = DONE. Phase 6 (`.py` policy CI gate) + was the only outstanding acceptance bullet. +- Phase 6 implementation (smallest safe slice): + - `tools/lint/no-python-files.ts` — TS+Bun port of the + `find`-based mechanization candidate the row drafted in YAML, + rebuilt against the `no-empty-dirs.ts` template (Rule 0: + no `.sh` outside install graph). + - `tools/lint/no-python-files.allowlist` — explicit allowlist + (starts empty; legitimate exceptions land here with reason + comments). + - `tools/lint/no-python-files.test.ts` — 9-test `bun test` + suite exercising: missing-allowlist → exit 2; clean tree + → exit 0; flagged `.py` → exit 1; allowlisted `.py` → exit 0; + `references/upstreams` hard-exclude; `.venv` hard-exclude; + `__pycache__` hard-exclude; `--list` mode always exits 0; + comment/blank lines in allowlist ignored. + - `.github/workflows/gate.yml` — new `lint-no-python-files` + job adjacent to `lint-no-empty-dirs`, same pattern. +- Focused checks: 9/9 tests pass; real-repo run reports + `0 allowlisted, 0 flagged`; `no-empty-dirs` regression = + green; gate.yml parses cleanly (17 jobs, new job present). +- Backlog row updated: Phase 6 marked DONE; `last_updated` + bumped to 2026-05-16. diff --git a/tools/lint/no-python-files.allowlist b/tools/lint/no-python-files.allowlist new file mode 100644 index 0000000000..eb7e81964c --- /dev/null +++ b/tools/lint/no-python-files.allowlist @@ -0,0 +1,21 @@ +# tools/lint/no-python-files.allowlist +# +# Repo-relative paths to .py files that are legitimately allowed +# in this repository despite the B-0156 (Aaron 2026-05-01) policy +# of "any .py" should be ported to TS or excluded. +# +# Format: one repo-relative path per line. Lines starting with `#` +# and blank lines are ignored. Trailing whitespace (incl. CR for +# Windows checkouts) is trimmed. +# +# Per B-0156 Phase 6: this file starts empty. references/upstreams, +# .venv, node_modules, __pycache__, site-packages, .lake are hard- +# excluded by the script itself (HARD_EXCLUDE_PREFIXES / +# HARD_EXCLUDE_SEGMENTS) and do NOT need to be listed here. +# +# Add an entry only when a .py file is genuinely required AND has a +# documented reason. Example: +# +# # tools/setup/common/foo.py — vendored upstream installer; cannot +# # be ported without forking upstream. Tracked in B-XXXX. +# tools/setup/common/foo.py diff --git a/tools/lint/no-python-files.test.ts b/tools/lint/no-python-files.test.ts new file mode 100644 index 0000000000..d7522b206b --- /dev/null +++ b/tools/lint/no-python-files.test.ts @@ -0,0 +1,142 @@ +// no-python-files.test.ts — unit tests for the Phase 6 (B-0156) +// .py policy lint. We exercise main() against synthetic trees in a +// temporary directory so the test is independent of repo state. + +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +import { main } from "./no-python-files"; + +function makeRepo(): string { + const root = mkdtempSync(join(tmpdir(), "no-python-files-")); + spawnSync("git", ["init", "-q", root], { encoding: "utf8" }); + return root; +} + +function writeAllowlist(root: string, body: string): void { + mkdirSync(join(root, "tools", "lint"), { recursive: true }); + writeFileSync(join(root, "tools", "lint", "no-python-files.allowlist"), body); +} + +function captureStdout(fn: () => T): { result: T; stdout: string; stderr: string } { + const realOut = process.stdout.write.bind(process.stdout); + const realErr = process.stderr.write.bind(process.stderr); + let stdout = ""; + let stderr = ""; + process.stdout.write = ((s: string | Uint8Array) => { + stdout += typeof s === "string" ? s : new TextDecoder().decode(s); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((s: string | Uint8Array) => { + stderr += typeof s === "string" ? s : new TextDecoder().decode(s); + return true; + }) as typeof process.stderr.write; + try { + const result = fn(); + return { result, stdout, stderr }; + } finally { + process.stdout.write = realOut; + process.stderr.write = realErr; + } +} + +describe("no-python-files", () => { + let originalCwd: string; + let repoRoot: string; + + beforeEach(() => { + originalCwd = process.cwd(); + repoRoot = makeRepo(); + process.chdir(repoRoot); + }); + + afterEach(() => { + process.chdir(originalCwd); + rmSync(repoRoot, { recursive: true, force: true }); + }); + + test("returns 2 when allowlist is missing", () => { + const { result } = captureStdout(() => main([])); + expect(result).toBe(2); + }); + + test("returns 0 when no .py files exist", () => { + writeAllowlist(repoRoot, ""); + writeFileSync(join(repoRoot, "hello.ts"), "// ts\n"); + const { result, stdout } = captureStdout(() => main([])); + expect(result).toBe(0); + expect(stdout).toContain("OK"); + }); + + test("returns 1 when a flagged .py file exists", () => { + writeAllowlist(repoRoot, ""); + writeFileSync(join(repoRoot, "rogue.py"), "print('hi')\n"); + const { result, stderr } = captureStdout(() => main([])); + expect(result).toBe(1); + expect(stderr).toContain("rogue.py"); + expect(stderr).toContain("FAIL"); + }); + + test("returns 0 when the only .py file is allowlisted", () => { + writeAllowlist(repoRoot, "tools/setup/common/legacy.py\n"); + mkdirSync(join(repoRoot, "tools", "setup", "common"), { recursive: true }); + writeFileSync( + join(repoRoot, "tools", "setup", "common", "legacy.py"), + "print('hi')\n", + ); + const { result, stdout } = captureStdout(() => main([])); + expect(result).toBe(0); + expect(stdout).toContain("1 allowlisted"); + }); + + test("ignores .py files under references/upstreams (hard-excluded prefix)", () => { + writeAllowlist(repoRoot, ""); + mkdirSync(join(repoRoot, "references", "upstreams", "project"), { + recursive: true, + }); + writeFileSync( + join(repoRoot, "references", "upstreams", "project", "main.py"), + "x = 1\n", + ); + const { result } = captureStdout(() => main([])); + expect(result).toBe(0); + }); + + test("ignores .py files under .venv (hard-excluded segment)", () => { + writeAllowlist(repoRoot, ""); + mkdirSync(join(repoRoot, ".venv", "lib"), { recursive: true }); + writeFileSync(join(repoRoot, ".venv", "lib", "thing.py"), "x = 1\n"); + const { result } = captureStdout(() => main([])); + expect(result).toBe(0); + }); + + test("ignores .py files under __pycache__ (hard-excluded segment)", () => { + writeAllowlist(repoRoot, ""); + mkdirSync(join(repoRoot, "src", "__pycache__"), { recursive: true }); + writeFileSync(join(repoRoot, "src", "__pycache__", "x.py"), "x = 1\n"); + const { result } = captureStdout(() => main([])); + expect(result).toBe(0); + }); + + test("--list mode returns 0 even when files are flagged", () => { + writeAllowlist(repoRoot, ""); + writeFileSync(join(repoRoot, "rogue.py"), "print('hi')\n"); + const { result, stdout } = captureStdout(() => main(["--list"])); + expect(result).toBe(0); + expect(stdout).toContain("Python files (flagged)"); + expect(stdout).toContain("rogue.py"); + }); + + test("comment and blank lines in the allowlist are ignored", () => { + writeAllowlist( + repoRoot, + "# leading comment\n\n # indented comment\nrogue.py\n", + ); + writeFileSync(join(repoRoot, "rogue.py"), "print('hi')\n"); + const { result } = captureStdout(() => main([])); + expect(result).toBe(0); + }); +}); diff --git a/tools/lint/no-python-files.ts b/tools/lint/no-python-files.ts new file mode 100644 index 0000000000..7b3de8cdc0 --- /dev/null +++ b/tools/lint/no-python-files.ts @@ -0,0 +1,251 @@ +#!/usr/bin/env bun +// no-python-files.ts — Phase 6 of B-0156: fail the build if a .py +// file exists outside the allowlisted paths (references/upstreams +// mirrors, vendored toolchain dirs). Per Aaron 2026-05-01: +// "any .py" should be ported to TS; this lint enforces the policy +// going forward so future contributors can't introduce one by +// accident. +// +// Usage: +// bun tools/lint/no-python-files.ts # check mode +// bun tools/lint/no-python-files.ts --list # list mode (always exit 0) +// +// Exit codes: +// 0 no flagged .py files (or --list mode) +// 1 one or more flagged .py files +// 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-python-files.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", + "site-packages", +]; + +function repoRoot(): string { + 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 []; + 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 toPosixRel(p: string): string { + return p.replace(/\\/g, "/"); +} + +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 readDirSafe( + dir: string, +): readonly import("node:fs").Dirent[] | null { + try { + return readdirSync(dir, { withFileTypes: true }); + } catch { + return null; + } +} + +function byteCompare(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} + +function findPythonFiles(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; + for (const e of entries) { + const full = join(dir, e.name); + const rel = toPosixRel(relative(root, full)); + if (isHardExcluded(rel)) continue; + if (e.isDirectory()) { + stack.push(full); + } else if (e.isFile() && e.name.endsWith(".py")) { + out.push(rel); + } + } + } + return out.sort(byteCompare); +} + +function trimTrailingSpaceTab(s: string): string { + 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 partitionPython( + found: readonly string[], + allowed: readonly string[], +): { + flagged: readonly string[]; + allowlisted: readonly string[]; +} { + const allowedSet = new Set(allowed); + const flagged: string[] = []; + const allowlisted: string[] = []; + for (const f of found) { + if (allowedSet.has(f)) allowlisted.push(f); + else flagged.push(f); + } + return { flagged, allowlisted }; +} + +function emitList( + allowlisted: readonly string[], + flagged: readonly string[], +): void { + process.stdout.write("=== Python files (allowlisted) ===\n"); + if (allowlisted.length === 0) { + process.stdout.write(" (none)\n"); + } else { + for (const f of allowlisted) process.stdout.write(` ${f}\n`); + } + process.stdout.write("\n"); + process.stdout.write("=== Python files (flagged) ===\n"); + if (flagged.length === 0) { + process.stdout.write(" (none)\n"); + } else { + for (const f of flagged) process.stdout.write(` ${f}\n`); + } +} + +function emitFailure( + flagged: readonly string[], + allowlistFile: string, +): void { + process.stderr.write( + `no-python-files: FAIL — ${String(flagged.length)} disallowed .py file(s):\n`, + ); + for (const f of flagged) process.stderr.write(` ${f}\n`); + process.stderr.write("\n"); + process.stderr.write("Per B-0156 (Aaron 2026-05-01): TS is preferred over Python in our codebase.\n"); + process.stderr.write("Fix options:\n"); + process.stderr.write(" 1. Port the file to TypeScript (preferred).\n"); + process.stderr.write(" 2. Delete the file if it is no longer needed.\n"); + process.stderr.write(" 3. If the file is legitimately required (e.g. vendored\n"); + process.stderr.write(` toolchain), add it to ${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-python-files: allowlist missing at ${allowlistPath}\n`, + ); + return 2; + } + + const candidates = findPythonFiles(root); + const ignored = new Set(gitCheckIgnore(candidates)); + const filtered = candidates.filter((f) => !ignored.has(f)); + const { flagged, allowlisted } = partitionPython(filtered, allowed); + + if (mode === "list") { + emitList(allowlisted, flagged); + return 0; + } + + if (flagged.length === 0) { + process.stdout.write( + `no-python-files: 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))); +}