Skip to content

Commit 912136a

Browse files
os-zhuangclaude
andcommitted
feat(cli): lint ```metadata doc embeds — body shape + reference liveness (ADR-0051 P1)
Completes the ADR-0051 P1 publish lint. `collectAndLintDocs` (run by `os compile` / `os lint`, which already pass the assembled stack) now also validates every ```metadata fence: - body shape: `type` ∈ {state_machine, flow, permission} (did-you-mean on a typo'd type), `name` required, `object` required for state_machine. - reference liveness against the package's OWN metadata: the object + named state_machine rule, the flow, or the permission set must exist in this stack. A dead same-package reference is a build error (same posture as docs/broken-link), with did-you-mean hints. Cross-package embeds are out of v1 scope. Note: scans share `stripCode()` which DELETES fenced blocks, so the embed scan walks raw lines instead. The body parser mirrors the objectui-side one so build-time and render-time agree. Tests: 8 cases (valid trio, unknown type, missing name/object, dead object/rule/flow/permission refs, non-metadata fences ignored, locale variants). Verified the real app-showcase doc lints clean (no false positives) with a typo'd-ref negative control. No CLI build fix was needed: a clean dependency-ordered build (`turbo run build --filter=@objectstack/cli`, 52/52) passes; the previously-seen `validate-expressions.ts` / adr-0048-test errors were stale/unbuilt local `dist`, not a code defect. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 13a1f97 commit 912136a

2 files changed

Lines changed: 211 additions & 2 deletions

File tree

packages/cli/src/utils/collect-docs.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,69 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
44
import fs from 'fs';
55
import os from 'os';
66
import path from 'path';
7-
import { collectDocsFromSrc, lintDocs, collectAndLintDocs, type DocItem } from './collect-docs.js';
7+
import { collectDocsFromSrc, lintDocs, collectAndLintDocs, lintMetadataEmbeds, type DocItem } from './collect-docs.js';
8+
9+
// ── ADR-0051: ```metadata embed lint (body shape + reference liveness) ──────
10+
describe('lintMetadataEmbeds (ADR-0051)', () => {
11+
const FENCE = '```';
12+
const embed = (body: string) => [`${FENCE}metadata`, body, FENCE].join('\n');
13+
const STACK = {
14+
manifest: { namespace: 'crm' },
15+
objects: [{ name: 'crm_lead', validations: [{ type: 'state_machine', name: 'crm_lead_stage' }] }],
16+
flows: [{ name: 'crm_onboard' }],
17+
permissions: [{ name: 'crm_rep' }],
18+
};
19+
const lintOne = (content: string) => lintMetadataEmbeds([{ name: 'crm_guide', content } as DocItem], STACK);
20+
21+
it('passes valid state_machine / flow / permission embeds', () => {
22+
const content = [
23+
embed('type: state_machine\nobject: crm_lead\nname: crm_lead_stage'),
24+
embed('type: flow\nname: crm_onboard'),
25+
embed('type: permission\nname: crm_rep'),
26+
].join('\n\n');
27+
expect(lintOne(content)).toHaveLength(0);
28+
});
29+
30+
it('flags an unknown type with a did-you-mean', () => {
31+
const issues = lintOne(embed('type: state_machien\nobject: crm_lead\nname: crm_lead_stage'));
32+
expect(issues).toHaveLength(1);
33+
expect(issues[0].rule).toBe('docs/metadata-embed');
34+
expect(issues[0].message).toMatch(/did you mean .?state_machine/);
35+
});
36+
37+
it('flags a missing name and a missing object', () => {
38+
expect(lintOne(embed('type: flow'))[0].message).toMatch(/missing `name`/);
39+
expect(lintOne(embed('type: state_machine\nname: crm_lead_stage'))[0].message).toMatch(/missing `object`/);
40+
});
41+
42+
it('flags a dead object reference with a did-you-mean', () => {
43+
const issues = lintOne(embed('type: state_machine\nobject: crm_leed\nname: crm_lead_stage'));
44+
expect(issues[0].rule).toBe('docs/metadata-embed-ref');
45+
expect(issues[0].message).toMatch(/crm_leed.*did you mean .?crm_lead/);
46+
});
47+
48+
it('flags a state_machine rule name absent on a real object', () => {
49+
const issues = lintOne(embed('type: state_machine\nobject: crm_lead\nname: crm_lead_stge'));
50+
expect(issues[0].rule).toBe('docs/metadata-embed-ref');
51+
expect(issues[0].message).toMatch(/did you mean .?crm_lead_stage/);
52+
});
53+
54+
it('flags dead flow and permission references', () => {
55+
expect(lintOne(embed('type: flow\nname: nope'))[0].rule).toBe('docs/metadata-embed-ref');
56+
expect(lintOne(embed('type: permission\nname: nope'))[0].rule).toBe('docs/metadata-embed-ref');
57+
});
58+
59+
it('ignores non-metadata fenced blocks', () => {
60+
expect(lintOne('```ts\nconst x = 1\n```\n\n```mermaid\nA-->B\n```')).toHaveLength(0);
61+
});
62+
63+
it('checks embeds inside locale variants', () => {
64+
const doc: DocItem = { name: 'crm_guide', content: 'ok', translations: { zh: { content: embed('type: flow\nname: nope') } } };
65+
const issues = lintMetadataEmbeds([doc], STACK);
66+
expect(issues).toHaveLength(1);
67+
expect(issues[0].path).toMatch(/zh/);
68+
});
69+
});
870

971
let tmp: string;
1072
let configPath: string;

packages/cli/src/utils/collect-docs.ts

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,153 @@ export function lintDocs(docs: DocItem[], namespace: string | undefined): DocIss
332332
return issues;
333333
}
334334

