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
138 changes: 136 additions & 2 deletions scripts/pm/dispatch-gates.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,11 @@
* many gates read the whole tree (check:nul-bytes) or a convention rather
* than a path. The PM's judgment call stays a judgment call; what this
* script removes is the memory-shaped half (which named checks exist and
* where they live).
* where they live);
* - a CONVENTION-TRIGGERED check is one the path derivation can never reach,
* because it counts a population it computes for itself and so names no
* path literal to match. Those are derived from the change's KIND instead
* and printed under their own heading — see CHANGE_KIND_GATES.
*
* The output is print-only and exits 0 on a completed derivation; a run that
* cannot read the workflows or package.json exits non-zero (#4690: unreadable
Expand DownExpand Up@@ -134,6 +138,103 @@ export function hintCovers(hint, inputPath) {
return inputPath.startsWith(plain) || plain.startsWith(inputPath);
}

// ---------------------------------------------------------------------------
// Change-kind derivation — the gates a path match can never reach
// ---------------------------------------------------------------------------

/**
* Is this path a test file, judged the way the gates below judge it?
*
* Both gates classify by the FILENAME infix (`*.test.*` / `*.spec.*`), not by
* directory: a helper at `__tests__/fixtures.ts` is test-adjacent but neither
* gate counts it — it falls in their NON-test population, where the ordinary
* blocking lint rule applies instead. Matching directories here would name two
* gates that cannot move, which is the failure mode this whole script exists to
* avoid. The extension set is the UNION of the two gates' own (one counts
* `.ts`/`.tsx`, the other also `.mts`/`.cts`); these are leads to run locally,
* not verdicts, so the wider side is the safe one.
*/
export function isTestFilePath(path) {
return /\.(test|spec)\.(ts|tsx|mts|cts)$/.test(path);
}

/**
* Gates that fire on what a change IS, keyed by a mechanically-detectable
* convention. Everything else in this script is derived at runtime and lists
* nothing; this table is the one exception, and it is bounded on purpose.
*
* ## Why these two cannot be derived like the rest
*
* The path derivation matches a gate when the gate's own source names a
* directory that covers your file. Both gates here compute their population
* instead of naming it — one lints a glob set that lives in the shared ESLint
* config, the other walks the workspace members — so neither source carries a
* literal to match, and both sit permanently in the "undetermined" bucket. No
* per-card gate list derived from paths can ever name them, however the
* derivation improves.
*
* ## Why a named table and not a wider heuristic
*
* The tempting generalisation — scan every discovered check script for
* `*.test.ts`-shaped literals and call those the test-sensitive gates — was
* measured against this tree and names 22 families, because a script's source
* mentions test paths in its fixtures, its self-test and its comments.
* "Mentions a test file" is not "counts test files", and 22 leads is the same
* as none. So the pair is written down, and the cost of writing it down is paid
* back by the two properties below.
*
* ## How this entry stays honest
*
* - Every `name` here is resolved against the families actually discovered in
* the workflows at runtime. A gate that is renamed, retired or dropped from
* CI does not silently stop being suggested — the run prints it as STALE and
* says to fix this table. A hand-written list that reports its own rot is a
* different object from one that quietly ages.
* - The entry is deletable, with a stated criterion: when a gate here grows a
* discoverable path literal, the ordinary derivation names it and its line
* below becomes redundant. Delete it then.
*/
export const CHANGE_KIND_GATES = [
{
kind: 'adds or edits a test file',
matches: isTestFilePath,
gates: [
{
name: 'check:query-options-erasure',
why: 'its test-surface ceiling counts sites in *.test/*.spec files, so new test code moves it',
},
{
name: 'check:type-check-coverage',
why: "TEST_DEBT ratchets a package's test-layer type errors, so a new test file that does not typecheck cleanly moves it",
},
],
},
];

/**
* Render the convention-triggered section. Pure over its inputs so the
* self-test can drive both the hit and the STALE branch offline;
* `resolveInvocation` returns a runnable command for a gate the live run
* discovered, or null for one it did not.
*/
export function changeKindLines(paths, resolveInvocation, kinds = CHANGE_KIND_GATES) {
const lines = [];
for (const { kind, matches, gates } of kinds) {
const hits = paths.filter((p) => matches(p));
if (hits.length === 0) continue;
lines.push(` ${kind}: ${hits.join(', ')}`);
for (const { name, why } of gates) {
const invocation = resolveInvocation(name);
lines.push(
invocation
? ` - ${invocation} — ${why}`
: ` - ⚠ ${name}: STALE — no workflow runs a gate under this name. It was renamed or retired; fix CHANGE_KIND_GATES in this script.`,
);
}
}
return lines;
}

// ---------------------------------------------------------------------------
// Live derivation
// ---------------------------------------------------------------------------
Expand DownExpand Up@@ -201,9 +302,20 @@ function derive(paths) {
} else {
console.log('No check family names the given paths in its own source.');
}
const kindLines = changeKindLines(paths, (name) => {
const entry = byCheck.get(name);
return entry ? runnableInvocation(entry) : null;
});
if (kindLines.length) {
console.log('\nConvention-triggered gates (this change KIND moves them; no path derivation can name them):');
for (const line of kindLines) console.log(line);
}

console.log(
`\nRepo-wide / undetermined (no path literals discoverable — not known irrelevant): ${undetermined.length} famil(ies).` +
'\nJudgment stays with the PM: convention-scoped gates (new fake engine ⇒ check:engine-double-contract, new error code ⇒ check:error-code-casing, any edit ⇒ check:nul-bytes) match by what the change IS, not where it lives.',
'\nConvention-scoped gates match by what the change IS, not where it lives. This script derives the conventions it can detect mechanically ' +
`(${CHANGE_KIND_GATES.map((k) => k.kind).join('; ')}) and prints them above when they hit; the rest stay the PM judgment call — ` +
'new fake engine ⇒ check:engine-double-contract, new error code ⇒ check:error-code-casing, any edit ⇒ check:nul-bytes.',
);
}

