From cc222637a3b4338c886f9298eed7789095f841cc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 15:59:27 +0000 Subject: [PATCH] fix(devx): assert that a published README's repo-relative targets exist (#10813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:published-readme-links` read the docs-site URLs a published README carries and said nothing about its repo-relative paths. `packages/runtime/README.md` linked six targets that are not in the tree, and a census across the whole published population found nine, in four packages. Nothing read them: the assertions above this one read ABSOLUTE urls, `check:published-readme-exports` reads fenced import lines, and the lychee lane never sees `packages/**/README.md`. Adds assertion 5 — a repo-relative destination must name something in the tree — to the existing gate rather than a second script, so it reuses that gate's population, extractor and classifier bucket. The gate's own header had already named this omission ("a different claim, owned by nothing here yet"). Deliberately only the decidable half. Whether a relative href means anything to a reader on npmjs.com is a separate question: npm renders the README outside the repo, so EVERY relative link is unresolvable there, including the 101 that are correct in-tree. Failing them all would be a ruling on link rewriting, not a scan, and this repo's published READMEs lean on the relative form throughout. Assertion 5 says GITHUB in its message for that reason. Vacuity is refused at the population level, never the document level: 18 of the 60 published documents carry no relative link at all, so per-document silence proves nothing. `run()` throws when the whole scan classifies nothing relative, the same way it already throws on zero links, and the green line prints the resolved count so a classifier that stopped recognising the bucket shows up as a number that fell. The six runtime targets were each traced to where the content went rather than deleted: the three MINI_KERNEL_*.md docs were removed from the repo root in January (d709ecce68, 5051 deletions, nothing added) and the kernel reference is the docs site now; `examples/host/` became app-host, then apps/server, then apps/objectos, then moved to objectstack-ai/cloud; `examples/msw-react-crud/` became app-react-crud, then apps/console, and ships as `@object-ui/console`; `test-mini-kernel.ts` was a root scratch script whose successor is this package's own 179-file suite under `src/` — which is what the truncated bullet with the unterminated backtick was reaching for. The other three packages: `../../plugins/driver-sql` is stale since the driver moved to `packages/drivers/` (#5618); `plugin-org-scoping`, `service-tenant` and `service-marketplace` are in no directory of this repo, so those links are dropped and the names kept as code spans — the spelling those same files already use for a package they cannot point at in-tree. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --- ...lished-readme-relative-target-existence.md | 42 +++ packages/adapters/hono/README.md | 2 +- packages/plugins/plugin-security/README.md | 4 +- packages/runtime/README.md | 24 +- packages/services/service-package/README.md | 6 +- scripts/check-published-readme-links.mjs | 272 +++++++++++++++++- 6 files changed, 322 insertions(+), 28 deletions(-) create mode 100644 .changeset/published-readme-relative-target-existence.md 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'