Skip to content

Commit 1ada658

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(docs-metadata): ADR-0046 — package docs as flat Markdown metadata (#1789)
Revised ADR-0046 (simplified from the directory/docSet design) plus the full P1 framework implementation: - spec: DocSchema { name, label?, content } (system/), StackDefinition.docs, 'doc' metadata type + registry entry (inert data, runtime-creatable for AI drafting) + canonical schema map, docs→doc plural mapping - cli: os build compiles flat src/docs/*.md into docs[] (frontmatter title / first # heading → label) and enforces the ADR lint: flat directory, namespace-prefixed snake_case names, namespace required when docs ship, MDX/image ban, same-package relative-link resolution; same rules wired into os lint - objectql: docs joins the generic metadata registration loop (manifest + nested plugins) - runtime: docs count as app payload; GET /metadata/doc list omits content by default (?include=content opts in) so unbounded manuals stay off hot paths - examples/app-todo: two cross-referencing docs as a living sample Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b1c6ec9 commit 1ada658

18 files changed

Lines changed: 783 additions & 201 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/cli": minor
4+
"@objectstack/objectql": minor
5+
"@objectstack/runtime": patch
6+
---
7+
8+
ADR-0046 P1: package documentation as metadata. New `doc` metadata element — flat Markdown files under `src/docs/*.md` compile into `docs: DocSchema[]` on the stack and register like any other metadata.
9+
10+
- spec: `DocSchema` ({ name, label?, content }) in `system/`, `StackDefinition.docs`, `doc` in `MetadataTypeSchema` + type registry (inert data, runtime-creatable) + canonical schema map, `docs → doc` plural mapping.
11+
- cli: `os build` collects flat `src/docs/*.md` (frontmatter `title:`/first `#` heading → label) and enforces the ADR lint — flat directory, namespace-prefixed snake_case names, namespace required when docs ship, MDX/image ban, same-package relative-link resolution. Same rules surface in `os lint`.
12+
- objectql: `docs` joins the generic metadata registration loop (manifest + nested plugins).
13+
- runtime: docs count as app payload; `GET /metadata/doc` list responses omit `content` by default (`?include=content` opts in) so unbounded manuals stay off hot paths.

docs/adr/0046-package-docs-as-metadata.md

Lines changed: 195 additions & 199 deletions
Large diffs are not rendered by default.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Todo App
2+
3+
A minimal task-management app demonstrating the ObjectStack metadata
4+
protocol end to end: objects, views, flows, agents — and this manual,
5+
which ships inside the package as `doc` metadata (ADR-0046).
6+
7+
Each Markdown file in the flat `src/docs/` directory compiles into one
8+
`doc` item at build time; the console renders it at `/docs/<name>`.
9+
10+
To learn how to work with tasks day to day, see the
11+
[user guide](./todo_user_guide.md).
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
title: User Guide
3+
---
4+
5+
# Working with Tasks
6+
7+
Create a task from the **Tasks** list view, set a priority, and assign
8+
it to a project. Completed tasks are archived automatically by the
9+
cleanup flow.
10+
11+
Tips:
12+
13+
- Use the kanban view to drag tasks between statuses.
14+
- Overdue tasks are highlighted in the default list view.
15+
16+
Back to the [overview](./todo_index.md).

packages/cli/src/commands/compile.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { loadConfig } from '../utils/config.js';
1010
import { lowerCallables } from '../utils/lower-callables.js';
1111
import { validateStackExpressions } from '../utils/validate-expressions.js';
1212
import { validateWidgetBindings } from '../utils/validate-widget-bindings.js';
13+
import { collectAndLintDocs } from '../utils/collect-docs.js';
1314
import { buildRuntimeBundle, cleanupOldRuntimeBundles } from '../utils/build-runtime.js';
1415
import {
1516
printHeader,
@@ -181,6 +182,36 @@ export default class Compile extends Command {
181182
}
182183
}
183184

185+
// 3d. Package docs (ADR-0046): compile flat `src/docs/*.md` into
186+
// `docs: DocSchema[]` and lint the combined set (flatness,
187+
// namespace-prefixed names, MDX/image ban, same-package link
188+
// resolution). Errors fail the build — the artifact is the
189+
// publish unit, so this IS the publish lint for docs.
190+
if (!flags.json) printStep('Collecting package docs (ADR-0046)...');
191+
const docsResult = collectAndLintDocs(absolutePath, result.data as Record<string, unknown>);
192+
const docErrors = docsResult.issues.filter((i) => i.severity === 'error');
193+
const docWarnings = docsResult.issues.filter((i) => i.severity === 'warning');
194+
if (docErrors.length > 0) {
195+
if (flags.json) {
196+
console.log(JSON.stringify({ success: false, error: 'docs validation failed', issues: docErrors }));
197+
this.exit(1);
198+
}
199+
console.log('');
200+
printError(`Package docs validation failed (${docErrors.length} issue${docErrors.length > 1 ? 's' : ''})`);
201+
for (const i of docErrors.slice(0, 50)) {
202+
console.log(` • ${i.path}: ${i.message}`);
203+
console.log(chalk.dim(` rule: ${i.rule}`));
204+
}
205+
this.exit(1);
206+
}
207+
if (docWarnings.length > 0 && !flags.json) {
208+
console.log('');
209+
for (const w of docWarnings) {
210+
printWarning(`${w.path}: ${w.message}`);
211+
console.log(chalk.dim(` rule: ${w.rule}`));
212+
}
213+
}
214+
184215
// 4. Generate Artifact
185216
if (!flags.json) printStep('Writing artifact...');
186217
const output = flags.output!;
@@ -192,6 +223,9 @@ export default class Compile extends Command {
192223
}
193224

194225
const finalBundle: Record<string, unknown> = { ...(result.data as Record<string, unknown>) };
226+
if (docsResult.docs.length > 0) {
227+
finalBundle.docs = docsResult.docs;
228+
}
195229

196230
// 4b. Bundle handler functions into `<artifactDir>/objectstack-runtime.{hash}.mjs`
197231
// and stamp the relative path into the JSON so the runtime can

packages/cli/src/commands/lint.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';
88
import { computeI18nCoverage } from '../utils/i18n-coverage.js';
99
import { lintDataModel } from '../lint/data-model-rules.js';
1010
import { validateWidgetBindings } from '../utils/validate-widget-bindings.js';
11+
import { collectAndLintDocs } from '../utils/collect-docs.js';
1112
import { scoreMetadata } from '../lint/score.js';
1213
import { runMetadataEval } from '../lint/metadata-eval.js';
1314
import { DEFAULT_METADATA_EVAL_CORPUS } from '../lint/corpus.js';
@@ -303,6 +304,13 @@ export default class Lint extends Command {
303304
const normalized = normalizeStackInput(config as Record<string, unknown>);
304305
const issues = lintConfig(normalized);
305306

307+
// ── Package docs (ADR-0046) ── collected src/docs/*.md + inline docs:
308+
// flatness, namespace-prefixed names, MDX/image ban, link resolution.
309+
const docsResult = collectAndLintDocs(absolutePath, normalized as Record<string, unknown>);
310+
for (const d of docsResult.issues) {
311+
issues.push({ severity: d.severity, rule: d.rule, message: d.message, path: d.path });
312+
}
313+
306314
// ── Translation coverage ──
307315
if (!flags['skip-i18n']) {
308316
const coverage = computeI18nCoverage(normalized, {
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
4+
import fs from 'fs';
5+
import os from 'os';
6+
import path from 'path';
7+
import { collectDocsFromSrc, lintDocs, collectAndLintDocs, type DocItem } from './collect-docs.js';
8+
9+
let tmp: string;
10+
let configPath: string;
11+
let docsDir: string;
12+
13+
beforeEach(() => {
14+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'os-docs-'));
15+
configPath = path.join(tmp, 'objectstack.config.ts');
16+
fs.writeFileSync(configPath, '// stub');
17+
docsDir = path.join(tmp, 'src', 'docs');
18+
fs.mkdirSync(docsDir, { recursive: true });
19+
});
20+
21+
afterEach(() => {
22+
fs.rmSync(tmp, { recursive: true, force: true });
23+
});
24+
25+
const write = (name: string, content: string) => fs.writeFileSync(path.join(docsDir, name), content);
26+
27+
describe('collectDocsFromSrc (ADR-0046 §3.2)', () => {
28+
it('compiles each flat .md file into a doc item (stem = name)', () => {
29+
write('crm_index.md', '# CRM Overview\n\nWhat it is.');
30+
write('crm_lead_guide.md', '---\ntitle: Lead Guide\n---\n\nBody here.');
31+
const { docs, issues } = collectDocsFromSrc(configPath);
32+
expect(issues).toHaveLength(0);
33+
expect(docs.map((d) => d.name).sort()).toEqual(['crm_index', 'crm_lead_guide']);
34+
const index = docs.find((d) => d.name === 'crm_index')!;
35+
expect(index.label).toBe('CRM Overview'); // first # heading
36+
const guide = docs.find((d) => d.name === 'crm_lead_guide')!;
37+
expect(guide.label).toBe('Lead Guide'); // frontmatter title wins
38+
expect(guide.content).not.toContain('title:'); // frontmatter stripped
39+
});
40+
41+
it('errors on subdirectories — flatness is the contract', () => {
42+
fs.mkdirSync(path.join(docsDir, 'user'));
43+
write('crm_index.md', '# x');
44+
const { docs, issues } = collectDocsFromSrc(configPath);
45+
expect(issues.some((i) => i.rule === 'docs/flat-directory' && i.severity === 'error')).toBe(true);
46+
expect(docs).toHaveLength(1); // the valid file still collects
47+
});
48+
49+
it('errors on non-snake_case filename stems', () => {
50+
write('Lead-Guide.md', '# x');
51+
const { docs, issues } = collectDocsFromSrc(configPath);
52+
expect(issues.some((i) => i.rule === 'docs/filename')).toBe(true);
53+
expect(docs).toHaveLength(0);
54+
});
55+
56+
it('ignores non-markdown files and returns empty when src/docs is absent', () => {
57+
write('notes.txt', 'not a doc');
58+
expect(collectDocsFromSrc(configPath).docs).toHaveLength(0);
59+
fs.rmSync(docsDir, { recursive: true });
60+
expect(collectDocsFromSrc(configPath).docs).toHaveLength(0);
61+
});
62+
});
63+
64+
describe('lintDocs (ADR-0046 §3.2–§3.4)', () => {
65+
const doc = (name: string, content: string): DocItem => ({ name, content });
66+
67+
it('requires manifest.namespace when docs ship', () => {
68+
const issues = lintDocs([doc('crm_index', 'x')], undefined);
69+
expect(issues.some((i) => i.rule === 'docs/namespace-required')).toBe(true);
70+
});
71+
72+
it('requires the namespace prefix on every doc name', () => {
73+
const issues = lintDocs([doc('lead_guide', 'x')], 'crm');
74+
const hit = issues.find((i) => i.rule === 'docs/namespace-prefix');
75+
expect(hit?.severity).toBe('error');
76+
expect(hit?.message).toContain('crm_lead_guide');
77+
});
78+
79+
it('rejects duplicate names across inline + collected docs', () => {
80+
const issues = lintDocs([doc('crm_index', 'a'), doc('crm_index', 'b')], 'crm');
81+
expect(issues.some((i) => i.rule === 'docs/duplicate-name')).toBe(true);
82+
});
83+
84+
it('bans image references (v1 text-only)', () => {
85+
const issues = lintDocs([doc('crm_index', 'See ![screenshot](https://x/y.png)')], 'crm');
86+
expect(issues.some((i) => i.rule === 'docs/no-images')).toBe(true);
87+
});
88+
89+
it('bans MDX/JSX but tolerates code blocks that mention it', () => {
90+
expect(
91+
lintDocs([doc('crm_a', 'Use <Tabs items={x}> here')], 'crm')
92+
.some((i) => i.rule === 'docs/no-mdx'),
93+
).toBe(true);
94+
expect(
95+
lintDocs([doc('crm_b', 'Example:\n\n```jsx\n<Tabs items={x} />\n```\n\nplain prose')], 'crm')
96+
.some((i) => i.rule === 'docs/no-mdx'),
97+
).toBe(false);
98+
});
99+
100+
it('resolves same-package relative links and flags broken ones', () => {
101+
const docs = [
102+
doc('crm_index', 'See the [guide](./crm_lead_guide.md#start) and [missing](./crm_nope.md).'),
103+
doc('crm_lead_guide', 'Back to [index](crm_index.md).'),
104+
];
105+
const issues = lintDocs(docs, 'crm');
106+
const broken = issues.filter((i) => i.rule === 'docs/broken-link');
107+
expect(broken).toHaveLength(1);
108+
expect(broken[0].message).toContain('crm_nope');
109+
});
110+
111+
it('leaves cross-package links (foreign prefix) to publish-time checks', () => {
112+
const issues = lintDocs([doc('crm_index', 'See [billing](./billing_setup.md).')], 'crm');
113+
expect(issues.some((i) => i.rule === 'docs/broken-link')).toBe(false);
114+
});
115+
});
116+
117+
describe('collectAndLintDocs', () => {
118+
it('merges inline stack docs with collected files and lints the union', () => {
119+
write('crm_admin_setup.md', '# Admin Setup\n\nSee [index](./crm_index.md).');
120+
const stack = {
121+
manifest: { namespace: 'crm' },
122+
docs: [{ name: 'crm_index', content: '# CRM' }],
123+
};
124+
const { docs, issues } = collectAndLintDocs(configPath, stack);
125+
expect(docs.map((d) => d.name).sort()).toEqual(['crm_admin_setup', 'crm_index']);
126+
expect(issues).toHaveLength(0);
127+
});
128+
});

0 commit comments

Comments
 (0)