diff --git a/.changeset/ai-docs-audit.md b/.changeset/ai-docs-audit.md new file mode 100644 index 0000000..810b126 --- /dev/null +++ b/.changeset/ai-docs-audit.md @@ -0,0 +1,36 @@ +--- +'@amritk/lynx-deep-linking': patch +'@amritk/lynx-notifications': patch +'@amritk/lynx-dialogs': patch +'@amritk/lynx-location': patch +'@amritk/mini-lynx': patch +'@amritk/mini': patch +--- + +Bring every package's shipped `AI.md` back in line with what that package +actually publishes, and add `bun run check:ai-docs` so it cannot drift again. + +The files had gone stale in the way generated-and-committed docs always do — +silently, and only for the audience that cannot file an issue about it. +`@amritk/mini` never documented `watch`, `template`, the typed `matchRoute` / +`buildPath` re-exports on `/router`, `Field` on `/forms`, or the `/vite` subpath +at all; `@amritk/mini-lynx` was missing `computed` / `effectScope`, +`fadeTransition`, `keepAboveKeyboard` and `HANDLER_PREFIX`; +`@amritk/lynx-notifications` documented neither its `/testing` subpath nor the +fake behind it. All four native packages exported `MODULE` and `EVENTS` with no +mention of what they are for, and only `@amritk/lynx-dialogs` showed how to wire +a fake into `installNativeBridge` — which is the one thing a consumer testing +its own screens needs. + +Two accuracy fixes matter more than the additions. Every native package's +*Status* section claimed the Objective-C compiles against the real Lynx pod; the +macOS CI job was disabled on cost, so it now compiles only when somebody runs +`pod lib lint` by hand, and the docs say that. And `@amritk/lynx-dialogs` never +carried a *Status* section at all, so nothing in it told a reader that none of +it has run on a device. + +`bun run check:ai-docs` reads each package's `exports` and fails on a runtime +export, a published subpath, or (for a package shipping native sources) a +*Status* section its `AI.md` never mentions. It runs early in CI, before the +build. Exports no consumer ever writes — the tree operations the JSX transform +calls, and the like — are listed in `INTERNAL_EXPORTS` with the reason. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63c2bfa..2b8a235 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,13 @@ jobs: - name: Check mini reactivity footgun run: bun run check:reactivity + # Regenerating `llms.txt` catches a stale bundle, but a stale *source* + # regenerates perfectly: an `AI.md` that never mentions the subpath a + # release added is wrong in a way no diff can see. This reads what each + # package actually exports and asks whether its `AI.md` says so. + - name: Check AI docs match what each package publishes + run: bun run check:ai-docs + # `llms.txt` and `llms-full.txt` are generated from the packages' `AI.md` # files and committed, so they go stale in exactly the way a committed # build artifact always does: silently, and only for the audience that diff --git a/AGENTS.md b/AGENTS.md index c1e8ee4..46c38e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,15 @@ this repository**. For Claude Code the same rules live in > an **`AI.md`** next to its `README.md` with a mental model, a minimal example, > and the gotchas most likely to trip up an LLM. Start there. +**Adding an export means adding a line to that package's `AI.md`.** +`bun run check:ai-docs` reads what each package publishes and fails on anything +its `AI.md` never mentions — a new subpath, a new function, a native package +with no *Status* section. It is not a style check: these files are the only +documentation a coding agent consuming the package will ever read, and a wrong +one is worse than a missing one because nothing about it looks stale. An export +no consumer ever writes goes in `INTERNAL_EXPORTS` in `scripts/ai-docs.ts`, with +the reason. + ## What this is `mini` is a **Bun monorepo** holding a deliberately tiny signals UI runtime in @@ -86,6 +95,7 @@ bun install # install workspace deps bun run test # run every package's tests (packages/* only) bun run check # biome lint + format check bun run check:reactivity # guard the compilerless-JSX called-signal footgun (packages + apps) +bun run check:ai-docs # every package's AI.md against what that package actually exports bun run check:android # compile the notifications Kotlin (needs ANDROID_HOME; skips without) bun run types:check # type-check both packages and both playgrounds bun run build # build both packages and both playgrounds diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1dc0af9..2574e32 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,7 @@ You'll need [Bun](https://bun.sh) ≥ 1.1. | `bun run check` | Lint with biome | | `bun run format` | Auto-format with biome | | `bun run check:reactivity` | Catch signals frozen by being called in JSX | +| `bun run check:ai-docs` | Check every package's `AI.md` against what it publishes | | `bun run types:check` | Type-check both packages and both playgrounds | | `bun run build` | Build both packages and both playgrounds | | `bun run test:dist` | Load, drive and npm-install the built artifacts (needs a prior build) | diff --git a/llms-full.txt b/llms-full.txt index 03f4904..e2ba843 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -4,7 +4,10 @@ Generated from each package's AI.md by `scripts/generate-llms.ts`. This is the p --- -# @amritk/mini — notes for AI coding agents +# AI.md — @amritk/mini + +For an LLM consuming this package. Editing the repo instead? See +[`AGENTS.md`](./AGENTS.md). A deliberately tiny signals-based UI layer: reactive DOM bindings plus a compilerless JSX runtime. This file is the fast path for an LLM; the full @@ -42,7 +45,7 @@ and `bun run check:reactivity` runs the same check in CI. ## Signals ```ts -import { signal, computed, effect, batch } from '@amritk/mini' +import { signal, computed, effect, effectScope, batch, watch } from '@amritk/mini' const count = signal(0) count() // read → 0 @@ -52,6 +55,22 @@ effect(() => console.log(doubled())) // re-runs on every change; runs sync batch(() => { count(1); count(2) }) // one propagation pass, not two ``` +- **`watch(get, callback, options?)`** reacts to *changes*, which `effect` + cannot express: the first evaluation only records dependencies, and the + callback fires on later changes with `(next, previous)`. That is what makes + "attach a listener when the overlay opens" safe to write — it must not fire + during setup. Pass `{ immediate: true }` to run for the current value too + (`previous` arrives `undefined`). Only `get` is tracked, so the callback may + read and write other signals without re-arming the watcher. It returns a stop + function, and values are compared with `Object.is`. +- **`effectScope(fn)`** owns the effects created inside it and disposes them + together. `mount` opens one for you — reach for it directly only outside a + component tree. +- **`template(html)`** parses a static HTML string **once** and returns a clone + factory; each call hands back a `TemplateInstance` — the cloned `root` plus a + `ref` map of the elements you marked — to wire up with the imperative binds + below. It is the escape hatch for hot markup, not the normal way to build UI. + ## Building UI ```tsx @@ -101,15 +120,53 @@ and `` actually selects the option. They are one-way; | Import | Purpose | Extra peer dep | |---|---|---| -| `@amritk/mini` | signals, `mount`, `list`, binds, JSX | — | -| `@amritk/mini/router` | client-side router (`createRouter`, `Link`, `RouterView`) | — | +| `@amritk/mini` | signals, `watch`, `mount`, `list`, `template`, binds, JSX | — | +| `@amritk/mini/router` | client-side router: `createRouter`, `Link`, `RouterView`, plus `matchRoute` / `buildPath` re-exported from `@amritk/mini-helpers` | — | | `@amritk/mini/flow` | `Show` / `Switch` / `Match` / `For` / `Dynamic` control-flow | — | -| `@amritk/mini/forms` | `createForm` field state + validation | `@amritk/runtime-validators` (schema arm only) | +| `@amritk/mini/forms` | `createForm` field state + validation, `Field`, `schemaToValidator` | `@amritk/runtime-validators` (schema arm only) | | `@amritk/mini/query` | `createQuery` cache/dedupe/retry adapter | `@tanstack/query-core` | | `@amritk/mini/hot` | `hotMount` — hot reloading at the app entry point | — | +| `@amritk/mini/vite` | build-time tooling: `catchCalledSignals`, `acceptHotUpdates`, `findCalledSignalBindings` | `vite` (for the plugins) | Install: `bun add @amritk/mini` (or npm/pnpm/yarn). +### Routing + +`createRouter` matches the URL into a reactive `route` signal and gives you +`navigate`; `` takes `router.navigate` as a **prop** rather than reading +an ambient context, because mini prop-drills on purpose. Matching itself is +pure string arithmetic re-exported from `@amritk/mini-helpers`, so the route +table and the type both follow the pattern: + +```ts +import { buildPath, matchRoute } from '@amritk/mini/router' + +matchRoute('/users/:id', '/users/42') // → { id: '42' }, typed from the literal +buildPath('/users/:id', { id: '42' }) // → '/users/42', and cannot forget a param +``` + +`matchRoute` returns `null` when nothing matched — `{}` is a *successful* match +of a pattern with no params, so `if (!params)` is the check. + +### Forms + +`createForm` holds values, dirty/touched/error state and submit handling as +signals, and withholds a field's message until it is blurred or the form +submitted. `` renders label, +control and live error in one go; `as="textarea" | "select"` picks the control. +Validation is either a plain `(values) => errors` function or a JSON Schema +object, told apart at runtime by `typeof` — the schema arm compiles through +`schemaToValidator` and is the only thing that needs +`@amritk/runtime-validators`. + +### Build-time tooling on `/vite` + +`catchCalledSignals()` is the plugin that catches the called-signal mistake +above; `findCalledSignalBindings(source)` is the scanner underneath it, for a +CLI gate or a non-Vite toolchain — hand it source text, get back the bindings it +flagged. Both are purely syntactic and skip any line marked +`// mini-static-ok`, which is how a deliberately static read opts out. + ## Hot reload Both halves are required — the plugin marks the boundary, the helper owns the diff --git a/scripts/ai-docs.test.ts b/scripts/ai-docs.test.ts new file mode 100644 index 0000000..8c6de99 --- /dev/null +++ b/scripts/ai-docs.test.ts @@ -0,0 +1,98 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +import { type AiDocFinding, auditAiDocs, auditPackage, type PackageManifest, publicValueExports } from './ai-docs' + +const MANIFEST: PackageManifest = { + name: '@amritk/thing', + files: ['dist', 'AI.md'], + exports: { + './package.json': './package.json', + '.': { development: './src/index.ts' }, + }, +} + +const DOC = ['# AI.md — @amritk/thing', '', 'See [`AGENTS.md`](./AGENTS.md).', '', 'Call `doThing()`.', ''].join('\n') + +/** A reader over an in-memory package, so the audit never touches the working tree. */ +const reader = + (files: Record) => + (path: string): string | null => + files[path] ?? null + +const problems = (findings: AiDocFinding[]): string[] => findings.map((finding) => finding.problem) + +describe('ai-docs', () => { + it('passes a package whose AI.md documents everything it exports', () => { + const findings = auditPackage( + MANIFEST, + reader({ 'AI.md': DOC, './src/index.ts': 'export const doThing = () => {}' }), + ) + expect(findings).toEqual([]) + }) + + it('flags a package with no AI.md at all, and says nothing else about it', () => { + const findings = auditPackage(MANIFEST, reader({})) + expect(problems(findings)).toEqual(['has no AI.md — every published package ships one next to its README']) + }) + + it('flags an AI.md that npm would not ship', () => { + const manifest = { ...MANIFEST, files: ['dist'] } + const findings = auditPackage(manifest, reader({ 'AI.md': DOC, './src/index.ts': '' })) + expect(problems(findings)).toContainEqual(expect.stringContaining('"files"')) + }) + + it('flags a heading that would not divide llms-full.txt', () => { + const doc = DOC.replace('# AI.md — @amritk/thing', '# @amritk/thing — notes for agents') + const findings = auditPackage(MANIFEST, reader({ 'AI.md': doc, './src/index.ts': '' })) + expect(problems(findings)).toContainEqual(expect.stringContaining('must open with')) + }) + + it('flags an export the AI.md never mentions', () => { + const source = 'export const doThing = () => {}\nexport const doOther = () => {}' + const findings = auditPackage(MANIFEST, reader({ 'AI.md': DOC, './src/index.ts': source })) + expect(problems(findings)).toEqual(['exports "doOther" from "@amritk/thing" but never documents it']) + }) + + it('flags a published subpath the AI.md never names', () => { + const manifest = { + ...MANIFEST, + exports: { ...MANIFEST.exports, './testing': { development: './src/testing/index.ts' } }, + } + const findings = auditPackage(manifest, reader({ 'AI.md': DOC, './src/index.ts': '' })) + expect(problems(findings)).toEqual(['never mentions the published subpath "@amritk/thing/testing"']) + }) + + // Requiring the specifier would be noise: a consumer reaches these through + // `jsxImportSource`, never by writing the import. + it('does not ask for the JSX transform subpaths to be documented', () => { + const manifest = { + ...MANIFEST, + exports: { ...MANIFEST.exports, './jsx-runtime': { development: './src/jsx-runtime.ts' } }, + } + const findings = auditPackage(manifest, reader({ 'AI.md': DOC, './src/index.ts': '' })) + expect(findings).toEqual([]) + }) + + it('insists a package shipping native sources says what has run on a device', () => { + const manifest = { ...MANIFEST, files: ['dist', 'android', 'ios', 'AI.md'] } + const findings = auditPackage(manifest, reader({ 'AI.md': DOC, './src/index.ts': '' })) + expect(problems(findings)).toContainEqual(expect.stringContaining('## Status')) + }) + + it('reads values but not types, so an AI.md is never pushed toward being a second .d.ts', () => { + const source = [ + "export type { Alone } from './alone'", + "export { doThing, type Shape } from './thing'", + "export { inner as renamed } from './inner'", + 'export const made = 1', + 'export type Local = string', + ].join('\n') + expect(publicValueExports(source).sort()).toEqual(['doThing', 'made', 'renamed']) + }) + + // The gate itself: the checked-in packages have to satisfy their own contract. + it('finds nothing wrong with the packages in this repo', () => { + expect(auditAiDocs(join(import.meta.dirname, '..'))).toEqual([]) + }) +}) diff --git a/scripts/ai-docs.ts b/scripts/ai-docs.ts new file mode 100644 index 0000000..2fac56b --- /dev/null +++ b/scripts/ai-docs.ts @@ -0,0 +1,209 @@ +/** + * The structural contract every package's `AI.md` keeps — the logic behind + * `scripts/check-ai-docs.ts` (a CI gate) and `scripts/ai-docs.test.ts`. + * + * `llms.txt` / `llms-full.txt` are regenerated and diffed in CI, which catches + * a stale *bundle* but not a stale *source*: an `AI.md` that never mentions the + * subpath or the function a release added regenerates perfectly and still lies + * to the only audience that cannot file an issue about it. This is the guard + * for that — it reads what a package actually exports and asks whether its + * `AI.md` says so. + * + * Kept side-effect-free so the test can exercise the same code the gate runs + * against fixtures instead of the working tree. + */ + +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' + +/** + * One `exports` entry. A bare string for `./package.json`-style passthroughs, + * otherwise the condition map — of which only `development` matters here, + * because it is the one condition pointing at source this can read. + */ +export type ExportTarget = string | { development?: string } + +export type PackageManifest = { + name?: string + private?: boolean + files?: string[] + exports?: Record +} + +/** One thing a package's `AI.md` does not hold up to, phrased for a CI log. */ +export type AiDocFinding = { package: string; problem: string } + +/** + * Subpaths a consumer never types. `jsx-runtime` and `jsx-dev-runtime` are + * resolved by the JSX transform from `jsxImportSource`, so documenting the + * specifier itself would be noise — what a consumer needs is the `tsconfig` + * line, and the audit checks for that separately. + */ +const TRANSFORM_SUBPATHS = ['./jsx-runtime', './jsx-dev-runtime'] + +/** + * Exports that exist for the runtime, the JSX transform or another package in + * this repo rather than for a human, listed per package so a *new* export + * cannot join them by accident. Adding one here is a claim that no consumer + * ever writes it; anything else has to earn a line in the package's `AI.md`. + */ +export const INTERNAL_EXPORTS: Record = { + // The tree operations and prop appliers are what the compiled JSX calls. A + // consumer writes the tags and lets the transform reach for these. + '@amritk/mini-lynx': [ + 'addEvent', + 'appendChildren', + 'applyProp', + 'applyStyle', + 'applyVisible', + 'createElement', + 'createRawText', + 'createWrapper', + 'firstChild', + 'nextSibling', + 'renderChild', + 'setText', + 'toCssName', + 'toStyleText', + // Installed by `renderPage`; an app that calls it directly is fighting the entry point. + 'setGlobalProps', + // The default transport, already installed. Only its *alternative* is a decision. + 'workletTransport', + // Reached through `setErrorHandler`; the runtime is what reports. + 'reportError', + ], + // `clearNamedHandlers` is teardown for the fallback transport's own tests. + '@amritk/mini-lynx/bridge': ['clearNamedHandlers'], + // Gesture ids are minted by `setGestureDetector` itself. + '@amritk/mini-lynx/gestures': ['nextGestureId'], + // Written by `trackKeyboard` from the engine event; an app reads `keyboardHeight`. + '@amritk/mini-lynx/keyboard': ['setKeyboardHeight'], +} + +/** Splits `a, type B, c as d` into its value names — `export type { … }` never reaches here. */ +const valueNames = (clause: string): string[] => + clause + .split(',') + .map((specifier) => specifier.trim()) + .filter((specifier) => specifier.length > 0 && !specifier.startsWith('type ')) + .map((specifier) => (specifier.includes(' as ') ? (specifier.split(' as ').pop() ?? '') : specifier).trim()) + .filter((name) => /^[A-Za-z_$][\w$]*$/.test(name)) + +/** + * Every runtime binding a module re-exports. Types are deliberately out of + * scope: an `AI.md` documents shapes by writing them out, not by naming the + * alias, so requiring the identifier would push these files toward being a + * second copy of the `.d.ts` — which is the one thing they must not become. + */ +export const publicValueExports = (source: string): string[] => { + const names = new Set() + for (const match of source.matchAll(/export\s+\{([^}]*)\}/g)) { + // `export type { … }` is a type-only block; the `type` sits before the brace. + const isTypeOnly = /export\s+type\s*$/.test(source.slice(0, match.index)) + if (isTypeOnly) continue + for (const name of valueNames(match[1] ?? '')) names.add(name) + } + for (const match of source.matchAll(/export\s+(?:const|function|class)\s+([A-Za-z_$][\w$]*)/g)) { + names.add(match[1] ?? '') + } + return [...names].filter((name) => name.length > 0) +} + +/** The specifier a consumer writes: `.` is the package itself, `./router` hangs off it. */ +export const specifierFor = (name: string, subpath: string): string => + subpath === '.' ? name : `${name}${subpath.slice(1)}` + +const subpathsOf = (manifest: PackageManifest): string[] => + Object.entries(manifest.exports ?? {}) + .filter(([subpath, target]) => { + if (subpath.endsWith('.json') || TRANSFORM_SUBPATHS.includes(subpath)) return false + // Only entries with a `development` condition point at source we can read. + return typeof target === 'object' && typeof target.development === 'string' + }) + .map(([subpath]) => subpath) + +/** + * Audits one package. `read` is injected so the test can drive fixtures, and so + * a missing file is a finding rather than a thrown error — a package with no + * `AI.md` at all is the case this exists to catch. + */ +export const auditPackage = ( + manifest: PackageManifest, + read: (relativePath: string) => string | null, +): AiDocFinding[] => { + const name = manifest.name ?? '(unnamed)' + const findings: AiDocFinding[] = [] + const add = (problem: string): void => void findings.push({ package: name, problem }) + + const doc = read('AI.md') + if (doc === null) { + add('has no AI.md — every published package ships one next to its README') + return findings + } + + if (!(manifest.files ?? []).includes('AI.md')) { + add('does not list "AI.md" in package.json "files", so npm would not ship it') + } + + const heading = `# AI.md — ${name}` + if (!doc.startsWith(`${heading}\n`)) { + add(`must open with "${heading}" — llms-full.txt concatenates these, and the heading is the only divider`) + } + + if (!doc.includes('AGENTS.md')) { + add('does not point at AGENTS.md, leaving an agent editing the repo with no way back to the invariants') + } + + // A package shipping `android/` or `ios/` carries native halves this repo + // cannot fully build and has never run on a device. That is the first thing a + // consumer needs and the easiest to leave out, so it is the one section the + // audit insists on: `lynx-dialogs` shipped without it once already. + const shipsNative = (manifest.files ?? []).some((entry) => entry === 'android' || entry === 'ios') + if (shipsNative && !doc.includes('## Status')) { + add('ships native sources but has no "## Status" section saying what has and has not run on a device') + } + + const internal = new Set(Object.entries(INTERNAL_EXPORTS).flatMap(([, names]) => names)) + for (const subpath of subpathsOf(manifest)) { + const specifier = specifierFor(name, subpath) + if (!doc.includes(specifier)) { + add(`never mentions the published subpath "${specifier}"`) + continue + } + const target = manifest.exports?.[subpath] + const entry = typeof target === 'object' ? target.development : undefined + const source = entry ? read(entry) : null + if (source === null) continue + + const allowed = new Set(INTERNAL_EXPORTS[specifier] ?? []) + for (const exported of publicValueExports(source)) { + // A name allow-listed anywhere in the repo is internal everywhere: the + // runtimes re-export each other's helpers under the same names. + if (allowed.has(exported) || internal.has(exported)) continue + if (!doc.includes(exported)) add(`exports "${exported}" from "${specifier}" but never documents it`) + } + } + + return findings +} + +/** Audits every published package under `/packages`. */ +export const auditAiDocs = (root: string): AiDocFinding[] => { + const findings: AiDocFinding[] = [] + for (const dir of readdirSync(join(root, 'packages')).sort()) { + const packageRoot = join(root, 'packages', dir) + const read = (relativePath: string): string | null => { + try { + return readFileSync(join(packageRoot, relativePath), 'utf8') + } catch { + return null + } + } + const manifestSource = read('package.json') + if (manifestSource === null) continue + const manifest = JSON.parse(manifestSource) as PackageManifest + if (manifest.private === true) continue + findings.push(...auditPackage(manifest, read)) + } + return findings +} diff --git a/scripts/check-ai-docs.ts b/scripts/check-ai-docs.ts new file mode 100644 index 0000000..ff37881 --- /dev/null +++ b/scripts/check-ai-docs.ts @@ -0,0 +1,22 @@ +/** + * CI gate: fails when a package's `AI.md` has drifted from what the package + * actually publishes. Runs beside `check:reactivity` rather than in a Vitest + * suite, because it needs neither a build nor the src aliases — it only reads + * manifests and Markdown, so it should fail in the first minute of CI. + */ + +import { join } from 'node:path' + +import { auditAiDocs } from './ai-docs' + +const findings = auditAiDocs(join(import.meta.dir, '..')) + +if (findings.length === 0) { + console.log('AI.md docs are in sync with every published surface.') + process.exit(0) +} + +console.error(`${findings.length} AI.md problem${findings.length === 1 ? '' : 's'}:\n`) +for (const finding of findings) console.error(` ${finding.package} — ${finding.problem}`) +console.error('\nDocument the surface in the package AI.md, or record it in INTERNAL_EXPORTS in scripts/ai-docs.ts.') +process.exit(1)