Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/6120-doc-snippet-dependency-resolution.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
---
---

Doc-snippet gate tooling only, no published package source changed.

`check-doc-snippet-types` compiles every covered snippet as its own module at the
repository ROOT. Workspace packages resolved there — it builds `paths` from each
package's own `exports` — but a THIRD-PARTY specifier did not: under pnpm, a
workspace package's own dependency is not hoisted to the root, so a snippet that
imports `lucide-react` failed `TS2307` even though `@object-ui/layout` and
`@object-ui/components` both declare it and any reader who installs those
packages gets it. Five correct blocks across `content/docs/layout` were red on
nothing but that, which blocked the whole group from being brought under the
gate. The snippets were right; the resolution environment was the gap.

The gate now derives `paths` for the specifiers each imported package DECLARES in
its own `dependencies`, resolved from inside that package's directory — the
environment a real consumer has. Deliberately narrow, and it fails closed:
`dependencies` only (not peers, not devDependencies), only packages a covered
document actually imports, only the bare specifier (no subpath wildcard), and a
dependency shipping no types is left unresolvable rather than approximated. The
repository's own manifests are untouched — declaring `lucide-react` at the root
to buy a snippet its coverage would change what this repo claims to need in order
to satisfy a checker.

A fourth self-control (`undeclared`) now runs on every invocation and keeps that
narrowness honest: a module importing `@floating-ui/react-dom` — installed here
as a transitive of Radix's popper, declared by no package a covered document
imports — MUST still produce `TS2307`. Widen resolution past the declarations and
that control goes green, which is the only way to notice that the gate has become
a rubber stamp no snippet can fail.
151 changes: 151 additions & 0 deletions scripts/__tests__/check-doc-snippet-types.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,9 +8,12 @@ import { fileURLToPath } from 'node:url';
// `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here.
import {
FRAGMENT_MARKER_EXAMPLES,
UNDECLARED_CONTROL_PACKAGE,
UNGATED_DOCS,
analyze,
deriveDeclaredDependencyPaths,
derivePackageTypePaths,
findInstalledCopy,
listDocuments,
scanFences,
} from '../check-doc-snippet-types.mjs';
Expand All@@ -35,6 +38,13 @@ import {
* `tsconfig.json` maps the workspace to source; a harness that inherited it
* would check the docs against code no consumer sees.
* 5. **The gate is wired**, in a workflow a docs-only pull request can start.
* 6. **Third-party resolution reaches exactly as far as the imported packages
* DECLARE** (objectui#6120). This one's failure mode is the worst in the list
* because it is invisible: widen resolution past the declarations and every
* document stays green while the gate stops being able to fail. The suite
* therefore pins both directions — a declared dependency IS mapped, and an
* installed-but-undeclared one is NOT — plus the two preconditions the
* UNDECLARED control needs in order to mean anything.
*
* Fixtures are throwaway trees, never `content/docs`: a committed fixture page
* would have to contain a deliberately broken snippet, and this very gate scans
Expand DownExpand Up@@ -212,6 +222,147 @@ describe('this repository', () => {
});
});

