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
20 changes: 20 additions & 0 deletions .github/workflows/lint.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -1079,6 +1079,26 @@ jobs:
- name: Cross-package test inputs
run: pnpm check:cross-package-test-inputs

# The READING half of the gate above (#10046). `--union-into` appends the
# cross-package scans to the `turbo ls` document ci.yml is about to shard,
# and `scripts/partition-test-shards.mjs` is the only thing that reads it.
# That script asserts the payload shape loudly on purpose — an
# experimental-command upgrade should become a red step naming the cause
# rather than a silently empty shard — but it had carried a `--self-test`
# that NOTHING ran since it was written, so every assertion in it, the
# partitioner's determinism and coverage pins included, evaluated never.
# A pin nobody runs is not a weaker pin, it is no pin: `--union-into` wrote
# a `count: 0` document alongside two items for as long as nobody looked,
# and the reader-side refusal that now catches that needs a live pin of its
# own or it rots the same way.
# Invoked as `node` rather than through a `pnpm check:*` alias for the same
# reason as the release-rehearsal self-test above: that alias belongs in
# root package.json, declared territory of the @changesets/cli v3 lane
# (#9465) while it runs. dispatch-gates.mjs derives gate families from
# either spelling. Pure functions, no IO, milliseconds.
- name: Shard partitioner self-test
run: node scripts/partition-test-shards.mjs --self-test

# The inventory of `packages/**` tests coupled to `examples/**` (#8754).
# Sibling of the gate above, on the axis it cannot see: that one detects
# tests whose FILESYSTEM READS escape their package, this one detects
Expand Down
47 changes: 46 additions & 1 deletion scripts/check-cross-package-test-inputs.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1009,6 +1009,34 @@ function expectedInputs(globs) {
return ['$TURBO_DEFAULT$', '!dist/**', '!coverage/**', '!.turbo/**', ...globs.map((g) => `$TURBO_ROOT$/${g}`)];
}

/**
* `turbo ls --output=json` emits `packages.count` beside `packages.items`, and
* keeps the two equal -- measured on turbo 2.10.10, all of the bare, `--filter`
* and `--affected` forms agree. So `count` is TURBO's field, not this script's
* invention, and a document we have appended to is a valid `turbo ls` payload
* only while the count moves with the array.
*
* Nothing reads `count` today, which is exactly what makes it cheap to keep
* true and expensive to leave stale: the consumer is partition-test-shards.mjs,
* whose stated posture is to assert this payload's shape LOUDLY so an
* experimental-command upgrade becomes a red step naming the cause rather than
* a silently empty shard. A hand-mutated document that contradicts itself about
* its own size is the input to that assertion. The reader now checks the
* agreement (`readPackageItems()` there), so this is a checked invariant across
* the two scripts rather than a convention someone has to remember.
*
* Reconciling inside the SERIALIZER rather than as a statement beside the write
* is the point: `unionInto()` has exactly one `writeFileSync`, and it has no
* other source of bytes, so "appended to `items` but forgot to move `count`" is
* not a state this script can reach. A separate `reconcile(); write();` pair
* would have re-created the original defect the first time someone added a
* second write path.
*/
function serializePackageList(parsed) {
parsed.packages.count = parsed.packages.items.length;
return JSON.stringify(parsed);
}

