Skip to content

Commit bddbb9d

Browse files
SisyphusZhengDevBot
andauthored
feat(tools,www): drive www nav/locale/link/SEO truth from owned sources with fail-closed gates (#1159, B2.4) (#1305)
* feat(tools,www): drive www nav/locale/link/SEO truth from owned sources with fail-closed gates (#1159, B2.4) - tools/check-www-links.ts + tools/lib/www-links.ts: built-output internal link/fragment gate over www/dist (every internal href/src resolves to a built file; every #fragment anchors; every sitemap.xml URL resolves) plus per-page SEO invariants (exactly one <title>, non-trivial meta description, og:title). Wired into the build task so the CI build gate fails on broken links. First run found and this change fixes real broken links: CHANGELOG.md's repository-relative links are now projected onto the canonical GitHub tree at the changelog route's render seam. - tools/check-www-truth.ts: the remaining hand-maintained surfaces must mechanically agree with owned truth — _generated-nav.ts byte-identical regeneration of route meta + vite.config headerNav (read through the TS AST), headerNav hrefs resolve to scanned routes, bilingual locale availability (orphan/missing zh and byte-identical en/zh pairs fail), and the CURRENT roadmap entry names the package version tag. - tools/check-content-examples.ts: ts/tsx guide examples importing @openelement/* type-check against the real framework sources (en/zh duplicates deduped); only documented snippet elision (consumer-project imports, elided app names, implicit any, the uninferred loader-data generic) is suppressed — framework-surface errors fail closed. - AutoFlow policy: www:check-truth and content:examples-check registered (ci + release tiers); the build gate's triggers cover the link checker. Version constants and release-state agreement were already pinned by docs:check-version-anchors / release:truth:check; this composes with them instead of duplicating. Scheduled external link checks stay deferred to Beta.3 under #1156 (workflow cap 8 — no ninth workflow added). * fix(tools): create .tmp before content-example compilation (#1159) Clean CI checkouts do not carry the gitignored .tmp directory, so Deno.makeTempDir({ dir: '.tmp' }) failed closed (NotFound) in content:examples-check and its tests. Create it recursively first. --------- Co-authored-by: DevBot <devbot@openelement.dev>
1 parent f72ef28 commit bddbb9d

10 files changed

Lines changed: 852 additions & 3 deletions

deno.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"tasks": {
3939
"dev": "cd www && deno run --allow-read --allow-write --allow-net --allow-env --allow-ffi --allow-sys npm:vite --config vite.config.ts",
4040
"www:dev-smoke": "deno run --allow-net --allow-run tools/smoke-www-dev.ts",
41-
"build": "deno task generate:ui-manifest && (cd www && deno run --config ../deno.json --allow-read --allow-write --allow-net --allow-env --allow-ffi --allow-sys --allow-run ../packages/adapter-vite/src/cli/build.ts) && deno task www:pagefind && deno task www:check-artifact-truth",
41+
"build": "deno task generate:ui-manifest && (cd www && deno run --config ../deno.json --allow-read --allow-write --allow-net --allow-env --allow-ffi --allow-sys --allow-run ../packages/adapter-vite/src/cli/build.ts) && deno task www:pagefind && deno task www:check-artifact-truth && deno task www:check-links",
4242
"www:pagefind": "cd www && deno run --config ../deno.json --allow-read --allow-write --allow-run --allow-env --allow-net --allow-ffi --allow-sys build-pagefind.ts",
4343
"preview": "cd www && deno run --allow-read --allow-write --allow-net --allow-env --allow-ffi npm:vite preview --config vite.config.ts",
4444
"workflow:check": "deno run --allow-read --allow-run=git tools/check-project-workflow.ts && deno task v044:orchestration:check",
@@ -62,6 +62,9 @@
6262
"www:check-current-truth": "deno run --allow-read tools/check-docs-truth.ts --check=www",
6363
"www:check-theme-tokens": "deno run --allow-read tools/check-www-theme-tokens.ts",
6464
"www:check-artifact-truth": "deno run --allow-read tools/check-docs-truth.ts --check=www --artifacts",
65+
"www:check-links": "deno run --allow-read tools/check-www-links.ts",
66+
"www:check-truth": "deno run --allow-read --allow-env tools/check-www-truth.ts",
67+
"content:examples-check": "deno run --allow-read --allow-write --allow-env tools/check-content-examples.ts",
6568
"package-surface:check": "deno run --allow-read --allow-env tools/check-package-surface.ts",
6669
"interface:snapshot": "deno run --allow-read --allow-env tools/check-public-interface-snapshot.ts",
6770
"interface:snapshot:write": "deno run --allow-read --allow-write --allow-env tools/check-public-interface-snapshot.ts --write",

tools/autoflow/policy.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,34 @@ const GATES: readonly GateDefinition[] = [
248248
/^deno\.json$/,
249249
],
250250
},
251+
{
252+
// #1159 (B2.4): hand-maintained www surfaces must mechanically agree with
253+
// owned truth — generated nav freshness (byte-identical regeneration of
254+
// route meta + headerNav), headerNav hrefs resolve to real routes,
255+
// bilingual locale availability (no orphan/missing/duplicated-untranslated
256+
// zh), and the CURRENT roadmap entry names the package version tag.
257+
name: 'www:check-truth',
258+
command: ['deno', 'task', 'www:check-truth'],
259+
tiers: ['ci', 'release'],
260+
triggers: [
261+
/^www\/(content|app\/routes|app\/data\/_generated-nav\.ts|vite\.config\.ts)/,
262+
/^tools\/(?:check-www-truth|project-constants)/,
263+
/^deno\.json$/,
264+
],
265+
},
266+
{
267+
// #1159 (B2.4): guide/architecture code examples that import
268+
// @openelement/* must type-check against the real framework sources.
269+
name: 'content:examples-check',
270+
command: ['deno', 'task', 'content:examples-check'],
271+
tiers: ['ci', 'release'],
272+
triggers: [
273+
/^www\/content\//,
274+
/^packages\//,
275+
/^tools\/check-content-examples/,
276+
/^deno\.json$/,
277+
],
278+
},
251279
{
252280
name: 'docs:check-version-anchors',
253281
command: ['deno', 'task', 'docs:check-version-anchors'],
@@ -404,7 +432,13 @@ const GATES: readonly GateDefinition[] = [
404432
name: 'build',
405433
command: ['deno', 'task', 'build'],
406434
tiers: ['ci', 'release'],
407-
triggers: [/^(packages|www)\//, /^deno\.json$/],
435+
// #1159: the build task ends with the built-output internal
436+
// link/fragment + SEO gate (www:check-links), so checker edits rebuild.
437+
triggers: [
438+
/^(packages|www)\//,
439+
/^deno\.json$/,
440+
/^tools\/(?:check-www-links|lib\/www-links)/,
441+
],
408442
},
409443
{
410444
// Runs after build when both gates are selected. check-coverage keeps a
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/** Content example type-check gate tests (#1159). */
2+
import { assert, assertEquals } from '@std/assert';
3+
import {
4+
extractExamples,
5+
suppressElidedDiagnostic,
6+
typeCheckExamples,
7+
} from './check-content-examples.ts';
8+
import ts from 'typescript';
9+
10+
Deno.test('extractExamples: only fenced ts/tsx blocks importing @openelement', () => {
11+
const markdown = [
12+
'```ts',
13+
"import { signal } from '@openelement/element';",
14+
'const count = signal(0);',
15+
'```',
16+
'```bash',
17+
'deno task dev',
18+
'```',
19+
'```ts',
20+
"import { defineConfig } from 'vite';",
21+
'```',
22+
'```tsx',
23+
"import { OpenElement } from '@openelement/element';",
24+
'export class X extends OpenElement {}',
25+
'```',
26+
].join('\n');
27+
const examples = extractExamples('guide/x.md', markdown);
28+
assertEquals(examples.length, 2);
29+
assertEquals(examples[0].lang, 'ts');
30+
assertEquals(examples[1].lang, 'tsx');
31+
});
32+
33+
Deno.test('typeCheckExamples: framework-surface errors fail closed (RED proof)', async () => {
34+
const failures = await typeCheckExamples([
35+
{
36+
file: 'fixture.md',
37+
index: 0,
38+
lang: 'ts',
39+
code: "import { noSuchExport } from '@openelement/element';\nconsole.log(noSuchExport);",
40+
},
41+
]);
42+
assertEquals(failures.length, 1);
43+
assert(failures[0].message.includes('TS2305'), failures[0].message);
44+
});
45+
46+
Deno.test('typeCheckExamples: unknown @openelement module fails closed', async () => {
47+
const failures = await typeCheckExamples([
48+
{
49+
file: 'fixture.md',
50+
index: 0,
51+
lang: 'ts',
52+
code: "import { x } from '@openelement/no-such-package';\nconsole.log(x);",
53+
},
54+
]);
55+
assertEquals(failures.length, 1);
56+
assert(failures[0].message.includes('TS2307'), failures[0].message);
57+
});
58+
59+
Deno.test('typeCheckExamples: elided consumer context is tolerated, real API checks apply', async () => {
60+
const failures = await typeCheckExamples([
61+
{
62+
file: 'fixture.md',
63+
index: 0,
64+
lang: 'ts',
65+
code: [
66+
"import { signal } from '@openelement/element';",
67+
"import GuestbookPage from '../components/page-guestbook.tsx'; // consumer file, elided",
68+
'const count = signal(0);',
69+
'count.value += 1;',
70+
'console.log(GuestbookPage, missingAppHelper());',
71+
'',
72+
].join('\n'),
73+
},
74+
]);
75+
assertEquals(failures, []);
76+
});
77+
78+
Deno.test('suppressElidedDiagnostic: suppression boundary is exact', () => {
79+
const make = (code: number, messageText: string): ts.Diagnostic => ({
80+
file: undefined,
81+
start: 0,
82+
length: 0,
83+
code,
84+
messageText,
85+
category: ts.DiagnosticCategory.Error,
86+
source: undefined,
87+
});
88+
assertEquals(suppressElidedDiagnostic(make(2307, "Cannot find module 'vite'.")), true);
89+
assertEquals(
90+
suppressElidedDiagnostic(make(2307, "Cannot find module '@openelement/generated/blog-data'.")),
91+
true,
92+
);
93+
assertEquals(
94+
suppressElidedDiagnostic(make(2307, "Cannot find module '@openelement/element'.")),
95+
false,
96+
);
97+
assertEquals(suppressElidedDiagnostic(make(2304, "Cannot find name 'listEntries'.")), true);
98+
assertEquals(
99+
suppressElidedDiagnostic(make(2339, "Property 'entries' does not exist on type '{}'.")),
100+
true,
101+
);
102+
assertEquals(
103+
suppressElidedDiagnostic(make(2339, "Property 'x' does not exist on type 'OpenElement'.")),
104+
false,
105+
);
106+
assertEquals(suppressElidedDiagnostic(make(2345, 'Argument of type ...')), false);
107+
});

tools/check-content-examples.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/**
2+
* Content example type-check gate (#1159, B2.4): TypeScript/TSX fenced code
3+
* blocks in the authored bilingual guides that import `@openelement/*` must
4+
* type-check against the real v0.44 framework sources. zh duplicates of an
5+
* en block are deduped by content. Fails closed with compiler diagnostics.
6+
*
7+
* Elision convention: guide snippets are written as consumer-project modules
8+
* and legitimately omit application context. The harness therefore suppresses
9+
* only the diagnostics that express that elision — unresolved non-framework
10+
* module specifiers (TS2307 outside `@openelement/*`; the virtual
11+
* `@openelement/generated/*` namespace is adapter-generated consumer code),
12+
* undefined names from elided app code (TS2304), implicit-any (TS7006) and
13+
* property access on the uninferred loader-data generic (TS2339 on `{}`).
14+
* Everything on the framework surface — unknown `@openelement` modules or
15+
* exports, argument/assignability errors against real APIs, syntax and JSX
16+
* errors — fails the gate.
17+
*/
18+
import ts from 'typescript';
19+
import { walk } from '@std/fs/walk';
20+
import { readPackages } from './lib/package-graph.ts';
21+
22+
export interface ExampleFailure {
23+
file: string;
24+
message: string;
25+
}
26+
27+
export interface ContentExample {
28+
/** First source document the block was found in. */
29+
file: string;
30+
index: number;
31+
lang: string;
32+
code: string;
33+
}
34+
35+
const FENCE_PATTERN = /```(ts|tsx)\n([\s\S]*?)```/g;
36+
37+
/** Extract unique ts/tsx fenced blocks that import @openelement packages. */
38+
export function extractExamples(file: string, markdown: string): ContentExample[] {
39+
const examples: ContentExample[] = [];
40+
for (const match of markdown.matchAll(FENCE_PATTERN)) {
41+
const code = match[2];
42+
if (!code.includes('@openelement/')) continue;
43+
examples.push({ file, index: examples.length, lang: match[1], code });
44+
}
45+
return examples;
46+
}
47+
48+
/** Build a paths map resolving workspace @openelement/* specifiers to files. */
49+
export async function workspacePaths(): Promise<Record<string, string[]>> {
50+
const paths: Record<string, string[]> = {};
51+
for (const pkg of await readPackages()) {
52+
const entries = typeof pkg.exports === 'string'
53+
? { '.': pkg.exports }
54+
: (pkg.exports ?? {}) as Record<string, string>;
55+
for (const [key, target] of Object.entries(entries)) {
56+
if (typeof target !== 'string') continue;
57+
const specifier = key === '.' ? pkg.name : `${pkg.name}/${key.replace(/^\.\//, '')}`;
58+
paths[specifier] = [`${pkg.dir}/${target.replace(/^\.\//, '')}`];
59+
}
60+
}
61+
return paths;
62+
}
63+
64+
/**
65+
* Type-check the given example blocks against the real framework. Each block
66+
* is compiled as its own module in one shared program so diagnostics carry
67+
* the example's virtual file name.
68+
*/
69+
export async function typeCheckExamples(examples: ContentExample[]): Promise<ExampleFailure[]> {
70+
if (examples.length === 0) return [];
71+
// The temp dir must live inside the workspace so node_modules resolution
72+
// (vite, preact, ...) walks up to the repo's dependencies; `.tmp` is
73+
// gitignored, so create it first (clean CI checkouts do not carry it).
74+
await Deno.mkdir('.tmp', { recursive: true });
75+
const dir = await Deno.makeTempDir({ dir: '.tmp', prefix: 'content-examples-' });
76+
try {
77+
const files: string[] = [];
78+
for (const [index, example] of examples.entries()) {
79+
const name = `example-${index}.${example.lang}`;
80+
await Deno.writeTextFile(`${dir}/${name}`, example.code);
81+
files.push(`${dir}/${name}`);
82+
}
83+
const paths = await workspacePaths();
84+
const options: ts.CompilerOptions = {
85+
allowImportingTsExtensions: true,
86+
experimentalDecorators: true,
87+
jsx: ts.JsxEmit.ReactJSX,
88+
jsxImportSource: '@openelement/element',
89+
module: ts.ModuleKind.ESNext,
90+
moduleResolution: ts.ModuleResolutionKind.Bundler,
91+
noEmit: true,
92+
skipLibCheck: true,
93+
strict: true,
94+
target: ts.ScriptTarget.ESNext,
95+
baseUrl: Deno.cwd(),
96+
paths,
97+
// Doc snippets are teaching material, not shippable modules: unused
98+
// locals and missing return-type annotations are not defects there.
99+
noUnusedLocals: false,
100+
noUnusedParameters: false,
101+
};
102+
const program = ts.createProgram(files, options);
103+
const failures: ExampleFailure[] = [];
104+
for (const [index, file] of files.entries()) {
105+
const source = program.getSourceFile(file);
106+
if (!source) {
107+
failures.push({ file: examples[index].file, message: 'example failed to parse' });
108+
continue;
109+
}
110+
const diagnostics = [
111+
...program.getSyntacticDiagnostics(source),
112+
...program.getSemanticDiagnostics(source),
113+
];
114+
for (const diagnostic of diagnostics) {
115+
if (suppressElidedDiagnostic(diagnostic)) continue;
116+
const position = diagnostic.file?.getLineAndCharacterOfPosition(diagnostic.start ?? 0);
117+
failures.push({
118+
file: `${examples[index].file} (block ${examples[index].index + 1})`,
119+
message: `TS${diagnostic.code} at line ${(position?.line ?? 0) + 1}: ${
120+
ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ')
121+
}`,
122+
});
123+
}
124+
}
125+
return failures;
126+
} finally {
127+
await Deno.remove(dir, { recursive: true });
128+
}
129+
}
130+
131+
/**
132+
* Elided-context suppression (see the module header): returns true only for
133+
* diagnostics that express documented snippet elision, never framework-surface
134+
* errors.
135+
*/
136+
export function suppressElidedDiagnostic(diagnostic: ts.Diagnostic): boolean {
137+
const text = ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ');
138+
if (diagnostic.code === 2307) {
139+
// Unresolved module: only framework modules are harness truth. The
140+
// `@openelement/generated/*` namespace is adapter-emitted consumer code.
141+
if (/Cannot find module '@openelement\/(?!generated\/)/.test(text)) return false;
142+
return true;
143+
}
144+
// Undefined names from elided application code.
145+
if (diagnostic.code === 2304) return true;
146+
// Implicit-any in teaching snippets.
147+
if (diagnostic.code === 7006) return true;
148+
// Property access on the uninferred loader-data generic (`{}`).
149+
if (diagnostic.code === 2339 && text.includes(`on type '{}'`)) return true;
150+
return false;
151+
}
152+
153+
export async function checkContentExamples(): Promise<ExampleFailure[]> {
154+
const seen = new Set<string>();
155+
const examples: ContentExample[] = [];
156+
for (const dir of ['www/content/guide', 'www/content/architecture']) {
157+
for await (const entry of walk(dir, { includeDirs: false, exts: ['.md'] })) {
158+
const markdown = await Deno.readTextFile(entry.path);
159+
for (const example of extractExamples(entry.path, markdown)) {
160+
// en/zh translations carry identical code — check each block once.
161+
if (seen.has(example.code)) continue;
162+
seen.add(example.code);
163+
examples.push(example);
164+
}
165+
}
166+
}
167+
return await typeCheckExamples(examples);
168+
}
169+
170+
if (import.meta.main) {
171+
const failures = await checkContentExamples();
172+
if (failures.length > 0) {
173+
console.error('Content example type-check failed:');
174+
for (const failure of failures) {
175+
console.error(`- ${failure.file}: ${failure.message}`);
176+
}
177+
Deno.exit(1);
178+
}
179+
console.log('Content example type-check passed.');
180+
}

0 commit comments

Comments
 (0)