Skip to content

Commit c90feef

Browse files
dmealingclaude
andcommitted
fix(docs)!: default outDir to ./docs/generated, not ./docs
`docs/` is the human documentation folder in most repositories. One estate's held a business-strategy document, four product screenshots, email drafts and an analytics roadmap — and a bare `meta docs` scattered 29 generated pages through it, then wanted to own `docs/README.md`. `meta verify --docs` reported all 29 as missing until the adopter found the config key. The docs gate's own design language is right — "docs.outDir is a directory, not a namespace MetaObjects owns" — and that is precisely the argument against defaulting into the most-owned directory name in the ecosystem. `--metamodel` keeps `./docs/metamodel`: a different artifact, not this project's pages. BREAKING for a project relying on the old default: pin `docs: { outDir: "./docs" }` and nothing moves. `meta init` now scaffolds the key, so a new project's choice is visible rather than implied. Documented in the 1.0 migration guide §13. The flag default and the config default are now ONE constant. They were two literals that happened to be the same string, and `meta docs --help` documented only `--out` — the flag that does NOT survive the run, which is the one thing a gate needs. `--help` now names the config key and says so. Found by the change: the docs-drift integration suite generated with an explicit `--out <dir>/docs` while `verify --docs` resolved the config default, and passed only because the two coincided. Sixteen cases failed at once when they stopped coinciding — the two doors onto one directory were never actually being compared. It now takes the config path, which is what the gate reads. Also here: the showcase emits no form, hook or grid, so it correctly gets no `agent/ui.md` any more, and that suite asserts the absence with the reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTcEKXTQMYt84fAjuw5A2M
1 parent c8f244d commit c90feef

7 files changed

Lines changed: 90 additions & 43 deletions

File tree