describe('third-party resolution reaches exactly as far as the imported packages declare', () => {
/** A workspace package with its own `node_modules`, the way pnpm links one. */
function treeWithDependency(files: Record<string, string> = {}): string {
return tempTree({
'content/docs/a.mdx': [FENCE + 'ts', "import 'declared-dep';", FENCE].join('\n'),
'packages/pkg-a/package.json': JSON.stringify({
name: 'pkg-a',
dependencies: { 'declared-dep': '^1.0.0' },
peerDependencies: { 'peer-dep': '^1.0.0' },
devDependencies: { 'dev-dep': '^1.0.0' },
}),
'packages/pkg-a/node_modules/declared-dep/package.json': JSON.stringify({
name: 'declared-dep',
types: 'index.d.ts',
}),
'packages/pkg-a/node_modules/declared-dep/index.d.ts': 'export declare const declared: number;\n',
// Installed right beside it and NOT declared: the shape a blanket mapping
// over node_modules would pick up, and the one no consumer can import.
'packages/pkg-a/node_modules/undeclared-dep/package.json': JSON.stringify({
name: 'undeclared-dep',
types: 'index.d.ts',
}),
'packages/pkg-a/node_modules/undeclared-dep/index.d.ts': 'export declare const undeclared: number;\n',
'packages/pkg-a/node_modules/peer-dep/package.json': JSON.stringify({ name: 'peer-dep', types: 'index.d.ts' }),
'packages/pkg-a/node_modules/peer-dep/index.d.ts': 'export declare const peer: number;\n',
'packages/pkg-a/node_modules/dev-dep/package.json': JSON.stringify({ name: 'dev-dep', types: 'index.d.ts' }),
'packages/pkg-a/node_modules/dev-dep/index.d.ts': 'export declare const dev: number;\n',
...files,
});
}

const derive = (root: string, imported: string[] = ['pkg-a']) =>
deriveDeclaredDependencyPaths(root, imported, { 'pkg-a': 'packages/pkg-a' }) as unknown as {
paths: Record<string, string[]>;
declaredBy: Record<string, string>;
untyped: { specifier: string }[];
};

it('maps a specifier the imported package DECLARES — that is what a consumer resolves', () => {
const { paths, declaredBy } = derive(treeWithDependency());
expect(Object.keys(paths)).toContain('declared-dep');
expect(paths['declared-dep'][0]).toMatch(/declared-dep[\\/]index\.d\.ts$/);
expect(declaredBy['declared-dep']).toBe('pkg-a');
});

it('does NOT map a package that is merely INSTALLED — the control that keeps this a check', () => {
// If this ever passes, resolution has been widened to a blanket mapping and
// a snippet may import what no reader of these packages can get.
const { paths } = derive(treeWithDependency());
expect(Object.keys(paths)).not.toContain('undeclared-dep');
});

it('does not map peerDependencies or devDependencies — it fails CLOSED', () => {
const { paths } = derive(treeWithDependency());
expect(Object.keys(paths)).not.toContain('peer-dep');
expect(Object.keys(paths)).not.toContain('dev-dep');
});

it('maps nothing for a package no covered document imports', () => {
const { paths } = derive(treeWithDependency(), []);
expect(paths).toEqual({});
});

it('leaves a specifier that ships no types unresolvable rather than approximating it', () => {
// A JS-only dependency: declared, installed, and carrying nothing a strict
// program can judge. Mapping it to something approximate would report green
// over a snippet nobody type-checked; leaving it unresolvable fails honestly.
const root = tempTree({
'packages/pkg-a/package.json': JSON.stringify({
name: 'pkg-a',
dependencies: { 'untyped-dep': '^1.0.0' },
}),
'packages/pkg-a/node_modules/untyped-dep/package.json': JSON.stringify({
name: 'untyped-dep',
main: 'index.js',
}),
'packages/pkg-a/node_modules/untyped-dep/index.js': 'module.exports = {};\n',
});
const { paths, untyped } = derive(root);
expect(Object.keys(paths)).not.toContain('untyped-dep');
expect(untyped.map((u) => u.specifier)).toContain('untyped-dep');
});

it('never maps a workspace package — those come from their own exports, or deliberately not at all', () => {
const root = tempTree({
'packages/pkg-a/package.json': JSON.stringify({ name: 'pkg-a', dependencies: { 'pkg-b': 'workspace:*' } }),
'packages/pkg-a/node_modules/pkg-b/package.json': JSON.stringify({ name: 'pkg-b', types: 'src/index.ts' }),
'packages/pkg-a/node_modules/pkg-b/src/index.ts': 'export const b = 1;\n',
});
const { paths } = deriveDeclaredDependencyPaths(root, ['pkg-a'], {
'pkg-a': 'packages/pkg-a',
'pkg-b': 'packages/pkg-b',
}) as unknown as { paths: Record<string, string[]> };
expect(Object.keys(paths)).not.toContain('pkg-b');
});

describe('in this repository', () => {
it("maps lucide-react, which the documented packages declare (objectui#6120)", () => {
const state = analyze({}) as unknown as {
dependencyPaths: Record<string, string[]>;
dependencyDeclaredBy: Record<string, string>;
};
expect(Object.keys(state.dependencyPaths)).toContain('lucide-react');
expect(state.dependencyPaths['lucide-react'][0]).toMatch(/\.d\.ts$/);
});

it('maps only declaration files, and never a package src/', () => {
const state = analyze({}) as unknown as { dependencyPaths: Record<string, string[]> };
const targets = Object.values(state.dependencyPaths).map((v) => v[0]);
expect(targets.length).toBeGreaterThan(10);
for (const target of targets) {
expect(target).toMatch(/\.d\.(ts|mts|cts)$/);
expect(target, 'a snippet must never be judged against a package src/').not.toMatch(
/[\\/]packages[\\/][^\\/]+[\\/]src[\\/]/,
);
}
});

it('the UNDECLARED control specifier is installed here — otherwise it proves nothing', () => {
expect(
findInstalledCopy(repoRoot, UNDECLARED_CONTROL_PACKAGE),
`${UNDECLARED_CONTROL_PACKAGE} is not installed, so "it does not resolve" measures nothing`,
).toBeTruthy();
});

it('the UNDECLARED control specifier is declared by no workspace package at all', () => {
const packagesDir = path.join(repoRoot, 'packages');
const declaring = fs
.readdirSync(packagesDir)
.filter((d) => fs.existsSync(path.join(packagesDir, d, 'package.json')))
.filter((d) => {
const manifest = JSON.parse(
fs.readFileSync(path.join(packagesDir, d, 'package.json'), 'utf8'),
) as { dependencies?: Record<string, string> };
return Boolean(manifest.dependencies?.[UNDECLARED_CONTROL_PACKAGE]);
});
expect(declaring, 'pick a control specifier no package declares').toEqual([]);
});
});
});

describe('wiring — a script nothing runs is not a gate', () => {
const workflowDir = path.join(repoRoot, '.github/workflows');
const workflowPath = path.join(workflowDir, 'doc-snippet-types.yml');
Expand Down
Loading
Loading