diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c2f0d5c7ac..45fdcc2216 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2110,6 +2110,37 @@ jobs: - name: Read-side tenant chokepoint gate run: pnpm check:tenant-chokepoint + # Every committed `pnpm --filter ` must name a real workspace + # package (#10853). + # + # ⛔ `pnpm --filter ` EXITS 0 when the filter matches nothing: + # + # $ pnpm --filter @objectstack/definitely-not-a-package test; echo $? + # No projects matched the filters in "..." + # 0 + # + # So a step here, a package.json script or a `scripts/**` helper can name + # a package that does not exist and stay GREEN forever, measuring nothing + # and reporting success. `cmd > log 2>&1; ec=$?` captures the 0 perfectly; + # the log even says what happened and nothing reads it. Same family as the + # `--`-before-vitest-args trap: exit 0, nothing measured, output that reads + # like success — #4690, sitting underneath the command used to PROVE + # results. + # + # This gate reads SPELLINGS THAT ARE CHECKED IN, and that is the whole of + # its claim. It cannot see a filter an agent types at a prompt, which is + # the population that actually bit; that half is covered, only for callers + # of the shared verify lock, by `scripts/pm/os-verify-lock.sh`'s filter + # preflight. Disjoint populations, neither a substitute for the other. + # + # Reads package.json, pnpm-workspace.yaml and text under `scripts/**` and + # `.github/workflows/**`. No build, no pnpm invocation, no network, + # sub-second. The `check:` key runs the shared resolver's --self-test + # first, then the gate's, then the gate: a checker whose resolver is + # broken has no verdict worth printing. + - name: Every committed pnpm --filter names a real package + run: pnpm check:pnpm-filter-targets + # Lane 1 of 4 behind the required `TypeScript Type Check` context. The # aggregator job at the bottom of this file explains the split, holds the # contract, and is the thing the merge queue actually requires — read it diff --git a/package.json b/package.json index a003b2dbca..b85a30c3b5 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "check:pm-governed-prose": "node scripts/pm/check-governed-prose.mjs --self-test && node scripts/pm/check-governed-prose.mjs", "check:partof-closing-keyword": "node scripts/check-partof-closing-keyword.mjs --self-test", "check:single-claim-paths": "node scripts/check-single-claim-paths.mjs --self-test", + "check:pnpm-filter-targets": "node scripts/pnpm-filter-targets.mjs --self-test && node scripts/check-pnpm-filter-targets.mjs --self-test && node scripts/check-pnpm-filter-targets.mjs", "check:adr-anchors": "node scripts/check-adr-anchors.mjs --self-test && node scripts/check-adr-anchors.mjs", "check:adr-links": "node scripts/check-adr-links.mjs --self-test && node scripts/check-adr-links.mjs", "check:platform-checklist": "node scripts/checklist-select.mjs --self-test && node scripts/check-platform-checklist.mjs", diff --git a/scripts/check-pnpm-filter-targets.mjs b/scripts/check-pnpm-filter-targets.mjs new file mode 100644 index 0000000000..43a1c6bfd0 --- /dev/null +++ b/scripts/check-pnpm-filter-targets.mjs @@ -0,0 +1,476 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-pnpm-filter-targets (#10853) -- every `pnpm --filter ` this repo + * COMMITS must name a package this workspace actually has. + * + * node scripts/check-pnpm-filter-targets.mjs # the gate + * node scripts/check-pnpm-filter-targets.mjs --list # the census it judged + * node scripts/check-pnpm-filter-targets.mjs --self-test # prove it can go red + * + * ## The defect + * + * `pnpm --filter ` EXITS 0 when the filter matches nothing: + * + * $ pnpm --filter @objectstack/definitely-not-a-package test; echo $? + * No projects matched the filters in "/home/user/objectstack" + * 0 + * + * So a workflow step, a `package.json` script or a `scripts/**` helper can name + * a package that does not exist and stay GREEN forever -- measuring nothing, + * reporting success. `cmd > log 2>&1; ec=$?` captures 0 faithfully; the log + * even says what happened, and nothing reads it. The full argument, the + * measured pnpm matching rule and the shared resolver live in + * `scripts/pnpm-filter-targets.mjs`; this file is only the sweep over the + * committed population. + * + * ## The population this gate protects -- and the one it does NOT + * + * ⛔ Stated plainly because overclaiming here would be the same defect the gate + * is about. This gate reads SPELLINGS THAT ARE CHECKED IN. It cannot see a + * filter an agent types at a prompt, and that ad-hoc population is the one that + * actually bit (a dispatch briefing spelled `@objectstack/adapter-hono`, which + * is not a package; the real name is `@objectstack/hono`). The ad-hoc half is + * covered separately and only for callers of the shared verify lock, by + * `scripts/pm/os-verify-lock.sh`'s filter preflight. Neither substitutes for + * the other; they protect disjoint populations, and between them they still + * leave uncovered any ad-hoc command that does not go through the lock. + * + * ## What "judged" means, and why the unjudged count is printed + * + * A selector is judged only when the answer cannot be argued with -- a plain + * package name. Globs, path selectors, since-ref selectors and interpolations + * are refused a verdict BY DESIGN (see the resolver's header), because a wrong + * red here would make this gate the thing that blocks correct work. + * + * That makes silence ambiguous, which is #4690's shape: a scan that judged + * nothing looks exactly like a scan that found nothing wrong. So the green line + * prints how many occurrences were judged, how many were not, and why -- and + * `run()` REFUSES a tree in which the extractor found no `--filter` at all, or + * judged none of the ones it found. Measured when this gate landed: 148 + * occurrences across 25 files, 120 of them judged, 0 dead -- plus 29 in + * comments or step labels and 57 in this rule's own three files, counted and + * not judged. + * + * ## Comments are counted, not judged + * + * Prose is excluded from the verdict: JS comments are masked with + * `js-comment-mask.mjs`, and whole-line `#` comments are dropped from shell and + * YAML. A `--filter` in a comment is usually an illustration, and several are + * deliberately fake (`--filter `). The count is still reported, so the + * excluded population is visible rather than silent -- a comment that teaches a + * package name which does not exist is a real defect, just not this gate's. + * + * ## Foreign workspaces are a declared exemption, not a hole + * + * `scripts/build-console.sh` and `scripts/gen-sdui-manifest.sh` filter + * `@object-ui/console`, which is not in this workspace and never will be -- they + * run those commands inside a checkout of `objectstack-ai/objectui`. That scope + * is declared in the resolver's `FOREIGN_SCOPES`, with the reason. A new + * foreign scope has to be added there deliberately. + */ + +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { isEntrypoint } from './invoked-as.mjs'; +import { maskComments } from './js-comment-mask.mjs'; +import { + extractFilters, + findWorkspaceRoot, + judgeSelector, + listWorkspacePackages, +} from './pnpm-filter-targets.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..'); + +/** + * The three files that OWN this rule. + * + * Every `--filter` they spell outside a comment is a FIXTURE -- a deliberately + * dead name, so the guard can be observed going red. (The lock wrapper's are its + * `--self-test` cases, which drive `echo pnpm --filter @objectstack/adapter-hono` + * through the real entry point.) Judging them would make this gate fail on its + * own negative controls, and deleting the fixtures to appease it would delete + * the only proof either guard can fail at all. + * + * Declared as an exact list and pinned in `--self-test`, so it cannot quietly + * grow into a mute button: a fourth entry fails that assertion. + */ +export const RULE_OWNING_FILES = [ + 'scripts/pnpm-filter-targets.mjs', + 'scripts/check-pnpm-filter-targets.mjs', + 'scripts/pm/os-verify-lock.sh', +]; + +const JS_EXTENSIONS = ['.mjs', '.mts', '.cjs', '.js', '.ts']; +const HASH_COMMENT_EXTENSIONS = ['.sh', '.bash', '.yml', '.yaml']; +const SCANNED_EXTENSIONS = [...JS_EXTENSIONS, ...HASH_COMMENT_EXTENSIONS, '.json']; + +/** + * Blank the regions whose `--filter` spellings are prose rather than commands. + * + * Line numbers are PRESERVED (spans are blanked, never deleted), so a finding + * still points at the line the reader will open. + * + * @param {string} text + * @param {string} file repo-relative path, for the extension + * @returns {{ code: string, commentText: string }} + */ +export function separateComments(text, file) { + const extension = file.slice(file.lastIndexOf('.')); + if (JS_EXTENSIONS.includes(extension)) { + let masked; + try { + masked = maskComments(text); + } catch { + return { code: text, commentText: '' }; + } + // What the mask removed is exactly the comment population. + const commentLines = []; + const original = text.split('\n'); + masked.split('\n').forEach((line, i) => { + if (line !== original[i]) commentLines.push(original[i]); + }); + return { code: masked, commentText: commentLines.join('\n') }; + } + if (HASH_COMMENT_EXTENSIONS.includes(extension)) { + const yaml = extension === '.yml' || extension === '.yaml'; + const code = []; + const comments = []; + for (const line of text.split('\n')) { + // ⚠️ A step `name:` is a LABEL, not a command, and this gate's OWN step + // is what proved it: `- name: Every committed pnpm --filter names a real + // package` reads as `--filter names`, a package that does not exist. The + // same distinction check-required-contexts had to learn (#10877) — prose + // that survives a correct comment stripper because it is not a comment. + if (/^\s*#/.test(line) || (yaml && /^\s*-?\s*name:\s/.test(line))) { + code.push(''); + comments.push(line); + } else { + code.push(line); + } + } + return { code: code.join('\n'), commentText: comments.join('\n') }; + } + return { code: text, commentText: '' }; +} + +/** + * Every file this gate reads: `.github/workflows/`, `scripts/**`, and every + * `package.json` the workspace declares (plus the root one). + * + * @param {string} root + * @returns {string[]} repo-relative posix paths + */ +export function scannedFiles(root) { + const files = []; + const walk = (dir) => { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) { + if (entry.name === 'node_modules' || entry.name === '.git') continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (SCANNED_EXTENSIONS.some((e) => entry.name.endsWith(e))) { + files.push(relative(root, full).split('\\').join('/')); + } + } + }; + walk(join(root, 'scripts')); + walk(join(root, '.github', 'workflows')); + const rootManifest = join(root, 'package.json'); + if (existsSync(rootManifest)) files.push('package.json'); + for (const dir of listWorkspacePackages(root).dirs) { + const manifest = join(dir, 'package.json'); + if (existsSync(manifest)) files.push(relative(root, manifest).split('\\').join('/')); + } + return [...new Set(files)].sort(); +} + +/** + * @typedef {{ file: string, line: number, value: string, judgement: ReturnType }} Occurrence + */ + +/** + * Judge one file's text. + * + * @param {string} text + * @param {string} file + * @param {string[]} names + * @returns {{ occurrences: Occurrence[], commented: number }} + */ +export function scanText(text, file, names) { + const { code, commentText } = separateComments(text, file); + const occurrences = extractFilters(code).map(({ value, line }) => ({ + file, + line, + value, + judgement: judgeSelector(value, names), + })); + return { occurrences, commented: extractFilters(commentText).length }; +} + +/** + * @param {string} root + */ +export function scanRepo(root) { + const { names } = listWorkspacePackages(root); + /** @type {Occurrence[]} */ + const occurrences = []; + let commented = 0; + let filesWithFilters = 0; + let exemptOccurrences = 0; + for (const file of scannedFiles(root)) { + let text; + try { + text = readFileSync(join(root, file), 'utf8'); + } catch { + continue; + } + if (!text.includes('--filter')) continue; + if (RULE_OWNING_FILES.includes(file)) { + exemptOccurrences += extractFilters(text).length; + continue; + } + const out = scanText(text, file, names); + commented += out.commented; + if (out.occurrences.length > 0) filesWithFilters++; + occurrences.push(...out.occurrences); + } + const dead = occurrences.filter((o) => o.judgement.verdict === 'zero'); + const judged = occurrences.filter((o) => o.judgement.verdict !== 'unjudged'); + /** @type {Record} */ + const unjudgedByKind = {}; + for (const occurrence of occurrences) { + if (occurrence.judgement.verdict !== 'unjudged') continue; + const kind = occurrence.judgement.selector.kind; + unjudgedByKind[kind] = (unjudgedByKind[kind] ?? 0) + 1; + } + return { names, occurrences, judged, dead, commented, unjudgedByKind, filesWithFilters, exemptOccurrences }; +} + +/** + * @param {Occurrence} occurrence + * @returns {string} + */ +export function describe(occurrence) { + const { file, line, value, judgement } = occurrence; + const suggestion = judgement.suggestion ? ` Did you mean \`${judgement.suggestion}\`?` : ''; + return ( + `${file}:${line}: \`--filter ${value}\` names no package in this workspace.${suggestion} ` + + 'pnpm EXITS 0 on a filter that matched nothing, so this command reports success while ' + + 'measuring nothing (#10853) — a red here is a run that was never happening.' + ); +} + +function run() { + const root = findWorkspaceRoot(HERE) ?? REPO_ROOT; + const { names, occurrences, judged, dead, commented, unjudgedByKind, filesWithFilters, exemptOccurrences } = scanRepo(root); + + // #4690: a scan that read nothing must not report as a scan that found + // nothing wrong. Both axes, because either one can go to zero on its own. + if (names.length === 0) { + console.error( + '✗ check:pnpm-filter-targets — the workspace resolved to ZERO packages, so every filter would ' + + 'read as dead. That is a broken reader, not a broken tree.', + ); + return 1; + } + if (occurrences.length === 0) { + console.error( + '✗ check:pnpm-filter-targets — found NO `--filter` occurrence anywhere in `scripts/**`, ' + + '`.github/workflows/**` or the workspace manifests. Measured when this gate landed: 148 ' + + 'occurrences across 25 files. Zero means the extractor stopped reading, not that the tree ' + + 'stopped spelling filters (#4690).', + ); + return 1; + } + if (judged.length === 0) { + console.error( + `✗ check:pnpm-filter-targets — found ${occurrences.length} \`--filter\` occurrence(s) and judged ` + + 'NONE of them. A gate that judges nothing is indistinguishable from a clean tree (#4690).', + ); + return 1; + } + + if (dead.length > 0) { + console.error(`✗ check:pnpm-filter-targets — ${dead.length} filter(s) name no package in this workspace\n`); + for (const occurrence of dead) console.error(` • ${describe(occurrence)}\n`); + console.error( + ' Fix the spelling, or — if the command deliberately runs against ANOTHER repo\'s workspace —\n' + + ' declare that scope in `FOREIGN_SCOPES` in scripts/pnpm-filter-targets.mjs, with the reason.\n', + ); + return 1; + } + + const unjudged = Object.entries(unjudgedByKind) + .sort() + .map(([kind, count]) => `${count} ${kind}`) + .join(', '); + console.log( + `✓ check:pnpm-filter-targets: ${judged.length}/${occurrences.length} \`--filter\` occurrence(s) across ` + + `${filesWithFilters} file(s) resolve against ${names.length} workspace package(s); ` + + `${occurrences.length - judged.length} not judged (${unjudged || 'none'}); ` + + `${commented} more in comments or step labels and ${exemptOccurrences} in this rule's own files, ` + + 'counted and not judged.', + ); + return 0; +} + +function list() { + const root = findWorkspaceRoot(HERE) ?? REPO_ROOT; + const { occurrences } = scanRepo(root); + for (const occurrence of occurrences) { + const { judgement } = occurrence; + const note = judgement.verdict === 'unjudged' ? `unjudged (${judgement.selector.kind})` : judgement.verdict; + console.log(`${occurrence.file}:${occurrence.line}\t${note}\t${occurrence.value}`); + } + console.log(`\n${occurrences.length} occurrence(s).`); + return 0; +} + +export function selfTest() { + const failures = []; + let checked = 0; + const ok = (description, condition) => { + checked++; + if (!condition) failures.push(description); + }; + + const root = findWorkspaceRoot(HERE) ?? REPO_ROOT; + const { names } = listWorkspacePackages(root); + ok(`the workspace reads (found ${names.length} packages)`, names.length > 50); + + // ---- ⭐ THE NEGATIVE CONTROL: the card's own reproduction ---------------- + // A fixture that names a package which does not exist MUST red, and the same + // fixture with the real name must be silent. Both directions, or the gate is + // not shown to work. + const deadFixture = 'jobs:\n test:\n steps:\n - run: pnpm --filter @objectstack/adapter-hono test\n'; + const deadScan = scanText(deadFixture, 'fixture.yml', names); + ok('NEGATIVE CONTROL: a dead filter in a workflow yields exactly one occurrence', deadScan.occurrences.length === 1); + ok('and it is judged zero', deadScan.occurrences[0]?.judgement.verdict === 'zero'); + ok('and the message names the file and line', describe(deadScan.occurrences[0]).startsWith('fixture.yml:4:')); + ok('and the message suggests the real package', describe(deadScan.occurrences[0]).includes('@objectstack/hono')); + ok('and the message says pnpm EXITS 0 — the reason this is a defect at all', describe(deadScan.occurrences[0]).includes('EXITS 0')); + + const liveFixture = deadFixture.replace('@objectstack/adapter-hono', '@objectstack/hono'); + const liveScan = scanText(liveFixture, 'fixture.yml', names); + ok('POSITIVE CONTROL: the same fixture with a real name yields no finding', liveScan.occurrences.every((o) => o.judgement.verdict === 'matches')); + + // The other three carriers, each with a dead name. + ok( + 'a dead filter in a package.json script is judged zero', + scanText('{"scripts":{"test":"pnpm --filter @objectstack/nope test"}}', 'package.json', names).occurrences[0]?.judgement.verdict === 'zero', + ); + ok( + 'a dead filter in a shell script is judged zero', + scanText('#!/usr/bin/env bash\npnpm --filter @objectstack/nope build\n', 'scripts/x.sh', names).occurrences[0]?.judgement.verdict === 'zero', + ); + ok( + 'a dead filter in a JS string is judged zero', + scanText("const cmd = 'pnpm --filter @objectstack/nope test';\n", 'scripts/x.mjs', names).occurrences[0]?.judgement.verdict === 'zero', + ); + + // ---- comments are counted, not judged ----------------------------------- + const shellComment = scanText('# pnpm --filter @objectstack/nope test\npnpm --filter @objectstack/spec test\n', 'scripts/x.sh', names); + ok('a `#` comment line is not judged', shellComment.occurrences.length === 1 && shellComment.occurrences[0].value === '@objectstack/spec'); + ok('but it IS counted', shellComment.commented === 1); + const jsComment = scanText("// pnpm --filter @objectstack/nope test\nconst x = 'pnpm --filter @objectstack/spec test';\n", 'scripts/x.mjs', names); + ok('a JS line comment is not judged', jsComment.occurrences.length === 1 && jsComment.occurrences[0].value === '@objectstack/spec'); + ok('but it IS counted', jsComment.commented === 1); + const jsBlock = scanText("/**\n * pnpm --filter @objectstack/nope test\n */\nconst x = 'pnpm --filter @objectstack/spec test';\n", 'scripts/x.mjs', names); + ok('a JSDoc block comment is not judged', jsBlock.occurrences.length === 1); + ok('a masked comment does NOT shift the line numbers of the code below it', jsBlock.occurrences[0]?.line === 4); + ok( + 'a STRING survives the mask — a filter in a JS string literal is code, not prose', + scanText("const x = 'pnpm --filter @objectstack/nope test';\n", 'scripts/x.mjs', names).occurrences.length === 1, + ); + + // A YAML step `name:` is prose. Measured on this gate's own step, which is + // where the false positive came from. + const labelFixture = 'jobs:\n lint:\n steps:\n - name: Every committed pnpm --filter names a real package\n run: pnpm check:pnpm-filter-targets\n'; + const labelScan = scanText(labelFixture, 'lint.yml', names); + ok('a YAML step `name:` is a LABEL, not a command, and yields no finding', labelScan.occurrences.length === 0); + ok('but it IS counted, so the excluded population stays visible', labelScan.commented === 1); + ok( + 'and a `run:` on the very next line is still judged', + scanText(' - name: run pnpm --filter names a real package\n run: pnpm --filter @objectstack/adapter-hono test\n', 'lint.yml', names) + .occurrences.filter((o) => o.judgement.verdict === 'zero').length === 1, + ); + + // ---- discrimination: the shapes that must NOT produce a finding ---------- + for (const [text, why] of [ + ['pnpm --filter ./packages/* typecheck', 'a path glob'], + ['pnpm --filter "@objectstack/*" build', 'a name glob'], + ['pnpm --filter "@objectstack/spec[origin/main]" test', 'a since-ref selector'], + ['pnpm --filter @object-ui/console build', 'a declared foreign scope'], + ['pnpm --filter "${pkg}" test', 'an interpolation'], + ['pnpm --filter test', 'a prose placeholder'], + ]) { + const scan = scanText(`${text}\n`, 'scripts/x.sh', names); + ok(`${why} yields no finding`, scan.occurrences.every((o) => o.judgement.verdict === 'unjudged')); + } + ok( + 'a dependency selector on a dead name is STILL a finding — the suffix does not excuse the typo', + scanText('pnpm --filter @objectstack/adapter-hono... build\n', 'scripts/x.sh', names).occurrences[0]?.judgement.verdict === 'zero', + ); + ok( + 'a dependency selector on a live name is not', + scanText('pnpm --filter @objectstack/hono... build\n', 'scripts/x.sh', names).occurrences[0]?.judgement.verdict === 'matches', + ); + + // ---- the real tree, and the vacuity refusals ----------------------------- + const live = scanRepo(root); + ok(`the live tree yields occurrences (found ${live.occurrences.length})`, live.occurrences.length > 50); + ok(`and judges a real share of them (judged ${live.judged.length})`, live.judged.length > 30); + ok(`and the checked-in tree is clean (dead: ${live.dead.map((d) => d.value).join(', ') || 'none'})`, live.dead.length === 0); + ok('and the foreign scope is present and unjudged, not silently missing', (live.unjudgedByKind.foreign ?? 0) > 0); + ok( + 'the self-exemption is exactly the three files that own the rule — never a fourth', + RULE_OWNING_FILES.length === 3 + && RULE_OWNING_FILES.includes('scripts/pnpm-filter-targets.mjs') + && RULE_OWNING_FILES.includes('scripts/check-pnpm-filter-targets.mjs') + && RULE_OWNING_FILES.includes('scripts/pm/os-verify-lock.sh'), + ); + ok( + 'and both exempted files really exist, so the exemption names something', + RULE_OWNING_FILES.every((f) => existsSync(join(root, f))), + ); + ok( + 'and the exempted occurrences are COUNTED, not silently dropped', + live.exemptOccurrences > 0, + ); + ok( + 'the file sweep reaches all three carriers', + ['package.json', 'scripts/', '.github/workflows/'].every((prefix) => + scannedFiles(root).some((f) => f === prefix || f.startsWith(prefix)), + ), + ); + + if (failures.length === 0) { + console.log( + `✓ check-pnpm-filter-targets --self-test: ${checked} assertions — a dead filter observed RED in all ` + + 'four carriers (workflow, package.json, shell, JS) and the same fixtures observed SILENT with a ' + + `real name; ${live.occurrences.length} live occurrence(s) swept.`, + ); + return 0; + } + console.error(`✗ check-pnpm-filter-targets --self-test — ${failures.length} failure(s)\n`); + for (const failure of failures) console.error(` • ${failure}`); + return 1; +} + +if (isEntrypoint(import.meta.url)) { + const flag = process.argv[2]; + if (flag === '--self-test') process.exit(selfTest()); + else if (flag === '--list') process.exit(list()); + else process.exit(run()); +} diff --git a/scripts/pm/os-verify-lock.sh b/scripts/pm/os-verify-lock.sh index 2a042f09b7..71b92f2d1b 100755 --- a/scripts/pm/os-verify-lock.sh +++ b/scripts/pm/os-verify-lock.sh @@ -485,6 +485,65 @@ preflight() { [[ -z "$PREFLIGHT_PROBLEMS" ]] } +# --- filter preflight ------------------------------------------------------- + +# ⛔ `pnpm --filter ` EXITS 0 when the filter matches no project (#10853): +# +# $ pnpm --filter @objectstack/adapter-hono test --maxWorkers=2 +# No projects matched the filters in "/home/user/objectstack" +# $ echo $? +# 0 +# +# That is the one failure this wrapper is worst placed to let through. Every +# discipline built on top of it reads the exit code -- `cmd > log 2>&1; ec=$?`, +# the VERDICT line below, a dev's report saying "suite green, exit 0" -- and all +# of them would be TRUE AND WORTHLESS, because nothing ran. A whole verification +# round can be fictitious with every exit code in it genuine. It is the same +# shape as the `--`-before-vitest-args trap: exit 0, nothing measured, output +# that reads like success. +# +# So the check happens HERE, at the entry point every heavy verify in this +# container is contracted to come through, and BEFORE the lock is taken: a +# command that will measure nothing should not spend the fleet's lock to do it. +# +# ⚠️ FAIL-OPEN in every direction, deliberately. A wrong refusal would make this +# wrapper the thing that blocks correct work, while a miss leaves today's +# behaviour exactly as it is. No node, no resolver on disk, a resolver that +# errors, a selector that cannot be decided (a glob, a path, an interpolation), +# or a command that changes directory -- all of them PROCEED. The only refusal +# is a plain package name that this workspace does not have. The resolver reads +# `pnpm-workspace.yaml` and the manifests it names and nothing else: no network, +# no pnpm invocation, no lockfile, so it cannot become the unbounded wait this +# file exists to prevent. +# +# Escape hatch, per call: OS_VERIFY_LOCK_NO_FILTER_CHECK=1. +filter_preflight() { + local label="$1" self_dir resolver out ec line + + [[ "${OS_VERIFY_LOCK_NO_FILTER_CHECK:-}" == "1" ]] && return 0 + case "$label" in + *--filter*) ;; + *) return 0 ;; + esac + command -v node > /dev/null 2>&1 || return 0 + + self_dir="$(cd "$(dirname "$SELF")" 2> /dev/null && pwd)" || return 0 + resolver="${self_dir}/../pnpm-filter-targets.mjs" + [[ -f "$resolver" ]] || return 0 + + out="$(node "$resolver" --preflight "$label" 2> /dev/null)" + ec=$? + # Exit 3 is the resolver's ONLY refusal code. Anything else -- 0, a crash, a + # missing dependency -- means proceed. + [[ "$ec" -eq 3 ]] || return 0 + + log "✗ this command would measure NOTHING:" + while IFS= read -r line; do + [[ -n "$line" ]] && log " $line" + done <<< "$out" + return 1 +} + # --- modes ------------------------------------------------------------------ usage() { @@ -498,6 +557,10 @@ usage: There is deliberately no -w / --timeout: the acquisition budget is capped at the call site. OS_VERIFY_LOCK_WAIT may LOWER it; a value above the cap is clamped. + +A `pnpm --filter ` that matches no project is refused BEFORE the lock is +taken, because pnpm exits 0 on it and the run would measure nothing (#10853). +OS_VERIFY_LOCK_NO_FILTER_CHECK=1 skips that check for one call. Exit 99 means this call never acquired the lock; every run prints a VERDICT line — including the refusals: `lock-unusable` (this host cannot operate the lock at all) and `usage-error`. A run that prints no verdict is a bug in this script. @@ -554,6 +617,15 @@ mode_run() { exit 99 fi + # A filter that matches nothing is a CALLER error, not a busy lock, so it + # exits 2 (the usage-error code) rather than 99. 99 means "never got a turn" + # and invites a retry; retrying this command would just reproduce the same + # silent green. + if ! filter_preflight "$label"; then + log "VERDICT filter-matches-nothing (exit 2) · never acquired · refused before waiting · nothing was built or tested · ${label}" + exit 2 + fi + effective_budget [[ -n "$BUDGET_NOTE" ]] && log "⚠ $BUDGET_NOTE" @@ -1125,6 +1197,65 @@ mode_self_test() { wait st_case 'three staggered waiters acquire in arrival order' "$(< "$order")" ABC + # (h) the filter preflight (#10853). BOTH directions, because a guard shown + # only to red could be a guard that reds on everything -- which here would + # block the fleet's verification. The commands are `echo`, so what is measured + # is the REFUSAL, not pnpm. + # + # Run from the repo root on purpose: the resolver answers about the workspace + # the command would run in, so the case has to put the child in one. + local repo_root fp_ran + repo_root="$(cd "$(dirname "$SELF")/../.." 2> /dev/null && pwd)" + fp_ran="${tmp}/fp-ran" + + : > "$fp_ran" + (cd "$repo_root" && bash "$SELF" -c "echo pnpm --filter @objectstack/adapter-hono test; : > '${fp_ran}.dead'") > /dev/null 2>&1 + st_case 'a filter naming no package is REFUSED (exit 2, not 99 -- retrying cannot help)' "$?" 2 + st_case 'and the refused command never ran' \ + "$([[ -e "${fp_ran}.dead" ]] && echo ran || echo 'did not run')" 'did not run' + out="$(cd "$repo_root" && bash "$SELF" -c 'echo pnpm --filter @objectstack/adapter-hono test' 2>&1)" + st_case 'and the refusal names the selector' \ + "$([[ "$out" == *'@objectstack/adapter-hono'* ]] && echo yes || echo no)" yes + st_case 'and suggests the real package' \ + "$([[ "$out" == *'@objectstack/hono'* ]] && echo yes || echo no)" yes + st_case 'and prints a VERDICT line, like every other refusal here' \ + "$(printf '%s' "$out" | grep -c 'VERDICT filter-matches-nothing (exit 2)')" 1 + st_case 'and says nothing was built or tested' \ + "$([[ "$out" == *'nothing was built or tested'* ]] && echo yes || echo no)" yes + + # The other direction: a REAL package name must sail straight through, run, + # and report an ordinary command-exit verdict. + : > "${fp_ran}.live" + out="$(cd "$repo_root" && bash "$SELF" -c "echo pnpm --filter @objectstack/hono test; : > '${fp_ran}.liveran'" 2>&1)" + st_case 'a filter naming a REAL package is not refused' "$?" 0 + st_case 'and its command actually ran' \ + "$([[ -e "${fp_ran}.liveran" ]] && echo ran || echo 'did not run')" ran + st_case 'and it reports an ordinary command-exit verdict' \ + "$(printf '%s' "$out" | grep -c 'VERDICT command-exit 0')" 1 + + # A selector the resolver refuses to decide must never be refused HERE. + (cd "$repo_root" && bash "$SELF" -c 'echo pnpm --filter ./packages/* build') > /dev/null 2>&1 + st_case 'an undecidable selector (a path glob) proceeds -- fail-open' "$?" 0 + (cd "$repo_root" && bash "$SELF" -c 'echo pnpm --filter "${PKG}" build') > /dev/null 2>&1 + st_case 'an interpolated selector proceeds -- the spelling is not the value' "$?" 0 + (cd "$repo_root" && bash "$SELF" -c 'echo cd elsewhere && echo pnpm --filter @objectstack/adapter-hono build') > /dev/null 2>&1 + st_case 'a command that changes directory proceeds -- unknown workspace, no verdict' "$?" 0 + (cd "$repo_root" && bash "$SELF" -c 'echo git fetch --unshallow --filter=blob:none') > /dev/null 2>&1 + st_case "git's partial-clone --filter is not pnpm's, and proceeds" "$?" 0 + + # The escape hatch, and the proof that it is the ONLY thing that changed the + # answer: same command, same tree, refused without it. + : > "${fp_ran}.hatch" + (cd "$repo_root" && OS_VERIFY_LOCK_NO_FILTER_CHECK=1 bash "$SELF" \ + -c "echo pnpm --filter @objectstack/adapter-hono test; : > '${fp_ran}.hatchran'") > /dev/null 2>&1 + st_case 'OS_VERIFY_LOCK_NO_FILTER_CHECK=1 lets a deliberate call through' "$?" 0 + st_case 'and that command really ran' \ + "$([[ -e "${fp_ran}.hatchran" ]] && echo ran || echo 'did not run')" ran + + # A command with no --filter at all must be untouched by any of this. + (cd "$repo_root" && bash "$SELF" -c true) > /dev/null 2>&1 + st_case 'a command with no --filter is unaffected' "$?" 0 + printf '\n' if ((st_fail > 0)); then printf '✗ os-verify-lock self-test: %d case(s) failed.\n' "$st_fail" diff --git a/scripts/pnpm-filter-targets.mjs b/scripts/pnpm-filter-targets.mjs new file mode 100644 index 0000000000..55a583f733 --- /dev/null +++ b/scripts/pnpm-filter-targets.mjs @@ -0,0 +1,724 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * pnpm-filter-targets (#10853) -- the ONE place that answers "does this + * `--filter` selector name anything in this workspace?". + * + * node scripts/pnpm-filter-targets.mjs --list + * node scripts/pnpm-filter-targets.mjs --preflight '' + * node scripts/pnpm-filter-targets.mjs --self-test + * + * ## The defect + * + * `pnpm --filter ` EXITS 0 when the filter matches no project. Measured + * on this tree, and re-measured when this module was written: + * + * $ pnpm --filter @objectstack/definitely-not-a-package test; echo $? + * No projects matched the filters in "/home/user/objectstack" + * 0 + * + * Exit 0 is the whole problem, not the typo. Every discipline this repo uses to + * make a test run trustworthy is defeated by it: `cmd > log 2>&1; ec=$?` -- the + * rule that exists because a pipe swallows the exit code -- faithfully captures + * 0, and a report saying "suite green, exit 0" is then TRUE AND WORTHLESS, + * because nothing ran. The instance that surfaced it: a dispatch briefing + * spelled `@objectstack/adapter-hono`, which is the name a reader guesses from + * the directory `packages/adapters/hono`; the real name is `@objectstack/hono`. + * The dev checked whether the filter had matched, on its own initiative, and + * caught what it called "a silent green that would have made an entire + * verification round fictitious". + * + * Same shape as the sibling trap already on the books -- never write `--` when + * passing args to vitest through pnpm, because `cac` silently discards + * everything after it. Both end at: exit 0, nothing measured, output that reads + * like success. This repo's recurring defect class (#4690), sitting underneath + * the very command agents use to PROVE results. + * + * ## Why this is one module and not two matchers + * + * Two consumers ask the same question and must not answer it differently: + * `scripts/check-pnpm-filter-targets.mjs` (the committed population) and + * `scripts/pm/os-verify-lock.sh` (the ad-hoc population, via `--preflight`). + * A second recogniser of the same rule is a divergence source -- the reason + * `check-ci-filter-parity.mjs` refused to grow one. + * + * ## The matching rule, MEASURED against real pnpm rather than assumed + * + * pnpm matches a bare pattern against the package NAME, in full or with the + * scope stripped -- and NOT against the directory. All four measured on this + * tree with `pnpm --filter ls --depth -1 --json` (pnpm 10.31.0): + * + * hono -> 1 (@objectstack/hono, in packages/adapters/hono) + * @objectstack/hono -> 1 + * app-todo -> 0 (the DIRECTORY examples/app-todo -- not a match) + * example-todo -> 1 (the unscoped NAME of @objectstack/example-todo) + * + * The `app-todo` row is the one that makes the rule non-obvious, and a matcher + * written from the directory intuition would have reported a live selector as + * dead. It is pinned in `--self-test`. + * + * ## What this module REFUSES to judge, and why that is the design + * + * A wrong red here is worse than a miss: it would make the guard the thing that + * blocks a correct run. So a selector is judged ONLY when the answer cannot be + * argued with -- a plain name, no metacharacters. Everything else is returned + * as `unjudged` WITH a reason, and the reason is reported, so an unjudged + * population is visible rather than silent: + * + * - GLOBS (`@objectstack/*`, `./packages/*`) -- deciding these needs a + * picomatch-compatible matcher, i.e. a THIRD recogniser with its own + * divergence risk. `check-ci-filter-parity.mjs` rejected exactly that trade + * for exactly this reason; this module makes the same call. + * - PATH selectors (`./pkg`, `{pkg}`) -- a directory, not a name. + * - SINCE-REF selectors (`[origin/main]`) -- the answer depends on git state, + * which is not a property of the spelling. + * - INTERPOLATION and prose placeholders (`${pkg}`, ``) -- the spelling + * is not the value. + * + * Dependency suffixes and the exclusion prefix are STRIPPED, not refused: + * `...pkg`, `pkg...`, `pkg^...`, `...^pkg` and `!pkg` all carry a plain name + * that is exactly as checkable as a bare one, and `@objectstack/adapter-hono...` + * is the same typo wearing a suffix. + */ + +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { isEntrypoint } from './invoked-as.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** + * Scopes whose packages live in a DIFFERENT repository's workspace, so a + * zero-match here is correct rather than a defect. + * + * Declared, never inferred: a new foreign scope has to be added here + * deliberately, which is the difference between a documented exemption and a + * silent hole. `scripts/build-console.sh` runs its `@object-ui/console` filters + * inside `$BUILD_ROOT` -- a checkout of `objectstack-ai/objectui` at the SHA + * pinned in `.objectui-sha` -- never against this workspace, so resolving those + * against this tree would be asking the wrong root. + */ +export const FOREIGN_SCOPES = [ + { + scope: '@object-ui/', + reason: + 'the objectui repo (objectstack-ai/objectui); scripts/build-console.sh and ' + + 'scripts/gen-sdui-manifest.sh run these filters inside that checkout, not this workspace', + }, +]; + +/** + * Metacharacters that make a selector a PATTERN rather than a name. + * + * ⚠️ `@`, `!` and `+` are NOT metacharacters on their own here, and treating + * them as such is a bug this self-test caught: `@` opens every scoped package + * name in this workspace, so a naive class would have refused a verdict on + * `@objectstack/spec` and on every other real selector -- the guard would then + * be silent on exactly the population it exists for. In picomatch they are + * extglob operators only when they PREFIX a group, hence the second alternative. + */ +const GLOB_CHARS = /[*?[\]]|[!?*+@]\(/; +/** The extglob operators, recognised only where picomatch treats them as such. */ +const EXTGLOB = /[!?*+@]\(/; +const GLOB_REASON = 'a glob pattern: deciding it needs a picomatch-compatible matcher, i.e. a second recogniser'; +/** Interpolation and prose placeholders: the spelling is not the value. */ +const NOT_A_LITERAL = /[$`{}<>]/; + +// -- the workspace ------------------------------------------------------------ + +/** + * The nearest ancestor of `startDir` holding a `pnpm-workspace.yaml`. + * + * Resolution is from the CALLER'S directory on purpose. `--preflight` judges a + * command that is about to run in the caller's cwd, which in this repo is + * routinely a per-task worktree rather than the shared checkout, and the + * workspace that matters is the one pnpm will read. + * + * @param {string} startDir + * @returns {string|null} + */ +export function findWorkspaceRoot(startDir) { + let dir = resolve(startDir); + for (;;) { + if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return dir; + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +/** + * The `packages:` globs of a pnpm-workspace.yaml. + * + * Hand-read rather than parsed with the `yaml` dependency, because the caller + * that matters most (`os-verify-lock.sh --preflight`) must work in a tree whose + * `node_modules` is absent or half-installed -- the exact situation in which a + * verification command is most likely to be wrong. The shape is a flat list of + * scalars; anything else is not this file. + * + * @param {string} text + * @returns {string[]} + */ +export function workspacePatterns(text) { + const out = []; + let inPackages = false; + for (const raw of String(text).split('\n')) { + const line = raw.replace(/\s+$/, ''); + if (/^packages:\s*$/.test(line)) { + inPackages = true; + continue; + } + if (!inPackages) continue; + const item = /^\s+-\s+(.*)$/.exec(line); + if (item) { + const value = item[1].trim().replace(/^['"]|['"]$/g, ''); + if (value) out.push(value); + continue; + } + if (line.trim() === '' || /^\s*#/.test(line)) continue; + break; // a new top-level key ends the list + } + return out; +} + +/** + * Expand one workspace glob to directories. Only `*` segments are expanded -- + * the shape this repo's workspace file uses (`packages/*`, `packages/qa/*`). + * + * @param {string} root + * @param {string} pattern + * @returns {string[]} absolute directories + */ +export function expandPattern(root, pattern) { + let dirs = [root]; + for (const segment of pattern.split('/')) { + if (!segment || segment === '.') continue; + const next = []; + for (const dir of dirs) { + if (segment.includes('*')) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + const re = new RegExp(`^${segment.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')}$`); + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === 'node_modules') continue; + if (re.test(entry.name)) next.push(join(dir, entry.name)); + } + } else { + const candidate = join(dir, segment); + try { + if (statSync(candidate).isDirectory()) next.push(candidate); + } catch { + /* not there */ + } + } + } + dirs = next; + } + return dirs; +} + +/** + * Every package name this workspace declares, plus the root manifest's own. + * + * @param {string} root workspace root + * @returns {{ names: string[], dirs: string[] }} + */ +export function listWorkspacePackages(root) { + const wsFile = join(root, 'pnpm-workspace.yaml'); + if (!existsSync(wsFile)) return { names: [], dirs: [] }; + const names = []; + const dirs = []; + for (const pattern of workspacePatterns(readFileSync(wsFile, 'utf8'))) { + if (pattern.startsWith('!')) continue; + for (const dir of expandPattern(root, pattern)) { + const manifest = join(dir, 'package.json'); + if (!existsSync(manifest)) continue; + try { + const parsed = JSON.parse(readFileSync(manifest, 'utf8')); + if (typeof parsed.name === 'string' && parsed.name) { + names.push(parsed.name); + dirs.push(dir); + } + } catch { + /* an unparseable manifest is not this module's finding */ + } + } + } + return { names: [...new Set(names)].sort(), dirs }; +} + +// -- the selector ------------------------------------------------------------- + +/** + * @typedef {{ raw: string, kind: string, name: string|null, judgeable: boolean, reason: string }} Selector + */ + +/** + * Classify one `--filter` value. + * + * @param {string} raw + * @returns {Selector} + */ +export function classifySelector(raw) { + const original = String(raw); + const un = (kind, reason) => ({ raw: original, kind, name: null, judgeable: false, reason }); + let s = original.trim(); + if (s === '') return un('empty', 'no selector'); + if (NOT_A_LITERAL.test(s)) { + return un('interpolated', 'carries an interpolation or a prose placeholder, so the spelling is not the value'); + } + // Extglob is tested BEFORE anything is stripped, so `!(docs)` stays a pattern + // while `!@objectstack/docs` goes on to be an excluded NAME. + if (EXTGLOB.test(s)) return un('glob', GLOB_REASON); + + // Exclusion is a prefix on an otherwise ordinary selector. + let excluded = false; + if (s.startsWith('!')) { + excluded = true; + s = s.slice(1); + } + // Dependency selectors are stripped FIRST, before the path test -- `...pkg` + // begins with a dot and would otherwise read as a relative path. That + // ordering bug made every `...pkg` selector unjudged, which is a silent hole + // rather than a wrong answer, so only a self-test could have found it. + s = s.replace(/^\.\.\.\^?/, '').replace(/\^?\.\.\.$/, ''); + if (s === '') return un('dependency-only', 'a dependency selector with no package name'); + + if (s.includes('[') || s.includes(']')) { + return un('since-ref', 'a since-ref selector: the answer depends on git state, not on the spelling'); + } + if (s.startsWith('.') || s.startsWith('/')) { + return un('path', 'a path selector: it names a directory, not a package'); + } + if (GLOB_CHARS.test(s)) return un('glob', GLOB_REASON); + + for (const foreign of FOREIGN_SCOPES) { + if (s.startsWith(foreign.scope)) { + return { + raw: original, + kind: 'foreign', + name: s, + judgeable: false, + reason: `names a package in ${foreign.reason}`, + }; + } + } + return { + raw: original, + kind: excluded ? 'name-excluded' : 'name', + name: s, + judgeable: true, + reason: '', + }; +} + +/** The name with any scope removed: `@objectstack/hono` -> `hono`. */ +export function unscoped(name) { + return name.startsWith('@') && name.includes('/') ? name.slice(name.indexOf('/') + 1) : name; +} + +/** + * How many workspace packages a plain-name pattern matches, by pnpm's rule. + * + * @param {string} pattern + * @param {string[]} names + * @returns {string[]} the matched names + */ +export function matchName(pattern, names) { + const scoped = pattern.startsWith('@'); + return names.filter((name) => name === pattern || (!scoped && unscoped(name) === pattern)); +} + +/** + * @typedef {{ verdict: 'matches'|'zero'|'unjudged', selector: Selector, matched: string[], suggestion: string|null }} Judgement + */ + +/** + * Judge one selector against a workspace. + * + * @param {string} raw + * @param {string[]} names + * @returns {Judgement} + */ +export function judgeSelector(raw, names) { + const selector = classifySelector(raw); + if (!selector.judgeable) return { verdict: 'unjudged', selector, matched: [], suggestion: null }; + const matched = matchName(selector.name, names); + if (matched.length > 0) return { verdict: 'matches', selector, matched, suggestion: null }; + return { verdict: 'zero', selector, matched: [], suggestion: nearest(selector.name, names) }; +} + +/** + * The likeliest intended name, or null. Cheap and deliberately conservative: an + * unscoped-tail hit first (`@objectstack/adapter-hono` -> the name whose tail is + * closest), then a single containment hit. + * + * @param {string} pattern + * @param {string[]} names + * @returns {string|null} + */ +export function nearest(pattern, names) { + const tail = unscoped(pattern); + const exactTail = names.filter((n) => unscoped(n) === tail); + if (exactTail.length === 1) return exactTail[0]; + const parts = tail.split('-').filter(Boolean); + const contains = names.filter((n) => { + const t = unscoped(n); + return parts.length > 0 && parts.every((p) => t.includes(p)); + }); + if (contains.length === 1) return contains[0]; + const suffix = names.filter((n) => { + const t = unscoped(n); + return parts.length > 1 && (t === parts[parts.length - 1] || t.endsWith(`-${parts[parts.length - 1]}`)); + }); + if (suffix.length === 1) return suffix[0]; + return null; +} + +// -- extraction --------------------------------------------------------------- + +/** + * Command separators. A `--filter` belongs to the command word that PRECEDES + * it on its own logical line, so the prefix is cut at the last separator before + * the match. + */ +const SEPARATORS = /[;&|(`]/; + +/** + * Does this prefix mean the `--filter` after it is pnpm's (or turbo's)? + * + * ⚠️ This discrimination is not decoration -- without it the sweep reports + * `git fetch --unshallow --filter=blob:none` (a git PARTIAL-CLONE filter, + * spelled in this repo) and the prose fragment "its full --filter invocation" + * as dead packages. Both were measured on this tree the first time the gate ran. + * + * Only the segment after the LAST separator counts, so `pnpm build && git fetch + * --filter=blob:none` does not inherit the `pnpm` from the other command. + * + * @param {string} prefix everything before `--filter` on its logical line + * @returns {boolean} + */ +export function isPnpmFilter(prefix) { + const parts = String(prefix).split(SEPARATORS); + const segment = parts[parts.length - 1]; + // The command word may be quoted, or reached by path: `"pnpm --filter ...` in + // a package.json script and `'pnpm --filter ...'` in a JS string are both real + // commands. So the boundary is "not a word/name character" rather than a + // whitelist of separators -- which still rejects `my-pnpm` and `@scope/pnpm`. + return /(^|[^\w@.-])(pnpm|turbo)(\s|$)/.test(segment); +} + +/** + * Every pnpm/turbo `--filter` value in a blob of text, with 1-based line numbers. + * + * Both spellings (`--filter x`, `--filter=x`) and all three quotings. The bare + * form stops at whitespace and at the shell metacharacters that end a word, so + * `--filter=@object-ui/console^...}` from a `${VAR:-default}` expansion yields + * the selector without the brace that closed the expansion. + * + * Backslash continuations are folded before matching, and the reported line is + * the FIRST line of the folded command -- a `pnpm \` whose `--filter` sits on + * the next physical line is one command, and would otherwise be missed. + * + * @param {string} text + * @returns {Array<{ value: string, line: number }>} + */ +export function extractFilters(text) { + const out = []; + const re = /--filter(?:=|\s+)(?:'([^']*)'|"([^"]*)"|([^\s'"`;|&)}]+))/g; + /** @type {Array<{ text: string, line: number }>} */ + const logical = []; + let accumulated = ''; + let startLine = 1; + String(text) + .split('\n') + .forEach((raw, i) => { + if (accumulated === '') startLine = i + 1; + accumulated += raw; + if (/\\$/.test(raw)) { + accumulated = `${accumulated.slice(0, -1)} `; + return; + } + logical.push({ text: accumulated, line: startLine }); + accumulated = ''; + }); + if (accumulated !== '') logical.push({ text: accumulated, line: startLine }); + + for (const { text: line, line: lineNumber } of logical) { + re.lastIndex = 0; + let m; + while ((m = re.exec(line)) !== null) { + if (!isPnpmFilter(line.slice(0, m.index))) continue; + out.push({ value: m[1] ?? m[2] ?? m[3] ?? '', line: lineNumber }); + } + } + return out; +} + +// -- the ad-hoc population: one command, about to run ------------------------- + +/** + * @typedef {{ decision: 'allow'|'refuse', reason: string, findings: Judgement[], judged: number }} Preflight + */ + +/** + * Judge a shell command that is ABOUT TO RUN in `cwd`. + * + * FAIL-OPEN by construction, and that is not timidity: this runs in front of + * every heavy verification in the container, so a wrong refusal would block + * correct work, while a miss leaves today's behaviour exactly as it is. It + * refuses only when the command is unambiguously a pnpm/turbo invocation in + * THIS workspace naming a package that does not exist. + * + * The `cd` bail-out is the load-bearing one: a command that changes directory + * may be filtering against another root entirely (`scripts/build-console.sh` + * does exactly that, in the objectui checkout), and this function has no way to + * know which. Unknown root, no verdict. + * + * @param {string} command + * @param {string} cwd + * @returns {Preflight} + */ +export function preflightCommand(command, cwd) { + const text = String(command); + const allow = (reason) => ({ decision: 'allow', reason, findings: [], judged: 0 }); + if (/(^|[\s;&|(])(cd|pushd|popd)(\s|$)/.test(text)) { + return allow('the command changes directory, so the workspace it filters against is not this one'); + } + const root = findWorkspaceRoot(cwd); + if (!root) return allow('no pnpm-workspace.yaml above the working directory'); + const { names } = listWorkspacePackages(root); + if (names.length === 0) return allow('the workspace declares no packages, so there is nothing to resolve against'); + + const findings = []; + let judged = 0; + for (const { value } of extractFilters(text)) { + const judgement = judgeSelector(value, names); + if (judgement.verdict === 'unjudged') continue; + judged++; + if (judgement.verdict === 'zero') findings.push(judgement); + } + if (findings.length > 0) { + return { decision: 'refuse', reason: 'a filter matches no project in this workspace', findings, judged }; + } + return { decision: 'allow', reason: judged > 0 ? `${judged} filter(s) resolved` : 'no judgeable filter', findings: [], judged }; +} + +/** + * The refusal text `os-verify-lock.sh` prints. Built here rather than in shell + * so the wording lives next to the rule it explains. + * + * @param {Preflight} result + * @returns {string} + */ +export function refusalMessage(result) { + const lines = []; + for (const finding of result.findings) { + const { selector, suggestion } = finding; + lines.push(`--filter ${selector.raw} matches NO project in this workspace.`); + if (suggestion) lines.push(` did you mean \`${suggestion}\`?`); + } + lines.push('pnpm exits 0 on a filter that matched nothing, so this run would have'); + lines.push('printed success and measured NOTHING (#10853). Refused before the lock,'); + lines.push('because a green exit code from a run that never happened is worse than no run.'); + lines.push('Deliberate? Set OS_VERIFY_LOCK_NO_FILTER_CHECK=1 for this call.'); + return lines.join('\n'); +} + +// -- CLI ---------------------------------------------------------------------- + +/** Exit code meaning "a filter is confidently dead"; anything else is allow. */ +export const EXIT_REFUSE = 3; + +function cliList() { + const root = findWorkspaceRoot(process.cwd()); + if (!root) { + console.error('pnpm-filter-targets: no pnpm-workspace.yaml above the working directory.'); + return 1; + } + const { names } = listWorkspacePackages(root); + console.log(`${names.length} workspace package(s) in ${root}:`); + for (const name of names) console.log(` ${name}`); + return 0; +} + +function cliPreflight(command) { + const result = preflightCommand(command, process.cwd()); + if (result.decision === 'refuse') { + process.stdout.write(`${refusalMessage(result)}\n`); + return EXIT_REFUSE; + } + return 0; +} + +export async function selfTest() { + const failures = []; + let checked = 0; + const ok = (description, condition) => { + checked++; + if (!condition) failures.push(description); + }; + + const root = findWorkspaceRoot(HERE); + ok('the workspace root is found from this file', root !== null); + const { names } = listWorkspacePackages(root ?? HERE); + ok(`the workspace lists packages (found ${names.length})`, names.length > 50); + ok('and the list holds the package the card is about', names.includes('@objectstack/hono')); + + // ---- the matching rule, exactly as measured against real pnpm ------------ + ok('a full scoped name matches', matchName('@objectstack/hono', names).length === 1); + ok('the UNSCOPED name matches too -- pnpm strips the scope', matchName('hono', names).length === 1); + ok( + 'a DIRECTORY name does NOT match: examples/app-todo is @objectstack/example-todo', + matchName('app-todo', names).length === 0 && matchName('example-todo', names).length === 1, + ); + ok('the card\'s guessed-from-the-path name matches nothing', matchName('@objectstack/adapter-hono', names).length === 0); + + // ---- the negative control: the card's own reproduction ------------------- + const dead = judgeSelector('@objectstack/adapter-hono', names); + ok('NEGATIVE CONTROL: the card\'s bad name is judged zero', dead.verdict === 'zero'); + ok('and the judgement suggests the real package', dead.suggestion === '@objectstack/hono'); + const alive = judgeSelector('@objectstack/hono', names); + ok('POSITIVE CONTROL: the real name is judged matching', alive.verdict === 'matches'); + + // ---- what is refused a verdict, one case per reason ---------------------- + const unjudged = [ + ['@objectstack/*', 'glob'], + ['./packages/*', 'path'], + ['{packages/spec}', 'interpolated'], + ['${pkg}', 'interpolated'], + ['', 'interpolated'], + ['[origin/main]', 'since-ref'], + ['@objectstack/spec[origin/main]', 'since-ref'], + ['@object-ui/console', 'foreign'], + ['', 'empty'], + ]; + for (const [raw, kind] of unjudged) { + const judged = judgeSelector(raw, names); + ok(`\`${raw}\` is unjudged, as kind ${kind}`, judged.verdict === 'unjudged' && judged.selector.kind === kind); + } + ok( + 'every unjudged selector carries a REASON, so an unjudged population is visible', + unjudged.every(([raw]) => judgeSelector(raw, names).selector.reason !== ''), + ); + + // ---- dependency selectors are stripped and judged, not waved through ----- + for (const suffixed of ['...@objectstack/hono', '@objectstack/hono...', '@objectstack/hono^...', '...^@objectstack/hono']) { + ok(`\`${suffixed}\` is judged, and matches`, judgeSelector(suffixed, names).verdict === 'matches'); + } + for (const suffixed of ['...@objectstack/adapter-hono', '@objectstack/adapter-hono...', '@objectstack/adapter-hono^...']) { + ok(`\`${suffixed}\` is the same typo wearing a suffix, and is judged zero`, judgeSelector(suffixed, names).verdict === 'zero'); + } + ok('`!@objectstack/hono` (exclusion) is judged and matches', judgeSelector('!@objectstack/hono', names).verdict === 'matches'); + ok('`!@objectstack/adapter-hono` is judged zero', judgeSelector('!@objectstack/adapter-hono', names).verdict === 'zero'); + + // ---- extraction --------------------------------------------------------- + const extracted = extractFilters( + [ + 'pnpm --filter @objectstack/spec test', + 'pnpm --filter=@objectstack/core build', + "pnpm --filter '@objectstack/cli' test", + 'pnpm --filter "@objectstack/lint" test', + 'pnpm exec turbo run build --filter=@object-ui/console^...}', + 'pnpm --filter ./packages/* typecheck', + ].join('\n'), + ); + ok(`extraction finds every spelling (found ${extracted.length})`, extracted.length === 6); + ok('the space form', extracted[0].value === '@objectstack/spec' && extracted[0].line === 1); + ok('the equals form', extracted[1].value === '@objectstack/core'); + ok('single quotes', extracted[2].value === '@objectstack/cli'); + ok('double quotes', extracted[3].value === '@objectstack/lint'); + ok('a `${VAR:-default}` expansion does not swallow the closing brace', extracted[4].value === '@object-ui/console^...'); + ok('a path glob', extracted[5].value === './packages/*' && extracted[5].line === 6); + + // The command a `--filter` BELONGS to. Both of these are real spellings in + // this repo, and both were reported as dead packages before this + // discrimination existed. + ok( + 'git\'s partial-clone --filter is not pnpm\'s', + extractFilters('git fetch --unshallow --filter=blob:none\n').length === 0, + ); + ok( + 'prose that merely contains the word --filter is not a command', + extractFilters("t('prints it as its full --filter invocation', x)\n").length === 0, + ); + ok( + 'a pnpm command earlier on the line is not inherited by a later git one', + extractFilters('pnpm -v && git fetch --filter=blob:none\n').length === 0, + ); + ok( + 'and the pnpm one on such a line IS still found', + extractFilters('git fetch --filter=blob:none && pnpm --filter @objectstack/spec test\n') + .map((e) => e.value) + .join() === '@objectstack/spec', + ); + const folded = extractFilters('pnpm \\\n --filter @objectstack/spec \\\n test\n'); + ok('a backslash continuation is folded, so `pnpm \\` + `--filter` is one command', folded.length === 1); + ok('and the reported line is where the command STARTS', folded[0]?.line === 1); + + // ---- the preflight: both directions, on the card's own command ---------- + const cwd = root ?? HERE; + const refused = preflightCommand('pnpm --filter @objectstack/adapter-hono test --maxWorkers=2', cwd); + ok('PREFLIGHT REDS on the card\'s literal reproduction', refused.decision === 'refuse'); + ok('and the refusal names the selector', refusalMessage(refused).includes('@objectstack/adapter-hono')); + ok('and suggests the real package', refusalMessage(refused).includes('@objectstack/hono')); + ok( + 'PREFLIGHT is SILENT on the same command with the real name', + preflightCommand('pnpm --filter @objectstack/hono test --maxWorkers=2', cwd).decision === 'allow', + ); + ok( + 'PREFLIGHT is silent on a filter it cannot judge', + preflightCommand('pnpm --filter ./packages/* build', cwd).decision === 'allow', + ); + ok( + 'PREFLIGHT is silent on a command that changes directory -- unknown root, no verdict', + preflightCommand('cd ../objectui && pnpm --filter @objectstack/adapter-hono build', cwd).decision === 'allow', + ); + ok( + 'PREFLIGHT is silent on a command that is not pnpm or turbo', + preflightCommand('grep -rn -- "--filter @objectstack/adapter-hono" scripts', cwd).decision === 'allow', + ); + ok( + 'PREFLIGHT judges a turbo filter too', + preflightCommand('pnpm exec turbo run build --filter=@objectstack/adapter-hono', cwd).decision === 'refuse', + ); + ok( + 'PREFLIGHT reports how many filters it actually judged', + preflightCommand('pnpm --filter @objectstack/hono test', cwd).judged === 1, + ); + + // ---- the workspace reader ------------------------------------------------ + const patterns = workspacePatterns(readFileSync(join(root ?? HERE, 'pnpm-workspace.yaml'), 'utf8')); + ok(`the workspace file yields its globs (found ${patterns.length})`, patterns.length >= 5); + ok('and stops at the next top-level key, not at the end of the file', !patterns.some((p) => p.includes(':'))); + ok('and holds the nested ones', patterns.includes('packages/adapters/*') || patterns.includes('packages/drivers/*')); + + if (failures.length === 0) { + console.log( + `✓ pnpm-filter-targets --self-test: ${checked} assertions over ${names.length} real workspace packages ` + + '(match rule pinned against measured pnpm behaviour; preflight observed both REFUSING and SILENT).', + ); + return 0; + } + console.error(`✗ pnpm-filter-targets --self-test -- ${failures.length} failure(s)\n`); + for (const failure of failures) console.error(` • ${failure}`); + return 1; +} + +if (isEntrypoint(import.meta.url)) { + const [flag, argument] = process.argv.slice(2); + if (flag === '--self-test') process.exit(await selfTest()); + else if (flag === '--list') process.exit(cliList()); + else if (flag === '--preflight') process.exit(cliPreflight(argument ?? '')); + else { + console.error('usage: pnpm-filter-targets.mjs [--list | --preflight | --self-test]'); + process.exit(2); + } +}