From e6248452e4896ef190d834ac9dcc54f40d8e580a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 04:06:16 +0000 Subject: [PATCH 1/2] fix(ci): declare the examples/** input radius for cli and lint test suites packages/cli and packages/lint reach examples/app-showcase through test-only relative imports across a workspace boundary no package dependency declares. Both of CI's scoping layers judge those four tests by where they live, so an examples-only PR could not fail on them and the first signal was a red Test Core in the shared merge queue -- which stalls every lane. Extends the existing CROSS_PACKAGE_TEST_INPUTS entries for both packages with the app modules their tests actually read, taken per package from the live import inventory rather than as one flat set, and mirrors them onto the matching #test tasks in turbo.json so the task cache moves with them too. Also makes the live-import inventory's inputs-declared classification judge coverage per coupling TARGET instead of per package. Without that, narrowing a radius to individual files would let a package that declares one app file be read as covering every coupling it has, so the next live import to a fourth file would report as visible while neither CI layer ran it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011RB4waLuNbdruCo6X9oobm --- scripts/check-cross-package-test-inputs.mjs | 40 ++++ scripts/check-examples-live-imports.mjs | 225 ++++++++++++++++---- turbo.json | 10 +- 3 files changed, 227 insertions(+), 48 deletions(-) diff --git a/scripts/check-cross-package-test-inputs.mjs b/scripts/check-cross-package-test-inputs.mjs index c5f734aa72..1075a3be92 100644 --- a/scripts/check-cross-package-test-inputs.mjs +++ b/scripts/check-cross-package-test-inputs.mjs @@ -120,21 +120,61 @@ const CROSS_PACKAGE_TEST_INPUTS = { // cluster package), and the pin exists to fail when the two drift. It only // does that if a producer-only change re-runs cli's tests, which is exactly // what this declaration buys. + // + // The `examples/` globs are the showcase modules two i18n tests import LIVE + // across the workspace boundary, each named per file because the two read + // DIFFERENT namespaces and are not interchangeable: + // test/i18n-section-coverage.test.ts dynamically imports `contact.view` + // and `semantic-zoo.object` and asserts `toEqual` over an exhaustive + // hardcoded `_sections` key list, so any newly NAMED section in either + // module goes red -- this is what PR #8742 broke in queue build + // 31825946401, where the merge queue was the first signal. + // test/i18n-tab-coverage.test.ts dynamically imports `task-triage.page` + // and asserts the same way over `_tabs`, so it moves with that page's + // filter-only presets and is untouched by `_sections` edits. + // Per-file rather than `examples/app-showcase/**`: the modules above have no + // relative imports of their own, so the read set IS the file set, and the + // wider glob would put cli's suite on every showcase edit. Adding a live + // import outside these paths fails `check:examples-live-imports`, which + // matches each coupling target against these globs -- so narrowing here + // cannot quietly reopen the blind spot. globs: [ 'packages/verify/src/**', 'packages/plugins/plugin-security/src/**', 'packages/services/service-cluster/src/**', + 'examples/app-showcase/src/ui/views/contact.view.ts', + 'examples/app-showcase/src/data/objects/semantic-zoo.object.ts', + 'examples/app-showcase/src/ui/pages/task-triage.page.ts', ], }, '@objectstack/lint': { // authoring-rule-wiring / validate-rule-compilability / // lint-startup-registry-verdict.corpus read each authoring rule's source // by repo-relative path, plus the CLI commands dir and the runtime gate. + // + // The `examples/` globs are the showcase modules two validator tests import + // LIVE across the workspace boundary. Both assert the SHIPPED app is clean + // rather than pinning a fixed shape -- #8515 lifted the pinned-shape cases + // onto the frozen `showcase-shape.fixtures.ts` snapshot, so what survives + // live are the cases that must keep resolving against the real app: + // src/validate-translatable-sections.test.ts imports `Contact`, + // `ContactViews` and `ShowcaseTranslationBundle`; a section introduced + // WITHOUT a name moves it, a correctly named one does not. + // src/validate-translation-references.test.ts imports `Contact` and + // `ContactViews` and asserts every translation key still resolves, so + // renaming or removing a Contact field, view, section or action moves + // it while adding one generally does not. + // Per-file for the same reason as `@objectstack/cli` above: these modules + // have no relative imports of their own, and a live import added outside + // them fails `check:examples-live-imports` by name. globs: [ 'packages/cli/src/commands/**', 'packages/metadata-protocol/src/**', 'packages/objectql/src/validation/**', 'packages/services/service-automation/src/**', + 'examples/app-showcase/src/data/objects/contact.object.ts', + 'examples/app-showcase/src/system/translations/index.ts', + 'examples/app-showcase/src/ui/views/contact.view.ts', ], }, '@objectstack/platform-objects': { diff --git a/scripts/check-examples-live-imports.mjs b/scripts/check-examples-live-imports.mjs index f7787fef59..1c930eb3aa 100644 --- a/scripts/check-examples-live-imports.mjs +++ b/scripts/check-examples-live-imports.mjs @@ -58,12 +58,27 @@ // wants to know these tests read it. // // `inputs-declared` -- the coupling escapes by relative path, but the owning -// package declares a matching `$TURBO_ROOT$/examples/**` -// glob on its `#test` task in turbo.json (the mechanism +// package declares a `$TURBO_ROOT$/examples/...` glob on +// its `#test` task in turbo.json that COVERS THIS +// COUPLING'S TARGET (the mechanism // `check-cross-package-test-inputs.mjs` maintains, whose // registry drives the affected-subset union too). Both of // CI's scoping layers move with the app. Not a gap. // +// Coverage is judged per target, not per package, and +// that distinction is load-bearing rather than pedantic +// (#8946). A radius may legitimately name individual app +// files instead of the whole app -- `@objectstack/cli` +// declares three showcase modules by path. A package- +// granular check would then read "cli declares something +// under examples/" as "every cli coupling is visible", +// so the next live import to a FOURTH app file would be +// reported as covered while neither CI layer ran it: +// the #8754 blind spot silently reopened inside the +// registry built to close it. An uncovered target keeps +// the file in `invisible`, where the ratchet demands it +// be declared or the radius widened. +// // `invisible` -- escapes by relative path (or by a filesystem read), // with NO declared dependency and NO declared input // glob. Neither CI layer can see it. THIS is #8754's @@ -110,42 +125,23 @@ const REPO_ROOT = resolve(HERE, '..'); * * ⛔ An entry is a RECORD, not a verdict: nothing here says a coupling should * be removed, kept, or rewritten. + * + * ── Why this is empty, and why that is not dead code ───────────────────────── + * + * It is empty because the population is currently empty, not because the + * registry was abandoned. #8754 opened it holding the four cli/lint i18n tests; + * #8946 gave `@objectstack/cli` and `@objectstack/lint` the input radius those + * four reach, so all four moved to `inputs-declared` and the ratchet's stale + * direction required their entries be deleted. Zero invisible couplings is the + * goal state of this gate, not the absence of one. + * + * The empty object still does work: the undeclared direction of the ratchet + * measures against it, so the next live coupling CI cannot see fails here + * naming itself. Note that the `--self-test` case asserting every entry carries + * a substantive note now passes vacuously -- it re-arms the moment an entry + * comes back, but until then it proves nothing. */ -const INVISIBLE_COUPLINGS = { - 'packages/cli/test/i18n-section-coverage.test.ts': { - note: - "Set-equality over what the app declares TODAY. Dynamically imports `contact.view` and " + - "`semantic-zoo.object`, runs `sectionKeys(...)` over them and asserts `toEqual` against an " + - "exhaustive hardcoded `_sections` key list. Any newly NAMED section in either module adds a " + - "key the list does not have and the assertion goes red -- this is the file PR #8742 broke in " + - "queue build 31825946401.", - }, - 'packages/cli/test/i18n-tab-coverage.test.ts': { - note: - "Set-equality over what the app declares TODAY, same shape as the file above but a DIFFERENT " + - "namespace. Dynamically imports `task-triage.page` and asserts `toEqual` over an exhaustive " + - "`_tabs` key list, so it moves with the page's filter-only presets and is untouched by " + - "`_sections` edits. The namespace split is why the #8231 edit flipped one cli file and not " + - "this one -- do not read the two as interchangeable.", - }, - 'packages/lint/src/validate-translatable-sections.test.ts': { - note: - "Asserts the shipped app is CLEAN, not that it has a fixed shape. Statically imports " + - "`Contact`, `ContactViews` and `ShowcaseTranslationBundle`. #8515 lifted the cases that pin a " + - "NAMELESS section onto the frozen `showcase-shape.fixtures.ts` snapshot, so the live imports " + - "survive only in the cases pinning what the app already gets right (findings resolve empty). " + - "An app edit that ADDS a correctly-named section does not move it; one that introduces a " + - "nameless section does.", - }, - 'packages/lint/src/validate-translation-references.test.ts': { - note: - "Same #8515 split as its sibling above, on the reference-resolution axis. Statically imports " + - "`Contact` and `ContactViews`; the frozen snapshot carries the pinned-shape cases, and the " + - "live symbols feed the cases asserting every translation key still RESOLVES against the " + - "shipped object/view pair. Renaming or removing a Contact field, view, section or action " + - "moves it; adding one generally does not.", - }, -}; +const INVISIBLE_COUPLINGS = {}; /** Recognised coupling spellings. Printed in the failure text and pinned by --self-test. */ const RECOGNISED_SPELLINGS = [ @@ -322,7 +318,11 @@ function owningPackage(fileAbs) { return null; } -/** Packages whose `#test` task declares an `examples/**` input glob in turbo.json. */ +/** + * Packages whose `#test` task declares an `examples/...` input glob in + * turbo.json, mapped to those globs in repo-relative form (the `$TURBO_ROOT$/` + * prefix stripped) so they can be matched against a coupling target. + */ function packagesWithExampleInputs() { const declared = new Map(); let turbo; @@ -334,14 +334,85 @@ function packagesWithExampleInputs() { const tasks = turbo.tasks ?? turbo.pipeline ?? {}; for (const [taskId, cfg] of Object.entries(tasks)) { if (!taskId.endsWith('#test')) continue; - const globs = (cfg?.inputs ?? []).filter( - (g) => typeof g === 'string' && g.includes('examples/'), - ); + const globs = (cfg?.inputs ?? []) + .filter((g) => typeof g === 'string' && g.includes('examples/')) + .map((g) => g.replace(/^\$TURBO_ROOT\$\//, '')); if (globs.length) declared.set(taskId.slice(0, -'#test'.length), globs); } return declared; } +/** + * Turbo input-glob semantics: `**` spans whole segments, `*` stays inside one. + * + * Mirrored from `globToRegExp` in `check-cross-package-test-inputs.mjs` rather + * than imported, because that module runs its gate at load time -- importing it + * would execute a second gate as a side effect of classifying. The duplication + * is pinned by `--self-test` on both sides; the two must agree, since this is + * the check that decides whether a declared radius really covers a coupling. + */ +function globToRegExp(glob) { + let re = ''; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + if (glob[i + 2] === '/') { + re += '(?:[^/]+/)*'; + i += 2; + } else { + re += '.*'; + i += 1; + } + } else { + re += '[^/]*'; + } + } else if ('.+?^${}()|[]\\/'.includes(c)) { + re += `\\${c}`; + } else { + re += c; + } + } + return new RegExp(`^${re}$`); +} + +/** + * Does one declared input glob cover this coupling target? + * + * A DIRECTORY target is read wholesale -- the dogfood shape chdirs into the app + * and compiles whatever is there -- so it is covered only by a glob whose + * subtree contains it, never by one naming a single file inside it. Matching a + * directory against the glob pattern directly would let `examples/app-*` report + * a whole app as covered on the strength of its own name. + */ +export function globCoversTarget(glob, target, isDir) { + if (!isDir && globToRegExp(glob).test(target)) return true; + if (!glob.endsWith('/**')) return false; + const prefix = glob.slice(0, -'/**'.length); + return target === prefix || target.startsWith(`${prefix}/`); +} + +/** + * The declared globs that fail to cover a by-path coupling, as repo-relative + * target paths. Empty means every path this test reaches really is hashed onto + * the package's `#test` task and really does pull it into the affected subset. + */ +function uncoveredTargetsOf(byPathCouplings, fileAbs, globs) { + const uncovered = []; + for (const c of byPathCouplings) { + const abs = resolveExampleSource(resolve(dirname(fileAbs), c.spec)); + const target = rel(abs); + let isDir = false; + try { + isDir = statSync(abs).isDirectory(); + } catch { + isDir = false; + } + if (!globs.some((g) => globCoversTarget(g, target, isDir))) uncovered.push(target); + } + return [...new Set(uncovered)].sort(); +} + /** Resolve a specifier to a repo-relative `examples/**` path, or null. */ function couplingTarget(spec, fileAbs, apps) { if (spec.startsWith('.')) { @@ -404,16 +475,21 @@ function collect() { return app?.name && Object.hasOwn(deps, app.name); }); + // Only the by-path couplings need an input glob: a by-package-name coupling + // on a declared edge is already reachable through the dependency graph. + const declaredGlobs = exampleInputs.get(pkgName) ?? []; + const uncoveredTargets = uncoveredTargetsOf(byPath, abs, declaredGlobs); + let visibility; if (byPath.length === 0 && declaredEdge) visibility = 'graph-visible'; - else if (exampleInputs.has(pkgName)) visibility = 'inputs-declared'; - else if (byPath.length === 0 && byPackageName.length > 0) visibility = 'invisible'; + else if (declaredGlobs.length > 0 && uncoveredTargets.length === 0) visibility = 'inputs-declared'; else visibility = 'invisible'; rows.push({ file, package: pkgName, visibility, + uncoveredTargets, targets: [...new Set(couplings.map((c) => c.target))].sort(), refs: couplings .map((c) => ({ kind: c.kind, spec: c.spec })) @@ -531,11 +607,22 @@ function verify() { for (const file of discovered) { if (!Object.hasOwn(INVISIBLE_COUPLINGS, file)) { const row = rows.find((r) => r.file === file); + // A package that declares SOME examples glob but not one covering this + // coupling is the more misleading case of the two, so it gets its own + // wording: the fix is usually to widen the radius, not to record the file. + const why = row.uncoveredTargets.length + ? `package ${row.package} declares examples input globs, but none of them covers:\n` + + row.uncoveredTargets.map((t) => ` ${t}`).join('\n') + + `\n so a diff touching those paths still does not re-run this test. Widen the\n` + + ` package's globs in scripts/check-cross-package-test-inputs.mjs (and mirror\n` + + ` them onto its #test task in turbo.json) -- or, if the coupling should stay\n` + + ` unscoped, record it:` + : `package ${row.package} reaches ${row.targets.join(', ')} with no declared\n` + + ` dependency and no examples/** input glob, so neither CI layer runs it on an\n` + + ` examples-only diff.`; problems.push( `UNDECLARED coupling: ${file}\n` + - ` package ${row.package} reaches ${row.targets.join(', ')} with no declared\n` + - ` dependency and no examples/** input glob, so neither CI layer runs it on an\n` + - ` examples-only diff.\n` + + ` ${why}\n` + ` Add an entry to INVISIBLE_COUPLINGS in ${rel(fileURLToPath(import.meta.url))}:\n` + ` '${file}': { note: 'what this test does with what it imports, and what kind of\n` + ` example-app edit would move it' },\n` + @@ -661,6 +748,52 @@ function selfTest() { 'every declared entry carries a substantive note', Object.values(INVISIBLE_COUPLINGS).every((e) => (e.note ?? '').trim().length >= 40), ], + + // ── input-glob coverage: the check that lets a radius be NARROW safely ── + // + // Pinned because a false positive here is silent and total: a coupling + // wrongly judged covered leaves `invisible`, so the ratchet stops asking + // for it and CI never runs it either. + [ + 'an exact-file glob covers that file', + globCoversTarget( + 'examples/app-showcase/src/ui/views/contact.view.ts', + 'examples/app-showcase/src/ui/views/contact.view.ts', + false, + ), + ], + [ + 'an exact-file glob does NOT cover a sibling file', + !globCoversTarget( + 'examples/app-showcase/src/ui/views/contact.view.ts', + 'examples/app-showcase/src/ui/pages/task-triage.page.ts', + false, + ), + ], + [ + 'a subtree glob covers a file beneath it', + globCoversTarget('examples/app-showcase/**', 'examples/app-showcase/src/data/objects/x.object.ts', false), + ], + [ + 'a subtree glob does NOT cover another app', + !globCoversTarget('examples/app-showcase/**', 'examples/app-crm/src/x.ts', false), + ], + [ + 'a subtree glob covers the directory it names (the chdir-and-compile shape)', + globCoversTarget('examples/app-showcase/**', 'examples/app-showcase', true), + ], + [ + 'an exact-file glob does NOT cover the directory containing it', + !globCoversTarget('examples/app-showcase/src/ui/views/contact.view.ts', 'examples/app-showcase', true), + ], + [ + 'a deeper subtree glob does NOT cover a directory above it', + !globCoversTarget('examples/app-showcase/src/**', 'examples/app-showcase', true), + ], + [ + '* does not span segments', + !globCoversTarget('examples/app-showcase/src/*.ts', 'examples/app-showcase/src/ui/x.ts', false), + ], ]; let failed = 0; diff --git a/turbo.json b/turbo.json index 1458df49f6..9da6221c79 100644 --- a/turbo.json +++ b/turbo.json @@ -61,7 +61,10 @@ "!.turbo/**", "$TURBO_ROOT$/packages/verify/src/**", "$TURBO_ROOT$/packages/plugins/plugin-security/src/**", - "$TURBO_ROOT$/packages/services/service-cluster/src/**" + "$TURBO_ROOT$/packages/services/service-cluster/src/**", + "$TURBO_ROOT$/examples/app-showcase/src/ui/views/contact.view.ts", + "$TURBO_ROOT$/examples/app-showcase/src/data/objects/semantic-zoo.object.ts", + "$TURBO_ROOT$/examples/app-showcase/src/ui/pages/task-triage.page.ts" ] }, "@objectstack/lint#test": { @@ -75,7 +78,10 @@ "$TURBO_ROOT$/packages/cli/src/commands/**", "$TURBO_ROOT$/packages/metadata-protocol/src/**", "$TURBO_ROOT$/packages/objectql/src/validation/**", - "$TURBO_ROOT$/packages/services/service-automation/src/**" + "$TURBO_ROOT$/packages/services/service-automation/src/**", + "$TURBO_ROOT$/examples/app-showcase/src/data/objects/contact.object.ts", + "$TURBO_ROOT$/examples/app-showcase/src/system/translations/index.ts", + "$TURBO_ROOT$/examples/app-showcase/src/ui/views/contact.view.ts" ] }, "@objectstack/platform-objects#test": { From bb7c03cb052555c13d385fc959ef566ff31a0085 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 04:08:13 +0000 Subject: [PATCH 2/2] fix(ci): say "no examples glob" only when the package really declares none The undeclared-coupling message chose its wording from uncoveredTargets, which is also non-empty when the package declares no examples glob at all -- so the zero-glob case was told its globs did not cover the target. Choose on whether any glob is declared instead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011RB4waLuNbdruCo6X9oobm --- scripts/check-examples-live-imports.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/check-examples-live-imports.mjs b/scripts/check-examples-live-imports.mjs index 1c930eb3aa..c902127049 100644 --- a/scripts/check-examples-live-imports.mjs +++ b/scripts/check-examples-live-imports.mjs @@ -489,6 +489,7 @@ function collect() { file, package: pkgName, visibility, + declaresExampleInputs: declaredGlobs.length > 0, uncoveredTargets, targets: [...new Set(couplings.map((c) => c.target))].sort(), refs: couplings @@ -610,7 +611,7 @@ function verify() { // A package that declares SOME examples glob but not one covering this // coupling is the more misleading case of the two, so it gets its own // wording: the fix is usually to widen the radius, not to record the file. - const why = row.uncoveredTargets.length + const why = row.declaresExampleInputs ? `package ${row.package} declares examples input globs, but none of them covers:\n` + row.uncoveredTargets.map((t) => ` ${t}`).join('\n') + `\n so a diff touching those paths still does not re-run this test. Widen the\n` +