Skip to content

Commit ee1e208

Browse files
committed
chore(armory): add engines.node presence gate (queue #31)
Extend scripts/lint-pkg.mjs (Gate 6) with a per-manifest engines.node presence assertion across the workspace root + all 10 packages. Closes enforcement queue #31 (drift-prevention half — declarations themselves landed 2026-04-22 via commit 0605d99). Option α (fold into existing wrapper). Same domain as the publint suggestion-strictness — both are per-manifest publish-readiness assertions — so the failure aggregation, message shape, and CI gate count (8) stay unchanged. Presence-only check; value alignment (currently uniform ">=24.0.0") is a separate doctrine question. Smoke test: temporarily stripped engines from packages/storage/package.json, ran lint:pkg, confirmed exit 1 with named failure "@script-development/fs-storage: engines field missing (queue #31 — engines.node presence required)" and aggregate footer "lint:pkg gate FAILED (1):"; reverted, re-ran, confirmed exit 0 with "lint:pkg gate PASS — 10 packages + root clean".
1 parent f51e8b2 commit ee1e208

2 files changed

Lines changed: 55 additions & 13 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Shared frontend service packages monorepo under the `@script-development` npm sc
99
- **Test:** vitest 4 (100% coverage threshold) + Stryker (90% mutation threshold)
1010
- **Lint:** oxlint (explicit config at `.oxlintrc.json`)
1111
- **Format:** oxfmt
12-
- **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.
12+
- **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`.
1313
- **Publish:** OIDC Trusted Publishing to public npm registry (no stored tokens)
1414
- **CI:** 8-gate pipeline: audit → format → lint → build → typecheck → lint:pkg → coverage → mutation
1515

scripts/lint-pkg.mjs

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,31 @@
11
#!/usr/bin/env node
2-
// Gate 6 (lint:pkg) enforcer — treats publint suggestions/warnings/errors as fatal.
2+
// Gate 6 (lint:pkg) enforcer — per-manifest publish-readiness assertions.
33
//
4-
// publint 0.3.18 CLI does not expose a flag to fail on suggestions (--strict
5-
// only promotes warnings → errors). This wrapper fills that gap: it runs
6-
// publint per workspace, captures stdout, and fails the gate if any package
7-
// emits a "Suggestions:", "Warnings:", or "Errors:" block. attw --pack runs
8-
// after publint per package and preserves its own exit code.
4+
// 1. publint + attw — treats publint suggestions/warnings/errors as fatal.
5+
// publint 0.3.18 CLI does not expose a flag to fail on suggestions
6+
// (--strict only promotes warnings → errors). This wrapper fills that gap:
7+
// it runs publint per workspace, captures stdout, and fails the gate if any
8+
// package emits a "Suggestions:", "Warnings:", or "Errors:" block.
9+
// attw --pack runs after publint per package and preserves its own exit code.
10+
// Motivated by enforcement queue #33 and the PR #35 regression: publint
11+
// suggestions about the "git+" URL prefix silently re-drifted across 10
12+
// packages because the gate tolerated them.
913
//
10-
// See enforcement queue #33 and the PR #35 regression that motivated this
11-
// tightening: publint suggestions about the "git+" URL prefix silently
12-
// re-drifted across 10 packages because the gate tolerated them.
14+
// 2. engines.node presence — closes enforcement queue #31 (drift-prevention
15+
// gate, deployed 2026-05-12). Every workspace package.json AND the root
16+
// package.json must declare a non-empty `engines.node` string. Value is NOT
17+
// validated (presence-only — the queue-31 target is "any new package added
18+
// to the Armory ships with the declaration"; value alignment across the
19+
// corpus is a separate doctrine question). The declarations themselves
20+
// landed 2026-04-22 via commit 0605d99 — this gate prevents regression on
21+
// new packages and on edits that strip the field.
1322

1423
import {spawnSync} from 'node:child_process';
1524
import {readdirSync, readFileSync, statSync} from 'node:fs';
1625
import {join} from 'node:path';
1726

1827
const PACKAGES_DIR = 'packages';
28+
const ROOT_MANIFEST = 'package.json';
1929
const PUBLINT_BLOCK_RE = /^(Suggestions|Warnings|Errors):$/m;
2030

2131
function listPackageDirs() {
@@ -31,9 +41,24 @@ function listPackageDirs() {
3141
.sort();
3242
}
3343

44+
function readManifest(manifestPath) {
45+
return JSON.parse(readFileSync(manifestPath, 'utf8'));
46+
}
47+
3448
function packageName(dir) {
35-
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
36-
return pkg.name ?? dir;
49+
return readManifest(join(dir, 'package.json')).name ?? dir;
50+
}
51+
52+
function checkEnginesNode(manifestPath, label) {
53+
const pkg = readManifest(manifestPath);
54+
if (pkg.engines === undefined || pkg.engines === null) {
55+
return `${label}: engines field missing (queue #31 — engines.node presence required)`;
56+
}
57+
const node = pkg.engines.node;
58+
if (typeof node !== 'string' || node.trim() === '') {
59+
return `${label}: engines.node missing or not a non-empty string (queue #31)`;
60+
}
61+
return null;
3762
}
3863

3964
function runCaptured(cmd, args, cwd) {
@@ -49,10 +74,27 @@ function main() {
4974
const dirs = listPackageDirs();
5075
const failures = [];
5176

77+
// Root manifest engines.node presence check (queue #31). Root is not in
78+
// packages/*, so it gets a dedicated assertion before the per-package loop.
79+
process.stdout.write(`\n--- lint:pkg engines.node (root ${ROOT_MANIFEST}) ---\n`);
80+
const rootFailure = checkEnginesNode(ROOT_MANIFEST, 'workspace-root');
81+
if (rootFailure) {
82+
failures.push(rootFailure);
83+
process.stderr.write(` ${rootFailure}\n`);
84+
} else {
85+
process.stdout.write(` workspace-root: engines.node OK\n`);
86+
}
87+
5288
for (const dir of dirs) {
5389
const name = packageName(dir);
5490
process.stdout.write(`\n--- lint:pkg ${name} (${dir}) ---\n`);
5591

92+
const enginesFailure = checkEnginesNode(join(dir, 'package.json'), name);
93+
if (enginesFailure) {
94+
failures.push(enginesFailure);
95+
process.stderr.write(` ${enginesFailure}\n`);
96+
}
97+
5698
const publint = runCaptured('npx', ['publint', 'run'], dir);
5799
const publintBlock = PUBLINT_BLOCK_RE.exec(publint.stdout);
58100
if (publint.status !== 0) {
@@ -76,7 +118,7 @@ function main() {
76118
}
77119

78120
process.stdout.write(
79-
`\nlint:pkg gate PASS — ${dirs.length} packages clean (publint suggestions/warnings/errors all treated as fatal).\n`,
121+
`\nlint:pkg gate PASS — ${dirs.length} packages + root clean (engines.node present; publint suggestions/warnings/errors all treated as fatal).\n`,
80122
);
81123
}
82124

0 commit comments

Comments
 (0)