diff --git a/CLAUDE.md b/CLAUDE.md index 9fd0876..3132c18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ Shared frontend service packages monorepo under the `@script-development` npm sc - **Test:** vitest 4 (100% coverage threshold) + Stryker (90% mutation threshold) - **Lint:** oxlint (explicit config at `.oxlintrc.json`) - **Format:** oxfmt -- **Package lint:** publint + attw (Are The Types Wrong) — `lint:pkg` enforces fail-on-any-advisory via `scripts/lint-pkg.mjs` (suggestions, warnings, and errors all treat as fatal — publint CLI default and `--strict` both exit 0 on suggestions). Motivated by enforcement queue #33 + the PR #35 `git+` prefix regression that silently drifted across 10 packages because the unenforced gate only printed the suggestion. +- **Package lint:** publint + attw (Are The Types Wrong) — `lint:pkg` enforces fail-on-any-advisory via `scripts/lint-pkg.mjs` (suggestions, warnings, and errors all treat as fatal — publint CLI default and `--strict` both exit 0 on suggestions). Motivated by enforcement queue #33 + the PR #35 `git+` prefix regression that silently drifted across 10 packages because the unenforced gate only printed the suggestion. The same wrapper also asserts `engines.node` presence across the root manifest + all workspace packages — closes enforcement queue #31 (drift-prevention gate, deployed 2026-05-12; declarations themselves landed 2026-04-22 via commit `0605d99`). Presence-only check; the value (`>=24.0.0` today) is not validated — value alignment is a separate doctrine question tracked alongside the CI `node-version`. - **Publish:** OIDC Trusted Publishing to public npm registry (no stored tokens) - **CI:** 8-gate pipeline: audit → format → lint → build → typecheck → lint:pkg → coverage → mutation diff --git a/scripts/lint-pkg.mjs b/scripts/lint-pkg.mjs index 51e10a5..1c1339f 100644 --- a/scripts/lint-pkg.mjs +++ b/scripts/lint-pkg.mjs @@ -1,21 +1,31 @@ #!/usr/bin/env node -// Gate 6 (lint:pkg) enforcer — treats publint suggestions/warnings/errors as fatal. +// Gate 6 (lint:pkg) enforcer — per-manifest publish-readiness assertions. // -// publint 0.3.18 CLI does not expose a flag to fail on suggestions (--strict -// only promotes warnings → errors). This wrapper fills that gap: it runs -// publint per workspace, captures stdout, and fails the gate if any package -// emits a "Suggestions:", "Warnings:", or "Errors:" block. attw --pack runs -// after publint per package and preserves its own exit code. +// 1. publint + attw — treats publint suggestions/warnings/errors as fatal. +// publint 0.3.18 CLI does not expose a flag to fail on suggestions +// (--strict only promotes warnings → errors). This wrapper fills that gap: +// it runs publint per workspace, captures stdout, and fails the gate if any +// package emits a "Suggestions:", "Warnings:", or "Errors:" block. +// attw --pack runs after publint per package and preserves its own exit code. +// Motivated by enforcement queue #33 and the PR #35 regression: publint +// suggestions about the "git+" URL prefix silently re-drifted across 10 +// packages because the gate tolerated them. // -// See enforcement queue #33 and the PR #35 regression that motivated this -// tightening: publint suggestions about the "git+" URL prefix silently -// re-drifted across 10 packages because the gate tolerated them. +// 2. engines.node presence — closes enforcement queue #31 (drift-prevention +// gate, deployed 2026-05-12). Every workspace package.json AND the root +// package.json must declare a non-empty `engines.node` string. Value is NOT +// validated (presence-only — the queue-31 target is "any new package added +// to the Armory ships with the declaration"; value alignment across the +// corpus is a separate doctrine question). The declarations themselves +// landed 2026-04-22 via commit 0605d99 — this gate prevents regression on +// new packages and on edits that strip the field. import {spawnSync} from 'node:child_process'; import {readdirSync, readFileSync, statSync} from 'node:fs'; import {join} from 'node:path'; const PACKAGES_DIR = 'packages'; +const ROOT_MANIFEST = 'package.json'; const PUBLINT_BLOCK_RE = /^(Suggestions|Warnings|Errors):$/m; function listPackageDirs() { @@ -31,9 +41,24 @@ function listPackageDirs() { .sort(); } +function readManifest(manifestPath) { + return JSON.parse(readFileSync(manifestPath, 'utf8')); +} + function packageName(dir) { - const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); - return pkg.name ?? dir; + return readManifest(join(dir, 'package.json')).name ?? dir; +} + +function checkEnginesNode(manifestPath, label) { + const pkg = readManifest(manifestPath); + if (pkg.engines === undefined || pkg.engines === null) { + return `${label}: engines field missing (queue #31 — engines.node presence required)`; + } + const node = pkg.engines.node; + if (typeof node !== 'string' || node.trim() === '') { + return `${label}: engines.node missing or not a non-empty string (queue #31)`; + } + return null; } function runCaptured(cmd, args, cwd) { @@ -49,10 +74,27 @@ function main() { const dirs = listPackageDirs(); const failures = []; + // Root manifest engines.node presence check (queue #31). Root is not in + // packages/*, so it gets a dedicated assertion before the per-package loop. + process.stdout.write(`\n--- lint:pkg engines.node (root ${ROOT_MANIFEST}) ---\n`); + const rootFailure = checkEnginesNode(ROOT_MANIFEST, 'workspace-root'); + if (rootFailure) { + failures.push(rootFailure); + process.stderr.write(` ${rootFailure}\n`); + } else { + process.stdout.write(` workspace-root: engines.node OK\n`); + } + for (const dir of dirs) { const name = packageName(dir); process.stdout.write(`\n--- lint:pkg ${name} (${dir}) ---\n`); + const enginesFailure = checkEnginesNode(join(dir, 'package.json'), name); + if (enginesFailure) { + failures.push(enginesFailure); + process.stderr.write(` ${enginesFailure}\n`); + } + const publint = runCaptured('npx', ['publint', 'run'], dir); const publintBlock = PUBLINT_BLOCK_RE.exec(publint.stdout); if (publint.status !== 0) { @@ -76,7 +118,7 @@ function main() { } process.stdout.write( - `\nlint:pkg gate PASS — ${dirs.length} packages clean (publint suggestions/warnings/errors all treated as fatal).\n`, + `\nlint:pkg gate PASS — ${dirs.length} packages + root clean (engines.node present; publint suggestions/warnings/errors all treated as fatal).\n`, ); }