/**
* Layer A. Adds any declaring package whose globs the diff touches to the
* package list ci.yml is about to shard, so the scan runs on the PR that
Expand DownExpand Up@@ -1045,7 +1073,10 @@ function unionInto(listPath, changedPath) {
items.push({ name, path: join(REPO_ROOT, dir) });
added.push(`${name} (declared glob matched ${hit})`);
}
writeFileSync(listPath, JSON.stringify(parsed));
// The push above changed the list's size, so the size the document DECLARES
// moves with it -- serializePackageList() is the only way this function turns
// `parsed` into bytes, precisely so that cannot be skipped.
writeFileSync(listPath, serializePackageList(parsed));
if (added.length) {
console.log('Cross-package scans pulled into this run because the diff touched their declared inputs:');
for (const a of added) console.log(` + ${a}`);
Expand DownExpand Up@@ -1371,6 +1402,20 @@ function selfTest() {
ok('a single-file glob does not cover the directory it sits in', !coversDirectory('scripts', ['scripts/check-nul-bytes.mjs']));
ok('a directory that does not exist is covered by nothing', !coversDirectory('scripts/no-such-dir-9763', ['**']));

// `--union-into`'s output document. `packages.count` is turbo's field and the
// append changes the size it describes, so the two are one operation -- these
// pin the half of the cross-script invariant this side owns (the reader's
// half is partition-test-shards.mjs `--self-test`).
// These run the real serializer -- the one and only source of the bytes
// `unionInto()` writes -- and assert on the parsed-back document, so they pin
// what lands on disk rather than an intermediate object.
const written = (packages) => JSON.parse(serializePackageList({ packageManager: 'pnpm9', packages })).packages;
ok('count follows an appended item', written({ count: 0, items: [{ name: 'a', path: 'p' }, { name: 'b', path: 'q' }] }).count === 2);
ok('a correct count is left correct', written({ count: 1, items: [{ name: 'a', path: 'p' }] }).count === 1);
ok('count follows an empty list down', written({ count: 7, items: [] }).count === 0);
ok('the write never invents items', written({ count: 0, items: [] }).items.length === 0);
ok('the write leaves turbo\'s other fields alone', JSON.parse(serializePackageList({ packageManager: 'pnpm9', packages: { count: 0, items: [] } })).packageManager === 'pnpm9');

const failed = cases.filter((c) => !c.cond);
for (const c of cases) console.log(`${c.cond ? 'ok ' : 'FAIL'} ${c.label}`);
if (failed.length) {
Expand Down
84 changes: 75 additions & 9 deletions scripts/partition-test-shards.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,8 +28,9 @@
// node scripts/partition-test-shards.mjs --self-test
//
// <turbo-ls.json> is the output of `turbo ls [--affected] --output=json`
// (shape: {packages:{items:[{name,path}]}}; `turbo ls` is marked experimental,
// so the shape is asserted loudly below rather than defaulted around).
// (shape: {packages:{count,items:[{name,path}]}}; `turbo ls` is marked
// experimental, so the payload is asserted loudly in readPackageItems() below
// rather than defaulted around).
// Prints the selected shard's package names, one per line -- possibly zero
// lines, which the caller must treat as "nothing to run", NOT as "no filter":
// a `turbo run test` with no --filter args runs the entire workspace.
Expand DownExpand Up@@ -78,6 +79,51 @@ export function partition(items, shardCount) {
return bins;
}

// Reads the package list out of a `turbo ls --output=json` payload, asserting
// two independent properties. They fail for different reasons and both are
// loud, because the failure this whole file guards against is the quiet one --
// a shard that tested nothing and went green.
//
// SHAPE -- `packages.items` must be an array. `turbo ls` is experimental, so
// an upgrade that renames or restructures this becomes a red step
// naming the cause rather than an empty shard.
// SIZE -- when the payload carries turbo's own `packages.count`, it must
// equal `items.length`. turbo never breaks this itself (measured on
// 2.10.10: the bare, `--filter` and `--affected` forms all agree),
// so a payload that DOES has been hand-mutated or truncated between
// turbo and here and is not trustworthy about how many packages
// this shard is meant to see. There is exactly one such mutator in
// this repo -- `--union-into` in check-cross-package-test-inputs.mjs,
// which appends the cross-package scans the dependency graph cannot
// reach -- and it maintains `count`. This assertion is what makes
// that a checked fact instead of a convention: it wrote a `count: 0`
// document alongside two items for as long as nobody looked.
//
// A payload carrying NO `count` is accepted on purpose. The field is redundant
// with the array, so its ABSENCE cannot mis-shard anything, while its
// DISAGREEMENT can; requiring it would turn a turbo upgrade that merely dropped
// a field nobody reads into a red Test Core on every PR. Note this is a
// redundancy check, not lenient parsing -- a `count` that is present and wrong
// is rejected, never repaired.
export function readPackageItems(parsed, listPath) {
const items = parsed?.packages?.items;
if (!Array.isArray(items)) {
throw new Error(
`${listPath}: expected \`turbo ls --output=json\` shape {packages:{items:[...]}} -- ` +
'did an experimental-command upgrade change the output?'
);
}
const count = parsed.packages.count;
if (count !== undefined && count !== items.length) {
throw new Error(
`${listPath}: packages.count is ${JSON.stringify(count)} but packages.items holds ` +
`${items.length} -- the payload contradicts itself about its own size, so it has ` +
'been hand-mutated or truncated since `turbo ls` wrote it. Refusing to shard it.'
);
}
return items;
}

function selfTest() {
const mk = (name, weight) => ({ name, weight });
// Coverage + determinism: every package lands in exactly one bin, and two
Expand All@@ -101,6 +147,32 @@ function selfTest() {
if (empty.some((bin) => bin.names.length > 0)) throw new Error('empty input produced packages');
const sparse = partition([mk('only', 5)], 3);
if (sparse.flatMap((bin) => bin.names).join() !== 'only') throw new Error('sparse input lost the package');

// Payload assertions. The document reaching this script has two writers --
// `turbo ls` and `--union-into` in check-cross-package-test-inputs.mjs -- so
// "count agrees with items" is a cross-script invariant; this is its reading
// half (the writing half is that script's own `--self-test`).
const threw = (fn) => {
try {
fn();
return false;
} catch {
return true;
}
};
const doc = (packages) => ({ packageManager: 'pnpm9', packages });
const two = [{ name: 'a', path: 'p' }, { name: 'b', path: 'q' }];
if (readPackageItems(doc({ count: 2, items: two }), 'f').length !== 2) throw new Error('payload: a consistent list was rejected');
if (readPackageItems(doc({ items: two }), 'f').length !== 2) throw new Error('payload: a list with no count was rejected');
if (readPackageItems(doc({ count: 0, items: [] }), 'f').length !== 0) throw new Error('payload: a legitimately empty list was rejected');
// The exact document `--union-into` used to write: two items, count still 0.
if (!threw(() => readPackageItems(doc({ count: 0, items: two }), 'f'))) throw new Error('payload: count 0 beside 2 items was accepted');
if (!threw(() => readPackageItems(doc({ count: 3, items: two }), 'f'))) throw new Error('payload: an over-count was accepted');
if (!threw(() => readPackageItems(doc({ count: '2', items: two }), 'f'))) throw new Error('payload: a non-numeric count was accepted');
if (!threw(() => readPackageItems(doc({ count: 2 }), 'f'))) throw new Error('payload: a missing items array was accepted');
if (!threw(() => readPackageItems(doc({ count: 0, items: {} }), 'f'))) throw new Error('payload: a non-array items was accepted');
if (!threw(() => readPackageItems({}, 'f'))) throw new Error('payload: a document with no packages key was accepted');

console.log('partition-test-shards: self-test OK');
}

Expand DownExpand Up@@ -131,13 +203,7 @@ function main() {
if (shardIndex > shardCount) throw new Error(`--shard ${shardSpec}: index exceeds count`);

const parsed = JSON.parse(readFileSync(listPath, 'utf8'));
const items = parsed?.packages?.items;
if (!Array.isArray(items)) {
throw new Error(
`${listPath}: expected \`turbo ls --output=json\` shape {packages:{items:[...]}} -- ` +
'did an experimental-command upgrade change the output?'
);
}
const items = readPackageItems(parsed, listPath);
const weighted = [];
for (const it of items) {
if (typeof it?.name !== 'string' || typeof it?.path !== 'string') {
Expand Down
Loading