Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions content/docs/guide/ci-cd-pipeline.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand Down
43 changes: 43 additions & 0 deletions scripts/__tests__/check-doc-snippet-types.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import {
UNGATED_DOCS,
analyze,
blockingPreconditions,
buildFilterArgs,
deriveDeclaredDependencyPaths,
derivePackageTypePaths,
findInstalledCopy,
Expand DownExpand Up@@ -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 <bare list> 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}`);
Expand Down
47 changes: 43 additions & 4 deletions scripts/check-doc-snippet-types.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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 <args>` 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 <args> 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<string>} packages package names the covered snippets import
* @returns {string} space-separated `--filter=<pkg>...` 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;
}
Expand Down
Loading