From 64734d158908d91de682b8faf7392c91af56a59a Mon Sep 17 00:00:00 2001 From: bodurkalukasz Date: Tue, 4 Aug 2026 19:58:12 +0200 Subject: [PATCH 1/5] fix(docs): stack-level exceptions have nowhere that reads them yet ADAPTING.md told an adopter to "record the exception" for an entry that does not fit, without saying where - and the obvious place, this stack's own stack.manifest.json, is exactly the file self-verify.mjs (core repo) never reads exceptions from: it merges files/sections/guards from the stack manifest but not its exceptions array, so a recorded deviation there is silently never checked, forever. Names the actual workaround until that core-repo gap closes: record the exception in standard.manifest.json instead, in the shape self-verify.mjs already reads (kind/match/reason). The merge fix itself is out of scope here - it belongs in the core repo's self-verify.mjs. --- ADAPTING.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/ADAPTING.md b/ADAPTING.md index 9809336..923d25c 100644 --- a/ADAPTING.md +++ b/ADAPTING.md @@ -32,6 +32,21 @@ what you took, not what you skipped. And the order matters more than the speed - is written as a move that leaves the build green, because a migration that goes red in the middle is a migration that gets reverted. +**Where to record the exception, today:** in `standard.manifest.json`'s own `exceptions` +array - not in this stack's `stack.manifest.json`, even though that is the file this table +talks about. `self-verify.mjs` (core repo) merges this stack's `files`/`sections`/`guards` +into the check it runs, but not its `exceptions` array, so an exception written into +`stack.manifest.json` is silently never read - a core-repo gap, tracked there, not fixed by +wording it differently here. Until that lands, add the entry to the standard manifest +instead, matching the shape `self-verify.mjs` already reads: + +```json +{ "kind": "file", "match": "pnpm-workspace.yaml", "reason": "npm workspace kept - no cooldown equivalent, see ADAPTING.md" } +``` + +`kind` is `"file"`, `"section"`, or `"guard"`; `match` is the path exactly as this stack's +own entry above spells it (or `file#heading` for a section, or the guard `id`). + | Entry | The repo probably has | The move | |---|---|---| | `biome.json` | ESLint + Prettier | Two-step, never big-bang: install Biome alongside, port rule intents (`biome migrate eslint --write` gets most), run both until the diff stabilizes, then remove ESLint/Prettier in their own PR. Keep Prettier only if SCSS stays (Biome does not format SCSS - that is the pick's own escape hatch). | From c366e7f40021ef42d2bc717888bcfd2b91ebbeac Mon Sep 17 00:00:00 2001 From: bodurkalukasz Date: Tue, 4 Aug 2026 19:58:43 +0200 Subject: [PATCH 2/5] fix(docs): pnpm import has no path from bun or Deno, and yarn 4 has two silent gaps ADAPTING.md's only migration move for pnpm-workspace.yaml was "pnpm import converts the existing lockfile", stated as if it held for any source tool. Reproduced against two real repos this week: bun fails immediately (ERR_PNPM_LOCKFILE_NOT_FOUND - bun.lock/bun.lockb are not a supported input at all), and yarn 4 (Berry) fails outright while packageManager still names yarn ("This project is configured to use yarn"). Adds the by-source-tool table: npm and yarn classic are the only inputs pnpm import actually documents; yarn Berry works once packageManager is dropped, but silently ignores any `resolutions` override (re-create it as pnpm.overrides by hand); bun and Deno have no importer path at all - delete the lockfile and run a fresh pnpm install, then hand-repin from the deleted lockfile since package.json ranges resolve fresh rather than to what the old tool had pinned. The historical yarn ERR_PNPM_INVALID_OVERRIDE_SELECTOR crash on a nested-path selector did not reproduce against this stack's pinned pnpm (11.1.2) in testing - noted as fixed upstream, with the caveat that hitting it anyway means a different pnpm is the one actually running. Deno's row is by inspection of pnpm import's supported-input list, not independently verified against a live Deno repo - stated as such. --- ADAPTING.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/ADAPTING.md b/ADAPTING.md index 923d25c..a5c2c27 100644 --- a/ADAPTING.md +++ b/ADAPTING.md @@ -50,7 +50,7 @@ own entry above spells it (or `file#heading` for a section, or the guard `id`). | Entry | The repo probably has | The move | |---|---|---| | `biome.json` | ESLint + Prettier | Two-step, never big-bang: install Biome alongside, port rule intents (`biome migrate eslint --write` gets most), run both until the diff stabilizes, then remove ESLint/Prettier in their own PR. Keep Prettier only if SCSS stays (Biome does not format SCSS - that is the pick's own escape hatch). | -| `pnpm-workspace.yaml` | npm or yarn, maybe no workspace | `pnpm import` converts the existing lockfile; workspaces map 1:1. The policy block (release-age cooldown, allowed build scripts) is the point of the entry - merge it even if the workspace globs differ. On npm/yarn WITHOUT migration appetite: record the exception; the cooldown has no npm/yarn equivalent, name that trade-off. | +| `pnpm-workspace.yaml` | npm, yarn, bun, or Deno, maybe no workspace | `pnpm import` converts the existing lockfile where that source tool is actually supported - see the by-source-tool breakdown below, it is not all four. Workspaces map 1:1. The policy block (release-age cooldown, allowed build scripts) is the point of the entry - merge it even if the workspace globs differ. Without a migration path, or without migration appetite: record the exception (see above); the cooldown has no equivalent outside pnpm's own config, name that trade-off. | | `tsconfig.base.json` | a looser tsconfig | Stage strictness: `strict` first, then `noUncheckedIndexedAccess` and friends one flag per PR - each flag surfaces real findings; a hundred errors at once teaches nothing. Extend, do not replace: their paths/aliases stay theirs. | | `vitest.config.ts` | Jest, or tests mixed in one pile | Vitest reads most Jest suites as-is (`vitest run` first, fix the stragglers). The entry's real content is the tier split - unit vs integration as separate projects; introduce the naming convention (`*.test.ts` / `*.integration.test.ts`) before touching any test body. | | `playwright.config.ts` | Cypress, or nothing | Nothing: start with the starter's two journeys (auth, home) adapted to their routes. Cypress: do not port wholesale - write the 3-5 journeys that matter in Playwright, retire Cypress when they cover the old suite's intent. | @@ -62,6 +62,21 @@ own entry above spells it (or `file#heading` for a section, or the guard `id`). | `.github/workflows/e2e.yml` | nothing, or e2e mixed into the main pipeline | Land it as its own workflow even if their e2e currently rides the fast gate: this tier boots a Docker stack and the app, so mixing it in makes every commit pay for a check most commits do not need. It is the one you gate a release on. | | `.nvmrc` | none, or an older pin | Copy; if their runtime is older, the Node major bump is its own migration - route it through the modernize pass in the core's adoption guide, not through this phase. | +## `pnpm import`, by source package manager + +The row above says `pnpm import` "converts the existing lockfile" as if that holds for +whatever the repo showed up with. It does not. Tested this week against two real +brownfield repos, both against this stack's pinned pnpm (11.1.2 - if a different `pnpm` +is the one actually on PATH, that mismatch is the first thing to check, see the yarn row): + +| From | Command | What actually happens | +|---|---|---| +| npm (`package-lock.json`) | `pnpm import` | works - documented, supported input. | +| yarn classic (`yarn.lock` v1) | `pnpm import` | works - documented, supported input. | +| yarn Berry / yarn 4 (`yarn.lock` v2+) | drop `packageManager: "yarn@..."` from `package.json` first, then `pnpm import` | pnpm refuses to run at all ("This project is configured to use yarn") while that field still names yarn - dropping it is the adoption anyway, not a workaround. Once dropped, the dependency graph itself imports fine. Two real gaps, not blockers: a yarn `resolutions` override is silently dropped - `pnpm import` never reads that field, so re-create anything load-bearing as `pnpm.overrides` by hand and verify the pinned version still resolves. Older pnpm (7.x) crashed on a nested-path override selector (`pkg/**/pkg`, `ERR_PNPM_INVALID_OVERRIDE_SELECTOR`) migrating such an override; retested against 11.1.2 here and it no longer reproduces - if it still happens, the `pnpm` actually running is not the one this stack pins. | +| bun (`bun.lock` / `bun.lockb`) | no importer path - `pnpm import --help` lists only `package-lock.json` / `npm-shrinkwrap.json` / `yarn.lock` | fails immediately, `ERR_PNPM_LOCKFILE_NOT_FOUND` (reproduced). The real move: delete `bun.lock`/`bun.lockb`, run a fresh `pnpm install`. Every dependency re-resolves to whatever `package.json` allows rather than what bun had pinned - if the ranges there are not already exact (common outside this stack's `saveExact` policy, DECISIONS#8), diff the deleted `bun.lock` against the new `pnpm-lock.yaml` before merging and hand-repin anything that moved. | +| Deno (`deno.lock`) | no importer path, same supported-input list as bun | same real move as bun: delete `deno.lock`, fresh `pnpm install`, hand-repin from the deleted lockfile. Not tested against a live Deno repo this round (the two repos tested were bun and yarn) - treat this row as the same failure mode by inspection of `pnpm import`'s supported-input list, not as independently verified. | + ## The app shell - picks, not table rows The manifest's config entries migrate via the table above. The app-shell picks From 40302b4699735dbffe675853bb022f0f8e791457 Mon Sep 17 00:00:00 2001 From: bodurkalukasz Date: Tue, 4 Aug 2026 20:00:50 +0200 Subject: [PATCH 3/5] fix(manifest): a merge-class file entry checks the filename, never the content Deleting pnpm-workspace.yaml's entire supply-chain policy block (minimumReleaseAge/saveExact/enablePrePostScripts, all becoming undefined) still reported compliant, because stack.manifest.json's file entry for that path only asserts the path exists - a merge-class entry has no content check, by design, and this is the one entry where the merged-in content is the whole point (DECISIONS#8). The starter's own CI already knows how to check these three keys (starter-boot.yml, via pnpm config get) - self-verify did not. Adds scripts/check-pnpm-workspace-policy.mjs: reads the three keys directly off pnpm-workspace.yaml with no YAML dependency (they are always top-level scalars in the reference copy) and no pnpm invocation - so it runs the same whether or not pnpm is even on PATH. Wired in as both a shipped file entry and a guard in stack.manifest.json, and listed in SHIPPED.md's checked table. Tested against the real starter file (passes) and against a stripped and a weakened copy (both fail with the specific key and expectation). Core is independently adding a generic required-keys mechanism for merge-class entries (tracked there, not landed as of this change) - once that ships, this custom guard should fold into it rather than staying a one-off; noted so the two don't drift apart. --- SHIPPED.md | 1 + stack.manifest.json | 17 +++++ .../scripts/check-pnpm-workspace-policy.mjs | 74 +++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 starter/scripts/check-pnpm-workspace-policy.mjs diff --git a/SHIPPED.md b/SHIPPED.md index 310bffe..4e867b2 100644 --- a/SHIPPED.md +++ b/SHIPPED.md @@ -32,6 +32,7 @@ number across both. These are what it runs here: | Runs | Proves | |---|---| | `pnpm check:all` | format + types + lint green - the stack's own quality gate counted in the same drift number | +| `node scripts/check-pnpm-workspace-policy.mjs` | the supply-chain policy block in `pnpm-workspace.yaml` (release-age cooldown, save-exact, no unreviewed lifecycle scripts) survived the merge, not just the file - reads the three keys directly, no pnpm on PATH required | ## What is not here diff --git a/stack.manifest.json b/stack.manifest.json index ee1fc2b..c6adebf 100644 --- a/stack.manifest.json +++ b/stack.manifest.json @@ -37,6 +37,14 @@ "profile": "core", "rule": "DECISIONS#8" }, + { + "path": "scripts/check-pnpm-workspace-policy.mjs", + "purpose": "the content check the file entry above can't do on its own - a merge-class file entry only proves pnpm-workspace.yaml exists, not that its policy block survived the merge", + "adapt": "copy", + "required": true, + "profile": "core", + "rule": "DECISIONS#8" + }, { "path": "vitest.config.ts", "purpose": "unit and integration tiers as separate Vitest projects", @@ -111,6 +119,15 @@ "purpose": "format + types + lint green - the stack's own quality gate counted in the same drift number", "profile": "core", "rule": "DECISIONS#3" + }, + { + "id": "pnpm-workspace-policy", + "run": "node scripts/check-pnpm-workspace-policy.mjs", + "kind": "static", + "blocks": true, + "purpose": "the supply-chain policy block in pnpm-workspace.yaml is present and not weakened - reads the file directly, no pnpm invocation, no network, no install", + "profile": "core", + "rule": "DECISIONS#8" } ] } diff --git a/starter/scripts/check-pnpm-workspace-policy.mjs b/starter/scripts/check-pnpm-workspace-policy.mjs new file mode 100644 index 0000000..e2ee4b2 --- /dev/null +++ b/starter/scripts/check-pnpm-workspace-policy.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// check-pnpm-workspace-policy - the supply-chain policy block in pnpm-workspace.yaml +// (DECISIONS#8) is the point of that manifest entry, not the workspace globs around it. +// stack.manifest.json's "files" entry for pnpm-workspace.yaml only asserts the path +// exists - a merge-class file check has no content awareness, so deleting the entire +// policy block (minimumReleaseAge/saveExact/enablePrePostScripts, all becoming +// undefined) still reports compliant. +// +// This guard reads the three keys directly instead of shelling out to pnpm: no pnpm on +// PATH, no install, no network - it runs the same on a repo that has never seen +// `pnpm install`. It is deliberately not a YAML parser: these three keys are always +// top-level scalars in the reference copy, so a line-anchored read covers them without +// a dependency. +// +// Usage: node scripts/check-pnpm-workspace-policy.mjs [path] # default pnpm-workspace.yaml +// +// No dependencies (Node built-ins only). Place at scripts/check-pnpm-workspace-policy.mjs. + +import { existsSync, readFileSync } from "node:fs"; + +const path = process.argv[2] || "pnpm-workspace.yaml"; + +if (!existsSync(path)) { + console.error(`check-pnpm-workspace-policy: ${path} not found`); + process.exit(1); +} + +const body = readFileSync(path, "utf8"); + +// Top-level only ("^key:") on purpose - these three keys are never nested, and +// anchoring at column 0 keeps a commented-out example ("# saveExact: true") from +// counting as present. +const readKey = (name) => { + const m = body.match(new RegExp(`^${name}:\\s*(.+?)\\s*(#.*)?$`, "m")); + return m ? m[1].trim().replace(/^['"]|['"]$/g, "") : undefined; +}; + +const failures = []; +const require = (name, value, ok, why) => { + if (!ok) failures.push(`${name}: ${value === undefined ? "missing" : `"${value}"`} - ${why}`); +}; + +const enablePrePostScripts = readKey("enablePrePostScripts"); +require( + "enablePrePostScripts", + enablePrePostScripts, + enablePrePostScripts === "false", + 'must be exactly "false" (DECISIONS#8 - no unreviewed lifecycle script runs on install)', +); + +const saveExact = readKey("saveExact"); +require( + "saveExact", + saveExact, + saveExact === "true", + 'must be exactly "true" (DECISIONS#8 - no ^/~ ranges added by pnpm on install)', +); + +const minimumReleaseAge = readKey("minimumReleaseAge"); +const ageMinutes = Number(minimumReleaseAge); +require( + "minimumReleaseAge", + minimumReleaseAge, + minimumReleaseAge !== undefined && Number.isFinite(ageMinutes) && ageMinutes >= 10080, + "must be a number >= 10080 minutes / 7 days (DECISIONS#8 - a floor against fresh-release supply-chain attacks, never lowered globally)", +); + +if (failures.length) { + console.error(`check-pnpm-workspace-policy: FAIL - the supply-chain policy block in ${path} is missing or weakened\n`); + for (const f of failures) console.error(` - ${f}`); + process.exit(1); +} + +console.log(`check-pnpm-workspace-policy: OK - ${path} carries the supply-chain policy (DECISIONS#8)`); From 37f0ce0624ad68a623472b17edea46acdfc52499 Mon Sep 17 00:00:00 2001 From: bodurkalukasz Date: Tue, 4 Aug 2026 20:01:42 +0200 Subject: [PATCH 4/5] fix(guard): missing pnpm reads as a lint failure, not a missing prerequisite Carrying stack.manifest.json means self-verify's only compliance signal for format/types/lint is shelling out to pnpm check:all. That check genuinely cannot be replaced by reading config files - it runs the actual toolchain - so pnpm on PATH plus a completed pnpm install (the full tree, network, no smaller alternative) is a real prerequisite, not something fixable from this side. What was fixable: with pnpm absent, the guard failed with a bare shell "command not found", indistinguishable in the drift report from a real lint failure on a compliant repo. The guard's run command now checks for pnpm first and says so explicitly before failing. SHIPPED.md documents the prerequisite next to the check it belongs to, and points at core's docs/method/prerequisites.md, which lists every guard's generic needs (Node, git, bash, jq) but had no Node-stack entry for this one. Also lists scripts/check-pnpm-workspace-policy.mjs (added for the supply-chain-policy content check) in SHIPPED.md's own "what ships" table - missed when that guard landed. --- SHIPPED.md | 12 ++++++++++++ stack.manifest.json | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/SHIPPED.md b/SHIPPED.md index 4e867b2..9e2be2a 100644 --- a/SHIPPED.md +++ b/SHIPPED.md @@ -20,6 +20,7 @@ your job names, your paths stay yours. | `docker-compose.test.yml` | ephemeral real dependencies for integration/e2e - not mocks | copy as-is · optional | | `perf/` | local Lighthouse perf-budget runner - report-only, committed baseline + dated history, no external upload; edit config.mjs routes | copy as-is · optional | | `scripts/perf-budget.sh` | local perf env prep (seed/build/start) - copy, then adapt its SEED/BUILD/START hooks to your stack | copy as-is · optional | +| `scripts/check-pnpm-workspace-policy.mjs` | reads pnpm-workspace.yaml's supply-chain policy keys directly - the content check the file entry above can't do on its own | copy as-is · required | | `renovate.json` | dependency updates as reviewed diffs, riding the same 7-day cooldown | merge into yours · optional | | `.github/workflows/ci.yml` | hardened least-privilege CI template | merge into yours · optional | | `.github/workflows/e2e.yml` | the e2e tier in CI, against the docker test stack | merge into yours · optional | @@ -34,6 +35,17 @@ number across both. These are what it runs here: | `pnpm check:all` | format + types + lint green - the stack's own quality gate counted in the same drift number | | `node scripts/check-pnpm-workspace-policy.mjs` | the supply-chain policy block in `pnpm-workspace.yaml` (release-age cooldown, save-exact, no unreviewed lifecycle scripts) survived the merge, not just the file - reads the three keys directly, no pnpm on PATH required | +Only `check:all` needs pnpm on PATH, and needs more than that to pass rather than fail +opaquely: this repo's dependencies already installed (`pnpm install` - the full tree, no +partial shortcut, network required at least once). It is a real toolchain run - format, +types, lint against the actual code - not something a config-file read could substitute +for, unlike the guard above it. Without pnpm on PATH the guard now says so explicitly +("pnpm not on PATH...") rather than failing with a bare shell `command not found` that +reads identically to a real lint failure in the drift report. Layer 1's own prerequisites +page (`docs/method/prerequisites.md` in the core repo) lists what every guard needs +generically (Node, git, bash, jq); this is this stack's addition to that same table, and +nothing here names it yet. + ## What is not here No Dockerfile and no deploy target. That is the least portable decision a repository makes, diff --git a/stack.manifest.json b/stack.manifest.json index c6adebf..6d989c7 100644 --- a/stack.manifest.json +++ b/stack.manifest.json @@ -113,10 +113,10 @@ "guards": [ { "id": "stack-check-all", - "run": "pnpm check:all", + "run": "command -v pnpm >/dev/null 2>&1 || { echo \"pnpm not on PATH - required for check:all (also needs pnpm install once, ~670MB/network - see SHIPPED.md, What gets checked)\" >&2; exit 1; }; pnpm check:all", "kind": "static", "blocks": true, - "purpose": "format + types + lint green - the stack's own quality gate counted in the same drift number", + "purpose": "format + types + lint green - the stack's own quality gate counted in the same drift number. Needs pnpm on PATH and dependencies already installed - a real toolchain run, not something a config-file read can substitute for; see SHIPPED.md for the prerequisite", "profile": "core", "rule": "DECISIONS#3" }, From f1a22c2d3878f04505da282a539a5b10380263fa Mon Sep 17 00:00:00 2001 From: bodurkalukasz Date: Tue, 4 Aug 2026 21:29:39 +0200 Subject: [PATCH 5/5] refactor: use core's generic requiredKeys instead of a custom guard core's self-verify.mjs now checks specific keys inside a merge-class file directly (repository-standards/core PR #16), the same mechanism this stack's custom scripts/check-pnpm-workspace-policy.mjs was built to work around before that landed. Declaring requiredKeys on the pnpm-workspace.yaml manifest entry does the same check with one fewer file to ship and maintain, and the manifest-side workaround note in ADAPTING.md for the exceptions-merge gap (repository-standards/core PR #17) is no longer needed either - a stack-level exception is honoured directly now. Verified end to end against the real starter file: the three keys pass when present and each fails by name, individually, when removed. --- ADAPTING.md | 18 ++--- SHIPPED.md | 3 +- stack.manifest.json | 20 +---- .../scripts/check-pnpm-workspace-policy.mjs | 74 ------------------- 4 files changed, 12 insertions(+), 103 deletions(-) delete mode 100644 starter/scripts/check-pnpm-workspace-policy.mjs diff --git a/ADAPTING.md b/ADAPTING.md index a5c2c27..9693460 100644 --- a/ADAPTING.md +++ b/ADAPTING.md @@ -32,20 +32,20 @@ what you took, not what you skipped. And the order matters more than the speed - is written as a move that leaves the build green, because a migration that goes red in the middle is a migration that gets reverted. -**Where to record the exception, today:** in `standard.manifest.json`'s own `exceptions` -array - not in this stack's `stack.manifest.json`, even though that is the file this table -talks about. `self-verify.mjs` (core repo) merges this stack's `files`/`sections`/`guards` -into the check it runs, but not its `exceptions` array, so an exception written into -`stack.manifest.json` is silently never read - a core-repo gap, tracked there, not fixed by -wording it differently here. Until that lands, add the entry to the standard manifest -instead, matching the shape `self-verify.mjs` already reads: +**Where to record the exception:** in this stack's own `stack.manifest.json`, in its +`exceptions` array - the file this table talks about. `self-verify.mjs` (core repo) merges +this stack's `files`/`sections`/`guards` and `exceptions` into the check it runs, so an +entry recorded here is honoured exactly like a core-manifest one: ```json { "kind": "file", "match": "pnpm-workspace.yaml", "reason": "npm workspace kept - no cooldown equivalent, see ADAPTING.md" } ``` -`kind` is `"file"`, `"section"`, or `"guard"`; `match` is the path exactly as this stack's -own entry above spells it (or `file#heading` for a section, or the guard `id`). +`kind` is `"file"`, `"section"`, `"content"`, or `"key"`; `match` is the path exactly as +this stack's own entry above spells it (`file#heading` for a section, `file#key.path` for +a declared key). A guard script itself cannot be excepted by presence - that would delete +the check rather than waive it; a `kind: "content"` exception on a guard is allowed, since +the guard still runs and must pass. | Entry | The repo probably has | The move | |---|---|---| diff --git a/SHIPPED.md b/SHIPPED.md index 9e2be2a..8824c95 100644 --- a/SHIPPED.md +++ b/SHIPPED.md @@ -20,7 +20,6 @@ your job names, your paths stay yours. | `docker-compose.test.yml` | ephemeral real dependencies for integration/e2e - not mocks | copy as-is · optional | | `perf/` | local Lighthouse perf-budget runner - report-only, committed baseline + dated history, no external upload; edit config.mjs routes | copy as-is · optional | | `scripts/perf-budget.sh` | local perf env prep (seed/build/start) - copy, then adapt its SEED/BUILD/START hooks to your stack | copy as-is · optional | -| `scripts/check-pnpm-workspace-policy.mjs` | reads pnpm-workspace.yaml's supply-chain policy keys directly - the content check the file entry above can't do on its own | copy as-is · required | | `renovate.json` | dependency updates as reviewed diffs, riding the same 7-day cooldown | merge into yours · optional | | `.github/workflows/ci.yml` | hardened least-privilege CI template | merge into yours · optional | | `.github/workflows/e2e.yml` | the e2e tier in CI, against the docker test stack | merge into yours · optional | @@ -33,7 +32,7 @@ number across both. These are what it runs here: | Runs | Proves | |---|---| | `pnpm check:all` | format + types + lint green - the stack's own quality gate counted in the same drift number | -| `node scripts/check-pnpm-workspace-policy.mjs` | the supply-chain policy block in `pnpm-workspace.yaml` (release-age cooldown, save-exact, no unreviewed lifecycle scripts) survived the merge, not just the file - reads the three keys directly, no pnpm on PATH required | +| the manifest's `requiredKeys` on `pnpm-workspace.yaml` | the supply-chain policy block (release-age cooldown, save-exact, no unreviewed lifecycle scripts) survived the merge, not just the file - the engine reads the three keys directly, no pnpm on PATH required | Only `check:all` needs pnpm on PATH, and needs more than that to pass rather than fail opaquely: this repo's dependencies already installed (`pnpm install` - the full tree, no diff --git a/stack.manifest.json b/stack.manifest.json index 6d989c7..eb111da 100644 --- a/stack.manifest.json +++ b/stack.manifest.json @@ -35,15 +35,8 @@ "adapt": "merge", "required": true, "profile": "core", - "rule": "DECISIONS#8" - }, - { - "path": "scripts/check-pnpm-workspace-policy.mjs", - "purpose": "the content check the file entry above can't do on its own - a merge-class file entry only proves pnpm-workspace.yaml exists, not that its policy block survived the merge", - "adapt": "copy", - "required": true, - "profile": "core", - "rule": "DECISIONS#8" + "rule": "DECISIONS#8", + "requiredKeys": ["minimumReleaseAge", "saveExact", "enablePrePostScripts"] }, { "path": "vitest.config.ts", @@ -119,15 +112,6 @@ "purpose": "format + types + lint green - the stack's own quality gate counted in the same drift number. Needs pnpm on PATH and dependencies already installed - a real toolchain run, not something a config-file read can substitute for; see SHIPPED.md for the prerequisite", "profile": "core", "rule": "DECISIONS#3" - }, - { - "id": "pnpm-workspace-policy", - "run": "node scripts/check-pnpm-workspace-policy.mjs", - "kind": "static", - "blocks": true, - "purpose": "the supply-chain policy block in pnpm-workspace.yaml is present and not weakened - reads the file directly, no pnpm invocation, no network, no install", - "profile": "core", - "rule": "DECISIONS#8" } ] } diff --git a/starter/scripts/check-pnpm-workspace-policy.mjs b/starter/scripts/check-pnpm-workspace-policy.mjs deleted file mode 100644 index e2ee4b2..0000000 --- a/starter/scripts/check-pnpm-workspace-policy.mjs +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env node -// check-pnpm-workspace-policy - the supply-chain policy block in pnpm-workspace.yaml -// (DECISIONS#8) is the point of that manifest entry, not the workspace globs around it. -// stack.manifest.json's "files" entry for pnpm-workspace.yaml only asserts the path -// exists - a merge-class file check has no content awareness, so deleting the entire -// policy block (minimumReleaseAge/saveExact/enablePrePostScripts, all becoming -// undefined) still reports compliant. -// -// This guard reads the three keys directly instead of shelling out to pnpm: no pnpm on -// PATH, no install, no network - it runs the same on a repo that has never seen -// `pnpm install`. It is deliberately not a YAML parser: these three keys are always -// top-level scalars in the reference copy, so a line-anchored read covers them without -// a dependency. -// -// Usage: node scripts/check-pnpm-workspace-policy.mjs [path] # default pnpm-workspace.yaml -// -// No dependencies (Node built-ins only). Place at scripts/check-pnpm-workspace-policy.mjs. - -import { existsSync, readFileSync } from "node:fs"; - -const path = process.argv[2] || "pnpm-workspace.yaml"; - -if (!existsSync(path)) { - console.error(`check-pnpm-workspace-policy: ${path} not found`); - process.exit(1); -} - -const body = readFileSync(path, "utf8"); - -// Top-level only ("^key:") on purpose - these three keys are never nested, and -// anchoring at column 0 keeps a commented-out example ("# saveExact: true") from -// counting as present. -const readKey = (name) => { - const m = body.match(new RegExp(`^${name}:\\s*(.+?)\\s*(#.*)?$`, "m")); - return m ? m[1].trim().replace(/^['"]|['"]$/g, "") : undefined; -}; - -const failures = []; -const require = (name, value, ok, why) => { - if (!ok) failures.push(`${name}: ${value === undefined ? "missing" : `"${value}"`} - ${why}`); -}; - -const enablePrePostScripts = readKey("enablePrePostScripts"); -require( - "enablePrePostScripts", - enablePrePostScripts, - enablePrePostScripts === "false", - 'must be exactly "false" (DECISIONS#8 - no unreviewed lifecycle script runs on install)', -); - -const saveExact = readKey("saveExact"); -require( - "saveExact", - saveExact, - saveExact === "true", - 'must be exactly "true" (DECISIONS#8 - no ^/~ ranges added by pnpm on install)', -); - -const minimumReleaseAge = readKey("minimumReleaseAge"); -const ageMinutes = Number(minimumReleaseAge); -require( - "minimumReleaseAge", - minimumReleaseAge, - minimumReleaseAge !== undefined && Number.isFinite(ageMinutes) && ageMinutes >= 10080, - "must be a number >= 10080 minutes / 7 days (DECISIONS#8 - a floor against fresh-release supply-chain attacks, never lowered globally)", -); - -if (failures.length) { - console.error(`check-pnpm-workspace-policy: FAIL - the supply-chain policy block in ${path} is missing or weakened\n`); - for (const f of failures) console.error(` - ${f}`); - process.exit(1); -} - -console.log(`check-pnpm-workspace-policy: OK - ${path} carries the supply-chain policy (DECISIONS#8)`);