|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// Every comment that ships into a project scaffolded by `objectstack init` |
| 4 | +// must be followable by the person reading it — someone who has that |
| 5 | +// project and nothing else. |
| 6 | +// |
| 7 | +// ## The defect |
| 8 | +// |
| 9 | +// `packages/cli/src/commands/init.ts` renders its templates as string |
| 10 | +// literals (`TEMPLATES[key].configContent` / `.srcFiles`) and writes them |
| 11 | +// straight into the user's project. Five of those literals carried ADR |
| 12 | +// identifiers — `ADR-0087` and `ADR-0090 D1` — addressed to a reader with |
| 13 | +// this monorepo open. A project scaffolded by `os init` ships no |
| 14 | +// `docs/adr/`, so the identifier named something the reader could not look |
| 15 | +// up. This is the same defect class #10324 fixed in `create-objectstack`'s |
| 16 | +// bundled template *files*; this is the OTHER scaffolder, which renders its |
| 17 | +// templates as in-source string literals instead. |
| 18 | +// |
| 19 | +// ## Why the population is the RENDERED output, not the source file |
| 20 | +// |
| 21 | +// `init.ts` also carries its own ordinary source comments that legitimately |
| 22 | +// cite ADRs and issue numbers (e.g. the `printCreatedFilesSummary` doc |
| 23 | +// comment cites #10499) — those never ship, because they live outside the |
| 24 | +// `configContent` / `srcFiles` functions the command actually writes to |
| 25 | +// disk. A pin that greps `init.ts` wholesale would match those too and |
| 26 | +// report on the wrong population. So this pin does not read the source |
| 27 | +// file at all: it calls the exact functions the `init` command calls |
| 28 | +// (`template.configContent(...)`, `writeTemplateSrcFiles(...)`) and scans |
| 29 | +// the files they actually write — the same real emitter |
| 30 | +// `init-scaffold-authoring-rules.test.ts` uses, for the same reason (so |
| 31 | +// neither test can drift from what `init` really does). |
| 32 | +// |
| 33 | +// ## Why this pin has TWO halves, and why the second is the load-bearing one |
| 34 | +// |
| 35 | +// The cheap way to make the references disappear is to delete the |
| 36 | +// comments. That would ship a worse project than one with the dead |
| 37 | +// references: the comments explain WHY `sharingModel` and `engines.protocol` |
| 38 | +// are the way they are — exactly what a newcomer deciding whether to change |
| 39 | +// them needs. A one-way "no ADR identifiers" grep would stay green while |
| 40 | +// the rationale is deleted out from under it. Hence: no unfollowable |
| 41 | +// reference (assertion 1) AND the fact each comment carries still stated |
| 42 | +// (assertion 2). A future reword is free; silently stripping the |
| 43 | +// explanation, or reintroducing a dead end, is not. |
| 44 | +// |
| 45 | +// ## The third half: a public link is only a fix while it resolves |
| 46 | +// |
| 47 | +// The two docs URLs the rewrite links (upgrading, permissions/sharing-rules) |
| 48 | +// are only a fix while they resolve. Assertion 3 checks every |
| 49 | +// canonical-origin docs URL in the rendered output against the docs content |
| 50 | +// tree the way Fumadocs routes it. The candidate-route logic is restated |
| 51 | +// here rather than imported from check-published-readme-links' own module |
| 52 | +// (which owns the canonical-origin constant), for the same reason #10324's |
| 53 | +// version does: an import would widen this suite's declared cross-package |
| 54 | +// read radius to buy six lines. |
| 55 | + |
| 56 | +import { describe, it, expect, afterAll } from 'vitest'; |
| 57 | +import fs from 'node:fs'; |
| 58 | +import path from 'node:path'; |
| 59 | +import { fileURLToPath } from 'node:url'; |
| 60 | +import { TEMPLATES, sanitizeNamespace, writeTemplateSrcFiles } from '../src/commands/init.js'; |
| 61 | + |
| 62 | +const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 63 | +const TMP_ROOT = path.resolve(HERE, '../tmp'); |
| 64 | +const CONTENT_DOCS = path.resolve(HERE, '..', '..', '..', 'content', 'docs'); |
| 65 | +const PROJECT_NAME = 'my-app'; |
| 66 | + |
| 67 | +const roots: string[] = []; |
| 68 | +afterAll(() => { |
| 69 | + for (const dir of roots) fs.rmSync(dir, { recursive: true, force: true }); |
| 70 | +}); |
| 71 | + |
| 72 | +interface Rendered { |
| 73 | + templateKey: string; |
| 74 | + file: string; |
| 75 | + content: string; |
| 76 | +} |
| 77 | + |
| 78 | +/** |
| 79 | + * Render every built-in template through `init`'s own emitter — the exact |
| 80 | + * functions the command calls, writing to real files in a throwaway |
| 81 | + * directory (mirroring `init-scaffold-authoring-rules.test.ts`) — and |
| 82 | + * return every file it produced. This IS the population the defect lives |
| 83 | + * in: text a scaffolded project actually receives. |
| 84 | + */ |
| 85 | +function renderAll(): Rendered[] { |
| 86 | + const namespace = sanitizeNamespace(PROJECT_NAME); |
| 87 | + const out: Rendered[] = []; |
| 88 | + fs.mkdirSync(TMP_ROOT, { recursive: true }); |
| 89 | + |
| 90 | + for (const templateKey of Object.keys(TEMPLATES)) { |
| 91 | + const template = TEMPLATES[templateKey]; |
| 92 | + const root = fs.mkdtempSync(path.join(TMP_ROOT, `render-${templateKey}-`)); |
| 93 | + roots.push(root); |
| 94 | + |
| 95 | + fs.writeFileSync( |
| 96 | + path.join(root, 'objectstack.config.ts'), |
| 97 | + template.configContent(PROJECT_NAME, namespace), |
| 98 | + ); |
| 99 | + writeTemplateSrcFiles(template.srcFiles, root, PROJECT_NAME, namespace); |
| 100 | + |
| 101 | + const walk = (dir: string) => { |
| 102 | + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { |
| 103 | + const abs = path.join(dir, entry.name); |
| 104 | + if (entry.isDirectory()) walk(abs); |
| 105 | + else out.push({ templateKey, file: path.relative(root, abs), content: fs.readFileSync(abs, 'utf8') }); |
| 106 | + } |
| 107 | + }; |
| 108 | + walk(root); |
| 109 | + } |
| 110 | + return out; |
| 111 | +} |
| 112 | + |
| 113 | +/** |
| 114 | + * References a reader who has only their own scaffolded project cannot |
| 115 | + * follow. Reused verbatim from #10324's |
| 116 | + * `starter-comments-self-contained.test.ts` — same defect class, same |
| 117 | + * vocabulary — spelled to match the identifier, not any particular |
| 118 | + * sentence, so the prose around it stays free to change. |
| 119 | + */ |
| 120 | +const MONOREPO_ONLY = [ |
| 121 | + { label: 'an ADR identifier', re: /\bADR-\d{3,4}\b/ }, |
| 122 | + { label: 'a bare issue number', re: /(^|[^\w/])#\d{3,6}\b/ }, |
| 123 | + { label: 'a repo build-script path', re: /\bscripts\/[\w.-]+\.(?:mjs|mts|cjs|ts|js)\b/ }, |
| 124 | + { label: 'a monorepo package path', re: /\bpackages\/[a-z0-9][\w-]*\//i }, |
| 125 | +]; |
| 126 | + |
| 127 | +describe('rendered init templates are followable by a stranger', () => { |
| 128 | + const rendered = renderAll(); |
| 129 | + |
| 130 | + // ── vacuity guard: prove this is reading real rendered output ────────── |
| 131 | + it('rendered a real, non-empty project per template (vacuity guard)', () => { |
| 132 | + expect(Object.keys(TEMPLATES).length).toBeGreaterThan(0); |
| 133 | + expect(rendered.length).toBeGreaterThan(0); |
| 134 | + for (const templateKey of Object.keys(TEMPLATES)) { |
| 135 | + const files = rendered.filter((r) => r.templateKey === templateKey); |
| 136 | + expect(files.map((f) => f.file), `template "${templateKey}"`).toContain('objectstack.config.ts'); |
| 137 | + } |
| 138 | + // The two templates that emit an object (app, plugin) must have reached |
| 139 | + // the OWD comment's file, or assertion 2 below would vacuously pass. |
| 140 | + const objectFiles = rendered.filter((r) => /src\/objects\/.*_item\.ts$/.test(r.file)); |
| 141 | + expect(objectFiles.length).toBeGreaterThan(0); |
| 142 | + }); |
| 143 | + |
| 144 | + // ── assertion 1: nothing unfollowable ─────────────────────────────────── |
| 145 | + it.each(rendered.map((r) => [`${r.templateKey}/${r.file}`, r] as const))( |
| 146 | + '%s cites nothing that only exists in this monorepo', |
| 147 | + (_label, r) => { |
| 148 | + for (const { label, re } of MONOREPO_ONLY) { |
| 149 | + const hit = re.exec(r.content); |
| 150 | + expect( |
| 151 | + hit, |
| 152 | + `${r.templateKey}/${r.file} cites ${label} (${JSON.stringify(hit?.[0])}). A project ` + |
| 153 | + 'scaffolded by `os init` ships no ADRs, no issue tracker and none of this repo\'s ' + |
| 154 | + 'scripts, so this reads as a reference the newcomer is failing to follow. State the ' + |
| 155 | + 'fact self-contained, or link a public docs page — do not delete the rationale.', |
| 156 | + ).toBeNull(); |
| 157 | + } |
| 158 | + }, |
| 159 | + ); |
| 160 | + |
| 161 | + // ── assertion 2: the rationale survives ───────────────────────────────── |
| 162 | + // The FACT each removed reference was carrying, matched loosely enough |
| 163 | + // that rewording is free and deletion is not. |
| 164 | + it.each(rendered.filter((r) => r.file === 'objectstack.config.ts').map((r) => [r.templateKey, r] as const))( |
| 165 | + 'template "%s" objectstack.config.ts still explains the protocol range', |
| 166 | + (_templateKey, r) => { |
| 167 | + expect(r.content, `${r.templateKey}/${r.file} must still explain why the range exists`).toMatch( |
| 168 | + /refuses this (app|plugin) at the boundary|incompatible runtime/i, |
| 169 | + ); |
| 170 | + expect(r.content, `${r.templateKey}/${r.file} must still explain it was stamped by scaffolding`).toMatch( |
| 171 | + /stamped/i, |
| 172 | + ); |
| 173 | + }, |
| 174 | + ); |
| 175 | + |
| 176 | + const objectFiles = rendered.filter((r) => /src\/objects\/.*_item\.ts$/.test(r.file)); |
| 177 | + it.each(objectFiles.map((r) => [`${r.templateKey}/${r.file}`, r] as const))( |
| 178 | + '%s still explains the org-wide default', |
| 179 | + (_label, r) => { |
| 180 | + expect(r.content, `${r.templateKey}/${r.file} must still explain what OWD means`).toMatch( |
| 181 | + /org-wide default|OWD/i, |
| 182 | + ); |
| 183 | + expect(r.content, `${r.templateKey}/${r.file} must still explain declaring it is required`).toMatch( |
| 184 | + /required|refuses/i, |
| 185 | + ); |
| 186 | + }, |
| 187 | + ); |
| 188 | + // Non-vacuity for assertion 2's own population: the app/plugin templates |
| 189 | + // both emit an object file, so this list must not be empty. |
| 190 | + it('found object source files to check the OWD rationale on', () => { |
| 191 | + expect(objectFiles.length).toBeGreaterThanOrEqual(2); |
| 192 | + }); |
| 193 | + |
| 194 | + // ── assertion 3: canonical docs links resolve ─────────────────────────── |
| 195 | + it('every canonical docs URL in rendered templates resolves to a real page', () => { |
| 196 | + // baseUrl '/docs' is mounted over content/docs, so the route path is the |
| 197 | + // file path minus the extension; a directory resolves only via an index |
| 198 | + // page. Restated from check-published-readme-links.mjs's pageCandidates |
| 199 | + // rather than imported — see file header. |
| 200 | + const candidates = (route: string) => [ |
| 201 | + `${route}.mdx`, |
| 202 | + `${route}.md`, |
| 203 | + `${route}/index.mdx`, |
| 204 | + `${route}/index.md`, |
| 205 | + ]; |
| 206 | + const urls: { where: string; url: string; route: string }[] = []; |
| 207 | + for (const r of rendered) { |
| 208 | + for (const m of r.content.matchAll(/https:\/\/objectstack\.ai\/docs\/([\w./-]*[\w-])/g)) { |
| 209 | + urls.push({ where: `${r.templateKey}/${r.file}`, url: m[0], route: m[1] }); |
| 210 | + } |
| 211 | + } |
| 212 | + // Non-vacuity: the rewrite puts docs links in every template on |
| 213 | + // purpose. Zero matches means the extractor broke, not that the |
| 214 | + // templates are clean. |
| 215 | + expect(urls.length, 'no canonical docs URLs found — the extractor is broken').toBeGreaterThan(0); |
| 216 | + |
| 217 | + for (const { where, url, route } of urls) { |
| 218 | + const found = candidates(route).some((c) => fs.existsSync(path.join(CONTENT_DOCS, c))); |
| 219 | + expect( |
| 220 | + found, |
| 221 | + `${where} links ${url}, which content/docs serves from none of ` + |
| 222 | + `${candidates(route).join(', ')}. A link that 404s is the same defect one level ` + |
| 223 | + 'out — repoint it, or make the comment self-contained instead.', |
| 224 | + ).toBe(true); |
| 225 | + } |
| 226 | + }); |
| 227 | +}); |
0 commit comments