diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48a5ea75f9..28ddd00992 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -307,6 +307,34 @@ jobs: echo "Affected-set diff base: $SCM_BASE (merge-base of origin/$BASE_REF and HEAD)" echo "Frozen payload base.sha: $PINNED_BASE_SHA -- $BASE_REF has moved $DRIFT commit(s) since it was frozen, and that drift is exactly what this step used to charge to this PR." TURBO_SCM_BASE="$SCM_BASE" pnpm exec turbo ls --affected --output=json > "$RUNNER_TEMP/turbo-ls.json" + # `turbo ls --affected` answers "which packages does the dependency + # GRAPH reach from this diff" — and some suites read files the graph + # does not connect them to. spec's api-methods-batch-conformance scan + # walks every `*.object.ts` in the monorepo while spec declares no + # dependency on the packages it judges (nor should it: the scan reads + # source text precisely to avoid inverting the spec -> * direction). + # A platform-objects-only diff therefore left it unrun, and #7769 + # landed a violation on `main` that only PRs touching `spec` ever saw. + # Measured on turbo 2.10.7: 51 packages affected by that diff, spec + # not among them. + # + # So packages that declare a cross-package input radius are unioned + # back in when the diff touches it. The declarations, and the static + # detector that refuses to let a new cross-package scan go + # undeclared, live in the script (`pnpm check:cross-package-test-inputs`). + # + # Failure here falls back to the FULL package list, never to the + # affected-only set: same posture as the merge-base fallback below + # (#6195), and the same reason — the full list is a strict superset, + # so doubt costs minutes rather than coverage. This is the FILTER + # CONTRACT's half 1 applied one layer down. + if ! git diff --name-only "$SCM_BASE" HEAD > "$RUNNER_TEMP/changed-files.txt" \ + || ! node scripts/check-cross-package-test-inputs.mjs \ + --union-into "$RUNNER_TEMP/turbo-ls.json" \ + --changed "$RUNNER_TEMP/changed-files.txt"; then + echo "::warning::Could not union cross-package scans into the affected set; falling back to the full package list for this shard." + pnpm exec turbo ls --output=json > "$RUNNER_TEMP/turbo-ls.json" + fi else # Falling back to the FULL package list, never to the frozen # base.sha. This is not the #4690 silent-skip anti-pattern: that is diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c8b8641db7..08b8d7384c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -537,6 +537,27 @@ jobs: - name: Required-context name pin run: pnpm check:required-contexts + # Cross-package test inputs (#7802). A test that resolves the repo root + # and walks it has an input set far wider than its own package, and BOTH + # of CI's scoping layers judge it by where it LIVES: `turbo ls --affected` + # reaches packages through the dependency GRAPH, and turbo's task cache + # hashes `$TURBO_DEFAULT$`, which is package-local. So spec's + # `*.object.ts` conformance scan went unrun on a platform-objects-only + # diff, AND replayed a cached green in the merge-queue build that runs the + # full package list — a violation reached `main` with every gate green and + # was then charged to the next unrelated PR that happened to touch `spec`. + # Twenty such tests, in eight packages, exist today. + # This gate finds the escaping tests ITSELF, statically — nothing has to + # be registered to be DETECTED — and fails on any package that has one and + # no declared input radius, on a declaration gone stale, on a declared + # glob turbo.json does not hash, and on a radius too narrow to cover the + # paths its own tests name. The point is what happens by default: a new + # cross-package scan nobody remembers to register produces a RED GATE + # naming it and the globs to write, not a silent skip. + # Reads ~2000 test sources; sub-second. + - name: Cross-package test inputs + run: pnpm check:cross-package-test-inputs + # #4248 packaging-hygiene guard. Without a `files` whitelist npm packs the # whole package directory, and 20 of the 49 publishable packages declared # none — so consumers installed TypeScript sources, unit tests and build diff --git a/package.json b/package.json index 99e8b3a25f..3bf8091e2a 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "check:workflow-status-functions": "node scripts/check-workflow-status-functions.mjs --self-test && node scripts/check-workflow-status-functions.mjs", "check:shard-attestation": "node scripts/check-shard-attestation.mjs --self-test && node scripts/check-shard-attestation.mjs", "check:required-contexts": "node scripts/check-required-contexts.mjs --self-test && node scripts/check-required-contexts.mjs", + "check:cross-package-test-inputs": "node scripts/check-cross-package-test-inputs.mjs --self-test && node scripts/check-cross-package-test-inputs.mjs", "check:published-files": "node scripts/check-published-files.mjs --self-test && node scripts/check-published-files.mjs", "check:type-check-coverage": "node scripts/check-type-check-coverage.mjs --self-test && node scripts/check-type-check-coverage.mjs", "check:type-check-debt": "node scripts/check-type-check-coverage.mjs --self-test && node scripts/check-type-check-coverage.mjs --re-measure", diff --git a/scripts/check-cross-package-test-inputs.mjs b/scripts/check-cross-package-test-inputs.mjs new file mode 100644 index 0000000000..6a331e7b20 --- /dev/null +++ b/scripts/check-cross-package-test-inputs.mjs @@ -0,0 +1,543 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// check-cross-package-test-inputs -- keeps CI's idea of a test's inputs equal +// to the test's REAL inputs, for the tests that read outside their own package. +// +// ── The defect this exists to make impossible (#7802) ──────────────────────── +// +// `packages/spec/src/data/api-methods-batch-conformance.test.ts` resolves the +// repo root and walks every `*.object.ts` in the monorepo. Its home package is +// `spec`; its input set is the whole repo. #7769 changed ONE object under +// `packages/platform-objects`, the scan's verdict flipped to red -- and the +// change reached `main` anyway, because BOTH of CI's scoping layers judge the +// scan by where it LIVES: +// +// Layer A -- `turbo ls --affected` in ci.yml's `test` job (the PR path). +// Affected packages come from the dependency GRAPH. `packages/spec` declares +// no dependency on `packages/platform-objects` (and should not -- the scan +// reads source text precisely to avoid inverting the spec -> * direction), +// so a platform-objects-only diff can never reach spec. Measured on +// turbo 2.10.7: 51 packages affected, `@objectstack/spec` not among them. +// +// Layer B -- turbo's task cache. `test` declares `inputs: ["$TURBO_DEFAULT$", +// ...]`, which is PACKAGE-LOCAL. `@objectstack/spec#test` therefore hashes +// the same before and after any change outside `packages/spec`, so even the +// merge-queue and push builds -- which deliberately partition the FULL +// package list, not the affected subset -- replay a cached green. +// Measured: `turbo run test --filter=@objectstack/spec` after the +// platform-objects edit printed `>>> FULL TURBO`, 42ms, replaying the +// previous run's log, while `--force` on the same tree failed the scan. +// +// Layer B is why the merge queue did not catch it. The `filter` job's +// `dorny/paths-filter` gate -- which DOES open up on `merge_group` -- was never +// the leak: `core` matches `packages/**`, so it was `true` throughout. +// +// ── The mechanism ─────────────────────────────────────────────────────────── +// +// A package whose tests read outside itself declares that radius ONCE, in +// CROSS_PACKAGE_TEST_INPUTS below, and both layers are driven from it: +// +// Layer A: `--union-into ` adds the declaring package to the +// shard's package set when the diff touches its declared globs. +// Layer B: `--verify` requires turbo.json to carry a matching +// `#test` task whose `inputs` include the same globs as +// `$TURBO_ROOT$/...` entries, so the cache hash moves with them. +// +// ── Why this is not just another hand-maintained list ─────────────────────── +// +// It IS a list, and a list you must remember to update is exactly the failure +// mode that produced #7802. So the list does not depend on anyone remembering: +// `--verify` finds the escaping tests ITSELF, statically, and fails naming any +// package that has one and no declaration. Add a new cross-package scan and the +// gate goes red with the package name and what to write; the default for an +// undeclared scan is a RED GATE, never a silent skip. The reverse rots too, so +// it is checked in the same pass: a declaration whose package no longer has an +// escaping test fails as stale. +// +// What the list buys over "just always run those packages" is the radius. A +// declared glob of `packages/**/*.object.ts` keeps spec's 5-minute suite off +// every PR that does not touch an object; `always-run` would put it on all of +// them, which is the affected-subset optimisation the 3-way shard exists for +// (ci.yml `test`) traded away to fix eight packages. +// +// Usage: +// node scripts/check-cross-package-test-inputs.mjs --verify +// node scripts/check-cross-package-test-inputs.mjs --union-into --changed +// node scripts/check-cross-package-test-inputs.mjs --list-escapes +// node scripts/check-cross-package-test-inputs.mjs --self-test + +import { readFileSync, readdirSync, statSync, existsSync, writeFileSync } from 'node:fs'; +import { join, resolve, relative, dirname, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import process from 'node:process'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..'); + +/** + * Packages whose test suites read files outside their own directory, with the + * repo-relative globs they really read. Keep a glob as NARROW as the evidence + * allows and no narrower: too wide only costs cache invalidation, too narrow + * silently restores the #7802 blind spot for that package. + * + * Every entry names the test that justifies it, so the next person can check + * the radius against the code rather than trusting the glob. + */ +const CROSS_PACKAGE_TEST_INPUTS = { + '@objectstack/spec': { + globs: [ + // api-methods-batch-conformance.test.ts + system/constants/platform-object-names.test.ts + 'packages/**/*.object.ts', + // src/identity/position-delegatable-enforcer.pin.test.ts reads the lint rule sources + 'packages/lint/src/**', + // scripts/root-index.test.ts + 'content/docs/references/index.mdx', + // scripts/dist-freshness.test.ts stages a fixture around the root scripts dir + 'scripts/**', + // scripts/liveness/evidence.test.ts resolves the evidence paths the + // liveness ledgers cite, so those files' existence is a spec input. + 'packages/runtime/src/**', + 'packages/objectql/src/validation/**', + 'packages/metadata-protocol/src/**', + 'packages/plugins/plugin-audit/src/**', + ], + }, + '@objectstack/core': { + // src/security/operation-private-keys.pin.test.ts walks `git ls-files` over + // the whole repo and reads every matching source file. + globs: ['packages/**/*.ts'], + }, + '@objectstack/cli': { + // src/commands/serve-verify-security-parity.contract.test.ts diffs + // cli's serve.ts against verify's harness.ts. + // It also pins plugin-security's permission-set test as a third witness. + globs: ['packages/verify/src/**', 'packages/plugins/plugin-security/src/**'], + }, + '@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. + globs: [ + 'packages/cli/src/commands/**', + 'packages/metadata-protocol/src/**', + 'packages/objectql/src/validation/**', + 'packages/services/service-automation/src/**', + ], + }, + '@objectstack/plugin-auth': { + // src/managed-extension-fields.test.ts walks every `*.object.ts`, and pins + // core's api-key source alongside it. + globs: ['packages/**/*.object.ts', 'packages/core/src/security/**'], + }, + '@objectstack/plugin-security': { + // src/audience-anchor-set-claims.pin.test.ts pins against spec's + // high-privilege table, and cross-checks spec's own delegatable pin. + globs: ['packages/spec/src/security/**', 'packages/spec/src/identity/**'], + }, + '@objectstack/dogfood': { + // test/*-conformance.test.ts read a fixed roster of probe files across + // runtime, rest, plugins and services by repo-relative path. Narrow to the + // roster rather than `packages/**/src/**`: the literal-coverage check below + // fails the moment a probe is added outside these, so narrowing here cannot + // quietly reopen the blind spot. + globs: [ + 'packages/client/src/**', + 'packages/mcp/src/**', + 'packages/plugins/plugin-hono-server/src/**', + 'packages/rest/src/**', + 'packages/runtime/src/**', + 'packages/services/service-realtime/src/**', + // flow-trigger / validation conformance pin spec's zod schemas. + 'packages/spec/src/automation/**', + 'packages/spec/src/data/**', + ], + }, + 'create-objectstack': { + // src/template-consistency.test.ts reads doc frontmatter by repo-relative + // path to decide which templates are internal. + globs: ['content/**'], + }, +}; + +// ── glob matching ──────────────────────────────────────────────────────────── +// Deliberately dependency-free: this gate runs in CI before anything is built, +// and a `scripts/` gate that can fail on a resolution problem is a gate that +// gets muted. Supports the three constructs the declarations above use: +// `**` (any number of path segments), `*` (within one segment), and literals. +export function globToRegExp(glob) { + let re = ''; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + // `**/` consumes zero or more whole segments; a trailing `**` consumes the rest. + 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}$`); +} + +export function matchesAny(path, globs) { + return globs.some((g) => globToRegExp(g).test(path)); +} + +// ── the escape detector ────────────────────────────────────────────────────── +const FS_READ = /\b(readFileSync|readdirSync|statSync|existsSync|globSync|opendirSync|execFileSync)\b/; +const SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage', '.turbo', '.next', '.git']); + +function walkTests(dir, out = []) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return out; + } + for (const e of entries) { + if (SKIP_DIRS.has(e.name)) continue; + const p = join(dir, e.name); + if (e.isDirectory()) walkTests(p, out); + else if (/\.test\.[cm]?[jt]sx?$/.test(e.name)) out.push(p); + } + return out; +} + +function packageRootOf(file) { + let d = dirname(file); + while (d.startsWith(REPO_ROOT) && d !== REPO_ROOT) { + if (existsSync(join(d, 'package.json'))) return d; + d = dirname(d); + } + return null; +} + +/** + * Depth, BELOW the package root, of every directory-valued binding in `src`. + * A binding at depth < 0 addresses something outside the package — which, in a + * file that also reads the filesystem, is precisely the #7802 shape. + * + * Deliberately a source scan and not a real parse: the shape it looks for + * (`dirname(fileURLToPath(import.meta.url))` seeds, `resolve`/`join` chains off + * them) is how every one of the 20 files it finds today is written, and a + * detector with no dependencies cannot itself fail to resolve in CI. It errs + * toward flagging: an unrecognised spelling yields no binding and no flag, so + * the accompanying `--self-test` pins the shapes that must keep flagging. + */ +export function escapingBindings(src, hereDepth) { + const depth = new Map(); + const DECL = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^;\n]+(?:\n\s*[^;\n]*)??)\s*;/g; + for (const m of src.matchAll(DECL)) { + const name = m[1]; + const expr = m[2].trim(); + if (/^(?:path\.)?dirname\(\s*fileURLToPath\(\s*import\.meta\.url\s*\)\s*\)$/.test(expr)) { + depth.set(name, hereDepth); + continue; + } + if (expr === '__dirname') { + depth.set(name, hereDepth); + continue; + } + const call = expr.match(/^(?:path\.)?(?:resolve|join)\(([\s\S]*)\)$/); + if (!call) continue; + const args = call[1].split(',').map((s) => s.trim()); + const first = args[0]; + let base; + if (/^(?:path\.)?dirname\(\s*fileURLToPath\(/.test(first)) base = hereDepth; + else if (first === '__dirname') base = hereDepth; + else if (/^[A-Za-z_$][\w$]*$/.test(first)) base = depth.get(first); + if (base === undefined) continue; + let d = base; + for (const a of args.slice(1)) { + const lit = a.match(/^(['"`])([^'"`]*)\1$/); + if (!lit) continue; + for (const seg of lit[2].split('/').filter(Boolean)) { + if (seg === '..') d -= 1; + else if (seg !== '.') d += 1; + } + } + depth.set(name, d); + } + return [...depth.entries()].filter(([, d]) => d < 0).map(([n, d]) => ({ name: n, depth: d })); +} + +/** + * Repo-relative path literals a test names in its own source — the roster a + * probe-style scan reads. Extracting them is what lets a declaration be NARROW + * safely: a glob is only allowed to be narrow while it still covers every path + * the tests actually name, and the moment someone adds a probe outside the + * declared radius the gate fails naming the file. Over-collection (a path in a + * comment or an assertion message) is harmless — it can only force a WIDER + * declaration, never a narrower one. + */ +export function repoRelativeLiterals(src) { + const out = new Set(); + for (const m of src.matchAll(/(['"`])((?:packages|apps|examples|content|scripts)\/[A-Za-z0-9._/-]+)\1/g)) { + out.add(m[2]); + } + return out; +} + +function packageNameOf(pkgRoot) { + try { + return JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')).name ?? null; + } catch { + return null; + } +} + +/** Every package with at least one test that reads outside its own directory. */ +export function findEscapingPackages() { + const found = new Map(); + for (const top of ['packages', 'apps', 'examples']) { + const dir = join(REPO_ROOT, top); + if (!existsSync(dir)) continue; + for (const file of walkTests(dir)) { + const src = readFileSync(file, 'utf8'); + if (!FS_READ.test(src)) continue; + const pkgRoot = packageRootOf(file); + if (!pkgRoot) continue; + const hereDepth = relative(pkgRoot, dirname(file)).split(sep).filter(Boolean).length; + if (!escapingBindings(src, hereDepth).length) continue; + const name = packageNameOf(pkgRoot); + if (!name) continue; + if (!found.has(name)) found.set(name, { dir: relative(REPO_ROOT, pkgRoot), tests: [], literals: new Map() }); + const entry = found.get(name); + const rel = relative(REPO_ROOT, file); + entry.tests.push(rel); + const own = relative(REPO_ROOT, pkgRoot); + for (const lit of repoRelativeLiterals(src)) { + // Paths inside the package's own directory are already covered by + // `$TURBO_DEFAULT$` and by the package's own affected-set membership. + if (lit === own || lit.startsWith(`${own}/`)) continue; + // Only literals naming a real FILE count. Test sources are full of + // synthetic fixture paths (`packages/a/src/x.ts`) and of directory + // prefixes used to build a path or phrase a message; neither is an + // input, and requiring a glob to cover them would force declarations + // wider than the truth. + let isFile = false; + try { + isFile = statSync(join(REPO_ROOT, lit)).isFile(); + } catch { + isFile = false; + } + if (!isFile) continue; + if (!entry.literals.has(lit)) entry.literals.set(lit, rel); + } + } + } + return found; +} + +// ── modes ──────────────────────────────────────────────────────────────────── + +function verify() { + const escaping = findEscapingPackages(); + const declared = new Set(Object.keys(CROSS_PACKAGE_TEST_INPUTS)); + const problems = []; + + for (const [name, info] of [...escaping].sort()) { + if (declared.has(name)) continue; + problems.push( + `${name} has test(s) that read outside the package but declares no input radius.\n` + + info.tests.map((t) => ` ${t}`).join('\n') + + `\n Add an entry to CROSS_PACKAGE_TEST_INPUTS in ${relative(REPO_ROOT, fileURLToPath(import.meta.url))}\n` + + ` with the repo-relative globs those tests read, then run this gate again\n` + + ` for the turbo.json inputs it requires.`, + ); + } + for (const name of [...declared].sort()) { + if (escaping.has(name)) continue; + problems.push( + `${name} declares a cross-package input radius, but no test in it reads outside\n` + + ` the package any more. Delete the entry (and its turbo.json inputs) — a stale\n` + + ` declaration invalidates that package's test cache for nothing.`, + ); + } + + // A declaration may be narrower than "the whole repo" only while it still + // covers every repo-relative path its tests name. This is what keeps + // narrowing honest: extending a probe roster past the declared radius fails + // here, by file name, instead of silently going ungated again. + for (const [name, { globs }] of Object.entries(CROSS_PACKAGE_TEST_INPUTS)) { + const info = escaping.get(name); + if (!info) continue; + const uncovered = [...info.literals].filter(([lit]) => !matchesAny(lit, globs)); + if (uncovered.length) { + problems.push( + `${name} names path(s) no declared glob covers, so a change to them would not\n` + + ` re-run its tests:\n` + + uncovered.map(([lit, test]) => ` ${lit} (named in ${test})`).join('\n') + + `\n Widen the package's globs to cover them.`, + ); + } + } + + // Layer B: turbo.json must hash the declared globs, or the merge queue + // replays a cached green over a scan it never ran (the #7802 escape itself). + let turbo; + try { + turbo = JSON.parse(readFileSync(join(REPO_ROOT, 'turbo.json'), 'utf8')); + } catch (e) { + console.error(`FAIL: cannot read turbo.json: ${e.message}`); + process.exit(1); + } + for (const [name, { globs }] of Object.entries(CROSS_PACKAGE_TEST_INPUTS)) { + const task = turbo.tasks?.[`${name}#test`]; + if (!task) { + problems.push( + `turbo.json has no "${name}#test" task. Without it the package's test cache is\n` + + ` keyed on package-local files only, so a change to its declared globs replays\n` + + ` a stale green instead of re-running. Add it with inputs:\n` + + ` ${JSON.stringify(expectedInputs(globs))}`, + ); + continue; + } + const missing = globs.filter((g) => !(task.inputs ?? []).includes(`$TURBO_ROOT$/${g}`)); + if (missing.length) { + problems.push( + `turbo.json "${name}#test" inputs are missing the declared glob(s):\n` + + missing.map((g) => ` $TURBO_ROOT$/${g}`).join('\n'), + ); + } + } + + if (problems.length) { + console.error('FAIL: cross-package test inputs are not declared consistently.\n'); + for (const p of problems) console.error(` - ${p}\n`); + console.error( + 'Why this gate exists: a test whose real inputs are wider than its package is\n' + + 'invisible to BOTH the affected-subset filter and the turbo cache, so it can go\n' + + 'red on `main` while every PR reports green (#7802).', + ); + process.exit(1); + } + console.log( + `OK: ${escaping.size} package(s) read outside themselves, all declared, ` + + `and turbo.json hashes every declared glob.`, + ); +} + +function expectedInputs(globs) { + return ['$TURBO_DEFAULT$', '!dist/**', '!coverage/**', '!.turbo/**', ...globs.map((g) => `$TURBO_ROOT$/${g}`)]; +} + +/** + * 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 + * actually changed its inputs. + */ +function unionInto(listPath, changedPath) { + const parsed = JSON.parse(readFileSync(listPath, 'utf8')); + const items = parsed?.packages?.items; + if (!Array.isArray(items)) { + console.error(`FAIL: ${listPath} is not a \`turbo ls --output=json\` payload ({packages:{items:[...]}}).`); + process.exit(1); + } + const changed = readFileSync(changedPath, 'utf8') + .split('\n') + .map((s) => s.trim()) + .filter(Boolean); + + const present = new Set(items.map((i) => i.name)); + const added = []; + for (const [name, { globs }] of Object.entries(CROSS_PACKAGE_TEST_INPUTS)) { + if (present.has(name)) continue; + const hit = changed.find((f) => matchesAny(f, globs)); + if (!hit) continue; + const dir = [...findEscapingPackages()].find(([n]) => n === name)?.[1]?.dir; + if (!dir) continue; + items.push({ name, path: join(REPO_ROOT, dir) }); + added.push(`${name} (declared glob matched ${hit})`); + } + writeFileSync(listPath, JSON.stringify(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}`); + } else { + console.log('No cross-package scan declares inputs touched by this diff.'); + } +} + +function selfTest() { + const cases = []; + const ok = (label, cond) => cases.push({ label, cond }); + + // glob semantics + ok('** spans segments', matchesAny('packages/platform-objects/src/identity/x.object.ts', ['packages/**/*.object.ts'])); + ok('** matches a direct child', matchesAny('packages/a.object.ts', ['packages/**/*.object.ts'])); + ok('* does not span segments', !matchesAny('packages/a/b.object.ts', ['packages/*.object.ts'])); + ok('non-matching extension rejected', !matchesAny('packages/x/src/a.ts', ['packages/**/*.object.ts'])); + ok('trailing ** matches subtree', matchesAny('packages/lint/src/rules/a.ts', ['packages/lint/src/**'])); + ok('literal file glob', matchesAny('content/docs/references/index.mdx', ['content/docs/references/index.mdx'])); + ok('dot is literal', !matchesAny('contentXdocs/references/index.mdx', ['content/docs/references/index.mdx'])); + + // detector shapes -- one per spelling that appears in the repo today + const at = (src, depth) => escapingBindings(src, depth).length > 0; + ok( + 'flags resolve() off a fileURLToPath seed (api-methods-batch-conformance)', + at("const HERE = dirname(fileURLToPath(import.meta.url));\nconst REPO_ROOT = resolve(HERE, '../../../..');", 2), + ); + ok( + 'flags join() with a multi-segment literal (dogfood)', + at("const HERE = dirname(fileURLToPath(import.meta.url));\nconst REPO_ROOT = join(HERE, '../../../..');", 2), + ); + ok( + 'flags a two-step chain (create-objectstack)', + at( + "const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');\n" + + "const repoRoot = path.resolve(pkgRoot, '..', '..');", + 1, + ), + ); + ok( + 'does NOT flag a within-package resolve', + !at("const HERE = dirname(fileURLToPath(import.meta.url));\nconst FIX = resolve(HERE, '../fixtures');", 2), + ); + ok( + 'does NOT flag a descent back below the package root', + !at( + "const HERE = dirname(fileURLToPath(import.meta.url));\n" + + "const ROOT = resolve(HERE, '../..');\n" + + "const SRC = join(ROOT, 'src', 'data');", + 2, + ), + ); + + const failed = cases.filter((c) => !c.cond); + for (const c of cases) console.log(`${c.cond ? 'ok ' : 'FAIL'} ${c.label}`); + if (failed.length) { + console.error(`\n${failed.length}/${cases.length} self-test case(s) failed.`); + process.exit(1); + } + console.log(`\nAll ${cases.length} self-test cases passed.`); +} + +const argv = process.argv.slice(2); +if (argv.includes('--self-test')) selfTest(); +else if (argv.includes('--list-escapes')) { + for (const [name, info] of [...findEscapingPackages()].sort()) { + console.log(`${name} (${info.dir})`); + for (const t of info.tests) console.log(` ${t}`); + } +} else if (argv.includes('--union-into')) { + const listPath = argv[argv.indexOf('--union-into') + 1]; + const changedPath = argv[argv.indexOf('--changed') + 1]; + if (!listPath || !changedPath) { + console.error('usage: check-cross-package-test-inputs.mjs --union-into --changed '); + process.exit(2); + } + unionInto(listPath, changedPath); +} else verify(); diff --git a/turbo.json b/turbo.json index 9d6cb71bd0..682cefc485 100644 --- a/turbo.json +++ b/turbo.json @@ -22,6 +22,114 @@ "outputs": [], "inputs": ["$TURBO_DEFAULT$", "!dist/**", "!coverage/**", "!.turbo/**"] }, + "@objectstack/spec#test": { + "dependsOn": ["^build"], + "outputs": [], + "inputs": [ + "$TURBO_DEFAULT$", + "!dist/**", + "!coverage/**", + "!.turbo/**", + "$TURBO_ROOT$/packages/**/*.object.ts", + "$TURBO_ROOT$/packages/lint/src/**", + "$TURBO_ROOT$/content/docs/references/index.mdx", + "$TURBO_ROOT$/scripts/**", + "$TURBO_ROOT$/packages/runtime/src/**", + "$TURBO_ROOT$/packages/objectql/src/validation/**", + "$TURBO_ROOT$/packages/metadata-protocol/src/**", + "$TURBO_ROOT$/packages/plugins/plugin-audit/src/**" + ] + }, + "@objectstack/core#test": { + "dependsOn": ["^build"], + "outputs": [], + "inputs": [ + "$TURBO_DEFAULT$", + "!dist/**", + "!coverage/**", + "!.turbo/**", + "$TURBO_ROOT$/packages/**/*.ts" + ] + }, + "@objectstack/cli#test": { + "dependsOn": ["^build"], + "outputs": [], + "inputs": [ + "$TURBO_DEFAULT$", + "!dist/**", + "!coverage/**", + "!.turbo/**", + "$TURBO_ROOT$/packages/verify/src/**", + "$TURBO_ROOT$/packages/plugins/plugin-security/src/**" + ] + }, + "@objectstack/lint#test": { + "dependsOn": ["^build"], + "outputs": [], + "inputs": [ + "$TURBO_DEFAULT$", + "!dist/**", + "!coverage/**", + "!.turbo/**", + "$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/**" + ] + }, + "@objectstack/plugin-auth#test": { + "dependsOn": ["^build"], + "outputs": [], + "inputs": [ + "$TURBO_DEFAULT$", + "!dist/**", + "!coverage/**", + "!.turbo/**", + "$TURBO_ROOT$/packages/**/*.object.ts", + "$TURBO_ROOT$/packages/core/src/security/**" + ] + }, + "@objectstack/plugin-security#test": { + "dependsOn": ["^build"], + "outputs": [], + "inputs": [ + "$TURBO_DEFAULT$", + "!dist/**", + "!coverage/**", + "!.turbo/**", + "$TURBO_ROOT$/packages/spec/src/security/**", + "$TURBO_ROOT$/packages/spec/src/identity/**" + ] + }, + "@objectstack/dogfood#test": { + "dependsOn": ["^build"], + "outputs": [], + "inputs": [ + "$TURBO_DEFAULT$", + "!dist/**", + "!coverage/**", + "!.turbo/**", + "$TURBO_ROOT$/packages/client/src/**", + "$TURBO_ROOT$/packages/mcp/src/**", + "$TURBO_ROOT$/packages/plugins/plugin-hono-server/src/**", + "$TURBO_ROOT$/packages/rest/src/**", + "$TURBO_ROOT$/packages/runtime/src/**", + "$TURBO_ROOT$/packages/services/service-realtime/src/**", + "$TURBO_ROOT$/packages/spec/src/automation/**", + "$TURBO_ROOT$/packages/spec/src/data/**" + ] + }, + "create-objectstack#test": { + "dependsOn": ["^build"], + "outputs": [], + "inputs": [ + "$TURBO_DEFAULT$", + "!dist/**", + "!coverage/**", + "!.turbo/**", + "$TURBO_ROOT$/content/**" + ] + }, "test:e2e": { "dependsOn": ["build"], "cache": false,