diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 3d140f64bf..58cde83149 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -603,6 +603,32 @@ jobs:
- name: Test-source alias gate
run: pnpm check:test-source-alias
+ # The TYPE axis of the same invariant (#8180). The gate above reads
+ # `vitest.config.*` and nothing else, so the identical exposure on the
+ # type axis was unguarded repo-wide — and its symptom is likewise a GREEN
+ # `typecheck`. Measured on `packages/qa/downstream-contract` (#8021):
+ # identical checkout, identical stale `dist`, a narrowing injected into
+ # `packages/spec/src` in SOURCE only — without a `paths` block
+ # `tsc --noEmit` exited 0 and clean; with one it reported the `TS2322` the
+ # frozen fixture should always have produced. The comment beside the
+ # type-check-debt step below documents the same mechanism as expected
+ # state ("tsc resolves workspace imports through each dependency's built
+ # `dist/*.d.ts`"), and turbo's `typecheck` dependsOn `^build` does not
+ # reach it for the same reason it does not reach the runtime axis.
+ # NOT a port of the gate above: tsc's `paths` algorithm is exact-key-wins
+ # then longest-prefix (Vite's is first-match-wins), type-only imports
+ # COUNT here, and `tsconfig.json` is JSONC that inherits through
+ # `extends`. The trap is also different and worse — `@objectstack/spec*`,
+ # star not after a separator, folds every namespace onto one target and
+ # type-checks GREEN against the wrong module instead of crashing.
+ # The registry is measured (51 of 76 packages on the day it landed),
+ # shrink-only and audited in BOTH directions. It finds no new offender
+ # today: it is a ratchet, so the remediation list is finite and cannot
+ # grow behind anyone's back. Static scan of sources and tsconfigs; no
+ # build needed (~1.2s), so it belongs in this job.
+ - name: Type-source resolution gate
+ run: pnpm check:type-source-resolution
+
# #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 bec4e34163..fe0d9d4b60 100644
--- a/package.json
+++ b/package.json
@@ -82,6 +82,7 @@
"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:test-source-alias": "node scripts/check-test-source-alias.mjs --self-test && node scripts/check-test-source-alias.mjs",
+ "check:type-source-resolution": "node scripts/check-type-source-resolution.mjs --self-test && node scripts/check-type-source-resolution.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-type-source-resolution.mjs b/scripts/check-type-source-resolution.mjs
new file mode 100644
index 0000000000..a0cc84e332
--- /dev/null
+++ b/scripts/check-type-source-resolution.mjs
@@ -0,0 +1,1331 @@
+#!/usr/bin/env node
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// check-type-source-resolution — a package's TYPES must be a verdict about the
+// source in the checkout, never about a sibling package's build artifact.
+//
+// ── The asymmetry this closes (#7849, #8021, #8180) ─────────────────────────
+//
+// `check:test-source-alias` made the source-resolution invariant mechanical on
+// the RUNTIME axis: it reads every package's `vitest.config.*`, simulates
+// Vite's resolution, and demands that anything still landing on `dist/` be
+// registered. Its header states why a sweep is the wrong terminal state — "it
+// leaves the NEXT package unguarded, and the symptom of the omission is a
+// green test, so nothing would report the gap".
+//
+// That reasoning is axis-independent. The gate was not: it reads
+// `vitest.config.*` and nothing else, so the identical exposure on the TYPE
+// axis was unguarded repo-wide, and its symptom is likewise a green
+// `typecheck`. `.github/workflows/lint.yml` even documents the exposure as the
+// expected state, beside the type-check-debt re-measure step — "tsc resolves
+// workspace imports through each dependency's built `dist/*.d.ts`".
+//
+// Measured end to end on `packages/qa/downstream-contract` (#8021). Identical
+// checkout, identical stale `dist`, a narrowing injected into
+// `packages/spec/src/integration/connector.zod.ts` in SOURCE ONLY, no rebuild:
+//
+// without a `paths` block `tsc --noEmit` -> exit 0, CLEAN
+// with a `paths` block src/additional-domains.fixtures.ts(35,3): error
+// TS2322: Type 'string' is not assignable to type
+// 'number'.
+//
+// That package is the repo's backward-compatibility gate, whose README
+// promises "a removed or NARROWED export fails here" — so it was the loudest
+// instance, not the only one. Ordering does not reach any of them: `turbo.json`
+// orders `typecheck` after `^build`, so `turbo run typecheck` and CI were never
+// the failing paths. What breaks is every path turbo does not mediate —
+// `pnpm --filter PKG typecheck` inside a package, an editor's TS server, an
+// agent in a tree built at an older commit. Those are exactly the paths a type
+// contract is re-read on WHILE someone is changing the dependency.
+//
+// ── Why this is NOT a port of the vitest gate ──────────────────────────────
+//
+// Four things differ, and each one is a way a copied gate would be wrong:
+//
+// 1. THE RESOLUTION ALGORITHM. Vite's is first-match-wins with prefix
+// matching. tsc's is: an EXACT (star-free) key wins outright regardless of
+// declaration order; otherwise the pattern key with the LONGEST MATCHING
+// PREFIX wins, and the captured text substitutes into the target's star.
+// #8020 and #8108 are both defects in how the vitest gate read a CORRECT
+// config — a resolution simulator that is wrong reads compliant packages
+// as broken, so the algorithm is written here from tsc's rules and pinned
+// by `--self-test`, never inherited.
+//
+// 2. THE TRAP HAS A DIFFERENT SPELLING AND IS WORSE. A `paths` key without a
+// `*` is an EXACT match, so a bare `@objectstack/spec` entry cannot
+// swallow `@objectstack/spec/ui` the way a Vite object-form alias does
+// (that one is `ENOTDIR`, loud, at run time). The tsconfig-shaped mistake
+// is `@objectstack/spec*` — star NOT preceded by a slash — which matches
+// every namespace AND the bare name and folds them all onto whatever the
+// target names. It does not crash: `spec/src/index.ts` re-exports most of
+// the namespace surface, so it type-checks against the WRONG MODULE and
+// stays green. A gate that does not flag that spelling certifies the exact
+// defect it exists to find; `--self-test` fixture (5) is that case.
+//
+// 3. `tsconfig.json` IS JSONC AND IT INHERITS. 55 of this repo's 76 package
+// tsconfigs carry comments and 66 use `extends` — both are the norm here,
+// not an edge case, and `vitest.config.*` has neither property. Comments
+// are stripped before `JSON.parse` (the same whole-line rule
+// `scripts/check-type-check-coverage.mjs` uses), and the `extends` chain
+// is resolved before `paths` is read. `compilerOptions` merge SHALLOWLY,
+// so a child's `paths` REPLACES the parent's rather than adding to it, and
+// relative targets resolve against `baseUrl` if declared and otherwise
+// against the directory of the config that ORIGINATED them — which is not
+// necessarily the package's own tsconfig.
+//
+// 4. TYPE-ONLY IMPORTS COUNT HERE. They are the exact inversion of the
+// vitest gate's rule 1: `import type { X } from 'y'` is erased before
+// anything resolves at run time, so that gate does not count it — and on
+// this axis it is precisely what tsc resolves, to `y`'s `.d.ts`. A port
+// that kept the type-only filter would go blind on the majority of the
+// imports it exists to judge.
+//
+// ── What this checks ───────────────────────────────────────────────────────
+//
+// For every workspace package with a `tsconfig.json`:
+//
+// 1. Determine the files tsc puts in the program (`files` / `include` /
+// `exclude`, with TS's defaults), and collect the workspace packages they
+// import — ALL imports, type-only included (see 4 above).
+// 2. Keep only the deps whose TYPES can go stale: the dep's declaration entry
+// resolves under `dist/`. A dep whose types already point at source is not
+// an artifact and needs no `paths` rule — counting it would be a false
+// positive the registry then has to carry forever.
+// 3. Resolve each specifier through the effective `paths` using TSC'S
+// algorithm (1 above). A specifier whose winning target lands under `src/`
+// — and exists on disk — is safe. A target that does NOT exist is not
+// safe: tsc falls back to node resolution, i.e. to `dist`, silently.
+// 4. Anything left resolves through a build artifact, and the package must be
+// registered in `KNOWN_DIST_RESOLVED_TYPE_IMPORTS` below with EXACTLY that
+// set. Unregistered ⇒ red.
+//
+// Rules are judged INDIVIDUALLY, never "does a `paths` block exist". Measured
+// on #8021: with the subpath rule kept and only the bare-entry rule deleted,
+// tsc stayed clean while `src/stack.ts`'s `defineStack` types came from `dist`.
+// A `paths` block that covers eight of nine specifiers is eight-ninths of a
+// verdict, and the ninth is silent.
+//
+// ── The registry, and what its SIZE means ──────────────────────────────────
+//
+// `KNOWN_DIST_RESOLVED_TYPE_IMPORTS` is the measured state of the repo on the
+// day this gate landed, and it is LARGE: exactly one package (`downstream-
+// contract`, fixed by #8021) declares a `paths` rule for a workspace dep at
+// all. So this gate finds no new offender today — it is a RATCHET, and that is
+// the whole of its value: the list of remediation cards is now finite, audited
+// in both directions, and unable to grow behind anyone's back. That is the same
+// trade `check:test-source-alias` made and its header argues for; it is stated
+// plainly here so a large registry is not read as a large finding.
+//
+// ⛔ SHRINK-ONLY, audited in BOTH directions, like `UNRESOLVED_ADR_CITATIONS`
+// in `check-adr-anchors.mjs`: an entry that is no longer needed FAILS and names
+// itself for deletion, so the registry cannot rot into a grandfather clause.
+// Each entry carries the exact set of dist-resolved deps and the audit demands
+// set EQUALITY — a bare list of package names would license a listed package to
+// acquire ten NEW artifact imports with nothing going red, which is the silent
+// regression headroom the type-check DEBT ledger paid 273 raw errors for.
+//
+// ⛔ Two things this gate deliberately does NOT do:
+// - It does not add or edit any package's `tsconfig.json`. Remediation is
+// per-package and lands as its own card, because the switch is not free:
+// putting a dependency's SOURCE into a consumer's program makes tsc check
+// that source under the CONSUMER's `compilerOptions`, which on #8021
+// surfaced 2 `TS2591` (spec source needs `types: ["node"]`) and 247
+// `TS6059` from a `rootDir` that emits nothing under `noEmit` and is still
+// enforced. The gate's job is to make the list of cards finite.
+// - It does not fail a package for having no `paths` block. A package that
+// imports no stale-able workspace dep needs none, and demanding one would
+// be cargo cult. The predicate is the import, not the file.
+//
+// Usage:
+// node scripts/check-type-source-resolution.mjs
+// node scripts/check-type-source-resolution.mjs --list # registry-shaped
+// node scripts/check-type-source-resolution.mjs --self-test
+
+import { readFileSync, readdirSync, statSync, existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
+import { join, resolve, relative, dirname, sep } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { tmpdir } from 'node:os';
+import process from 'node:process';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = resolve(HERE, '..');
+
+/**
+ * Packages whose tsc program imports a workspace package whose declarations
+ * resolve to `dist/`, with no `paths` rule redirecting it to source — i.e.
+ * packages whose type verdicts are currently a function of build state.
+ *
+ * MEASURED, not curated: this is what `--list` printed on the day the gate
+ * landed. Each value is the exact set of dist-resolved type imports for that
+ * package.
+ *
+ * ⛔ SHRINK-ONLY. Adding an entry, or widening one, is not how a red build gets
+ * fixed — add the rule to that package's `tsconfig.json` instead (see the
+ * header for the two rules `downstream-contract` uses and the `@pkg*` spelling
+ * that must never be used). Entries are audited in both directions, so one that
+ * is no longer needed fails the gate and names itself for deletion.
+ */
+const KNOWN_DIST_RESOLVED_TYPE_IMPORTS = {
+ '@objectstack/account': ['@objectstack/platform-objects'],
+ '@objectstack/client': ['@objectstack/core', '@objectstack/spec'],
+ '@objectstack/client-react': ['@objectstack/client', '@objectstack/spec'],
+ '@objectstack/connector-mcp': ['@objectstack/core', '@objectstack/spec'],
+ '@objectstack/connector-openapi': ['@objectstack/core', '@objectstack/spec'],
+ '@objectstack/connector-rest': ['@objectstack/core', '@objectstack/spec'],
+ '@objectstack/connector-slack': [
+ '@objectstack/core', '@objectstack/service-automation', '@objectstack/spec',
+ ],
+ '@objectstack/core': ['@objectstack/metadata-core', '@objectstack/spec'],
+ '@objectstack/dogfood': [
+ '@objectstack/cli', '@objectstack/connector-mcp', '@objectstack/connector-openapi',
+ '@objectstack/connector-rest', '@objectstack/core', '@objectstack/driver-sql',
+ '@objectstack/driver-sqlite-wasm', '@objectstack/mcp', '@objectstack/metadata',
+ '@objectstack/metadata-core', '@objectstack/objectql', '@objectstack/platform-objects',
+ '@objectstack/plugin-audit', '@objectstack/plugin-auth', '@objectstack/plugin-email',
+ '@objectstack/plugin-security', '@objectstack/plugin-webhooks', '@objectstack/service-analytics',
+ '@objectstack/service-messaging', '@objectstack/service-storage', '@objectstack/spec',
+ '@objectstack/types', '@objectstack/verify',
+ ],
+ '@objectstack/driver-memory': ['@objectstack/core', '@objectstack/spec', '@objectstack/types'],
+ '@objectstack/driver-mongodb': ['@objectstack/core', '@objectstack/spec', '@objectstack/types'],
+ '@objectstack/driver-sql': [
+ '@objectstack/core', '@objectstack/formula', '@objectstack/observability', '@objectstack/spec',
+ '@objectstack/types',
+ ],
+ '@objectstack/driver-sqlite-wasm': [
+ '@objectstack/core', '@objectstack/driver-sql', '@objectstack/formula', '@objectstack/spec',
+ ],
+ '@objectstack/driver-turso': [
+ '@objectstack/core', '@objectstack/driver-sql', '@objectstack/spec', '@objectstack/verify',
+ ],
+ '@objectstack/embedder-openai': ['@objectstack/spec'],
+ '@objectstack/example-crm': ['@objectstack/driver-sql', '@objectstack/objectql', '@objectstack/spec'],
+ '@objectstack/example-embed-objectql': [
+ '@objectstack/driver-memory', '@objectstack/objectql', '@objectstack/spec',
+ ],
+ '@objectstack/example-showcase': [
+ '@objectstack/cloud-connection', '@objectstack/connector-mcp', '@objectstack/connector-openapi',
+ '@objectstack/connector-rest', '@objectstack/connector-slack', '@objectstack/core',
+ '@objectstack/driver-sql', '@objectstack/formula', '@objectstack/objectql',
+ '@objectstack/plugin-approvals', '@objectstack/runtime', '@objectstack/service-automation',
+ '@objectstack/service-datasource', '@objectstack/service-messaging', '@objectstack/spec',
+ ],
+ '@objectstack/example-todo': [
+ '@objectstack/core', '@objectstack/driver-sqlite-wasm', '@objectstack/mcp', '@objectstack/objectql',
+ '@objectstack/runtime', '@objectstack/service-automation', '@objectstack/spec',
+ '@objectstack/trigger-record-change',
+ ],
+ '@objectstack/formula': ['@objectstack/spec'],
+ '@objectstack/hono': ['@objectstack/plugin-hono-server', '@objectstack/runtime', '@objectstack/types'],
+ '@objectstack/http-conformance': ['@objectstack/core'],
+ '@objectstack/knowledge-memory': ['@objectstack/core', '@objectstack/spec'],
+ '@objectstack/knowledge-ragflow': ['@objectstack/core', '@objectstack/spec'],
+ '@objectstack/lint': ['@objectstack/formula', '@objectstack/sdui-parser', '@objectstack/spec'],
+ '@objectstack/mcp': [
+ '@objectstack/core', '@objectstack/formula', '@objectstack/spec', '@objectstack/types',
+ ],
+ '@objectstack/metadata': [
+ '@objectstack/core', '@objectstack/driver-sqlite-wasm', '@objectstack/metadata-core',
+ '@objectstack/metadata-fs', '@objectstack/spec', '@objectstack/types',
+ ],
+ '@objectstack/metadata-core': ['@objectstack/spec'],
+ '@objectstack/metadata-fs': ['@objectstack/metadata-core'],
+ '@objectstack/metadata-protocol': [
+ '@objectstack/core', '@objectstack/formula', '@objectstack/lint', '@objectstack/metadata',
+ '@objectstack/metadata-core', '@objectstack/spec', '@objectstack/types',
+ ],
+ '@objectstack/objectql': [
+ '@objectstack/core', '@objectstack/formula', '@objectstack/metadata', '@objectstack/metadata-core',
+ '@objectstack/metadata-protocol', '@objectstack/spec', '@objectstack/types',
+ ],
+ '@objectstack/platform-objects': ['@objectstack/metadata-core', '@objectstack/spec'],
+ '@objectstack/plugin-approvals': [
+ '@objectstack/core', '@objectstack/formula', '@objectstack/spec', '@objectstack/types',
+ ],
+ '@objectstack/plugin-audit': ['@objectstack/core', '@objectstack/objectql', '@objectstack/spec'],
+ '@objectstack/plugin-auth': [
+ '@objectstack/core', '@objectstack/platform-objects', '@objectstack/rest', '@objectstack/spec',
+ '@objectstack/types',
+ ],
+ '@objectstack/plugin-email': [
+ '@objectstack/core', '@objectstack/formula', '@objectstack/objectql', '@objectstack/platform-objects',
+ '@objectstack/service-queue', '@objectstack/service-settings', '@objectstack/spec',
+ ],
+ '@objectstack/plugin-hono-server': [
+ '@objectstack/core', '@objectstack/observability', '@objectstack/spec', '@objectstack/types',
+ ],
+ '@objectstack/plugin-pinyin-search': ['@objectstack/core', '@objectstack/objectql', '@objectstack/types'],
+ '@objectstack/plugin-reports': [
+ '@objectstack/core', '@objectstack/driver-sql', '@objectstack/objectql',
+ '@objectstack/platform-objects', '@objectstack/spec',
+ ],
+ '@objectstack/plugin-security': [
+ '@objectstack/core', '@objectstack/formula', '@objectstack/metadata-core',
+ '@objectstack/platform-objects', '@objectstack/spec',
+ ],
+ '@objectstack/plugin-sharing': [
+ '@objectstack/core', '@objectstack/formula', '@objectstack/metadata-core', '@objectstack/objectql',
+ '@objectstack/platform-objects', '@objectstack/spec', '@objectstack/types',
+ ],
+ '@objectstack/rest': [
+ '@objectstack/core', '@objectstack/metadata-core', '@objectstack/objectql',
+ '@objectstack/observability', '@objectstack/platform-objects', '@objectstack/service-package',
+ '@objectstack/spec', '@objectstack/types',
+ ],
+ '@objectstack/runtime': [
+ '@objectstack/core', '@objectstack/driver-sql', '@objectstack/metadata', '@objectstack/metadata-core',
+ '@objectstack/metadata-protocol', '@objectstack/objectql', '@objectstack/observability',
+ '@objectstack/plugin-auth', '@objectstack/plugin-security', '@objectstack/rest',
+ '@objectstack/service-cluster', '@objectstack/service-datasource', '@objectstack/spec',
+ '@objectstack/types',
+ ],
+ '@objectstack/service-sms': ['@objectstack/core', '@objectstack/plugin-auth', '@objectstack/spec'],
+ '@objectstack/setup': ['@objectstack/platform-objects', '@objectstack/spec'],
+ '@objectstack/studio': ['@objectstack/platform-objects', '@objectstack/spec'],
+ '@objectstack/trigger-api': ['@objectstack/core', '@objectstack/spec'],
+ '@objectstack/trigger-record-change': ['@objectstack/core', '@objectstack/spec'],
+ '@objectstack/trigger-schedule': [
+ '@objectstack/core', '@objectstack/service-automation', '@objectstack/spec',
+ ],
+ '@objectstack/types': ['@objectstack/spec'],
+ '@objectstack/verify': [
+ '@objectstack/objectql', '@objectstack/platform-objects', '@objectstack/plugin-auth',
+ '@objectstack/plugin-hono-server', '@objectstack/plugin-security', '@objectstack/plugin-sharing',
+ '@objectstack/rest', '@objectstack/runtime', '@objectstack/service-analytics',
+ '@objectstack/service-automation', '@objectstack/service-datasource', '@objectstack/service-settings',
+ '@objectstack/spec', '@objectstack/types',
+ ],
+};
+
+// ── workspace enumeration ───────────────────────────────────────────────────
+
+/** Directory globs from pnpm-workspace.yaml, which are all `
/*`. */
+const WORKSPACE_PARENT_DIRS = [
+ 'packages',
+ 'packages/apps',
+ 'packages/adapters',
+ 'packages/connectors',
+ 'packages/drivers',
+ 'packages/plugins',
+ 'packages/qa',
+ 'packages/services',
+ 'packages/triggers',
+ 'apps',
+ 'examples',
+];
+
+const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'coverage', '.turbo', '.next', '.cache']);
+const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
+
+class UnreadableConfig extends Error {}
+
+function listWorkspacePackages(root) {
+ const out = [];
+ for (const parent of WORKSPACE_PARENT_DIRS) {
+ const abs = join(root, parent);
+ if (!existsSync(abs)) continue;
+ for (const name of readdirSync(abs)) {
+ const dir = join(abs, name);
+ const manifest = join(dir, 'package.json');
+ if (!existsSync(manifest)) continue;
+ let json;
+ try {
+ json = JSON.parse(readFileSync(manifest, 'utf8'));
+ } catch {
+ continue;
+ }
+ if (!json.name) continue;
+ out.push({ name: json.name, dir, rel: relative(root, dir), json });
+ }
+ }
+ return out;
+}
+
+/**
+ * Every declaration entry point this package publishes. Explicit `types` wins
+ * where it exists — `types`, `typings`, and any `types` condition anywhere in
+ * `exports`. Only when a package declares none of those does tsc fall back to
+ * the JS entry's sibling `.d.ts`, so only then do the JS entries answer.
+ */
+function declarationTargets(json) {
+ const explicit = [];
+ if (typeof json.types === 'string') explicit.push(json.types);
+ if (typeof json.typings === 'string') explicit.push(json.typings);
+
+ const walk = (node, underTypes) => {
+ if (typeof node === 'string') {
+ if (underTypes) explicit.push(node);
+ return;
+ }
+ if (node == null || typeof node !== 'object') return;
+ for (const [key, value] of Object.entries(node)) {
+ walk(value, underTypes || key === 'types');
+ }
+ };
+ walk(json.exports, false);
+ if (explicit.length > 0) return explicit;
+
+ const fallback = [];
+ const walkAll = (node) => {
+ if (typeof node === 'string') {
+ fallback.push(node);
+ return;
+ }
+ if (node == null || typeof node !== 'object') return;
+ for (const value of Object.values(node)) walkAll(value);
+ };
+ walkAll(json.exports);
+ if (typeof json.main === 'string') fallback.push(json.main);
+ if (typeof json.module === 'string') fallback.push(json.module);
+ return fallback;
+}
+
+/**
+ * Does importing this package's TYPES land on a build artifact? Any declaration
+ * entry under `dist/` is enough: a stale build can then decide a consumer's
+ * verdict through that entry, and which entry a given specifier reaches is the
+ * consumer's business, not this predicate's.
+ */
+function typesResolveToArtifact(json) {
+ return declarationTargets(json).some((target) => /(^|[^a-z])dist\//.test(target.replace(/\\/g, '/')));
+}
+
+// ── tsconfig reading: JSONC, `extends`, and where relative targets resolve ──
+
+/**
+ * `tsconfig.json` is JSONC. Whole-line `//` comments are stripped before
+ * `JSON.parse`, exactly as `scripts/check-type-check-coverage.mjs` does — and
+ * this is not a nicety here: 55 of 76 package tsconfigs in this repo carry
+ * comments, several of them long rationale blocks that NAME the very specifiers
+ * being matched. A gate that cannot parse them reads every one of those
+ * packages as unreadable.
+ */
+function parseJsonc(raw, file) {
+ const stripped = raw.replace(/^\s*\/\/.*$/gm, '');
+ try {
+ return JSON.parse(stripped);
+ } catch (error) {
+ throw new UnreadableConfig(`${file} is not parseable as JSONC: ${error.message}`);
+ }
+}
+
+function resolveExtendsTarget(spec, fromDir) {
+ if (spec.startsWith('.')) {
+ const base = resolve(fromDir, spec);
+ for (const candidate of [base, base + '.json', join(base, 'tsconfig.json')]) {
+ if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
+ }
+ throw new UnreadableConfig(`\`extends\` target does not exist: ${spec}`);
+ }
+ // Bare specifier — resolved out of node_modules, walking up.
+ let dir = fromDir;
+ for (;;) {
+ for (const candidate of [
+ join(dir, 'node_modules', spec),
+ join(dir, 'node_modules', spec + '.json'),
+ join(dir, 'node_modules', spec, 'tsconfig.json'),
+ ]) {
+ if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
+ }
+ const parent = dirname(dir);
+ if (parent === dir) break;
+ dir = parent;
+ }
+ throw new UnreadableConfig(`\`extends\` target cannot be resolved without node_modules: ${spec}`);
+}
+
+/**
+ * The effective config for one tsconfig file, with its `extends` chain applied.
+ *
+ * `compilerOptions` merge SHALLOWLY — a child's `paths` REPLACES the parent's
+ * whole map rather than merging into it — and `files` / `include` / `exclude`
+ * are replaced outright by any config that declares them. Each option remembers
+ * the config file it ORIGINATED in, because relative `paths` targets and
+ * `baseUrl` resolve against that file's directory, which is not necessarily the
+ * package's own tsconfig.
+ */
+function loadTsconfig(file, seen = new Set()) {
+ const abs = resolve(file);
+ if (seen.has(abs)) throw new UnreadableConfig(`\`extends\` chain is cyclic at ${abs}`);
+ seen.add(abs);
+
+ const json = parseJsonc(readFileSync(abs, 'utf8'), abs);
+ const dir = dirname(abs);
+
+ let compilerOptions = {};
+ let originOf = {};
+ let fileSet = {};
+
+ const bases = json.extends == null ? [] : Array.isArray(json.extends) ? json.extends : [json.extends];
+ for (const base of bases) {
+ const parent = loadTsconfig(resolveExtendsTarget(base, dir), new Set(seen));
+ compilerOptions = { ...compilerOptions, ...parent.compilerOptions };
+ originOf = { ...originOf, ...parent.originOf };
+ fileSet = { ...fileSet, ...parent.fileSet };
+ }
+
+ for (const [key, value] of Object.entries(json.compilerOptions ?? {})) {
+ compilerOptions[key] = value;
+ originOf[key] = dir;
+ }
+ for (const key of ['files', 'include', 'exclude']) {
+ if (json[key] !== undefined) fileSet[key] = { value: json[key], dir };
+ }
+
+ return { file: abs, dir, compilerOptions, originOf, fileSet };
+}
+
+/**
+ * Where a relative `paths` target is resolved from: `baseUrl` when declared
+ * (itself relative to the config that declared IT), otherwise the directory of
+ * the config that declared `paths`.
+ */
+function pathsBaseDir(config) {
+ const pathsOrigin = config.originOf.paths ?? config.dir;
+ const baseUrl = config.compilerOptions.baseUrl;
+ if (typeof baseUrl === 'string') return resolve(config.originOf.baseUrl ?? config.dir, baseUrl);
+ return pathsOrigin;
+}
+
+// ── the file set tsc actually puts in the program ───────────────────────────
+
+/** A tsconfig glob as a RegExp over POSIX-separated paths relative to its dir. */
+function globToRegExp(glob) {
+ const g = glob.replace(/\\/g, '/').replace(/^\.\//, '');
+ let re = '';
+ for (let i = 0; i < g.length; i++) {
+ const c = g[i];
+ if (c === '*') {
+ if (g[i + 1] === '*') {
+ i++;
+ if (g[i + 1] === '/') {
+ i++;
+ re += '(?:[^/]*/)*';
+ } else {
+ re += '.*';
+ }
+ } else {
+ re += '[^/]*';
+ }
+ } else if (c === '?') {
+ re += '[^/]';
+ } else {
+ re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
+ }
+ }
+ return new RegExp('^' + re + '$');
+}
+
+const DEFAULT_EXCLUDES = ['node_modules', 'bower_components', 'jspm_packages'];
+
+function walkFiles(dir, acc = []) {
+ let entries;
+ try {
+ entries = readdirSync(dir, { withFileTypes: true });
+ } catch {
+ return acc;
+ }
+ for (const entry of entries) {
+ if (entry.name.startsWith('.')) continue;
+ const path = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (SKIP_DIRS.has(entry.name)) continue;
+ walkFiles(path, acc);
+ } else if (entry.isFile()) {
+ acc.push(path);
+ }
+ }
+ return acc;
+}
+
+/**
+ * The source files tsc puts in the program for this config: `files` plus
+ * everything matching `include` (default `**/*`), minus `exclude` (default
+ * node_modules and friends, plus `outDir`). Directory-shaped excludes exclude
+ * everything beneath them, as tsc's do.
+ *
+ * This is the type axis's ENTRY SET, and it is deliberately wider than the
+ * vitest gate's: that one starts at test files and follows relative imports,
+ * because a specifier only decides a runtime verdict if a test can reach it.
+ * Here every file in the program is checked, so every import in it decides a
+ * verdict — there is nothing to walk from.
+ */
+function programFiles(pkgDir, config) {
+ const rel = (abs) => relative(pkgDir, abs).split(sep).join('/');
+
+ const includeSpec = config.fileSet.include;
+ const excludeSpec = config.fileSet.exclude;
+ const filesSpec = config.fileSet.files;
+
+ const excludes = (excludeSpec?.value ?? DEFAULT_EXCLUDES).map((g) => ({ glob: g, re: globToRegExp(g) }));
+ const outDir = config.compilerOptions.outDir;
+ if (typeof outDir === 'string') excludes.push({ glob: outDir, re: globToRegExp(outDir) });
+
+ const isExcluded = (path) => {
+ for (const { glob, re } of excludes) {
+ if (re.test(path)) return true;
+ const bare = glob.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
+ if (!/[*?]/.test(bare) && (path === bare || path.startsWith(bare + '/'))) return true;
+ }
+ return false;
+ };
+
+ const out = new Set();
+
+ for (const entry of filesSpec?.value ?? []) {
+ const abs = resolve(filesSpec.dir, entry);
+ if (existsSync(abs)) out.add(abs);
+ }
+
+ // `files` alone, with no `include`, means exactly those files.
+ if (filesSpec?.value?.length > 0 && includeSpec === undefined) return [...out];
+
+ const includes = (includeSpec?.value ?? ['**/*']).map((g) => globToRegExp(g));
+ const includeDir = includeSpec?.dir ?? pkgDir;
+ for (const abs of walkFiles(includeDir)) {
+ if (!SOURCE_FILE.test(abs)) continue;
+ const path = relative(includeDir, abs).split(sep).join('/');
+ if (isExcluded(rel(abs))) continue;
+ if (includes.some((re) => re.test(path))) out.add(abs);
+ }
+ return [...out];
+}
+
+// ── import extraction ───────────────────────────────────────────────────────
+
+const IMPORT_PATTERNS =
+ /(?:^|[\s;})])(?:import|export)\s+([\s\S]*?)\s*from\s*['"]([^'"]+)['"]|(?:^|[\s;{(=,:<])import\s*\(\s*['"]([^'"]+)['"]\s*\)|(?:^|[\s;{(=,])require\s*\(\s*['"]([^'"]+)['"]\s*\)|(?:^|[\s;}])import\s+['"]([^'"]+)['"]/g;
+
+/**
+ * Every module specifier this file makes tsc resolve — INCLUDING type-only
+ * imports, which is the inversion of the runtime gate's rule (see header note
+ * 4). `import type { X } from 'y'` never resolves at run time and always
+ * resolves at type time; filtering it here would go blind on the majority of
+ * this axis's imports. `import('y').X` in type position is caught by the
+ * dynamic-import branch.
+ */
+function extractTypeImports(text) {
+ const specs = [];
+ IMPORT_PATTERNS.lastIndex = 0;
+ let match;
+ while ((match = IMPORT_PATTERNS.exec(text))) {
+ const spec = match[2] ?? match[3] ?? match[4] ?? match[5];
+ if (spec) specs.push(spec);
+ }
+ return specs;
+}
+
+// ── tsc's `paths` resolution ────────────────────────────────────────────────
+
+/**
+ * Resolve `specifier` through `paths` the way TSC does — deliberately NOT the
+ * way Vite does:
+ *
+ * - an EXACT (star-free) key that equals the specifier wins outright,
+ * whatever the declaration order;
+ * - otherwise, among pattern keys whose prefix and suffix both match, the one
+ * with the LONGEST MATCHING PREFIX wins — again independent of order;
+ * - the captured text substitutes for the star in the target.
+ *
+ * Vite's algorithm is first-match-wins with prefix matching, and a gate that
+ * used it would read correct configs as wrong (the #8020 / #8108 failure).
+ *
+ * Returns `null` when nothing matches, and null is a REAL ANSWER: it means tsc
+ * falls through to node resolution, i.e. to `dist`.
+ *
+ * Only the FIRST target of a key is followed. tsc tries the list in order and
+ * takes the first that exists, so a fallback list can only ever land somewhere
+ * the first entry did not — reported below as "does not exist", never as safe.
+ */
+function resolveThroughPaths(specifier, paths, baseDir) {
+ const exact = paths[specifier];
+ if (exact && !specifier.includes('*') && exact.length > 0) {
+ return { key: specifier, target: resolve(baseDir, exact[0]) };
+ }
+
+ let best = null;
+ for (const [key, targets] of Object.entries(paths)) {
+ const star = key.indexOf('*');
+ if (star === -1 || !targets || targets.length === 0) continue;
+ const prefix = key.slice(0, star);
+ const suffix = key.slice(star + 1);
+ if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue;
+ if (specifier.length < prefix.length + suffix.length) continue;
+ if (best && best.prefixLength >= prefix.length) continue;
+ const captured = specifier.slice(prefix.length, specifier.length - suffix.length);
+ best = { prefixLength: prefix.length, key, target: resolve(baseDir, targets[0].replace('*', captured)) };
+ }
+ return best ? { key: best.key, target: best.target } : null;
+}
+
+const MODULE_SUFFIXES = ['', '.ts', '.tsx', '.d.ts', '.mts', '.cts', '.d.mts', '.d.cts', '.js', '.jsx', '.mjs', '.cjs'];
+
+/**
+ * Does this `paths` target name something tsc can actually load? A rule whose
+ * target does not exist is not a rule: tsc falls back to node resolution — to
+ * `dist` — and says nothing. That silent fallthrough is the same failure class
+ * as having no rule at all, so it is reported as one.
+ */
+function existsAsModule(target) {
+ for (const suffix of MODULE_SUFFIXES) {
+ const candidate = target + suffix;
+ try {
+ if (statSync(candidate).isFile()) return true;
+ } catch {
+ /* next candidate */
+ }
+ }
+ for (const suffix of MODULE_SUFFIXES) {
+ if (suffix === '') continue;
+ try {
+ if (statSync(join(target, 'index' + suffix)).isFile()) return true;
+ } catch {
+ /* next candidate */
+ }
+ }
+ return false;
+}
+
+function pointsAtSource(path) {
+ const posix = path.split(sep).join('/');
+ return /(^|\/)src(\/|$)/.test(posix) && !/(^|\/)dist(\/|$)/.test(posix);
+}
+
+/**
+ * The `@objectstack/spec*` trap: a pattern key whose star is NOT preceded by a
+ * separator, matching a workspace package NAME. Such a key folds the bare
+ * entry, every subpath and every sibling package sharing the prefix onto one
+ * target. Unlike the Vite object-form trap it does not crash — the target
+ * re-exports most of the surface, so it type-checks against the wrong module
+ * and stays GREEN. This is the one spelling a type-axis gate must refuse
+ * outright, whatever the rest of the config does.
+ */
+function starTraps(paths, workspaceNames) {
+ const traps = [];
+ for (const key of Object.keys(paths)) {
+ const star = key.indexOf('*');
+ if (star <= 0) continue;
+ const prefix = key.slice(0, star);
+ const suffix = key.slice(star + 1);
+ if (prefix.endsWith('/')) continue;
+ const swallowed = [...workspaceNames].filter(
+ (name) => name.startsWith(prefix) && name.endsWith(suffix) && name.length >= prefix.length + suffix.length,
+ );
+ if (swallowed.length > 0) traps.push({ key, swallowed: swallowed.sort() });
+ }
+ return traps;
+}
+
+// ── the scan ────────────────────────────────────────────────────────────────
+
+function scan(root) {
+ const workspace = listWorkspacePackages(root);
+ const names = new Set(workspace.map((p) => p.name));
+ const artifactPackages = new Set(workspace.filter((p) => typesResolveToArtifact(p.json)).map((p) => p.name));
+
+ const packages = [];
+ for (const pkg of workspace) {
+ const configPath = join(pkg.dir, 'tsconfig.json');
+ if (!existsSync(configPath)) continue;
+
+ let config = null;
+ let unreadable = null;
+ try {
+ config = loadTsconfig(configPath);
+ } catch (error) {
+ if (!(error instanceof UnreadableConfig)) throw error;
+ unreadable = error.message;
+ }
+
+ if (unreadable) {
+ packages.push({
+ name: pkg.name,
+ rel: pkg.rel,
+ fileCount: 0,
+ unreadable,
+ distResolved: [],
+ traps: [],
+ missingTargets: [],
+ });
+ continue;
+ }
+
+ const paths = config.compilerOptions.paths ?? {};
+ const baseDir = pathsBaseDir(config);
+ const files = programFiles(pkg.dir, config);
+
+ /** bare workspace name -> the specifiers actually written */
+ const imports = new Map();
+ for (const file of files) {
+ let text;
+ try {
+ text = readFileSync(file, 'utf8');
+ } catch {
+ continue;
+ }
+ for (const spec of extractTypeImports(text)) {
+ if (spec.startsWith('.') || spec.startsWith('/')) continue;
+ const scoped = spec.match(/^(@[^/]+\/[^/]+)(?:\/.*)?$/);
+ const bare = scoped ? scoped[1] : spec.split('/')[0];
+ if (bare === pkg.name || !names.has(bare)) continue;
+ if (!imports.has(bare)) imports.set(bare, new Set());
+ imports.get(bare).add(spec);
+ }
+ }
+
+ const distResolved = [];
+ const missingTargets = [];
+ for (const [dep, specs] of [...imports].sort(([a], [b]) => a.localeCompare(b))) {
+ if (!artifactPackages.has(dep)) continue; // types already point at source
+ let anyDistResolved = false;
+ for (const spec of [...specs].sort()) {
+ const resolved = resolveThroughPaths(spec, paths, baseDir);
+ if (!resolved) {
+ anyDistResolved = true;
+ continue;
+ }
+ if (!existsAsModule(resolved.target)) {
+ missingTargets.push({ spec, key: resolved.key, target: relative(root, resolved.target) });
+ anyDistResolved = true;
+ continue;
+ }
+ if (!pointsAtSource(resolved.target)) anyDistResolved = true;
+ }
+ if (anyDistResolved) distResolved.push(dep);
+ }
+
+ packages.push({
+ name: pkg.name,
+ rel: pkg.rel,
+ fileCount: files.length,
+ unreadable: null,
+ distResolved,
+ traps: starTraps(paths, names),
+ missingTargets,
+ });
+ }
+
+ return { packages, artifactPackages, totalPackages: workspace.length };
+}
+
+// ── the gate ────────────────────────────────────────────────────────────────
+
+function check(root, registry) {
+ const failures = [];
+ const { packages, artifactPackages, totalPackages } = scan(root);
+
+ // Census guard. Every reading below is a scan result, and a scan that has
+ // quietly stopped matching reports a spotless repo. Zero is never the good
+ // news it looks like.
+ if (totalPackages === 0)
+ failures.push('scanner found NO workspace packages at all — the scan is broken, not the repo');
+ if (artifactPackages.size === 0)
+ failures.push('scanner found NO package whose types resolve to `dist/` — entry detection is broken, not the repo');
+ if (packages.length === 0)
+ failures.push('scanner found NO package with a tsconfig.json — config discovery is broken, not the repo');
+ if (packages.length > 0 && packages.every((p) => p.fileCount === 0 && !p.unreadable))
+ failures.push('scanner put NO file in ANY program — include/exclude handling is broken, not the repo');
+
+ const measured = new Map(packages.filter((p) => p.distResolved.length > 0).map((p) => [p.name, p.distResolved]));
+
+ for (const pkg of packages) {
+ if (pkg.unreadable) {
+ failures.push(
+ `${pkg.rel}: tsconfig.json cannot be read (${pkg.unreadable}).\n` +
+ ' This gate must be able to resolve every `extends` chain and read every `paths` block.',
+ );
+ }
+ for (const trap of pkg.traps) {
+ failures.push(
+ `${pkg.rel}: \`paths\` key \`${trap.key}\` puts its star where a separator belongs, so it swallows ` +
+ `${trap.swallowed.join(', ')}\n` +
+ ' and every subpath of them, folding all of it onto one target. This does NOT crash — the target\n' +
+ ' re-exports most of the surface, so the program type-checks against the WRONG MODULE and stays\n' +
+ ` green. Split it into an exact key and a \`/*\` key:\n` +
+ ` "${trap.swallowed[0]}": ["/src/index.ts"],\n` +
+ ` "${trap.swallowed[0]}/*": ["/src/*/index.ts"]`,
+ );
+ }
+ for (const missing of pkg.missingTargets) {
+ failures.push(
+ `${pkg.rel}: \`paths\` key \`${missing.key}\` maps \`${missing.spec}\` to \`${missing.target}\`, which does ` +
+ 'not exist.\n' +
+ ' A rule whose target is missing is not a rule: tsc falls back to node resolution — i.e. to `dist` —\n' +
+ ' and reports nothing. Fix the target or delete the rule; a rule that matches nothing is worse than\n' +
+ ' no rule, because it reads as coverage.',
+ );
+ }
+ }
+
+ for (const [name, deps] of measured) {
+ const registered = registry[name];
+ if (!registered) {
+ const pkg = packages.find((p) => p.name === name);
+ failures.push(
+ `${pkg.rel} (${name}): its tsc program imports ${deps.length} workspace package(s) whose declarations\n` +
+ ` resolve to \`dist/\` with no \`paths\` rule pointing at source:\n` +
+ ` ${deps.join(', ')}\n` +
+ ' Every type verdict in this package — including whatever `typecheck` reports — is currently a\n' +
+ ' function of build state, and the dangerous case is SILENT (a dist merely BEHIND the source\n' +
+ ' type-checks GREEN against old declarations). Add the rules to its tsconfig.json:\n' +
+ ` "paths": { "${deps[0]}": ["/src/index.ts"], "${deps[0]}/*": ["/src/*/index.ts"] }\n` +
+ ' ⚠️ Never spell that key `*`: the star must follow a separator. See this file\'s header.',
+ );
+ continue;
+ }
+ const added = deps.filter((d) => !registered.includes(d));
+ const gone = registered.filter((d) => !deps.includes(d));
+ if (added.length > 0)
+ failures.push(
+ `${name}: NEW dist-resolved type import(s) since this entry was measured: ${added.join(', ')}.\n` +
+ " Add the `paths` rules to the package's tsconfig.json — widening the registry entry is not the fix.",
+ );
+ if (gone.length > 0)
+ failures.push(
+ `${name}: registry entry is STALE — no longer dist-resolved: ${gone.join(', ')}.\n` +
+ ` Narrow the entry to exactly: ${JSON.stringify(deps)}`,
+ );
+ }
+
+ for (const name of Object.keys(registry)) {
+ if (measured.has(name)) continue;
+ const known = packages.some((p) => p.name === name);
+ failures.push(
+ known
+ ? `${name}: registry entry is no longer needed — every workspace type import resolves to source now. Delete the entry.`
+ : `${name}: registry entry names a package with no tsconfig.json (or no such package). Delete the entry.`,
+ );
+ }
+
+ return { failures, packages, measured };
+}
+
+// ── reporting ───────────────────────────────────────────────────────────────
+
+function printList(root) {
+ const { packages } = scan(root);
+ const offenders = packages.filter((p) => p.distResolved.length > 0).sort((a, b) => a.name.localeCompare(b.name));
+ console.log('const KNOWN_DIST_RESOLVED_TYPE_IMPORTS = {');
+ for (const pkg of offenders) {
+ console.log(` '${pkg.name}': [${pkg.distResolved.map((d) => `'${d}'`).join(', ')}],`);
+ }
+ console.log('};');
+ console.error(
+ `\n${offenders.length} of ${packages.length} packages with a tsconfig.json have >=1 workspace type import ` +
+ `resolving through \`dist/\` (${offenders.reduce((n, p) => n + p.distResolved.length, 0)} package-dependency pairs); ` +
+ `${packages.length - offenders.length} are clean.`,
+ );
+}
+
+// ── self-test ───────────────────────────────────────────────────────────────
+//
+// Every reader this repo has written for a resolution config has been wrong at
+// least once, and always in the same shape: the instrument was validated
+// against the spelling its author had in mind and not against the spelling the
+// repo actually uses. So each case below pins the CORRECT form as well as the
+// WRONG one — a gate that only proves it catches the defect has not shown it
+// leaves compliant configs alone, and a false red on 76 packages is worse than
+// the exposure.
+
+function fixture(root, rel, files) {
+ const dir = join(root, rel);
+ for (const [name, content] of Object.entries(files)) {
+ const path = join(dir, name);
+ mkdirSync(dirname(path), { recursive: true });
+ writeFileSync(path, content, 'utf8');
+ }
+ return dir;
+}
+
+const ARTIFACT_MANIFEST = (name) =>
+ JSON.stringify(
+ { name, main: 'dist/index.js', types: 'dist/index.d.ts', exports: { '.': { types: './dist/index.d.ts' } } },
+ null,
+ 2,
+ );
+
+function buildFixtureTree() {
+ const root = join(tmpdir(), `os-type-source-resolution-selftest-${process.pid}`);
+ rmSync(root, { recursive: true, force: true });
+ mkdirSync(join(root, 'packages'), { recursive: true });
+
+ writeFileSync(
+ join(root, 'tsconfig.json'),
+ JSON.stringify({ compilerOptions: { strict: true }, exclude: ['node_modules'] }, null, 2),
+ 'utf8',
+ );
+
+ // The stale-able dependency the fixtures import, with the namespace shape
+ // `@objectstack/spec` really has.
+ fixture(root, 'packages/spec', {
+ 'package.json': ARTIFACT_MANIFEST('@fx/spec'),
+ 'src/index.ts': 'export const alive = 1;\n',
+ 'src/ui/index.ts': 'export const ui = 1;\n',
+ 'src/integration/index.ts': 'export const integration = 1;\n',
+ });
+
+ // A sibling sharing the prefix — what `@fx/spec*` also swallows.
+ fixture(root, 'packages/spec-tools', {
+ 'package.json': ARTIFACT_MANIFEST('@fx/spec-tools'),
+ 'src/index.ts': 'export const tools = 1;\n',
+ });
+
+ // (1) violating: imports the artifact, no `paths` at all.
+ fixture(root, 'packages/violator', {
+ 'package.json': ARTIFACT_MANIFEST('@fx/violator'),
+ 'tsconfig.json': JSON.stringify({ include: ['src/**/*'] }, null, 2),
+ 'src/thing.ts': "import { alive } from '@fx/spec';\nexport const thing = alive;\n",
+ });
+
+ // (2) compliant: the two-rule shape, exact key + `/*` key.
+ fixture(root, 'packages/compliant', {
+ 'package.json': ARTIFACT_MANIFEST('@fx/compliant'),
+ 'tsconfig.json': JSON.stringify(
+ {
+ compilerOptions: {
+ paths: { '@fx/spec': ['../spec/src/index.ts'], '@fx/spec/*': ['../spec/src/*/index.ts'] },
+ },
+ include: ['src/**/*'],
+ },
+ null,
+ 2,
+ ),
+ 'src/thing.ts': "import { alive } from '@fx/spec';\nimport { ui } from '@fx/spec/ui';\nexport const t = alive + ui;\n",
+ });
+
+ // (3) TYPE-ONLY imports count on this axis — the inversion of the runtime
+ // gate's rule. A gate that inherited the type-only filter reports this
+ // package as clean while its declarations come from `dist`.
+ fixture(root, 'packages/type-only', {
+ 'package.json': ARTIFACT_MANIFEST('@fx/type-only'),
+ 'tsconfig.json': JSON.stringify({ include: ['src/**/*'] }, null, 2),
+ 'src/thing.ts': "import type { Alive } from '@fx/spec';\nexport type T = Alive;\n",
+ });
+
+ // (4) JSONC — the CORRECT spelling, buried in the comment style this repo
+ // really writes (55 of 76 tsconfigs). A reader that cannot strip comments
+ // reports this compliant package as unreadable.
+ fixture(root, 'packages/jsonc', {
+ 'package.json': ARTIFACT_MANIFEST('@fx/jsonc'),
+ 'tsconfig.json':
+ '{\n' +
+ ' "compilerOptions": {\n' +
+ ' // Why `paths` at all: the dep resolves through `exports` to dist,\n' +
+ ' // so `@fx/spec` and `@fx/spec/ui` would be a verdict about the last\n' +
+ ' // build. Both rules are load-bearing and fail INDEPENDENTLY.\n' +
+ ' "paths": {\n' +
+ ' "@fx/spec": ["../spec/src/index.ts"],\n' +
+ ' "@fx/spec/*": ["../spec/src/*/index.ts"]\n' +
+ ' }\n' +
+ ' },\n' +
+ ' "include": ["src/**/*"]\n' +
+ '}\n',
+ 'src/thing.ts': "import { alive } from '@fx/spec';\nimport { ui } from '@fx/spec/ui';\nexport const t = alive + ui;\n",
+ });
+
+ // ── (5) THE TRAP ──────────────────────────────────────────────────────────
+ //
+ // `@fx/spec*` — star NOT preceded by a separator. It matches the bare name,
+ // every namespace, AND the sibling `@fx/spec-tools`, folding all of them onto
+ // one target. It does not crash and it type-checks green, which is why it is
+ // strictly worse than the Vite `ENOTDIR` trap. Every specifier here resolves
+ // to a path under `src/`, so a gate that only asks "did it land on source"
+ // certifies this config as correct — which is the exact defect this gate
+ // exists to find.
+ fixture(root, 'packages/star-trap', {
+ 'package.json': ARTIFACT_MANIFEST('@fx/star-trap'),
+ 'tsconfig.json': JSON.stringify(
+ { compilerOptions: { paths: { '@fx/spec*': ['../spec/src/index.ts'] } }, include: ['src/**/*'] },
+ null,
+ 2,
+ ),
+ 'src/thing.ts':
+ "import { alive } from '@fx/spec';\nimport { ui } from '@fx/spec/ui';\n" +
+ "import { tools } from '@fx/spec-tools';\nexport const t = alive + ui + tools;\n",
+ });
+
+ // (6) `extends`, with the `paths` living in the PARENT and relative targets
+ // resolved from the PARENT's directory. Correct spelling; must stay quiet.
+ fixture(root, 'packages/inherits', {
+ 'package.json': ARTIFACT_MANIFEST('@fx/inherits'),
+ 'base/tsconfig.base.json': JSON.stringify(
+ { compilerOptions: { paths: { '@fx/spec': ['../../spec/src/index.ts'] } } },
+ null,
+ 2,
+ ),
+ 'tsconfig.json': JSON.stringify({ extends: './base/tsconfig.base.json', include: ['src/**/*'] }, null, 2),
+ 'src/thing.ts': "import { alive } from '@fx/spec';\nexport const t = alive;\n",
+ });
+
+ // (7) `compilerOptions` merge SHALLOWLY: a child's `paths` REPLACES the
+ // parent's whole map. The parent aliases `@fx/spec`; the child redeclares
+ // `paths` for something else, so the inherited rule is GONE — and a gate that
+ // deep-merged would report this package as compliant when tsc does not.
+ fixture(root, 'packages/shadowed', {
+ 'package.json': ARTIFACT_MANIFEST('@fx/shadowed'),
+ 'base.json': JSON.stringify({ compilerOptions: { paths: { '@fx/spec': ['./spec-shim.ts'] } } }, null, 2),
+ 'spec-shim.ts': 'export const alive = 1;\n',
+ 'tsconfig.json': JSON.stringify(
+ { extends: './base.json', compilerOptions: { paths: { '#internal/*': ['./src/*'] } }, include: ['src/**/*'] },
+ null,
+ 2,
+ ),
+ 'src/thing.ts': "import { alive } from '@fx/spec';\nexport const t = alive;\n",
+ });
+
+ // (8) tsc's algorithm, not Vite's — part one: an EXACT key wins outright even
+ // when a pattern key is declared FIRST and also matches. Vite is
+ // first-match-wins, so a ported resolver sends this to `dist` and reports a
+ // compliant package.
+ fixture(root, 'packages/exact-wins', {
+ 'package.json': ARTIFACT_MANIFEST('@fx/exact-wins'),
+ 'tsconfig.json': JSON.stringify(
+ {
+ compilerOptions: {
+ paths: { '@fx/*': ['../spec/dist/*'], '@fx/spec': ['../spec/src/index.ts'] },
+ },
+ include: ['src/**/*'],
+ },
+ null,
+ 2,
+ ),
+ 'src/thing.ts': "import { alive } from '@fx/spec';\nexport const t = alive;\n",
+ });
+
+ // (9) …part two: among pattern keys the LONGEST MATCHING PREFIX wins, again
+ // regardless of order. The short `@fx/*` is declared first and lands on
+ // `dist`; tsc picks `@fx/spec/*`.
+ fixture(root, 'packages/longest-prefix-wins', {
+ 'package.json': ARTIFACT_MANIFEST('@fx/longest-prefix-wins'),
+ 'tsconfig.json': JSON.stringify(
+ {
+ compilerOptions: {
+ paths: { '@fx/*': ['../spec/dist/*'], '@fx/spec/*': ['../spec/src/*/index.ts'] },
+ },
+ include: ['src/**/*'],
+ },
+ null,
+ 2,
+ ),
+ 'src/thing.ts': "import { ui } from '@fx/spec/ui';\nexport const t = ui;\n",
+ });
+
+ // (10) a `paths` rule is a spelling, not a licence: one landing on `dist/` is
+ // still a dist-resolved type import.
+ fixture(root, 'packages/paths-to-dist', {
+ 'package.json': ARTIFACT_MANIFEST('@fx/paths-to-dist'),
+ 'tsconfig.json': JSON.stringify(
+ { compilerOptions: { paths: { '@fx/spec': ['../spec/dist/index.d.ts'] } }, include: ['src/**/*'] },
+ null,
+ 2,
+ ),
+ 'src/thing.ts': "import { alive } from '@fx/spec';\nexport const t = alive;\n",
+ });
+
+ // (11) a rule whose target does not exist is not a rule — tsc silently falls
+ // back to node resolution. Reported as its own failure so the diagnostic
+ // names the typo rather than the package.
+ fixture(root, 'packages/missing-target', {
+ 'package.json': ARTIFACT_MANIFEST('@fx/missing-target'),
+ 'tsconfig.json': JSON.stringify(
+ { compilerOptions: { paths: { '@fx/spec': ['../spec/source/index.ts'] } }, include: ['src/**/*'] },
+ null,
+ 2,
+ ),
+ 'src/thing.ts': "import { alive } from '@fx/spec';\nexport const t = alive;\n",
+ });
+
+ // (12) FALSE-POSITIVE GUARD: a package with a tsconfig and no workspace dep
+ // resolving through `dist/` must not appear at all. A gate that flags
+ // everything is as useless as one that flags nothing.
+ fixture(root, 'packages/no-workspace-dep', {
+ 'package.json': ARTIFACT_MANIFEST('@fx/no-workspace-dep'),
+ 'tsconfig.json': JSON.stringify({ include: ['src/**/*'] }, null, 2),
+ 'src/thing.ts': "import { readFileSync } from 'node:fs';\nexport const t = readFileSync;\n",
+ });
+
+ // (13) …and a dep whose own types already point at SOURCE is not an artifact.
+ fixture(root, 'packages/source-dep', {
+ 'package.json': JSON.stringify({ name: '@fx/source-dep', types: 'src/index.ts', exports: { '.': './src/index.ts' } }, null, 2),
+ 'src/index.ts': 'export const s = 1;\n',
+ });
+ fixture(root, 'packages/consumes-source', {
+ 'package.json': ARTIFACT_MANIFEST('@fx/consumes-source'),
+ 'tsconfig.json': JSON.stringify({ include: ['src/**/*'] }, null, 2),
+ 'src/thing.ts': "import { s } from '@fx/source-dep';\nexport const t = s;\n",
+ });
+
+ // (14) `include` is a real filter: a file OUTSIDE the program cannot decide a
+ // type verdict, so its imports are not this package's exposure.
+ fixture(root, 'packages/outside-program', {
+ 'package.json': ARTIFACT_MANIFEST('@fx/outside-program'),
+ 'tsconfig.json': JSON.stringify({ include: ['src/**/*'], exclude: ['src/generated'] }, null, 2),
+ 'src/thing.ts': "export const t = 1;\n",
+ 'src/generated/gen.ts': "import { alive } from '@fx/spec';\nexport const g = alive;\n",
+ 'scripts/tool.ts': "import { alive } from '@fx/spec';\nexport const s = alive;\n",
+ });
+
+ // (15) `baseUrl` moves where relative targets resolve FROM. Same rule, same
+ // package, correct — a reader that ignores `baseUrl` calls this a missing
+ // target and reports a compliant package.
+ fixture(root, 'packages/base-url', {
+ 'package.json': ARTIFACT_MANIFEST('@fx/base-url'),
+ 'tsconfig.json': JSON.stringify(
+ { compilerOptions: { baseUrl: '..', paths: { '@fx/spec': ['./spec/src/index.ts'] } }, include: ['src/**/*'] },
+ null,
+ 2,
+ ),
+ 'src/thing.ts': "import { alive } from '@fx/spec';\nexport const t = alive;\n",
+ });
+
+ // (16) fail-closed: a tsconfig this gate cannot parse is UNREADABLE, never a
+ // package that resolves nothing.
+ fixture(root, 'packages/unparseable', {
+ 'package.json': ARTIFACT_MANIFEST('@fx/unparseable'),
+ 'tsconfig.json': '{ "compilerOptions": { "paths": { "@fx/spec": ["../spec/src/index.ts"] } } /* trailing */ ,,}\n',
+ 'src/thing.ts': "import { alive } from '@fx/spec';\nexport const t = alive;\n",
+ });
+
+ return root;
+}
+
+function selfTest() {
+ const root = buildFixtureTree();
+ const problems = [];
+ const expect = (condition, message) => {
+ if (!condition) problems.push(message);
+ };
+ const has = (failures, needle) => failures.some((f) => f.includes(needle));
+ const reported = (result, rel) => result.failures.some((f) => f.includes(rel));
+
+ try {
+ const bare = check(root, {});
+
+ // ── the defect is caught ──────────────────────────────────────────────
+ expect(reported(bare, 'packages/violator'), 'a package with no `paths` at all was not reported');
+ expect(
+ reported(bare, 'packages/type-only'),
+ 'a TYPE-ONLY import of a dist-resolving dep was not counted — the runtime gate\'s filter must NOT be inherited',
+ );
+ expect(reported(bare, 'packages/shadowed'), "a child `paths` block that REPLACES the parent's was deep-merged");
+ expect(reported(bare, 'packages/paths-to-dist'), 'a `paths` rule landing on `dist/` was read as aliased to source');
+
+ // ── THE TRAP: `@fx/spec*`, star not after a separator ─────────────────
+ // Every specifier in this fixture lands under `src/`, so "did it reach
+ // source" says yes. The gate must still refuse the spelling.
+ expect(reported(bare, 'packages/star-trap'), 'the `@pkg*` trap (star not preceded by a separator) was NOT flagged');
+ expect(
+ has(bare.failures, '@fx/spec-tools'),
+ 'the `@pkg*` trap diagnostic did not name the sibling package the key also swallows',
+ );
+
+ // ── a rule that matches nothing is not coverage ───────────────────────
+ expect(reported(bare, 'packages/missing-target'), 'a `paths` target that does not exist was accepted as a rule');
+ expect(has(bare.failures, 'does not exist'), 'the missing-target diagnostic did not say the target is missing');
+
+ // ── the CORRECT spellings must stay quiet ─────────────────────────────
+ expect(!reported(bare, 'packages/compliant'), 'the two-rule compliant config was reported');
+ expect(!reported(bare, 'packages/jsonc'), 'a CORRECT config was reported because its comments were not stripped');
+ expect(!reported(bare, 'packages/inherits'), 'a correct `paths` block inherited through `extends` was not seen');
+ expect(!reported(bare, 'packages/base-url'), '`baseUrl` was ignored, so a correct config read as a missing target');
+ expect(
+ !reported(bare, 'packages/exact-wins'),
+ "an EXACT key declared after a matching pattern key did not win — that is Vite's algorithm, not tsc's",
+ );
+ expect(
+ !reported(bare, 'packages/longest-prefix-wins'),
+ 'the LONGEST matching prefix did not win among pattern keys — first-match-wins is Vite, not tsc',
+ );
+
+ // ── false positives ───────────────────────────────────────────────────
+ expect(!reported(bare, 'packages/no-workspace-dep'), 'a package with no workspace dep at all was flagged');
+ expect(!reported(bare, 'packages/consumes-source'), 'a dep whose types already point at source was flagged');
+ expect(
+ !reported(bare, 'packages/outside-program'),
+ '`include`/`exclude` were ignored, so a file outside the program decided the verdict',
+ );
+
+ // ── fail-closed ───────────────────────────────────────────────────────
+ expect(reported(bare, 'packages/unparseable'), 'an unparseable tsconfig was read as resolving nothing');
+ expect(has(bare.failures, 'cannot be read'), 'an unparseable tsconfig did not fail as unreadable');
+
+ // ── the registry, audited in BOTH directions ──────────────────────────
+ const measuredNames = {
+ '@fx/violator': ['@fx/spec'],
+ '@fx/type-only': ['@fx/spec'],
+ '@fx/shadowed': ['@fx/spec'],
+ '@fx/paths-to-dist': ['@fx/spec'],
+ '@fx/star-trap': ['@fx/spec'],
+ '@fx/missing-target': ['@fx/spec'],
+ };
+ const registered = check(root, measuredNames);
+ expect(
+ !has(registered.failures, 'resolve to `dist/` with no'),
+ 'correctly registered packages still failed the unregistered check',
+ );
+ // Registration silences the REGISTRY complaint and nothing else: the trap
+ // and the dead rule are config defects, not measured state to grandfather.
+ expect(reported(registered, 'packages/star-trap'), 'registering a package suppressed the `@pkg*` trap failure');
+ expect(reported(registered, 'packages/missing-target'), 'registering a package suppressed the dead-rule failure');
+
+ const stale = check(root, { ...measuredNames, '@fx/compliant': ['@fx/spec'] });
+ expect(has(stale.failures, 'no longer needed'), 'a registry entry for an already-fixed package did not fail');
+
+ const ghost = check(root, { ...measuredNames, '@fx/ghost': ['@fx/spec'] });
+ expect(has(ghost.failures, '@fx/ghost'), 'a registry entry for a non-existent package did not fail');
+
+ fixture(root, 'packages/other', {
+ 'package.json': ARTIFACT_MANIFEST('@fx/other'),
+ 'src/index.ts': 'export const other = 1;\n',
+ });
+ fixture(root, 'packages/violator', {
+ 'src/second.ts': "import { other } from '@fx/other';\nexport const s = other;\n",
+ });
+ const grown = check(root, measuredNames);
+ expect(has(grown.failures, 'NEW dist-resolved type import'), 'a new dist-resolved import under an entry did not fail');
+
+ const wide = check(root, { ...measuredNames, '@fx/violator': ['@fx/spec', '@fx/other', '@fx/gone'] });
+ expect(has(wide.failures, 'STALE'), 'a registry entry listing a dep that is no longer dist-resolved did not fail');
+
+ // ── census guard: an empty tree is a broken scanner, never a clean repo ─
+ const empty = join(tmpdir(), `os-type-source-resolution-empty-${process.pid}`);
+ rmSync(empty, { recursive: true, force: true });
+ mkdirSync(empty, { recursive: true });
+ const emptyResult = check(empty, {});
+ expect(has(emptyResult.failures, 'the scan is broken'), 'an empty tree did not trip the census guard');
+ rmSync(empty, { recursive: true, force: true });
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+
+ if (problems.length > 0) {
+ console.error('check-type-source-resolution --self-test FAILED:');
+ for (const problem of problems) console.error(` - ${problem}`);
+ process.exit(1);
+ }
+ console.log('check-type-source-resolution --self-test OK');
+}
+
+// ── entry point ─────────────────────────────────────────────────────────────
+
+const argv = process.argv.slice(2);
+if (argv.includes('--self-test')) {
+ selfTest();
+} else if (argv.includes('--list')) {
+ printList(REPO_ROOT);
+} else {
+ const { failures, packages, measured } = check(REPO_ROOT, KNOWN_DIST_RESOLVED_TYPE_IMPORTS);
+ if (failures.length > 0) {
+ console.error('check-type-source-resolution FAILED\n');
+ for (const failure of failures) console.error(` ✗ ${failure}\n`);
+ console.error(
+ "A package's types must be a verdict about the source in the checkout. See this file's header for\n" +
+ 'why the dangerous case is a typecheck that PASSES.',
+ );
+ process.exit(1);
+ }
+ console.log(
+ `check-type-source-resolution OK — ${packages.length} packages with a tsconfig.json scanned; ` +
+ `${measured.size} registered as still resolving a workspace dep's types through \`dist/\`.`,
+ );
+}