‎server/typescript/packages/cli/src/commands/init.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,9 @@ export default defineConfig({
139139
barrel(),
140140
],
141141
docs: {
142-
outDir: "./docs", // every surface lands here (run: meta docs)
142+
outDir: "./docs/generated", // every surface lands here (run: meta docs).
143+
// A SUB-directory on purpose: these pages are regenerated
144+
// and overwritten, and docs/ itself is usually yours.
143145
layout: "flat", // or "package" for multi-package models
144146
// surfaces defaults to ["model", "api", "requirements", "agent"] — all four.
145147
// Deliberately NOT narrowed here: the requirements and agent surfaces emit ZERO

‎server/typescript/packages/cli/src/index.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,11 @@ DOCS FLAGS:
7575
[<project-root>] PROJECT ROOT to resolve metadata from — the directory that CONTAINS
7676
your metadata, NOT the metadata directory. Passing it SCOPES the run
7777
(default: cwd)
78-
--out <dir>, -o Output directory for the pages (default: ./docs)
78+
--out <dir>, -o Output directory for THIS run (default: ./docs/generated). It does
79+
NOT survive the run — set 'docs: { outDir }' in metaobjects.config.ts
80+
for a directory that persists, which is what 'meta verify --docs'
81+
measures. The default is a sub-directory on purpose: these pages are
82+
regenerated and overwritten, and 'docs/' itself is usually yours.
7983
--templates <dir> Project root to resolve adopter templates/ overrides (default: <project-root>)
8084
--prompts <dir> Extra dir holding prompt .mustache sources for --site (e.g. data/templates/)
8185
@@ -272,7 +276,11 @@ FLAGS:
272276
it SCOPES the run to that directory's own sources; no ancestor
273277
.metaobjects/config.json is consulted. Omitted (default), the project
274278
is discovered by walking up.
275-
--out <dir>, -o Output directory for the pages (default: ./docs)
279+
--out <dir>, -o Output directory for THIS run (default: ./docs/generated). It does
280+
NOT survive the run — set 'docs: { outDir }' in metaobjects.config.ts
281+
for a directory that persists, which is what 'meta verify --docs'
282+
measures. The default is a sub-directory on purpose: these pages are
283+
regenerated and overwritten, and 'docs/' itself is usually yours.
276284
--model Emit the markdown model surface (entity + template pages)
277285
--api Emit the markdown api surface (generated SDK reference)
278286
--requirements Emit the declared requirement ledger (requirements.md + .toon).
@@ -281,7 +289,9 @@ FLAGS:
281289
agent/ui.md (the generated forms and grids) and
282290
agent/requirements.md (the ledger plus a claimed-node index).
283291
Needs metaobjects.config.ts (the dialect and physical names come
284-
from it); each page is skipped when its tier has nothing to describe.
292+
from it); each page is skipped when its tier has nothing to describe
293+
— agent/ui.md needs a UI generator (a form, hook or grid) in the
294+
suite, not merely objects that could have one.
285295
--metamodel Document the built-in metamodel vocabulary (no metadata needed)
286296
--site Generate the browsable HTML documentation site (<out>/site/)
287297
--scaffold-site Copy the site's templates + assets into codegen/docs-site/ to own (theme) them

‎server/typescript/packages/cli/src/lib/args.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,8 +272,9 @@ export interface VerifyFlags {
272272
* A SEPARATE subverb rather than part of `--codegen`, because they check different
273273
* trees under different ownership rules: `--codegen` regenerates `outDir`/`targets` and
274274
* must respect the hand edits `meta gen` preserves, while the docs tree has no merge,
275-
* no manifest, and lives in a directory (`./docs` by default) full of hand-written
276-
* files it does not own. Folding them together would have to pick one of those rules
275+
* no manifest, and lives in a directory the adopter chose (`./docs/generated` by
276+
* default) that may hold hand-written files it does not own. Folding them together
277+
* would have to pick one of those rules
277278
* for both.
278279
*/
279280
docs: boolean;

‎server/typescript/packages/cli/src/lib/docs-drift.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@
1919
// `.gen-state/.hashes.json` to tell a preserved hand edit from stale output; here
2020
// there is no such offer to honour.
2121
//
22-
// 2. IT REPORTS A FILE AS EXTRA ONLY WHERE IT OWNS THE DIRECTORY. `docs.outDir`
23-
// defaults to `./docs`, which in a real repository is full of hand-written
24-
// documentation — this repository's own `docs/` holds a hundred such files. Its
22+
// 2. IT REPORTS A FILE AS EXTRA ONLY WHERE IT OWNS THE DIRECTORY. `docs.outDir` is a
23+
// directory an adopter chooses, and it may well be one full of hand-written
24+
// documentation — this repository's own `docs/` holds a hundred such files. (The
25+
// default moved to `./docs/generated` for exactly that reason: defaulting into the
26+
// most-owned directory name in the ecosystem contradicted this paragraph.) Its
2527
// `api/` subtree is not ours either: on a multi-port project those pages are written
2628
// by the OTHER port's docs command (`mvn metaobjects:docs`, `metaobjects docs`,
2729
// `dotnet meta docs`), which this gate never runs, so a fresh run here legitimately

‎server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ DOCS FLAGS:
5757
[<project-root>] PROJECT ROOT to resolve metadata from — the directory that CONTAINS
5858
your metadata, NOT the metadata directory. Passing it SCOPES the run
5959
(default: cwd)
60-
--out <dir>, -o Output directory for the pages (default: ./docs)
60+
--out <dir>, -o Output directory for THIS run (default: ./docs/generated). It does
61+
NOT survive the run — set 'docs: { outDir }' in metaobjects.config.ts
62+
for a directory that persists, which is what 'meta verify --docs'
63+
measures. The default is a sub-directory on purpose: these pages are
64+
regenerated and overwritten, and 'docs/' itself is usually yours.
6165
--templates <dir> Project root to resolve adopter templates/ overrides (default: <project-root>)
6266
--prompts <dir> Extra dir holding prompt .mustache sources for --site (e.g. data/templates/)
6367

‎server/typescript/packages/cli/test/integration/verify-docs-drift.test.ts‎

Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ import { run } from "../../src/index.js";
1919
// test/integration/ -> cli -> packages -> typescript -> server -> repo root
2020
const SHOWCASE = resolve(import.meta.dirname, "../../../../../../examples/showcase");
2121

22+
// The docs root, taken from the CONFIG default rather than a `--out` flag — because the
23+
// gate reads the config and `--out` does not survive the run. The test used to pass
24+
// `--out <dir>/docs` while `verify --docs` resolved the default, and it only agreed
25+
// because the two happened to be the same string. When the default moved to
26+
// `docs/generated`, that coincidence ended and every case here failed at once: the two
27+
// doors onto one directory were never actually being compared.
28+
const DOCS_ROOT = join("docs", "generated");
29+
2230
/** A throwaway copy of the showcase with a freshly generated docs tree. */
2331
function project(): string {
2432
const dir = mkdtempSync(join(tmpdir(), "verify-docs-"));
@@ -27,7 +35,7 @@ function project(): string {
2735
}
2836

2937
async function generateDocs(dir: string): Promise<void> {
30-
expect(await run(["docs", dir, "--out", join(dir, "docs")])).toBe(0);
38+
expect(await run(["docs", dir])).toBe(0);
3139
}
3240

3341
describe("meta verify --docs", () => {
@@ -38,12 +46,17 @@ describe("meta verify --docs", () => {
3846
// The agent surface materialises only with a loadable gen config, which the
3947
// showcase has. Assert the pages exist before asserting the gate is green, so a
4048
// green result cannot come from the surface having silently emitted nothing.
41-
expect(existsSync(join(dir, "docs", "agent", "schema.md"))).toBe(true);
42-
expect(existsSync(join(dir, "docs", "agent", "ui.md"))).toBe(true);
43-
expect(existsSync(join(dir, "docs", "agent", "requirements.md"))).toBe(true);
49+
expect(existsSync(join(dir, DOCS_ROOT, "agent", "schema.md"))).toBe(true);
50+
expect(existsSync(join(dir, DOCS_ROOT, "agent", "requirements.md"))).toBe(true);
51+
// NO ui.md, and that is the assertion. The showcase wires entity/queries/routes/
52+
// prompt generators and not one UI generator, so nothing here emits a form, a hook
53+
// or a grid — the page's metadata predicate (`servesReadApi`) said otherwise and
54+
// produced a control table for forms that do not exist plus endpoints for a client
55+
// that was never generated. Whether the tier exists is a GENERATOR fact.
56+
expect(existsSync(join(dir, DOCS_ROOT, "agent", "ui.md"))).toBe(false);
4457
// Built from the REAL migrate-ts snapshot: the dialect comes from the project's
4558
// own config, not from `meta docs`'s neutral "sqlite" placeholder.
46-
const schema = readFileSync(join(dir, "docs", "agent", "schema.md"), "utf8");
59+
const schema = readFileSync(join(dir, DOCS_ROOT, "agent", "schema.md"), "utf8");
4760
expect(schema).toContain("`subscribers`");
4861
expect(schema).toContain("Declared by `acme::Subscriber`.");
4962

@@ -57,7 +70,7 @@ describe("meta verify --docs", () => {
5770
const dir = project();
5871
try {
5972
await generateDocs(dir);
60-
const page = join(dir, "docs", "agent", "schema.md");
73+
const page = join(dir, DOCS_ROOT, "agent", "schema.md");
6174
writeFileSync(page, readFileSync(page, "utf8").replace("subscribers", "subscribersRENAMED"));
6275
expect(await run(["verify", "--cwd", dir, "--docs"])).toBe(1);
6376
} finally {
@@ -69,7 +82,8 @@ describe("meta verify --docs", () => {
6982
const dir = project();
7083
try {
7184
await generateDocs(dir);
72-
rmSync(join(dir, "docs", "agent", "ui.md"));
85+
// A page the showcase DOES emit — ui.md is not one of them (no UI generator).
86+
rmSync(join(dir, DOCS_ROOT, "agent", "schema.md"));
7387
expect(await run(["verify", "--cwd", dir, "--docs"])).toBe(1);
7488
} finally {
7589
rmSync(dir, { recursive: true, force: true });
@@ -98,10 +112,10 @@ describe("meta verify --docs", () => {
98112
// hand-written documentation. Convicting those is the jurisdiction mistake
99113
// `verify --codegen`'s orphan branch was corrected for; with no manifest to appeal
100114
// to, this gate must never make it.
101-
writeFileSync(join(dir, "docs", "ARCHITECTURE.md"), "# Ours, not MetaObjects'.\n");
115+
writeFileSync(join(dir, DOCS_ROOT, "ARCHITECTURE.md"), "# Ours, not MetaObjects'.\n");
102116
// Inside `agent/` too: the gate convicts a STALE GENERATED page there, and it tells
103117
// the two apart by the `@generated` marker rather than by the directory alone.
104-
writeFileSync(join(dir, "docs", "agent", "NOTES.md"), "# Also ours.\n");
118+
writeFileSync(join(dir, DOCS_ROOT, "agent", "NOTES.md"), "# Also ours.\n");
105119
expect(await run(["verify", "--cwd", dir, "--docs"])).toBe(0);
106120
} finally {
107121
rmSync(dir, { recursive: true, force: true });
@@ -121,8 +135,8 @@ describe("meta verify --docs", () => {
121135
// Simulated by copying a generated page to a name no run emits: same shape, one
122136
// condition — a committed file carrying our marker that a fresh run does not
123137
// produce — without needing to break the project to reach it.
124-
const stale = readFileSync(join(dir, "docs", "agent", "schema.md"), "utf8");
125-
writeFileSync(join(dir, "docs", "agent", "schema.postgres.md"), stale);
138+
const stale = readFileSync(join(dir, DOCS_ROOT, "agent", "schema.md"), "utf8");
139+
writeFileSync(join(dir, DOCS_ROOT, "agent", "schema.postgres.md"), stale);
126140
expect(await run(["verify", "--cwd", dir, "--docs"])).toBe(1);
127141
} finally {
128142
rmSync(dir, { recursive: true, force: true });
@@ -137,7 +151,7 @@ describe("meta verify --docs", () => {
137151
// repository's `docs/` may hold a symlink to a build output absent on CI. `statSync`
138152
// follows it and throws ENOENT, which the caller reports as "regeneration failed" —
139153
// the gate going red, blaming the fresh run, for a dangling link it did not create.
140-
symlinkSync(join(dir, "docs", "nowhere-at-all"), join(dir, "docs", "dangling.md"));
154+
symlinkSync(join(dir, DOCS_ROOT, "nowhere-at-all"), join(dir, DOCS_ROOT, "dangling.md"));
141155
expect(await run(["verify", "--cwd", dir, "--docs"])).toBe(0);
142156
} finally {
143157
rmSync(dir, { recursive: true, force: true });
@@ -202,10 +216,10 @@ async function runCapturingStderr(args: string[]): Promise<{ exit: number; out:
202216

203217
describe("meta verify --docs — a git-ignored page is not drift", () => {
204218
test("the F27 reproduction: generate all, commit two, gate is clean", async () => {
205-
const dir = gitProject("docs/*\n!docs/requirements.md\n!docs/requirements.toon\n");
219+
const dir = gitProject("docs/generated/*\n!docs/generated/requirements.md\n!docs/generated/requirements.toon\n");
206220
try {
207221
await generateDocs(dir);
208-
git(dir, "add", "-f", ".gitignore", "docs/requirements.md", "docs/requirements.toon");
222+
git(dir, "add", "-f", ".gitignore", "docs/generated/requirements.md", "docs/generated/requirements.toon");
209223
git(dir, "commit", "-qm", "docs");
210224

211225
const { exit, out } = await runCapturingStderr(["verify", "--cwd", dir, "--docs"]);
@@ -218,12 +232,12 @@ describe("meta verify --docs — a git-ignored page is not drift", () => {
218232

219233
test("...and a REAL edit to one of the two committed pages still convicts", async () => {
220234
// The other half. A rule that exempts everything is not a fix, it is a mute button.
221-
const dir = gitProject("docs/*\n!docs/requirements.md\n!docs/requirements.toon\n");
235+
const dir = gitProject("docs/generated/*\n!docs/generated/requirements.md\n!docs/generated/requirements.toon\n");
222236
try {
223237
await generateDocs(dir);
224-
git(dir, "add", "-f", ".gitignore", "docs/requirements.md", "docs/requirements.toon");
238+
git(dir, "add", "-f", ".gitignore", "docs/generated/requirements.md", "docs/generated/requirements.toon");
225239
git(dir, "commit", "-qm", "docs");
226-
writeFileSync(join(dir, "docs", "requirements.md"), "hand-edited\n");
240+
writeFileSync(join(dir, DOCS_ROOT, "requirements.md"), "hand-edited\n");
227241

228242
const { exit, out } = await runCapturingStderr(["verify", "--cwd", dir, "--docs"]);
229243
expect(exit).toBe(1);
@@ -238,12 +252,12 @@ describe("meta verify --docs — a git-ignored page is not drift", () => {
238252
test("a TRACKED page is compared even when a pattern would ignore it", async () => {
239253
// The property the whole design rests on: git never reports a tracked path as
240254
// ignored. So this rule can never be used to hide a page the project commits.
241-
const dir = gitProject("docs/\n");
255+
const dir = gitProject("docs/generated/\n");
242256
try {
243257
await generateDocs(dir);
244-
git(dir, "add", "-f", ".gitignore", "docs/agent/schema.md");
258+
git(dir, "add", "-f", ".gitignore", "docs/generated/agent/schema.md");
245259
git(dir, "commit", "-qm", "docs");
246-
writeFileSync(join(dir, "docs", "agent", "schema.md"), "hand-edited\n");
260+
writeFileSync(join(dir, DOCS_ROOT, "agent", "schema.md"), "hand-edited\n");
247261

248262
const { exit, out } = await runCapturingStderr(["verify", "--cwd", dir, "--docs"]);
249263
expect(exit).toBe(1);
@@ -255,7 +269,7 @@ describe("meta verify --docs — a git-ignored page is not drift", () => {
255269

256270
test("ignoring EVERYTHING is refused, not reported clean", async () => {
257271
// A gate asked to check pages that could check none must not answer "no drift".
258-
const dir = gitProject("docs/\n");
272+
const dir = gitProject("docs/generated/\n");
259273
try {
260274
await generateDocs(dir);
261275
git(dir, "add", "-f", ".gitignore");
@@ -277,7 +291,7 @@ describe("meta verify --docs — a git-ignored page is not drift", () => {
277291
await generateDocs(dir);
278292
git(dir, "add", "-f", "docs");
279293
git(dir, "commit", "-qm", "docs");
280-
rmSync(join(dir, "docs", "README.md"));
294+
rmSync(join(dir, DOCS_ROOT, "README.md"));
281295

282296
const { exit, out } = await runCapturingStderr(["verify", "--cwd", dir, "--docs"]);
283297
expect(exit).toBe(1);
@@ -300,14 +314,14 @@ describe("meta verify --docs — a git-ignored page is not drift", () => {
300314
// Untracked on purpose: git never reports a TRACKED path as ignored (the test
301315
// above), so the only way to reach this branch is the real-world shape — a leftover
302316
// generated page on a machine that has run `meta docs`, under an ignored path.
303-
const dir = gitProject("docs/agent/stale-page.md\n");
317+
const dir = gitProject("docs/generated/agent/stale-page.md\n");
304318
try {
305319
await generateDocs(dir);
306320
git(dir, "add", "-f", ".gitignore", "docs");
307321
git(dir, "commit", "-qm", "docs");
308322
// Ours by both halves of `isOurs`: under `agent/`, carrying the generated marker.
309323
writeFileSync(
310-
join(dir, "docs", "agent", "stale-page.md"),
324+
join(dir, DOCS_ROOT, "agent", "stale-page.md"),
311325
"<!-- @generated by meta docs -->\n\nA page a fresh run no longer emits.\n",
312326
);
313327

@@ -330,13 +344,13 @@ describe("meta verify --docs — a git-ignored page is not drift", () => {
330344
// Stated as a CONTRAST on one fixture, because that is the only form that proves
331345
// the exemption is what produced the clean run: the same project, the same missing
332346
// page, exempt when git can answer and convicted when it cannot.
333-
const dir = gitProject("docs/*\n!docs/requirements.md\n!docs/requirements.toon\n");
347+
const dir = gitProject("docs/generated/*\n!docs/generated/requirements.md\n!docs/generated/requirements.toon\n");
334348
try {
335349
await generateDocs(dir);
336-
git(dir, "add", "-f", ".gitignore", "docs/requirements.md", "docs/requirements.toon");
350+
git(dir, "add", "-f", ".gitignore", "docs/generated/requirements.md", "docs/generated/requirements.toon");
337351
git(dir, "commit", "-qm", "docs");
338352
// An ignored page that is NOT on disk — the fresh-clone shape.
339-
rmSync(join(dir, "docs", "README.md"));
353+
rmSync(join(dir, DOCS_ROOT, "README.md"));
340354

341355
const withGit = await runCapturingStderr(["verify", "--cwd", dir, "--docs"]);
342356
expect(withGit.exit).toBe(0);
@@ -361,18 +375,18 @@ describe("meta verify --docs — a git-ignored page is not drift", () => {
361375
// The exemption is computed over the fresh set BY NAME, on disk or not. Deciding
362376
// per-file-existence would have made `checked` a property of the machine: a dev box
363377
// that has run `meta docs` has all 589 pages present and a CI runner has two.
364-
const body = "docs/*\n!docs/requirements.md\n!docs/requirements.toon\n";
378+
const body = "docs/generated/*\n!docs/generated/requirements.md\n!docs/generated/requirements.toon\n";
365379
const withPages = gitProject(body);
366380
const withoutPages = gitProject(body);
367381
try {
368382
for (const dir of [withPages, withoutPages]) {
369383
await generateDocs(dir);
370-
git(dir, "add", "-f", ".gitignore", "docs/requirements.md", "docs/requirements.toon");
384+
git(dir, "add", "-f", ".gitignore", "docs/generated/requirements.md", "docs/generated/requirements.toon");
371385
git(dir, "commit", "-qm", "docs");
372386
}
373387
// One machine keeps every locally generated page; the other is a fresh clone.
374-
rmSync(join(withoutPages, "docs", "README.md"));
375-
rmSync(join(withoutPages, "docs", "agent"), { recursive: true, force: true });
388+
rmSync(join(withoutPages, DOCS_ROOT, "README.md"));
389+
rmSync(join(withoutPages, DOCS_ROOT, "agent"), { recursive: true, force: true });
376390

377391
const a = await runCapturingStderr(["verify", "--cwd", withPages, "--docs"]);
378392
const b = await runCapturingStderr(["verify", "--cwd", withoutPages, "--docs"]);

0 commit comments

Comments
 (0)