From 703c5ff660ef094399136baa40c933eaf594e7e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 15:13:17 +0000 Subject: [PATCH] =?UTF-8?q?fix(scripts):=20check-i18n-coverage=20=E7=9A=84?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E5=89=8D=E7=BD=AE=E6=94=B9=E4=B8=BA=E7=9C=9F?= =?UTF-8?q?=E6=A3=80=E6=9F=A5,=E4=B8=8D=E5=86=8D=E8=AE=A9=E7=AC=AC?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=20example=20=E9=A1=B6=E7=BD=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/check-i18n-coverage.mjs` 与 #5217 修的门禁是 lint.yml 里紧邻的两步, 带着同一句只写在注释里的前置("Requires the workspace build")。在装完依赖但 未构建的 worktree 里,它抛一个未捕获异常 + node 栈: Error: os lint produced no output for examples/app-crm/objectstack.config.ts 成因只有一个,而且不在那个文件里:门禁跑的是构建产物,oclif 从 dist/commands 解析 `os lint`,CLI 没 build 时什么也不输出 —— app-crm 只是恰好第一个被处理。 诊断把读者送进一个完全无辜的示例配置。 改动与 #5217 落地形状同构: - 进入 per-config 循环之前一次前置判定(探测 oclif 真正要加载的命令文件), 失败时一条前置结论 + 一句修法,并声明「什么都没测量」; - 循环内保留签名安全网,覆盖探测看不见的 stale/partial dist,首个 config 即 中止,并明说该 config 不是成因; - 两个纯分类器(oclifCommandFileFor / looksLikeMissingCliCommand)提取到 scripts/cli-build-prerequisite.mjs 共享,check-i18n-bundles.mjs 改为 import —— 两份拷贝就是两套事实来源(#5186),而 oclif 硬换行这类知识只会被改一边; - 补 --self-test(语料逐字录自本仓未构建实跑),并按仓内惯例接进 package.json 的 check:i18n-coverage,否则自测永远不跑。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3 --- package.json | 2 +- scripts/check-i18n-bundles.mjs | 83 ++------- scripts/check-i18n-coverage.mjs | 261 ++++++++++++++++++++++++++++- scripts/cli-build-prerequisite.mjs | 106 ++++++++++++ 4 files changed, 380 insertions(+), 72 deletions(-) create mode 100644 scripts/cli-build-prerequisite.mjs diff --git a/package.json b/package.json index d91efdde45..58615fa8a3 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "lint": "eslint . --no-inline-config", "i18n:extract": "tsx packages/cli/bin/run-dev.js i18n extract packages/platform-objects/scripts/i18n-extract.config.ts --locales=zh-CN,ja-JP,es-ES --fill=default --out=packages/platform-objects/src/apps/translations", "check:i18n": "node scripts/check-i18n-bundles.mjs --self-test && node scripts/check-i18n-bundles.mjs", - "check:i18n-coverage": "node scripts/check-i18n-coverage.mjs", + "check:i18n-coverage": "node scripts/check-i18n-coverage.mjs --self-test && node scripts/check-i18n-coverage.mjs", "check:nul-bytes": "node scripts/check-nul-bytes.mjs --self-test && node scripts/check-nul-bytes.mjs", "check:doc-authoring": "node scripts/check-doc-authoring.mjs --self-test && node scripts/check-doc-authoring.mjs", "check:docs-audit-scope": "node scripts/docs-audit/affected-docs.mjs --self-test && node scripts/docs-audit/check-audit-scope.mjs --self-test && node scripts/docs-audit/check-audit-scope.mjs", diff --git a/scripts/check-i18n-bundles.mjs b/scripts/check-i18n-bundles.mjs index 9193f912b4..64006e14ff 100644 --- a/scripts/check-i18n-bundles.mjs +++ b/scripts/check-i18n-bundles.mjs @@ -66,19 +66,22 @@ // "Prefer failing to falling back" (AGENTS.md, route & surface ownership §3): // the prerequisite verdict is a HARD failure that states it checked nothing — // never a skip, and never anything a reader can mistake for "bundles are fine". +// +// The two pure functions that answer it moved to `scripts/cli-build-prerequisite.mjs` +// when #5862 found the same missing precondition in `check-i18n-coverage.mjs`, one +// lint.yml step away. They are imported, not copied: see that module's header. import { spawnSync } from 'node:child_process'; import { readFileSync, existsSync } from 'node:fs'; import { readdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; +import { + CLI, + CLI_BUILD_FIX, + looksLikeMissingCliCommand, + oclifCommandFileFor, + resolveCliCommandFile, +} from './cli-build-prerequisite.mjs'; -const CLI = 'packages/cli/bin/run.js'; -/** - * `CLI` is a SOURCE file — four lines handing off to `@oclif/core` — so it is - * present in an unbuilt tree and proves nothing. What the gate actually depends - * on is the built command surface `bin/run.js` makes oclif resolve, which is why - * the prerequisite probe below reads the package rather than the bin stub. - */ -const CLI_PKG = 'packages/cli'; /** The one command this gate invokes per package, as oclif topic/command parts. */ const EXTRACT_COMMAND_ID = ['i18n', 'extract']; const write = process.argv.includes('--write'); @@ -173,55 +176,10 @@ function collectDriftedBundles(text) { return [...String(text ?? '').matchAll(/(?:out of date|missing):\s+(\S+)/g)].map((m) => m[1]); } -/** - * Where oclif will look for the command this gate runs, derived from the CLI - * package's own `oclif.commands.target` (#5217). Pure: takes the parsed - * package.json, returns a repo-relative path or a reason it cannot tell. - * - * Derived rather than hardcoded for the same reason the extract flags come from - * each config's docstring: `dist/commands` is the CLI's declaration of where its - * commands live, and a gate that restates it would keep probing the old path for - * a release after someone moves it — passing while checking nothing. - */ -function oclifCommandFileFor(pkgJson, commandId) { - const target = pkgJson?.oclif?.commands?.target ?? pkgJson?.oclif?.commands; - if (typeof target !== 'string' || !target) { - return { unknown: `${CLI_PKG}/package.json declares no oclif.commands.target` }; - } - const rel = target.replace(/^\.\//, '').replace(/\/+$/, ''); - return { file: join(CLI_PKG, rel, ...commandId.slice(0, -1), `${commandId.at(-1)}.js`) }; -} - -/** - * oclif's own "command not found", which is what an unbuilt (or half-built) - * CLI answers with. The in-loop safety net for the prerequisite probe, and it has - * to survive oclif's line wrapping to be worth anything: oclif hard-wraps that - * one sentence across two or three ` › `-prefixed lines, and it wraps at a width - * that depends on the config path's length, so the real corpus contains BOTH - * - * " › Error: command \n › i18n:extract: not \n › found" - * " › Error: command i18n:extract: not found" - * - * — the second one split inside the path itself. A per-line regex (the obvious - * first implementation, and the one that reads as correct) matches NEITHER. So - * the prefixes come off and the whole text is flattened before matching. - * - * Returns the matched SENTENCE (re-joined into one readable line) so the caller - * can quote it as evidence, or '' for no match. Returning the whole flattened - * text instead is a trap this returned from once in review: a stale-dist run - * also carries a node `Warning:` block above the error, and quoting the flattened - * text put that unrelated block in the report while the actual sentence sat past - * the truncation. - */ -function looksLikeMissingCliCommand(text) { - const flat = String(text ?? '') - .split('\n') - .map((l) => l.replace(/^\s*›\s*/, '')) - .join(' ') - .replace(/\s+/g, ' ') - .trim(); - return flat.match(/Error:\s*command\b.*?\bnot found\b/)?.[0] ?? ''; -} +// `oclifCommandFileFor` and `looksLikeMissingCliCommand` — the two classifiers the +// prerequisite is built from — now live in `./cli-build-prerequisite.mjs`, shared +// with `check-i18n-coverage.mjs` (#5862). The self-test below still drives them +// directly, so this gate's corpus keeps proving them from here. /** stderr lines that are neither the lint signature nor blank — pass them through. */ function passthroughStderrLines(text) { @@ -404,7 +362,7 @@ function reportPrerequisiteNotMet(headline, detail) { console.error( `\ncheck-i18n-bundles: PREREQUISITE NOT MET — ${headline}\n\n` + detail.map((l) => (l ? ` ${l}` : '')).join('\n') + - `\n\n Fix: pnpm exec turbo run build --filter=@objectstack/cli\n\n` + + `\n\n Fix: ${CLI_BUILD_FIX}\n\n` + ` Nothing was checked: no bundle was compared and no config was parsed, so this\n` + ` result says NOTHING about whether the committed translation bundles are in sync.\n` + ` (Exit code 1 — but piping this gate reports the PIPE's status, so\n` + @@ -429,14 +387,7 @@ function reportPrerequisiteNotMet(headline, detail) { * is only the cheap early answer. */ function checkCliBuildPrerequisite() { - let pkgJson; - try { - pkgJson = JSON.parse(readFileSync(join(CLI_PKG, 'package.json'), 'utf8')); - } catch (e) { - console.error(`check-i18n-bundles: could not read ${CLI_PKG}/package.json (${e.message}) — build prerequisite not pre-checked`); - return; - } - const resolved = oclifCommandFileFor(pkgJson, EXTRACT_COMMAND_ID); + const resolved = resolveCliCommandFile(EXTRACT_COMMAND_ID); if (resolved.unknown) { console.error(`check-i18n-bundles: ${resolved.unknown} — build prerequisite not pre-checked`); return; diff --git a/scripts/check-i18n-coverage.mjs b/scripts/check-i18n-coverage.mjs index 6262e85c82..62254389f0 100644 --- a/scripts/check-i18n-coverage.mjs +++ b/scripts/check-i18n-coverage.mjs @@ -21,6 +21,7 @@ // run with --update to ratchet down and commit the baseline. // // node scripts/check-i18n-coverage.mjs [--update] +// node scripts/check-i18n-coverage.mjs --self-test # prove the classifiers go red // // Counts only what `os lint` shows a user: the platform metadata-form baseline // is folded away (it is owned and translated by platform-objects), so this @@ -29,17 +30,51 @@ // does not, and that set is what must not grow. // // Requires the workspace build (it runs the built CLI), so it belongs after the -// build step with the other consumer gates. +// build step with the other consumer gates. `--self-test` does not: it drives the +// pure classifiers against recorded samples, no build and no CLI. +// +// That requirement is now CHECKED, not merely declared (#5862). It used to be the +// sentence above and nothing else, and in an installed-but-unbuilt worktree the +// gate answered with an uncaught exception plus a node stack: +// +// Error: os lint produced no output for examples/app-crm/objectstack.config.ts +// at countI18nIssues (…/check-i18n-coverage.mjs:101:28) +// +// — which names an entirely innocent example config. There is exactly one cause, +// and it is not in that file: this gate runs the BUILT CLI, oclif resolves +// `os lint` from `dist/commands`, and an unbuilt CLI prints nothing at all. The +// first config to be processed simply took the blame for the environment. +// +// CI never sees this (lint.yml's `typecheck` job runs `Build workspace packages` +// well before `pnpm check:i18n-coverage`), which is exactly why it survived: the +// only people who meet it are the ones reproducing a red i18n CI locally, at the +// moment a wrong first diagnosis costs the most. `checkCliBuildPrerequisite()` +// now answers it once, before the per-config loop. +// +// Same shape as the neighbouring `check-i18n-bundles.mjs` step (#5217), sharing +// its two pure classifiers from `scripts/cli-build-prerequisite.mjs` rather than +// copying them. "Prefer failing to falling back" (AGENTS.md, route & surface +// ownership §3): the prerequisite verdict is a HARD failure that states it +// measured nothing — never a skip, and never anything a reader can mistake for +// "no config declares an untranslated label". import { execFileSync } from 'node:child_process'; import { readdirSync, readFileSync, writeFileSync, existsSync, openSync, closeSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; +import { + CLI, + CLI_BUILD_FIX, + looksLikeMissingCliCommand, + oclifCommandFileFor, + resolveCliCommandFile, +} from './cli-build-prerequisite.mjs'; // NOTE: covers both `examples/*` and every package with an extract config. const EXAMPLES_DIR = 'examples'; const BASELINE_PATH = 'scripts/i18n-coverage-baseline.json'; -const CLI = 'packages/cli/bin/run.js'; +/** The one command this gate invokes per config, as oclif topic/command parts. */ +const LINT_COMMAND_ID = ['lint']; const update = process.argv.includes('--update'); @@ -73,6 +108,19 @@ function discoverPackages(dir = 'packages', out = []) { return out.sort(); } +/** + * The gate's content classifier: how many of a report's issues are i18n ones. + * Pure, so `--self-test` can drive it with a recorded report instead of a build. + * + * The `i18n/` prefix is the contract with `os lint --json`; everything else in + * the report belongs to other rules and must not move this number. + */ +function countI18nRuleIssues(report) { + const issues = report?.issues ?? []; + if (!Array.isArray(issues)) return 0; + return issues.filter((i) => typeof i?.rule === 'string' && i.rule.startsWith('i18n/')).length; +} + /** * Untranslated declared strings `os lint` would show for one config. * @@ -87,17 +135,46 @@ function countI18nIssues(configPath) { const tmp = join(tmpdir(), `os-lint-${randomUUID()}.json`); const fd = openSync(tmp, 'w'); try { + let stderr = ''; try { execFileSync(process.execPath, [CLI, 'lint', configPath, '--json'], { stdio: ['ignore', fd, 'pipe'], }); - } catch { + } catch (err) { // `os lint` exits non-zero whenever the config has errors of any kind; // the JSON payload is still what we want. A run that produced no output // is a hard failure — never silently a zero. + // + // stderr is kept, not discarded: oclif reports an unresolvable command + // there, and that text is the difference between "this config is broken" + // and "your workspace is not built". `execFileSync` surfaces it only on + // the throw path, which is the only path this can arrive on — a command + // oclif cannot find always exits non-zero (measured: 2). + stderr = String(err?.stderr ?? ''); } closeSync(fd); const raw = readFileSync(tmp, 'utf8'); + + // The prerequisite's safety net, checked BEFORE the empty-output failure + // below — otherwise a missing build reports as "no output for " and + // sends the reader into a config that is not at fault (#5862). It fires on + // what the pre-loop probe cannot see: a stale build whose command surface no + // longer answers to this id, a partial dist that satisfies the file check, or + // a package.json shape the derivation could not read. Aborting on the FIRST + // config is the point — every remaining one fails for the same one reason. + const signature = looksLikeMissingCliCommand(`${raw}\n${stderr}`); + if (signature) { + reportPrerequisiteNotMet('the built CLI cannot resolve the command this gate runs', [ + `\`os ${LINT_COMMAND_ID.join(' ')}\` exited with oclif's own "command not found":`, + ``, + ` ${signature.length > 160 ? `${signature.slice(0, 160)}…` : signature}`, + ``, + `${configPath} is NOT at fault — it is simply the first config this gate`, + `reached. Every remaining one would fail the same way for the same reason,`, + `so the loop stopped here rather than blaming an example.`, + ]); + } + if (!raw.trim()) throw new Error(`os lint produced no output for ${configPath}`); let report; try { @@ -106,13 +183,187 @@ function countI18nIssues(configPath) { throw new Error(`os lint produced unparseable JSON for ${configPath} (${raw.length} bytes): ${err.message}`); } if (report.error) throw new Error(`os lint failed for ${configPath}: ${report.error}`); - const issues = report.issues ?? []; - return issues.filter((i) => typeof i.rule === 'string' && i.rule.startsWith('i18n/')).length; + return countI18nRuleIssues(report); } finally { try { unlinkSync(tmp); } catch { /* already gone */ } } } +// --------------------------------------------------------------------------- +// Self-test — the proof that each classifier can go red. A gate observed only +// green is indistinguishable from a gate that matches nothing (#4690), and the +// prerequisite classifier is the one that can never be observed red in CI: CI +// always builds first, so nothing else would ever exercise it. +// --------------------------------------------------------------------------- + +function selfTest() { + const failures = []; + const expect = (name, cond, detail) => { + if (!cond) failures.push(`${name} — ${detail}`); + }; + + // Both recorded VERBATIM from `node packages/cli/bin/run.js lint --json` + // in an installed-but-unbuilt worktree at f192981fe — the run reproduced in + // #5862. oclif wraps its one-sentence error at a width that depends on the + // config path, so the same failure arrives in two shapes: the examples' short + // paths stay on one line, while the package extract configs `discoverPackages()` + // feeds this same gate wrap across three. Keep both — a per-line regex passes + // the first and fails the second, which is the implementation this corpus + // exists to reject. + const OCLIF_LINT_UNWRAPPED = ' › Error: command lint:examples/app-crm/objectstack.config.ts not found'; + const OCLIF_LINT_WRAPPED_3_LINE = + ' › Error: command \n › lint:packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts not \n › found'; + + expect('#5862 unwrapped form', !!looksLikeMissingCliCommand(OCLIF_LINT_UNWRAPPED), 'the single-line form must match'); + expect('#5862 wrapped 3-line', !!looksLikeMissingCliCommand(OCLIF_LINT_WRAPPED_3_LINE), 'oclif line wrapping must not hide the signature'); + expect( + '#5862 flattens for the message', + looksLikeMissingCliCommand(OCLIF_LINT_WRAPPED_3_LINE) === + 'Error: command lint:packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts not found', + `the evidence line must come back as one readable sentence; got ${JSON.stringify(looksLikeMissingCliCommand(OCLIF_LINT_WRAPPED_3_LINE))}`, + ); + + // Must not contaminate — or be contaminated by — the content verdict. A real + // untranslated-label result on a correctly built workspace must never be + // reported as "your workspace is not built", which would send the reader to run + // a build that changes nothing and hide the actual coverage regression. + const REAL_LINT_REPORT = { + issues: [ + { rule: 'i18n/missing-translation', severity: 'warning', message: "object 'contacts' label is untranslated for zh-CN" }, + { rule: 'i18n/missing-translation', severity: 'warning', message: "field 'contacts.email' label is untranslated for ja-JP" }, + { rule: 'schema/unknown-key', severity: 'error', message: "'foo' is not a declared object key" }, + ], + }; + expect( + '#5862 a real report is not a missing build', + !looksLikeMissingCliCommand(JSON.stringify(REAL_LINT_REPORT)), + 'lint output leaked into the prerequisite verdict', + ); + expect( + '#5862 empty output is not a missing build', + !looksLikeMissingCliCommand(''), + 'no output at all is a different failure and keeps its own message', + ); + expect( + '#5862 unrelated failure is not a missing build', + !looksLikeMissingCliCommand("Error: Cannot find module 'node:fs/promises'\n at ModuleJob.run"), + 'only oclif command resolution may claim this verdict', + ); + + // The probe derives its path from the CLI's own declaration; pin the derivation + // against the real oclif block so a moved `target` is caught here rather than by + // a probe that quietly checks a path nothing writes any more. `lint` is a + // SINGLE-segment id — the topic-less shape #5217's corpus never exercised. + const derived = oclifCommandFileFor( + { oclif: { commands: { strategy: 'pattern', target: './dist/commands', glob: '**/*.js' } } }, + LINT_COMMAND_ID, + ); + expect('#5862 derives the command file', derived.file === 'packages/cli/dist/commands/lint.js', `got ${JSON.stringify(derived)}`); + const undeclaredTarget = oclifCommandFileFor({ oclif: {} }, LINT_COMMAND_ID); + expect( + '#5862 unreadable shape defers, loudly', + !!undeclaredTarget.unknown && !undeclaredTarget.file, + `an unreadable oclif block must yield a reason, not a guessed path; got ${JSON.stringify(undeclaredTarget)}`, + ); + + // The content classifier. Its silent-failure mode is narrower than the + // prerequisite's — a classifier that stopped matching would drive every count + // to 0 and the ratchet's DOWN direction fails on that — but the `i18n/` prefix + // is a contract with `os lint --json`, so pin it rather than infer it. + expect('#5862 counts i18n rules', countI18nRuleIssues(REAL_LINT_REPORT) === 2, `got ${countI18nRuleIssues(REAL_LINT_REPORT)}`); + expect( + '#5862 ignores other rules', + countI18nRuleIssues({ issues: [{ rule: 'schema/unknown-key' }, { rule: 'i18nx/not-ours' }] }) === 0, + 'only the `i18n/` namespace counts', + ); + expect('#5862 tolerates an issue-less report', countI18nRuleIssues({}) === 0, 'a clean report is 0, never a crash'); + + if (failures.length) { + console.error(`✗ check:i18n-coverage --self-test — ${failures.length} failure(s)\n`); + for (const f of failures) console.error(` ${f}`); + process.exit(1); + } + console.log('✓ check:i18n-coverage --self-test — the missing-CLI-build and i18n-rule classifiers both go red, and stay distinct.'); +} + +if (process.argv.includes('--self-test')) { + selfTest(); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// The prerequisite: this gate runs the BUILT CLI (#5862). +// --------------------------------------------------------------------------- + +/** + * ONE prerequisite and ONE command to satisfy it — never per config, and never + * phrased so it can be mistaken for a verdict about a config's translations. + * + * Exits 1, the same code the real verdict uses: any wrapper that treats non-zero + * as failure keeps behaving identically, and inventing a second failure code + * would be a new contract nobody asked for. + * + * The remedy is stated at TWO widths on purpose. `CLI_BUILD_FIX` is the command + * that clears exactly what was checked, and nothing more — this probe measures the + * CLI and may not claim anything about the rest of the tree. But unlike the + * neighbouring bundles gate, this one also lints `examples/*`, whose configs import + * other workspace packages by name; on a never-built tree, clearing only the CLI + * just moves the wall (measured: `Cannot find module '…/@objectstack/connector-mcp/ + * dist/index.mjs'` from app-showcase). Naming the fuller command as the fuller + * remedy costs one line and keeps this message from under-prescribing — which is + * the same defect, one step later, as the diagnosis it replaces. + */ +function reportPrerequisiteNotMet(headline, detail) { + console.error( + `\ncheck-i18n-coverage: PREREQUISITE NOT MET — ${headline}\n\n` + + detail.map((l) => (l ? ` ${l}` : '')).join('\n') + + `\n\n Fix: ${CLI_BUILD_FIX}\n` + + ` …and on a tree that has never been built, \`pnpm build\`: this gate also\n` + + ` lints \`examples/*\`, whose configs import other workspace packages.\n\n` + + ` Nothing was measured: no config was linted and no count was compared, so this\n` + + ` result says NOTHING about whether any declared label went untranslated — and\n` + + ` the baseline was left exactly as committed (\`--update\` included).\n` + + ` (Exit code 1 — but piping this gate reports the PIPE's status, so\n` + + ` \`pnpm check:i18n-coverage | tail -4\` reads green either way. Use \`echo "EXIT=$?"\`.)`, + ); + process.exit(1); +} + +/** + * Answered once, before the per-config loop — so a missing build costs one + * verdict instead of an exception thrown from inside the first example, and + * costs zero CLI spawns. + * + * Probes the exact command FILE the loop needs, not merely `dist/`: an + * interrupted or partial build leaves the directory behind, and a `dist/` that + * exists without `commands/lint.js` reproduces the very stack trace this check + * exists to prevent. + * + * When the CLI's package.json shape moves out from under the derivation, this + * says so on stderr and defers to the in-loop signature net rather than failing: + * a probe that cannot read the declaration must not turn a correctly-built + * workspace red. It stays audible either way — the net is the enforcement, this + * is only the cheap early answer. + */ +function checkCliBuildPrerequisite() { + const resolved = resolveCliCommandFile(LINT_COMMAND_ID); + if (resolved.unknown) { + console.error(`check-i18n-coverage: ${resolved.unknown} — build prerequisite not pre-checked`); + return; + } + if (existsSync(resolved.file)) return; + reportPrerequisiteNotMet('the workspace CLI is not built', [ + `This gate counts what \`os lint\` reports, and it runs the BUILT CLI.`, + `${CLI} is only a source stub that hands off to oclif, which`, + `resolves \`os ${LINT_COMMAND_ID.join(' ')}\` from the compiled output — and that command`, + `is not there:`, + ``, + ` ${resolved.file}`, + ]); +} + +checkCliBuildPrerequisite(); + const current = {}; for (const configPath of [...discoverExamples(), ...discoverPackages()]) { current[configPath] = countI18nIssues(configPath); diff --git a/scripts/cli-build-prerequisite.mjs b/scripts/cli-build-prerequisite.mjs new file mode 100644 index 0000000000..74f581b931 --- /dev/null +++ b/scripts/cli-build-prerequisite.mjs @@ -0,0 +1,106 @@ +// cli-build-prerequisite — the one answer to "is the workspace CLI built?", +// shared by the gates that shell out to the BUILT CLI. +// +// `packages/cli/bin/run.js` is a four-line SOURCE stub handing off to +// `@oclif/core`, so it is present in an unbuilt tree and proves nothing. What a +// gate actually depends on is the compiled command surface oclif resolves from +// `oclif.commands.target` — and when that is missing, the CLI answers nothing and +// the gate blames whichever input file it happened to be holding. +// +// #5217 wrote these two pure functions inside `check-i18n-bundles.mjs`; #5862 +// found the identical missing precondition one `lint.yml` step away, in +// `check-i18n-coverage.mjs`. They live here, in one module, rather than as two +// copies: the knowledge they encode is not obvious — oclif's hard wrapping below +// is the whole reason the naive implementation fails — and a copy is a second +// source of truth that gets fixed on one side only (#5186). +// +// What is deliberately NOT shared is the WORDING. Only the gate knows what it did +// not check, and "nothing was checked" is the load-bearing half of the message. +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** The bin stub every gate spawns. A source file — its presence means nothing. */ +export const CLI = 'packages/cli/bin/run.js'; +/** The package whose `oclif` block declares where the built commands land. */ +export const CLI_PKG = 'packages/cli'; +/** The one command that satisfies the prerequisite, for every gate that reports it. */ +export const CLI_BUILD_FIX = 'pnpm exec turbo run build --filter=@objectstack/cli'; + +/** + * Where oclif will look for a command, derived from the CLI package's own + * `oclif.commands.target` (#5217). Pure: takes the parsed package.json and the + * command id as oclif topic/command parts, returns a repo-relative path or a + * reason it cannot tell. + * + * Derived rather than hardcoded for the same reason the extract flags come from + * each config's docstring: `dist/commands` is the CLI's declaration of where its + * commands live, and a gate that restates it would keep probing the old path for + * a release after someone moves it — passing while checking nothing. + * + * @param {any} pkgJson parsed `packages/cli/package.json` + * @param {string[]} commandId e.g. `['i18n', 'extract']`, or `['lint']` + * @returns {{ file: string } | { unknown: string }} + */ +export function oclifCommandFileFor(pkgJson, commandId) { + const target = pkgJson?.oclif?.commands?.target ?? pkgJson?.oclif?.commands; + if (typeof target !== 'string' || !target) { + return { unknown: `${CLI_PKG}/package.json declares no oclif.commands.target` }; + } + const rel = target.replace(/^\.\//, '').replace(/\/+$/, ''); + return { file: join(CLI_PKG, rel, ...commandId.slice(0, -1), `${commandId.at(-1)}.js`) }; +} + +/** + * oclif's own "command not found", which is what an unbuilt (or half-built) + * CLI answers with. The in-loop safety net for the probe below, and it has to + * survive oclif's line wrapping to be worth anything: oclif hard-wraps that one + * sentence across two or three ` › `-prefixed lines, at a width that depends on + * the argument's length, so the real corpus contains BOTH + * + * " › Error: command \n › i18n:extract: not \n › found" + * " › Error: command i18n:extract: not found" + * + * — the second one split inside the path itself. A per-line regex (the obvious + * first implementation, and the one that reads as correct) matches NEITHER. So + * the prefixes come off and the whole text is flattened before matching. + * + * Returns the matched SENTENCE (re-joined into one readable line) so the caller + * can quote it as evidence, or '' for no match. Returning the whole flattened + * text instead is a trap this returned from once in review: a stale-dist run also + * carries a node `Warning:` block above the error, and quoting the flattened text + * put that unrelated block in the report while the actual sentence sat past the + * truncation. + * + * @param {string} text combined stdout/stderr + * @returns {string} the error sentence, or '' when this is not that failure + */ +export function looksLikeMissingCliCommand(text) { + const flat = String(text ?? '') + .split('\n') + .map((l) => l.replace(/^\s*›\s*/, '')) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + return flat.match(/Error:\s*command\b.*?\bnot found\b/)?.[0] ?? ''; +} + +/** + * `oclifCommandFileFor` against the real `packages/cli/package.json`. + * + * Both failure modes come back as one `unknown` REASON rather than a guessed + * path, because a probe that cannot read the declaration must not turn a + * correctly-built workspace red: the caller prints the reason and defers to its + * in-loop signature net, which is the actual enforcement. + * + * @param {string[]} commandId + * @returns {{ file: string } | { unknown: string }} + */ +export function resolveCliCommandFile(commandId) { + let pkgJson; + try { + pkgJson = JSON.parse(readFileSync(join(CLI_PKG, 'package.json'), 'utf8')); + } catch (e) { + return { unknown: `could not read ${CLI_PKG}/package.json (${e.message})` }; + } + return oclifCommandFileFor(pkgJson, commandId); +}