Skip to content

Commit 18d5fec

Browse files
Elon Muskclaude
andauthored
fix(cli): make os init's rendered template comments self-contained (#11192)
* fix(cli): make os init's rendered template comments self-contained packages/cli/src/commands/init.ts renders its templates as string literals and writes them into the user's project. Five of those literals carried ADR identifiers (ADR-0087, ADR-0090 D1) addressed to a reader with this monorepo open; a project scaffolded by `os init` ships no docs/adr/, so the identifier named something unfollowable. Rewrite each self-contained, keeping the rationale, and link the same public docs routes #10324 verified for create-objectstack: - protocol range -> https://objectstack.ai/docs/upgrading - org-wide default -> https://objectstack.ai/docs/permissions/sharing-rules Add a pin that renders every TEMPLATES entry through init's own emitter (configContent / writeTemplateSrcFiles) and scans the rendered output -- not the source file -- so it cannot be confused by init.ts's own ordinary source comments that legitimately cite ADRs and issue numbers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r * chore(cli): add changeset; avoid tripping cross-package-test-inputs on a comment mention The pin's docblock backtick-quoted a scripts/*.mjs path, which the cross-package-test-inputs gate's literal collector reads the same as a real quoted specifier. Reword to match the established convention (no scripts/ prefix, no quoting) instead of widening the package's declared turbo glob for a path nothing actually reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 52a41b7 commit 18d5fec

3 files changed

Lines changed: 271 additions & 13 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`objectstack init` no longer writes ADR identifiers into the user's project (#11023). Five comments rendered by `packages/cli/src/commands/init.ts`'s built-in templates (`app`, `plugin`, `empty`) cited `ADR-0087` and `ADR-0090 D1` — addressed to a reader with this monorepo open. A project scaffolded by `os init` ships no `docs/adr/`, so the identifier named something unfollowable.
6+
7+
Each comment is rewritten self-contained, keeping the rationale it carried, and links a public docs page instead of the internal identifier — the same wording #10324 settled on for `create-objectstack`'s bundled templates:
8+
9+
- protocol compatibility range → https://objectstack.ai/docs/upgrading
10+
- org-wide default (`sharingModel`) → https://objectstack.ai/docs/permissions/sharing-rules

packages/cli/src/commands/init.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,13 @@ export default defineStack({
394394
type: 'app',
395395
name: '${toTitleCase(name)}',
396396
description: '${toTitleCase(name)} application built with ObjectStack',
397-
// Protocol major this app is authored against (ADR-0087 load-time check).
397+
// Protocol compatibility range: the metadata-protocol major this app is
398+
// authored against. The runtime checks it before it loads anything, so a
399+
// runtime outside the range refuses this app at the boundary with the
400+
// exact migration command instead of crashing later. Scaffolding stamped
401+
// it to match the ObjectStack version you installed — change it when you
402+
// deliberately move to a new protocol major, not to silence a mismatch.
403+
// Guide: https://objectstack.ai/docs/upgrading
398404
engines: { protocol: '^${PROTOCOL_MAJOR}' },
399405
},
400406
@@ -430,11 +436,12 @@ const ${toCamelCase(namespace)}Item: Data.Object = {
430436
defaultValue: 'draft',
431437
},
432438
},
433-
// Org-wide default (OWD): who can see records they do NOT own. ADR-0090 D1
434-
// requires this to be an authored decision rather than an accident — the
435-
// \`security-owd-unset\` author-time rule refuses an object without it, so a
436-
// scaffold that omitted it could not compile. 'private' is the rule's own
437-
// recommended default: owner + explicit shares.
439+
// Org-wide default (OWD): who can see records they don't own. 'private' is
440+
// owner-only until access is widened by a permission grant or a sharing
441+
// rule. Declaring it is required, deliberately: \`objectstack build\`
442+
// refuses an object that declares no OWD, so the baseline is always an
443+
// authored decision rather than an accident. The other values, and how to
444+
// widen access safely: https://objectstack.ai/docs/permissions/sharing-rules
438445
sharingModel: 'private',
439446
};
440447
@@ -474,7 +481,14 @@ export default defineStack({
474481
type: 'plugin',
475482
name: '${toTitleCase(name)} Plugin',
476483
description: 'ObjectStack Plugin: ${toTitleCase(name)}',
477-
// Protocol major this plugin is authored against (ADR-0087 load-time check).
484+
// Protocol compatibility range: the metadata-protocol major this plugin
485+
// is authored against. The runtime checks it before it loads anything, so
486+
// a runtime outside the range refuses this plugin at the boundary with
487+
// the exact migration command instead of crashing later. Scaffolding
488+
// stamped it to match the ObjectStack version you installed — change it
489+
// when you deliberately move to a new protocol major, not to silence a
490+
// mismatch.
491+
// Guide: https://objectstack.ai/docs/upgrading
478492
engines: { protocol: '^${PROTOCOL_MAJOR}' },
479493
},
480494
@@ -496,11 +510,12 @@ const ${toCamelCase(namespace)}Item: Data.Object = {
496510
required: true,
497511
},
498512
},
499-
// Org-wide default (OWD): who can see records they do NOT own. ADR-0090 D1
500-
// requires this to be an authored decision rather than an accident — the
501-
// \`security-owd-unset\` author-time rule refuses an object without it, so a
502-
// scaffold that omitted it could not compile. 'private' is the rule's own
503-
// recommended default: owner + explicit shares.
513+
// Org-wide default (OWD): who can see records they don't own. 'private' is
514+
// owner-only until access is widened by a permission grant or a sharing
515+
// rule. Declaring it is required, deliberately: \`objectstack build\`
516+
// refuses an object that declares no OWD, so the baseline is always an
517+
// authored decision rather than an accident. The other values, and how to
518+
// widen access safely: https://objectstack.ai/docs/permissions/sharing-rules
504519
sharingModel: 'private',
505520
};
506521
@@ -537,7 +552,13 @@ export default defineStack({
537552
type: 'app',
538553
name: '${toTitleCase(name)}',
539554
description: '',
540-
// Protocol major this app is authored against (ADR-0087 load-time check).
555+
// Protocol compatibility range: the metadata-protocol major this app is
556+
// authored against. The runtime checks it before it loads anything, so a
557+
// runtime outside the range refuses this app at the boundary with the
558+
// exact migration command instead of crashing later. Scaffolding stamped
559+
// it to match the ObjectStack version you installed — change it when you
560+
// deliberately move to a new protocol major, not to silence a mismatch.
561+
// Guide: https://objectstack.ai/docs/upgrading
541562
engines: { protocol: '^${PROTOCOL_MAJOR}' },
542563
},
543564
});
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
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

Comments
 (0)