From 3e9c50a639d547bbef10bf18601192a9efb98516 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 07:56:40 +0000 Subject: [PATCH] fix(tooling): emit the dependency closure from check-doc-snippet-types --build-filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate's `--build-filter` emitted the packages the covered documents import. That is a true answer to a different question than "what do I build": those packages depend on workspace packages no snippet names, and without them the build the gate prescribes dies on an import the reader never wrote. Each filter now carries pnpm/turbo's dependency-closure suffix `...`. Measured on this tree from an unbuilt state: - `pnpm run build` selected 21 packages and failed with ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL @object-ui/components, TS2307: Cannot find module '@object-ui/sdui-parser'. - `pnpm run build` selected 33 and exited 0, after which the gate returns a real verdict instead of its precondition message. - `turbo run build` selects the IDENTICAL 33 tasks either way, because the `build` task declares dependsOn: ["^build"] — so the suffix is a no-op for the workflow that consumes this, and a fix everywhere else. Both spellings wear the same `--filter=` flag, so which one closed the gap was invisible at the point of use. The emission moves into an exported `buildFilterArgs` so the closure suffix is pinned by a test rather than by the list, which is supposed to move as coverage grows. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe --- content/docs/guide/ci-cd-pipeline.md | 10 +++- .../__tests__/check-doc-snippet-types.test.ts | 43 +++++++++++++++++ scripts/check-doc-snippet-types.mjs | 47 +++++++++++++++++-- 3 files changed, 94 insertions(+), 6 deletions(-) diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 619dfc5966..36d625c3d8 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -649,8 +649,14 @@ rather than new capability. snippets import must exist as `dist/*.d.ts` first. The build is filtered to exactly those packages, and the filter is emitted by the gate itself (`node scripts/check-doc-snippet-types.mjs --build-filter`) rather than hand-maintained in the workflow — so it can never drift from what the -documents import, and the cost grows only when coverage grows. This is deliberately **not** the -per-PR full-repo build the 2026-08-16 ruling on +documents import, and the cost grows only when coverage grows. Each emitted filter carries pnpm and +turbo's dependency-closure suffix (`--filter=@object-ui/react...`), because the packages the +documents import are not a buildable unit on their own: they depend on workspace packages no snippet +names, and those have to exist first. Under `turbo run build` the suffix selects the same tasks +`dependsOn: ["^build"]` already did; under `pnpm ... run build`, which selects exactly what it +matches, it is the difference between a build that completes and one that dies on an import the +reader never wrote ([#5911](https://github.com/objectstack-ai/objectui/issues/5911)). This is +deliberately **not** the per-PR full-repo build the 2026-08-16 ruling on [#4846](https://github.com/objectstack-ai/objectui/issues/4846) rejected; see *Published Dist Gate* below. diff --git a/scripts/__tests__/check-doc-snippet-types.test.ts b/scripts/__tests__/check-doc-snippet-types.test.ts index 9ef29f66d5..3a85b12411 100644 --- a/scripts/__tests__/check-doc-snippet-types.test.ts +++ b/scripts/__tests__/check-doc-snippet-types.test.ts @@ -15,6 +15,7 @@ import { UNGATED_DOCS, analyze, blockingPreconditions, + buildFilterArgs, deriveDeclaredDependencyPaths, derivePackageTypePaths, findInstalledCopy, @@ -480,6 +481,48 @@ describe('wiring — a script nothing runs is not a gate', () => { ).toBeLessThan(invoke); }); + /** + * objectui#5911 — the emitted list must be BUILDABLE, not merely accurate. + * + * The set the gate computes is the packages the DOCUMENTS import. That is a + * true answer to a different question than "what do I build": those packages + * depend on workspace packages no snippet names, and without them the build + * the gate prescribes dies on an import the reader never wrote. Measured on + * this tree before the fix: `pnpm run build` selected 21 packages + * and failed with `TS2307: Cannot find module '@object-ui/sdui-parser'`. + * + * The suffix is pinned rather than the list, because the list is supposed to + * move as coverage grows — that is the property `--build-filter` exists for. + */ + it('emits the dependency-closure suffix on every filter, so the build it prescribes is complete', () => { + const args = buildFilterArgs(['@object-ui/react', '@object-ui/core']); + expect(args).toBe('--filter=@object-ui/core... --filter=@object-ui/react...'); + for (const word of args.split(' ')) { + expect(word, 'a bare --filter= builds the package without what it depends on').toMatch( + /^--filter=\S+\.\.\.$/, + ); + } + }); + + it('keeps the emission sorted and shell-safe — the workflow word-splits it unquoted', () => { + const args = buildFilterArgs(['@object-ui/types', '@object-ui/app-shell', '@object-ui/i18n']); + expect(args.split(' ')).toEqual([ + '--filter=@object-ui/app-shell...', + '--filter=@object-ui/i18n...', + '--filter=@object-ui/types...', + ]); + expect(args, 'a glob or quote here would be re-interpreted by the runner shell').not.toMatch( + /["'`$*?]/, + ); + }); + + it('names every package it is given, so the closure suffix never replaces a name', () => { + const names = ['@object-ui/react', '@object-ui/core', '@object-ui/i18n']; + const args = buildFilterArgs(names); + for (const name of names) expect(args).toContain(`--filter=${name}...`); + expect(args.split(' ')).toHaveLength(names.length); + }); + it('is reachable by name from the workspace root', () => { const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); expect(pkg.scripts['check:doc-snippets']).toBe(`node ${SCRIPT}`); diff --git a/scripts/check-doc-snippet-types.mjs b/scripts/check-doc-snippet-types.mjs index ec68e7be71..1b3391b4c5 100644 --- a/scripts/check-doc-snippet-types.mjs +++ b/scripts/check-doc-snippet-types.mjs @@ -5,7 +5,8 @@ * reader who copies it actually imports. * * Run: node scripts/check-doc-snippet-types.mjs (also `pnpm check:doc-snippets`) - * node scripts/check-doc-snippet-types.mjs --build-filter (turbo filter args) + * node scripts/check-doc-snippet-types.mjs --build-filter (filter args for + * turbo or pnpm; each carries the `...` dependency-closure suffix) * Exit: 0 = every covered snippet parses and type-checks, the harness proved * itself on its own controls, and the coverage ledger is exact. * 1 = THE GATE RAN AND FOUND ERRORS. A snippet failed to parse or to @@ -1082,18 +1083,56 @@ export function blockingPreconditions(findings) { ); } +/** + * The filter arguments that name the packages the covered snippets import, each + * carrying pnpm/turbo's DEPENDENCY-CLOSURE suffix `...` ("this package AND the + * packages it depends on"). + * + * The closure suffix is why this is a function and not an inline `map`. The set + * this gate computes is the packages the DOCUMENTS import, which is not a + * buildable unit: a package the docs import pulls in workspace packages no + * snippet ever names, and those still have to be built before the imported one + * can compile. Emitting the bare names left that gap to the caller's tool to + * close by accident (objectui#5911): + * + * - `turbo run build ` closed it silently, because this repository's + * `build` task declares `dependsOn: ["^build"]`. Measured on this tree, the + * bare list and the `...` list select the IDENTICAL 33 tasks, so the suffix + * changes nothing for the workflow that consumes this — it is a no-op where + * the closure was already right. + * - `pnpm run build` did NOT, because pnpm's `--filter` selects exactly + * what it matches and runs each package's own script. Measured on this tree: + * 21 packages selected instead of 33, and the build died at + * `ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL @object-ui/components` on + * `TS2307: Cannot find module '@object-ui/sdui-parser'` — a workspace + * package no snippet imports, so nothing put it in the list. + * + * Both spellings wear the same `--filter=` flag, so which one closes the gap was + * invisible at the point of use. Carrying the closure in the emitted list makes + * the answer independent of the tool the reader reaches for, which matters most + * for the reader who is here because the gate just told them to build something. + * + * @param {Iterable} packages package names the covered snippets import + * @returns {string} space-separated `--filter=...` words, sorted + */ +export function buildFilterArgs(packages) { + return [...packages].sort().map((n) => `--filter=${n}...`).join(' '); +} + function main() { const argv = process.argv.slice(2); const state = analyze({}); if (argv.includes('--build-filter')) { - // Turbo filter arguments for exactly the packages the covered snippets - // import. Coverage grows -> the build grows, and nothing else does. + // Filter arguments for exactly the packages the covered snippets import, + // plus their dependency closure. Coverage grows -> the build grows, and + // nothing else does. Why the closure travels in the list: see + // `buildFilterArgs` above. // ⛔ This query answers from an UNBUILT tree by design and must keep exiting // 0 there: it is what the workflow runs to learn what to build, one step // BEFORE the build. Making it share the precondition exit would deadlock the // gate against its own build step. - process.stdout.write([...state.neededPackages].sort().map((n) => `--filter=${n}`).join(' ')); + process.stdout.write(buildFilterArgs(state.neededPackages)); process.stdout.write('\n'); return 0; }