|
| 1 | +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. |
| 2 | +// |
| 3 | +// Every comment that ships INTO a scaffolded project must be followable by the |
| 4 | +// person reading it — someone who has this project and nothing else. |
| 5 | +// |
| 6 | +// ## The defect |
| 7 | +// |
| 8 | +// The two files a newcomer opens first after scaffolding, objectstack.config.ts |
| 9 | +// and src/objects/note.object.ts, carried six references addressed to a reader |
| 10 | +// with this monorepo open: four ADR identifiers, one bare issue number, and the |
| 11 | +// path of a release-time script. None of docs/adr, the issue tracker, or that |
| 12 | +// script ships in a scaffolded project, so "// per ADR-0097" was a reference the |
| 13 | +// reader could not resolve — it read as an instruction they were failing to |
| 14 | +// follow rather than as the context it was meant to be. |
| 15 | +// |
| 16 | +// ## Why this pin has TWO halves, and why the second is the load-bearing one |
| 17 | +// |
| 18 | +// The cheap way to make the references disappear is to delete the comments. That |
| 19 | +// would be a worse project than the one with the dead references: the comments |
| 20 | +// explain WHY each setting is the way it is, which is exactly what a newcomer |
| 21 | +// deciding whether to change it needs. So a one-way "no ADR identifiers" grep |
| 22 | +// would rot in the one direction that matters — it stays green while the |
| 23 | +// rationale is deleted out from under it. |
| 24 | +// |
| 25 | +// Hence: no unfollowable reference (assertion 1) AND the fact each comment |
| 26 | +// carries still stated (assertion 2). A future edit can reword freely; it cannot |
| 27 | +// quietly strip the explanation, and it cannot re-introduce a dead end. |
| 28 | +// |
| 29 | +// ## The third half: a public link is only a fix while it resolves |
| 30 | +// |
| 31 | +// Replacing an internal identifier with a docs URL moves the same defect one |
| 32 | +// level out if the URL 404s — a reference that looks authoritative and lands |
| 33 | +// nowhere. Assertion 3 resolves every canonical-origin docs URL in the shipped |
| 34 | +// tree against content/docs the way Fumadocs routes it: baseUrl /docs over |
| 35 | +// content/docs, and a directory that exists but carries no index page is a 404. |
| 36 | +// That candidate list is check-docs-redirects' pageCandidates, restated in six |
| 37 | +// lines rather than imported, because importing a root script into this package |
| 38 | +// would widen this suite's declared cross-package read radius to buy nothing. |
| 39 | +// |
| 40 | +// Host CONVERGENCE is deliberately not asserted here — the tree still carries |
| 41 | +// two non-canonical docs hostnames and they are another card's (#10990). This |
| 42 | +// pin only judges URLs already on the canonical origin, so the two cards cannot |
| 43 | +// collide. |
| 44 | + |
| 45 | +import { describe, it, expect } from 'vitest'; |
| 46 | +import fs from 'node:fs'; |
| 47 | +import path from 'node:path'; |
| 48 | +import { fileURLToPath } from 'node:url'; |
| 49 | + |
| 50 | +const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 51 | +const templateRoot = path.resolve(HERE, 'templates'); |
| 52 | +const contentDocs = path.resolve(HERE, '..', '..', '..', 'content', 'docs'); |
| 53 | + |
| 54 | +/** |
| 55 | + * The blank template's README is scanned by nothing here yet: it still carries |
| 56 | + * an ADR identifier of its own, and it is owned by other cards in the same |
| 57 | + * family (a scaffolding-guidance fix was in flight over it while this landed). |
| 58 | + * |
| 59 | + * The exclusion is SELF-RETIRING rather than permanent — the last assertion |
| 60 | + * fails the moment the README stops needing it, so whoever cleans that file is |
| 61 | + * told, in their own run, to delete this entry and let the file be scanned. |
| 62 | + * A silent exemption over the most-read file in the tree is the failure this |
| 63 | + * shape exists to avoid. |
| 64 | + */ |
| 65 | +const EXCLUDED = new Map([['blank/README.md', 'still carries an ADR identifier; owned by another card']]); |
| 66 | + |
| 67 | +/** Text files the scaffolder copies into the user's project. */ |
| 68 | +function shippedFiles(): string[] { |
| 69 | + const out: string[] = []; |
| 70 | + const walk = (dir: string) => { |
| 71 | + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { |
| 72 | + const abs = path.join(dir, entry.name); |
| 73 | + if (entry.isDirectory()) walk(abs); |
| 74 | + else out.push(path.relative(templateRoot, abs).split(path.sep).join('/')); |
| 75 | + } |
| 76 | + }; |
| 77 | + walk(templateRoot); |
| 78 | + return out.sort(); |
| 79 | +} |
| 80 | + |
| 81 | +/** |
| 82 | + * References a reader who has only their own scaffolded project cannot follow. |
| 83 | + * Each is spelled to match the identifier, not any particular sentence, so the |
| 84 | + * prose around it stays free to change. |
| 85 | + */ |
| 86 | +const MONOREPO_ONLY = [ |
| 87 | + { label: 'an ADR identifier', re: /\bADR-\d{3,4}\b/ }, |
| 88 | + { label: 'a bare issue number', re: /(^|[^\w/])#\d{3,6}\b/ }, |
| 89 | + { label: 'a repo build-script path', re: /\bscripts\/[\w.-]+\.(?:mjs|mts|cjs|ts|js)\b/ }, |
| 90 | + { label: 'a monorepo package path', re: /\bpackages\/[a-z0-9][\w-]*\//i }, |
| 91 | +]; |
| 92 | + |
| 93 | +const read = (rel: string) => fs.readFileSync(path.join(templateRoot, rel), 'utf8'); |
| 94 | + |
| 95 | +describe('shipped template comments are followable by a stranger', () => { |
| 96 | + const files = shippedFiles(); |
| 97 | + |
| 98 | + it('reads a real template tree (vacuity guard)', () => { |
| 99 | + expect(files).toContain('blank/objectstack.config.ts'); |
| 100 | + expect(files).toContain('blank/src/objects/note.object.ts'); |
| 101 | + expect(files.length).toBeGreaterThan(8); |
| 102 | + }); |
| 103 | + |
| 104 | + // ── assertion 1: nothing unfollowable ──────────────────────────────────── |
| 105 | + it.each(shippedFiles().filter((f) => !EXCLUDED.has(f)))( |
| 106 | + '%s cites nothing that only exists in this monorepo', |
| 107 | + (rel) => { |
| 108 | + const text = read(rel); |
| 109 | + for (const { label, re } of MONOREPO_ONLY) { |
| 110 | + const hit = re.exec(text); |
| 111 | + expect( |
| 112 | + hit, |
| 113 | + `${rel} cites ${label} (${JSON.stringify(hit?.[0])}). A scaffolded project ` + |
| 114 | + 'ships no ADRs, no issue tracker and none of this repo\'s scripts, so this ' + |
| 115 | + 'reads as a reference the newcomer is failing to follow. State the fact ' + |
| 116 | + 'self-contained, or link a public docs page — do not delete the rationale.', |
| 117 | + ).toBeNull(); |
| 118 | + } |
| 119 | + }, |
| 120 | + ); |
| 121 | + |
| 122 | + // ── assertion 2: the rationale survives ────────────────────────────────── |
| 123 | + // Each entry is the FACT the removed reference was carrying, matched loosely |
| 124 | + // enough that rewording is free and deletion is not. |
| 125 | + const RATIONALE: { file: string; facts: { what: string; re: RegExp }[] }[] = [ |
| 126 | + { |
| 127 | + file: 'blank/objectstack.config.ts', |
| 128 | + facts: [ |
| 129 | + { what: 'why the protocol range exists (an incompatible runtime refuses the app)', re: /refuses? this app|refuse this package|incompatible runtime/i }, |
| 130 | + { what: 'that the protocol range is stamped for you, not hand-tuned', re: /stamped|scaffold(ing|ed)/i }, |
| 131 | + { what: 'why `automation` must stay when a connector is listed', re: /nowhere to register|boot fails/i }, |
| 132 | + { what: 'that a declarative mcp stdio transport is denied by default', re: /denied by default/i }, |
| 133 | + ], |
| 134 | + }, |
| 135 | + { |
| 136 | + file: 'blank/src/objects/note.object.ts', |
| 137 | + facts: [ |
| 138 | + { what: 'what the org-wide default means', re: /org-wide default|OWD/i }, |
| 139 | + { what: 'that declaring it is required rather than optional', re: /required|refuses/i }, |
| 140 | + ], |
| 141 | + }, |
| 142 | + ]; |
| 143 | + |
| 144 | + for (const { file, facts } of RATIONALE) { |
| 145 | + describe(file, () => { |
| 146 | + for (const { what, re } of facts) { |
| 147 | + it(`still explains ${what}`, () => { |
| 148 | + expect( |
| 149 | + read(file), |
| 150 | + `${file} no longer explains ${what}. These comments were rewritten to drop ` + |
| 151 | + 'monorepo-only references while KEEPING what they explain; deleting the ' + |
| 152 | + 'explanation is not the same fix.', |
| 153 | + ).toMatch(re); |
| 154 | + }); |
| 155 | + } |
| 156 | + }); |
| 157 | + } |
| 158 | + |
| 159 | + // ── assertion 3: canonical docs links resolve ──────────────────────────── |
| 160 | + it('every canonical docs URL in the shipped tree resolves to a real page', () => { |
| 161 | + // baseUrl '/docs' is mounted over content/docs, so the route path is the |
| 162 | + // file path minus the extension; a directory resolves only via an index page. |
| 163 | + const candidates = (route: string) => [ |
| 164 | + `${route}.mdx`, |
| 165 | + `${route}.md`, |
| 166 | + `${route}/index.mdx`, |
| 167 | + `${route}/index.md`, |
| 168 | + ]; |
| 169 | + const urls: { rel: string; url: string; route: string }[] = []; |
| 170 | + for (const rel of shippedFiles()) { |
| 171 | + const text = read(rel); |
| 172 | + for (const m of text.matchAll(/https:\/\/objectstack\.ai\/docs\/([\w./-]*[\w-])/g)) { |
| 173 | + urls.push({ rel, url: m[0], route: m[1] }); |
| 174 | + } |
| 175 | + } |
| 176 | + // Non-vacuity: the rewritten starter comments put docs links in this tree on |
| 177 | + // purpose. Zero matches means the extractor broke, not that the tree is clean. |
| 178 | + expect(urls.length, 'no canonical docs URLs found — the extractor is broken').toBeGreaterThan(0); |
| 179 | + |
| 180 | + for (const { rel, url, route } of urls) { |
| 181 | + const found = candidates(route).some((c) => fs.existsSync(path.join(contentDocs, c))); |
| 182 | + expect( |
| 183 | + found, |
| 184 | + `${rel} links ${url}, which content/docs serves from none of ` + |
| 185 | + `${candidates(route).join(', ')}. A link that 404s is the same defect one ` + |
| 186 | + 'level out — repoint it, or make the comment self-contained instead.', |
| 187 | + ).toBe(true); |
| 188 | + } |
| 189 | + }); |
| 190 | + |
| 191 | + // ── the exclusion is live, or it is gone ───────────────────────────────── |
| 192 | + it.each([...EXCLUDED.keys()])('%s still needs its exclusion', (rel) => { |
| 193 | + const text = read(rel); |
| 194 | + const hits = MONOREPO_ONLY.filter(({ re }) => re.test(text)); |
| 195 | + expect( |
| 196 | + hits.length, |
| 197 | + `${rel} no longer cites anything monorepo-only — remove it from EXCLUDED in ` + |
| 198 | + 'this file so it is scanned like every other shipped file. An exclusion kept ' + |
| 199 | + 'past its cause is how a file stops being checked without anyone deciding to ' + |
| 200 | + 'stop checking it.', |
| 201 | + ).toBeGreaterThan(0); |
| 202 | + }); |
| 203 | +}); |
0 commit comments