@@ -151,8 +151,10 @@ import { randomUUID } from 'node:crypto';
151151import {
152152 CLI ,
153153 CLI_BUILD_FIX ,
154+ closureBuildFix ,
154155 looksLikeMissingCliCommand ,
155156 oclifCommandFileFor ,
157+ owningPackageOf ,
156158 resolveCliCommandFile ,
157159} from './cli-build-prerequisite.mjs' ;
158160
@@ -186,15 +188,44 @@ const at = (rel) => join(REPO_ROOT, rel);
186188/** The one command this gate invokes per config, as oclif topic/command parts. */
187189const LINT_COMMAND_ID = [ 'lint' ] ;
188190
191+ /**
192+ * This gate's WHOLE build prerequisite, named once (#12564) — the CLI plus the
193+ * build closure of every config in the population, derived from the population
194+ * itself rather than written down.
195+ *
196+ * Computed at module scope on purpose: it is one command for the ROUND, not one
197+ * per config, which is what keeps it eligible for `SHARED_REMEDIES` below. A
198+ * remedy narrowed to the configs that actually failed would have to reach a
199+ * classifier, and a classifier that can see the population is a classifier that
200+ * can put per-round detail in a cause's identity — the #11395 hole, re-opened
201+ * from the other side. Round-constant, so `groupFailuresByCause` keys exactly as
202+ * it did before.
203+ */
204+ const POPULATION_CLOSURE = closureBuildFix ( [ ...discoverExamples ( ) , ...discoverPackages ( ) ] ) ;
205+
189206/**
190207 * The remedy when a config's OWN workspace dependencies were never built (#6033) —
191208 * distinct from `CLI_BUILD_FIX`, which clears only the CLI. An example config
192209 * imports workspace packages by name, so a tree with just the CLI built still
193210 * cannot be linted, and the two remedies must not be confused for each other.
211+ *
212+ * ⭐ It used to say `pnpm build`, which is CORRECT and is not what a reader does
213+ * (#12564). Sitting directly under it is a `why:` line naming ONE package, and
214+ * that line is the specific, actionable-looking one — so a reader builds that
215+ * package, re-runs, and is told the next one, because node stops resolving a
216+ * config's imports at the first missing `dist/` (the mechanism is written out
217+ * over `closureBuildFix`). Measured: four locked build rounds to get one reading,
218+ * and the walk was six rounds long — the reporter escaped at four only by
219+ * abandoning the diagnosis and building an owning package's closure instead.
220+ * Naming the closure here is what makes the specific line and the remedy agree.
221+ *
222+ * Falls back to the coarser `pnpm build` when the closure cannot be derived: a
223+ * strict superset costs time, never coverage, and a PARTIAL closure would be this
224+ * card's own defect wearing a derivation's clothes (see `closureBuildFix`).
194225 */
195- const WORKSPACE_BUILD_FIX = 'pnpm build' ;
226+ const WORKSPACE_BUILD_FIX = POPULATION_CLOSURE . command ?? 'pnpm build' ;
196227/** …and when the package is not on disk at all, a build alone cannot help. */
197- const INSTALL_THEN_BUILD_FIX = ' pnpm install && pnpm build' ;
228+ const INSTALL_THEN_BUILD_FIX = ` pnpm install && ${ WORKSPACE_BUILD_FIX } ` ;
198229
199230const update = process . argv . includes ( '--update' ) ;
200231
@@ -831,11 +862,24 @@ function selfTest() {
831862 // about them may move. Pinned as the literal remedy strings a reader acts on.
832863 expect (
833864 '#11395 the classified branches are untouched' ,
834- explainConfigFailure ( REAL_UNBUILT_DEP_ERROR ) . fix === 'pnpm build' &&
835- explainConfigFailure ( "Cannot find package '@objectstack/nope' imported from /repo/x/y.ts" ) . fix === 'pnpm install && pnpm build' ,
865+ explainConfigFailure ( REAL_UNBUILT_DEP_ERROR ) . fix === WORKSPACE_BUILD_FIX &&
866+ explainConfigFailure ( "Cannot find package '@objectstack/nope' imported from /repo/x/y.ts" ) . fix ===
867+ INSTALL_THEN_BUILD_FIX ,
836868 'the two classified remedies are the text a reader runs; they must not move' ,
837869 ) ;
838870
871+ // The two remedies are still DISTINCT and still say what they used to say
872+ // about each other: a package that is on disk but unbuilt needs a build, and
873+ // one that is not there at all needs an install FIRST. #12564 changed what the
874+ // build half spells, not which branch prescribes it.
875+ expect (
876+ '#12564 the two classified remedies stay distinct' ,
877+ WORKSPACE_BUILD_FIX !== INSTALL_THEN_BUILD_FIX &&
878+ INSTALL_THEN_BUILD_FIX === `pnpm install && ${ WORKSPACE_BUILD_FIX } ` &&
879+ WORKSPACE_BUILD_FIX !== CLI_BUILD_FIX ,
880+ 'the unbuilt / not-installed / CLI-only remedies must not collapse into one another' ,
881+ ) ;
882+
839883 // -------------------------------------------------------------------------
840884 // Root anchoring and the population classifier (#10907). These are the only
841885 // assertions in this file that can fail over a CORRECT tree in a WRONG place,
@@ -885,6 +929,71 @@ function selfTest() {
885929 `absolute paths would silently re-key every baseline entry; got ${ JSON . stringify ( offRoot . slice ( 0 , 2 ) ) } ` ,
886930 ) ;
887931
932+ // -------------------------------------------------------------------------
933+ // The build-prerequisite CLOSURE (#12564). What makes the remedy worth naming
934+ // is that ONE round of it clears the whole population — so the property to pin
935+ // is COMPLETENESS, and the failure to refuse is a closure that names SOME of
936+ // the population. A partial closure is the worse half of this card's defect: it
937+ // looks derived, it is specific, and it still does not converge.
938+ // -------------------------------------------------------------------------
939+ const derivedClosure = closureBuildFix ( onRoot ) ;
940+ expect (
941+ '#12564 the live population yields a closure' ,
942+ typeof derivedClosure . command === 'string' && derivedClosure . command . length > 0 ,
943+ `no closure could be named for ${ onRoot . length } config(s): ${ derivedClosure . unknown ?? '(no reason given)' } ` ,
944+ ) ;
945+ // Every config's OWN owner must be in the command. Compared per config against
946+ // the same manifests the derivation read, not against a list written here — a
947+ // list would be the hand-maintained note this derivation exists to avoid.
948+ const missingOwners = onRoot
949+ . map ( ( configPath ) => owningPackageOf ( configPath ) )
950+ . filter ( ( owner ) => ! owner || ! ( derivedClosure . command ?? '' ) . includes ( `--filter=${ owner } ` ) ) ;
951+ expect (
952+ '#12564 …naming every config in the population' ,
953+ missingOwners . length === 0 ,
954+ `${ missingOwners . length } owner(s) absent from the closure (${ missingOwners . join ( ', ' ) } ) — a closure that ` +
955+ 'misses one config leaves the reader exactly the round-trip this remedy exists to remove' ,
956+ ) ;
957+ // The CLI is in it too. Without that, the command printed under PREREQUISITE
958+ // NOT MET would not clear the prerequisite it is printed for — a remedy whose
959+ // success condition it cannot itself reach.
960+ expect (
961+ '#12564 …and the CLI the gate spawns' ,
962+ ( derivedClosure . command ?? '' ) . includes ( '--filter=@objectstack/cli' ) ,
963+ 'the closure must clear the CLI prerequisite it is offered as the remedy for' ,
964+ ) ;
965+ // ⛔ ALL-OR-NOTHING. A broken scan is the sharp case: with the CLI always
966+ // seeded, an EMPTY population would render as `--filter=@objectstack/cli`
967+ // alone — byte-for-byte the CLI-only remedy this card exists to replace,
968+ // presented as though it were the whole closure. That is a guard whose
969+ // total-failure output is indistinguishable from a plausible success. One
970+ // unowned config is the same defect part-way. Both must come back as a REASON,
971+ // never a command — the rule `emptyPopulationVerdict` states for the verdict.
972+ const emptyClosure = closureBuildFix ( [ ] ) ;
973+ const partialClosure = closureBuildFix ( [ ...onRoot , 'no/such/place/objectstack.config.ts' ] ) ;
974+ expect (
975+ '#12564 an empty population names no closure' ,
976+ emptyClosure . command === undefined && typeof emptyClosure . unknown === 'string' ,
977+ `an empty population produced a command (${ emptyClosure . command } ) — a broken scan must not render as the ` +
978+ 'CLI-only remedy wearing the closure\'s name' ,
979+ ) ;
980+ expect (
981+ '#12564 …and one unowned config refuses the WHOLE closure' ,
982+ partialClosure . command === undefined && typeof partialClosure . unknown === 'string' ,
983+ 'a closure missing one config is this card\'s own defect wearing a derivation\'s clothes' ,
984+ ) ;
985+ // The refusals this gate is CREDITED for do not move (#12564 fence 2). The
986+ // remedy got longer; nothing about it may turn a refusal into a pass, so the
987+ // two headlines stay reachable and the partial round still declines to judge.
988+ expect (
989+ '#12564 the refusal semantics did not move' ,
990+ measureAllConfigs ( [ 'x.ts' , 'y.ts' ] , ( c ) =>
991+ c === 'x.ts' ? { failure : explainConfigFailure ( REAL_UNBUILT_DEP_ERROR ) } : { count : 0 } ,
992+ ) . failures . length === 1 ,
993+ 'a partial round must still collect a failure — a round that reports green over an unmeasured config is ' +
994+ 'strictly worse than the four rounds this card is about' ,
995+ ) ;
996+
888997 // The SHARED probe's own anchoring (#11394). #10907 anchored this FILE; the
889998 // module it asks "is the CLI built?" kept reading `packages/cli/package.json`
890999 // CWD-relatively, so from any other cwd the probe ENOENTed and deferred — and
@@ -974,7 +1083,9 @@ function selfTest() {
9741083 `✓ check:i18n-coverage --self-test — the missing-CLI-build, i18n-rule and per-config-failure classifiers all go red, ` +
9751084 `stay distinct, and a failing config does not end the round; all ${ FAILURE_BRANCHES . length } failure branches state ` +
9761085 `one shared cause ONCE, with no config path in the key; the population resolves to ${ onRoot . length } config(s) ` +
977- `from outside the repo root as well as inside it, and an empty one is refused rather than reported OK.` ,
1086+ `from outside the repo root as well as inside it, and an empty one is refused rather than reported OK; ` +
1087+ `the build-prerequisite closure names all ${ onRoot . length } of them plus the CLI, and refuses whole rather ` +
1088+ `than naming some.` ,
9781089 ) ;
9791090}
9801091
@@ -1009,9 +1120,16 @@ function reportPrerequisiteNotMet(headline, detail) {
10091120 console . error (
10101121 `\ncheck-i18n-coverage: PREREQUISITE NOT MET — ${ headline } \n\n` +
10111122 detail . map ( ( l ) => ( l ? ` ${ l } ` : '' ) ) . join ( '\n' ) +
1012- `\n\n Fix: ${ CLI_BUILD_FIX } \n` +
1013- ` …and on a tree that has never been built, \`pnpm build\`: this gate also\n` +
1014- ` lints \`examples/*\`, whose configs import other workspace packages.\n\n` +
1123+ `\n\n Fix: ${ WORKSPACE_BUILD_FIX } \n\n` +
1124+ ` That is this gate's WHOLE prerequisite in ONE command (#12564) — the CLI,\n` +
1125+ ` plus the build closure of every config in its population. Clearing only\n` +
1126+ ` the CLI (\`${ CLI_BUILD_FIX } \`)\n` +
1127+ ` moves the wall rather than removing it: this gate also lints \`examples/*\`,\n` +
1128+ ` whose configs import workspace packages by name.\n` +
1129+ ` ⛔ And clearing it one package at a time does NOT converge. node stops\n` +
1130+ ` resolving a config's imports at the FIRST one with no \`dist/\`, so each\n` +
1131+ ` round can name exactly one more, however many are missing — measured at\n` +
1132+ ` six rounds down a single config's import list. Run the closure once.\n\n` +
10151133 ` Nothing was measured: no config was linted and no count was compared, so this\n` +
10161134 ` result says NOTHING about whether any declared label went untranslated — and\n` +
10171135 ` the baseline was left exactly as committed (\`--update\` included).\n` +
0 commit comments