diff --git a/docs/research/repository-hygiene-guards.md b/docs/research/repository-hygiene-guards.md index 351cc4b1..de832686 100644 --- a/docs/research/repository-hygiene-guards.md +++ b/docs/research/repository-hygiene-guards.md @@ -46,8 +46,10 @@ uses no new dependency, and every failure message says how to fix it. It enforce `check-developer-entrypoints.ts`. 6. Every `experiments/*` directory is still run by code, scripts, `package.json` or CI. 7. Source files open with a comment saying what they are for. -8. No tracked `.py`, `requirements*.txt`, `pyproject.toml` or `.python-version` file, since P5 - retired Python (ADR-0050); an explicit allow-list in the script is empty by default. +8. Python is allowed at the edges (SDKs, tooling, small scripts), as in other TypeScript agent + harnesses such as DeepSeek's `dsh`, but never in the product (Server, Web, Desktop, Node and + their shared packages, ADR-0050), and every `.py` file must be run by CI, `package.json`, + scripts or other code. The owner relaxed the earlier outright ban on 2026-10-11. Rules 5 and 7 start from a recorded baseline in `scripts/hygiene-baseline.json` that may only go down; the check asks for the baseline to be lowered as soon as files improve. diff --git a/scripts/check-hygiene.test.ts b/scripts/check-hygiene.test.ts index 9752e0b4..b30fc599 100644 --- a/scripts/check-hygiene.test.ts +++ b/scripts/check-hygiene.test.ts @@ -133,20 +133,27 @@ test("new source files must say what they are for; the recorded gap only shrinks ); }); -test("Python files and packaging names cannot come back", () => { - for (const name of [ - "scripts/fix.py", - "experiments/probe/requirements-dev.txt", - "apps/tool/pyproject.toml", - ".python-version", - ]) - assert.match( - checkHygiene(input({ ...clean, [name]: "x\n" })).join("\n"), - new RegExp(`${name.replaceAll(".", "\\.")}: Python was retired in P5 \\(ADR-0050\\)`, "u"), - ); - // Similar names that are not Python stay allowed. - const similar = { ...clean, "docs/requirements.md": "x\n", "apps/web/src/copy.pyx.ts": "// x\n" }; - assert.deepEqual(checkHygiene(input(similar)), []); +test("Python is allowed at the edges while something runs it, never in the product", () => { + // A small script that CI or package.json runs is fine, with its tests and packaging files. + const tooling: Record = { + ...clean, + "scripts/encode_gif.py": "print(1)\n", + "scripts/tests/test_encode_gif.py": "import encode_gif\n", + "scripts/requirements.txt": "pillow==11.0.0\n", + "package.json": + '{"scripts":{"gif":"python3 scripts/encode_gif.py && pytest scripts/tests","probe":"python3 experiments/probe/run.py"}}\n', + }; + assert.deepEqual(checkHygiene(input(tooling)), []); + // A Python file nothing runs is dead code. + const dead = { ...tooling, "scripts/forgotten.py": "print(1)\n" }; + assert.match(checkHygiene(input(dead)).join("\n"), /scripts\/forgotten\.py: nothing in CI/u); + // The product stays TypeScript even when something would run the file. + const product: Record = { ...tooling, "apps/server/src/helper.py": "x = 1\n" }; + product["package.json"] = '{"scripts":{"h":"python3 apps/server/src/helper.py"}}\n'; + assert.match( + checkHygiene(input(product)).join("\n"), + /helper\.py: the product .* is TypeScript/u, + ); }); test("an opening comment may follow a shebang but not code", () => { diff --git a/scripts/check-hygiene.ts b/scripts/check-hygiene.ts index 4e027e50..6b75ff6d 100644 --- a/scripts/check-hygiene.ts +++ b/scripts/check-hygiene.ts @@ -24,15 +24,15 @@ const DATED_DOC = /(?:PLAN|ROADMAP|STATUS|WORKLOG|HANDOFF|CLEANUP|TODO)[^/]*\.md export const NESTED_RULES_MAX_LINES = 60; -/** Python sources and packaging files. P5 (ADR-0050) retired the last Python runtime. */ -const PYTHON_FILE = - /(?:^|\/)(?:[^/]+\.py|requirements[^/]*\.txt|pyproject\.toml|\.python-version)$/u; - /** - * Non-Python artifacts that must keep a Python-looking name. Empty by default; an entry needs a - * reason in the PR that adds it. + * Python is welcome at the edges (an SDK for Python users, sandbox or document tooling, small + * scripts), as in other TypeScript agent harnesses. The product itself stays one language: P5 + * (ADR-0050) moved the control plane, Worker and clients to TypeScript, and splitting them again + * is what made the repository incoherent. */ -export const PYTHON_NAME_ALLOWED: ReadonlySet = new Set(); +const PYTHON_SOURCE = /\.py$/u; +const TYPESCRIPT_ONLY = + /^(?:apps\/(?:server|web|desktop|node)|packages\/(?:protocol|domain|db|work))\//u; /** Paths written in backticks in living docs must exist. ADRs and research records are dated evidence. */ const PATH_ROOTS = "apps|packages|providers|plugins|scripts|docs|experiments|tests|deploy"; @@ -208,10 +208,34 @@ export function checkHygiene(input: HygieneInput): string[] { ); } - // 8. Python stays retired: the control plane, Worker and probes are TypeScript. - for (const file of files) - if (PYTHON_FILE.test(file) && !PYTHON_NAME_ALLOWED.has(file)) - failures.push(`${file}: Python was retired in P5 (ADR-0050); write this in TypeScript`); + // 8. Python only at the edges, and only while something still runs it. + const pythonRunners = files.filter( + // Evidence JSON only records hashes; package.json is the JSON that runs things. + (file) => + !file.endsWith(".md") && + (!file.endsWith(".json") || basename(file) === "package.json") && + !file.startsWith("docs/"), + ); + for (const file of files.filter((path) => PYTHON_SOURCE.test(path))) { + if (TYPESCRIPT_ONLY.test(file)) { + failures.push( + `${file}: the product (Server, Web, Desktop, Node and their shared packages) is TypeScript (ADR-0050); keep Python to SDKs, tooling and scripts`, + ); + continue; + } + const name = basename(file); + const folder = dirname(file); + const run = pythonRunners.some( + (runner) => + runner !== file && + (read(runner).includes(name) || + (name.startsWith("test_") && read(runner).includes(folder))), + ); + if (!run) + failures.push( + `${file}: nothing in CI, package.json, scripts or other code runs this Python file; wire it up or delete it`, + ); + } return failures; }