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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
66 changes: 54 additions & 12 deletions scripts/lint-pkg.mjs
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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`,
);
}

Expand Down