diff --git a/.changeset/published-readme-relative-target-existence.md b/.changeset/published-readme-relative-target-existence.md new file mode 100644 index 0000000000..ccffbf6eff --- /dev/null +++ b/.changeset/published-readme-relative-target-existence.md @@ -0,0 +1,42 @@ +--- +"@objectstack/runtime": patch +"@objectstack/hono": patch +"@objectstack/plugin-security": patch +"@objectstack/service-package": patch +--- + +docs: repair the dead repo-relative targets in four published READMEs (#10813) + +A published README ships inside the npm tarball, so a dead relative link in one +is shipped to every reader who installs the package. Nine of them were measured +across four packages, and nothing read them: `check:published-readme-links` +checked docs-site URLs, `check:published-readme-exports` checked fenced import +lines, and the lychee lane never sees `packages/**/README.md`. + +`@objectstack/runtime` carried six dead targets. Each was traced to where the +content actually went rather than deleted: + +- `MINI_KERNEL_GUIDE.md`, `MINI_KERNEL_ARCHITECTURE.md` and + `MINI_KERNEL_IMPLEMENTATION.md` were deleted from the repo root in January as + "redundant markdown files" (d709ecce68 — 14 files, 5051 deletions, nothing + added). The kernel reference they described is the docs site now, so the + Documentation section is the same footer eight sibling READMEs already use. +- `examples/host/` was renamed to `examples/app-host`, then `apps/server`, then + `apps/objectos`, and finally split out to `objectstack-ai/cloud`. In-repo, an + HTTP server in front of the runtime is `@objectstack/plugin-hono-server` plus + the `@objectstack/hono` adapter, so the bullet points there. +- `examples/msw-react-crud/` became `examples/app-react-crud`, then + `apps/console`, and now ships as `@object-ui/console` from another repo. +- `test-mini-kernel.ts` was a root-level scratch script; this package's suite is + 179 test files under `src/`. +- The section also ended on a truncated bullet with an unterminated backtick + (`` - `packages/runtime/src/ ``), which is now a real pointer to that suite. + +The other three packages: `@objectstack/hono` and `@objectstack/service-package` +still spelled `@objectstack/driver-sql` as `../../plugins/driver-sql`, stale +since the driver moved to `packages/drivers/` (#5618). `@objectstack/plugin-security` +and `@objectstack/service-package` linked three packages that are in no directory +of this repo (`plugin-org-scoping`, `service-tenant`, `service-marketplace`); +those links are dropped and the names kept as code spans, which is the spelling +those same files already use for a package they cannot point at in-tree. Whether +those three packages exist at all is a separate question, filed separately. diff --git a/packages/adapters/hono/README.md b/packages/adapters/hono/README.md index 252a7e2b48..8ae81ce90e 100644 --- a/packages/adapters/hono/README.md +++ b/packages/adapters/hono/README.md @@ -66,7 +66,7 @@ app.use('/api/*', objectStackMiddleware(kernel)); ## Edge runtime notes - Hono adapter is the **preferred** adapter for Cloudflare Workers, Deno Deploy, Bun, and Vercel Edge. -- Drivers differ by runtime: use [`@objectstack/driver-sql`](../../plugins/driver-sql) on Node. (Edge/multi-tenant turso driver ships in ObjectStack Cloud.) +- Drivers differ by runtime: use [`@objectstack/driver-sql`](../../drivers/driver-sql) on Node. (Edge/multi-tenant turso driver ships in ObjectStack Cloud.) - Persist no long-lived state in module scope beyond the `kernel` instance. ## When to use diff --git a/packages/plugins/plugin-security/README.md b/packages/plugins/plugin-security/README.md index 86a1804514..fa52fa2cfa 100644 --- a/packages/plugins/plugin-security/README.md +++ b/packages/plugins/plugin-security/README.md @@ -37,7 +37,7 @@ await kernel.bootstrap(); `SecurityPlugin` is single-tenant by default. It enforces RBAC, owner-based RLS, and Field-Level Security regardless of mode. -For **multi-tenant** (logical row-level Organization scoping) install [`@objectstack/plugin-org-scoping`](../plugin-org-scoping/README.md) *before* SecurityPlugin: +For **multi-tenant** (logical row-level Organization scoping) install `@objectstack/plugin-org-scoping` *before* SecurityPlugin: ```typescript import { OrgScopingPlugin } from '@objectstack/plugin-org-scoping'; @@ -92,7 +92,7 @@ Compilation output is a filter AST merged into every query's `where` clause, so ## When to use - ✅ Any multi-user deployment. -- ✅ Enforcing tenant isolation (combine with [`@objectstack/service-tenant`](../../services/service-tenant)). +- ✅ Enforcing tenant isolation (combine with `@objectstack/service-tenant`). ## When not to use diff --git a/packages/runtime/README.md b/packages/runtime/README.md index 3e68de42cb..ab19ff5459 100644 --- a/packages/runtime/README.md +++ b/packages/runtime/README.md @@ -256,11 +256,18 @@ interface PluginContext { ## Examples -See the `examples/` directory for complete examples: -- `examples/host/` - Full server setup with Hono -- `examples/msw-react-crud/` - Browser-based setup with MSW -- `test-mini-kernel.ts` - Comprehensive kernel test suite -- `packages/runtime/src/ +Complete, CI-exercised examples live in the repo's [`examples/`](../../examples) +catalog. The three that build on this package: + +- [`app-todo`](../../examples/app-todo) — the smallest complete app; the fastest read of the `AppPlugin` conventions. +- [`app-crm`](../../examples/app-crm) — relational modeling driven through the metadata loading pipeline. +- [`app-showcase`](../../examples/app-showcase) — the kitchen-sink conformance fixture. + +To put an HTTP server in front of one, see [`@objectstack/plugin-hono-server`](../plugins/plugin-hono-server) +(boots the kernel behind Hono) and [`@objectstack/hono`](../adapters/hono) (the adapter itself). + +This package's own behaviour is pinned by the test suite under [`src/`](./src) — +`pnpm --filter @objectstack/runtime test`. ## Benefits of MiniKernel @@ -640,9 +647,10 @@ Defaults are noop — zero overhead until you plug an adapter. ## Documentation -- [MiniKernel Guide](../../MINI_KERNEL_GUIDE.md) - Complete API documentation and patterns -- [MiniKernel Architecture](../../MINI_KERNEL_ARCHITECTURE.md) - Architecture diagrams and flows -- [MiniKernel Implementation](../../MINI_KERNEL_IMPLEMENTATION.md) - Implementation details +- 📖 Docs: +- 📚 API Reference: +- 🛡️ Hardening: [`docs/HARDENING.md`](../../docs/HARDENING.md) +- 📈 Observability: [`docs/OBSERVABILITY.md`](../../docs/OBSERVABILITY.md) ## License diff --git a/packages/services/service-package/README.md b/packages/services/service-package/README.md index 90500ce06b..283df0ec0b 100644 --- a/packages/services/service-package/README.md +++ b/packages/services/service-package/README.md @@ -94,7 +94,7 @@ CREATE INDEX IF NOT EXISTS idx_packages_latest ## Requirements -- A driver plugin that registers an `IDataEngine` under the service name `'objectql'` with `execute()` support — typically [`@objectstack/driver-sql`](../../plugins/driver-sql). `@objectstack/driver-memory` can be used for tests but does not persist across restarts. (ObjectStack Cloud additionally ships `@objectstack/driver-turso` for edge/multi-tenant.) +- A driver plugin that registers an `IDataEngine` under the service name `'objectql'` with `execute()` support — typically [`@objectstack/driver-sql`](../../drivers/driver-sql). `@objectstack/driver-memory` can be used for tests but does not persist across restarts. (ObjectStack Cloud additionally ships `@objectstack/driver-turso` for edge/multi-tenant.) ## When to use @@ -105,13 +105,13 @@ CREATE INDEX IF NOT EXISTS idx_packages_latest ## When not to use - ❌ Not a package manager for npm/TypeScript source packages — use npm. -- ❌ Not a runtime plugin loader — pair with [`@objectstack/service-marketplace`](../service-marketplace) or a custom loader for that. +- ❌ Not a runtime plugin loader — pair with `@objectstack/service-marketplace` or a custom loader for that. ## Related Packages - [`@objectstack/core`](../../core) — kernel hosting this plugin. - [`@objectstack/spec`](../../spec) — provides `ObjectStackManifest` and `IDataEngine` contracts. -- [`@objectstack/driver-sql`](../../plugins/driver-sql) — supplies the `'objectql'` service. (ObjectStack Cloud also ships `@objectstack/driver-turso`.) +- [`@objectstack/driver-sql`](../../drivers/driver-sql) — supplies the `'objectql'` service. (ObjectStack Cloud also ships `@objectstack/driver-turso`.) ## Links diff --git a/scripts/check-published-readme-links.mjs b/scripts/check-published-readme-links.mjs index 62ce99bd39..85b96db20f 100644 --- a/scripts/check-published-readme-links.mjs +++ b/scripts/check-published-readme-links.mjs @@ -46,14 +46,14 @@ // form affordable -- at 149 links a gate can assert per link without a // baseline. The spelling census at that commit: // -// 94 relative (../sibling, ./file.md) -- not this gate's business +// 94 relative (../sibling, ./file.md) -- assertion 5 (added #10813) // 34 absolute, other hosts -- not this gate's business // 10 fragment-only (#section) -- not this gate's business // 8 ROOT-RELATIVE -- assertion 1, all findings // 2 relative into `content/docs` -- lands on raw MDX source // 1 docs.objectstack.ai -- assertions 3 + 4 // -// ## The four assertions, and why they are ordered by cost +// ## The five assertions, and why they are ordered by cost // // 1. ROOT-RELATIVE IS REJECTED OUTRIGHT. No filesystem lookup, and it cannot // false-positive: there is no root-relative href that is correct in a file @@ -123,10 +123,65 @@ // and twelve by 2026-08-21. A gate that prescribes is a gate that propagates, // so the prescription has to be the ruled one. // -// Deliberately NOT asserted: whether a relative link resolves (that is a -// different claim, owned by nothing here yet, and `../../../content/docs/x.mdx` -// is *followable* -- it just lands on raw MDX source), and whether an external -// URL is alive (that is lychee's job and it needs the network). +// Deliberately NOT asserted: whether an external URL is alive (that is lychee's +// job and it needs the network), and what a relative href means to a reader on +// npm -- see the assertion-5 section below for why that second one is a ruling +// rather than a scan. Whether a relative target EXISTS was in this list until +// #10813; it is assertion 5 now. +// +// ## Assertion 5: the half of a relative link that IS decidable (#10813) +// +// `packages/runtime/README.md` linked six repo-relative targets that are not in +// the tree: three MiniKernel design documents deleted from the repo root in +// January as "redundant markdown files", and three example paths whose +// directories were renamed twice and then moved out of this repo altogether. +// Nothing read them, and the reason is the same one that produced every other +// gate in this family: the assertions above read ABSOLUTE urls, +// `check:published-readme-exports` reads fenced import lines, and the lychee +// lane never sees `packages/**/README.md`. A relative href could name anything +// and ship to npm green. +// +// Two shapes hide under "relative link in a published README", and only one of +// them is decidable without a ruling: +// +// * DOES THE TARGET EXIST IN THE TREE -- decidable, and it cannot +// false-positive: the path is in the tree or it is not. That is assertion +// 5. The census that makes it affordable is the same one that makes the +// docs-site assertions affordable -- 101 relative destinations across the +// population, small enough to assert per link with no baseline. +// +// * WHAT A RELATIVE HREF MEANS TO A READER ON NPM -- npmjs.com renders the +// README outside the repo, so EVERY relative link is unresolvable there, +// including the 101 that are perfectly correct in-tree. Failing them all +// would not be a gate; it would be a ruling on link rewriting that nobody +// has made, and this repo's published READMEs lean on the relative form +// everywhere (`[`@objectstack/spec`](../spec)`). Out of scope BY NAME, not +// by omission. +// +// So assertion 5 makes the narrow claim and says so in the message it prints: +// the link is dead ON GITHUB, where a relative href is supposed to work. +// +// A climb ABOVE the repo root is reported as its own kind rather than folded in +// with a missing file, because the remedies do not overlap -- `../../../x` from +// a `packages//README.md` is an off-by-one in the climb, while `../x` is a +// target that moved or was deleted. +// +// ## What assertion 5 does on a README carrying no relative paths +// +// Nothing -- and that is the common case rather than an edge one: 18 of the 60 +// published documents carry no relative destination at all, so on nearly a +// third of the population this assertion is vacuous BY CONSTRUCTION. That is +// correct behaviour (a document with no relative link has no relative link to +// break) and it is also precisely the shape of #4690: a check that passes +// because it read nothing is indistinguishable, in the output, from one that +// read everything and found it clean. +// +// The vacuity is therefore refused at the POPULATION level, never the document +// level. `run()` throws when the whole scan yields zero relative destinations, +// the same way it already throws on zero links, and 101-across-42-documents is +// the measurement that refusal is calibrated against. The green line prints the +// resolved count too, so a classifier that quietly stopped recognising the +// bucket surfaces as a number that fell rather than as continued silence. // // ## The three link shapes read, and the one that only looks like a fourth // @@ -182,7 +237,7 @@ // mute button, and a muted gate still reads as coverage. import { existsSync, readFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { join, posix, relative as relativeTo, resolve, sep } from 'node:path'; import process from 'node:process'; import { stripCodeSpans, stripFencedBlocks } from './check-adr-links.mjs'; @@ -406,17 +461,55 @@ export function resolveDocsPage(contentRoot, pathname) { return null; } +/** + * Where a relative destination written in `file` lands, and whether anything is + * there. + * + * `file` is the repo-root-relative posix path of the document, so the base is + * its own directory -- the same base GitHub uses to resolve a relative href in + * a rendered blob. Fragment and query are stripped before resolving + * (`../a.md#L10` targets `../a.md`) and the path is percent-decoded, because + * `%20` in an href is a space in a filename. + * + * Four outcomes rather than two. `escapes` is separated from `missing` because + * the remedies do not overlap (see the header). `empty` is the destination that + * carries only a query -- there is no path to resolve, so there is nothing to + * assert, and naming it keeps it out of the resolved COUNT rather than silently + * inflating it. + * + * @param {string} repoRoot + * @param {string} file repo-root-relative posix path of the document + * @param {string} dest + * @returns {{ status: 'ok'|'missing'|'escapes'|'empty', target: string|null }} + */ +export function resolveRelativeTarget(repoRoot, file, dest) { + const pathOnly = dest.split('#')[0].split('?')[0]; + if (pathOnly === '') return { status: 'empty', target: null }; + let decoded; + try { + decoded = decodeURIComponent(pathOnly); + } catch { + decoded = pathOnly; // a lone `%` is not an escape sequence; read it literally + } + const root = resolve(repoRoot); + const target = resolve(root, posix.dirname(file), decoded); + if (target !== root && !target.startsWith(`${root}${sep}`)) { + return { status: 'escapes', target }; + } + return { status: existsSync(target) ? 'ok' : 'missing', target }; +} + /** * @typedef {{ kind: string, file: string, line: number, dest: string, detail: string }} Finding */ /** - * Run all three assertions over one document. + * Run all five assertions over one document. * - * @param {{ file: string, text: string, contentRoot: string, table: [string, string][] }} options + * @param {{ file: string, text: string, contentRoot: string, table: [string, string][], repoRoot: string }} options * @returns {{ findings: Finding[], stats: Record }} */ -export function checkDocument({ file, text, contentRoot, table }) { +export function checkDocument({ file, text, contentRoot, table, repoRoot = ROOT }) { /** @type {Finding[]} */ const findings = []; const stats = { @@ -429,6 +522,7 @@ export function checkDocument({ file, text, contentRoot, table }) { fragment: 0, relative: 0, 'non-canonical': 0, + 'relative-resolved': 0, resolved: 0, redirected: 0, fragments: 0, @@ -458,6 +552,35 @@ export function checkDocument({ file, text, contentRoot, table }) { continue; } + // ── 5. a repo-relative destination has to name something in the tree ─ + // The narrow, decidable half: existence. NOT whether npm can follow it -- + // npm can follow no relative link at all, and ruling on that is a card, not + // a scan (see the header). + if (kind === 'relative') { + const { status, target } = resolveRelativeTarget(repoRoot, file, dest); + if (status === 'ok') stats['relative-resolved']++; + else if (status === 'escapes') { + add( + 'escapes-repo', + `climbs above the repository root, which no host can serve. ` + + `${file} sits ${posix.dirname(file).split('/').length} directory level(s) down, so ` + + `count the ../ segments against that depth.`, + ); + } else if (status === 'missing') { + add( + 'dead-relative-target', + `resolves to ${relativeTo(resolve(repoRoot), target)}, which is not in this tree. ` + + `A relative href resolves against this file's own directory, so a target that moved ` + + `or was deleted is a 404 ON GITHUB — the surface where relative links are supposed ` + + `to work. Point it at where the content lives now, or, if it lives nowhere in this ` + + `repo, drop the link and keep the name as a code span. ` + + `(Whether npm can follow a relative link AT ALL is a separate, unruled question — ` + + `this assertion is only about existence.)`, + ); + } + continue; + } + if (kind !== 'docs-site') continue; const url = new URL(dest); @@ -529,7 +652,8 @@ function report(findings, stats, population) { `✓ check:published-readme-links — ${stats.links} outbound link(s) across ` + `${population} published markdown file(s): 0 root-relative, ` + `0 non-canonical origin(s), ${stats.resolved} docs-site page(s) resolved ` - + `(${stats.redirected} via redirect), ${stats.fragments} anchor(s) verified.`, + + `(${stats.redirected} via redirect), ${stats.fragments} anchor(s) verified, ` + + `${stats['relative-resolved']}/${stats.relative} relative target(s) found in the tree.`, ); return 0; } @@ -569,6 +693,7 @@ async function run() { text: doc.text, contentRoot: CONTENT_ROOT, table, + repoRoot: ROOT, }); findings.push(...f); for (const [k, v] of Object.entries(stats)) totals[k] = (totals[k] ?? 0) + v; @@ -582,6 +707,17 @@ async function run() { + 'The extractor matched nothing — that is a broken scanner, not a clean tree.', ); } + // Assertion 5's own population axis. It is vacuous per DOCUMENT by design (18 + // of 60 carry no relative link), so the document-level silence proves nothing + // and only the population total can. 101 across 42 documents when this + // landed; zero means the `relative` bucket stopped being recognised. + if (!totals.relative) { + throw new Error( + `${SELF}: read ${docs.length} published document(s) carrying ${totals.links} link(s), and ` + + 'classified NONE of them relative. Measured when assertion 5 landed: 101 relative ' + + 'destinations across 42 of 60 documents. Zero is a broken classifier, not a clean tree.', + ); + } return report(findings, totals, docs.length); } @@ -783,8 +919,21 @@ function selfTest() { // REAL content root with links known to exist / not exist there. Using the // real tree keeps the self-test honest about the resolver it actually ships. const table = [['/docs/guides/:path*', '/docs']]; + // The fixture sits where a published README actually sits. Assertion 5 + // resolves against the DOCUMENT's directory, so depth is load-bearing: at + // `packages//README.md`, `../x` is a sibling package, `../../x` is the + // repo root, and `../../../x` is above it — the three cases that assertion + // has to tell apart. A depth-1 fixture would make the escape case + // unreachable and the sibling case wrong. + const FIXTURE_FILE = 'packages/fixture-pkg/README.md'; const runDoc = (text) => - checkDocument({ file: 'fixture/README.md', text, contentRoot: CONTENT_ROOT, table }); + checkDocument({ + file: FIXTURE_FILE, + text, + contentRoot: CONTENT_ROOT, + table, + repoRoot: ROOT, + }); // Assertion 1 -- observed FAILING, then observed SILENT. const a1 = runDoc('See [Flows](/content/docs/automation/flows.mdx).'); @@ -799,7 +948,7 @@ function selfTest() { ); const a1clean = runDoc('See [Flows](https://objectstack.ai/docs/automation/flows).'); ok('A1 SILENT on the absolute form', a1clean.findings.length === 0); - ok('A1 SILENT on a relative link', runDoc('See [spec](../../spec/src/).').findings.length === 0); + ok('A1 SILENT on a relative link', runDoc('See [spec](../spec/src/).').findings.length === 0); ok('A1 SILENT on an external URL', runDoc('See [x](https://github.com/o/r).').findings.length === 0); ok('A1 SILENT on a bare fragment', runDoc('See [x](#see-also).').findings.length === 0); ok( @@ -882,6 +1031,98 @@ function selfTest() { ); } + // ---- Assertion 5 -- observed FAILING, then observed SILENT ------------ + // Real paths in the real tree, for the same reason assertions 3 and 4 use the + // real content root: it keeps the self-test honest about the resolver that + // actually ships. + const a5 = runDoc('See [Guide](../../MINI_KERNEL_GUIDE.md).'); + ok( + 'A5 FAILS on a relative target that is not in the tree', + a5.findings.length === 1 && a5.findings[0].kind === 'dead-relative-target', + ); + ok('A5 failure names the path it resolved to', a5.findings[0]?.detail.includes('MINI_KERNEL_GUIDE.md')); + // The message has to name the surface the claim is actually about. A reader + // told only "broken on npm" would reach for link rewriting -- the half this + // assertion deliberately does not rule on. + ok('A5 failure says GITHUB, the surface where a relative link should work', a5.findings[0]?.detail.includes('GITHUB')); + ok('A5 does not count a dead target as resolved', a5.stats['relative-resolved'] === 0); + + const a5ok = runDoc('See [LICENSING.md](../../LICENSING.md).'); + ok('A5 SILENT on a relative target that exists', a5ok.findings.length === 0); + ok('A5 counts the target it resolved', a5ok.stats['relative-resolved'] === 1); + const a5dir = runDoc('See [runtime](../runtime).'); + ok( + 'A5 SILENT on a DIRECTORY target — GitHub renders a listing', + a5dir.findings.length === 0 && a5dir.stats['relative-resolved'] === 1, + ); + // The `./` form, both limbs. It needs a fixture document in a package that + // really has a `src/`, so it drives `checkDocument` directly rather than + // through `runDoc` — `packages/fixture-pkg/` does not exist, and a pin whose + // only true branch is "the target is missing" would agree with a resolver + // that had stopped working. + const runIn = (file, text) => + checkDocument({ file, text, contentRoot: CONTENT_ROOT, table, repoRoot: ROOT }); + const a5dot = runIn('packages/runtime/README.md', 'See [src](./src).'); + ok( + 'A5 SILENT on a ./ target that exists inside the package', + a5dot.findings.length === 0 && a5dot.stats['relative-resolved'] === 1, + ); + const a5dotBad = runIn('packages/runtime/README.md', 'See [src](./no-such-dir).'); + ok( + 'A5 FAILS on a ./ target that does not', + a5dotBad.findings.length === 1 && a5dotBad.findings[0].kind === 'dead-relative-target', + ); + ok( + 'A5 resolves against the DOCUMENT, not a fixed base: the same href reads differently elsewhere', + runIn('packages/spec/README.md', 'See [src](./src).').stats['relative-resolved'] === 1 + && runIn('packages/runtime/README.md', 'See [x](../spec/src).').stats['relative-resolved'] === 1, + ); + + // The three ways a destination carries more than a path. + ok('A5 strips a #fragment before resolving', runDoc('See [L](../../LICENSING.md#patents).').findings.length === 0); + ok('A5 strips a ?query before resolving', runDoc('See [L](../../LICENSING.md?plain=1).').findings.length === 0); + ok('A5 percent-decodes the path', runDoc('See [L](../../LICENSING%2Emd).').findings.length === 0); + const a5bad = runDoc('See [L](../../NO_SUCH_FILE_ANYWHERE.md#frag).'); + ok('A5 still FAILS when a dead target wears a fragment', a5bad.findings.length === 1 && a5bad.findings[0].kind === 'dead-relative-target'); + + // The climb above the root is its own kind, not a missing file. + const a5esc = runDoc('See [X](../../../outside-this-repo.md).'); + ok( + 'A5 reports a climb above the repo root as its OWN kind', + a5esc.findings.length === 1 && a5esc.findings[0].kind === 'escapes-repo', + ); + ok('A5 escape message names the depth to count against', a5esc.findings[0]?.detail.includes('2 directory level(s)')); + + // Discrimination: the same string in a region this gate does not read. + ok('A5 SILENT inside a fenced block', runDoc('```md\n[x](../../MINI_KERNEL_GUIDE.md)\n```').findings.length === 0); + ok( + 'A5 SILENT inside a code span', + runDoc('A span `[x](../../MINI_KERNEL_GUIDE.md)` is not a link.').findings.length === 0, + ); + + // Out of scope BY NAME, pinned so a later widening is a deliberate act. + ok('A5 does not claim a bare fragment', runDoc('See [x](#see-also).').stats.relative === 0); + ok('A5 does not claim an absolute URL', runDoc('See [x](https://github.com/o/r).').stats.relative === 0); + ok('A5 does not claim a root-relative path', runDoc('See [x](/content/docs/a.mdx).').stats.relative === 0); + // The npm half. This link is unresolvable on npmjs.com — like all 101 of them + // — and assertion 5 passes it anyway. That is the fence, and it is asserted + // rather than merely described, because a later author reading "relative + // links break on npm" in the header could reasonably decide to fail them. + const a5npm = runDoc('See [core](../core).'); + ok( + 'A5 PASSES a correct relative link despite npm being unable to follow it — that half is a ruling, not a scan', + a5npm.findings.length === 0 && a5npm.stats['relative-resolved'] === 1, + ); + + // The per-document vacuity the header names, asserted rather than assumed: + // a document with no relative link yields no finding AND a zero counter, so + // the population-level refusal in run() is the thing carrying the weight. + const a5none = runDoc('See [Flows](https://objectstack.ai/docs/automation/flows).'); + ok( + 'A5 is vacuous on a document carrying no relative link, and counts zero rather than staying silent', + a5none.stats.relative === 0 && a5none.stats['relative-resolved'] === 0 && a5none.findings.length === 0, + ); + // ---- the autolink reaches the assertions, not just the extractor ------ // Extraction is necessary and not sufficient: the defect was that all four // assertions were SILENT on this shape, so each is re-observed FAILING with @@ -955,7 +1196,10 @@ function selfTest() { '✓ check:published-readme-links --self-test — extraction discrimination (fence, code span,\n' + ' ref-def, pointy brackets, titles, AUTOLINKS), all seven classify buckets, the remedy\n' + ' builder and its refusal, the canonicaliser both ways, the pageCandidates directory/index\n' - + ' subtlety, and all four assertions observed both FAILING and SILENT — including the host\n' + + ' subtlety, the relative-target resolver (fragment, query and percent-decoding stripped;\n' + + ' a directory target accepted; a climb above the repo root reported as its own kind; the\n' + + ' npm half PASSED on purpose; and the per-document vacuity counted rather than silent),\n' + + ' and all five assertions observed both FAILING and SILENT — including the host\n' + ' split itself (an alias origin is a FINDING, and is still resolved and still\n' + ' anchor-checked) and the autolink shape (assertions 2, 3 and 4 re-observed FAILING on it;\n' + ' A1 pinned UNREACHABLE through it, since an autolink body must be an absolute URI; a\n'