Expand DownExpand Up@@ -264,6 +376,28 @@ function selfTest() {
t('input dir covers hint below it', hintCovers('packages/spec/scripts/check-x.mjs', 'packages/spec'));
t('unrelated path does not match', !hintCovers('.claude/agents', 'packages/rest/src/server.ts'));

// Change-kind derivation. The predicate is pinned in BOTH directions against
// what the two gates themselves count: filename infix, never directory — a
// helper inside `__tests__/` is in their non-test population.
t('test file by .test infix', isTestFilePath('packages/objectql/src/engine.test.ts'));
t('test file by .spec infix', isTestFilePath('packages/rest/src/server.spec.tsx'));
t('test file with an mts extension', isTestFilePath('packages/spec/src/x.test.mts'));
t('a __tests__ helper is NOT a test file to these gates', !isTestFilePath('packages/core/src/__tests__/fixtures.ts'));
t('a plain source file is not a test file', !isTestFilePath('packages/objectql/src/engine.ts'));
t('a non-TS file named test is not a test file', !isTestFilePath('docs/how.test.md'));

const resolved = (name) => `pnpm ${name}`;
const kindHit = changeKindLines(['packages/objectql/src/engine.test.ts'], resolved);
t('a test path emits the convention section', kindHit.length === 3 && kindHit[0].includes('adds or edits a test file'));
t('the section names both convention gates, runnably', kindHit.some((l) => l.includes('pnpm check:query-options-erasure')) && kindHit.some((l) => l.includes('pnpm check:type-check-coverage')));
t('a non-test path emits nothing', changeKindLines(['scripts/pm/dispatch-gates.mjs'], resolved).length === 0);

// The table's own rot detector: a name no live run discovers must say so,
// never disappear quietly.
const stale = changeKindLines(['a.test.ts'], () => null);
t('an undiscoverable gate renders as STALE', stale.filter((l) => l.includes('STALE')).length === 2);
t('every declared convention gate carries a reason', CHANGE_KIND_GATES.every((k) => k.gates.every((g) => g.name && g.why)));

let failed = 0;
for (const [name, cond] of cases) {
if (!cond) failed++;
Expand Down
20 changes: 20 additions & 0 deletions scripts/pm/ensure-pm-labels.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,4 +39,24 @@ for D in engine-core metadata drivers services identity devx spec spec-surface c
gh label create "domain:$D" -R objectstack-ai/objectstack -c bfd4f2 -d "Domain lane — seat card indexed by label:pm:seat" 2>/dev/null || true
done

# Release board — `target:<major>` marks a release BLOCKER for that major
# (SKILL.md "发版板"). Its consumer is a named query, one per backlog:
# `label:target:<major> is:open`, and all three boards reading empty IS the
# release condition — so the family belongs in all three repos, not just the
# main one.
#
# Seed the CURRENT release window only. A major seeded ahead of its window puts
# an empty board into every autocomplete with no producer behind it — the same
# harm as recreating a retired lane above. At the start of a new window MOVE the
# value here rather than accumulating them; a closed window's label object stays
# in the repos as history and needs no entry.
#
# Creation is create-if-missing, so where these already exist this is a no-op
# and their current colour and description are left exactly as they are.
for V in v17; do
for R in objectstack-ai/objectstack objectstack-ai/objectui objectstack-ai/cloud; do
gh label create "target:$V" -R "$R" -c ededed -d "Release blocker for $V — stays on the release board until fixed, dropped as no longer valid, or explicitly accepted for GA" 2>/dev/null || true
done
done

echo "✓ ensure-pm-labels: label vocabulary ensured (idempotent)."
Loading