Skip to content

Commit 5e88099

Browse files
author
DevBot
committed
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).
1 parent 06454c2 commit 5e88099

10 files changed

Lines changed: 850 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
@@ -208,6 +208,34 @@ const GATES: readonly GateDefinition[] = [
208208
tiers: ['ci', 'release'],
209209
triggers: [/^www\//, /^docs\//, /^tools\/project-constants\.ts$/],
210210
},
211+
{
212+
// #1159 (B2.4): hand-maintained www surfaces must mechanically agree with
213+
// owned truth — generated nav freshness (byte-identical regeneration of
214+
// route meta + headerNav), headerNav hrefs resolve to real routes,
215+
// bilingual locale availability (no orphan/missing/duplicated-untranslated
216+
// zh), and the CURRENT roadmap entry names the package version tag.
217+
name: 'www:check-truth',
218+
command: ['deno', 'task', 'www:check-truth'],
219+
tiers: ['ci', 'release'],
220+
triggers: [
221+
/^www\/(content|app\/routes|app\/data\/_generated-nav\.ts|vite\.config\.ts)/,
222+
/^tools\/(?:check-www-truth|project-constants)/,
223+
/^deno\.json$/,
224+
],
225+
},
226+
{
227+
// #1159 (B2.4): guide/architecture code examples that import
228+
// @openelement/* must type-check against the real framework sources.
229+
name: 'content:examples-check',
230+
command: ['deno', 'task', 'content:examples-check'],
231+
tiers: ['ci', 'release'],
232+
triggers: [
233+
/^www\/content\//,
234+
/^packages\//,
235+
/^tools\/check-content-examples/,
236+
/^deno\.json$/,
237+
],
238+
},
211239
{
212240
name: 'docs:check-version-anchors',
213241
command: ['deno', 'task', 'docs:check-version-anchors'],
@@ -364,7 +392,13 @@ const GATES: readonly GateDefinition[] = [
364392
name: 'build',
365393
command: ['deno', 'task', 'build'],
366394
tiers: ['ci', 'release'],
367-
triggers: [/^(packages|www)\//, /^deno\.json$/],
395+
// #1159: the build task ends with the built-output internal
396+
// link/fragment + SEO gate (www:check-links), so checker edits rebuild.
397+
triggers: [
398+
/^(packages|www)\//,
399+
/^deno\.json$/,
400+
/^tools\/(?:check-www-links|lib\/www-links)/,
401+
],
368402
},
369403
{
370404
// 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: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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.
73+
const dir = await Deno.makeTempDir({ dir: '.tmp', prefix: 'content-examples-' });
74+
try {
75+
const files: string[] = [];
76+
for (const [index, example] of examples.entries()) {
77+
const name = `example-${index}.${example.lang}`;
78+
await Deno.writeTextFile(`${dir}/${name}`, example.code);
79+
files.push(`${dir}/${name}`);
80+
}
81+
const paths = await workspacePaths();
82+
const options: ts.CompilerOptions = {
83+
allowImportingTsExtensions: true,
84+
experimentalDecorators: true,
85+
jsx: ts.JsxEmit.ReactJSX,
86+
jsxImportSource: '@openelement/element',
87+
module: ts.ModuleKind.ESNext,
88+
moduleResolution: ts.ModuleResolutionKind.Bundler,
89+
noEmit: true,
90+
skipLibCheck: true,
91+
strict: true,
92+
target: ts.ScriptTarget.ESNext,
93+
baseUrl: Deno.cwd(),
94+
paths,
95+
// Doc snippets are teaching material, not shippable modules: unused
96+
// locals and missing return-type annotations are not defects there.
97+
noUnusedLocals: false,
98+
noUnusedParameters: false,
99+
};
100+
const program = ts.createProgram(files, options);
101+
const failures: ExampleFailure[] = [];
102+
for (const [index, file] of files.entries()) {
103+
const source = program.getSourceFile(file);
104+
if (!source) {
105+
failures.push({ file: examples[index].file, message: 'example failed to parse' });
106+
continue;
107+
}
108+
const diagnostics = [
109+
...program.getSyntacticDiagnostics(source),
110+
...program.getSemanticDiagnostics(source),
111+
];
112+
for (const diagnostic of diagnostics) {
113+
if (suppressElidedDiagnostic(diagnostic)) continue;
114+
const position = diagnostic.file?.getLineAndCharacterOfPosition(diagnostic.start ?? 0);
115+
failures.push({
116+
file: `${examples[index].file} (block ${examples[index].index + 1})`,
117+
message: `TS${diagnostic.code} at line ${(position?.line ?? 0) + 1}: ${
118+
ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ')
119+
}`,
120+
});
121+
}
122+
}
123+
return failures;
124+
} finally {
125+
await Deno.remove(dir, { recursive: true });
126+
}
127+
}
128+
129+
/**
130+
* Elided-context suppression (see the module header): returns true only for
131+
* diagnostics that express documented snippet elision, never framework-surface
132+
* errors.
133+
*/
134+
export function suppressElidedDiagnostic(diagnostic: ts.Diagnostic): boolean {
135+
const text = ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ');
136+
if (diagnostic.code === 2307) {
137+
// Unresolved module: only framework modules are harness truth. The
138+
// `@openelement/generated/*` namespace is adapter-emitted consumer code.
139+
if (/Cannot find module '@openelement\/(?!generated\/)/.test(text)) return false;
140+
return true;
141+
}
142+
// Undefined names from elided application code.
143+
if (diagnostic.code === 2304) return true;
144+
// Implicit-any in teaching snippets.
145+
if (diagnostic.code === 7006) return true;
146+
// Property access on the uninferred loader-data generic (`{}`).
147+
if (diagnostic.code === 2339 && text.includes(`on type '{}'`)) return true;
148+
return false;
149+
}
150+
151+
export async function checkContentExamples(): Promise<ExampleFailure[]> {
152+
const seen = new Set<string>();
153+
const examples: ContentExample[] = [];
154+
for (const dir of ['www/content/guide', 'www/content/architecture']) {
155+
for await (const entry of walk(dir, { includeDirs: false, exts: ['.md'] })) {
156+
const markdown = await Deno.readTextFile(entry.path);
157+
for (const example of extractExamples(entry.path, markdown)) {
158+
// en/zh translations carry identical code — check each block once.
159+
if (seen.has(example.code)) continue;
160+
seen.add(example.code);
161+
examples.push(example);
162+
}
163+
}
164+
}
165+
return await typeCheckExamples(examples);
166+
}
167+
168+
if (import.meta.main) {
169+
const failures = await checkContentExamples();
170+
if (failures.length > 0) {
171+
console.error('Content example type-check failed:');
172+
for (const failure of failures) {
173+
console.error(`- ${failure.file}: ${failure.message}`);
174+
}
175+
Deno.exit(1);
176+
}
177+
console.log('Content example type-check passed.');
178+
}

0 commit comments

Comments
 (0)