335+
// ── Inline metadata views (ADR-0051): ```metadata fences ────────────────────
336+
337+
type AnyRec = Record<string, unknown>;
338+
339+
const METADATA_EMBED_TYPES = ['state_machine', 'flow', 'permission'] as const;
340+
341+
/** Parse a flat `key: value` ```metadata fence body (data, not code). Mirrors
342+
* the objectui-side parser so build-time validation matches render-time. */
343+
function parseMetadataFenceBody(src: string): Record<string, string> {
344+
const out: Record<string, string> = {};
345+
for (const raw of src.split('\n')) {
346+
const line = raw.trim();
347+
if (!line || line.startsWith('#')) continue;
348+
const i = line.indexOf(':');
349+
if (i < 1) continue;
350+
const key = line.slice(0, i).trim();
351+
let value = line.slice(i + 1).trim();
352+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
353+
value = value.slice(1, -1);
354+
}
355+
if (key) out[key] = value;
356+
}
357+
return out;
358+
}
359+
360+
/** Extract each ```metadata fenced block's raw body from Markdown. NOTE: the
361+
* other content scans use stripCode(), which DELETES fenced blocks — so this
362+
* walks the raw lines instead. */
363+
function extractMetadataFences(content: string): string[] {
364+
const bodies: string[] = [];
365+
const lines = content.split('\n');
366+
for (let i = 0; i < lines.length; i++) {
367+
if (/^\s*```\s*metadata\b/.test(lines[i])) {
368+
const body: string[] = [];
369+
i++;
370+
while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) body.push(lines[i++]);
371+
bodies.push(body.join('\n'));
372+
}
373+
}
374+
return bodies;
375+
}
376+
377+
function levenshtein(a: string, b: string): number {
378+
const dp: number[] = Array.from({ length: b.length + 1 }, (_, j) => j);
379+
for (let i = 1; i <= a.length; i++) {
380+
let prev = dp[0];
381+
dp[0] = i;
382+
for (let j = 1; j <= b.length; j++) {
383+
const tmp = dp[j];
384+
dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
385+
prev = tmp;
386+
}
387+
}
388+
return dp[b.length];
389+
}
390+
391+
/** Closest candidate within a small edit distance, for did-you-mean hints. */
392+
function nearest(name: string, candidates: string[]): string | undefined {
393+
let best: string | undefined;
394+
let bestD = Infinity;
395+
for (const c of candidates) {
396+
const d = levenshtein(name, c);
397+
if (d < bestD) { bestD = d; best = c; }
398+
}
399+
return best !== undefined && bestD <= Math.max(2, Math.floor(name.length / 3)) ? best : undefined;
400+
}
401+
402+
/**
403+
* Lint ```metadata embeds (ADR-0051): body shape + reference liveness against
404+
* the package's OWN metadata. A broken embed degrades to a placeholder at
405+
* render time (never crashes), but a dead same-package reference is a build
406+
* error here — the same posture as `docs/broken-link`. Cross-package embeds
407+
* are out of v1 scope, so every reference must resolve within this stack.
408+
*/
409+
export function lintMetadataEmbeds(docs: DocItem[], stack: Record<string, unknown>): DocIssue[] {
410+
const issues: DocIssue[] = [];
411+
const objects = Array.isArray(stack.objects) ? (stack.objects as AnyRec[]) : [];
412+
const flows = Array.isArray(stack.flows) ? (stack.flows as AnyRec[]) : [];
413+
const permissions = Array.isArray(stack.permissions) ? (stack.permissions as AnyRec[]) : [];
414+
const objByName = new Map(objects.map((o) => [String(o?.name), o]));
415+
const flowNames = flows.map((f) => String(f?.name));
416+
const permNames = permissions.map((p) => String(p?.name));
417+
418+
const scan = (docName: string, content: string, locale?: string): void => {
419+
const where = `docs/${docName}${locale ? ` (${locale})` : ''}`;
420+
const err = (rule: string, message: string) => issues.push({ severity: 'error', rule, message, path: where });
421+
422+
for (const body of extractMetadataFences(content)) {
423+
const f = parseMetadataFenceBody(body);
424+
const type = f.type;
425+
426+
// ── body shape ──
427+
if (!type) {
428+
err('docs/metadata-embed', `metadata embed in "${docName}" is missing \`type\` (one of ${METADATA_EMBED_TYPES.join(', ')}).`);
429+
continue;
430+
}
431+
if (!(METADATA_EMBED_TYPES as readonly string[]).includes(type)) {
432+
const s = nearest(type, METADATA_EMBED_TYPES as unknown as string[]);
433+
err('docs/metadata-embed', `metadata embed in "${docName}" has unknown type "${type}"${s ? ` — did you mean \`${s}\`?` : ` (expected ${METADATA_EMBED_TYPES.join(', ')})`}.`);
434+
continue;
435+
}
436+
if (!f.name) {
437+
err('docs/metadata-embed', `metadata embed (${type}) in "${docName}" is missing \`name\`.`);
438+
continue;
439+
}
440+
441+
// ── reference liveness (same-package) ──
442+
if (type === 'state_machine') {
443+
if (!f.object) {
444+
err('docs/metadata-embed', `state_machine embed "${f.name}" in "${docName}" is missing \`object\` (a state machine is a rule on an object).`);
445+
continue;
446+
}
447+
const obj = objByName.get(f.object);
448+
if (!obj) {
449+
const s = nearest(f.object, [...objByName.keys()]);
450+
err('docs/metadata-embed-ref', `state_machine embed in "${docName}" references object "${f.object}", which does not exist in this package${s ? ` — did you mean \`${s}\`?` : ''}.`);
451+
continue;
452+
}
453+
const rules = obj.validations ?? obj.validationRules;
454+
const smNames = (Array.isArray(rules) ? (rules as AnyRec[]) : [])
455+
.filter((r) => r?.type === 'state_machine')
456+
.map((r) => String(r?.name));
457+
if (!smNames.includes(f.name)) {
458+
const s = nearest(f.name, smNames);
459+
err('docs/metadata-embed-ref', `state_machine embed in "${docName}" references "${f.name}", but object "${f.object}" has no state_machine rule with that name${s ? ` — did you mean \`${s}\`?` : ''}.`);
460+
}
461+
} else if (type === 'flow') {
462+
if (!flowNames.includes(f.name)) {
463+
const s = nearest(f.name, flowNames);
464+
err('docs/metadata-embed-ref', `flow embed in "${docName}" references flow "${f.name}", which does not exist in this package${s ? ` — did you mean \`${s}\`?` : ''}.`);
465+
}
466+
} else if (type === 'permission') {
467+
if (!permNames.includes(f.name)) {
468+
const s = nearest(f.name, permNames);
469+
err('docs/metadata-embed-ref', `permission embed in "${docName}" references permission set "${f.name}", which does not exist in this package${s ? ` — did you mean \`${s}\`?` : ''}.`);
470+
}
471+
}
472+
}
473+
};
474+
475+
for (const doc of docs) {
476+
scan(doc.name, doc.content);
477+
for (const [locale, v] of Object.entries(doc.translations ?? {})) scan(doc.name, v.content, locale);
478+
}
479+
return issues;
480+
}
481+
335482
/**
336483
* One-call entry for `os build`: collect `src/docs/*.md`, merge with the
337484
* stack's inline `docs`, and lint the combined set. Returns the merged
@@ -346,6 +493,6 @@ export function collectAndLintDocs(
346493
const collected = collectDocsFromSrc(configPath);
347494
const namespace = (stack.manifest as { namespace?: string } | undefined)?.namespace;
348495
const docs = [...inline, ...collected.docs];
349-
const issues = [...collected.issues, ...lintDocs(docs, namespace)];
496+
const issues = [...collected.issues, ...lintDocs(docs, namespace), ...lintMetadataEmbeds(docs, stack)];
350497
return { docs, issues };
351498
}

0 commit comments

Comments
 (0)