From b4bf5641b7fe106cd7c8d365bcde9ffbac222faa Mon Sep 17 00:00:00 2001 From: Shanire Date: Wed, 26 Aug 2026 10:41:36 +0800 Subject: [PATCH 01/23] fix: close web governance review gaps --- docs/engineering/architecture.md | 7 +- docs/operations/verification.md | 9 +- site/public/sitemap.xml | 76 +++++----- site/scripts/build-representations.test.mjs | 131 +++++++++++++++++- site/scripts/check-html.mjs | 71 +++++++++- site/scripts/generate-seo.mjs | 11 +- site/scripts/last-modified.mjs | 66 ++++++++- site/scripts/last-modified.test.mjs | 39 ++++++ site/scripts/markdown-representation.mjs | 15 +- site/scripts/markdown-representation.test.mjs | 15 ++ site/scripts/public-web-contract.test.mjs | 28 ++++ site/scripts/seo-contract.test.mjs | 28 +++- site/scripts/worker-routing.test.mjs | 71 +++++++--- site/src/data/routeLastModified.ts | 49 +++++++ site/src/lib/discovery.ts | 10 +- site/src/lib/publicWebContract.ts | 21 +++ site/worker.js | 22 ++- 17 files changed, 567 insertions(+), 102 deletions(-) create mode 100644 site/scripts/last-modified.test.mjs create mode 100644 site/scripts/public-web-contract.test.mjs create mode 100644 site/src/lib/publicWebContract.ts diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index f536873..a4e570a 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -71,6 +71,7 @@ The manifest intentionally keeps only dependencies imported by current source. D | `site/src/components/games/runtime/` | Shared deterministic random source, round statistics, lazy audio, and viewport gate for the seven games. | | `site/src/config/site.ts` | DP大师 brand, copyright holders, and the single production host with its same-origin API endpoints. | | `site/src/lib/publicRoutes.ts` | Catalog-derived authority for 47 public routes plus one separately declared internal prerender specimen. | +| `site/src/lib/publicWebContract.ts` | Shared internal representation paths and public `Content-Signal` contract consumed by generation, discovery, and the Worker. | | `site/src/lib/pageMeta.ts` | Pure route metadata authority for titles, summaries, canonicals, breadcrumbs, and indexing policy. | | `site/src/lib/seoHead.ts` | Server/client shared head and JSON-LD generation. | | `site/src/lib/discovery.ts` | Sitemap, robots (including the AI-crawler group), llms.txt, and route-summary generation. | @@ -79,7 +80,7 @@ The manifest intentionally keeps only dependencies imported by current source. D | `site/src/entry-server.tsx` | React 19 static rendering entry used by the prerender build. | | `site/scripts/build.mjs` | Builds the client plus an isolated production SSR bundle, then prerenders. | | `site/scripts/prerender.mjs` | Writes route HTML, one Markdown representation per public route, 404.html, and discovery files. | -| `site/scripts/markdown-representation.mjs` | DOM-based projection from locally prerendered semantic HTML to compact Markdown; it is not a second hand-written content source. | +| `site/scripts/markdown-representation.mjs` | Deterministic DOM-based projection from locally prerendered semantic HTML to compact Markdown with a normalized legal heading outline; it is not a second hand-written content source. | | `site/worker/` | Shared feedback, privacy-bounded first-party analytics, and egress-probe cores. | | `site/worker.js` + `site/worker/` | The single deployment adapter: public-page content negotiation plus same-origin feedback, analytics, and egress diagnostics. | @@ -99,7 +100,7 @@ All 29 teaching solver surfaces are Adapters over the algorithm boundary: public Known deep links are physical prerendered HTML assets. Cloudflare Workers Static Assets serves them and uses `404-page` for anything unmatched, returning the themed `404.html` with a real HTTP 404. See root [deploy.md](../../deploy.md) for the platform contract. -For a registered public `GET` or `HEAD`, `worker/content-negotiation.js` selects HTML or Markdown from `Accept`; HTML wins equal preferences and the Worker returns 406 when both supported media types are explicitly unacceptable. Markdown is fetched only through a distinct internal asset URL, so its ETag and static-asset cache key cannot collide with HTML. Both representations add `Vary: Accept`, alternate links, `nosniff`, and the approved public `Content-Signal`; direct requests to the internal prefix return 404. APIs, ordinary assets, the internal specimen, and unknown routes bypass negotiation. +For a registered public `GET` or `HEAD`, `worker/content-negotiation.js` selects HTML or Markdown from `Accept`; HTML wins equal preferences and the Worker returns 406 when both supported media types are explicitly unacceptable. Markdown is fetched only through a distinct internal asset URL, so its ETag and static-asset cache key cannot collide with HTML. Both representations add `Vary: Accept`, alternate links, `nosniff`, and the approved public `Content-Signal`; direct requests to the internal prefix return 404. `HEAD` executes the same matrix and headers but never returns a body. APIs, ordinary assets, the internal specimen, and unknown routes bypass negotiation. The internal path mapper and public signal live in `publicWebContract.ts`, preventing the generator, discovery files, and Worker from silently diverging. # SEO And Accessibility @@ -107,7 +108,7 @@ For a registered public `GET` or `HEAD`, `worker/content-negotiation.js` selects `seoHead.ts` renders the same head contract at build time that `RouteMeta` maintains after client navigation: title, description, citable abstract, robots, canonical, Open Graph, Twitter, and a Schema.org graph. Every indexable page includes Organization and WebSite nodes and states `isAccessibleForFree`; the WebSite node declares a `SearchAction` pointing at the real `?q=` problem search. Lessons add Course/LearningResource/TechArticle with `educationalUse` and an `EducationalAudience`, family pages add CollectionPage plus an ItemList of their ready lessons, other pages add WebPage, and routes with hierarchy add BreadcrumbList. Course duration is deliberately not declared: there is no trustworthy source for it, so `hasCourseInstance` is omitted rather than fabricated. -`site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. +`site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. Each route records both its Git-derived `lastmod` and a SHA-256 digest of its route-owned source files: an unchanged digest preserves the previous timestamp, while changed content cannot retain the old timestamp even when both builds happen on the same day. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. # Analytics diff --git a/docs/operations/verification.md b/docs/operations/verification.md index f9baac4..6c1c7f2 100644 --- a/docs/operations/verification.md +++ b/docs/operations/verification.md @@ -44,7 +44,7 @@ Pure TypeScript Modules imported by Node tests must use explicit `.ts` extension # SEO And Accessibility Checks -`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and Git-derived `lastmod` data. It also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree, rejects missing or extra Markdown assets, browser-only markup and internal-path leakage, and requires each Markdown representation to remain less than half the size of its corresponding HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. +`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and Git-derived `lastmod` data. A stored SHA-256 digest ties every route's `lastmod` to its exact route-owned source set, so a same-day semantic edit still advances the timestamp while an unchanged regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. The browser gate runs Chromium against built `dist/` through a strict, non-reused custom preview server. Route tests directly open `/`, `/part/a`, `/part/a/01`, `/method`, and `/part/g/plug`, then exercise live client navigation and keyboard focus. They check HTTP status, prerendered HTML, absence of pending streamed-Suspense placeholders, route CSS at first paint, hydration without errors, CLS below `0.05`, title/description/abstract/canonical/zero-hreflang/Open Graph/JSON-LD metadata, one visible `h1`, route announcements, current-page semantics, initial-load focus, changed-route focus, and skip-link focus. Navigation-positioning tests click a representative lesson outline in each of the seven families, restore a direct lesson fragment, verify the mobile outline and an ordinary family-page fragment, and require a changed route to reset old-page scroll immediately. An unknown path must return 404 with `noindex,nofollow`, no canonical, and the themed not-found heading. Worker-level Node tests separately cover the Accept matrix, 406 behavior, HEAD parity, representation-specific ETags, hidden internal assets, and unaffected API/static/unknown paths. @@ -95,8 +95,11 @@ When changing lesson content: Deployment checks live in root [deploy.md](../../deploy.md). At minimum after deployment: -* Open a normal route and a direct deep link on both domains. -* Confirm each domain self-canonicalizes and links to both regional variants. +* Open a normal route and a direct deep link on `https://dp.round1.cc`; confirm both self-canonicalize and emit zero hreflang links. +* Run GET and HEAD against a public route with `Accept: text/html` and `Accept: text/markdown`, then request an unacceptable media type and confirm 406. +* Confirm both successful representations carry distinct ETag values, `Vary: Accept`, the appropriate alternate/canonical `Link`, and `Content-Signal: ai-train=yes, search=yes, ai-input=yes`. +* Alternate HTML, Markdown, and HTML requests through the final edge to prove the cache never crosses representations; direct requests under `/_representations/` must return 404. +* Inspect the final `robots.txt` and confirm Cloudflare has not injected `ai-train=no` or crawler `Disallow` rules that conflict with the repository policy. * Request a made-up path and confirm HTTP 404 plus `noindex,nofollow`. * Submit a feedback test. * Confirm `POST /api/analytics` returns 204 for a valid same-origin event and inspect the platform log. diff --git a/site/public/sitemap.xml b/site/public/sitemap.xml index 3ab0e22..32e6311 100644 --- a/site/public/sitemap.xml +++ b/site/public/sitemap.xml @@ -5,39 +5,39 @@ 2026-08-22 - https://dp.round1.cc/part/a + https://dp.round1.cc/method 2026-08-22 - https://dp.round1.cc/part/b + https://dp.round1.cc/part/a 2026-08-22 - https://dp.round1.cc/part/c + https://dp.round1.cc/part/a/01 2026-08-22 - https://dp.round1.cc/part/d + https://dp.round1.cc/part/a/complete 2026-08-22 - https://dp.round1.cc/part/e + https://dp.round1.cc/part/a/cost2d 2026-08-22 - https://dp.round1.cc/part/f + https://dp.round1.cc/part/a/dep 2026-08-22 - https://dp.round1.cc/part/g + https://dp.round1.cc/part/a/fractional 2026-08-22 - https://dp.round1.cc/part/a/01 + https://dp.round1.cc/part/a/group 2026-08-22 - https://dp.round1.cc/part/a/complete + https://dp.round1.cc/part/a/mixed 2026-08-22 @@ -45,31 +45,31 @@ 2026-08-22 - https://dp.round1.cc/part/a/group + https://dp.round1.cc/part/a/variant 2026-08-22 - https://dp.round1.cc/part/a/mixed + https://dp.round1.cc/part/b 2026-08-22 - https://dp.round1.cc/part/a/cost2d + https://dp.round1.cc/part/b/count 2026-08-22 - https://dp.round1.cc/part/a/dep + https://dp.round1.cc/part/b/edit 2026-08-22 - https://dp.round1.cc/part/a/variant + https://dp.round1.cc/part/b/fsm 2026-08-22 - https://dp.round1.cc/part/a/fractional + https://dp.round1.cc/part/b/lcs 2026-08-22 - https://dp.round1.cc/part/b/path + https://dp.round1.cc/part/b/lis 2026-08-22 @@ -77,23 +77,23 @@ 2026-08-22 - https://dp.round1.cc/part/b/lis + https://dp.round1.cc/part/b/path 2026-08-22 - https://dp.round1.cc/part/b/lcs + https://dp.round1.cc/part/c 2026-08-22 - https://dp.round1.cc/part/b/edit + https://dp.round1.cc/part/c/merge 2026-08-22 - https://dp.round1.cc/part/b/fsm + https://dp.round1.cc/part/c/palindrome 2026-08-22 - https://dp.round1.cc/part/b/count + https://dp.round1.cc/part/c/ring 2026-08-22 @@ -101,31 +101,31 @@ 2026-08-22 - https://dp.round1.cc/part/c/ring + https://dp.round1.cc/part/c/tree 2026-08-22 - https://dp.round1.cc/part/c/palindrome + https://dp.round1.cc/part/d 2026-08-22 - https://dp.round1.cc/part/c/tree + https://dp.round1.cc/part/d/grid 2026-08-22 - https://dp.round1.cc/part/c/merge + https://dp.round1.cc/part/d/matpow 2026-08-22 - https://dp.round1.cc/part/d/grid + https://dp.round1.cc/part/e 2026-08-22 - https://dp.round1.cc/part/d/matpow + https://dp.round1.cc/part/e/basic 2026-08-22 - https://dp.round1.cc/part/e/basic + https://dp.round1.cc/part/e/center 2026-08-22 @@ -137,15 +137,15 @@ 2026-08-22 - https://dp.round1.cc/part/e/center + https://dp.round1.cc/part/f 2026-08-22 - https://dp.round1.cc/part/f/select + https://dp.round1.cc/part/f/count 2026-08-22 - https://dp.round1.cc/part/f/knapsack + https://dp.round1.cc/part/f/cover 2026-08-22 @@ -153,19 +153,19 @@ 2026-08-22 - https://dp.round1.cc/part/f/cover + https://dp.round1.cc/part/f/knapsack 2026-08-22 - https://dp.round1.cc/part/f/count + https://dp.round1.cc/part/f/select 2026-08-22 - https://dp.round1.cc/part/g/board + https://dp.round1.cc/part/g 2026-08-22 - https://dp.round1.cc/part/g/tsp + https://dp.round1.cc/part/g/board 2026-08-22 @@ -173,15 +173,15 @@ 2026-08-22 - https://dp.round1.cc/part/g/subset + https://dp.round1.cc/part/g/plug 2026-08-22 - https://dp.round1.cc/part/g/plug + https://dp.round1.cc/part/g/subset 2026-08-22 - https://dp.round1.cc/method + https://dp.round1.cc/part/g/tsp 2026-08-22 diff --git a/site/scripts/build-representations.test.mjs b/site/scripts/build-representations.test.mjs index 966b6f1..46b60b2 100644 --- a/site/scripts/build-representations.test.mjs +++ b/site/scripts/build-representations.test.mjs @@ -5,6 +5,7 @@ import { dirname, join } from 'node:path' import test from 'node:test' import { checkMarkdownRepresentations } from './check-html.mjs' +import { renderMarkdownRepresentation } from './markdown-representation.mjs' function write(root, relative, content) { const path = join(root, relative) @@ -15,17 +16,27 @@ function write(root, relative, content) { test('the build gate requires one clean and compact Markdown asset per public path', () => { const dir = mkdtempSync(join(tmpdir(), 'dpmaster-representations-')) try { - write(dir, 'index.html', '

首页

完整互动页面正文'.repeat(30) + '

') - write(dir, 'part/a/index.html', '

背包

完整互动页面正文'.repeat(30) + '

') + const homeHtml = `

首页

正文

` + const partHtml = `

背包

正文

` + write(dir, 'index.html', homeHtml) + write(dir, 'part/a/index.html', partHtml) write( dir, '_representations/markdown/index.md', - '# 首页\n\n正文\n\n---\n\n[在原页面查看完整互动内容](https://dp.round1.cc/)\n', + renderMarkdownRepresentation({ + html: homeHtml, + canonical: 'https://dp.round1.cc/', + summary: '首页摘要', + }), ) write( dir, '_representations/markdown/part/a.md', - '# 背包\n\n正文\n\n---\n\n[在原页面查看完整互动内容](https://dp.round1.cc/part/a)\n', + renderMarkdownRepresentation({ + html: partHtml, + canonical: 'https://dp.round1.cc/part/a', + summary: '背包摘要', + }), ) assert.deepEqual( @@ -62,3 +73,115 @@ test('the build gate rejects missing and unexpected Markdown assets', () => { rmSync(dir, { recursive: true, force: true }) } }) + +test('the build gate rejects either side of an HTML and Markdown semantic drift', () => { + const dir = mkdtempSync(join(tmpdir(), 'dpmaster-representations-')) + try { + const canonical = 'https://dp.round1.cc/' + const summary = '同一内容模型生成的摘要。' + const originalHtml = ` +

首页

原始正文需要与 Markdown 保持一致。

+ + ` + write(dir, 'index.html', originalHtml) + write( + dir, + '_representations/markdown/index.md', + renderMarkdownRepresentation({ html: originalHtml, canonical, summary }), + ) + assert.equal(checkMarkdownRepresentations({ dir, paths: ['/'] }).ok, true) + + write( + dir, + 'index.html', + originalHtml.replace('原始正文需要与 Markdown 保持一致。', '已经改变但 Markdown 没有更新的正文。'), + ) + const htmlDrift = checkMarkdownRepresentations({ dir, paths: ['/'] }) + assert.equal(htmlDrift.ok, false) + assert.ok(htmlDrift.errors.some((error) => error.includes('does not match semantic HTML'))) + + write(dir, 'index.html', originalHtml) + write( + dir, + '_representations/markdown/index.md', + renderMarkdownRepresentation({ html: originalHtml, canonical, summary }) + .replace('原始正文', 'Markdown 独有正文'), + ) + const markdownDrift = checkMarkdownRepresentations({ dir, paths: ['/'] }) + assert.equal(markdownDrift.ok, false) + assert.ok(markdownDrift.errors.some((error) => error.includes('does not match semantic HTML'))) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('the build gate rejects an illegal Markdown heading hierarchy', () => { + const dir = mkdtempSync(join(tmpdir(), 'dpmaster-representations-')) + try { + const canonical = 'https://dp.round1.cc/' + const html = ` +

首页

跳级小节

正文

+ + ` + write(dir, 'index.html', html) + write( + dir, + '_representations/markdown/index.md', + renderMarkdownRepresentation({ html, canonical, summary: '摘要' }) + .replace(/^## 跳级小节$/m, '### 跳级小节'), + ) + + const result = checkMarkdownRepresentations({ dir, paths: ['/'] }) + assert.equal(result.ok, false) + assert.ok(result.errors.some((error) => error.includes('illegal heading hierarchy'))) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('the heading gate ignores hash comments inside fenced code', () => { + const dir = mkdtempSync(join(tmpdir(), 'dpmaster-representations-')) + try { + const canonical = 'https://dp.round1.cc/' + const html = ` +

首页

# 伪代码注释\nvalue = 1
+ + ` + write(dir, 'index.html', html) + write( + dir, + '_representations/markdown/index.md', + renderMarkdownRepresentation({ html, canonical, summary: '摘要' }), + ) + + assert.deepEqual( + checkMarkdownRepresentations({ dir, paths: ['/'] }), + { ok: true, markdownFiles: 1, errors: [] }, + ) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('the build gate rejects an unresolved same-origin Markdown link', () => { + const dir = mkdtempSync(join(tmpdir(), 'dpmaster-representations-')) + try { + const canonical = 'https://dp.round1.cc/' + const html = ` +

首页

失效入口

+ + ` + write(dir, 'index.html', html) + write( + dir, + '_representations/markdown/index.md', + renderMarkdownRepresentation({ html, canonical, summary: '摘要' }), + ) + + const result = checkMarkdownRepresentations({ dir, paths: ['/'] }) + assert.equal(result.ok, false) + assert.ok(result.errors.some((error) => error.includes('unresolved same-origin link'))) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/site/scripts/check-html.mjs b/site/scripts/check-html.mjs index e12de10..9bbd21a 100644 --- a/site/scripts/check-html.mjs +++ b/site/scripts/check-html.mjs @@ -8,8 +8,10 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs' import { extname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { JSDOM } from 'jsdom' +import { markdownAssetRelativePath } from '../src/lib/publicWebContract.ts' import { PRERENDER_PATHS, PUBLIC_PATHS } from '../src/lib/publicRoutes.ts' -import { markdownAssetRelativePath } from './markdown-representation.mjs' +import { renderMarkdownRepresentation } from './markdown-representation.mjs' const MANUAL_BEACON_MARKERS = Object.freeze([ '', @@ -42,6 +44,30 @@ function htmlAssetRelativePath(pathname) { return pathname === '/' ? 'index.html' : `${pathname.slice(1)}/index.html` } +function hasLegalHeadingHierarchy(markdown) { + const levels = [] + let fence = null + for (const line of markdown.split('\n')) { + const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1] + if (marker) { + if (fence === null) fence = marker + else if (marker[0] === fence[0] && marker.length >= fence.length) fence = null + continue + } + if (fence !== null) continue + const level = /^(#{1,6})\s+\S/.exec(line)?.[1].length + if (level !== undefined) levels.push(level) + } + if (levels.length === 0 || levels[0] !== 1) return false + if (levels.filter((level) => level === 1).length !== 1) return false + return levels.slice(1).every((level, index) => level <= levels[index] + 1) +} + +function markdownLinkTargets(markdown) { + return [...markdown.matchAll(/!?\[[^\]]*\]\(([^)\s]+)\)/g)] + .map((match) => match[1]) +} + export function expectedHtmlCount(paths = PRERENDER_PATHS) { // 首页只有 index.html,其余每条路由两份,再加一个 404.html。 return 1 + (paths.length - 1) * 2 + 1 @@ -59,6 +85,7 @@ export function checkMarkdownRepresentations({ const expected = paths.map(markdownAssetRelativePath).sort() const actualSet = new Set(actual) const expectedSet = new Set(expected) + const publicPathSet = new Set(paths) const errors = [] for (const path of expected.filter((path) => !actualSet.has(path))) { @@ -78,6 +105,8 @@ export function checkMarkdownRepresentations({ if (!/^#\s+\S/m.test(markdown)) { errors.push(`Markdown representation has no heading: ${relativeMarkdown}`) + } else if (!hasLegalHeadingHierarchy(markdown)) { + errors.push(`Markdown representation has an illegal heading hierarchy: ${relativeMarkdown}`) } if (/<(?:script|style|button|input|textarea|select|form)\b/i.test(markdown)) { errors.push(`Markdown representation contains browser-only markup: ${relativeMarkdown}`) @@ -88,10 +117,46 @@ export function checkMarkdownRepresentations({ if (!markdown.includes(`[在原页面查看完整互动内容](${canonical})`)) { errors.push(`Markdown representation lacks its canonical interactive-content link: ${relativeMarkdown}`) } + for (const target of markdownLinkTargets(markdown)) { + let url + try { + url = new URL(target, canonical) + } catch { + errors.push(`Markdown representation has an invalid public link: ${relativeMarkdown} -> ${target}`) + continue + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + errors.push(`Markdown representation has an invalid public link: ${relativeMarkdown} -> ${target}`) + continue + } + if (url.origin !== origin || publicPathSet.has(url.pathname)) continue + let assetPath = null + try { + assetPath = join(dir, decodeURIComponent(url.pathname).replace(/^\/+/, '')) + } catch { + // The URL parsed, but an invalid percent escape still makes the target unusable. + } + if (!assetPath || !existsSync(assetPath)) { + errors.push(`Markdown representation has an unresolved same-origin link: ${relativeMarkdown} -> ${url.href}`) + } + } if (!existsSync(htmlPath)) { errors.push(`Markdown representation has no matching HTML asset: ${relativeMarkdown}`) - } else if (Buffer.byteLength(markdown) >= Buffer.byteLength(readFileSync(htmlPath, 'utf8')) / 2) { - errors.push(`Markdown representation is not compact enough: ${relativeMarkdown}`) + } else { + const html = readFileSync(htmlPath, 'utf8') + const document = new JSDOM(html).window.document + const summary = document.querySelector('meta[name="abstract"]')?.getAttribute('content') ?? '' + const expected = renderMarkdownRepresentation({ html, canonical, summary }) + const repeated = renderMarkdownRepresentation({ html, canonical, summary }) + if (expected !== repeated) { + errors.push(`Markdown representation is not deterministic: ${relativeMarkdown}`) + } + if (markdown !== expected) { + errors.push(`Markdown representation does not match semantic HTML: ${relativeMarkdown}`) + } + if (Buffer.byteLength(markdown) >= Buffer.byteLength(html) / 2) { + errors.push(`Markdown representation is not compact enough: ${relativeMarkdown}`) + } } } diff --git a/site/scripts/generate-seo.mjs b/site/scripts/generate-seo.mjs index 7fbc6fd..408eb5d 100644 --- a/site/scripts/generate-seo.mjs +++ b/site/scripts/generate-seo.mjs @@ -4,13 +4,18 @@ import { SITE } from '../src/config/site.ts' import { generateDiscoveryFiles } from '../src/lib/discovery.ts' import { getPageMeta } from '../src/lib/pageMeta.ts' import { PUBLIC_PATHS } from '../src/lib/publicRoutes.ts' +import * as previousRouteEvidence from '../src/data/routeLastModified.ts' import { renderRouteHead, replaceRouteHead } from '../src/lib/seoHead.ts' import { - collectRouteLastModified, + collectRouteContentEvidence, renderRouteLastModifiedModule, } from './last-modified.mjs' -const lastModified = collectRouteLastModified() +const routeEvidence = collectRouteContentEvidence({ + previousLastModified: previousRouteEvidence.ROUTE_LAST_MODIFIED, + previousContentDigests: previousRouteEvidence.ROUTE_CONTENT_DIGESTS ?? {}, +}) +const { lastModified } = routeEvidence const files = generateDiscoveryFiles(SITE, lastModified) const outputs = Object.entries(files).map(([name, content]) => [ new URL(`../public/${name}`, import.meta.url), @@ -18,7 +23,7 @@ const outputs = Object.entries(files).map(([name, content]) => [ ]) outputs.push([ new URL('../src/data/routeLastModified.ts', import.meta.url), - renderRouteLastModifiedModule(lastModified), + renderRouteLastModifiedModule(lastModified, routeEvidence.contentDigests), ]) // index.html 的首页 head 由渲染 47 条路由的同一个渲染器生成。手工维护过一次 diff --git a/site/scripts/last-modified.mjs b/site/scripts/last-modified.mjs index 04eae43..d0abaee 100644 --- a/site/scripts/last-modified.mjs +++ b/site/scripts/last-modified.mjs @@ -1,4 +1,6 @@ import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { PUBLIC_PATHS } from '../src/lib/publicRoutes.ts' import { routeModuleIds } from './route-assets.mjs' @@ -36,14 +38,24 @@ function dirtyRouteFiles(files) { ]) } -function localDate() { - const now = new Date() +function localDate(now = new Date()) { const year = now.getFullYear() const month = String(now.getMonth() + 1).padStart(2, '0') const day = String(now.getDate()).padStart(2, '0') return `${year}-${month}-${day}` } +function routeContentDigest(files) { + const digest = createHash('sha256') + for (const file of files) { + digest.update(file) + digest.update('\0') + digest.update(readFileSync(new URL(`../../${file}`, import.meta.url))) + digest.update('\0') + } + return digest.digest('hex') +} + function gitDate(files) { const result = spawnSync( 'git', @@ -61,26 +73,68 @@ function gitDate(files) { } export function collectRouteLastModified() { + return collectRouteContentEvidence().lastModified +} + +export function resolveContentLastModified({ + previousDigest, + currentDigest, + previousLastModified, + candidateLastModified, + now = new Date(), +}) { + if (previousDigest && previousDigest === currentDigest && previousLastModified) { + return previousLastModified + } + if ( + previousDigest + && previousDigest !== currentDigest + && previousLastModified === candidateLastModified + ) { + return now.toISOString() + } + return candidateLastModified +} + +export function collectRouteContentEvidence({ + previousLastModified = {}, + previousContentDigests = {}, + now = new Date(), +} = {}) { const filesByRoute = new Map( PUBLIC_PATHS.map((pathname) => [pathname, routeFiles(pathname)]), ) const dirtyFiles = dirtyRouteFiles(new Set([...filesByRoute.values()].flat())) - const workingDate = dirtyFiles.size > 0 ? localDate() : null + const workingDate = dirtyFiles.size > 0 ? localDate(now) : null + const contentDigests = Object.fromEntries( + [...filesByRoute].map(([pathname, files]) => [ + pathname, + routeContentDigest(files), + ]), + ) - return Object.fromEntries( + const lastModified = Object.fromEntries( [...filesByRoute].map(([pathname, files]) => [ pathname, - files.some((file) => dirtyFiles.has(file)) + resolveContentLastModified({ + previousDigest: previousContentDigests[pathname], + currentDigest: contentDigests[pathname], + previousLastModified: previousLastModified[pathname], + candidateLastModified: files.some((file) => dirtyFiles.has(file)) ? workingDate : gitDate(files), + now, + }), ]), ) + return { lastModified, contentDigests } } -export function renderRouteLastModifiedModule(lastModified) { +export function renderRouteLastModifiedModule(lastModified, contentDigests = {}) { return [ '// 由 scripts/generate-seo.mjs 从 Git 历史生成,请勿手改。', `export const ROUTE_LAST_MODIFIED: Readonly> = Object.freeze(${JSON.stringify(lastModified, null, 2)})`, + `export const ROUTE_CONTENT_DIGESTS: Readonly> = Object.freeze(${JSON.stringify(contentDigests, null, 2)})`, '', ].join('\n') } diff --git a/site/scripts/last-modified.test.mjs b/site/scripts/last-modified.test.mjs new file mode 100644 index 0000000..b8973b9 --- /dev/null +++ b/site/scripts/last-modified.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { resolveContentLastModified } from './last-modified.mjs' + +test('a changed semantic digest always advances lastmod without destabilizing reruns', () => { + const now = new Date('2026-08-26T02:30:00.000Z') + + assert.equal( + resolveContentLastModified({ + previousDigest: 'old-digest', + currentDigest: 'new-digest', + previousLastModified: '2026-08-26', + candidateLastModified: '2026-08-26', + now, + }), + '2026-08-26T02:30:00.000Z', + ) + assert.equal( + resolveContentLastModified({ + previousDigest: 'new-digest', + currentDigest: 'new-digest', + previousLastModified: '2026-08-26T02:30:00.000Z', + candidateLastModified: '2026-08-27', + now: new Date('2026-08-27T01:00:00.000Z'), + }), + '2026-08-26T02:30:00.000Z', + ) + assert.equal( + resolveContentLastModified({ + previousDigest: 'old-digest', + currentDigest: 'new-digest', + previousLastModified: '2026-08-25', + candidateLastModified: '2026-08-26', + now, + }), + '2026-08-26', + ) +}) diff --git a/site/scripts/markdown-representation.mjs b/site/scripts/markdown-representation.mjs index 4d7f5d1..1c3fd0f 100644 --- a/site/scripts/markdown-representation.mjs +++ b/site/scripts/markdown-representation.mjs @@ -1,6 +1,7 @@ import { JSDOM } from 'jsdom' import { mkdirSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' +import { markdownAssetRelativePath } from '../src/lib/publicWebContract.ts' const SKIPPED_TAGS = new Set([ 'button', @@ -98,7 +99,11 @@ function renderNode(node, context) { return `\n\n\`\`\`${language}\n${code}\n\`\`\`\n\n` } if (/^h[1-6]$/.test(tag)) { - const level = Number(tag[1]) + const sourceLevel = Number(tag[1]) + const level = context.lastHeadingLevel === 0 + ? 1 + : Math.min(sourceLevel, context.lastHeadingLevel + 1) + context.lastHeadingLevel = level return `\n\n${'#'.repeat(level)} ${compact(renderChildren(element, context))}\n\n` } if (tag === 'p') return `\n\n${compact(renderChildren(element, context))}\n\n` @@ -140,7 +145,7 @@ export function renderMarkdownRepresentation({ html, canonical, summary }) { const main = document.querySelector('main') if (!main) throw new Error(`Prerendered route ${canonical} is missing
`) - const body = renderNode(main, { canonical }) + const body = renderNode(main, { canonical, lastHeadingLevel: 0 }) .replace(/[ \t]+\n/g, '\n') .replace(/\n[ \t]+/g, '\n') .replace(/\n{3,}/g, '\n\n') @@ -149,12 +154,6 @@ export function renderMarkdownRepresentation({ html, canonical, summary }) { return `${summaryBlock}${body}\n\n---\n\n[在原页面查看完整互动内容](${canonical})\n` } -export function markdownAssetRelativePath(pathname) { - return pathname === '/' - ? '_representations/markdown/index.md' - : `_representations/markdown${pathname}.md` -} - export function writeMarkdownRepresentation({ outDir, pathname, diff --git a/site/scripts/markdown-representation.test.mjs b/site/scripts/markdown-representation.test.mjs index c0eae69..35fe0ba 100644 --- a/site/scripts/markdown-representation.test.mjs +++ b/site/scripts/markdown-representation.test.mjs @@ -59,3 +59,18 @@ test('a route writes its Markdown representation only under the internal asset p rmSync(outDir, { recursive: true, force: true }) } }) + +test('the Markdown projection normalizes skipped HTML heading levels', () => { + const markdown = renderMarkdownRepresentation({ + html: '

方法论

前提

最优子结构

步骤

状态
', + canonical: 'https://dp.round1.cc/method', + summary: '', + }) + + assert.match(markdown, /^# 方法论$/m) + assert.match(markdown, /^## 前提$/m) + assert.match(markdown, /^### 最优子结构$/m) + assert.match(markdown, /^## 步骤$/m) + assert.match(markdown, /^### 状态$/m) + assert.doesNotMatch(markdown, /^####/m) +}) diff --git a/site/scripts/public-web-contract.test.mjs b/site/scripts/public-web-contract.test.mjs new file mode 100644 index 0000000..45724cc --- /dev/null +++ b/site/scripts/public-web-contract.test.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import baseline from '../baseline.config.json' with { type: 'json' } +import { + CONTENT_SIGNAL_HEADER, + markdownAssetPath, + markdownAssetRelativePath, + PUBLIC_CONTENT_SIGNAL, +} from '../src/lib/publicWebContract.ts' + +test('one public-Web contract owns the content signal and Markdown asset mapping', () => { + assert.deepEqual( + PUBLIC_CONTENT_SIGNAL, + baseline.markdownNegotiation.contentSignal, + ) + assert.equal(CONTENT_SIGNAL_HEADER, 'ai-train=yes, search=yes, ai-input=yes') + assert.equal(markdownAssetPath('/'), '/_representations/markdown/index.md') + assert.equal(markdownAssetRelativePath('/'), '_representations/markdown/index.md') + assert.equal( + markdownAssetPath('/part/a/01'), + '/_representations/markdown/part/a/01.md', + ) + assert.equal( + markdownAssetRelativePath('/part/a/01'), + '_representations/markdown/part/a/01.md', + ) +}) diff --git a/site/scripts/seo-contract.test.mjs b/site/scripts/seo-contract.test.mjs index 1808683..53d09c1 100644 --- a/site/scripts/seo-contract.test.mjs +++ b/site/scripts/seo-contract.test.mjs @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises' import test from 'node:test' import { SITE, SITE_ORIGIN } from '../src/config/site.ts' import { PARTS } from '../src/data/catalog.ts' +import { ROUTE_CONTENT_DIGESTS } from '../src/data/routeLastModified.ts' import { getPageMeta } from '../src/lib/pageMeta.ts' import { INTERNAL_PATHS, @@ -154,7 +155,7 @@ test('discovery files expose the 47 approved URLs and real summaries', async () siteSource('src/lib/publicRoutes.ts'), siteSource('package.json').then(JSON.parse), ]) - const expected = PUBLIC_PATHS.map((path) => `${SITE.origin}${path}`) + const expected = PUBLIC_PATHS.map((path) => `${SITE.origin}${path}`).sort() const actual = [...sitemap.matchAll(/([^<]+)<\/loc>/g)].map((match) => match[1]) assert.deepEqual(actual, expected) @@ -207,13 +208,15 @@ test('discovery files expose the 47 approved URLs and real summaries', async () assert.match(generator, /\.\.\/src\/lib\/publicRoutes\.ts/) assert.match(generator, /generateDiscoveryFiles/) - assert.match(generator, /collectRouteLastModified/) + assert.match(generator, /collectRouteContentEvidence/) // index.html 的 head 也由生成器产出,手工维护会和 SITE.origin 漂移。 assert.match(generator, /replaceRouteHead/) assert.match(lastModified, /gitNames\(\['diff', '--name-only', '-z', 'HEAD'/) assert.match(lastModified, /gitNames\(\['ls-files', '--others', '--exclude-standard', '-z'/) assert.match(lastModified, /files\.some\(\(file\) => dirtyFiles\.has\(file\)\)/) - assert.match(lastModified, /localDate\(\)/) + assert.match(lastModified, /localDate\(now\)/) + assert.equal(Object.keys(ROUTE_CONTENT_DIGESTS).length, PUBLIC_PATHS.length) + assert.ok(Object.values(ROUTE_CONTENT_DIGESTS).every((digest) => /^[0-9a-f]{64}$/.test(digest))) assert.match(publicRoutes, /\.\.\/data\/catalog\.ts/) assert.equal(packageJson.scripts['check:seo'], 'node scripts/generate-seo.mjs --check') assert.match(packageJson.scripts.prebuild, /seo:generate/) @@ -271,3 +274,22 @@ test('build contracts provide one Cloudflare output, SSR prerendering, hydration assert.equal(packageJson.scripts['deploy:eo'], undefined) assert.match(packageJson.scripts.preview, /scripts\/preview\.mjs/) }) + +test('deployment verification documents the single-origin negotiation exit gate', async () => { + const verification = await siteSource('../docs/operations/verification.md') + + assert.doesNotMatch(verification, /both domains|regional variants/i) + for (const contract of [ + 'dp.round1.cc', + 'text/markdown', + 'HEAD', + '406', + 'ETag', + 'Vary: Accept', + '/_representations/', + 'Content-Signal', + 'ai-train=yes, search=yes, ai-input=yes', + ]) { + assert.ok(verification.includes(contract), contract) + } +}) diff --git a/site/scripts/worker-routing.test.mjs b/site/scripts/worker-routing.test.mjs index 03b97a5..1dbdb69 100644 --- a/site/scripts/worker-routing.test.mjs +++ b/site/scripts/worker-routing.test.mjs @@ -61,6 +61,9 @@ test('a public route can negotiate its internal Markdown representation', async test('public route HTTP responses apply the approved Accept selection matrix', async () => { const vectors = [ + [null, '/part/a', 200], + ['text/html', '/part/a', 200], + ['text/markdown', '/_representations/markdown/part/a.md', 200], ['text/markdown, */*;q=0.5', '/_representations/markdown/part/a.md', 200], ['text/html;q=1, text/markdown;q=0.8', '/part/a', 200], ['text/html;q=0, text/markdown;q=1', '/_representations/markdown/part/a.md', 200], @@ -69,37 +72,71 @@ test('public route HTTP responses apply the approved Accept selection matrix', a ['text/html;q=0, text/markdown;q=0', null, 406], ['application/json', null, 406], ['text/markdown;q=0, */*;q=1', '/part/a', 200], + ['not-a-media-range, text/html', '/part/a', 200], + ['not-a-media-range', null, 406], ] for (const [accept, expectedPath, expectedStatus] of vectors) { - let requestedPath = null - const response = await worker.fetch( - new Request(`${ORIGIN}/part/a`, { headers: { Accept: accept } }), - { - ASSETS: { - fetch: async (request) => { - requestedPath = new URL(request.url).pathname - return new Response('asset') - }, + const label = accept ?? '' + const requested = [] + const env = { + ASSETS: { + fetch: async (request) => { + const path = new URL(request.url).pathname + const etag = path.startsWith('/_representations/markdown/') + ? '"markdown-etag"' + : '"html-etag"' + requested.push({ path, method: request.method }) + if (request.headers.get('If-None-Match') === etag) { + return new Response(null, { status: 304, headers: { ETag: etag } }) + } + return new Response(`asset:${path}`, { headers: { ETag: etag } }) }, }, - ) + } + const headers = accept === null ? {} : { Accept: accept } + const get = await worker.fetch(new Request(`${ORIGIN}/part/a`, { headers }), env) - assert.equal(response.status, expectedStatus, accept) - assert.equal(requestedPath, expectedPath, accept) - assert.equal(response.headers.get('Vary'), 'Accept', accept) + assert.equal(get.status, expectedStatus, label) + assert.equal(requested[0]?.path ?? null, expectedPath, label) + assert.equal(get.headers.get('Vary'), 'Accept', label) if (expectedStatus === 200) { assert.equal( - response.headers.get('Cache-Control'), + get.headers.get('Cache-Control'), 'public, max-age=0, must-revalidate', - accept, + label, ) assert.equal( - response.headers.get('Content-Signal'), + get.headers.get('Content-Signal'), 'ai-train=yes, search=yes, ai-input=yes', - accept, + label, ) } + + const head = await worker.fetch( + new Request(`${ORIGIN}/part/a`, { method: 'HEAD', headers }), + env, + ) + assert.equal(head.status, expectedStatus, `${label} HEAD`) + assert.equal(await head.text(), '', `${label} HEAD body`) + + const conditionalHeaders = new Headers(headers) + conditionalHeaders.set( + 'If-None-Match', + expectedPath?.startsWith('/_representations/markdown/') + ? '"markdown-etag"' + : '"html-etag"', + ) + const conditional = await worker.fetch( + new Request(`${ORIGIN}/part/a`, { headers: conditionalHeaders }), + env, + ) + assert.equal( + conditional.status, + expectedStatus === 200 ? 304 : 406, + `${label} conditional`, + ) + assert.equal(conditional.headers.get('Vary'), 'Accept', `${label} conditional Vary`) } }) diff --git a/site/src/data/routeLastModified.ts b/site/src/data/routeLastModified.ts index 42b2414..92e0c75 100644 --- a/site/src/data/routeLastModified.ts +++ b/site/src/data/routeLastModified.ts @@ -48,3 +48,52 @@ export const ROUTE_LAST_MODIFIED: Readonly> = Object.free "/method": "2026-08-22", "/problems": "2026-08-22" }) +export const ROUTE_CONTENT_DIGESTS: Readonly> = Object.freeze({ + "/": "10923dc00400656d729642c9c779abd96de29fc3a98f872ca92cfe273313646a", + "/part/a": "4456480843dee7779268363ccb9342a60ab75febdc8c889fb5d4e1e116bb9f83", + "/part/b": "2c4a6a5bf16b10e6361cde5e8b8c43dc3d7ff05bad42c89ee6e6254a781901f5", + "/part/c": "2bf7f67d58b5e87acd272a6e4db29f0b83d77ed2ed45c46845cd982f968fe086", + "/part/d": "bf4c9238c1f695261b6c2d55e0d862182be90513b68e0256ddf6930046e0820c", + "/part/e": "9937ff0c0a118f65f064a6afd6e950f17703b7a80aa2dd0d745be2bbcb173a68", + "/part/f": "bd0fd330e81790efc9855137afb8412255711d3210c98ba1427e3b03844dbdc8", + "/part/g": "0746c2219b94f42d6f10ebab3e1de77679b63e39e52db85a4a476ef8bbb53cf5", + "/part/a/01": "5958608e502dff0c87b26216d560801788871a051c9def3f2f5824b01ee6b026", + "/part/a/complete": "d081724edd35dad5481ab30e211461f15f281e28ca9ca3425c138305321a697c", + "/part/a/multiple": "b6d14a9682d79d783938bfdbfe6f9b48731260480a0832edf24223fb87a5bdb1", + "/part/a/group": "84c1ca6f132993044f24572566010b71e7efca3a831c78d45647d2f6a3c34162", + "/part/a/mixed": "c420391cc9b31ecf8685ceea84ab19dd408de8165af37e6a99d2d56093ab425c", + "/part/a/cost2d": "e36781fb1fed1465344385559aec3ce7c6dfa85ae1591ce98ac2abe35c597aaf", + "/part/a/dep": "2a53bea72e4be19d1e672e0dc7da6f7d7cde15aca80cd0943562b5922c3a5e0b", + "/part/a/variant": "259a4a9f13d8ad5cd78743b09287c2fd6d961aa1cf6ce686a23dad1fb2e8f425", + "/part/a/fractional": "3211cc56c943f154887e3e176b0a1dc15bf91699a45cc1c06b8f4e6dffe8bf0a", + "/part/b/path": "ec5a201ae5821e71e13ce4f4148186c39baf5ae0ee90df3c1caa8c64a1fbc6fe", + "/part/b/maxseg": "4198b40f8a0bc779d7dffc969d6aad2332ca9fee8339051546b4dcc7769d6ebc", + "/part/b/lis": "f06206e03a328f50d03dad7237eef48172794f48a38fc907d0390a57ff618ec4", + "/part/b/lcs": "87dcbc02372228148fafdd94ad71b3d7d2d53a1c47eb1f2cb7e493303f2e5735", + "/part/b/edit": "9b76eb4fbf1368a0dd2797a8cfee9e43950369015411a8162d554fe1d809c9df", + "/part/b/fsm": "deec6a19f207664c6a829781cecab3d14d3a613973d17fc48baaa3f8eae11a79", + "/part/b/count": "0f9dc6b75c8c13237579b0d41544144d056f5e0b6e9246c589ec9947537804ae", + "/part/c/stone": "e21d7965e1969e616c0ea30d283bde85202e03ad50880a5da672bff39b411da2", + "/part/c/ring": "d745b20570a6427f7dbb65c5785f988533846de7ccf95d5c1fe2dd6d500bcc30", + "/part/c/palindrome": "4d5cd4fb1effe55eebe8a762628bf44daf23197734cce00371293391edab8ca1", + "/part/c/tree": "bd91dcda51ef5f5d088f16cf3fdcdb99ffec35a9b89ba5720ef75ddfc08544ef", + "/part/c/merge": "de2900f19e526ecf4104a8b296043d11edfdca047b5dd69ccd6c8d3f6319902d", + "/part/d/grid": "4dc08ed7f3e29e72a222576644b0e1d9581d883f61220046ec4beccc5d4a82f6", + "/part/d/matpow": "2a41b5c78187a989563d935e185d12bab4cf72cee67008c8bb0ce038b49727a4", + "/part/e/basic": "eb51c068504570aad0f515ab5d53a4056bc39c15336ce3514565e254a54f6811", + "/part/e/distsum": "aed2478be7737f6af0e20b65ac08e646de0ac4e83eaebd77da058ea156c02893", + "/part/e/inout": "4d1b2976e92b03bf8695548d519eab3dc2cab2c0280be504ff7d59bcb77bffa9", + "/part/e/center": "c594f09ba48d835879fb45d7dbcf1b2b34cf660cea59717e56539e737c0943c4", + "/part/f/select": "beee77fb1cbc488abfa372ee798b13947c8bdad00637126a51f1680522706e2d", + "/part/f/knapsack": "7f2ed8a218d228bf7091216187fbbc3d930f138fd0158d57207ccca2dbd029dd", + "/part/f/diameter": "d34b94fb1dc92748bc5c68fbe2d49553a67449715c06db217a8a5d296f13fa9d", + "/part/f/cover": "1d85a3448f59e82463587dff9a63204d39a24e8dc2b74bcc485e96d1fcbf92af", + "/part/f/count": "6dbfee3fc5c08bf7fc4b6ab272380da78c0e7834eeabba87f13d3a4f08eb5b95", + "/part/g/board": "d431ad9cad60338511dc87f0e066361e9b95fae93ea5b3bc6eeecec7d1114e04", + "/part/g/tsp": "7b22e8adfedbaa8f54504a22aa93aa5801241282ede70b95d40a245e97820a9b", + "/part/g/cover": "7b214f975637dc13762ae2cc4d0b1d648dc5a8d381eb3c886f428b2df6828a6c", + "/part/g/subset": "679137b4c1f747ed47f21c57b7976f20e3c118ba0f31fd1f06ce3bcdcff6988f", + "/part/g/plug": "c9283f89f4d451e496c8a410a2b6b4aa772217b3076329609755be2384c057fe", + "/method": "0a6a4ec30698fee8bd995dffa1490ef476f084a6a3fc6f375b669e0cc8fa0fff", + "/problems": "f217ae78ce0154b3ebb66121e023d392981f2489d06a077bb29a759ff1ff33b2" +}) diff --git a/site/src/lib/discovery.ts b/site/src/lib/discovery.ts index e67afe1..8be4471 100644 --- a/site/src/lib/discovery.ts +++ b/site/src/lib/discovery.ts @@ -3,6 +3,7 @@ import type { SiteConfig } from '../config/site.ts' import { PARTS } from '../data/catalog.ts' import { getPageMeta } from './pageMeta.ts' import { PUBLIC_PATHS } from './publicRoutes.ts' +import { CONTENT_SIGNAL_HEADER } from './publicWebContract.ts' function xml(value: string): string { return value @@ -81,6 +82,11 @@ export function generateDiscoveryFiles( }) const byPath = new Map(summaries.map((entry) => [entry.path, entry])) const pick = (path: string) => byPath.get(path) + const sitemapSummaries = [...summaries].sort((left, right) => { + const leftUrl = left.url ?? `${site.origin}${left.path}` + const rightUrl = right.url ?? `${site.origin}${right.path}` + return leftUrl < rightUrl ? -1 : leftUrl > rightUrl ? 1 : 0 + }) const readyLessonCount = PARTS.flatMap((part) => part.types.filter((type) => type.status === 'ready'), ).length @@ -88,7 +94,7 @@ export function generateDiscoveryFiles( const sitemap = [ '', '', - ...summaries.flatMap((entry) => [ + ...sitemapSummaries.flatMap((entry) => [ ' ', ` ${xml(entry.url ?? `${site.origin}${entry.path}`)}`, ...(entry.lastModified ? [` ${entry.lastModified}`] : []), @@ -121,7 +127,7 @@ export function generateDiscoveryFiles( `- 发布者:${BRAND.owner}`, '- 访问门槛:无账号、无登录、无付费墙,打开网页即可阅读全部内容', `- 站内检索:${site.origin}/problems?q=关键词`, - '- 公开内容信号:ai-train=yes, search=yes, ai-input=yes', + `- 公开内容信号:${CONTENT_SIGNAL_HEADER}`, '- 内容协商:在同一 URL 请求 `text/markdown` 可读取无浏览器交互噪音的 Markdown 表示', '', ...llmsSection( diff --git a/site/src/lib/publicWebContract.ts b/site/src/lib/publicWebContract.ts new file mode 100644 index 0000000..76f60c9 --- /dev/null +++ b/site/src/lib/publicWebContract.ts @@ -0,0 +1,21 @@ +export const PUBLIC_CONTENT_SIGNAL = Object.freeze({ + 'ai-train': 'yes', + search: 'yes', + 'ai-input': 'yes', +}) + +export const CONTENT_SIGNAL_HEADER = Object.entries(PUBLIC_CONTENT_SIGNAL) + .map(([name, value]) => `${name}=${value}`) + .join(', ') + +const MARKDOWN_ASSET_PREFIX = '/_representations/markdown' + +export function markdownAssetPath(pathname: string): string { + return pathname === '/' + ? `${MARKDOWN_ASSET_PREFIX}/index.md` + : `${MARKDOWN_ASSET_PREFIX}${pathname}.md` +} + +export function markdownAssetRelativePath(pathname: string): string { + return markdownAssetPath(pathname).slice(1) +} diff --git a/site/worker.js b/site/worker.js index ee37b18..c5ba592 100644 --- a/site/worker.js +++ b/site/worker.js @@ -1,21 +1,19 @@ // Cloudflare Worker 入口(wrangler.jsonc 的 "main")—— 站点唯一的发布目标。 -// 作用:先接住反馈、统计与诊断端点,其余请求交回静态资源绑定 -//(含预渲染 HTML 与真实 404)。业务逻辑放在 worker/ 下,单一事实来源。 +// 作用:先接住反馈、统计与诊断端点,在公开 GET/HEAD 上协商 HTML/Markdown, +// 其余请求交回静态资源绑定(含预渲染 HTML 与真实 404)。 import { handleFeedback } from './worker/feedback-core.js' import { handleAnalytics } from './worker/analytics-core.js' import { corsDecision, preflightResponse } from './worker/webhook-core.js' import { handleEgressProbe } from './worker/egress-probe.js' import { negotiateRepresentation } from './worker/content-negotiation.js' +import { + CONTENT_SIGNAL_HEADER, + markdownAssetPath, +} from './src/lib/publicWebContract.ts' import routeSummaries from './public/route-summaries.json' with { type: 'json' } const PUBLIC_PATHS = new Set(routeSummaries.routes.map((route) => route.path)) -function markdownAssetPath(pathname) { - return pathname === '/' - ? '/_representations/markdown/index.md' - : `/_representations/markdown${pathname}.md` -} - function mergeVary(headers, value) { const values = (headers.get('Vary') ?? '') .split(',') @@ -27,12 +25,12 @@ function mergeVary(headers, value) { headers.set('Vary', values.join(', ')) } -function representationResponse(asset, url, representation) { +function representationResponse(asset, url, representation, includeBody) { const headers = new Headers(asset.headers) const canonical = `${routeSummaries.origin}${url.pathname}` mergeVary(headers, 'Accept') headers.set('X-Content-Type-Options', 'nosniff') - headers.set('Content-Signal', 'ai-train=yes, search=yes, ai-input=yes') + headers.set('Content-Signal', CONTENT_SIGNAL_HEADER) // 页面表示都必须重验证;Markdown 不得因内部 `.md` 扩展名获得比 HTML 更宽的浏览器缓存。 headers.set('Cache-Control', 'public, max-age=0, must-revalidate') if (representation === 'markdown') { @@ -45,7 +43,7 @@ function representationResponse(asset, url, representation) { } else { headers.set('Link', `<${canonical}>; rel="alternate"; type="text/markdown"`) } - return new Response(asset.body, { status: asset.status, headers }) + return new Response(includeBody ? asset.body : null, { status: asset.status, headers }) } async function negotiatedResponse(request, env, url, representation) { @@ -59,7 +57,7 @@ async function negotiatedResponse(request, env, url, representation) { ? new Request(new URL(markdownAssetPath(url.pathname), url), request) : request const asset = await env.ASSETS.fetch(assetRequest) - return representationResponse(asset, url, representation) + return representationResponse(asset, url, representation, request.method !== 'HEAD') } // cloudflare:sockets 只在 Workers 运行时存在;用惰性动态 import, From a7da4353f7900c693626cf7a0df5bc0339c89a92 Mon Sep 17 00:00:00 2001 From: Shanire Date: Wed, 26 Aug 2026 10:56:08 +0800 Subject: [PATCH 02/23] fix: stabilize web governance evidence --- docs/engineering/architecture.md | 2 +- docs/operations/verification.md | 2 +- site/index.html | 2 +- site/public/llms.txt | 94 +++++----- site/public/route-summaries.json | 94 +++++----- site/public/sitemap.xml | 94 +++++----- site/scripts/last-modified.mjs | 56 +++--- site/scripts/last-modified.test.mjs | 35 ++-- site/scripts/route-assets.mjs | 2 + site/scripts/semantic-source-graph.mjs | 85 +++++++++ site/scripts/semantic-source-graph.test.mjs | 32 ++++ site/scripts/seo-contract.test.mjs | 17 +- site/scripts/worker-routing.test.mjs | 40 +++++ site/src/data/routeLastModified.ts | 188 ++++++++++---------- site/worker.js | 6 +- 15 files changed, 462 insertions(+), 287 deletions(-) create mode 100644 site/scripts/semantic-source-graph.mjs create mode 100644 site/scripts/semantic-source-graph.test.mjs diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index a4e570a..b9ca0ba 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -108,7 +108,7 @@ For a registered public `GET` or `HEAD`, `worker/content-negotiation.js` selects `seoHead.ts` renders the same head contract at build time that `RouteMeta` maintains after client navigation: title, description, citable abstract, robots, canonical, Open Graph, Twitter, and a Schema.org graph. Every indexable page includes Organization and WebSite nodes and states `isAccessibleForFree`; the WebSite node declares a `SearchAction` pointing at the real `?q=` problem search. Lessons add Course/LearningResource/TechArticle with `educationalUse` and an `EducationalAudience`, family pages add CollectionPage plus an ItemList of their ready lessons, other pages add WebPage, and routes with hierarchy add BreadcrumbList. Course duration is deliberately not declared: there is no trustworthy source for it, so `hasCourseInstance` is omitted rather than fabricated. -`site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. Each route records both its Git-derived `lastmod` and a SHA-256 digest of its route-owned source files: an unchanged digest preserves the previous timestamp, while changed content cannot retain the old timestamp even when both builds happen on the same day. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. +`site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. `semantic-source-graph.mjs` starts from the shared SSR/metadata shell plus the exact route page, lesson, and family-art modules, then follows their complete static import graph while excluding unrelated route entry points and the generated evidence module itself. Each route records a line-ending-neutral SHA-256 digest of that graph. Clean sources use the latest Git commit timestamp, dirty sources use the newest source-file modification timestamp, an unchanged digest preserves the previous value, and generation fails when changed content lacks advancing source evidence. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. # Analytics diff --git a/docs/operations/verification.md b/docs/operations/verification.md index 6c1c7f2..3580b58 100644 --- a/docs/operations/verification.md +++ b/docs/operations/verification.md @@ -44,7 +44,7 @@ Pure TypeScript Modules imported by Node tests must use explicit `.ts` extension # SEO And Accessibility Checks -`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and Git-derived `lastmod` data. A stored SHA-256 digest ties every route's `lastmod` to its exact route-owned source set, so a same-day semantic edit still advances the timestamp while an unchanged regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. +`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. A line-ending-neutral SHA-256 digest follows each route's complete static semantic dependency graph. Clean sources use their latest Git commit timestamp; dirty sources use their newest filesystem modification timestamp; and generation fails if the digest changes without that evidence advancing. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. The browser gate runs Chromium against built `dist/` through a strict, non-reused custom preview server. Route tests directly open `/`, `/part/a`, `/part/a/01`, `/method`, and `/part/g/plug`, then exercise live client navigation and keyboard focus. They check HTTP status, prerendered HTML, absence of pending streamed-Suspense placeholders, route CSS at first paint, hydration without errors, CLS below `0.05`, title/description/abstract/canonical/zero-hreflang/Open Graph/JSON-LD metadata, one visible `h1`, route announcements, current-page semantics, initial-load focus, changed-route focus, and skip-link focus. Navigation-positioning tests click a representative lesson outline in each of the seven families, restore a direct lesson fragment, verify the mobile outline and an ordinary family-page fragment, and require a changed route to reset old-page scroll immediately. An unknown path must return 404 with `noindex,nofollow`, no canonical, and the themed not-found heading. Worker-level Node tests separately cover the Accept matrix, 406 behavior, HEAD parity, representation-specific ETags, hidden internal assets, and unaffected API/static/unknown paths. diff --git a/site/index.html b/site/index.html index 96b28c8..ca89ca3 100644 --- a/site/index.html +++ b/site/index.html @@ -51,7 +51,7 @@ - + diff --git a/site/public/llms.txt b/site/public/llms.txt index 8d98ce9..781c8a2 100644 --- a/site/public/llms.txt +++ b/site/public/llms.txt @@ -12,74 +12,74 @@ ## 入口 -- [DP大师 · DP Master](https://dp.round1.cc/): DP大师面向算法学习者,用精讲、逐帧可视化、题目索引和小游戏讲清状态定义、转移顺序与模型迁移。(最近更新:2026-08-22) -- [通用方法论 · DP大师](https://dp.round1.cc/method): 用状态设计、转移方程、计算顺序、空间优化和调试清单,建立可复用的动态规划解题方法。(最近更新:2026-08-22) -- [题目索引 · DP大师](https://dp.round1.cc/problems): 按 DP 家族、课程、难度和关键词检索教程中的例题与练习,快速定位对应的洛谷题目和学习路径。(最近更新:2026-08-22) +- [DP大师 · DP Master](https://dp.round1.cc/): DP大师面向算法学习者,用精讲、逐帧可视化、题目索引和小游戏讲清状态定义、转移顺序与模型迁移。(最近更新:2026-08-23T21:18:04+08:00) +- [通用方法论 · DP大师](https://dp.round1.cc/method): 用状态设计、转移方程、计算顺序、空间优化和调试清单,建立可复用的动态规划解题方法。(最近更新:2026-08-23T21:18:04+08:00) +- [题目索引 · DP大师](https://dp.round1.cc/problems): 按 DP 家族、课程、难度和关键词检索教程中的例题与练习,快速定位对应的洛谷题目和学习路径。(最近更新:2026-08-23T21:18:04+08:00) ## 背包 DP(A) -- [背包 DP · DP大师](https://dp.round1.cc/part/a): 背包 DP学习路径以“容量受限下的取舍:物品件数属性决定了背包的谱系。”为主线,串联 9 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22) -- [01 背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/01): 01 背包课程回答“每件物品只能取一次时,为什么容量必须逆序更新”。内容以取或不取·一维逆推·恰好装满为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [完全背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/complete): 完全背包课程回答“物品可无限取用时,正序更新如何复用本轮状态”。内容以无限件·一维正推为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [多重背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/multiple): 多重背包课程回答“有限件物品怎样从朴素枚举优化到二进制或单调队列”。内容以朴素·二进制·单调队列为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [分组背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/group): 分组背包课程回答“每组至多选一件时,如何隔离组内选择”。内容以每组至多选一件为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [混合背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/mixed): 混合背包课程回答“01、完全和多重物品怎样共用一套转移框架”。内容以01/完全/多重同题为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [二维费用背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/cost2d): 二维费用背包课程回答“同时受两种容量约束时,状态维度与枚举顺序如何设计”。内容以两种费用同时受限为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [有依赖的背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/dep): 有依赖的背包课程回答“带主件附件依赖的选择怎样转化为合法组合”。内容以主件-附件·依赖→分组为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [背包综合变形 · 背包 DP · DP大师](https://dp.round1.cc/part/a/variant): 背包综合变形课程回答“背包模型如何扩展到计数、撤销和方案恢复”。内容以方案数·撤销·具体方案为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [辨析:分数背包=贪心 · 背包 DP · DP大师](https://dp.round1.cc/part/a/fractional): 辨析:分数背包=贪心课程回答“为什么可分割物品属于贪心而不是 01 背包”。内容以可分割⇒贪心 vs 整取⇒DP为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) +- [背包 DP · DP大师](https://dp.round1.cc/part/a): 背包 DP学习路径以“容量受限下的取舍:物品件数属性决定了背包的谱系。”为主线,串联 9 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-23T21:18:04+08:00) +- [01 背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/01): 01 背包课程回答“每件物品只能取一次时,为什么容量必须逆序更新”。内容以取或不取·一维逆推·恰好装满为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [完全背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/complete): 完全背包课程回答“物品可无限取用时,正序更新如何复用本轮状态”。内容以无限件·一维正推为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [多重背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/multiple): 多重背包课程回答“有限件物品怎样从朴素枚举优化到二进制或单调队列”。内容以朴素·二进制·单调队列为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [分组背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/group): 分组背包课程回答“每组至多选一件时,如何隔离组内选择”。内容以每组至多选一件为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [混合背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/mixed): 混合背包课程回答“01、完全和多重物品怎样共用一套转移框架”。内容以01/完全/多重同题为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [二维费用背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/cost2d): 二维费用背包课程回答“同时受两种容量约束时,状态维度与枚举顺序如何设计”。内容以两种费用同时受限为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [有依赖的背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/dep): 有依赖的背包课程回答“带主件附件依赖的选择怎样转化为合法组合”。内容以主件-附件·依赖→分组为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [背包综合变形 · 背包 DP · DP大师](https://dp.round1.cc/part/a/variant): 背包综合变形课程回答“背包模型如何扩展到计数、撤销和方案恢复”。内容以方案数·撤销·具体方案为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [辨析:分数背包=贪心 · 背包 DP · DP大师](https://dp.round1.cc/part/a/fractional): 辨析:分数背包=贪心课程回答“为什么可分割物品属于贪心而不是 01 背包”。内容以可分割⇒贪心 vs 整取⇒DP为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) ## 线性 DP(B) -- [线性 DP · DP大师](https://dp.round1.cc/part/b): 线性 DP学习路径以“把问题排成一条推进的序列,dp[i] 只依赖更早的状态。”为主线,串联 7 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22) -- [路径型 / 递推入门 · 线性 DP · DP大师](https://dp.round1.cc/part/b/path): 路径型 / 递推入门课程回答“沿序列或网格推进时,如何定义最小充分状态”。内容以数字三角形·过河卒·方格取数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [最大子段和 · 线性 DP · DP大师](https://dp.round1.cc/part/b/maxseg): 最大子段和课程回答“如何用一个局部状态维护最大连续子段”。内容以Kadane·环形·两段不相交为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [最长上升子序列 LIS · 线性 DP · DP大师](https://dp.round1.cc/part/b/lis): 最长上升子序列 LIS课程回答“最长上升子序列如何从二次转移优化到对数查找”。内容以O(n²) 与 O(n log n)·导弹拦截为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [最长公共子序列 LCS · 线性 DP · DP大师](https://dp.round1.cc/part/b/lcs): 最长公共子序列 LCS课程回答“两个序列的公共结构如何通过二维状态刻画”。内容以排列 LCS→LIS·计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [编辑距离 · 线性 DP · DP大师](https://dp.round1.cc/part/b/edit): 编辑距离课程回答“插入、删除和替换如何统一进编辑距离转移”。内容以删/插/改三向转移为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [线性状态机 DP · 线性 DP · DP大师](https://dp.round1.cc/part/b/fsm): 线性状态机 DP课程回答“带持有或选择限制的问题怎样画成有限状态机”。内容以受限选取·股票买卖为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [计数 / 划分型 · 线性 DP · DP大师](https://dp.round1.cc/part/b/count): 计数 / 划分型课程回答“计数与划分问题如何避免重复或遗漏方案”。内容以方案数·高精度·整数划分为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) +- [线性 DP · DP大师](https://dp.round1.cc/part/b): 线性 DP学习路径以“把问题排成一条推进的序列,dp[i] 只依赖更早的状态。”为主线,串联 7 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-23T21:18:04+08:00) +- [路径型 / 递推入门 · 线性 DP · DP大师](https://dp.round1.cc/part/b/path): 路径型 / 递推入门课程回答“沿序列或网格推进时,如何定义最小充分状态”。内容以数字三角形·过河卒·方格取数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [最大子段和 · 线性 DP · DP大师](https://dp.round1.cc/part/b/maxseg): 最大子段和课程回答“如何用一个局部状态维护最大连续子段”。内容以Kadane·环形·两段不相交为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [最长上升子序列 LIS · 线性 DP · DP大师](https://dp.round1.cc/part/b/lis): 最长上升子序列 LIS课程回答“最长上升子序列如何从二次转移优化到对数查找”。内容以O(n²) 与 O(n log n)·导弹拦截为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [最长公共子序列 LCS · 线性 DP · DP大师](https://dp.round1.cc/part/b/lcs): 最长公共子序列 LCS课程回答“两个序列的公共结构如何通过二维状态刻画”。内容以排列 LCS→LIS·计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [编辑距离 · 线性 DP · DP大师](https://dp.round1.cc/part/b/edit): 编辑距离课程回答“插入、删除和替换如何统一进编辑距离转移”。内容以删/插/改三向转移为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [线性状态机 DP · 线性 DP · DP大师](https://dp.round1.cc/part/b/fsm): 线性状态机 DP课程回答“带持有或选择限制的问题怎样画成有限状态机”。内容以受限选取·股票买卖为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [计数 / 划分型 · 线性 DP · DP大师](https://dp.round1.cc/part/b/count): 计数 / 划分型课程回答“计数与划分问题如何避免重复或遗漏方案”。内容以方案数·高精度·整数划分为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) ## 区间 DP(C) -- [区间 DP · DP大师](https://dp.round1.cc/part/c): 区间 DP学习路径以“dp[l][r] 表示区间最优,枚举分割/合并点,按长度递推。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22) -- [石子合并(链形) · 区间 DP · DP大师](https://dp.round1.cc/part/c/stone): 石子合并(链形)课程回答“区间合并代价为何要按长度递增计算”。内容以区间合并基础模型为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [环形区间 DP · 区间 DP · DP大师](https://dp.round1.cc/part/c/ring): 环形区间 DP课程回答“环形区间怎样通过复制序列转化为链形区间”。内容以断环为链·能量项链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [回文 / 括号 · 区间 DP · DP大师](https://dp.round1.cc/part/c/palindrome): 回文 / 括号课程回答“端点匹配如何组织回文与括号类转移”。内容以收缩扩展·端点匹配为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [加分二叉树型 · 区间 DP · DP大师](https://dp.round1.cc/part/c/tree): 加分二叉树型课程回答“枚举区间根节点时,子区间如何对应左右子树”。内容以枚举根·区间即子树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [合并 / 删除类 · 区间 DP · DP大师](https://dp.round1.cc/part/c/merge): 合并 / 删除类课程回答“合并与删除过程怎样压缩成区间状态”。内容以2048·区间删除代价为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) +- [区间 DP · DP大师](https://dp.round1.cc/part/c): 区间 DP学习路径以“dp[l][r] 表示区间最优,枚举分割/合并点,按长度递推。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-23T21:18:04+08:00) +- [石子合并(链形) · 区间 DP · DP大师](https://dp.round1.cc/part/c/stone): 石子合并(链形)课程回答“区间合并代价为何要按长度递增计算”。内容以区间合并基础模型为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [环形区间 DP · 区间 DP · DP大师](https://dp.round1.cc/part/c/ring): 环形区间 DP课程回答“环形区间怎样通过复制序列转化为链形区间”。内容以断环为链·能量项链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [回文 / 括号 · 区间 DP · DP大师](https://dp.round1.cc/part/c/palindrome): 回文 / 括号课程回答“端点匹配如何组织回文与括号类转移”。内容以收缩扩展·端点匹配为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [加分二叉树型 · 区间 DP · DP大师](https://dp.round1.cc/part/c/tree): 加分二叉树型课程回答“枚举区间根节点时,子区间如何对应左右子树”。内容以枚举根·区间即子树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [合并 / 删除类 · 区间 DP · DP大师](https://dp.round1.cc/part/c/merge): 合并 / 删除类课程回答“合并与删除过程怎样压缩成区间状态”。内容以2048·区间删除代价为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) ## 矩阵 DP(D) -- [矩阵 DP · DP大师](https://dp.round1.cc/part/d): 矩阵 DP学习路径以“两条主线:网格坐标上的 DP,与矩阵快速幂加速的递推。”为主线,串联 2 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22) -- [网格 / 矩阵上的 DP · 矩阵 DP · DP大师](https://dp.round1.cc/part/d/grid): 网格 / 矩阵上的 DP课程回答“网格路径、最大正方形和双路径如何选择状态维度”。内容以路径·最大正方形·双线程为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [矩阵快速幂加速 · 矩阵 DP · DP大师](https://dp.round1.cc/part/d/matpow): 矩阵快速幂加速课程回答“线性递推怎样写成矩阵并用快速幂加速”。内容以递推→矩阵幂·O(k³log n)为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) +- [矩阵 DP · DP大师](https://dp.round1.cc/part/d): 矩阵 DP学习路径以“两条主线:网格坐标上的 DP,与矩阵快速幂加速的递推。”为主线,串联 2 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-23T21:18:04+08:00) +- [网格 / 矩阵上的 DP · 矩阵 DP · DP大师](https://dp.round1.cc/part/d/grid): 网格 / 矩阵上的 DP课程回答“网格路径、最大正方形和双路径如何选择状态维度”。内容以路径·最大正方形·双线程为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [矩阵快速幂加速 · 矩阵 DP · DP大师](https://dp.round1.cc/part/d/matpow): 矩阵快速幂加速课程回答“线性递推怎样写成矩阵并用快速幂加速”。内容以递推→矩阵幂·O(k³log n)为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) ## 换根 DP(E) -- [换根 DP · DP大师](https://dp.round1.cc/part/e): 换根 DP学习路径以“二次扫描:固定根一遍 DFS,再一遍换根 O(1) 推每个点。”为主线,串联 4 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22) -- [换根基础模型 · 换根 DP · DP大师](https://dp.round1.cc/part/e/basic): 换根基础模型课程回答“一次定根结果如何在线性时间转移到所有根”。内容以二次扫描骨架为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [距离和换根 · 换根 DP · DP大师](https://dp.round1.cc/part/e/distsum): 距离和换根课程回答“换根时全树距离和为什么只需常数时间更新”。内容以深度和·带权距离和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [子树内外合并 · 换根 DP · DP大师](https://dp.round1.cc/part/e/inout): 子树内外合并课程回答“子树内外贡献如何在第二遍 DFS 中合并”。内容以距离≤k 点权和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [中心 / 偏心距 · 换根 DP · DP大师](https://dp.round1.cc/part/e/center): 中心 / 偏心距课程回答“偏心距与树中心如何由向下和向上信息共同得到”。内容以树的直径·核为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) +- [换根 DP · DP大师](https://dp.round1.cc/part/e): 换根 DP学习路径以“二次扫描:固定根一遍 DFS,再一遍换根 O(1) 推每个点。”为主线,串联 4 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-23T21:18:04+08:00) +- [换根基础模型 · 换根 DP · DP大师](https://dp.round1.cc/part/e/basic): 换根基础模型课程回答“一次定根结果如何在线性时间转移到所有根”。内容以二次扫描骨架为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [距离和换根 · 换根 DP · DP大师](https://dp.round1.cc/part/e/distsum): 距离和换根课程回答“换根时全树距离和为什么只需常数时间更新”。内容以深度和·带权距离和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [子树内外合并 · 换根 DP · DP大师](https://dp.round1.cc/part/e/inout): 子树内外合并课程回答“子树内外贡献如何在第二遍 DFS 中合并”。内容以距离≤k 点权和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [中心 / 偏心距 · 换根 DP · DP大师](https://dp.round1.cc/part/e/center): 中心 / 偏心距课程回答“偏心距与树中心如何由向下和向上信息共同得到”。内容以树的直径·核为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) ## 树形 DP(F) -- [树形 DP · DP大师](https://dp.round1.cc/part/f): 树形 DP学习路径以“dp[u][…] 表示子树最优,后序遍历自底向上合并。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22) -- [选点 / 最大独立集 · 树形 DP · DP大师](https://dp.round1.cc/part/f/select): 选点 / 最大独立集课程回答“父子不能同时选择时,选与不选状态如何配合”。内容以没有上司的舞会为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [树上背包 · 树形 DP · DP大师](https://dp.round1.cc/part/f/knapsack): 树上背包课程回答“树上选取数量约束如何在子树间做背包合并”。内容以二叉苹果树·选课为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [直径 / 重心 DP · 树形 DP · DP大师](https://dp.round1.cc/part/f/diameter): 直径 / 重心 DP课程回答“过当前节点的多条链如何组合出直径与重心信息”。内容以过点最长链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [覆盖 / 支配 / 染色 · 树形 DP · DP大师](https://dp.round1.cc/part/f/cover): 覆盖 / 支配 / 染色课程回答“覆盖、支配和染色约束需要哪些互斥状态”。内容以三状态·染色计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [方案数 / 距离统计 · 树形 DP · DP大师](https://dp.round1.cc/part/f/count): 方案数 / 距离统计课程回答“树上方案数与距离统计如何在合并时避免重复”。内容以联合权值·括号树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) +- [树形 DP · DP大师](https://dp.round1.cc/part/f): 树形 DP学习路径以“dp[u][…] 表示子树最优,后序遍历自底向上合并。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-23T21:18:04+08:00) +- [选点 / 最大独立集 · 树形 DP · DP大师](https://dp.round1.cc/part/f/select): 选点 / 最大独立集课程回答“父子不能同时选择时,选与不选状态如何配合”。内容以没有上司的舞会为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [树上背包 · 树形 DP · DP大师](https://dp.round1.cc/part/f/knapsack): 树上背包课程回答“树上选取数量约束如何在子树间做背包合并”。内容以二叉苹果树·选课为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [直径 / 重心 DP · 树形 DP · DP大师](https://dp.round1.cc/part/f/diameter): 直径 / 重心 DP课程回答“过当前节点的多条链如何组合出直径与重心信息”。内容以过点最长链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [覆盖 / 支配 / 染色 · 树形 DP · DP大师](https://dp.round1.cc/part/f/cover): 覆盖 / 支配 / 染色课程回答“覆盖、支配和染色约束需要哪些互斥状态”。内容以三状态·染色计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [方案数 / 距离统计 · 树形 DP · DP大师](https://dp.round1.cc/part/f/count): 方案数 / 距离统计课程回答“树上方案数与距离统计如何在合并时避免重复”。内容以联合权值·括号树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) ## 状压 DP(G) -- [状压 DP · DP大师](https://dp.round1.cc/part/g): 状压 DP学习路径以“状态是一个集合,用二进制整数表示;转移在 mask 间进行。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22) -- [棋盘 / 轮廓状压 · 状压 DP · DP大师](https://dp.round1.cc/part/g/board): 棋盘 / 轮廓状压课程回答“逐行棋盘约束怎样编码为兼容位掩码”。内容以互不侵犯·炮兵阵地为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [集合状压 / TSP · 状压 DP · DP大师](https://dp.round1.cc/part/g/tsp): 集合状压 / TSP课程回答“集合访问状态如何保证 Hamilton 路径不重不漏”。内容以最短 Hamilton·吃奶酪为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [状压 + 覆盖 · 状压 DP · DP大师](https://dp.round1.cc/part/g/cover): 状压 + 覆盖课程回答“几何覆盖选择怎样预处理成可转移的状态集合”。内容以愤怒的小鸟·宝藏为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [综合技巧 · 状压 DP · DP大师](https://dp.round1.cc/part/g/subset): 综合技巧课程回答“子集枚举与计数变形有哪些可复用的位运算技巧”。内容以枚举子集·计数变形为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) -- [插头 DP · 状压 DP · DP大师](https://dp.round1.cc/part/g/plug): 插头 DP课程回答“轮廓线上的连通性怎样压缩为插头状态”。内容以轮廓线连通性为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22) +- [状压 DP · DP大师](https://dp.round1.cc/part/g): 状压 DP学习路径以“状态是一个集合,用二进制整数表示;转移在 mask 间进行。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-23T21:18:04+08:00) +- [棋盘 / 轮廓状压 · 状压 DP · DP大师](https://dp.round1.cc/part/g/board): 棋盘 / 轮廓状压课程回答“逐行棋盘约束怎样编码为兼容位掩码”。内容以互不侵犯·炮兵阵地为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [集合状压 / TSP · 状压 DP · DP大师](https://dp.round1.cc/part/g/tsp): 集合状压 / TSP课程回答“集合访问状态如何保证 Hamilton 路径不重不漏”。内容以最短 Hamilton·吃奶酪为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [状压 + 覆盖 · 状压 DP · DP大师](https://dp.round1.cc/part/g/cover): 状压 + 覆盖课程回答“几何覆盖选择怎样预处理成可转移的状态集合”。内容以愤怒的小鸟·宝藏为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [综合技巧 · 状压 DP · DP大师](https://dp.round1.cc/part/g/subset): 综合技巧课程回答“子集枚举与计数变形有哪些可复用的位运算技巧”。内容以枚举子集·计数变形为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [插头 DP · 状压 DP · DP大师](https://dp.round1.cc/part/g/plug): 插头 DP课程回答“轮廓线上的连通性怎样压缩为插头状态”。内容以轮廓线连通性为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) ## 使用说明 diff --git a/site/public/route-summaries.json b/site/public/route-summaries.json index 187adec..b783459 100644 --- a/site/public/route-summaries.json +++ b/site/public/route-summaries.json @@ -9,7 +9,7 @@ "title": "DP大师 · DP Master", "summary": "DP大师面向算法学习者,用精讲、逐帧可视化、题目索引和小游戏讲清状态定义、转移顺序与模型迁移。", "type": "home", - "lastModified": "2026-08-22" + "lastModified": "2026-08-23T21:18:04+08:00" }, { "path": "/part/a", @@ -17,7 +17,7 @@ "title": "背包 DP · DP大师", "summary": "背包 DP学习路径以“容量受限下的取舍:物品件数属性决定了背包的谱系。”为主线,串联 9 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-22" + "lastModified": "2026-08-23T21:18:04+08:00" }, { "path": "/part/b", @@ -25,7 +25,7 @@ "title": "线性 DP · DP大师", "summary": "线性 DP学习路径以“把问题排成一条推进的序列,dp[i] 只依赖更早的状态。”为主线,串联 7 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-22" + "lastModified": "2026-08-23T21:18:04+08:00" }, { "path": "/part/c", @@ -33,7 +33,7 @@ "title": "区间 DP · DP大师", "summary": "区间 DP学习路径以“dp[l][r] 表示区间最优,枚举分割/合并点,按长度递推。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-22" + "lastModified": "2026-08-23T21:18:04+08:00" }, { "path": "/part/d", @@ -41,7 +41,7 @@ "title": "矩阵 DP · DP大师", "summary": "矩阵 DP学习路径以“两条主线:网格坐标上的 DP,与矩阵快速幂加速的递推。”为主线,串联 2 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-22" + "lastModified": "2026-08-23T21:18:04+08:00" }, { "path": "/part/e", @@ -49,7 +49,7 @@ "title": "换根 DP · DP大师", "summary": "换根 DP学习路径以“二次扫描:固定根一遍 DFS,再一遍换根 O(1) 推每个点。”为主线,串联 4 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-22" + "lastModified": "2026-08-23T21:18:04+08:00" }, { "path": "/part/f", @@ -57,7 +57,7 @@ "title": "树形 DP · DP大师", "summary": "树形 DP学习路径以“dp[u][…] 表示子树最优,后序遍历自底向上合并。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-22" + "lastModified": "2026-08-23T21:18:04+08:00" }, { "path": "/part/g", @@ -65,7 +65,7 @@ "title": "状压 DP · DP大师", "summary": "状压 DP学习路径以“状态是一个集合,用二进制整数表示;转移在 mask 间进行。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-22" + "lastModified": "2026-08-23T21:18:04+08:00" }, { "path": "/part/a/01", @@ -73,7 +73,7 @@ "title": "01 背包 · 背包 DP · DP大师", "summary": "01 背包课程回答“每件物品只能取一次时,为什么容量必须逆序更新”。内容以取或不取·一维逆推·恰好装满为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -83,7 +83,7 @@ "title": "完全背包 · 背包 DP · DP大师", "summary": "完全背包课程回答“物品可无限取用时,正序更新如何复用本轮状态”。内容以无限件·一维正推为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -93,7 +93,7 @@ "title": "多重背包 · 背包 DP · DP大师", "summary": "多重背包课程回答“有限件物品怎样从朴素枚举优化到二进制或单调队列”。内容以朴素·二进制·单调队列为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -103,7 +103,7 @@ "title": "分组背包 · 背包 DP · DP大师", "summary": "分组背包课程回答“每组至多选一件时,如何隔离组内选择”。内容以每组至多选一件为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -113,7 +113,7 @@ "title": "混合背包 · 背包 DP · DP大师", "summary": "混合背包课程回答“01、完全和多重物品怎样共用一套转移框架”。内容以01/完全/多重同题为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -123,7 +123,7 @@ "title": "二维费用背包 · 背包 DP · DP大师", "summary": "二维费用背包课程回答“同时受两种容量约束时,状态维度与枚举顺序如何设计”。内容以两种费用同时受限为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -133,7 +133,7 @@ "title": "有依赖的背包 · 背包 DP · DP大师", "summary": "有依赖的背包课程回答“带主件附件依赖的选择怎样转化为合法组合”。内容以主件-附件·依赖→分组为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -143,7 +143,7 @@ "title": "背包综合变形 · 背包 DP · DP大师", "summary": "背包综合变形课程回答“背包模型如何扩展到计数、撤销和方案恢复”。内容以方案数·撤销·具体方案为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -153,7 +153,7 @@ "title": "辨析:分数背包=贪心 · 背包 DP · DP大师", "summary": "辨析:分数背包=贪心课程回答“为什么可分割物品属于贪心而不是 01 背包”。内容以可分割⇒贪心 vs 整取⇒DP为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -163,7 +163,7 @@ "title": "路径型 / 递推入门 · 线性 DP · DP大师", "summary": "路径型 / 递推入门课程回答“沿序列或网格推进时,如何定义最小充分状态”。内容以数字三角形·过河卒·方格取数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -173,7 +173,7 @@ "title": "最大子段和 · 线性 DP · DP大师", "summary": "最大子段和课程回答“如何用一个局部状态维护最大连续子段”。内容以Kadane·环形·两段不相交为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -183,7 +183,7 @@ "title": "最长上升子序列 LIS · 线性 DP · DP大师", "summary": "最长上升子序列 LIS课程回答“最长上升子序列如何从二次转移优化到对数查找”。内容以O(n²) 与 O(n log n)·导弹拦截为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -193,7 +193,7 @@ "title": "最长公共子序列 LCS · 线性 DP · DP大师", "summary": "最长公共子序列 LCS课程回答“两个序列的公共结构如何通过二维状态刻画”。内容以排列 LCS→LIS·计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -203,7 +203,7 @@ "title": "编辑距离 · 线性 DP · DP大师", "summary": "编辑距离课程回答“插入、删除和替换如何统一进编辑距离转移”。内容以删/插/改三向转移为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -213,7 +213,7 @@ "title": "线性状态机 DP · 线性 DP · DP大师", "summary": "线性状态机 DP课程回答“带持有或选择限制的问题怎样画成有限状态机”。内容以受限选取·股票买卖为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -223,7 +223,7 @@ "title": "计数 / 划分型 · 线性 DP · DP大师", "summary": "计数 / 划分型课程回答“计数与划分问题如何避免重复或遗漏方案”。内容以方案数·高精度·整数划分为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -233,7 +233,7 @@ "title": "石子合并(链形) · 区间 DP · DP大师", "summary": "石子合并(链形)课程回答“区间合并代价为何要按长度递增计算”。内容以区间合并基础模型为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -243,7 +243,7 @@ "title": "环形区间 DP · 区间 DP · DP大师", "summary": "环形区间 DP课程回答“环形区间怎样通过复制序列转化为链形区间”。内容以断环为链·能量项链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -253,7 +253,7 @@ "title": "回文 / 括号 · 区间 DP · DP大师", "summary": "回文 / 括号课程回答“端点匹配如何组织回文与括号类转移”。内容以收缩扩展·端点匹配为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -263,7 +263,7 @@ "title": "加分二叉树型 · 区间 DP · DP大师", "summary": "加分二叉树型课程回答“枚举区间根节点时,子区间如何对应左右子树”。内容以枚举根·区间即子树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -273,7 +273,7 @@ "title": "合并 / 删除类 · 区间 DP · DP大师", "summary": "合并 / 删除类课程回答“合并与删除过程怎样压缩成区间状态”。内容以2048·区间删除代价为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -283,7 +283,7 @@ "title": "网格 / 矩阵上的 DP · 矩阵 DP · DP大师", "summary": "网格 / 矩阵上的 DP课程回答“网格路径、最大正方形和双路径如何选择状态维度”。内容以路径·最大正方形·双线程为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -293,7 +293,7 @@ "title": "矩阵快速幂加速 · 矩阵 DP · DP大师", "summary": "矩阵快速幂加速课程回答“线性递推怎样写成矩阵并用快速幂加速”。内容以递推→矩阵幂·O(k³log n)为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -303,7 +303,7 @@ "title": "换根基础模型 · 换根 DP · DP大师", "summary": "换根基础模型课程回答“一次定根结果如何在线性时间转移到所有根”。内容以二次扫描骨架为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -313,7 +313,7 @@ "title": "距离和换根 · 换根 DP · DP大师", "summary": "距离和换根课程回答“换根时全树距离和为什么只需常数时间更新”。内容以深度和·带权距离和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -323,7 +323,7 @@ "title": "子树内外合并 · 换根 DP · DP大师", "summary": "子树内外合并课程回答“子树内外贡献如何在第二遍 DFS 中合并”。内容以距离≤k 点权和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -333,7 +333,7 @@ "title": "中心 / 偏心距 · 换根 DP · DP大师", "summary": "中心 / 偏心距课程回答“偏心距与树中心如何由向下和向上信息共同得到”。内容以树的直径·核为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -343,7 +343,7 @@ "title": "选点 / 最大独立集 · 树形 DP · DP大师", "summary": "选点 / 最大独立集课程回答“父子不能同时选择时,选与不选状态如何配合”。内容以没有上司的舞会为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -353,7 +353,7 @@ "title": "树上背包 · 树形 DP · DP大师", "summary": "树上背包课程回答“树上选取数量约束如何在子树间做背包合并”。内容以二叉苹果树·选课为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -363,7 +363,7 @@ "title": "直径 / 重心 DP · 树形 DP · DP大师", "summary": "直径 / 重心 DP课程回答“过当前节点的多条链如何组合出直径与重心信息”。内容以过点最长链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -373,7 +373,7 @@ "title": "覆盖 / 支配 / 染色 · 树形 DP · DP大师", "summary": "覆盖 / 支配 / 染色课程回答“覆盖、支配和染色约束需要哪些互斥状态”。内容以三状态·染色计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -383,7 +383,7 @@ "title": "方案数 / 距离统计 · 树形 DP · DP大师", "summary": "方案数 / 距离统计课程回答“树上方案数与距离统计如何在合并时避免重复”。内容以联合权值·括号树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -393,7 +393,7 @@ "title": "棋盘 / 轮廓状压 · 状压 DP · DP大师", "summary": "棋盘 / 轮廓状压课程回答“逐行棋盘约束怎样编码为兼容位掩码”。内容以互不侵犯·炮兵阵地为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -403,7 +403,7 @@ "title": "集合状压 / TSP · 状压 DP · DP大师", "summary": "集合状压 / TSP课程回答“集合访问状态如何保证 Hamilton 路径不重不漏”。内容以最短 Hamilton·吃奶酪为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -413,7 +413,7 @@ "title": "状压 + 覆盖 · 状压 DP · DP大师", "summary": "状压 + 覆盖课程回答“几何覆盖选择怎样预处理成可转移的状态集合”。内容以愤怒的小鸟·宝藏为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -423,7 +423,7 @@ "title": "综合技巧 · 状压 DP · DP大师", "summary": "综合技巧课程回答“子集枚举与计数变形有哪些可复用的位运算技巧”。内容以枚举子集·计数变形为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -433,7 +433,7 @@ "title": "插头 DP · 状压 DP · DP大师", "summary": "插头 DP课程回答“轮廓线上的连通性怎样压缩为插头状态”。内容以轮廓线连通性为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -443,7 +443,7 @@ "title": "通用方法论 · DP大师", "summary": "用状态设计、转移方程、计算顺序、空间优化和调试清单,建立可复用的动态规划解题方法。", "type": "static", - "lastModified": "2026-08-22" + "lastModified": "2026-08-23T21:18:04+08:00" }, { "path": "/problems", @@ -451,7 +451,7 @@ "title": "题目索引 · DP大师", "summary": "按 DP 家族、课程、难度和关键词检索教程中的例题与练习,快速定位对应的洛谷题目和学习路径。", "type": "static", - "lastModified": "2026-08-22" + "lastModified": "2026-08-23T21:18:04+08:00" } ] } diff --git a/site/public/sitemap.xml b/site/public/sitemap.xml index 32e6311..50c2e6f 100644 --- a/site/public/sitemap.xml +++ b/site/public/sitemap.xml @@ -2,190 +2,190 @@ https://dp.round1.cc/ - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/method - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/a - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/a/01 - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/a/complete - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/a/cost2d - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/a/dep - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/a/fractional - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/a/group - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/a/mixed - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/a/multiple - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/a/variant - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/b - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/b/count - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/b/edit - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/b/fsm - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/b/lcs - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/b/lis - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/b/maxseg - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/b/path - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/c - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/c/merge - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/c/palindrome - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/c/ring - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/c/stone - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/c/tree - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/d - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/d/grid - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/d/matpow - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/e - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/e/basic - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/e/center - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/e/distsum - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/e/inout - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/f - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/f/count - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/f/cover - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/f/diameter - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/f/knapsack - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/f/select - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/g - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/g/board - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/g/cover - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/g/plug - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/g/subset - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/g/tsp - 2026-08-22 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/problems - 2026-08-22 + 2026-08-23T21:18:04+08:00 diff --git a/site/scripts/last-modified.mjs b/site/scripts/last-modified.mjs index d0abaee..c5cf01d 100644 --- a/site/scripts/last-modified.mjs +++ b/site/scripts/last-modified.mjs @@ -1,24 +1,11 @@ import { spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' -import { readFileSync } from 'node:fs' +import { readFileSync, statSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { PUBLIC_PATHS } from '../src/lib/publicRoutes.ts' -import { routeModuleIds } from './route-assets.mjs' +import { semanticRouteFiles } from './semantic-source-graph.mjs' const projectRoot = fileURLToPath(new URL('../../', import.meta.url)) -const COMMON_FILES = [ - 'site/src/data/catalog.ts', - 'site/src/data/editorial.ts', - 'site/src/lib/pageMeta.ts', - 'site/src/lib/seoHead.ts', -] - -function routeFiles(pathname) { - return [ - ...COMMON_FILES, - ...routeModuleIds(pathname).map((moduleId) => `site/${moduleId}`), - ] -} function gitNames(args) { const result = spawnSync('git', args, { @@ -38,11 +25,8 @@ function dirtyRouteFiles(files) { ]) } -function localDate(now = new Date()) { - const year = now.getFullYear() - const month = String(now.getMonth() + 1).padStart(2, '0') - const day = String(now.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` +export function normalizeContentForDigest(content) { + return content.replaceAll('\r\n', '\n').replaceAll('\r', '\n') } function routeContentDigest(files) { @@ -50,23 +34,35 @@ function routeContentDigest(files) { for (const file of files) { digest.update(file) digest.update('\0') - digest.update(readFileSync(new URL(`../../${file}`, import.meta.url))) + digest.update(normalizeContentForDigest( + readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8'), + )) digest.update('\0') } return digest.digest('hex') } +function workingTreeDate(files) { + const latest = Math.max(...files.map((file) => ( + statSync(new URL(`../../${file}`, import.meta.url)).mtimeMs + ))) + if (!Number.isFinite(latest)) { + throw new Error(`Missing working-tree lastmod for ${files.join(', ')}`) + } + return new Date(latest).toISOString() +} + function gitDate(files) { const result = spawnSync( 'git', - ['log', '-1', '--format=%cs', '--', ...files], + ['log', '-1', '--format=%cI', '--', ...files], { cwd: projectRoot, encoding: 'utf8' }, ) if (result.status !== 0) { throw new Error(`Unable to read Git lastmod: ${result.stderr.trim()}`) } const value = result.stdout.trim() - if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + if (!/^\d{4}-\d{2}-\d{2}T/.test(value) || Number.isNaN(Date.parse(value))) { throw new Error(`Missing Git lastmod for ${files.join(', ')}`) } return value @@ -81,7 +77,7 @@ export function resolveContentLastModified({ currentDigest, previousLastModified, candidateLastModified, - now = new Date(), + pathname = '', }) { if (previousDigest && previousDigest === currentDigest && previousLastModified) { return previousLastModified @@ -91,7 +87,7 @@ export function resolveContentLastModified({ && previousDigest !== currentDigest && previousLastModified === candidateLastModified ) { - return now.toISOString() + throw new Error(`Semantic content changed without lastmod advancing for ${pathname}`) } return candidateLastModified } @@ -99,13 +95,11 @@ export function resolveContentLastModified({ export function collectRouteContentEvidence({ previousLastModified = {}, previousContentDigests = {}, - now = new Date(), } = {}) { const filesByRoute = new Map( - PUBLIC_PATHS.map((pathname) => [pathname, routeFiles(pathname)]), + PUBLIC_PATHS.map((pathname) => [pathname, semanticRouteFiles(pathname)]), ) const dirtyFiles = dirtyRouteFiles(new Set([...filesByRoute.values()].flat())) - const workingDate = dirtyFiles.size > 0 ? localDate(now) : null const contentDigests = Object.fromEntries( [...filesByRoute].map(([pathname, files]) => [ pathname, @@ -121,9 +115,9 @@ export function collectRouteContentEvidence({ currentDigest: contentDigests[pathname], previousLastModified: previousLastModified[pathname], candidateLastModified: files.some((file) => dirtyFiles.has(file)) - ? workingDate - : gitDate(files), - now, + ? workingTreeDate(files.filter((file) => dirtyFiles.has(file))) + : gitDate(files), + pathname, }), ]), ) diff --git a/site/scripts/last-modified.test.mjs b/site/scripts/last-modified.test.mjs index b8973b9..d692fc6 100644 --- a/site/scripts/last-modified.test.mjs +++ b/site/scripts/last-modified.test.mjs @@ -1,39 +1,46 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { resolveContentLastModified } from './last-modified.mjs' - -test('a changed semantic digest always advances lastmod without destabilizing reruns', () => { - const now = new Date('2026-08-26T02:30:00.000Z') +import { + normalizeContentForDigest, + resolveContentLastModified, +} from './last-modified.mjs' +test('semantic digests ignore checkout-specific CRLF conversion', () => { assert.equal( + normalizeContentForDigest('first\r\nsecond\r\n'), + normalizeContentForDigest('first\nsecond\n'), + ) +}) + +test('a changed semantic digest requires source-backed lastmod evidence', () => { + assert.throws( + () => ( resolveContentLastModified({ previousDigest: 'old-digest', currentDigest: 'new-digest', previousLastModified: '2026-08-26', candidateLastModified: '2026-08-26', - now, - }), - '2026-08-26T02:30:00.000Z', + }) + ), + /changed without lastmod advancing/, ) assert.equal( resolveContentLastModified({ previousDigest: 'new-digest', currentDigest: 'new-digest', - previousLastModified: '2026-08-26T02:30:00.000Z', - candidateLastModified: '2026-08-27', - now: new Date('2026-08-27T01:00:00.000Z'), + previousLastModified: '2026-08-26T02:29:00.000Z', + candidateLastModified: '2026-08-27T01:00:00.000Z', }), - '2026-08-26T02:30:00.000Z', + '2026-08-26T02:29:00.000Z', ) assert.equal( resolveContentLastModified({ previousDigest: 'old-digest', currentDigest: 'new-digest', previousLastModified: '2026-08-25', - candidateLastModified: '2026-08-26', - now, + candidateLastModified: '2026-08-26T02:29:00.000Z', }), - '2026-08-26', + '2026-08-26T02:29:00.000Z', ) }) diff --git a/site/scripts/route-assets.mjs b/site/scripts/route-assets.mjs index 41234ed..4c49b9f 100644 --- a/site/scripts/route-assets.mjs +++ b/site/scripts/route-assets.mjs @@ -16,6 +16,8 @@ const PAGE_MODULES = Object.freeze({ notFound: 'src/pages/NotFound.tsx', }) +export const ROUTE_PAGE_MODULES = Object.freeze(Object.values(PAGE_MODULES)) + function contentModuleMap() { const lines = readFileSync(catalogPath, 'utf8').split(/\r?\n/) const modules = new Map() diff --git a/site/scripts/semantic-source-graph.mjs b/site/scripts/semantic-source-graph.mjs new file mode 100644 index 0000000..81babee --- /dev/null +++ b/site/scripts/semantic-source-graph.mjs @@ -0,0 +1,85 @@ +import { existsSync, readFileSync, statSync } from 'node:fs' +import { dirname, extname, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parseSync } from 'oxc-parser' + +import { ROUTE_PAGE_MODULES, routeModuleIds } from './route-assets.mjs' + +const projectRoot = fileURLToPath(new URL('../../', import.meta.url)) +const PARSED_EXTENSIONS = new Set(['.js', '.jsx', '.mjs', '.ts', '.tsx']) +const RESOLVED_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json'] +const SELF_GENERATED_FILES = new Set(['site/src/data/routeLastModified.ts']) + +function projectPath(path) { + return relative(projectRoot, path).replaceAll('\\', '/') +} + +function resolveStaticImport(importer, specifier) { + if (!specifier.startsWith('.')) return null + const unresolved = resolve(dirname(importer), specifier) + const candidates = extname(unresolved) + ? [unresolved] + : [ + ...RESOLVED_EXTENSIONS.map((extension) => `${unresolved}${extension}`), + ...RESOLVED_EXTENSIONS.map((extension) => resolve(unresolved, `index${extension}`)), + ] + const resolved = candidates.find((candidate) => ( + existsSync(candidate) && statSync(candidate).isFile() + )) + if (!resolved || extname(resolved) === '.css') return null + return resolved +} + +function staticImportSpecifiers(path) { + if (!PARSED_EXTENSIONS.has(extname(path))) return [] + const source = readFileSync(path, 'utf8') + const { program, errors } = parseSync(path, source) + if (errors.length > 0) { + throw new Error(`Unable to parse semantic dependency ${path}: ${errors[0].message}`) + } + return program.body.flatMap((statement) => { + if ( + statement.type !== 'ImportDeclaration' + && statement.type !== 'ExportNamedDeclaration' + && statement.type !== 'ExportAllDeclaration' + ) return [] + if (statement.importKind === 'type' || statement.exportKind === 'type') return [] + return typeof statement.source?.value === 'string' ? [statement.source.value] : [] + }) +} + +export function semanticRouteFiles(pathname) { + const routeModules = routeModuleIds(pathname) + const routePages = new Set(ROUTE_PAGE_MODULES.map((moduleId) => `site/${moduleId}`)) + const allowedPages = new Set( + routeModules + .filter((moduleId) => moduleId.startsWith('src/pages/')) + .map((moduleId) => `site/${moduleId}`), + ) + const pending = [ + resolve(projectRoot, 'site/src/entry-server.tsx'), + resolve(projectRoot, 'site/src/lib/seoHead.ts'), + ...routeModules.map((moduleId) => resolve(projectRoot, 'site', moduleId)), + ] + const visited = new Set() + + while (pending.length > 0) { + const path = pending.pop() + if (!path) continue + const relativePath = projectPath(path) + if (SELF_GENERATED_FILES.has(relativePath)) continue + if ( + routePages.has(relativePath) + && !allowedPages.has(relativePath) + ) continue + if (visited.has(relativePath)) continue + visited.add(relativePath) + + for (const specifier of staticImportSpecifiers(path)) { + const dependency = resolveStaticImport(path, specifier) + if (dependency) pending.push(dependency) + } + } + + return [...visited].sort() +} diff --git a/site/scripts/semantic-source-graph.test.mjs b/site/scripts/semantic-source-graph.test.mjs new file mode 100644 index 0000000..4835eee --- /dev/null +++ b/site/scripts/semantic-source-graph.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { semanticRouteFiles } from './semantic-source-graph.mjs' + +test('lesson evidence follows the full static semantic dependency graph', () => { + const files = new Set(semanticRouteFiles('/part/a/01')) + + for (const file of [ + 'site/src/entry-server.tsx', + 'site/src/app/AppContent.tsx', + 'site/src/components/layout/Shell.tsx', + 'site/src/config/site.ts', + 'site/src/pages/TypePage.tsx', + 'site/src/components/PartGlyph.tsx', + 'site/src/content/a/Knapsack01.tsx', + 'site/src/content/a/KnapsackArt.tsx', + 'site/src/components/ui/CodeBlock.tsx', + ]) { + assert.ok(files.has(file), file) + } + assert.equal(files.has('site/src/pages/Home.tsx'), false) + assert.equal(files.has('site/src/data/routeLastModified.ts'), false) +}) + +test('home evidence includes its route-only motion controller', () => { + const files = new Set(semanticRouteFiles('/')) + + assert.ok(files.has('site/src/pages/Home.tsx')) + assert.ok(files.has('site/src/pages/HomeMotionController.tsx')) + assert.equal(files.has('site/src/pages/TypePage.tsx'), false) +}) diff --git a/site/scripts/seo-contract.test.mjs b/site/scripts/seo-contract.test.mjs index 53d09c1..572935c 100644 --- a/site/scripts/seo-contract.test.mjs +++ b/site/scripts/seo-contract.test.mjs @@ -14,6 +14,14 @@ import { const siteRoot = new URL('../', import.meta.url) const NEWLINE = String.fromCharCode(10) +function isW3CDateTime(value) { + return ( + typeof value === 'string' + && /^\d{4}-\d{2}-\d{2}T/.test(value) + && !Number.isNaN(Date.parse(value)) + ) +} + async function siteSource(path) { return readFile(new URL(path, siteRoot), 'utf8') } @@ -53,7 +61,7 @@ test('the single origin owns every canonical and emits no hreflang alternates', assert.equal('alternates' in meta, false) assert.ok(meta.description.length >= 30) assert.ok(meta.summary.length >= 30) - assert.match(meta.dateModified, /^\d{4}-\d{2}-\d{2}$/) + assert.equal(isW3CDateTime(meta.dateModified), true) } }) @@ -191,7 +199,7 @@ test('discovery files expose the 47 approved URLs and real summaries', async () assert.equal(routeSummaries.brand, 'DP大师') assert.equal(routeSummaries.origin, SITE.origin) assert.equal('region' in routeSummaries, false) - assert.ok(routeSummaries.routes.every((route) => /^\d{4}-\d{2}-\d{2}$/.test(route.lastModified))) + assert.ok(routeSummaries.routes.every((route) => isW3CDateTime(route.lastModified))) assert.ok(routeSummaries.routes.every((route) => !('alternates' in route))) assert.equal( @@ -214,7 +222,10 @@ test('discovery files expose the 47 approved URLs and real summaries', async () assert.match(lastModified, /gitNames\(\['diff', '--name-only', '-z', 'HEAD'/) assert.match(lastModified, /gitNames\(\['ls-files', '--others', '--exclude-standard', '-z'/) assert.match(lastModified, /files\.some\(\(file\) => dirtyFiles\.has\(file\)\)/) - assert.match(lastModified, /localDate\(now\)/) + assert.match(lastModified, /workingTreeDate/) + assert.match(lastModified, /--format=%cI/) + assert.match(lastModified, /normalizeContentForDigest/) + assert.match(lastModified, /semanticRouteFiles/) assert.equal(Object.keys(ROUTE_CONTENT_DIGESTS).length, PUBLIC_PATHS.length) assert.ok(Object.values(ROUTE_CONTENT_DIGESTS).every((digest) => /^[0-9a-f]{64}$/.test(digest))) assert.match(publicRoutes, /\.\.\/data\/catalog\.ts/) diff --git a/site/scripts/worker-routing.test.mjs b/site/scripts/worker-routing.test.mjs index 1dbdb69..d346e47 100644 --- a/site/scripts/worker-routing.test.mjs +++ b/site/scripts/worker-routing.test.mjs @@ -119,6 +119,32 @@ test('public route HTTP responses apply the approved Accept selection matrix', a ) assert.equal(head.status, expectedStatus, `${label} HEAD`) assert.equal(await head.text(), '', `${label} HEAD body`) + if (expectedStatus === 200) { + assert.deepEqual( + requested[1], + { path: expectedPath, method: 'HEAD' }, + `${label} HEAD representation`, + ) + } else { + assert.equal(requested.length, 0, `${label} HEAD asset bypass`) + } + for (const header of [ + 'Cache-Control', + 'Content-Language', + 'Content-Signal', + 'Content-Type', + 'ETag', + 'Link', + 'Vary', + 'X-Content-Type-Options', + 'X-Robots-Tag', + ]) { + assert.equal( + head.headers.get(header), + get.headers.get(header), + `${label} HEAD ${header}`, + ) + } const conditionalHeaders = new Headers(headers) conditionalHeaders.set( @@ -137,6 +163,20 @@ test('public route HTTP responses apply the approved Accept selection matrix', a `${label} conditional`, ) assert.equal(conditional.headers.get('Vary'), 'Accept', `${label} conditional Vary`) + if (expectedStatus === 200) { + assert.deepEqual( + requested[2], + { path: expectedPath, method: 'GET' }, + `${label} conditional representation`, + ) + assert.equal( + conditional.headers.get('ETag'), + get.headers.get('ETag'), + `${label} conditional ETag`, + ) + } else { + assert.equal(requested.length, 0, `${label} conditional asset bypass`) + } } }) diff --git a/site/src/data/routeLastModified.ts b/site/src/data/routeLastModified.ts index 92e0c75..3d510eb 100644 --- a/site/src/data/routeLastModified.ts +++ b/site/src/data/routeLastModified.ts @@ -1,99 +1,99 @@ // 由 scripts/generate-seo.mjs 从 Git 历史生成,请勿手改。 export const ROUTE_LAST_MODIFIED: Readonly> = Object.freeze({ - "/": "2026-08-22", - "/part/a": "2026-08-22", - "/part/b": "2026-08-22", - "/part/c": "2026-08-22", - "/part/d": "2026-08-22", - "/part/e": "2026-08-22", - "/part/f": "2026-08-22", - "/part/g": "2026-08-22", - "/part/a/01": "2026-08-22", - "/part/a/complete": "2026-08-22", - "/part/a/multiple": "2026-08-22", - "/part/a/group": "2026-08-22", - "/part/a/mixed": "2026-08-22", - "/part/a/cost2d": "2026-08-22", - "/part/a/dep": "2026-08-22", - "/part/a/variant": "2026-08-22", - "/part/a/fractional": "2026-08-22", - "/part/b/path": "2026-08-22", - "/part/b/maxseg": "2026-08-22", - "/part/b/lis": "2026-08-22", - "/part/b/lcs": "2026-08-22", - "/part/b/edit": "2026-08-22", - "/part/b/fsm": "2026-08-22", - "/part/b/count": "2026-08-22", - "/part/c/stone": "2026-08-22", - "/part/c/ring": "2026-08-22", - "/part/c/palindrome": "2026-08-22", - "/part/c/tree": "2026-08-22", - "/part/c/merge": "2026-08-22", - "/part/d/grid": "2026-08-22", - "/part/d/matpow": "2026-08-22", - "/part/e/basic": "2026-08-22", - "/part/e/distsum": "2026-08-22", - "/part/e/inout": "2026-08-22", - "/part/e/center": "2026-08-22", - "/part/f/select": "2026-08-22", - "/part/f/knapsack": "2026-08-22", - "/part/f/diameter": "2026-08-22", - "/part/f/cover": "2026-08-22", - "/part/f/count": "2026-08-22", - "/part/g/board": "2026-08-22", - "/part/g/tsp": "2026-08-22", - "/part/g/cover": "2026-08-22", - "/part/g/subset": "2026-08-22", - "/part/g/plug": "2026-08-22", - "/method": "2026-08-22", - "/problems": "2026-08-22" + "/": "2026-08-23T21:18:04+08:00", + "/part/a": "2026-08-23T21:18:04+08:00", + "/part/b": "2026-08-23T21:18:04+08:00", + "/part/c": "2026-08-23T21:18:04+08:00", + "/part/d": "2026-08-23T21:18:04+08:00", + "/part/e": "2026-08-23T21:18:04+08:00", + "/part/f": "2026-08-23T21:18:04+08:00", + "/part/g": "2026-08-23T21:18:04+08:00", + "/part/a/01": "2026-08-23T21:18:04+08:00", + "/part/a/complete": "2026-08-23T21:18:04+08:00", + "/part/a/multiple": "2026-08-23T21:18:04+08:00", + "/part/a/group": "2026-08-23T21:18:04+08:00", + "/part/a/mixed": "2026-08-23T21:18:04+08:00", + "/part/a/cost2d": "2026-08-23T21:18:04+08:00", + "/part/a/dep": "2026-08-23T21:18:04+08:00", + "/part/a/variant": "2026-08-23T21:18:04+08:00", + "/part/a/fractional": "2026-08-23T21:18:04+08:00", + "/part/b/path": "2026-08-23T21:18:04+08:00", + "/part/b/maxseg": "2026-08-23T21:18:04+08:00", + "/part/b/lis": "2026-08-23T21:18:04+08:00", + "/part/b/lcs": "2026-08-23T21:18:04+08:00", + "/part/b/edit": "2026-08-23T21:18:04+08:00", + "/part/b/fsm": "2026-08-23T21:18:04+08:00", + "/part/b/count": "2026-08-23T21:18:04+08:00", + "/part/c/stone": "2026-08-23T21:18:04+08:00", + "/part/c/ring": "2026-08-23T21:18:04+08:00", + "/part/c/palindrome": "2026-08-23T21:18:04+08:00", + "/part/c/tree": "2026-08-23T21:18:04+08:00", + "/part/c/merge": "2026-08-23T21:18:04+08:00", + "/part/d/grid": "2026-08-23T21:18:04+08:00", + "/part/d/matpow": "2026-08-23T21:18:04+08:00", + "/part/e/basic": "2026-08-23T21:18:04+08:00", + "/part/e/distsum": "2026-08-23T21:18:04+08:00", + "/part/e/inout": "2026-08-23T21:18:04+08:00", + "/part/e/center": "2026-08-23T21:18:04+08:00", + "/part/f/select": "2026-08-23T21:18:04+08:00", + "/part/f/knapsack": "2026-08-23T21:18:04+08:00", + "/part/f/diameter": "2026-08-23T21:18:04+08:00", + "/part/f/cover": "2026-08-23T21:18:04+08:00", + "/part/f/count": "2026-08-23T21:18:04+08:00", + "/part/g/board": "2026-08-23T21:18:04+08:00", + "/part/g/tsp": "2026-08-23T21:18:04+08:00", + "/part/g/cover": "2026-08-23T21:18:04+08:00", + "/part/g/subset": "2026-08-23T21:18:04+08:00", + "/part/g/plug": "2026-08-23T21:18:04+08:00", + "/method": "2026-08-23T21:18:04+08:00", + "/problems": "2026-08-23T21:18:04+08:00" }) export const ROUTE_CONTENT_DIGESTS: Readonly> = Object.freeze({ - "/": "10923dc00400656d729642c9c779abd96de29fc3a98f872ca92cfe273313646a", - "/part/a": "4456480843dee7779268363ccb9342a60ab75febdc8c889fb5d4e1e116bb9f83", - "/part/b": "2c4a6a5bf16b10e6361cde5e8b8c43dc3d7ff05bad42c89ee6e6254a781901f5", - "/part/c": "2bf7f67d58b5e87acd272a6e4db29f0b83d77ed2ed45c46845cd982f968fe086", - "/part/d": "bf4c9238c1f695261b6c2d55e0d862182be90513b68e0256ddf6930046e0820c", - "/part/e": "9937ff0c0a118f65f064a6afd6e950f17703b7a80aa2dd0d745be2bbcb173a68", - "/part/f": "bd0fd330e81790efc9855137afb8412255711d3210c98ba1427e3b03844dbdc8", - "/part/g": "0746c2219b94f42d6f10ebab3e1de77679b63e39e52db85a4a476ef8bbb53cf5", - "/part/a/01": "5958608e502dff0c87b26216d560801788871a051c9def3f2f5824b01ee6b026", - "/part/a/complete": "d081724edd35dad5481ab30e211461f15f281e28ca9ca3425c138305321a697c", - "/part/a/multiple": "b6d14a9682d79d783938bfdbfe6f9b48731260480a0832edf24223fb87a5bdb1", - "/part/a/group": "84c1ca6f132993044f24572566010b71e7efca3a831c78d45647d2f6a3c34162", - "/part/a/mixed": "c420391cc9b31ecf8685ceea84ab19dd408de8165af37e6a99d2d56093ab425c", - "/part/a/cost2d": "e36781fb1fed1465344385559aec3ce7c6dfa85ae1591ce98ac2abe35c597aaf", - "/part/a/dep": "2a53bea72e4be19d1e672e0dc7da6f7d7cde15aca80cd0943562b5922c3a5e0b", - "/part/a/variant": "259a4a9f13d8ad5cd78743b09287c2fd6d961aa1cf6ce686a23dad1fb2e8f425", - "/part/a/fractional": "3211cc56c943f154887e3e176b0a1dc15bf91699a45cc1c06b8f4e6dffe8bf0a", - "/part/b/path": "ec5a201ae5821e71e13ce4f4148186c39baf5ae0ee90df3c1caa8c64a1fbc6fe", - "/part/b/maxseg": "4198b40f8a0bc779d7dffc969d6aad2332ca9fee8339051546b4dcc7769d6ebc", - "/part/b/lis": "f06206e03a328f50d03dad7237eef48172794f48a38fc907d0390a57ff618ec4", - "/part/b/lcs": "87dcbc02372228148fafdd94ad71b3d7d2d53a1c47eb1f2cb7e493303f2e5735", - "/part/b/edit": "9b76eb4fbf1368a0dd2797a8cfee9e43950369015411a8162d554fe1d809c9df", - "/part/b/fsm": "deec6a19f207664c6a829781cecab3d14d3a613973d17fc48baaa3f8eae11a79", - "/part/b/count": "0f9dc6b75c8c13237579b0d41544144d056f5e0b6e9246c589ec9947537804ae", - "/part/c/stone": "e21d7965e1969e616c0ea30d283bde85202e03ad50880a5da672bff39b411da2", - "/part/c/ring": "d745b20570a6427f7dbb65c5785f988533846de7ccf95d5c1fe2dd6d500bcc30", - "/part/c/palindrome": "4d5cd4fb1effe55eebe8a762628bf44daf23197734cce00371293391edab8ca1", - "/part/c/tree": "bd91dcda51ef5f5d088f16cf3fdcdb99ffec35a9b89ba5720ef75ddfc08544ef", - "/part/c/merge": "de2900f19e526ecf4104a8b296043d11edfdca047b5dd69ccd6c8d3f6319902d", - "/part/d/grid": "4dc08ed7f3e29e72a222576644b0e1d9581d883f61220046ec4beccc5d4a82f6", - "/part/d/matpow": "2a41b5c78187a989563d935e185d12bab4cf72cee67008c8bb0ce038b49727a4", - "/part/e/basic": "eb51c068504570aad0f515ab5d53a4056bc39c15336ce3514565e254a54f6811", - "/part/e/distsum": "aed2478be7737f6af0e20b65ac08e646de0ac4e83eaebd77da058ea156c02893", - "/part/e/inout": "4d1b2976e92b03bf8695548d519eab3dc2cab2c0280be504ff7d59bcb77bffa9", - "/part/e/center": "c594f09ba48d835879fb45d7dbcf1b2b34cf660cea59717e56539e737c0943c4", - "/part/f/select": "beee77fb1cbc488abfa372ee798b13947c8bdad00637126a51f1680522706e2d", - "/part/f/knapsack": "7f2ed8a218d228bf7091216187fbbc3d930f138fd0158d57207ccca2dbd029dd", - "/part/f/diameter": "d34b94fb1dc92748bc5c68fbe2d49553a67449715c06db217a8a5d296f13fa9d", - "/part/f/cover": "1d85a3448f59e82463587dff9a63204d39a24e8dc2b74bcc485e96d1fcbf92af", - "/part/f/count": "6dbfee3fc5c08bf7fc4b6ab272380da78c0e7834eeabba87f13d3a4f08eb5b95", - "/part/g/board": "d431ad9cad60338511dc87f0e066361e9b95fae93ea5b3bc6eeecec7d1114e04", - "/part/g/tsp": "7b22e8adfedbaa8f54504a22aa93aa5801241282ede70b95d40a245e97820a9b", - "/part/g/cover": "7b214f975637dc13762ae2cc4d0b1d648dc5a8d381eb3c886f428b2df6828a6c", - "/part/g/subset": "679137b4c1f747ed47f21c57b7976f20e3c118ba0f31fd1f06ce3bcdcff6988f", - "/part/g/plug": "c9283f89f4d451e496c8a410a2b6b4aa772217b3076329609755be2384c057fe", - "/method": "0a6a4ec30698fee8bd995dffa1490ef476f084a6a3fc6f375b669e0cc8fa0fff", - "/problems": "f217ae78ce0154b3ebb66121e023d392981f2489d06a077bb29a759ff1ff33b2" + "/": "ee02b161f7682cc9f793b118905fe20ddd8794cd83b0727f69001068af4ed2f3", + "/part/a": "bafa46e1b5091cc59fe66baf7e184bd8568b1e26628910a150c2ff29560a85ee", + "/part/b": "91b347f4e72f338b616c2e0921218aced5c5daa32ba51f85f955920f6dd2ae51", + "/part/c": "bc2b3b27df4afbd103a41c9b00be81421c060412d64c87a68910100e325ccffe", + "/part/d": "e5676766f2b3f317084487c3d73e83680f1144888a3236e3b782d54039fe880c", + "/part/e": "2cf296e9b93eeeb00c0324f87cc4f802ed8b4414a3bda7aa16b2c77468849672", + "/part/f": "9b72e1c9a3ecf1db07c49a0ad184e2a1da88f8954b3d8dd595d9e529e8c6560d", + "/part/g": "2c3cb75e8e3b6f2f01d10fa65d70e861d3d9d77dae1c36f677382f4dcb0bbae4", + "/part/a/01": "c339e04c7c9910abed66629923cea676f6badc0533f88d74e1f5a112c10a8343", + "/part/a/complete": "abfbdd77fe6b16fe13efcd88e736737bfd14e70c1e7b80cfc0ecebfac954a462", + "/part/a/multiple": "796d53696089cfc988ceac0612a1d0048d686d5b1e20dce3da366f77026d9abd", + "/part/a/group": "cd05c7c3e32a4aea7128e271895eef1fddc534c1b39fe26e8b9485af492f9476", + "/part/a/mixed": "444aa782391ccd20f3bce3ded147f262ca6e10fd1b9fe741a0ff40b787cb90d1", + "/part/a/cost2d": "d04eb3fbaa097b8e052e7236cef8f26fc1e10409be16a27fca05f437955c1142", + "/part/a/dep": "6ed8d6ef86929d950b2c9a55f9755ee83f09d6f0860830186308d780982cdf54", + "/part/a/variant": "d267a0fba75fbb1dc88a9c74eff8308f76dc2a1da44ad8b9482400d58bd6dc5a", + "/part/a/fractional": "1528887a58d6e01ba291a9bfd12fb19192d9247e1a5c6f675e90f2aeca231e30", + "/part/b/path": "da3b0107a483e519ae8864fa302c68a25ce4ce1da09558d4ec511bf9c4a706c7", + "/part/b/maxseg": "fdef46cc46a31ab42c424ce359790d36b8345ab5750a2798ec904b43cd359b0c", + "/part/b/lis": "2dc96d725e97a6e1d3d2c613df2ec940feec1e2f9c620024ddf223fd07b4e145", + "/part/b/lcs": "486dc3a8b1f03216f351153b18f2d0730ddc2d9ba3996650c1ab6865bc26c1dd", + "/part/b/edit": "fb29080d14ad49996383229e85e1cbfa45a95563124610de9dc26b594d22aaf5", + "/part/b/fsm": "2b18ff68c95c7a52b145bc440b5c51ae2aa656b4372ebc17f4b28d63920e2a39", + "/part/b/count": "46dcbcda30aa2ff823095f9aab3ceb6ac6729f7ca32a9f57e506e736e48d0b7f", + "/part/c/stone": "6ca7d6d0bb1c179b460c08df412bf430fc311bfbbcd778a0e18f1fae51004afd", + "/part/c/ring": "3fdb01eb627054a728d252d83e2662c6bb0186566fcf4668cbc24f1f1b9a6025", + "/part/c/palindrome": "7b377c1a58f9090de890aa9a00281c6df7ea77e8f1b90edc3bfe62222d71c02b", + "/part/c/tree": "367bf1e0b66f529b3370ff4a5b88d6c401a667fde32449cda25e1bcc5c4929ff", + "/part/c/merge": "b4274fbbed296c550fef515c98d2f57df1b5ce246ad4690d440a7d5789a3e10a", + "/part/d/grid": "03d50ce50fa1ddcd3e4683259238a74c8db83b17c84b18d38f374061e294ab7e", + "/part/d/matpow": "c7810f0f619e3ded6db4218d3d4e0bd625f9936cf636a46d8786400ff5f5c76b", + "/part/e/basic": "19ca55a1f071e1fbd12d23aeb86df257f568e23d34d5b9919d3be1d0cee01e14", + "/part/e/distsum": "659bbaa095beed9ae59709f5febb80f77d48cb1e11b1a1817c920d8afe5791d7", + "/part/e/inout": "11994e04551dfa6d9b6bbc7038512f33ea2fab8fe66bb6c82e9659aa1fcfecd2", + "/part/e/center": "6cf58aae33924fa129cc1cd0d3f1e4adb1295e353c84d211d77d718f3e5ad0da", + "/part/f/select": "3cf7cb5629daa5150f9d46ec188fd3cb997eaee92d0a79de7a48236d5735492b", + "/part/f/knapsack": "18d05f744d9b07b064601f3fa0eda75fd94a7ba576147fc6ed5652cee527ffe2", + "/part/f/diameter": "ac15e058a8e6845bfcdddde5972a896bdf5c1b3f0b80bdc52c32b733d46b647d", + "/part/f/cover": "74918ca74fbc10562dea6a5fac3da2ef32d4f1c459b40f8e510832cc0e19315d", + "/part/f/count": "41b239ba5e41087ec6a7ef0000b212b0e6a38f6f58e97ae0ef4a9867861f6939", + "/part/g/board": "abaacf900a0133083a5fbff8669e122690a8215aac796efdd670a85cebd67c1f", + "/part/g/tsp": "08d818ab1e4a48376e74454e9591f34ff97ba079baa10b9ccae4ac2184a97075", + "/part/g/cover": "8e36ffee3f2afc609587eb7f5f83b7eb2459c9e1b6fb8e4aaeaa6364ff2c9a05", + "/part/g/subset": "ffea580d381065c972eec809d71ef44c5df91ec67c366d2d4e4d2418567b803a", + "/part/g/plug": "6282151c35236589bdcdfc13609a02b29caf8fe09aedb4cc18b4dfeae2b1a77f", + "/method": "8f5a8d40119df560f8385b105333851a548190224690f4bdf07fc2793fb269df", + "/problems": "add2f52f4f7039f9cb81976f9ec0a9e75654ae6099a504dec2667cd553a9bc6a" }) diff --git a/site/worker.js b/site/worker.js index c5ba592..621d5f1 100644 --- a/site/worker.js +++ b/site/worker.js @@ -50,7 +50,11 @@ async function negotiatedResponse(request, env, url, representation) { if (representation === null) { return new Response(request.method === 'HEAD' ? null : 'Not Acceptable', { status: 406, - headers: { Vary: 'Accept', 'X-Robots-Tag': 'noindex, nofollow' }, + headers: { + 'Content-Type': 'text/plain;charset=UTF-8', + Vary: 'Accept', + 'X-Robots-Tag': 'noindex, nofollow', + }, }) } const assetRequest = representation === 'markdown' From 0aa6a9daf5f3847c63942408e172427b132bf16d Mon Sep 17 00:00:00 2001 From: Shanire Date: Wed, 26 Aug 2026 11:11:35 +0800 Subject: [PATCH 03/23] fix: tighten source-backed evidence --- deploy.md | 2 +- docs/engineering/architecture.md | 2 +- site/public/llms.txt | 2 +- site/scripts/generate-seo.mjs | 7 +- site/scripts/last-modified.mjs | 39 +++++++-- site/scripts/last-modified.test.mjs | 19 ++++ site/scripts/semantic-source-graph.mjs | 3 +- site/scripts/semantic-source-graph.test.mjs | 7 +- site/scripts/seo-contract.test.mjs | 6 +- site/src/data/routeLastModified.ts | 97 +++++++++++---------- site/src/lib/discovery.ts | 2 +- 11 files changed, 120 insertions(+), 66 deletions(-) diff --git a/deploy.md b/deploy.md index fdcbbf2..c4baaa6 100644 --- a/deploy.md +++ b/deploy.md @@ -252,7 +252,7 @@ wrangler tail dpmaster ## Sitemap 与搜索平台提交 -构建把 sitemap 写进发布目录,线上地址固定为 `https://dp.round1.cc/sitemap.xml`,列出 47 个页面,每条带 Git 派生的 `lastmod`。单域站点不输出 hreflang 互指,`xmlns:xhtml` 命名空间也不再出现。 +构建把 sitemap 写进发布目录,线上地址固定为 `https://dp.round1.cc/sitemap.xml`,列出 47 个页面,每条带源码证据派生的 `lastmod`:已提交源码取 Git 提交时间,工作树编辑取文件修改时间,绝不使用模板或构建日期。单域站点不输出 hreflang 互指,`xmlns:xhtml` 命名空间也不再出现。 ★ 域名迁移后必须重新提交,旧域名的验证与 sitemap 记录不会自动继承: diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index b9ca0ba..56e677e 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -108,7 +108,7 @@ For a registered public `GET` or `HEAD`, `worker/content-negotiation.js` selects `seoHead.ts` renders the same head contract at build time that `RouteMeta` maintains after client navigation: title, description, citable abstract, robots, canonical, Open Graph, Twitter, and a Schema.org graph. Every indexable page includes Organization and WebSite nodes and states `isAccessibleForFree`; the WebSite node declares a `SearchAction` pointing at the real `?q=` problem search. Lessons add Course/LearningResource/TechArticle with `educationalUse` and an `EducationalAudience`, family pages add CollectionPage plus an ItemList of their ready lessons, other pages add WebPage, and routes with hierarchy add BreadcrumbList. Course duration is deliberately not declared: there is no trustworthy source for it, so `hasCourseInstance` is omitted rather than fabricated. -`site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. `semantic-source-graph.mjs` starts from the shared SSR/metadata shell plus the exact route page, lesson, and family-art modules, then follows their complete static import graph while excluding unrelated route entry points and the generated evidence module itself. Each route records a line-ending-neutral SHA-256 digest of that graph. Clean sources use the latest Git commit timestamp, dirty sources use the newest source-file modification timestamp, an unchanged digest preserves the previous value, and generation fails when changed content lacks advancing source evidence. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. +`site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. `semantic-source-graph.mjs` starts from the shared `
` shell and metadata roots plus the exact route page, lesson, and family-art modules, then follows their complete static import graph while excluding unrelated route entry points, client-only analytics, and the generated evidence module itself. Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. Clean sources use the latest Git commit timestamp, dirty sources use the newest source-file modification timestamp, an unchanged digest preserves the previous value, and generation fails when changed content lacks strictly advancing source evidence; changing the digest schema preserves the prior content timestamp while replacing only the evidence. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. # Analytics diff --git a/site/public/llms.txt b/site/public/llms.txt index 781c8a2..d93321d 100644 --- a/site/public/llms.txt +++ b/site/public/llms.txt @@ -86,4 +86,4 @@ - 引用课程时优先使用对应课程 URL、页面标题和摘要,不要改写状态定义与转移方程。 - 每条 URL 都是可直接访问的预渲染页面,正文在首屏 HTML 中即可读到,无需执行 JavaScript。 - 题目内容采用教学摘要并链接原题,不复刻完整题面;转述题目时请指向原题链接。 -- 课程由AzureL蔚澜算法持续维护;“最近更新”取自构建时 Git 历史,不使用模板日期。 +- 课程由AzureL蔚澜算法持续维护;“最近更新”取自源码时间证据(已提交源码用 Git 提交时间,工作树编辑用文件修改时间),不使用模板或构建日期。 diff --git a/site/scripts/generate-seo.mjs b/site/scripts/generate-seo.mjs index 408eb5d..95f1312 100644 --- a/site/scripts/generate-seo.mjs +++ b/site/scripts/generate-seo.mjs @@ -14,6 +14,7 @@ import { const routeEvidence = collectRouteContentEvidence({ previousLastModified: previousRouteEvidence.ROUTE_LAST_MODIFIED, previousContentDigests: previousRouteEvidence.ROUTE_CONTENT_DIGESTS ?? {}, + previousContentDigestVersion: previousRouteEvidence.ROUTE_CONTENT_DIGEST_VERSION ?? 0, }) const { lastModified } = routeEvidence const files = generateDiscoveryFiles(SITE, lastModified) @@ -23,7 +24,11 @@ const outputs = Object.entries(files).map(([name, content]) => [ ]) outputs.push([ new URL('../src/data/routeLastModified.ts', import.meta.url), - renderRouteLastModifiedModule(lastModified, routeEvidence.contentDigests), + renderRouteLastModifiedModule( + lastModified, + routeEvidence.contentDigests, + routeEvidence.contentDigestVersion, + ), ]) // index.html 的首页 head 由渲染 47 条路由的同一个渲染器生成。手工维护过一次 diff --git a/site/scripts/last-modified.mjs b/site/scripts/last-modified.mjs index c5cf01d..a103eb1 100644 --- a/site/scripts/last-modified.mjs +++ b/site/scripts/last-modified.mjs @@ -6,6 +6,7 @@ import { PUBLIC_PATHS } from '../src/lib/publicRoutes.ts' import { semanticRouteFiles } from './semantic-source-graph.mjs' const projectRoot = fileURLToPath(new URL('../../', import.meta.url)) +export const ROUTE_CONTENT_DIGEST_VERSION = 1 function gitNames(args) { const result = spawnSync('git', args, { @@ -77,17 +78,23 @@ export function resolveContentLastModified({ currentDigest, previousLastModified, candidateLastModified, + evidenceSchemaChanged = false, pathname = '', }) { if (previousDigest && previousDigest === currentDigest && previousLastModified) { return previousLastModified } - if ( - previousDigest - && previousDigest !== currentDigest - && previousLastModified === candidateLastModified - ) { - throw new Error(`Semantic content changed without lastmod advancing for ${pathname}`) + if (previousDigest && previousDigest !== currentDigest && previousLastModified) { + if (evidenceSchemaChanged) return previousLastModified + const previousTime = Date.parse(previousLastModified) + const candidateTime = Date.parse(candidateLastModified) + if ( + Number.isNaN(previousTime) + || Number.isNaN(candidateTime) + || candidateTime <= previousTime + ) { + throw new Error(`Semantic content changed without lastmod advancing for ${pathname}`) + } } return candidateLastModified } @@ -95,7 +102,11 @@ export function resolveContentLastModified({ export function collectRouteContentEvidence({ previousLastModified = {}, previousContentDigests = {}, + previousContentDigestVersion = 0, } = {}) { + const evidenceSchemaChanged = ( + previousContentDigestVersion !== ROUTE_CONTENT_DIGEST_VERSION + ) const filesByRoute = new Map( PUBLIC_PATHS.map((pathname) => [pathname, semanticRouteFiles(pathname)]), ) @@ -117,16 +128,26 @@ export function collectRouteContentEvidence({ candidateLastModified: files.some((file) => dirtyFiles.has(file)) ? workingTreeDate(files.filter((file) => dirtyFiles.has(file))) : gitDate(files), + evidenceSchemaChanged, pathname, }), ]), ) - return { lastModified, contentDigests } + return { + lastModified, + contentDigests, + contentDigestVersion: ROUTE_CONTENT_DIGEST_VERSION, + } } -export function renderRouteLastModifiedModule(lastModified, contentDigests = {}) { +export function renderRouteLastModifiedModule( + lastModified, + contentDigests = {}, + contentDigestVersion = ROUTE_CONTENT_DIGEST_VERSION, +) { return [ - '// 由 scripts/generate-seo.mjs 从 Git 历史生成,请勿手改。', + '// 由 scripts/generate-seo.mjs 从源码时间证据生成,请勿手改。', + `export const ROUTE_CONTENT_DIGEST_VERSION = ${contentDigestVersion}`, `export const ROUTE_LAST_MODIFIED: Readonly> = Object.freeze(${JSON.stringify(lastModified, null, 2)})`, `export const ROUTE_CONTENT_DIGESTS: Readonly> = Object.freeze(${JSON.stringify(contentDigests, null, 2)})`, '', diff --git a/site/scripts/last-modified.test.mjs b/site/scripts/last-modified.test.mjs index d692fc6..86947dd 100644 --- a/site/scripts/last-modified.test.mjs +++ b/site/scripts/last-modified.test.mjs @@ -25,6 +25,15 @@ test('a changed semantic digest requires source-backed lastmod evidence', () => ), /changed without lastmod advancing/, ) + assert.throws( + () => resolveContentLastModified({ + previousDigest: 'old-digest', + currentDigest: 'new-digest', + previousLastModified: '2026-08-26T10:00:00.000Z', + candidateLastModified: '2026-08-25T10:00:00.000Z', + }), + /changed without lastmod advancing/, + ) assert.equal( resolveContentLastModified({ previousDigest: 'new-digest', @@ -43,4 +52,14 @@ test('a changed semantic digest requires source-backed lastmod evidence', () => }), '2026-08-26T02:29:00.000Z', ) + assert.equal( + resolveContentLastModified({ + previousDigest: 'old-schema-digest', + currentDigest: 'new-schema-digest', + previousLastModified: '2026-08-26T02:29:00.000Z', + candidateLastModified: '2026-08-26T02:29:00.000Z', + evidenceSchemaChanged: true, + }), + '2026-08-26T02:29:00.000Z', + ) }) diff --git a/site/scripts/semantic-source-graph.mjs b/site/scripts/semantic-source-graph.mjs index 81babee..49be4e7 100644 --- a/site/scripts/semantic-source-graph.mjs +++ b/site/scripts/semantic-source-graph.mjs @@ -57,7 +57,8 @@ export function semanticRouteFiles(pathname) { .map((moduleId) => `site/${moduleId}`), ) const pending = [ - resolve(projectRoot, 'site/src/entry-server.tsx'), + resolve(projectRoot, 'site/src/components/layout/Shell.tsx'), + resolve(projectRoot, 'site/src/lib/pageMeta.ts'), resolve(projectRoot, 'site/src/lib/seoHead.ts'), ...routeModules.map((moduleId) => resolve(projectRoot, 'site', moduleId)), ] diff --git a/site/scripts/semantic-source-graph.test.mjs b/site/scripts/semantic-source-graph.test.mjs index 4835eee..0a42287 100644 --- a/site/scripts/semantic-source-graph.test.mjs +++ b/site/scripts/semantic-source-graph.test.mjs @@ -7,10 +7,10 @@ test('lesson evidence follows the full static semantic dependency graph', () => const files = new Set(semanticRouteFiles('/part/a/01')) for (const file of [ - 'site/src/entry-server.tsx', - 'site/src/app/AppContent.tsx', 'site/src/components/layout/Shell.tsx', 'site/src/config/site.ts', + 'site/src/lib/pageMeta.ts', + 'site/src/lib/seoHead.ts', 'site/src/pages/TypePage.tsx', 'site/src/components/PartGlyph.tsx', 'site/src/content/a/Knapsack01.tsx', @@ -21,6 +21,9 @@ test('lesson evidence follows the full static semantic dependency graph', () => } assert.equal(files.has('site/src/pages/Home.tsx'), false) assert.equal(files.has('site/src/data/routeLastModified.ts'), false) + assert.equal(files.has('site/src/analytics/AnalyticsRouteTracker.tsx'), false) + assert.equal(files.has('site/src/analytics/AnalyticsRuntime.tsx'), false) + assert.equal(files.has('site/src/entry-server.tsx'), false) }) test('home evidence includes its route-only motion controller', () => { diff --git a/site/scripts/seo-contract.test.mjs b/site/scripts/seo-contract.test.mjs index 572935c..1866a5c 100644 --- a/site/scripts/seo-contract.test.mjs +++ b/site/scripts/seo-contract.test.mjs @@ -3,7 +3,10 @@ import { readFile } from 'node:fs/promises' import test from 'node:test' import { SITE, SITE_ORIGIN } from '../src/config/site.ts' import { PARTS } from '../src/data/catalog.ts' -import { ROUTE_CONTENT_DIGESTS } from '../src/data/routeLastModified.ts' +import { + ROUTE_CONTENT_DIGEST_VERSION, + ROUTE_CONTENT_DIGESTS, +} from '../src/data/routeLastModified.ts' import { getPageMeta } from '../src/lib/pageMeta.ts' import { INTERNAL_PATHS, @@ -226,6 +229,7 @@ test('discovery files expose the 47 approved URLs and real summaries', async () assert.match(lastModified, /--format=%cI/) assert.match(lastModified, /normalizeContentForDigest/) assert.match(lastModified, /semanticRouteFiles/) + assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 1) assert.equal(Object.keys(ROUTE_CONTENT_DIGESTS).length, PUBLIC_PATHS.length) assert.ok(Object.values(ROUTE_CONTENT_DIGESTS).every((digest) => /^[0-9a-f]{64}$/.test(digest))) assert.match(publicRoutes, /\.\.\/data\/catalog\.ts/) diff --git a/site/src/data/routeLastModified.ts b/site/src/data/routeLastModified.ts index 3d510eb..d6f18f5 100644 --- a/site/src/data/routeLastModified.ts +++ b/site/src/data/routeLastModified.ts @@ -1,4 +1,5 @@ -// 由 scripts/generate-seo.mjs 从 Git 历史生成,请勿手改。 +// 由 scripts/generate-seo.mjs 从源码时间证据生成,请勿手改。 +export const ROUTE_CONTENT_DIGEST_VERSION = 1 export const ROUTE_LAST_MODIFIED: Readonly> = Object.freeze({ "/": "2026-08-23T21:18:04+08:00", "/part/a": "2026-08-23T21:18:04+08:00", @@ -49,51 +50,51 @@ export const ROUTE_LAST_MODIFIED: Readonly> = Object.free "/problems": "2026-08-23T21:18:04+08:00" }) export const ROUTE_CONTENT_DIGESTS: Readonly> = Object.freeze({ - "/": "ee02b161f7682cc9f793b118905fe20ddd8794cd83b0727f69001068af4ed2f3", - "/part/a": "bafa46e1b5091cc59fe66baf7e184bd8568b1e26628910a150c2ff29560a85ee", - "/part/b": "91b347f4e72f338b616c2e0921218aced5c5daa32ba51f85f955920f6dd2ae51", - "/part/c": "bc2b3b27df4afbd103a41c9b00be81421c060412d64c87a68910100e325ccffe", - "/part/d": "e5676766f2b3f317084487c3d73e83680f1144888a3236e3b782d54039fe880c", - "/part/e": "2cf296e9b93eeeb00c0324f87cc4f802ed8b4414a3bda7aa16b2c77468849672", - "/part/f": "9b72e1c9a3ecf1db07c49a0ad184e2a1da88f8954b3d8dd595d9e529e8c6560d", - "/part/g": "2c3cb75e8e3b6f2f01d10fa65d70e861d3d9d77dae1c36f677382f4dcb0bbae4", - "/part/a/01": "c339e04c7c9910abed66629923cea676f6badc0533f88d74e1f5a112c10a8343", - "/part/a/complete": "abfbdd77fe6b16fe13efcd88e736737bfd14e70c1e7b80cfc0ecebfac954a462", - "/part/a/multiple": "796d53696089cfc988ceac0612a1d0048d686d5b1e20dce3da366f77026d9abd", - "/part/a/group": "cd05c7c3e32a4aea7128e271895eef1fddc534c1b39fe26e8b9485af492f9476", - "/part/a/mixed": "444aa782391ccd20f3bce3ded147f262ca6e10fd1b9fe741a0ff40b787cb90d1", - "/part/a/cost2d": "d04eb3fbaa097b8e052e7236cef8f26fc1e10409be16a27fca05f437955c1142", - "/part/a/dep": "6ed8d6ef86929d950b2c9a55f9755ee83f09d6f0860830186308d780982cdf54", - "/part/a/variant": "d267a0fba75fbb1dc88a9c74eff8308f76dc2a1da44ad8b9482400d58bd6dc5a", - "/part/a/fractional": "1528887a58d6e01ba291a9bfd12fb19192d9247e1a5c6f675e90f2aeca231e30", - "/part/b/path": "da3b0107a483e519ae8864fa302c68a25ce4ce1da09558d4ec511bf9c4a706c7", - "/part/b/maxseg": "fdef46cc46a31ab42c424ce359790d36b8345ab5750a2798ec904b43cd359b0c", - "/part/b/lis": "2dc96d725e97a6e1d3d2c613df2ec940feec1e2f9c620024ddf223fd07b4e145", - "/part/b/lcs": "486dc3a8b1f03216f351153b18f2d0730ddc2d9ba3996650c1ab6865bc26c1dd", - "/part/b/edit": "fb29080d14ad49996383229e85e1cbfa45a95563124610de9dc26b594d22aaf5", - "/part/b/fsm": "2b18ff68c95c7a52b145bc440b5c51ae2aa656b4372ebc17f4b28d63920e2a39", - "/part/b/count": "46dcbcda30aa2ff823095f9aab3ceb6ac6729f7ca32a9f57e506e736e48d0b7f", - "/part/c/stone": "6ca7d6d0bb1c179b460c08df412bf430fc311bfbbcd778a0e18f1fae51004afd", - "/part/c/ring": "3fdb01eb627054a728d252d83e2662c6bb0186566fcf4668cbc24f1f1b9a6025", - "/part/c/palindrome": "7b377c1a58f9090de890aa9a00281c6df7ea77e8f1b90edc3bfe62222d71c02b", - "/part/c/tree": "367bf1e0b66f529b3370ff4a5b88d6c401a667fde32449cda25e1bcc5c4929ff", - "/part/c/merge": "b4274fbbed296c550fef515c98d2f57df1b5ce246ad4690d440a7d5789a3e10a", - "/part/d/grid": "03d50ce50fa1ddcd3e4683259238a74c8db83b17c84b18d38f374061e294ab7e", - "/part/d/matpow": "c7810f0f619e3ded6db4218d3d4e0bd625f9936cf636a46d8786400ff5f5c76b", - "/part/e/basic": "19ca55a1f071e1fbd12d23aeb86df257f568e23d34d5b9919d3be1d0cee01e14", - "/part/e/distsum": "659bbaa095beed9ae59709f5febb80f77d48cb1e11b1a1817c920d8afe5791d7", - "/part/e/inout": "11994e04551dfa6d9b6bbc7038512f33ea2fab8fe66bb6c82e9659aa1fcfecd2", - "/part/e/center": "6cf58aae33924fa129cc1cd0d3f1e4adb1295e353c84d211d77d718f3e5ad0da", - "/part/f/select": "3cf7cb5629daa5150f9d46ec188fd3cb997eaee92d0a79de7a48236d5735492b", - "/part/f/knapsack": "18d05f744d9b07b064601f3fa0eda75fd94a7ba576147fc6ed5652cee527ffe2", - "/part/f/diameter": "ac15e058a8e6845bfcdddde5972a896bdf5c1b3f0b80bdc52c32b733d46b647d", - "/part/f/cover": "74918ca74fbc10562dea6a5fac3da2ef32d4f1c459b40f8e510832cc0e19315d", - "/part/f/count": "41b239ba5e41087ec6a7ef0000b212b0e6a38f6f58e97ae0ef4a9867861f6939", - "/part/g/board": "abaacf900a0133083a5fbff8669e122690a8215aac796efdd670a85cebd67c1f", - "/part/g/tsp": "08d818ab1e4a48376e74454e9591f34ff97ba079baa10b9ccae4ac2184a97075", - "/part/g/cover": "8e36ffee3f2afc609587eb7f5f83b7eb2459c9e1b6fb8e4aaeaa6364ff2c9a05", - "/part/g/subset": "ffea580d381065c972eec809d71ef44c5df91ec67c366d2d4e4d2418567b803a", - "/part/g/plug": "6282151c35236589bdcdfc13609a02b29caf8fe09aedb4cc18b4dfeae2b1a77f", - "/method": "8f5a8d40119df560f8385b105333851a548190224690f4bdf07fc2793fb269df", - "/problems": "add2f52f4f7039f9cb81976f9ec0a9e75654ae6099a504dec2667cd553a9bc6a" + "/": "de153f96776b1abf0fde45eb8a813f88ad24ce2cef5485ed60555ac87d55c248", + "/part/a": "1fa33c7602559b15d569b7f3ef3e67f8d6f3d6a5398f623a55b02f9982e07d78", + "/part/b": "9e1834ce3df9a0db5a32812567a79b98becd322b43aa6f1d67365d338df627be", + "/part/c": "d9beea70b02400722ff79d1e7ac6ab05220482718dabcb652ccc40fa5e403336", + "/part/d": "558a6898494f4cd78b278ba5b93e3bdca55ac8bd059ad6d0cb5bfc9fab065bf0", + "/part/e": "0823d9780a3cbb441ec8c59ce640732a7d1ad282ad4f604d6d5e7e430e99d2af", + "/part/f": "d0411f0efb1695dd9c4e419be8852062746a49cca0b4d62939ad256df525139c", + "/part/g": "dc2171ceba20df888a0b6f0aa9121be8487fb7f02568437bb0cb51d06e2fed51", + "/part/a/01": "c9cad548af0e85c7c8d347f87348903652cee87fd4746330d96a29c06d5c4e10", + "/part/a/complete": "540088259c74021f8da6299669802883bae32bf70b70572cd382b1a1cd986b0d", + "/part/a/multiple": "cf46c73b71a90c48fc8d2b653f22280a650fd56920c452983767c830ee35a947", + "/part/a/group": "1ae18c2c99e6427f45499febd89fef5b4f2ee15b37cc64107d2c68144d55cacf", + "/part/a/mixed": "172f85af9e7b5ef93ea70bb1520be74c23ba6418c4e32876248604d257fd58e9", + "/part/a/cost2d": "768c86e9953b3fb00f4d31e18cba58a8e51741760935df917ba2f56af237766b", + "/part/a/dep": "1dec0a6a11a67329820a1825d1ad49abb83e108a99bac95d484be7d7c165e61a", + "/part/a/variant": "c53386ce74474f5ba80271ab3547be9e8669060075a988f3985db8c3a14d1123", + "/part/a/fractional": "6cf87d138ec42f3ce3d92a647b6462d5228fc656eda256e4990ed447f9dd182d", + "/part/b/path": "745379d6ae14411efb8db22d83725cace1e8cfc1531c9162501140a62a835614", + "/part/b/maxseg": "e4ecc05b1d22e7346c4917068fae6d57a1a22f84756fb04d49114c5a41827c5d", + "/part/b/lis": "345fb8bab922fb733f8b4eaf308b72cb1c8892c389dcd549ad94b8182280a056", + "/part/b/lcs": "56cfd6193128e94c115fea633754a1ccdedd0fea0f5db1e8c8741b093b1d3c14", + "/part/b/edit": "4991d26db88142f7878ba2b54c928a018c2ab526a23a8f37ef9009ca7090b767", + "/part/b/fsm": "6fc7eba768572aa5606c7d43429def728fbf08b86b36ef19feafcba20598a10f", + "/part/b/count": "ad5eeb773b38c5526f3c4d49badfff0f6905db9b981f9732f0ba138aefba89a1", + "/part/c/stone": "afad84fd2b955dd180a84c6a790101b99c6b6949d305a023fc0ac3191bce8b9b", + "/part/c/ring": "42d31d78ea4a56249cdfe0807c58ffb6b1348fa4b92493b6b4a34ba23ba8437a", + "/part/c/palindrome": "49e2a64621a1c20f5829a7f57aa725227611bf0bbd26b3c467e846743ba62ef3", + "/part/c/tree": "f0d10a53179f57a8b8bbd36ec8f004d7cc4a7cbaf7331a0ad4263938e355dda6", + "/part/c/merge": "dafdb7cd6b6ddf1636996ca822ed1ba69fedbd8dd1b22a4d70b7db4619eda2db", + "/part/d/grid": "378fa195af9f7e7636e746509da87e319fdd6cb2881412c5867b72cad98a5360", + "/part/d/matpow": "8a6c224fbb73b111226b445537beed683629badd2062e82e30745f1fb644cdf1", + "/part/e/basic": "3fe864c32b3aa73936e2b7ba3ac2b752f8930fad706af415421c4b4e3a77b286", + "/part/e/distsum": "b82fa3f180e2766cb322832122422f25c88999c1e5d809e08b7c667fd41d3c9b", + "/part/e/inout": "7e9ae8e4987579e6f5ddda3cd7e92718a4d5e70c87354926d9af73f5bfd9ed39", + "/part/e/center": "93c7742eee9db8a36dda6d94fb8e06345ecdb26f934a99a022ba77ae9af67bb5", + "/part/f/select": "c3cacd67e7b9563a918c2defe18d254ce6dcc551e4b23e0d2228b1cab754d8ab", + "/part/f/knapsack": "d749745b5559d1906e809c13aa015c4e89c076d026dd3e430ad1653ba8f23573", + "/part/f/diameter": "77ecc14d5d93594b90fdef4b36e94ad07294ac42bffdd745bb124bc7b551d4a3", + "/part/f/cover": "7e0201cd044771b718d5ba98be0b2cafdfdb2aee817e4c6ed4c602661a2f84f6", + "/part/f/count": "2e4cb9c3d1c5414664244ceb777a9a1e52e77d56ac7e8689e553744eabed81da", + "/part/g/board": "71d29f97bf13a02c8cafd2be24f18433a5f01db5e2ec854f0d484b95dc9d9c5f", + "/part/g/tsp": "abda035d3412451e9c91f8aaa70b21eeeb59c7c83f3114c51fd890b057626e58", + "/part/g/cover": "c4d56f9c6eb715667c78d68e0f1f36fdb0b300946b0c83af875a8dc6b7ae18e9", + "/part/g/subset": "d3aa22f7fd2436f174d33ae06b9e03bb49d7c26be5517341073220f46ec9e8c6", + "/part/g/plug": "07d2848b5b5037d62f56774746cbe367fe91581ad44786248b04a2fcdbf28f9c", + "/method": "df011a2015e65f552b7e9cbe2249a994c6f6479b54e7a19d159e567b1f806a5b", + "/problems": "d4cdac154d735c94e5af07cf1125e0fd5f038e387700cb5dc7934c6038d104cd" }) diff --git a/site/src/lib/discovery.ts b/site/src/lib/discovery.ts index 8be4471..348d8b6 100644 --- a/site/src/lib/discovery.ts +++ b/site/src/lib/discovery.ts @@ -151,7 +151,7 @@ export function generateDiscoveryFiles( '- 引用课程时优先使用对应课程 URL、页面标题和摘要,不要改写状态定义与转移方程。', '- 每条 URL 都是可直接访问的预渲染页面,正文在首屏 HTML 中即可读到,无需执行 JavaScript。', '- 题目内容采用教学摘要并链接原题,不复刻完整题面;转述题目时请指向原题链接。', - `- 课程由${BRAND.owner}持续维护;“最近更新”取自构建时 Git 历史,不使用模板日期。`, + `- 课程由${BRAND.owner}持续维护;“最近更新”取自源码时间证据(已提交源码用 Git 提交时间,工作树编辑用文件修改时间),不使用模板或构建日期。`, '', ].join('\n') From 1f0971e8eeb0c9db9da9d6ef6ff83ba8855837dd Mon Sep 17 00:00:00 2001 From: Shanire Date: Wed, 26 Aug 2026 11:29:22 +0800 Subject: [PATCH 04/23] fix: make route evidence semantically precise --- docs/engineering/architecture.md | 2 +- docs/operations/verification.md | 2 +- site/scripts/last-modified.mjs | 20 +++-- site/scripts/last-modified.test.mjs | 10 +++ site/scripts/semantic-source-graph.mjs | 38 +++++++- site/scripts/semantic-source-graph.test.mjs | 80 ++++++++++++++++- site/scripts/seo-contract.test.mjs | 2 +- site/src/data/routeLastModified.ts | 96 ++++++++++----------- 8 files changed, 187 insertions(+), 63 deletions(-) diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index 56e677e..30ef075 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -108,7 +108,7 @@ For a registered public `GET` or `HEAD`, `worker/content-negotiation.js` selects `seoHead.ts` renders the same head contract at build time that `RouteMeta` maintains after client navigation: title, description, citable abstract, robots, canonical, Open Graph, Twitter, and a Schema.org graph. Every indexable page includes Organization and WebSite nodes and states `isAccessibleForFree`; the WebSite node declares a `SearchAction` pointing at the real `?q=` problem search. Lessons add Course/LearningResource/TechArticle with `educationalUse` and an `EducationalAudience`, family pages add CollectionPage plus an ItemList of their ready lessons, other pages add WebPage, and routes with hierarchy add BreadcrumbList. Course duration is deliberately not declared: there is no trustworthy source for it, so `hasCourseInstance` is omitted rather than fabricated. -`site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. `semantic-source-graph.mjs` starts from the shared `
` shell and metadata roots plus the exact route page, lesson, and family-art modules, then follows their complete static import graph while excluding unrelated route entry points, client-only analytics, and the generated evidence module itself. Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. Clean sources use the latest Git commit timestamp, dirty sources use the newest source-file modification timestamp, an unchanged digest preserves the previous value, and generation fails when changed content lacks strictly advancing source evidence; changing the digest schema preserves the prior content timestamp while replacing only the evidence. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. +`site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. `semantic-source-graph.mjs` follows the real SSR injection chain from `entry-server.tsx` through the static application/providers and route tree, together with metadata roots and the exact route page, lesson, and family-art modules. Mixed shell modules contribute only their semantic route-rendering JSX slices; the graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. Clean sources use the latest Git commit timestamp, dirty sources use the newest source-file modification timestamp, an unchanged digest preserves the previous value, and generation fails when changed content lacks strictly advancing source evidence; changing the digest schema preserves the prior content timestamp only when no newer source evidence exists, while a simultaneous real content update adopts its later timestamp. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. # Analytics diff --git a/docs/operations/verification.md b/docs/operations/verification.md index 3580b58..dabcaae 100644 --- a/docs/operations/verification.md +++ b/docs/operations/verification.md @@ -44,7 +44,7 @@ Pure TypeScript Modules imported by Node tests must use explicit `.ts` extension # SEO And Accessibility Checks -`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. A line-ending-neutral SHA-256 digest follows each route's complete static semantic dependency graph. Clean sources use their latest Git commit timestamp; dirty sources use their newest filesystem modification timestamp; and generation fails if the digest changes without that evidence advancing. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. +`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. A line-ending-neutral SHA-256 digest follows each route's real SSR injection chain and exact semantic content graph while pruning navigation, feedback, analytics, theme, and motion-only branches. Mixed shell modules contribute only their route-rendering JSX slices. Clean sources use their latest Git commit timestamp; dirty sources use their newest filesystem modification timestamp; and generation fails if the digest changes without that evidence advancing. A digest-schema migration preserves the prior timestamp only when it has no newer source evidence; a simultaneous content edit must still advance. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. The browser gate runs Chromium against built `dist/` through a strict, non-reused custom preview server. Route tests directly open `/`, `/part/a`, `/part/a/01`, `/method`, and `/part/g/plug`, then exercise live client navigation and keyboard focus. They check HTTP status, prerendered HTML, absence of pending streamed-Suspense placeholders, route CSS at first paint, hydration without errors, CLS below `0.05`, title/description/abstract/canonical/zero-hreflang/Open Graph/JSON-LD metadata, one visible `h1`, route announcements, current-page semantics, initial-load focus, changed-route focus, and skip-link focus. Navigation-positioning tests click a representative lesson outline in each of the seven families, restore a direct lesson fragment, verify the mobile outline and an ordinary family-page fragment, and require a changed route to reset old-page scroll immediately. An unknown path must return 404 with `noindex,nofollow`, no canonical, and the themed not-found heading. Worker-level Node tests separately cover the Accept matrix, 406 behavior, HEAD parity, representation-specific ETags, hidden internal assets, and unaffected API/static/unknown paths. diff --git a/site/scripts/last-modified.mjs b/site/scripts/last-modified.mjs index a103eb1..e584d35 100644 --- a/site/scripts/last-modified.mjs +++ b/site/scripts/last-modified.mjs @@ -3,10 +3,13 @@ import { createHash } from 'node:crypto' import { readFileSync, statSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { PUBLIC_PATHS } from '../src/lib/publicRoutes.ts' -import { semanticRouteFiles } from './semantic-source-graph.mjs' +import { + semanticRouteFiles, + semanticSourceForDigest, +} from './semantic-source-graph.mjs' const projectRoot = fileURLToPath(new URL('../../', import.meta.url)) -export const ROUTE_CONTENT_DIGEST_VERSION = 1 +export const ROUTE_CONTENT_DIGEST_VERSION = 2 function gitNames(args) { const result = spawnSync('git', args, { @@ -35,9 +38,8 @@ function routeContentDigest(files) { for (const file of files) { digest.update(file) digest.update('\0') - digest.update(normalizeContentForDigest( - readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8'), - )) + const source = readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8') + digest.update(normalizeContentForDigest(semanticSourceForDigest(file, source))) digest.update('\0') } return digest.digest('hex') @@ -85,16 +87,20 @@ export function resolveContentLastModified({ return previousLastModified } if (previousDigest && previousDigest !== currentDigest && previousLastModified) { - if (evidenceSchemaChanged) return previousLastModified const previousTime = Date.parse(previousLastModified) const candidateTime = Date.parse(candidateLastModified) if ( Number.isNaN(previousTime) || Number.isNaN(candidateTime) - || candidateTime <= previousTime ) { throw new Error(`Semantic content changed without lastmod advancing for ${pathname}`) } + if (evidenceSchemaChanged && candidateTime <= previousTime) { + return previousLastModified + } + if (candidateTime <= previousTime) { + throw new Error(`Semantic content changed without lastmod advancing for ${pathname}`) + } } return candidateLastModified } diff --git a/site/scripts/last-modified.test.mjs b/site/scripts/last-modified.test.mjs index 86947dd..d09dc07 100644 --- a/site/scripts/last-modified.test.mjs +++ b/site/scripts/last-modified.test.mjs @@ -62,4 +62,14 @@ test('a changed semantic digest requires source-backed lastmod evidence', () => }), '2026-08-26T02:29:00.000Z', ) + assert.equal( + resolveContentLastModified({ + previousDigest: 'old-schema-digest', + currentDigest: 'new-schema-and-content-digest', + previousLastModified: '2026-08-26T02:29:00.000Z', + candidateLastModified: '2026-08-26T03:29:00.000Z', + evidenceSchemaChanged: true, + }), + '2026-08-26T03:29:00.000Z', + ) }) diff --git a/site/scripts/semantic-source-graph.mjs b/site/scripts/semantic-source-graph.mjs index 49be4e7..d9cc818 100644 --- a/site/scripts/semantic-source-graph.mjs +++ b/site/scripts/semantic-source-graph.mjs @@ -9,6 +9,41 @@ const projectRoot = fileURLToPath(new URL('../../', import.meta.url)) const PARSED_EXTENSIONS = new Set(['.js', '.jsx', '.mjs', '.ts', '.tsx']) const RESOLVED_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json'] const SELF_GENERATED_FILES = new Set(['site/src/data/routeLastModified.ts']) +const NON_SEMANTIC_FILES = new Set([ + 'site/src/components/feedback/FeedbackWidget.tsx', + 'site/src/components/layout/ErrorBoundary.tsx', + 'site/src/components/layout/Sidebar.tsx', + 'site/src/components/layout/TopBar.tsx', + 'site/src/components/seo/RouteMeta.tsx', + 'site/src/lib/hashNavigation.ts', + 'site/src/pages/HomeMotionController.tsx', + 'site/src/theme/ThemeContext.tsx', +]) +const NON_SEMANTIC_PREFIXES = ['site/src/analytics/'] +const SEMANTIC_ELEMENT_SLICES = new Map([ + ['site/src/app/AppContent.tsx', ['', '']], + ['site/src/components/layout/Shell.tsx', ['
']], + ['site/src/components/layout/RouteStage.tsx', ['']], +]) + +function isNonSemanticFile(path) { + return ( + NON_SEMANTIC_FILES.has(path) + || NON_SEMANTIC_PREFIXES.some((prefix) => path.startsWith(prefix)) + ) +} + +export function semanticSourceForDigest(path, source) { + const markers = SEMANTIC_ELEMENT_SLICES.get(path) + if (!markers) return source + const [startMarker, endMarker] = markers + const start = source.indexOf(startMarker) + const end = source.indexOf(endMarker, start) + if (start < 0 || end < 0) { + throw new Error(`Missing semantic source slice for ${path}`) + } + return source.slice(start, end + endMarker.length) +} function projectPath(path) { return relative(projectRoot, path).replaceAll('\\', '/') @@ -57,7 +92,7 @@ export function semanticRouteFiles(pathname) { .map((moduleId) => `site/${moduleId}`), ) const pending = [ - resolve(projectRoot, 'site/src/components/layout/Shell.tsx'), + resolve(projectRoot, 'site/src/entry-server.tsx'), resolve(projectRoot, 'site/src/lib/pageMeta.ts'), resolve(projectRoot, 'site/src/lib/seoHead.ts'), ...routeModules.map((moduleId) => resolve(projectRoot, 'site', moduleId)), @@ -69,6 +104,7 @@ export function semanticRouteFiles(pathname) { if (!path) continue const relativePath = projectPath(path) if (SELF_GENERATED_FILES.has(relativePath)) continue + if (isNonSemanticFile(relativePath)) continue if ( routePages.has(relativePath) && !allowedPages.has(relativePath) diff --git a/site/scripts/semantic-source-graph.test.mjs b/site/scripts/semantic-source-graph.test.mjs index 0a42287..a52444f 100644 --- a/site/scripts/semantic-source-graph.test.mjs +++ b/site/scripts/semantic-source-graph.test.mjs @@ -1,13 +1,23 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { semanticRouteFiles } from './semantic-source-graph.mjs' +import { PUBLIC_PATHS } from '../src/lib/publicRoutes.ts' +import { + semanticRouteFiles, + semanticSourceForDigest, +} from './semantic-source-graph.mjs' test('lesson evidence follows the full static semantic dependency graph', () => { const files = new Set(semanticRouteFiles('/part/a/01')) for (const file of [ + 'site/src/entry-server.tsx', + 'site/src/app/StaticApp.tsx', + 'site/src/app/AppContent.tsx', + 'site/src/app/StaticLessonContentContext.tsx', + 'site/src/components/art/StaticFamilyArtContext.tsx', 'site/src/components/layout/Shell.tsx', + 'site/src/components/layout/RouteStage.tsx', 'site/src/config/site.ts', 'site/src/lib/pageMeta.ts', 'site/src/lib/seoHead.ts', @@ -21,15 +31,77 @@ test('lesson evidence follows the full static semantic dependency graph', () => } assert.equal(files.has('site/src/pages/Home.tsx'), false) assert.equal(files.has('site/src/data/routeLastModified.ts'), false) + assert.equal(files.has('site/src/components/feedback/FeedbackWidget.tsx'), false) + assert.equal(files.has('site/src/components/layout/Sidebar.tsx'), false) + assert.equal(files.has('site/src/components/layout/TopBar.tsx'), false) + assert.equal(files.has('site/src/theme/ThemeContext.tsx'), false) + assert.equal(files.has('site/src/analytics/index.ts'), false) assert.equal(files.has('site/src/analytics/AnalyticsRouteTracker.tsx'), false) assert.equal(files.has('site/src/analytics/AnalyticsRuntime.tsx'), false) - assert.equal(files.has('site/src/entry-server.tsx'), false) }) -test('home evidence includes its route-only motion controller', () => { +test('home evidence includes semantic content and excludes its motion-only controller', () => { const files = new Set(semanticRouteFiles('/')) assert.ok(files.has('site/src/pages/Home.tsx')) - assert.ok(files.has('site/src/pages/HomeMotionController.tsx')) + assert.ok(files.has('site/src/components/PartGlyph.tsx')) + assert.equal(files.has('site/src/pages/HomeMotionController.tsx'), false) assert.equal(files.has('site/src/pages/TypePage.tsx'), false) }) + +test('all public route evidence excludes non-semantic client branches', () => { + const forbiddenFiles = new Set([ + 'site/src/components/feedback/FeedbackWidget.tsx', + 'site/src/components/layout/ErrorBoundary.tsx', + 'site/src/components/layout/Sidebar.tsx', + 'site/src/components/layout/TopBar.tsx', + 'site/src/components/seo/RouteMeta.tsx', + 'site/src/lib/hashNavigation.ts', + 'site/src/pages/HomeMotionController.tsx', + 'site/src/theme/ThemeContext.tsx', + ]) + for (const pathname of PUBLIC_PATHS) { + const files = semanticRouteFiles(pathname) + assert.equal( + files.some((file) => ( + forbiddenFiles.has(file) || file.startsWith('site/src/analytics/') + )), + false, + pathname, + ) + } +}) + +test('mixed shell modules hash only their semantic rendering slice', () => { + const shellBefore = `useEffect(() => first())\n
` + const shellAfterClientChange = `useEffect(() => second())\n
` + const shellAfterSemanticChange = `useEffect(() => second())\n

正文

` + + assert.equal( + semanticSourceForDigest('site/src/components/layout/Shell.tsx', shellBefore), + semanticSourceForDigest('site/src/components/layout/Shell.tsx', shellAfterClientChange), + ) + assert.notEqual( + semanticSourceForDigest('site/src/components/layout/Shell.tsx', shellBefore), + semanticSourceForDigest('site/src/components/layout/Shell.tsx', shellAfterSemanticChange), + ) + + assert.equal( + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + '', + ), + '', + ) + assert.equal( + semanticSourceForDigest( + 'site/src/components/layout/RouteStage.tsx', + 'useEffect(() => clientOnly())\n{outlet}', + ), + '{outlet}', + ) + assert.throws( + () => semanticSourceForDigest('site/src/components/layout/Shell.tsx', '
'), + /Missing semantic source slice/, + ) +}) diff --git a/site/scripts/seo-contract.test.mjs b/site/scripts/seo-contract.test.mjs index 1866a5c..0761c6f 100644 --- a/site/scripts/seo-contract.test.mjs +++ b/site/scripts/seo-contract.test.mjs @@ -229,7 +229,7 @@ test('discovery files expose the 47 approved URLs and real summaries', async () assert.match(lastModified, /--format=%cI/) assert.match(lastModified, /normalizeContentForDigest/) assert.match(lastModified, /semanticRouteFiles/) - assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 1) + assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 2) assert.equal(Object.keys(ROUTE_CONTENT_DIGESTS).length, PUBLIC_PATHS.length) assert.ok(Object.values(ROUTE_CONTENT_DIGESTS).every((digest) => /^[0-9a-f]{64}$/.test(digest))) assert.match(publicRoutes, /\.\.\/data\/catalog\.ts/) diff --git a/site/src/data/routeLastModified.ts b/site/src/data/routeLastModified.ts index d6f18f5..c8a3a97 100644 --- a/site/src/data/routeLastModified.ts +++ b/site/src/data/routeLastModified.ts @@ -1,5 +1,5 @@ // 由 scripts/generate-seo.mjs 从源码时间证据生成,请勿手改。 -export const ROUTE_CONTENT_DIGEST_VERSION = 1 +export const ROUTE_CONTENT_DIGEST_VERSION = 2 export const ROUTE_LAST_MODIFIED: Readonly> = Object.freeze({ "/": "2026-08-23T21:18:04+08:00", "/part/a": "2026-08-23T21:18:04+08:00", @@ -50,51 +50,51 @@ export const ROUTE_LAST_MODIFIED: Readonly> = Object.free "/problems": "2026-08-23T21:18:04+08:00" }) export const ROUTE_CONTENT_DIGESTS: Readonly> = Object.freeze({ - "/": "de153f96776b1abf0fde45eb8a813f88ad24ce2cef5485ed60555ac87d55c248", - "/part/a": "1fa33c7602559b15d569b7f3ef3e67f8d6f3d6a5398f623a55b02f9982e07d78", - "/part/b": "9e1834ce3df9a0db5a32812567a79b98becd322b43aa6f1d67365d338df627be", - "/part/c": "d9beea70b02400722ff79d1e7ac6ab05220482718dabcb652ccc40fa5e403336", - "/part/d": "558a6898494f4cd78b278ba5b93e3bdca55ac8bd059ad6d0cb5bfc9fab065bf0", - "/part/e": "0823d9780a3cbb441ec8c59ce640732a7d1ad282ad4f604d6d5e7e430e99d2af", - "/part/f": "d0411f0efb1695dd9c4e419be8852062746a49cca0b4d62939ad256df525139c", - "/part/g": "dc2171ceba20df888a0b6f0aa9121be8487fb7f02568437bb0cb51d06e2fed51", - "/part/a/01": "c9cad548af0e85c7c8d347f87348903652cee87fd4746330d96a29c06d5c4e10", - "/part/a/complete": "540088259c74021f8da6299669802883bae32bf70b70572cd382b1a1cd986b0d", - "/part/a/multiple": "cf46c73b71a90c48fc8d2b653f22280a650fd56920c452983767c830ee35a947", - "/part/a/group": "1ae18c2c99e6427f45499febd89fef5b4f2ee15b37cc64107d2c68144d55cacf", - "/part/a/mixed": "172f85af9e7b5ef93ea70bb1520be74c23ba6418c4e32876248604d257fd58e9", - "/part/a/cost2d": "768c86e9953b3fb00f4d31e18cba58a8e51741760935df917ba2f56af237766b", - "/part/a/dep": "1dec0a6a11a67329820a1825d1ad49abb83e108a99bac95d484be7d7c165e61a", - "/part/a/variant": "c53386ce74474f5ba80271ab3547be9e8669060075a988f3985db8c3a14d1123", - "/part/a/fractional": "6cf87d138ec42f3ce3d92a647b6462d5228fc656eda256e4990ed447f9dd182d", - "/part/b/path": "745379d6ae14411efb8db22d83725cace1e8cfc1531c9162501140a62a835614", - "/part/b/maxseg": "e4ecc05b1d22e7346c4917068fae6d57a1a22f84756fb04d49114c5a41827c5d", - "/part/b/lis": "345fb8bab922fb733f8b4eaf308b72cb1c8892c389dcd549ad94b8182280a056", - "/part/b/lcs": "56cfd6193128e94c115fea633754a1ccdedd0fea0f5db1e8c8741b093b1d3c14", - "/part/b/edit": "4991d26db88142f7878ba2b54c928a018c2ab526a23a8f37ef9009ca7090b767", - "/part/b/fsm": "6fc7eba768572aa5606c7d43429def728fbf08b86b36ef19feafcba20598a10f", - "/part/b/count": "ad5eeb773b38c5526f3c4d49badfff0f6905db9b981f9732f0ba138aefba89a1", - "/part/c/stone": "afad84fd2b955dd180a84c6a790101b99c6b6949d305a023fc0ac3191bce8b9b", - "/part/c/ring": "42d31d78ea4a56249cdfe0807c58ffb6b1348fa4b92493b6b4a34ba23ba8437a", - "/part/c/palindrome": "49e2a64621a1c20f5829a7f57aa725227611bf0bbd26b3c467e846743ba62ef3", - "/part/c/tree": "f0d10a53179f57a8b8bbd36ec8f004d7cc4a7cbaf7331a0ad4263938e355dda6", - "/part/c/merge": "dafdb7cd6b6ddf1636996ca822ed1ba69fedbd8dd1b22a4d70b7db4619eda2db", - "/part/d/grid": "378fa195af9f7e7636e746509da87e319fdd6cb2881412c5867b72cad98a5360", - "/part/d/matpow": "8a6c224fbb73b111226b445537beed683629badd2062e82e30745f1fb644cdf1", - "/part/e/basic": "3fe864c32b3aa73936e2b7ba3ac2b752f8930fad706af415421c4b4e3a77b286", - "/part/e/distsum": "b82fa3f180e2766cb322832122422f25c88999c1e5d809e08b7c667fd41d3c9b", - "/part/e/inout": "7e9ae8e4987579e6f5ddda3cd7e92718a4d5e70c87354926d9af73f5bfd9ed39", - "/part/e/center": "93c7742eee9db8a36dda6d94fb8e06345ecdb26f934a99a022ba77ae9af67bb5", - "/part/f/select": "c3cacd67e7b9563a918c2defe18d254ce6dcc551e4b23e0d2228b1cab754d8ab", - "/part/f/knapsack": "d749745b5559d1906e809c13aa015c4e89c076d026dd3e430ad1653ba8f23573", - "/part/f/diameter": "77ecc14d5d93594b90fdef4b36e94ad07294ac42bffdd745bb124bc7b551d4a3", - "/part/f/cover": "7e0201cd044771b718d5ba98be0b2cafdfdb2aee817e4c6ed4c602661a2f84f6", - "/part/f/count": "2e4cb9c3d1c5414664244ceb777a9a1e52e77d56ac7e8689e553744eabed81da", - "/part/g/board": "71d29f97bf13a02c8cafd2be24f18433a5f01db5e2ec854f0d484b95dc9d9c5f", - "/part/g/tsp": "abda035d3412451e9c91f8aaa70b21eeeb59c7c83f3114c51fd890b057626e58", - "/part/g/cover": "c4d56f9c6eb715667c78d68e0f1f36fdb0b300946b0c83af875a8dc6b7ae18e9", - "/part/g/subset": "d3aa22f7fd2436f174d33ae06b9e03bb49d7c26be5517341073220f46ec9e8c6", - "/part/g/plug": "07d2848b5b5037d62f56774746cbe367fe91581ad44786248b04a2fcdbf28f9c", - "/method": "df011a2015e65f552b7e9cbe2249a994c6f6479b54e7a19d159e567b1f806a5b", - "/problems": "d4cdac154d735c94e5af07cf1125e0fd5f038e387700cb5dc7934c6038d104cd" + "/": "389b40caf8178d5484a97929f773cf376056478405f91dee11d853763ee6127b", + "/part/a": "07a1391b2ead879ee375b5dad1583101d639f483d95f7d496097563e7fcb9e82", + "/part/b": "dc246b5c2d8fff3f9ddad508225e78dc4096ae44c50fb51a0d6ca65057601a8d", + "/part/c": "c19cc82b706a1efc6e2b010b2bf67c3414b8a72fdb5a4528d18b53eaf65db910", + "/part/d": "c1474ed2e118d64a2ff79f044d8e79aecf964cd8d1b0e3469cc6a68f03654f24", + "/part/e": "58c81d6f8e0e06cdd3d82e256700af45fc001aa5095d0acb8896d81b2185e7ef", + "/part/f": "4348f443c4db61544c6bf0d7683a9c30a9abb18cbcd459a7aa8b58815b6f4304", + "/part/g": "5dcb7cfaa891b1c27290a010e3d14edf78dff855787a2831a6b10edfc5b93cf6", + "/part/a/01": "7bc2c1869b0ae5b288c55ffbe881ed97e19e55b83477640c8ab5d6e4118c5e7a", + "/part/a/complete": "fc5b06f0b206d9eb7fb1a583c5eb9c872b8e3499beaff69bd1f04dd79299d897", + "/part/a/multiple": "a6655896743fbd4dd75494bc25ec7307020a3fbeb56c1daaff4ca64e0ecc3022", + "/part/a/group": "6e1e05afa630026a4d4e4da6d586ec24a9c21fad479e2b2c291ed9e3d60beee6", + "/part/a/mixed": "06ed86412ce201d258dfa1e1ed6d12f42eae27ed8d14afa340b39a4f790ab4a6", + "/part/a/cost2d": "ee0ebdffed0f6b51da878f25d69553ee95bd77ac6a77d3c3788f65d629de096f", + "/part/a/dep": "9772ff5397325c40f76dd929e6c47ad4e20039470f6a38aecdbe6726ae00ec82", + "/part/a/variant": "286560b5249d56b9f3964cdcfda7d88b0d089c512ac8ab73259f92271434a867", + "/part/a/fractional": "dd8175ae6502099e6c256083fb267ba9ea6956461deb06934304aedd0bfbab81", + "/part/b/path": "5535b0072628f8f56a8f659d980561c2298366df11a3599b20bce3562e2d97c9", + "/part/b/maxseg": "0147406927db44da48eb33a5d1708dbb24cc80b29f16b7e1535dca57688a13f2", + "/part/b/lis": "5afef5ef17f4b25dafab109803d50833d9fa4d1b80c09e35395eef413d466f1c", + "/part/b/lcs": "49e36a2b47b2898c67f68dc09e8d2f828c0f39ed687e2051c161c438055b81f0", + "/part/b/edit": "6f25e9f4a79f1f09606207dad5df2a96bad312b5dc7ff700dd42ede6d3576ace", + "/part/b/fsm": "c591b7301cf2cfe745832cd2748f5633ee34583ab632df55c96f804aeb4a6b16", + "/part/b/count": "0a318f0125f2ae068911d633c36f693459a7e8677af86ad0f264263bcbe701ec", + "/part/c/stone": "b42fbf5f86da0b7392acaa712ce474184c8a6d883b20f24f9d0251f803861e97", + "/part/c/ring": "a620b6a2c3f0a9dfaf6628fb59507d52175a22d442c3f98f5591a1004cf3f83c", + "/part/c/palindrome": "679dc05c65ac113024b7f4bd70df6a15801781e794654c00df26b139d851dd6a", + "/part/c/tree": "5e57cce1b0e2c5efda6a14470d9746162f4373411cafe347f5fefa7bf907fb02", + "/part/c/merge": "60f55106849e33137ed346d07b84b901638208dd94889980808559838bb91d4e", + "/part/d/grid": "51a007b1d588ef1a55a4544b7b317a566dc66b37e5ba2bb114f48fe5d3c81ce9", + "/part/d/matpow": "4719380a30939188b50773353c1281424bd38faf9e0548f1a8f6eca903a06a94", + "/part/e/basic": "8e998a60c9fad45777649166d9cac1390337f1710b2013aa9d987b43a7976eb7", + "/part/e/distsum": "deac32fc7c803232f2936aaf6728c6e696d15816e825b4f19651bd27cb3ebc12", + "/part/e/inout": "210fe09516169de66251416841326e808407b30a6e5dafc0de3ceadefbddef6c", + "/part/e/center": "9ca49b2a02899a81dadb4ccbbcd27e6037b80ba63c3c2a8139e5011bb9829393", + "/part/f/select": "5d60d673ecc83c21babc3fc07424245cf14f049fc018968b8e94874fe7c3eefa", + "/part/f/knapsack": "dffc5f57b23919de4cc1bf8ebf45057662add918e0e25d5e45c52c57a0d072af", + "/part/f/diameter": "a2081c868c6c45f0aff1ef357904c4038f13a76098cfe25794f247b28d7a230d", + "/part/f/cover": "0839f6a76c351ca4b1c2c8440f94f9630c91d87a71a829ae7bae3798ed3f3a1f", + "/part/f/count": "beb4667cfc3c48d63f030c4cc091a41a9484cd899bd43b7974c779dc58102ac8", + "/part/g/board": "f19569ad3ff789260b2ff84fbdba2dc8abd466fa0e703830b60b3bed3762c03a", + "/part/g/tsp": "c60c2b43afc75f6934e9ffd88cc579b3e634e36108e74957032d52ae53eeaf94", + "/part/g/cover": "5a659bd25da978214baf576ef3a22e380853177569063a1c3b5bf62deb951f65", + "/part/g/subset": "3996543c5b32207a88372c3df091b395b5381d51b897bb0f4e7bbaaa9e420c03", + "/part/g/plug": "789a4eba1584bc1e0a53fcbdec6d5d2c4cffd148503c6bc648103d99e2a0b7bd", + "/method": "0c91718a2b8c0e9c9c476d22f912c60cef246de06d5b65942a6f3d77da77b721", + "/problems": "66d9b58ef67db6cf928c0285727f065a708e693f315b02f0f36befbc24c455b1" }) From 9d41da1a7ec79b0dd037b5c32eed19479616c309 Mon Sep 17 00:00:00 2001 From: Shanire Date: Wed, 26 Aug 2026 11:45:26 +0800 Subject: [PATCH 05/23] fix: close semantic evidence blind spots Signed-off-by: Shanire --- docs/engineering/architecture.md | 2 +- docs/operations/verification.md | 2 +- site/index.html | 2 +- site/public/llms.txt | 94 +++++----- site/public/route-summaries.json | 94 +++++----- site/public/sitemap.xml | 94 +++++----- site/scripts/semantic-source-graph.mjs | 100 +++++++++-- site/scripts/semantic-source-graph.test.mjs | 52 ++++-- site/src/data/routeLastModified.ts | 188 ++++++++++---------- 9 files changed, 362 insertions(+), 266 deletions(-) diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index 30ef075..0ae19db 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -108,7 +108,7 @@ For a registered public `GET` or `HEAD`, `worker/content-negotiation.js` selects `seoHead.ts` renders the same head contract at build time that `RouteMeta` maintains after client navigation: title, description, citable abstract, robots, canonical, Open Graph, Twitter, and a Schema.org graph. Every indexable page includes Organization and WebSite nodes and states `isAccessibleForFree`; the WebSite node declares a `SearchAction` pointing at the real `?q=` problem search. Lessons add Course/LearningResource/TechArticle with `educationalUse` and an `EducationalAudience`, family pages add CollectionPage plus an ItemList of their ready lessons, other pages add WebPage, and routes with hierarchy add BreadcrumbList. Course duration is deliberately not declared: there is no trustworthy source for it, so `hasCourseInstance` is omitted rather than fabricated. -`site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. `semantic-source-graph.mjs` follows the real SSR injection chain from `entry-server.tsx` through the static application/providers and route tree, together with metadata roots and the exact route page, lesson, and family-art modules. Mixed shell modules contribute only their semantic route-rendering JSX slices; the graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. Clean sources use the latest Git commit timestamp, dirty sources use the newest source-file modification timestamp, an unchanged digest preserves the previous value, and generation fails when changed content lacks strictly advancing source evidence; changing the digest schema preserves the prior content timestamp only when no newer source evidence exists, while a simultaneous real content update adopts its later timestamp. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. +`site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. `semantic-source-graph.mjs` follows the real SSR injection chain from `entry-server.tsx` through the static application/providers and route tree, together with metadata roots and the exact route page, lesson, and family-art modules. Mixed shell modules use an AST-rooted projection: the route-rendering JSX plus every local declaration it references are hashed, so render helpers and route/outlet bindings remain evidence while unrelated effects do not. The graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. Clean sources use the latest Git commit timestamp, dirty sources use the newest source-file modification timestamp, an unchanged digest preserves the previous value, and generation fails when changed content lacks strictly advancing source evidence; changing the digest schema preserves the prior content timestamp only when no newer source evidence exists, while a simultaneous real content update adopts its later timestamp. Version 2 was seeded from the last trusted pre-digest baseline (`2026-08-22`) rather than the invalid version 1 timestamps that a shared client-effect change had advanced. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. # Analytics diff --git a/docs/operations/verification.md b/docs/operations/verification.md index dabcaae..30ad963 100644 --- a/docs/operations/verification.md +++ b/docs/operations/verification.md @@ -44,7 +44,7 @@ Pure TypeScript Modules imported by Node tests must use explicit `.ts` extension # SEO And Accessibility Checks -`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. A line-ending-neutral SHA-256 digest follows each route's real SSR injection chain and exact semantic content graph while pruning navigation, feedback, analytics, theme, and motion-only branches. Mixed shell modules contribute only their route-rendering JSX slices. Clean sources use their latest Git commit timestamp; dirty sources use their newest filesystem modification timestamp; and generation fails if the digest changes without that evidence advancing. A digest-schema migration preserves the prior timestamp only when it has no newer source evidence; a simultaneous content edit must still advance. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. +`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. A line-ending-neutral SHA-256 digest follows each route's real SSR injection chain and exact semantic content graph while pruning navigation, feedback, analytics, theme, and motion-only branches. Mixed shell modules contribute an AST-rooted closure of route-rendering JSX plus its referenced local declarations, including render helpers and route/outlet bindings. Clean sources use their latest Git commit timestamp; dirty sources use their newest filesystem modification timestamp; and generation fails if the digest changes without that evidence advancing. A digest-schema migration preserves the prior timestamp only when it has no newer source evidence; a simultaneous content edit must still advance. Version 2 was explicitly reseeded from the trusted `2026-08-22` baseline because version 1 had incorrectly advanced every route for a shared client-effect-only change. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. The browser gate runs Chromium against built `dist/` through a strict, non-reused custom preview server. Route tests directly open `/`, `/part/a`, `/part/a/01`, `/method`, and `/part/g/plug`, then exercise live client navigation and keyboard focus. They check HTTP status, prerendered HTML, absence of pending streamed-Suspense placeholders, route CSS at first paint, hydration without errors, CLS below `0.05`, title/description/abstract/canonical/zero-hreflang/Open Graph/JSON-LD metadata, one visible `h1`, route announcements, current-page semantics, initial-load focus, changed-route focus, and skip-link focus. Navigation-positioning tests click a representative lesson outline in each of the seven families, restore a direct lesson fragment, verify the mobile outline and an ordinary family-page fragment, and require a changed route to reset old-page scroll immediately. An unknown path must return 404 with `noindex,nofollow`, no canonical, and the themed not-found heading. Worker-level Node tests separately cover the Accept matrix, 406 behavior, HEAD parity, representation-specific ETags, hidden internal assets, and unaffected API/static/unknown paths. diff --git a/site/index.html b/site/index.html index ca89ca3..3e9c867 100644 --- a/site/index.html +++ b/site/index.html @@ -51,7 +51,7 @@ - + diff --git a/site/public/llms.txt b/site/public/llms.txt index d93321d..57883be 100644 --- a/site/public/llms.txt +++ b/site/public/llms.txt @@ -12,74 +12,74 @@ ## 入口 -- [DP大师 · DP Master](https://dp.round1.cc/): DP大师面向算法学习者,用精讲、逐帧可视化、题目索引和小游戏讲清状态定义、转移顺序与模型迁移。(最近更新:2026-08-23T21:18:04+08:00) -- [通用方法论 · DP大师](https://dp.round1.cc/method): 用状态设计、转移方程、计算顺序、空间优化和调试清单,建立可复用的动态规划解题方法。(最近更新:2026-08-23T21:18:04+08:00) -- [题目索引 · DP大师](https://dp.round1.cc/problems): 按 DP 家族、课程、难度和关键词检索教程中的例题与练习,快速定位对应的洛谷题目和学习路径。(最近更新:2026-08-23T21:18:04+08:00) +- [DP大师 · DP Master](https://dp.round1.cc/): DP大师面向算法学习者,用精讲、逐帧可视化、题目索引和小游戏讲清状态定义、转移顺序与模型迁移。(最近更新:2026-08-22T23:46:11+08:00) +- [通用方法论 · DP大师](https://dp.round1.cc/method): 用状态设计、转移方程、计算顺序、空间优化和调试清单,建立可复用的动态规划解题方法。(最近更新:2026-08-22T12:47:11+08:00) +- [题目索引 · DP大师](https://dp.round1.cc/problems): 按 DP 家族、课程、难度和关键词检索教程中的例题与练习,快速定位对应的洛谷题目和学习路径。(最近更新:2026-08-22T12:47:11+08:00) ## 背包 DP(A) -- [背包 DP · DP大师](https://dp.round1.cc/part/a): 背包 DP学习路径以“容量受限下的取舍:物品件数属性决定了背包的谱系。”为主线,串联 9 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-23T21:18:04+08:00) -- [01 背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/01): 01 背包课程回答“每件物品只能取一次时,为什么容量必须逆序更新”。内容以取或不取·一维逆推·恰好装满为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [完全背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/complete): 完全背包课程回答“物品可无限取用时,正序更新如何复用本轮状态”。内容以无限件·一维正推为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [多重背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/multiple): 多重背包课程回答“有限件物品怎样从朴素枚举优化到二进制或单调队列”。内容以朴素·二进制·单调队列为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [分组背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/group): 分组背包课程回答“每组至多选一件时,如何隔离组内选择”。内容以每组至多选一件为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [混合背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/mixed): 混合背包课程回答“01、完全和多重物品怎样共用一套转移框架”。内容以01/完全/多重同题为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [二维费用背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/cost2d): 二维费用背包课程回答“同时受两种容量约束时,状态维度与枚举顺序如何设计”。内容以两种费用同时受限为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [有依赖的背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/dep): 有依赖的背包课程回答“带主件附件依赖的选择怎样转化为合法组合”。内容以主件-附件·依赖→分组为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [背包综合变形 · 背包 DP · DP大师](https://dp.round1.cc/part/a/variant): 背包综合变形课程回答“背包模型如何扩展到计数、撤销和方案恢复”。内容以方案数·撤销·具体方案为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [辨析:分数背包=贪心 · 背包 DP · DP大师](https://dp.round1.cc/part/a/fractional): 辨析:分数背包=贪心课程回答“为什么可分割物品属于贪心而不是 01 背包”。内容以可分割⇒贪心 vs 整取⇒DP为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [背包 DP · DP大师](https://dp.round1.cc/part/a): 背包 DP学习路径以“容量受限下的取舍:物品件数属性决定了背包的谱系。”为主线,串联 9 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T12:47:11+08:00) +- [01 背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/01): 01 背包课程回答“每件物品只能取一次时,为什么容量必须逆序更新”。内容以取或不取·一维逆推·恰好装满为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [完全背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/complete): 完全背包课程回答“物品可无限取用时,正序更新如何复用本轮状态”。内容以无限件·一维正推为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [多重背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/multiple): 多重背包课程回答“有限件物品怎样从朴素枚举优化到二进制或单调队列”。内容以朴素·二进制·单调队列为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [分组背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/group): 分组背包课程回答“每组至多选一件时,如何隔离组内选择”。内容以每组至多选一件为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [混合背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/mixed): 混合背包课程回答“01、完全和多重物品怎样共用一套转移框架”。内容以01/完全/多重同题为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [二维费用背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/cost2d): 二维费用背包课程回答“同时受两种容量约束时,状态维度与枚举顺序如何设计”。内容以两种费用同时受限为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [有依赖的背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/dep): 有依赖的背包课程回答“带主件附件依赖的选择怎样转化为合法组合”。内容以主件-附件·依赖→分组为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [背包综合变形 · 背包 DP · DP大师](https://dp.round1.cc/part/a/variant): 背包综合变形课程回答“背包模型如何扩展到计数、撤销和方案恢复”。内容以方案数·撤销·具体方案为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [辨析:分数背包=贪心 · 背包 DP · DP大师](https://dp.round1.cc/part/a/fractional): 辨析:分数背包=贪心课程回答“为什么可分割物品属于贪心而不是 01 背包”。内容以可分割⇒贪心 vs 整取⇒DP为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) ## 线性 DP(B) -- [线性 DP · DP大师](https://dp.round1.cc/part/b): 线性 DP学习路径以“把问题排成一条推进的序列,dp[i] 只依赖更早的状态。”为主线,串联 7 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-23T21:18:04+08:00) -- [路径型 / 递推入门 · 线性 DP · DP大师](https://dp.round1.cc/part/b/path): 路径型 / 递推入门课程回答“沿序列或网格推进时,如何定义最小充分状态”。内容以数字三角形·过河卒·方格取数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [最大子段和 · 线性 DP · DP大师](https://dp.round1.cc/part/b/maxseg): 最大子段和课程回答“如何用一个局部状态维护最大连续子段”。内容以Kadane·环形·两段不相交为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [最长上升子序列 LIS · 线性 DP · DP大师](https://dp.round1.cc/part/b/lis): 最长上升子序列 LIS课程回答“最长上升子序列如何从二次转移优化到对数查找”。内容以O(n²) 与 O(n log n)·导弹拦截为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [最长公共子序列 LCS · 线性 DP · DP大师](https://dp.round1.cc/part/b/lcs): 最长公共子序列 LCS课程回答“两个序列的公共结构如何通过二维状态刻画”。内容以排列 LCS→LIS·计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [编辑距离 · 线性 DP · DP大师](https://dp.round1.cc/part/b/edit): 编辑距离课程回答“插入、删除和替换如何统一进编辑距离转移”。内容以删/插/改三向转移为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [线性状态机 DP · 线性 DP · DP大师](https://dp.round1.cc/part/b/fsm): 线性状态机 DP课程回答“带持有或选择限制的问题怎样画成有限状态机”。内容以受限选取·股票买卖为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [计数 / 划分型 · 线性 DP · DP大师](https://dp.round1.cc/part/b/count): 计数 / 划分型课程回答“计数与划分问题如何避免重复或遗漏方案”。内容以方案数·高精度·整数划分为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [线性 DP · DP大师](https://dp.round1.cc/part/b): 线性 DP学习路径以“把问题排成一条推进的序列,dp[i] 只依赖更早的状态。”为主线,串联 7 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T12:47:11+08:00) +- [路径型 / 递推入门 · 线性 DP · DP大师](https://dp.round1.cc/part/b/path): 路径型 / 递推入门课程回答“沿序列或网格推进时,如何定义最小充分状态”。内容以数字三角形·过河卒·方格取数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [最大子段和 · 线性 DP · DP大师](https://dp.round1.cc/part/b/maxseg): 最大子段和课程回答“如何用一个局部状态维护最大连续子段”。内容以Kadane·环形·两段不相交为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [最长上升子序列 LIS · 线性 DP · DP大师](https://dp.round1.cc/part/b/lis): 最长上升子序列 LIS课程回答“最长上升子序列如何从二次转移优化到对数查找”。内容以O(n²) 与 O(n log n)·导弹拦截为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [最长公共子序列 LCS · 线性 DP · DP大师](https://dp.round1.cc/part/b/lcs): 最长公共子序列 LCS课程回答“两个序列的公共结构如何通过二维状态刻画”。内容以排列 LCS→LIS·计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [编辑距离 · 线性 DP · DP大师](https://dp.round1.cc/part/b/edit): 编辑距离课程回答“插入、删除和替换如何统一进编辑距离转移”。内容以删/插/改三向转移为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [线性状态机 DP · 线性 DP · DP大师](https://dp.round1.cc/part/b/fsm): 线性状态机 DP课程回答“带持有或选择限制的问题怎样画成有限状态机”。内容以受限选取·股票买卖为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [计数 / 划分型 · 线性 DP · DP大师](https://dp.round1.cc/part/b/count): 计数 / 划分型课程回答“计数与划分问题如何避免重复或遗漏方案”。内容以方案数·高精度·整数划分为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) ## 区间 DP(C) -- [区间 DP · DP大师](https://dp.round1.cc/part/c): 区间 DP学习路径以“dp[l][r] 表示区间最优,枚举分割/合并点,按长度递推。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-23T21:18:04+08:00) -- [石子合并(链形) · 区间 DP · DP大师](https://dp.round1.cc/part/c/stone): 石子合并(链形)课程回答“区间合并代价为何要按长度递增计算”。内容以区间合并基础模型为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [环形区间 DP · 区间 DP · DP大师](https://dp.round1.cc/part/c/ring): 环形区间 DP课程回答“环形区间怎样通过复制序列转化为链形区间”。内容以断环为链·能量项链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [回文 / 括号 · 区间 DP · DP大师](https://dp.round1.cc/part/c/palindrome): 回文 / 括号课程回答“端点匹配如何组织回文与括号类转移”。内容以收缩扩展·端点匹配为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [加分二叉树型 · 区间 DP · DP大师](https://dp.round1.cc/part/c/tree): 加分二叉树型课程回答“枚举区间根节点时,子区间如何对应左右子树”。内容以枚举根·区间即子树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [合并 / 删除类 · 区间 DP · DP大师](https://dp.round1.cc/part/c/merge): 合并 / 删除类课程回答“合并与删除过程怎样压缩成区间状态”。内容以2048·区间删除代价为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [区间 DP · DP大师](https://dp.round1.cc/part/c): 区间 DP学习路径以“dp[l][r] 表示区间最优,枚举分割/合并点,按长度递推。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T12:47:11+08:00) +- [石子合并(链形) · 区间 DP · DP大师](https://dp.round1.cc/part/c/stone): 石子合并(链形)课程回答“区间合并代价为何要按长度递增计算”。内容以区间合并基础模型为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [环形区间 DP · 区间 DP · DP大师](https://dp.round1.cc/part/c/ring): 环形区间 DP课程回答“环形区间怎样通过复制序列转化为链形区间”。内容以断环为链·能量项链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [回文 / 括号 · 区间 DP · DP大师](https://dp.round1.cc/part/c/palindrome): 回文 / 括号课程回答“端点匹配如何组织回文与括号类转移”。内容以收缩扩展·端点匹配为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [加分二叉树型 · 区间 DP · DP大师](https://dp.round1.cc/part/c/tree): 加分二叉树型课程回答“枚举区间根节点时,子区间如何对应左右子树”。内容以枚举根·区间即子树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [合并 / 删除类 · 区间 DP · DP大师](https://dp.round1.cc/part/c/merge): 合并 / 删除类课程回答“合并与删除过程怎样压缩成区间状态”。内容以2048·区间删除代价为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) ## 矩阵 DP(D) -- [矩阵 DP · DP大师](https://dp.round1.cc/part/d): 矩阵 DP学习路径以“两条主线:网格坐标上的 DP,与矩阵快速幂加速的递推。”为主线,串联 2 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-23T21:18:04+08:00) -- [网格 / 矩阵上的 DP · 矩阵 DP · DP大师](https://dp.round1.cc/part/d/grid): 网格 / 矩阵上的 DP课程回答“网格路径、最大正方形和双路径如何选择状态维度”。内容以路径·最大正方形·双线程为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [矩阵快速幂加速 · 矩阵 DP · DP大师](https://dp.round1.cc/part/d/matpow): 矩阵快速幂加速课程回答“线性递推怎样写成矩阵并用快速幂加速”。内容以递推→矩阵幂·O(k³log n)为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [矩阵 DP · DP大师](https://dp.round1.cc/part/d): 矩阵 DP学习路径以“两条主线:网格坐标上的 DP,与矩阵快速幂加速的递推。”为主线,串联 2 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T12:47:11+08:00) +- [网格 / 矩阵上的 DP · 矩阵 DP · DP大师](https://dp.round1.cc/part/d/grid): 网格 / 矩阵上的 DP课程回答“网格路径、最大正方形和双路径如何选择状态维度”。内容以路径·最大正方形·双线程为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [矩阵快速幂加速 · 矩阵 DP · DP大师](https://dp.round1.cc/part/d/matpow): 矩阵快速幂加速课程回答“线性递推怎样写成矩阵并用快速幂加速”。内容以递推→矩阵幂·O(k³log n)为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) ## 换根 DP(E) -- [换根 DP · DP大师](https://dp.round1.cc/part/e): 换根 DP学习路径以“二次扫描:固定根一遍 DFS,再一遍换根 O(1) 推每个点。”为主线,串联 4 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-23T21:18:04+08:00) -- [换根基础模型 · 换根 DP · DP大师](https://dp.round1.cc/part/e/basic): 换根基础模型课程回答“一次定根结果如何在线性时间转移到所有根”。内容以二次扫描骨架为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [距离和换根 · 换根 DP · DP大师](https://dp.round1.cc/part/e/distsum): 距离和换根课程回答“换根时全树距离和为什么只需常数时间更新”。内容以深度和·带权距离和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [子树内外合并 · 换根 DP · DP大师](https://dp.round1.cc/part/e/inout): 子树内外合并课程回答“子树内外贡献如何在第二遍 DFS 中合并”。内容以距离≤k 点权和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [中心 / 偏心距 · 换根 DP · DP大师](https://dp.round1.cc/part/e/center): 中心 / 偏心距课程回答“偏心距与树中心如何由向下和向上信息共同得到”。内容以树的直径·核为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [换根 DP · DP大师](https://dp.round1.cc/part/e): 换根 DP学习路径以“二次扫描:固定根一遍 DFS,再一遍换根 O(1) 推每个点。”为主线,串联 4 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T12:47:11+08:00) +- [换根基础模型 · 换根 DP · DP大师](https://dp.round1.cc/part/e/basic): 换根基础模型课程回答“一次定根结果如何在线性时间转移到所有根”。内容以二次扫描骨架为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [距离和换根 · 换根 DP · DP大师](https://dp.round1.cc/part/e/distsum): 距离和换根课程回答“换根时全树距离和为什么只需常数时间更新”。内容以深度和·带权距离和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [子树内外合并 · 换根 DP · DP大师](https://dp.round1.cc/part/e/inout): 子树内外合并课程回答“子树内外贡献如何在第二遍 DFS 中合并”。内容以距离≤k 点权和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [中心 / 偏心距 · 换根 DP · DP大师](https://dp.round1.cc/part/e/center): 中心 / 偏心距课程回答“偏心距与树中心如何由向下和向上信息共同得到”。内容以树的直径·核为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) ## 树形 DP(F) -- [树形 DP · DP大师](https://dp.round1.cc/part/f): 树形 DP学习路径以“dp[u][…] 表示子树最优,后序遍历自底向上合并。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-23T21:18:04+08:00) -- [选点 / 最大独立集 · 树形 DP · DP大师](https://dp.round1.cc/part/f/select): 选点 / 最大独立集课程回答“父子不能同时选择时,选与不选状态如何配合”。内容以没有上司的舞会为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [树上背包 · 树形 DP · DP大师](https://dp.round1.cc/part/f/knapsack): 树上背包课程回答“树上选取数量约束如何在子树间做背包合并”。内容以二叉苹果树·选课为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [直径 / 重心 DP · 树形 DP · DP大师](https://dp.round1.cc/part/f/diameter): 直径 / 重心 DP课程回答“过当前节点的多条链如何组合出直径与重心信息”。内容以过点最长链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [覆盖 / 支配 / 染色 · 树形 DP · DP大师](https://dp.round1.cc/part/f/cover): 覆盖 / 支配 / 染色课程回答“覆盖、支配和染色约束需要哪些互斥状态”。内容以三状态·染色计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [方案数 / 距离统计 · 树形 DP · DP大师](https://dp.round1.cc/part/f/count): 方案数 / 距离统计课程回答“树上方案数与距离统计如何在合并时避免重复”。内容以联合权值·括号树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [树形 DP · DP大师](https://dp.round1.cc/part/f): 树形 DP学习路径以“dp[u][…] 表示子树最优,后序遍历自底向上合并。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T12:47:11+08:00) +- [选点 / 最大独立集 · 树形 DP · DP大师](https://dp.round1.cc/part/f/select): 选点 / 最大独立集课程回答“父子不能同时选择时,选与不选状态如何配合”。内容以没有上司的舞会为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [树上背包 · 树形 DP · DP大师](https://dp.round1.cc/part/f/knapsack): 树上背包课程回答“树上选取数量约束如何在子树间做背包合并”。内容以二叉苹果树·选课为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [直径 / 重心 DP · 树形 DP · DP大师](https://dp.round1.cc/part/f/diameter): 直径 / 重心 DP课程回答“过当前节点的多条链如何组合出直径与重心信息”。内容以过点最长链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [覆盖 / 支配 / 染色 · 树形 DP · DP大师](https://dp.round1.cc/part/f/cover): 覆盖 / 支配 / 染色课程回答“覆盖、支配和染色约束需要哪些互斥状态”。内容以三状态·染色计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [方案数 / 距离统计 · 树形 DP · DP大师](https://dp.round1.cc/part/f/count): 方案数 / 距离统计课程回答“树上方案数与距离统计如何在合并时避免重复”。内容以联合权值·括号树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) ## 状压 DP(G) -- [状压 DP · DP大师](https://dp.round1.cc/part/g): 状压 DP学习路径以“状态是一个集合,用二进制整数表示;转移在 mask 间进行。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-23T21:18:04+08:00) -- [棋盘 / 轮廓状压 · 状压 DP · DP大师](https://dp.round1.cc/part/g/board): 棋盘 / 轮廓状压课程回答“逐行棋盘约束怎样编码为兼容位掩码”。内容以互不侵犯·炮兵阵地为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [集合状压 / TSP · 状压 DP · DP大师](https://dp.round1.cc/part/g/tsp): 集合状压 / TSP课程回答“集合访问状态如何保证 Hamilton 路径不重不漏”。内容以最短 Hamilton·吃奶酪为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [状压 + 覆盖 · 状压 DP · DP大师](https://dp.round1.cc/part/g/cover): 状压 + 覆盖课程回答“几何覆盖选择怎样预处理成可转移的状态集合”。内容以愤怒的小鸟·宝藏为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [综合技巧 · 状压 DP · DP大师](https://dp.round1.cc/part/g/subset): 综合技巧课程回答“子集枚举与计数变形有哪些可复用的位运算技巧”。内容以枚举子集·计数变形为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) -- [插头 DP · 状压 DP · DP大师](https://dp.round1.cc/part/g/plug): 插头 DP课程回答“轮廓线上的连通性怎样压缩为插头状态”。内容以轮廓线连通性为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [状压 DP · DP大师](https://dp.round1.cc/part/g): 状压 DP学习路径以“状态是一个集合,用二进制整数表示;转移在 mask 间进行。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T12:47:11+08:00) +- [棋盘 / 轮廓状压 · 状压 DP · DP大师](https://dp.round1.cc/part/g/board): 棋盘 / 轮廓状压课程回答“逐行棋盘约束怎样编码为兼容位掩码”。内容以互不侵犯·炮兵阵地为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [集合状压 / TSP · 状压 DP · DP大师](https://dp.round1.cc/part/g/tsp): 集合状压 / TSP课程回答“集合访问状态如何保证 Hamilton 路径不重不漏”。内容以最短 Hamilton·吃奶酪为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [状压 + 覆盖 · 状压 DP · DP大师](https://dp.round1.cc/part/g/cover): 状压 + 覆盖课程回答“几何覆盖选择怎样预处理成可转移的状态集合”。内容以愤怒的小鸟·宝藏为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [综合技巧 · 状压 DP · DP大师](https://dp.round1.cc/part/g/subset): 综合技巧课程回答“子集枚举与计数变形有哪些可复用的位运算技巧”。内容以枚举子集·计数变形为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [插头 DP · 状压 DP · DP大师](https://dp.round1.cc/part/g/plug): 插头 DP课程回答“轮廓线上的连通性怎样压缩为插头状态”。内容以轮廓线连通性为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) ## 使用说明 diff --git a/site/public/route-summaries.json b/site/public/route-summaries.json index b783459..404989f 100644 --- a/site/public/route-summaries.json +++ b/site/public/route-summaries.json @@ -9,7 +9,7 @@ "title": "DP大师 · DP Master", "summary": "DP大师面向算法学习者,用精讲、逐帧可视化、题目索引和小游戏讲清状态定义、转移顺序与模型迁移。", "type": "home", - "lastModified": "2026-08-23T21:18:04+08:00" + "lastModified": "2026-08-22T23:46:11+08:00" }, { "path": "/part/a", @@ -17,7 +17,7 @@ "title": "背包 DP · DP大师", "summary": "背包 DP学习路径以“容量受限下的取舍:物品件数属性决定了背包的谱系。”为主线,串联 9 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-23T21:18:04+08:00" + "lastModified": "2026-08-22T12:47:11+08:00" }, { "path": "/part/b", @@ -25,7 +25,7 @@ "title": "线性 DP · DP大师", "summary": "线性 DP学习路径以“把问题排成一条推进的序列,dp[i] 只依赖更早的状态。”为主线,串联 7 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-23T21:18:04+08:00" + "lastModified": "2026-08-22T12:47:11+08:00" }, { "path": "/part/c", @@ -33,7 +33,7 @@ "title": "区间 DP · DP大师", "summary": "区间 DP学习路径以“dp[l][r] 表示区间最优,枚举分割/合并点,按长度递推。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-23T21:18:04+08:00" + "lastModified": "2026-08-22T12:47:11+08:00" }, { "path": "/part/d", @@ -41,7 +41,7 @@ "title": "矩阵 DP · DP大师", "summary": "矩阵 DP学习路径以“两条主线:网格坐标上的 DP,与矩阵快速幂加速的递推。”为主线,串联 2 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-23T21:18:04+08:00" + "lastModified": "2026-08-22T12:47:11+08:00" }, { "path": "/part/e", @@ -49,7 +49,7 @@ "title": "换根 DP · DP大师", "summary": "换根 DP学习路径以“二次扫描:固定根一遍 DFS,再一遍换根 O(1) 推每个点。”为主线,串联 4 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-23T21:18:04+08:00" + "lastModified": "2026-08-22T12:47:11+08:00" }, { "path": "/part/f", @@ -57,7 +57,7 @@ "title": "树形 DP · DP大师", "summary": "树形 DP学习路径以“dp[u][…] 表示子树最优,后序遍历自底向上合并。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-23T21:18:04+08:00" + "lastModified": "2026-08-22T12:47:11+08:00" }, { "path": "/part/g", @@ -65,7 +65,7 @@ "title": "状压 DP · DP大师", "summary": "状压 DP学习路径以“状态是一个集合,用二进制整数表示;转移在 mask 间进行。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-23T21:18:04+08:00" + "lastModified": "2026-08-22T12:47:11+08:00" }, { "path": "/part/a/01", @@ -73,7 +73,7 @@ "title": "01 背包 · 背包 DP · DP大师", "summary": "01 背包课程回答“每件物品只能取一次时,为什么容量必须逆序更新”。内容以取或不取·一维逆推·恰好装满为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -83,7 +83,7 @@ "title": "完全背包 · 背包 DP · DP大师", "summary": "完全背包课程回答“物品可无限取用时,正序更新如何复用本轮状态”。内容以无限件·一维正推为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -93,7 +93,7 @@ "title": "多重背包 · 背包 DP · DP大师", "summary": "多重背包课程回答“有限件物品怎样从朴素枚举优化到二进制或单调队列”。内容以朴素·二进制·单调队列为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -103,7 +103,7 @@ "title": "分组背包 · 背包 DP · DP大师", "summary": "分组背包课程回答“每组至多选一件时,如何隔离组内选择”。内容以每组至多选一件为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -113,7 +113,7 @@ "title": "混合背包 · 背包 DP · DP大师", "summary": "混合背包课程回答“01、完全和多重物品怎样共用一套转移框架”。内容以01/完全/多重同题为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -123,7 +123,7 @@ "title": "二维费用背包 · 背包 DP · DP大师", "summary": "二维费用背包课程回答“同时受两种容量约束时,状态维度与枚举顺序如何设计”。内容以两种费用同时受限为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -133,7 +133,7 @@ "title": "有依赖的背包 · 背包 DP · DP大师", "summary": "有依赖的背包课程回答“带主件附件依赖的选择怎样转化为合法组合”。内容以主件-附件·依赖→分组为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -143,7 +143,7 @@ "title": "背包综合变形 · 背包 DP · DP大师", "summary": "背包综合变形课程回答“背包模型如何扩展到计数、撤销和方案恢复”。内容以方案数·撤销·具体方案为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -153,7 +153,7 @@ "title": "辨析:分数背包=贪心 · 背包 DP · DP大师", "summary": "辨析:分数背包=贪心课程回答“为什么可分割物品属于贪心而不是 01 背包”。内容以可分割⇒贪心 vs 整取⇒DP为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -163,7 +163,7 @@ "title": "路径型 / 递推入门 · 线性 DP · DP大师", "summary": "路径型 / 递推入门课程回答“沿序列或网格推进时,如何定义最小充分状态”。内容以数字三角形·过河卒·方格取数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -173,7 +173,7 @@ "title": "最大子段和 · 线性 DP · DP大师", "summary": "最大子段和课程回答“如何用一个局部状态维护最大连续子段”。内容以Kadane·环形·两段不相交为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -183,7 +183,7 @@ "title": "最长上升子序列 LIS · 线性 DP · DP大师", "summary": "最长上升子序列 LIS课程回答“最长上升子序列如何从二次转移优化到对数查找”。内容以O(n²) 与 O(n log n)·导弹拦截为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -193,7 +193,7 @@ "title": "最长公共子序列 LCS · 线性 DP · DP大师", "summary": "最长公共子序列 LCS课程回答“两个序列的公共结构如何通过二维状态刻画”。内容以排列 LCS→LIS·计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -203,7 +203,7 @@ "title": "编辑距离 · 线性 DP · DP大师", "summary": "编辑距离课程回答“插入、删除和替换如何统一进编辑距离转移”。内容以删/插/改三向转移为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -213,7 +213,7 @@ "title": "线性状态机 DP · 线性 DP · DP大师", "summary": "线性状态机 DP课程回答“带持有或选择限制的问题怎样画成有限状态机”。内容以受限选取·股票买卖为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -223,7 +223,7 @@ "title": "计数 / 划分型 · 线性 DP · DP大师", "summary": "计数 / 划分型课程回答“计数与划分问题如何避免重复或遗漏方案”。内容以方案数·高精度·整数划分为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -233,7 +233,7 @@ "title": "石子合并(链形) · 区间 DP · DP大师", "summary": "石子合并(链形)课程回答“区间合并代价为何要按长度递增计算”。内容以区间合并基础模型为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -243,7 +243,7 @@ "title": "环形区间 DP · 区间 DP · DP大师", "summary": "环形区间 DP课程回答“环形区间怎样通过复制序列转化为链形区间”。内容以断环为链·能量项链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -253,7 +253,7 @@ "title": "回文 / 括号 · 区间 DP · DP大师", "summary": "回文 / 括号课程回答“端点匹配如何组织回文与括号类转移”。内容以收缩扩展·端点匹配为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -263,7 +263,7 @@ "title": "加分二叉树型 · 区间 DP · DP大师", "summary": "加分二叉树型课程回答“枚举区间根节点时,子区间如何对应左右子树”。内容以枚举根·区间即子树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -273,7 +273,7 @@ "title": "合并 / 删除类 · 区间 DP · DP大师", "summary": "合并 / 删除类课程回答“合并与删除过程怎样压缩成区间状态”。内容以2048·区间删除代价为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -283,7 +283,7 @@ "title": "网格 / 矩阵上的 DP · 矩阵 DP · DP大师", "summary": "网格 / 矩阵上的 DP课程回答“网格路径、最大正方形和双路径如何选择状态维度”。内容以路径·最大正方形·双线程为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -293,7 +293,7 @@ "title": "矩阵快速幂加速 · 矩阵 DP · DP大师", "summary": "矩阵快速幂加速课程回答“线性递推怎样写成矩阵并用快速幂加速”。内容以递推→矩阵幂·O(k³log n)为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -303,7 +303,7 @@ "title": "换根基础模型 · 换根 DP · DP大师", "summary": "换根基础模型课程回答“一次定根结果如何在线性时间转移到所有根”。内容以二次扫描骨架为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -313,7 +313,7 @@ "title": "距离和换根 · 换根 DP · DP大师", "summary": "距离和换根课程回答“换根时全树距离和为什么只需常数时间更新”。内容以深度和·带权距离和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -323,7 +323,7 @@ "title": "子树内外合并 · 换根 DP · DP大师", "summary": "子树内外合并课程回答“子树内外贡献如何在第二遍 DFS 中合并”。内容以距离≤k 点权和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -333,7 +333,7 @@ "title": "中心 / 偏心距 · 换根 DP · DP大师", "summary": "中心 / 偏心距课程回答“偏心距与树中心如何由向下和向上信息共同得到”。内容以树的直径·核为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -343,7 +343,7 @@ "title": "选点 / 最大独立集 · 树形 DP · DP大师", "summary": "选点 / 最大独立集课程回答“父子不能同时选择时,选与不选状态如何配合”。内容以没有上司的舞会为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -353,7 +353,7 @@ "title": "树上背包 · 树形 DP · DP大师", "summary": "树上背包课程回答“树上选取数量约束如何在子树间做背包合并”。内容以二叉苹果树·选课为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -363,7 +363,7 @@ "title": "直径 / 重心 DP · 树形 DP · DP大师", "summary": "直径 / 重心 DP课程回答“过当前节点的多条链如何组合出直径与重心信息”。内容以过点最长链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -373,7 +373,7 @@ "title": "覆盖 / 支配 / 染色 · 树形 DP · DP大师", "summary": "覆盖 / 支配 / 染色课程回答“覆盖、支配和染色约束需要哪些互斥状态”。内容以三状态·染色计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -383,7 +383,7 @@ "title": "方案数 / 距离统计 · 树形 DP · DP大师", "summary": "方案数 / 距离统计课程回答“树上方案数与距离统计如何在合并时避免重复”。内容以联合权值·括号树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -393,7 +393,7 @@ "title": "棋盘 / 轮廓状压 · 状压 DP · DP大师", "summary": "棋盘 / 轮廓状压课程回答“逐行棋盘约束怎样编码为兼容位掩码”。内容以互不侵犯·炮兵阵地为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -403,7 +403,7 @@ "title": "集合状压 / TSP · 状压 DP · DP大师", "summary": "集合状压 / TSP课程回答“集合访问状态如何保证 Hamilton 路径不重不漏”。内容以最短 Hamilton·吃奶酪为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -413,7 +413,7 @@ "title": "状压 + 覆盖 · 状压 DP · DP大师", "summary": "状压 + 覆盖课程回答“几何覆盖选择怎样预处理成可转移的状态集合”。内容以愤怒的小鸟·宝藏为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -423,7 +423,7 @@ "title": "综合技巧 · 状压 DP · DP大师", "summary": "综合技巧课程回答“子集枚举与计数变形有哪些可复用的位运算技巧”。内容以枚举子集·计数变形为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -433,7 +433,7 @@ "title": "插头 DP · 状压 DP · DP大师", "summary": "插头 DP课程回答“轮廓线上的连通性怎样压缩为插头状态”。内容以轮廓线连通性为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-23T21:18:04+08:00", + "lastModified": "2026-08-22T12:47:11+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -443,7 +443,7 @@ "title": "通用方法论 · DP大师", "summary": "用状态设计、转移方程、计算顺序、空间优化和调试清单,建立可复用的动态规划解题方法。", "type": "static", - "lastModified": "2026-08-23T21:18:04+08:00" + "lastModified": "2026-08-22T12:47:11+08:00" }, { "path": "/problems", @@ -451,7 +451,7 @@ "title": "题目索引 · DP大师", "summary": "按 DP 家族、课程、难度和关键词检索教程中的例题与练习,快速定位对应的洛谷题目和学习路径。", "type": "static", - "lastModified": "2026-08-23T21:18:04+08:00" + "lastModified": "2026-08-22T12:47:11+08:00" } ] } diff --git a/site/public/sitemap.xml b/site/public/sitemap.xml index 50c2e6f..332dd0e 100644 --- a/site/public/sitemap.xml +++ b/site/public/sitemap.xml @@ -2,190 +2,190 @@ https://dp.round1.cc/ - 2026-08-23T21:18:04+08:00 + 2026-08-22T23:46:11+08:00 https://dp.round1.cc/method - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/a - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/a/01 - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/a/complete - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/a/cost2d - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/a/dep - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/a/fractional - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/a/group - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/a/mixed - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/a/multiple - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/a/variant - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/b - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/b/count - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/b/edit - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/b/fsm - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/b/lcs - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/b/lis - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/b/maxseg - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/b/path - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/c - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/c/merge - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/c/palindrome - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/c/ring - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/c/stone - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/c/tree - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/d - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/d/grid - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/d/matpow - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/e - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/e/basic - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/e/center - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/e/distsum - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/e/inout - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/f - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/f/count - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/f/cover - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/f/diameter - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/f/knapsack - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/f/select - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/g - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/g/board - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/g/cover - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/g/plug - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/g/subset - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/part/g/tsp - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 https://dp.round1.cc/problems - 2026-08-23T21:18:04+08:00 + 2026-08-22T12:47:11+08:00 diff --git a/site/scripts/semantic-source-graph.mjs b/site/scripts/semantic-source-graph.mjs index d9cc818..0a1d1b3 100644 --- a/site/scripts/semantic-source-graph.mjs +++ b/site/scripts/semantic-source-graph.mjs @@ -20,10 +20,10 @@ const NON_SEMANTIC_FILES = new Set([ 'site/src/theme/ThemeContext.tsx', ]) const NON_SEMANTIC_PREFIXES = ['site/src/analytics/'] -const SEMANTIC_ELEMENT_SLICES = new Map([ - ['site/src/app/AppContent.tsx', ['', '']], - ['site/src/components/layout/Shell.tsx', ['
']], - ['site/src/components/layout/RouteStage.tsx', ['']], +const SEMANTIC_ELEMENT_ROOTS = new Map([ + ['site/src/app/AppContent.tsx', 'Routes'], + ['site/src/components/layout/Shell.tsx', 'main'], + ['site/src/components/layout/RouteStage.tsx', 'motion.div'], ]) function isNonSemanticFile(path) { @@ -33,16 +33,92 @@ function isNonSemanticFile(path) { ) } +function visitNodes(value, visitor, parent = null) { + if (!value || typeof value !== 'object') return + if (Array.isArray(value)) { + for (const entry of value) visitNodes(entry, visitor, parent) + return + } + if (typeof value.type === 'string') visitor(value, parent) + for (const child of Object.values(value)) { + if (child && typeof child === 'object') visitNodes(child, visitor, value) + } +} + +function boundNames(pattern) { + if (!pattern || typeof pattern !== 'object') return [] + if (pattern.type === 'Identifier') return [pattern.name] + if (pattern.type === 'AssignmentPattern') return boundNames(pattern.left) + if (pattern.type === 'RestElement') return boundNames(pattern.argument) + if (pattern.type === 'ArrayPattern') { + return pattern.elements.flatMap((element) => boundNames(element)) + } + if (pattern.type === 'ObjectPattern') { + return pattern.properties.flatMap((property) => ( + property.type === 'Property' + ? boundNames(property.value) + : boundNames(property.argument) + )) + } + return [] +} + +function referencedNames(node) { + const names = new Set() + visitNodes(node, (candidate) => { + if (candidate.type === 'Identifier' || candidate.type === 'JSXIdentifier') { + names.add(candidate.name) + } + }) + return names +} + export function semanticSourceForDigest(path, source) { - const markers = SEMANTIC_ELEMENT_SLICES.get(path) - if (!markers) return source - const [startMarker, endMarker] = markers - const start = source.indexOf(startMarker) - const end = source.indexOf(endMarker, start) - if (start < 0 || end < 0) { - throw new Error(`Missing semantic source slice for ${path}`) + const rootName = SEMANTIC_ELEMENT_ROOTS.get(path) + if (!rootName) return source + const { program, errors } = parseSync(path, source) + if (errors.length > 0) { + throw new Error(`Unable to parse semantic source ${path}: ${errors[0].message}`) + } + + const declarations = new Map() + const roots = [] + visitNodes(program, (node, parent) => { + if (node.type === 'FunctionDeclaration' && node.id?.name) { + declarations.set(node.id.name, node) + } + if (node.type === 'VariableDeclarator') { + const declaration = parent?.type === 'VariableDeclaration' ? parent : node + for (const name of boundNames(node.id)) declarations.set(name, declaration) + } + if ( + node.type === 'JSXElement' + && source.slice(node.openingElement.name.start, node.openingElement.name.end) === rootName + ) { + roots.push(node) + } + }) + if (roots.length !== 1) { + throw new Error(`Expected one semantic JSX root ${rootName} in ${path}, found ${roots.length}`) } - return source.slice(start, end + endMarker.length) + + const slices = new Map() + const pendingNames = [...referencedNames(roots[0])] + slices.set(`${roots[0].start}:${roots[0].end}`, roots[0]) + while (pendingNames.length > 0) { + const name = pendingNames.pop() + const declaration = declarations.get(name) + if (!declaration) continue + const key = `${declaration.start}:${declaration.end}` + if (slices.has(key)) continue + slices.set(key, declaration) + pendingNames.push(...referencedNames(declaration)) + } + + return [...slices.values()] + .sort((left, right) => left.start - right.start) + .map((node) => source.slice(node.start, node.end)) + .join('\n') } function projectPath(path) { diff --git a/site/scripts/semantic-source-graph.test.mjs b/site/scripts/semantic-source-graph.test.mjs index a52444f..05dd5e7 100644 --- a/site/scripts/semantic-source-graph.test.mjs +++ b/site/scripts/semantic-source-graph.test.mjs @@ -72,10 +72,10 @@ test('all public route evidence excludes non-semantic client branches', () => { } }) -test('mixed shell modules hash only their semantic rendering slice', () => { - const shellBefore = `useEffect(() => first())\n
` - const shellAfterClientChange = `useEffect(() => second())\n
` - const shellAfterSemanticChange = `useEffect(() => second())\n

正文

` +test('mixed shell modules hash their AST-backed render dependencies', () => { + const shellBefore = `export default function Shell() {\n useEffect(() => first())\n const mainRef = useRef(null)\n return
\n}` + const shellAfterClientChange = `export default function Shell() {\n useEffect(() => second())\n const mainRef = useRef(null)\n return
\n}` + const shellAfterSemanticChange = `export default function Shell() {\n useEffect(() => second())\n const mainRef = useRef(null)\n return

正文

\n}` assert.equal( semanticSourceForDigest('site/src/components/layout/Shell.tsx', shellBefore), @@ -86,22 +86,42 @@ test('mixed shell modules hash only their semantic rendering slice', () => { semanticSourceForDigest('site/src/components/layout/Shell.tsx', shellAfterSemanticChange), ) + const appBefore = `function RouteView({ View }) { return }\nexport function AppContent({ views }) {\n const { Home: HomeView } = views\n return <>} />\n}` + const appAfterAnalyticsChange = appBefore.replace('AnalyticsRuntime', 'AnalyticsTracker') + const appAfterContentInjection = appBefore.replace( + 'return ', + 'return <>

正文注入

', + ) + const appAfterRouteBinding = appBefore.replace('View={HomeView}', 'View={MethodView}') assert.equal( - semanticSourceForDigest( - 'site/src/app/AppContent.tsx', - '', - ), - '', + semanticSourceForDigest('site/src/app/AppContent.tsx', appBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', appAfterAnalyticsChange), + ) + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', appBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', appAfterContentInjection), + ) + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', appBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', appAfterRouteBinding), ) + + const stageBefore = `const routeEase = [0, 1]\nexport default function RouteStage() {\n const outlet = useOutlet()\n const hasMounted = useState(false)[0]\n useEffect(() => first())\n return {outlet}\n}` + const stageAfterClientChange = stageBefore.replace('first()', 'second()') + const stageAfterOutletChange = stageBefore.replace('useOutlet()', '

替换正文

') assert.equal( - semanticSourceForDigest( - 'site/src/components/layout/RouteStage.tsx', - 'useEffect(() => clientOnly())\n{outlet}', - ), - '{outlet}', + semanticSourceForDigest('site/src/components/layout/RouteStage.tsx', stageBefore), + semanticSourceForDigest('site/src/components/layout/RouteStage.tsx', stageAfterClientChange), + ) + assert.notEqual( + semanticSourceForDigest('site/src/components/layout/RouteStage.tsx', stageBefore), + semanticSourceForDigest('site/src/components/layout/RouteStage.tsx', stageAfterOutletChange), ) assert.throws( - () => semanticSourceForDigest('site/src/components/layout/Shell.tsx', '
'), - /Missing semantic source slice/, + () => semanticSourceForDigest( + 'site/src/components/layout/Shell.tsx', + 'export default function Shell() { return
}', + ), + /Expected one semantic JSX root/, ) }) diff --git a/site/src/data/routeLastModified.ts b/site/src/data/routeLastModified.ts index c8a3a97..01b9362 100644 --- a/site/src/data/routeLastModified.ts +++ b/site/src/data/routeLastModified.ts @@ -1,100 +1,100 @@ // 由 scripts/generate-seo.mjs 从源码时间证据生成,请勿手改。 export const ROUTE_CONTENT_DIGEST_VERSION = 2 export const ROUTE_LAST_MODIFIED: Readonly> = Object.freeze({ - "/": "2026-08-23T21:18:04+08:00", - "/part/a": "2026-08-23T21:18:04+08:00", - "/part/b": "2026-08-23T21:18:04+08:00", - "/part/c": "2026-08-23T21:18:04+08:00", - "/part/d": "2026-08-23T21:18:04+08:00", - "/part/e": "2026-08-23T21:18:04+08:00", - "/part/f": "2026-08-23T21:18:04+08:00", - "/part/g": "2026-08-23T21:18:04+08:00", - "/part/a/01": "2026-08-23T21:18:04+08:00", - "/part/a/complete": "2026-08-23T21:18:04+08:00", - "/part/a/multiple": "2026-08-23T21:18:04+08:00", - "/part/a/group": "2026-08-23T21:18:04+08:00", - "/part/a/mixed": "2026-08-23T21:18:04+08:00", - "/part/a/cost2d": "2026-08-23T21:18:04+08:00", - "/part/a/dep": "2026-08-23T21:18:04+08:00", - "/part/a/variant": "2026-08-23T21:18:04+08:00", - "/part/a/fractional": "2026-08-23T21:18:04+08:00", - "/part/b/path": "2026-08-23T21:18:04+08:00", - "/part/b/maxseg": "2026-08-23T21:18:04+08:00", - "/part/b/lis": "2026-08-23T21:18:04+08:00", - "/part/b/lcs": "2026-08-23T21:18:04+08:00", - "/part/b/edit": "2026-08-23T21:18:04+08:00", - "/part/b/fsm": "2026-08-23T21:18:04+08:00", - "/part/b/count": "2026-08-23T21:18:04+08:00", - "/part/c/stone": "2026-08-23T21:18:04+08:00", - "/part/c/ring": "2026-08-23T21:18:04+08:00", - "/part/c/palindrome": "2026-08-23T21:18:04+08:00", - "/part/c/tree": "2026-08-23T21:18:04+08:00", - "/part/c/merge": "2026-08-23T21:18:04+08:00", - "/part/d/grid": "2026-08-23T21:18:04+08:00", - "/part/d/matpow": "2026-08-23T21:18:04+08:00", - "/part/e/basic": "2026-08-23T21:18:04+08:00", - "/part/e/distsum": "2026-08-23T21:18:04+08:00", - "/part/e/inout": "2026-08-23T21:18:04+08:00", - "/part/e/center": "2026-08-23T21:18:04+08:00", - "/part/f/select": "2026-08-23T21:18:04+08:00", - "/part/f/knapsack": "2026-08-23T21:18:04+08:00", - "/part/f/diameter": "2026-08-23T21:18:04+08:00", - "/part/f/cover": "2026-08-23T21:18:04+08:00", - "/part/f/count": "2026-08-23T21:18:04+08:00", - "/part/g/board": "2026-08-23T21:18:04+08:00", - "/part/g/tsp": "2026-08-23T21:18:04+08:00", - "/part/g/cover": "2026-08-23T21:18:04+08:00", - "/part/g/subset": "2026-08-23T21:18:04+08:00", - "/part/g/plug": "2026-08-23T21:18:04+08:00", - "/method": "2026-08-23T21:18:04+08:00", - "/problems": "2026-08-23T21:18:04+08:00" + "/": "2026-08-22T23:46:11+08:00", + "/part/a": "2026-08-22T12:47:11+08:00", + "/part/b": "2026-08-22T12:47:11+08:00", + "/part/c": "2026-08-22T12:47:11+08:00", + "/part/d": "2026-08-22T12:47:11+08:00", + "/part/e": "2026-08-22T12:47:11+08:00", + "/part/f": "2026-08-22T12:47:11+08:00", + "/part/g": "2026-08-22T12:47:11+08:00", + "/part/a/01": "2026-08-22T12:47:11+08:00", + "/part/a/complete": "2026-08-22T12:47:11+08:00", + "/part/a/multiple": "2026-08-22T12:47:11+08:00", + "/part/a/group": "2026-08-22T12:47:11+08:00", + "/part/a/mixed": "2026-08-22T12:47:11+08:00", + "/part/a/cost2d": "2026-08-22T12:47:11+08:00", + "/part/a/dep": "2026-08-22T12:47:11+08:00", + "/part/a/variant": "2026-08-22T12:47:11+08:00", + "/part/a/fractional": "2026-08-22T12:47:11+08:00", + "/part/b/path": "2026-08-22T12:47:11+08:00", + "/part/b/maxseg": "2026-08-22T12:47:11+08:00", + "/part/b/lis": "2026-08-22T12:47:11+08:00", + "/part/b/lcs": "2026-08-22T12:47:11+08:00", + "/part/b/edit": "2026-08-22T12:47:11+08:00", + "/part/b/fsm": "2026-08-22T12:47:11+08:00", + "/part/b/count": "2026-08-22T12:47:11+08:00", + "/part/c/stone": "2026-08-22T12:47:11+08:00", + "/part/c/ring": "2026-08-22T12:47:11+08:00", + "/part/c/palindrome": "2026-08-22T12:47:11+08:00", + "/part/c/tree": "2026-08-22T12:47:11+08:00", + "/part/c/merge": "2026-08-22T12:47:11+08:00", + "/part/d/grid": "2026-08-22T12:47:11+08:00", + "/part/d/matpow": "2026-08-22T12:47:11+08:00", + "/part/e/basic": "2026-08-22T12:47:11+08:00", + "/part/e/distsum": "2026-08-22T12:47:11+08:00", + "/part/e/inout": "2026-08-22T12:47:11+08:00", + "/part/e/center": "2026-08-22T12:47:11+08:00", + "/part/f/select": "2026-08-22T12:47:11+08:00", + "/part/f/knapsack": "2026-08-22T12:47:11+08:00", + "/part/f/diameter": "2026-08-22T12:47:11+08:00", + "/part/f/cover": "2026-08-22T12:47:11+08:00", + "/part/f/count": "2026-08-22T12:47:11+08:00", + "/part/g/board": "2026-08-22T12:47:11+08:00", + "/part/g/tsp": "2026-08-22T12:47:11+08:00", + "/part/g/cover": "2026-08-22T12:47:11+08:00", + "/part/g/subset": "2026-08-22T12:47:11+08:00", + "/part/g/plug": "2026-08-22T12:47:11+08:00", + "/method": "2026-08-22T12:47:11+08:00", + "/problems": "2026-08-22T12:47:11+08:00" }) export const ROUTE_CONTENT_DIGESTS: Readonly> = Object.freeze({ - "/": "389b40caf8178d5484a97929f773cf376056478405f91dee11d853763ee6127b", - "/part/a": "07a1391b2ead879ee375b5dad1583101d639f483d95f7d496097563e7fcb9e82", - "/part/b": "dc246b5c2d8fff3f9ddad508225e78dc4096ae44c50fb51a0d6ca65057601a8d", - "/part/c": "c19cc82b706a1efc6e2b010b2bf67c3414b8a72fdb5a4528d18b53eaf65db910", - "/part/d": "c1474ed2e118d64a2ff79f044d8e79aecf964cd8d1b0e3469cc6a68f03654f24", - "/part/e": "58c81d6f8e0e06cdd3d82e256700af45fc001aa5095d0acb8896d81b2185e7ef", - "/part/f": "4348f443c4db61544c6bf0d7683a9c30a9abb18cbcd459a7aa8b58815b6f4304", - "/part/g": "5dcb7cfaa891b1c27290a010e3d14edf78dff855787a2831a6b10edfc5b93cf6", - "/part/a/01": "7bc2c1869b0ae5b288c55ffbe881ed97e19e55b83477640c8ab5d6e4118c5e7a", - "/part/a/complete": "fc5b06f0b206d9eb7fb1a583c5eb9c872b8e3499beaff69bd1f04dd79299d897", - "/part/a/multiple": "a6655896743fbd4dd75494bc25ec7307020a3fbeb56c1daaff4ca64e0ecc3022", - "/part/a/group": "6e1e05afa630026a4d4e4da6d586ec24a9c21fad479e2b2c291ed9e3d60beee6", - "/part/a/mixed": "06ed86412ce201d258dfa1e1ed6d12f42eae27ed8d14afa340b39a4f790ab4a6", - "/part/a/cost2d": "ee0ebdffed0f6b51da878f25d69553ee95bd77ac6a77d3c3788f65d629de096f", - "/part/a/dep": "9772ff5397325c40f76dd929e6c47ad4e20039470f6a38aecdbe6726ae00ec82", - "/part/a/variant": "286560b5249d56b9f3964cdcfda7d88b0d089c512ac8ab73259f92271434a867", - "/part/a/fractional": "dd8175ae6502099e6c256083fb267ba9ea6956461deb06934304aedd0bfbab81", - "/part/b/path": "5535b0072628f8f56a8f659d980561c2298366df11a3599b20bce3562e2d97c9", - "/part/b/maxseg": "0147406927db44da48eb33a5d1708dbb24cc80b29f16b7e1535dca57688a13f2", - "/part/b/lis": "5afef5ef17f4b25dafab109803d50833d9fa4d1b80c09e35395eef413d466f1c", - "/part/b/lcs": "49e36a2b47b2898c67f68dc09e8d2f828c0f39ed687e2051c161c438055b81f0", - "/part/b/edit": "6f25e9f4a79f1f09606207dad5df2a96bad312b5dc7ff700dd42ede6d3576ace", - "/part/b/fsm": "c591b7301cf2cfe745832cd2748f5633ee34583ab632df55c96f804aeb4a6b16", - "/part/b/count": "0a318f0125f2ae068911d633c36f693459a7e8677af86ad0f264263bcbe701ec", - "/part/c/stone": "b42fbf5f86da0b7392acaa712ce474184c8a6d883b20f24f9d0251f803861e97", - "/part/c/ring": "a620b6a2c3f0a9dfaf6628fb59507d52175a22d442c3f98f5591a1004cf3f83c", - "/part/c/palindrome": "679dc05c65ac113024b7f4bd70df6a15801781e794654c00df26b139d851dd6a", - "/part/c/tree": "5e57cce1b0e2c5efda6a14470d9746162f4373411cafe347f5fefa7bf907fb02", - "/part/c/merge": "60f55106849e33137ed346d07b84b901638208dd94889980808559838bb91d4e", - "/part/d/grid": "51a007b1d588ef1a55a4544b7b317a566dc66b37e5ba2bb114f48fe5d3c81ce9", - "/part/d/matpow": "4719380a30939188b50773353c1281424bd38faf9e0548f1a8f6eca903a06a94", - "/part/e/basic": "8e998a60c9fad45777649166d9cac1390337f1710b2013aa9d987b43a7976eb7", - "/part/e/distsum": "deac32fc7c803232f2936aaf6728c6e696d15816e825b4f19651bd27cb3ebc12", - "/part/e/inout": "210fe09516169de66251416841326e808407b30a6e5dafc0de3ceadefbddef6c", - "/part/e/center": "9ca49b2a02899a81dadb4ccbbcd27e6037b80ba63c3c2a8139e5011bb9829393", - "/part/f/select": "5d60d673ecc83c21babc3fc07424245cf14f049fc018968b8e94874fe7c3eefa", - "/part/f/knapsack": "dffc5f57b23919de4cc1bf8ebf45057662add918e0e25d5e45c52c57a0d072af", - "/part/f/diameter": "a2081c868c6c45f0aff1ef357904c4038f13a76098cfe25794f247b28d7a230d", - "/part/f/cover": "0839f6a76c351ca4b1c2c8440f94f9630c91d87a71a829ae7bae3798ed3f3a1f", - "/part/f/count": "beb4667cfc3c48d63f030c4cc091a41a9484cd899bd43b7974c779dc58102ac8", - "/part/g/board": "f19569ad3ff789260b2ff84fbdba2dc8abd466fa0e703830b60b3bed3762c03a", - "/part/g/tsp": "c60c2b43afc75f6934e9ffd88cc579b3e634e36108e74957032d52ae53eeaf94", - "/part/g/cover": "5a659bd25da978214baf576ef3a22e380853177569063a1c3b5bf62deb951f65", - "/part/g/subset": "3996543c5b32207a88372c3df091b395b5381d51b897bb0f4e7bbaaa9e420c03", - "/part/g/plug": "789a4eba1584bc1e0a53fcbdec6d5d2c4cffd148503c6bc648103d99e2a0b7bd", - "/method": "0c91718a2b8c0e9c9c476d22f912c60cef246de06d5b65942a6f3d77da77b721", - "/problems": "66d9b58ef67db6cf928c0285727f065a708e693f315b02f0f36befbc24c455b1" + "/": "95e9c0c6235252d7e08b3776259265bc01dfe3715b08e8708cb96660b9a04710", + "/part/a": "c2079022bad74a24044d1202be7c9f680cbb3587f0aae2fd6f99fb716574cf0c", + "/part/b": "edb96fc1ff406556a7d272e265ec3695089a1dd1e3ab2f352ba924e3307cb5a0", + "/part/c": "54b52763201a9c6c694b3cdac332070c5aa60e5ae265797fc90ceae9866eb9cd", + "/part/d": "3e7535710483ca736f2aebca95e1bdc6b3cb1482d221b56fefd9f4bb40d08297", + "/part/e": "2047860e51dbb4de203708a80fb544d18afbf63eaf0737d8bca8ca968ba90c5b", + "/part/f": "deb7a7e32a0e7feccc0719dc123d7dcd274fa774a65cc91cdfd3693a6ed0bda0", + "/part/g": "9dc688813d6d8e4c0a1dc02bd350ba398b746a00b6e8e36a995704be0c0e04dd", + "/part/a/01": "95c5a26f6164a4acf23e9eba5a957f39769d0cfa3b9fdb05498d2a962df2400f", + "/part/a/complete": "dad028b9c51d5a348f1209a76bb3397b37e0728a94b64ac2a9c6aa2ed0088061", + "/part/a/multiple": "f59f59d39a33fb31019f7f3e442b691cf7c8f57b176ea64cc973ca7572f0ec43", + "/part/a/group": "bcc3f010ae89308a0a63e670911fc65b0c7283297c506ed33e01b3ceef919fe2", + "/part/a/mixed": "2f675238a6a423bf459cfa855e8463e47c64453268756708243de241a772652a", + "/part/a/cost2d": "cc631f8a0eb6e007f09ff102575f555e37ee782e904623ba523fc60822a037f0", + "/part/a/dep": "98d88f0f1f015f5980d94de92b29ee1550a7ad6092a90991e558a9c2ca6fad30", + "/part/a/variant": "585ca92620c6a8e3de68f4025eddaf0fd9c5c8c64eb0afc44d8823666fe76589", + "/part/a/fractional": "e0e6cc5b93ef67cecef842da7c4664bf79ce6818f231fcbab32d8efe389a3b10", + "/part/b/path": "41d4524338b542348c6241f043540fd9e18535934992c70cfe7913619af8a1e7", + "/part/b/maxseg": "11965a785f9c8c948e07f34a6ab9a1fcb9963760c93400f38a59dda15fea6100", + "/part/b/lis": "003289eb132e1f80587a41b80d7a4671a0c0cf4a8e3751cd15f470277d483fd9", + "/part/b/lcs": "4f9a9572b683bbda6ddff5a86dfa99fad97127dfef1cc1ad87ddd72d72f205bb", + "/part/b/edit": "93296d563c4f8283c942620517a62d652d6b2bbd661a0d7f17fceff373fbc882", + "/part/b/fsm": "c5b03fd03c15ed6dda7bc581e1cc4bf1f9496dcb2fa0240d96a2d5741fcb7c47", + "/part/b/count": "7a53264b9090e82f78eccd8f41a8721546199249143512a8f33336420f176878", + "/part/c/stone": "ad45d24051837ca13dbb1dcae57541eb9205c80ccd13c05ae3b91c7e58a48964", + "/part/c/ring": "5106c7b96726cc2c2d3c35c7b042313a820fc32810c1d5bdb4c6e61499ee8572", + "/part/c/palindrome": "769af1aafc827d557dab863aba72056a437ad0d17d01ec276f588bd14f080d41", + "/part/c/tree": "d8e32120dd34096b78f32338727441839c53dcc99b9d0a6da3fc86f66dbccd65", + "/part/c/merge": "ed8c56d4e179117552b69056d378443f80424d0eb4b381fcf41d2ddfbfd34eaf", + "/part/d/grid": "7fcf844e9acb539a15e05aba8d78ecaa91cbc5d59b6c7e9e3d79af31a7f46741", + "/part/d/matpow": "bd4745128ca1151e8f8af0ab3d43ce319c1bd0b0a9983752fd28e9f888d46990", + "/part/e/basic": "a3e18be58e069925373254bdc446ff45d7589131aefac35e7a7142ed93ad42c6", + "/part/e/distsum": "9d461657d27fa37f203de7f8bb9b3a9766ddd1233ad042e398987d3e4c7a8e24", + "/part/e/inout": "2a1cf244b30f3708360098188e34adf309235fcadd2e105d93d8a823bf6a931c", + "/part/e/center": "36ce64c5d8b6080edb3ad85459568cd8410877b98cc10a5ae2ea34aed5e102f6", + "/part/f/select": "0ab3227b8fdcc61c7be1914ed3e175157d554808c00168502b082fa338a20b9c", + "/part/f/knapsack": "8a1e5d94d0d303d106d39c91d28fe8b6f08c414bc7950bd35ee182df5213a0fe", + "/part/f/diameter": "c1da7209ea351e86f4e93e8777485d11225eabe5aa07003d82f4cec3d9ed9c7a", + "/part/f/cover": "e477e0e3ad2c5c60dcf28ffebff843662c3028457c5645c30419b096ac480042", + "/part/f/count": "0f405dcbce6a83251fee3b4996dc6cd530b4c74d7ed24feca09706791defc8af", + "/part/g/board": "2626ded466cbe49412a76b74e369b5c4282c3ae9709ab469aebe4f507392f6dc", + "/part/g/tsp": "8241be33c05454bb56e18f0445cfa66058cbbf1396110ff05ec4de128746d822", + "/part/g/cover": "aff2804871b3a8e7c159e9278eb13146f9a8881ea4a3ff86ed2b2568ace984c6", + "/part/g/subset": "df1170969d0bbbdcbc02bab7f1705220c662e2bbb4ffc876b64e86c66e191af0", + "/part/g/plug": "cbce43f1a89b974f075f4b6fe30fc568d58439b59099232f39ad7283166f6826", + "/method": "26ff5759a5b241d2a5c233901d69d67dd2f28a886653c591c0888aae8c33990b", + "/problems": "8e73767135b4abaa37565e834cbecf7c35eecaf3d82cf18d9dbb6b098b20691b" }) From 079f80166664cbccb94965b36ffe72614f37c84b Mon Sep 17 00:00:00 2001 From: Shanire Date: Wed, 26 Aug 2026 12:09:03 +0800 Subject: [PATCH 06/23] fix: isolate semantic route evidence Signed-off-by: Shanire --- docs/engineering/architecture.md | 4 +- docs/operations/verification.md | 2 +- site/public/llms.txt | 92 ++--- site/public/route-summaries.json | 92 ++--- site/public/sitemap.xml | 92 ++--- site/scripts/analytics-core.test.mjs | 6 +- site/scripts/last-modified.mjs | 2 +- site/scripts/semantic-source-graph.mjs | 327 +++++++++++++++--- site/scripts/semantic-source-graph.test.mjs | 57 +++ site/scripts/seo-contract.test.mjs | 9 +- site/src/analytics/index.ts | 8 +- .../components/feedback/FeedbackWidget.tsx | 4 +- site/src/components/layout/Sidebar.tsx | 5 +- site/src/config/client-runtime.ts | 8 + site/src/config/sidebar-copy.ts | 5 + site/src/config/site.ts | 11 - site/src/data/routeLastModified.ts | 188 +++++----- 17 files changed, 596 insertions(+), 316 deletions(-) create mode 100644 site/src/config/client-runtime.ts create mode 100644 site/src/config/sidebar-copy.ts diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index 0ae19db..a2f3262 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -69,7 +69,7 @@ The manifest intentionally keeps only dependencies imported by current source. D | `site/src/components/dp-engine/` | Shared visualization, playback, controls, and safe-caption rendering. | | `site/src/components/games/` | One game per family; games consume public result Interfaces instead of teaching frames. | | `site/src/components/games/runtime/` | Shared deterministic random source, round statistics, lazy audio, and viewport gate for the seven games. | -| `site/src/config/site.ts` | DP大师 brand, copyright holders, and the single production host with its same-origin API endpoints. | +| `site/src/config/site.ts` | Public representation identity only: DP大师 brand, canonical origin, and language. Client endpoints and sidebar-only copy live in separate modules that the semantic route graph cannot reach. | | `site/src/lib/publicRoutes.ts` | Catalog-derived authority for 47 public routes plus one separately declared internal prerender specimen. | | `site/src/lib/publicWebContract.ts` | Shared internal representation paths and public `Content-Signal` contract consumed by generation, discovery, and the Worker. | | `site/src/lib/pageMeta.ts` | Pure route metadata authority for titles, summaries, canonicals, breadcrumbs, and indexing policy. | @@ -108,7 +108,7 @@ For a registered public `GET` or `HEAD`, `worker/content-negotiation.js` selects `seoHead.ts` renders the same head contract at build time that `RouteMeta` maintains after client navigation: title, description, citable abstract, robots, canonical, Open Graph, Twitter, and a Schema.org graph. Every indexable page includes Organization and WebSite nodes and states `isAccessibleForFree`; the WebSite node declares a `SearchAction` pointing at the real `?q=` problem search. Lessons add Course/LearningResource/TechArticle with `educationalUse` and an `EducationalAudience`, family pages add CollectionPage plus an ItemList of their ready lessons, other pages add WebPage, and routes with hierarchy add BreadcrumbList. Course duration is deliberately not declared: there is no trustworthy source for it, so `hasCourseInstance` is omitted rather than fabricated. -`site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. `semantic-source-graph.mjs` follows the real SSR injection chain from `entry-server.tsx` through the static application/providers and route tree, together with metadata roots and the exact route page, lesson, and family-art modules. Mixed shell modules use an AST-rooted projection: the route-rendering JSX plus every local declaration it references are hashed, so render helpers and route/outlet bindings remain evidence while unrelated effects do not. The graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. Clean sources use the latest Git commit timestamp, dirty sources use the newest source-file modification timestamp, an unchanged digest preserves the previous value, and generation fails when changed content lacks strictly advancing source evidence; changing the digest schema preserves the prior content timestamp only when no newer source evidence exists, while a simultaneous real content update adopts its later timestamp. Version 2 was seeded from the last trusted pre-digest baseline (`2026-08-22`) rather than the invalid version 1 timestamps that a shared client-effect change had advanced. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. +`site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. `semantic-source-graph.mjs` follows the real SSR injection chain from `entry-server.tsx` through the static application/providers and route tree, together with metadata roots and the exact route page, lesson, and family-art modules. Mixed shell modules use an AST-rooted projection: the route-rendering JSX plus the closure of local declarations and later assignments reached by lexical binding identity are hashed, so shadowed names, class helpers, render functions, and route/outlet bindings remain precise while unrelated effects and JSX attribute names do not leak in. The graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Public representation identity is structurally isolated in `config/site.ts`; client endpoints and sidebar-only copy are in modules reachable only through pruned branches. Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. Clean sources use the latest Git commit timestamp, dirty sources use the newest source-file modification timestamp, an unchanged digest preserves the previous value, and generation fails when changed content lacks strictly advancing source evidence; changing the digest schema preserves the prior content timestamp only when no newer source evidence exists, while a simultaneous real content update adopts its later timestamp. Version 3 was reseeded from the trusted `2026-08-22` history: the home route uses the visible hero change at `23:46:11+08:00`, and the other 46 routes use the single-origin metadata/content change at `11:47:40+08:00`. Version 1 had advanced every route for a shared client effect, while version 2 still admitted sidebar-only copyright copy through a shared configuration file. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. # Analytics diff --git a/docs/operations/verification.md b/docs/operations/verification.md index 30ad963..eb9cb43 100644 --- a/docs/operations/verification.md +++ b/docs/operations/verification.md @@ -44,7 +44,7 @@ Pure TypeScript Modules imported by Node tests must use explicit `.ts` extension # SEO And Accessibility Checks -`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. A line-ending-neutral SHA-256 digest follows each route's real SSR injection chain and exact semantic content graph while pruning navigation, feedback, analytics, theme, and motion-only branches. Mixed shell modules contribute an AST-rooted closure of route-rendering JSX plus its referenced local declarations, including render helpers and route/outlet bindings. Clean sources use their latest Git commit timestamp; dirty sources use their newest filesystem modification timestamp; and generation fails if the digest changes without that evidence advancing. A digest-schema migration preserves the prior timestamp only when it has no newer source evidence; a simultaneous content edit must still advance. Version 2 was explicitly reseeded from the trusted `2026-08-22` baseline because version 1 had incorrectly advanced every route for a shared client-effect-only change. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. +`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. A line-ending-neutral SHA-256 digest follows each route's real SSR injection chain and exact semantic content graph while pruning navigation, feedback, analytics, theme, and motion-only branches. Mixed shell modules contribute an AST-rooted closure of route-rendering JSX, local declarations, and later assignments resolved by lexical binding identity; regression probes cover shadowing, class helpers, route/outlet bindings, effects, and non-reference JSX names. Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. Clean sources use their latest Git commit timestamp; dirty sources use their newest filesystem modification timestamp; and generation fails if the digest changes without that evidence advancing. A digest-schema migration preserves the prior timestamp only when it has no newer source evidence; a simultaneous content edit must still advance. Version 3 was explicitly reseeded from trusted `2026-08-22` Git history: `23:46:11+08:00` for the home hero change and `11:47:40+08:00` for the other 46 routes' latest single-origin metadata/content change. Version 1 was polluted by a shared client effect and version 2 by sidebar-only copyright copy in the former shared configuration module. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. The browser gate runs Chromium against built `dist/` through a strict, non-reused custom preview server. Route tests directly open `/`, `/part/a`, `/part/a/01`, `/method`, and `/part/g/plug`, then exercise live client navigation and keyboard focus. They check HTTP status, prerendered HTML, absence of pending streamed-Suspense placeholders, route CSS at first paint, hydration without errors, CLS below `0.05`, title/description/abstract/canonical/zero-hreflang/Open Graph/JSON-LD metadata, one visible `h1`, route announcements, current-page semantics, initial-load focus, changed-route focus, and skip-link focus. Navigation-positioning tests click a representative lesson outline in each of the seven families, restore a direct lesson fragment, verify the mobile outline and an ordinary family-page fragment, and require a changed route to reset old-page scroll immediately. An unknown path must return 404 with `noindex,nofollow`, no canonical, and the themed not-found heading. Worker-level Node tests separately cover the Accept matrix, 406 behavior, HEAD parity, representation-specific ETags, hidden internal assets, and unaffected API/static/unknown paths. diff --git a/site/public/llms.txt b/site/public/llms.txt index 57883be..62bfba5 100644 --- a/site/public/llms.txt +++ b/site/public/llms.txt @@ -13,73 +13,73 @@ ## 入口 - [DP大师 · DP Master](https://dp.round1.cc/): DP大师面向算法学习者,用精讲、逐帧可视化、题目索引和小游戏讲清状态定义、转移顺序与模型迁移。(最近更新:2026-08-22T23:46:11+08:00) -- [通用方法论 · DP大师](https://dp.round1.cc/method): 用状态设计、转移方程、计算顺序、空间优化和调试清单,建立可复用的动态规划解题方法。(最近更新:2026-08-22T12:47:11+08:00) -- [题目索引 · DP大师](https://dp.round1.cc/problems): 按 DP 家族、课程、难度和关键词检索教程中的例题与练习,快速定位对应的洛谷题目和学习路径。(最近更新:2026-08-22T12:47:11+08:00) +- [通用方法论 · DP大师](https://dp.round1.cc/method): 用状态设计、转移方程、计算顺序、空间优化和调试清单,建立可复用的动态规划解题方法。(最近更新:2026-08-22T11:47:40+08:00) +- [题目索引 · DP大师](https://dp.round1.cc/problems): 按 DP 家族、课程、难度和关键词检索教程中的例题与练习,快速定位对应的洛谷题目和学习路径。(最近更新:2026-08-22T11:47:40+08:00) ## 背包 DP(A) -- [背包 DP · DP大师](https://dp.round1.cc/part/a): 背包 DP学习路径以“容量受限下的取舍:物品件数属性决定了背包的谱系。”为主线,串联 9 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T12:47:11+08:00) -- [01 背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/01): 01 背包课程回答“每件物品只能取一次时,为什么容量必须逆序更新”。内容以取或不取·一维逆推·恰好装满为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [完全背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/complete): 完全背包课程回答“物品可无限取用时,正序更新如何复用本轮状态”。内容以无限件·一维正推为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [多重背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/multiple): 多重背包课程回答“有限件物品怎样从朴素枚举优化到二进制或单调队列”。内容以朴素·二进制·单调队列为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [分组背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/group): 分组背包课程回答“每组至多选一件时,如何隔离组内选择”。内容以每组至多选一件为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [混合背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/mixed): 混合背包课程回答“01、完全和多重物品怎样共用一套转移框架”。内容以01/完全/多重同题为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [二维费用背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/cost2d): 二维费用背包课程回答“同时受两种容量约束时,状态维度与枚举顺序如何设计”。内容以两种费用同时受限为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [有依赖的背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/dep): 有依赖的背包课程回答“带主件附件依赖的选择怎样转化为合法组合”。内容以主件-附件·依赖→分组为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [背包综合变形 · 背包 DP · DP大师](https://dp.round1.cc/part/a/variant): 背包综合变形课程回答“背包模型如何扩展到计数、撤销和方案恢复”。内容以方案数·撤销·具体方案为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [辨析:分数背包=贪心 · 背包 DP · DP大师](https://dp.round1.cc/part/a/fractional): 辨析:分数背包=贪心课程回答“为什么可分割物品属于贪心而不是 01 背包”。内容以可分割⇒贪心 vs 整取⇒DP为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [背包 DP · DP大师](https://dp.round1.cc/part/a): 背包 DP学习路径以“容量受限下的取舍:物品件数属性决定了背包的谱系。”为主线,串联 9 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T11:47:40+08:00) +- [01 背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/01): 01 背包课程回答“每件物品只能取一次时,为什么容量必须逆序更新”。内容以取或不取·一维逆推·恰好装满为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [完全背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/complete): 完全背包课程回答“物品可无限取用时,正序更新如何复用本轮状态”。内容以无限件·一维正推为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [多重背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/multiple): 多重背包课程回答“有限件物品怎样从朴素枚举优化到二进制或单调队列”。内容以朴素·二进制·单调队列为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [分组背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/group): 分组背包课程回答“每组至多选一件时,如何隔离组内选择”。内容以每组至多选一件为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [混合背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/mixed): 混合背包课程回答“01、完全和多重物品怎样共用一套转移框架”。内容以01/完全/多重同题为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [二维费用背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/cost2d): 二维费用背包课程回答“同时受两种容量约束时,状态维度与枚举顺序如何设计”。内容以两种费用同时受限为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [有依赖的背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/dep): 有依赖的背包课程回答“带主件附件依赖的选择怎样转化为合法组合”。内容以主件-附件·依赖→分组为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [背包综合变形 · 背包 DP · DP大师](https://dp.round1.cc/part/a/variant): 背包综合变形课程回答“背包模型如何扩展到计数、撤销和方案恢复”。内容以方案数·撤销·具体方案为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [辨析:分数背包=贪心 · 背包 DP · DP大师](https://dp.round1.cc/part/a/fractional): 辨析:分数背包=贪心课程回答“为什么可分割物品属于贪心而不是 01 背包”。内容以可分割⇒贪心 vs 整取⇒DP为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) ## 线性 DP(B) -- [线性 DP · DP大师](https://dp.round1.cc/part/b): 线性 DP学习路径以“把问题排成一条推进的序列,dp[i] 只依赖更早的状态。”为主线,串联 7 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T12:47:11+08:00) -- [路径型 / 递推入门 · 线性 DP · DP大师](https://dp.round1.cc/part/b/path): 路径型 / 递推入门课程回答“沿序列或网格推进时,如何定义最小充分状态”。内容以数字三角形·过河卒·方格取数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [最大子段和 · 线性 DP · DP大师](https://dp.round1.cc/part/b/maxseg): 最大子段和课程回答“如何用一个局部状态维护最大连续子段”。内容以Kadane·环形·两段不相交为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [最长上升子序列 LIS · 线性 DP · DP大师](https://dp.round1.cc/part/b/lis): 最长上升子序列 LIS课程回答“最长上升子序列如何从二次转移优化到对数查找”。内容以O(n²) 与 O(n log n)·导弹拦截为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [最长公共子序列 LCS · 线性 DP · DP大师](https://dp.round1.cc/part/b/lcs): 最长公共子序列 LCS课程回答“两个序列的公共结构如何通过二维状态刻画”。内容以排列 LCS→LIS·计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [编辑距离 · 线性 DP · DP大师](https://dp.round1.cc/part/b/edit): 编辑距离课程回答“插入、删除和替换如何统一进编辑距离转移”。内容以删/插/改三向转移为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [线性状态机 DP · 线性 DP · DP大师](https://dp.round1.cc/part/b/fsm): 线性状态机 DP课程回答“带持有或选择限制的问题怎样画成有限状态机”。内容以受限选取·股票买卖为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [计数 / 划分型 · 线性 DP · DP大师](https://dp.round1.cc/part/b/count): 计数 / 划分型课程回答“计数与划分问题如何避免重复或遗漏方案”。内容以方案数·高精度·整数划分为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [线性 DP · DP大师](https://dp.round1.cc/part/b): 线性 DP学习路径以“把问题排成一条推进的序列,dp[i] 只依赖更早的状态。”为主线,串联 7 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T11:47:40+08:00) +- [路径型 / 递推入门 · 线性 DP · DP大师](https://dp.round1.cc/part/b/path): 路径型 / 递推入门课程回答“沿序列或网格推进时,如何定义最小充分状态”。内容以数字三角形·过河卒·方格取数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [最大子段和 · 线性 DP · DP大师](https://dp.round1.cc/part/b/maxseg): 最大子段和课程回答“如何用一个局部状态维护最大连续子段”。内容以Kadane·环形·两段不相交为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [最长上升子序列 LIS · 线性 DP · DP大师](https://dp.round1.cc/part/b/lis): 最长上升子序列 LIS课程回答“最长上升子序列如何从二次转移优化到对数查找”。内容以O(n²) 与 O(n log n)·导弹拦截为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [最长公共子序列 LCS · 线性 DP · DP大师](https://dp.round1.cc/part/b/lcs): 最长公共子序列 LCS课程回答“两个序列的公共结构如何通过二维状态刻画”。内容以排列 LCS→LIS·计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [编辑距离 · 线性 DP · DP大师](https://dp.round1.cc/part/b/edit): 编辑距离课程回答“插入、删除和替换如何统一进编辑距离转移”。内容以删/插/改三向转移为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [线性状态机 DP · 线性 DP · DP大师](https://dp.round1.cc/part/b/fsm): 线性状态机 DP课程回答“带持有或选择限制的问题怎样画成有限状态机”。内容以受限选取·股票买卖为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [计数 / 划分型 · 线性 DP · DP大师](https://dp.round1.cc/part/b/count): 计数 / 划分型课程回答“计数与划分问题如何避免重复或遗漏方案”。内容以方案数·高精度·整数划分为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) ## 区间 DP(C) -- [区间 DP · DP大师](https://dp.round1.cc/part/c): 区间 DP学习路径以“dp[l][r] 表示区间最优,枚举分割/合并点,按长度递推。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T12:47:11+08:00) -- [石子合并(链形) · 区间 DP · DP大师](https://dp.round1.cc/part/c/stone): 石子合并(链形)课程回答“区间合并代价为何要按长度递增计算”。内容以区间合并基础模型为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [环形区间 DP · 区间 DP · DP大师](https://dp.round1.cc/part/c/ring): 环形区间 DP课程回答“环形区间怎样通过复制序列转化为链形区间”。内容以断环为链·能量项链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [回文 / 括号 · 区间 DP · DP大师](https://dp.round1.cc/part/c/palindrome): 回文 / 括号课程回答“端点匹配如何组织回文与括号类转移”。内容以收缩扩展·端点匹配为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [加分二叉树型 · 区间 DP · DP大师](https://dp.round1.cc/part/c/tree): 加分二叉树型课程回答“枚举区间根节点时,子区间如何对应左右子树”。内容以枚举根·区间即子树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [合并 / 删除类 · 区间 DP · DP大师](https://dp.round1.cc/part/c/merge): 合并 / 删除类课程回答“合并与删除过程怎样压缩成区间状态”。内容以2048·区间删除代价为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [区间 DP · DP大师](https://dp.round1.cc/part/c): 区间 DP学习路径以“dp[l][r] 表示区间最优,枚举分割/合并点,按长度递推。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T11:47:40+08:00) +- [石子合并(链形) · 区间 DP · DP大师](https://dp.round1.cc/part/c/stone): 石子合并(链形)课程回答“区间合并代价为何要按长度递增计算”。内容以区间合并基础模型为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [环形区间 DP · 区间 DP · DP大师](https://dp.round1.cc/part/c/ring): 环形区间 DP课程回答“环形区间怎样通过复制序列转化为链形区间”。内容以断环为链·能量项链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [回文 / 括号 · 区间 DP · DP大师](https://dp.round1.cc/part/c/palindrome): 回文 / 括号课程回答“端点匹配如何组织回文与括号类转移”。内容以收缩扩展·端点匹配为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [加分二叉树型 · 区间 DP · DP大师](https://dp.round1.cc/part/c/tree): 加分二叉树型课程回答“枚举区间根节点时,子区间如何对应左右子树”。内容以枚举根·区间即子树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [合并 / 删除类 · 区间 DP · DP大师](https://dp.round1.cc/part/c/merge): 合并 / 删除类课程回答“合并与删除过程怎样压缩成区间状态”。内容以2048·区间删除代价为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) ## 矩阵 DP(D) -- [矩阵 DP · DP大师](https://dp.round1.cc/part/d): 矩阵 DP学习路径以“两条主线:网格坐标上的 DP,与矩阵快速幂加速的递推。”为主线,串联 2 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T12:47:11+08:00) -- [网格 / 矩阵上的 DP · 矩阵 DP · DP大师](https://dp.round1.cc/part/d/grid): 网格 / 矩阵上的 DP课程回答“网格路径、最大正方形和双路径如何选择状态维度”。内容以路径·最大正方形·双线程为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [矩阵快速幂加速 · 矩阵 DP · DP大师](https://dp.round1.cc/part/d/matpow): 矩阵快速幂加速课程回答“线性递推怎样写成矩阵并用快速幂加速”。内容以递推→矩阵幂·O(k³log n)为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [矩阵 DP · DP大师](https://dp.round1.cc/part/d): 矩阵 DP学习路径以“两条主线:网格坐标上的 DP,与矩阵快速幂加速的递推。”为主线,串联 2 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T11:47:40+08:00) +- [网格 / 矩阵上的 DP · 矩阵 DP · DP大师](https://dp.round1.cc/part/d/grid): 网格 / 矩阵上的 DP课程回答“网格路径、最大正方形和双路径如何选择状态维度”。内容以路径·最大正方形·双线程为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [矩阵快速幂加速 · 矩阵 DP · DP大师](https://dp.round1.cc/part/d/matpow): 矩阵快速幂加速课程回答“线性递推怎样写成矩阵并用快速幂加速”。内容以递推→矩阵幂·O(k³log n)为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) ## 换根 DP(E) -- [换根 DP · DP大师](https://dp.round1.cc/part/e): 换根 DP学习路径以“二次扫描:固定根一遍 DFS,再一遍换根 O(1) 推每个点。”为主线,串联 4 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T12:47:11+08:00) -- [换根基础模型 · 换根 DP · DP大师](https://dp.round1.cc/part/e/basic): 换根基础模型课程回答“一次定根结果如何在线性时间转移到所有根”。内容以二次扫描骨架为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [距离和换根 · 换根 DP · DP大师](https://dp.round1.cc/part/e/distsum): 距离和换根课程回答“换根时全树距离和为什么只需常数时间更新”。内容以深度和·带权距离和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [子树内外合并 · 换根 DP · DP大师](https://dp.round1.cc/part/e/inout): 子树内外合并课程回答“子树内外贡献如何在第二遍 DFS 中合并”。内容以距离≤k 点权和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [中心 / 偏心距 · 换根 DP · DP大师](https://dp.round1.cc/part/e/center): 中心 / 偏心距课程回答“偏心距与树中心如何由向下和向上信息共同得到”。内容以树的直径·核为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [换根 DP · DP大师](https://dp.round1.cc/part/e): 换根 DP学习路径以“二次扫描:固定根一遍 DFS,再一遍换根 O(1) 推每个点。”为主线,串联 4 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T11:47:40+08:00) +- [换根基础模型 · 换根 DP · DP大师](https://dp.round1.cc/part/e/basic): 换根基础模型课程回答“一次定根结果如何在线性时间转移到所有根”。内容以二次扫描骨架为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [距离和换根 · 换根 DP · DP大师](https://dp.round1.cc/part/e/distsum): 距离和换根课程回答“换根时全树距离和为什么只需常数时间更新”。内容以深度和·带权距离和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [子树内外合并 · 换根 DP · DP大师](https://dp.round1.cc/part/e/inout): 子树内外合并课程回答“子树内外贡献如何在第二遍 DFS 中合并”。内容以距离≤k 点权和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [中心 / 偏心距 · 换根 DP · DP大师](https://dp.round1.cc/part/e/center): 中心 / 偏心距课程回答“偏心距与树中心如何由向下和向上信息共同得到”。内容以树的直径·核为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) ## 树形 DP(F) -- [树形 DP · DP大师](https://dp.round1.cc/part/f): 树形 DP学习路径以“dp[u][…] 表示子树最优,后序遍历自底向上合并。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T12:47:11+08:00) -- [选点 / 最大独立集 · 树形 DP · DP大师](https://dp.round1.cc/part/f/select): 选点 / 最大独立集课程回答“父子不能同时选择时,选与不选状态如何配合”。内容以没有上司的舞会为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [树上背包 · 树形 DP · DP大师](https://dp.round1.cc/part/f/knapsack): 树上背包课程回答“树上选取数量约束如何在子树间做背包合并”。内容以二叉苹果树·选课为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [直径 / 重心 DP · 树形 DP · DP大师](https://dp.round1.cc/part/f/diameter): 直径 / 重心 DP课程回答“过当前节点的多条链如何组合出直径与重心信息”。内容以过点最长链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [覆盖 / 支配 / 染色 · 树形 DP · DP大师](https://dp.round1.cc/part/f/cover): 覆盖 / 支配 / 染色课程回答“覆盖、支配和染色约束需要哪些互斥状态”。内容以三状态·染色计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [方案数 / 距离统计 · 树形 DP · DP大师](https://dp.round1.cc/part/f/count): 方案数 / 距离统计课程回答“树上方案数与距离统计如何在合并时避免重复”。内容以联合权值·括号树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [树形 DP · DP大师](https://dp.round1.cc/part/f): 树形 DP学习路径以“dp[u][…] 表示子树最优,后序遍历自底向上合并。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T11:47:40+08:00) +- [选点 / 最大独立集 · 树形 DP · DP大师](https://dp.round1.cc/part/f/select): 选点 / 最大独立集课程回答“父子不能同时选择时,选与不选状态如何配合”。内容以没有上司的舞会为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [树上背包 · 树形 DP · DP大师](https://dp.round1.cc/part/f/knapsack): 树上背包课程回答“树上选取数量约束如何在子树间做背包合并”。内容以二叉苹果树·选课为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [直径 / 重心 DP · 树形 DP · DP大师](https://dp.round1.cc/part/f/diameter): 直径 / 重心 DP课程回答“过当前节点的多条链如何组合出直径与重心信息”。内容以过点最长链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [覆盖 / 支配 / 染色 · 树形 DP · DP大师](https://dp.round1.cc/part/f/cover): 覆盖 / 支配 / 染色课程回答“覆盖、支配和染色约束需要哪些互斥状态”。内容以三状态·染色计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [方案数 / 距离统计 · 树形 DP · DP大师](https://dp.round1.cc/part/f/count): 方案数 / 距离统计课程回答“树上方案数与距离统计如何在合并时避免重复”。内容以联合权值·括号树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) ## 状压 DP(G) -- [状压 DP · DP大师](https://dp.round1.cc/part/g): 状压 DP学习路径以“状态是一个集合,用二进制整数表示;转移在 mask 间进行。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T12:47:11+08:00) -- [棋盘 / 轮廓状压 · 状压 DP · DP大师](https://dp.round1.cc/part/g/board): 棋盘 / 轮廓状压课程回答“逐行棋盘约束怎样编码为兼容位掩码”。内容以互不侵犯·炮兵阵地为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [集合状压 / TSP · 状压 DP · DP大师](https://dp.round1.cc/part/g/tsp): 集合状压 / TSP课程回答“集合访问状态如何保证 Hamilton 路径不重不漏”。内容以最短 Hamilton·吃奶酪为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [状压 + 覆盖 · 状压 DP · DP大师](https://dp.round1.cc/part/g/cover): 状压 + 覆盖课程回答“几何覆盖选择怎样预处理成可转移的状态集合”。内容以愤怒的小鸟·宝藏为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [综合技巧 · 状压 DP · DP大师](https://dp.round1.cc/part/g/subset): 综合技巧课程回答“子集枚举与计数变形有哪些可复用的位运算技巧”。内容以枚举子集·计数变形为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) -- [插头 DP · 状压 DP · DP大师](https://dp.round1.cc/part/g/plug): 插头 DP课程回答“轮廓线上的连通性怎样压缩为插头状态”。内容以轮廓线连通性为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T12:47:11+08:00) +- [状压 DP · DP大师](https://dp.round1.cc/part/g): 状压 DP学习路径以“状态是一个集合,用二进制整数表示;转移在 mask 间进行。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T11:47:40+08:00) +- [棋盘 / 轮廓状压 · 状压 DP · DP大师](https://dp.round1.cc/part/g/board): 棋盘 / 轮廓状压课程回答“逐行棋盘约束怎样编码为兼容位掩码”。内容以互不侵犯·炮兵阵地为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [集合状压 / TSP · 状压 DP · DP大师](https://dp.round1.cc/part/g/tsp): 集合状压 / TSP课程回答“集合访问状态如何保证 Hamilton 路径不重不漏”。内容以最短 Hamilton·吃奶酪为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [状压 + 覆盖 · 状压 DP · DP大师](https://dp.round1.cc/part/g/cover): 状压 + 覆盖课程回答“几何覆盖选择怎样预处理成可转移的状态集合”。内容以愤怒的小鸟·宝藏为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [综合技巧 · 状压 DP · DP大师](https://dp.round1.cc/part/g/subset): 综合技巧课程回答“子集枚举与计数变形有哪些可复用的位运算技巧”。内容以枚举子集·计数变形为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [插头 DP · 状压 DP · DP大师](https://dp.round1.cc/part/g/plug): 插头 DP课程回答“轮廓线上的连通性怎样压缩为插头状态”。内容以轮廓线连通性为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) ## 使用说明 diff --git a/site/public/route-summaries.json b/site/public/route-summaries.json index 404989f..7c34b03 100644 --- a/site/public/route-summaries.json +++ b/site/public/route-summaries.json @@ -17,7 +17,7 @@ "title": "背包 DP · DP大师", "summary": "背包 DP学习路径以“容量受限下的取舍:物品件数属性决定了背包的谱系。”为主线,串联 9 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-22T12:47:11+08:00" + "lastModified": "2026-08-22T11:47:40+08:00" }, { "path": "/part/b", @@ -25,7 +25,7 @@ "title": "线性 DP · DP大师", "summary": "线性 DP学习路径以“把问题排成一条推进的序列,dp[i] 只依赖更早的状态。”为主线,串联 7 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-22T12:47:11+08:00" + "lastModified": "2026-08-22T11:47:40+08:00" }, { "path": "/part/c", @@ -33,7 +33,7 @@ "title": "区间 DP · DP大师", "summary": "区间 DP学习路径以“dp[l][r] 表示区间最优,枚举分割/合并点,按长度递推。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-22T12:47:11+08:00" + "lastModified": "2026-08-22T11:47:40+08:00" }, { "path": "/part/d", @@ -41,7 +41,7 @@ "title": "矩阵 DP · DP大师", "summary": "矩阵 DP学习路径以“两条主线:网格坐标上的 DP,与矩阵快速幂加速的递推。”为主线,串联 2 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-22T12:47:11+08:00" + "lastModified": "2026-08-22T11:47:40+08:00" }, { "path": "/part/e", @@ -49,7 +49,7 @@ "title": "换根 DP · DP大师", "summary": "换根 DP学习路径以“二次扫描:固定根一遍 DFS,再一遍换根 O(1) 推每个点。”为主线,串联 4 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-22T12:47:11+08:00" + "lastModified": "2026-08-22T11:47:40+08:00" }, { "path": "/part/f", @@ -57,7 +57,7 @@ "title": "树形 DP · DP大师", "summary": "树形 DP学习路径以“dp[u][…] 表示子树最优,后序遍历自底向上合并。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-22T12:47:11+08:00" + "lastModified": "2026-08-22T11:47:40+08:00" }, { "path": "/part/g", @@ -65,7 +65,7 @@ "title": "状压 DP · DP大师", "summary": "状压 DP学习路径以“状态是一个集合,用二进制整数表示;转移在 mask 间进行。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-22T12:47:11+08:00" + "lastModified": "2026-08-22T11:47:40+08:00" }, { "path": "/part/a/01", @@ -73,7 +73,7 @@ "title": "01 背包 · 背包 DP · DP大师", "summary": "01 背包课程回答“每件物品只能取一次时,为什么容量必须逆序更新”。内容以取或不取·一维逆推·恰好装满为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -83,7 +83,7 @@ "title": "完全背包 · 背包 DP · DP大师", "summary": "完全背包课程回答“物品可无限取用时,正序更新如何复用本轮状态”。内容以无限件·一维正推为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -93,7 +93,7 @@ "title": "多重背包 · 背包 DP · DP大师", "summary": "多重背包课程回答“有限件物品怎样从朴素枚举优化到二进制或单调队列”。内容以朴素·二进制·单调队列为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -103,7 +103,7 @@ "title": "分组背包 · 背包 DP · DP大师", "summary": "分组背包课程回答“每组至多选一件时,如何隔离组内选择”。内容以每组至多选一件为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -113,7 +113,7 @@ "title": "混合背包 · 背包 DP · DP大师", "summary": "混合背包课程回答“01、完全和多重物品怎样共用一套转移框架”。内容以01/完全/多重同题为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -123,7 +123,7 @@ "title": "二维费用背包 · 背包 DP · DP大师", "summary": "二维费用背包课程回答“同时受两种容量约束时,状态维度与枚举顺序如何设计”。内容以两种费用同时受限为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -133,7 +133,7 @@ "title": "有依赖的背包 · 背包 DP · DP大师", "summary": "有依赖的背包课程回答“带主件附件依赖的选择怎样转化为合法组合”。内容以主件-附件·依赖→分组为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -143,7 +143,7 @@ "title": "背包综合变形 · 背包 DP · DP大师", "summary": "背包综合变形课程回答“背包模型如何扩展到计数、撤销和方案恢复”。内容以方案数·撤销·具体方案为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -153,7 +153,7 @@ "title": "辨析:分数背包=贪心 · 背包 DP · DP大师", "summary": "辨析:分数背包=贪心课程回答“为什么可分割物品属于贪心而不是 01 背包”。内容以可分割⇒贪心 vs 整取⇒DP为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -163,7 +163,7 @@ "title": "路径型 / 递推入门 · 线性 DP · DP大师", "summary": "路径型 / 递推入门课程回答“沿序列或网格推进时,如何定义最小充分状态”。内容以数字三角形·过河卒·方格取数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -173,7 +173,7 @@ "title": "最大子段和 · 线性 DP · DP大师", "summary": "最大子段和课程回答“如何用一个局部状态维护最大连续子段”。内容以Kadane·环形·两段不相交为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -183,7 +183,7 @@ "title": "最长上升子序列 LIS · 线性 DP · DP大师", "summary": "最长上升子序列 LIS课程回答“最长上升子序列如何从二次转移优化到对数查找”。内容以O(n²) 与 O(n log n)·导弹拦截为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -193,7 +193,7 @@ "title": "最长公共子序列 LCS · 线性 DP · DP大师", "summary": "最长公共子序列 LCS课程回答“两个序列的公共结构如何通过二维状态刻画”。内容以排列 LCS→LIS·计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -203,7 +203,7 @@ "title": "编辑距离 · 线性 DP · DP大师", "summary": "编辑距离课程回答“插入、删除和替换如何统一进编辑距离转移”。内容以删/插/改三向转移为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -213,7 +213,7 @@ "title": "线性状态机 DP · 线性 DP · DP大师", "summary": "线性状态机 DP课程回答“带持有或选择限制的问题怎样画成有限状态机”。内容以受限选取·股票买卖为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -223,7 +223,7 @@ "title": "计数 / 划分型 · 线性 DP · DP大师", "summary": "计数 / 划分型课程回答“计数与划分问题如何避免重复或遗漏方案”。内容以方案数·高精度·整数划分为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -233,7 +233,7 @@ "title": "石子合并(链形) · 区间 DP · DP大师", "summary": "石子合并(链形)课程回答“区间合并代价为何要按长度递增计算”。内容以区间合并基础模型为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -243,7 +243,7 @@ "title": "环形区间 DP · 区间 DP · DP大师", "summary": "环形区间 DP课程回答“环形区间怎样通过复制序列转化为链形区间”。内容以断环为链·能量项链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -253,7 +253,7 @@ "title": "回文 / 括号 · 区间 DP · DP大师", "summary": "回文 / 括号课程回答“端点匹配如何组织回文与括号类转移”。内容以收缩扩展·端点匹配为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -263,7 +263,7 @@ "title": "加分二叉树型 · 区间 DP · DP大师", "summary": "加分二叉树型课程回答“枚举区间根节点时,子区间如何对应左右子树”。内容以枚举根·区间即子树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -273,7 +273,7 @@ "title": "合并 / 删除类 · 区间 DP · DP大师", "summary": "合并 / 删除类课程回答“合并与删除过程怎样压缩成区间状态”。内容以2048·区间删除代价为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -283,7 +283,7 @@ "title": "网格 / 矩阵上的 DP · 矩阵 DP · DP大师", "summary": "网格 / 矩阵上的 DP课程回答“网格路径、最大正方形和双路径如何选择状态维度”。内容以路径·最大正方形·双线程为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -293,7 +293,7 @@ "title": "矩阵快速幂加速 · 矩阵 DP · DP大师", "summary": "矩阵快速幂加速课程回答“线性递推怎样写成矩阵并用快速幂加速”。内容以递推→矩阵幂·O(k³log n)为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -303,7 +303,7 @@ "title": "换根基础模型 · 换根 DP · DP大师", "summary": "换根基础模型课程回答“一次定根结果如何在线性时间转移到所有根”。内容以二次扫描骨架为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -313,7 +313,7 @@ "title": "距离和换根 · 换根 DP · DP大师", "summary": "距离和换根课程回答“换根时全树距离和为什么只需常数时间更新”。内容以深度和·带权距离和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -323,7 +323,7 @@ "title": "子树内外合并 · 换根 DP · DP大师", "summary": "子树内外合并课程回答“子树内外贡献如何在第二遍 DFS 中合并”。内容以距离≤k 点权和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -333,7 +333,7 @@ "title": "中心 / 偏心距 · 换根 DP · DP大师", "summary": "中心 / 偏心距课程回答“偏心距与树中心如何由向下和向上信息共同得到”。内容以树的直径·核为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -343,7 +343,7 @@ "title": "选点 / 最大独立集 · 树形 DP · DP大师", "summary": "选点 / 最大独立集课程回答“父子不能同时选择时,选与不选状态如何配合”。内容以没有上司的舞会为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -353,7 +353,7 @@ "title": "树上背包 · 树形 DP · DP大师", "summary": "树上背包课程回答“树上选取数量约束如何在子树间做背包合并”。内容以二叉苹果树·选课为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -363,7 +363,7 @@ "title": "直径 / 重心 DP · 树形 DP · DP大师", "summary": "直径 / 重心 DP课程回答“过当前节点的多条链如何组合出直径与重心信息”。内容以过点最长链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -373,7 +373,7 @@ "title": "覆盖 / 支配 / 染色 · 树形 DP · DP大师", "summary": "覆盖 / 支配 / 染色课程回答“覆盖、支配和染色约束需要哪些互斥状态”。内容以三状态·染色计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -383,7 +383,7 @@ "title": "方案数 / 距离统计 · 树形 DP · DP大师", "summary": "方案数 / 距离统计课程回答“树上方案数与距离统计如何在合并时避免重复”。内容以联合权值·括号树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -393,7 +393,7 @@ "title": "棋盘 / 轮廓状压 · 状压 DP · DP大师", "summary": "棋盘 / 轮廓状压课程回答“逐行棋盘约束怎样编码为兼容位掩码”。内容以互不侵犯·炮兵阵地为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -403,7 +403,7 @@ "title": "集合状压 / TSP · 状压 DP · DP大师", "summary": "集合状压 / TSP课程回答“集合访问状态如何保证 Hamilton 路径不重不漏”。内容以最短 Hamilton·吃奶酪为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -413,7 +413,7 @@ "title": "状压 + 覆盖 · 状压 DP · DP大师", "summary": "状压 + 覆盖课程回答“几何覆盖选择怎样预处理成可转移的状态集合”。内容以愤怒的小鸟·宝藏为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -423,7 +423,7 @@ "title": "综合技巧 · 状压 DP · DP大师", "summary": "综合技巧课程回答“子集枚举与计数变形有哪些可复用的位运算技巧”。内容以枚举子集·计数变形为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -433,7 +433,7 @@ "title": "插头 DP · 状压 DP · DP大师", "summary": "插头 DP课程回答“轮廓线上的连通性怎样压缩为插头状态”。内容以轮廓线连通性为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T12:47:11+08:00", + "lastModified": "2026-08-22T11:47:40+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -443,7 +443,7 @@ "title": "通用方法论 · DP大师", "summary": "用状态设计、转移方程、计算顺序、空间优化和调试清单,建立可复用的动态规划解题方法。", "type": "static", - "lastModified": "2026-08-22T12:47:11+08:00" + "lastModified": "2026-08-22T11:47:40+08:00" }, { "path": "/problems", @@ -451,7 +451,7 @@ "title": "题目索引 · DP大师", "summary": "按 DP 家族、课程、难度和关键词检索教程中的例题与练习,快速定位对应的洛谷题目和学习路径。", "type": "static", - "lastModified": "2026-08-22T12:47:11+08:00" + "lastModified": "2026-08-22T11:47:40+08:00" } ] } diff --git a/site/public/sitemap.xml b/site/public/sitemap.xml index 332dd0e..f8bdfbf 100644 --- a/site/public/sitemap.xml +++ b/site/public/sitemap.xml @@ -6,186 +6,186 @@ https://dp.round1.cc/method - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/a - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/a/01 - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/a/complete - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/a/cost2d - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/a/dep - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/a/fractional - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/a/group - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/a/mixed - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/a/multiple - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/a/variant - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/b - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/b/count - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/b/edit - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/b/fsm - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/b/lcs - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/b/lis - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/b/maxseg - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/b/path - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/c - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/c/merge - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/c/palindrome - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/c/ring - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/c/stone - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/c/tree - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/d - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/d/grid - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/d/matpow - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/e - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/e/basic - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/e/center - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/e/distsum - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/e/inout - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/f - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/f/count - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/f/cover - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/f/diameter - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/f/knapsack - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/f/select - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/g - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/g/board - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/g/cover - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/g/plug - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/g/subset - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/part/g/tsp - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 https://dp.round1.cc/problems - 2026-08-22T12:47:11+08:00 + 2026-08-22T11:47:40+08:00 diff --git a/site/scripts/analytics-core.test.mjs b/site/scripts/analytics-core.test.mjs index 24dde66..9ccca42 100644 --- a/site/scripts/analytics-core.test.mjs +++ b/site/scripts/analytics-core.test.mjs @@ -130,7 +130,11 @@ test('the single analytics provider stays on the production host and never injec const source = await import('node:fs/promises').then(({ readFile }) => readFile(new URL('../src/analytics/index.ts', import.meta.url), 'utf8'), ) - assert.match(source, /window\.location\.hostname !== SITE\.hostname/) + assert.match( + source, + /window\.location\.hostname !== CLIENT_RUNTIME\.productionHostname/, + ) + assert.match(source, /CLIENT_RUNTIME\.analyticsEndpoint/) assert.doesNotMatch(source, /static\.cloudflareinsights\.com/) assert.doesNotMatch(source, /script\.dataset\.cfBeacon/) assert.match(source, /ANALYTICS_PROVIDER = 'cloudflare'/) diff --git a/site/scripts/last-modified.mjs b/site/scripts/last-modified.mjs index e584d35..241b4bd 100644 --- a/site/scripts/last-modified.mjs +++ b/site/scripts/last-modified.mjs @@ -9,7 +9,7 @@ import { } from './semantic-source-graph.mjs' const projectRoot = fileURLToPath(new URL('../../', import.meta.url)) -export const ROUTE_CONTENT_DIGEST_VERSION = 2 +export const ROUTE_CONTENT_DIGEST_VERSION = 3 function gitNames(args) { const result = spawnSync('git', args, { diff --git a/site/scripts/semantic-source-graph.mjs b/site/scripts/semantic-source-graph.mjs index 0a1d1b3..8aef861 100644 --- a/site/scripts/semantic-source-graph.mjs +++ b/site/scripts/semantic-source-graph.mjs @@ -33,44 +33,240 @@ function isNonSemanticFile(path) { ) } -function visitNodes(value, visitor, parent = null) { - if (!value || typeof value !== 'object') return - if (Array.isArray(value)) { - for (const entry of value) visitNodes(entry, visitor, parent) - return +function childNodes(node) { + const children = [] + for (const [key, value] of Object.entries(node)) { + if (key === 'type' || key === 'start' || key === 'end') continue + if (Array.isArray(value)) { + for (const child of value) { + if (child && typeof child === 'object' && typeof child.type === 'string') { + children.push([child, key]) + } + } + } else if (value && typeof value === 'object' && typeof value.type === 'string') { + children.push([value, key]) + } } - if (typeof value.type === 'string') visitor(value, parent) - for (const child of Object.values(value)) { - if (child && typeof child === 'object') visitNodes(child, visitor, value) + return children +} + +function bindingIdentifiers(pattern, identifiers = []) { + if (!pattern || typeof pattern !== 'object') return identifiers + if (pattern.type === 'Identifier') { + identifiers.push(pattern) + } else if (pattern.type === 'AssignmentPattern') { + bindingIdentifiers(pattern.left, identifiers) + } else if (pattern.type === 'RestElement') { + bindingIdentifiers(pattern.argument, identifiers) + } else if (pattern.type === 'ArrayPattern') { + for (const element of pattern.elements) bindingIdentifiers(element, identifiers) + } else if (pattern.type === 'ObjectPattern') { + for (const property of pattern.properties) { + if (property.type === 'Property') bindingIdentifiers(property.value, identifiers) + if (property.type === 'RestElement') bindingIdentifiers(property.argument, identifiers) + } + } else if (pattern.type === 'TSParameterProperty') { + bindingIdentifiers(pattern.parameter, identifiers) } + return identifiers } -function boundNames(pattern) { - if (!pattern || typeof pattern !== 'object') return [] - if (pattern.type === 'Identifier') return [pattern.name] - if (pattern.type === 'AssignmentPattern') return boundNames(pattern.left) - if (pattern.type === 'RestElement') return boundNames(pattern.argument) - if (pattern.type === 'ArrayPattern') { - return pattern.elements.flatMap((element) => boundNames(element)) - } - if (pattern.type === 'ObjectPattern') { - return pattern.properties.flatMap((property) => ( - property.type === 'Property' - ? boundNames(property.value) - : boundNames(property.argument) - )) +function createScope(parent, kind) { + return { parent, kind, bindings: new Map() } +} + +function nearestFunctionScope(scope) { + let current = scope + while (current.parent && current.kind !== 'function' && current.kind !== 'program') { + current = current.parent } - return [] + return current } -function referencedNames(node) { - const names = new Set() - visitNodes(node, (candidate) => { - if (candidate.type === 'Identifier' || candidate.type === 'JSXIdentifier') { - names.add(candidate.name) +function semanticBindingGraph(program) { + const rootScope = createScope(null, 'program') + const nodeScopes = new WeakMap() + const nodeParents = new WeakMap() + const declaredIdentifiers = new WeakSet() + + function declare(pattern, scope, semanticNode) { + for (const identifier of bindingIdentifiers(pattern)) { + declaredIdentifiers.add(identifier) + const existing = scope.bindings.get(identifier.name) + if (existing) { + if (semanticNode) existing.semanticNodes.add(semanticNode) + } else { + scope.bindings.set(identifier.name, { + identifier, + semanticNodes: new Set(semanticNode ? [semanticNode] : []), + writes: [], + }) + } } - }) - return names + } + + function build(node, scope, parent = null, key = null, functionBody = false) { + nodeScopes.set(node, scope) + if (parent) nodeParents.set(node, { parent, key }) + + if (node.type === 'Program') { + for (const [child, childKey] of childNodes(node)) build(child, scope, node, childKey) + return + } + if ( + node.type === 'FunctionDeclaration' + || node.type === 'FunctionExpression' + || node.type === 'ArrowFunctionExpression' + ) { + if (node.type === 'FunctionDeclaration' && node.id) declare(node.id, scope, node) + const functionScope = createScope(scope, 'function') + if (node.type === 'FunctionExpression' && node.id) { + declare(node.id, functionScope, node) + } + for (const parameter of node.params ?? []) declare(parameter, functionScope, parameter) + if (node.id) build(node.id, functionScope, node, 'id') + for (const parameter of node.params ?? []) build(parameter, functionScope, node, 'params') + if (node.body) build(node.body, functionScope, node, 'body', true) + return + } + if (node.type === 'BlockStatement') { + const blockScope = functionBody ? scope : createScope(scope, 'block') + for (const [child, childKey] of childNodes(node)) build(child, blockScope, node, childKey) + return + } + if (node.type === 'VariableDeclaration') { + const declarationScope = node.kind === 'var' ? nearestFunctionScope(scope) : scope + for (const declarator of node.declarations) { + declare(declarator.id, declarationScope, node) + } + for (const [child, childKey] of childNodes(node)) build(child, scope, node, childKey) + return + } + if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') { + if (node.type === 'ClassDeclaration' && node.id) declare(node.id, scope, node) + const classScope = createScope(scope, 'class') + if (node.type === 'ClassDeclaration' && node.id) { + const classBinding = scope.bindings.get(node.id.name) + if (classBinding) classScope.bindings.set(node.id.name, classBinding) + } + if (node.type === 'ClassExpression' && node.id) declare(node.id, classScope, node) + for (const [child, childKey] of childNodes(node)) { + build(child, child === node.id ? scope : classScope, node, childKey) + } + return + } + if (node.type === 'CatchClause') { + const catchScope = createScope(scope, 'block') + if (node.param) declare(node.param, catchScope, node.param) + for (const [child, childKey] of childNodes(node)) build(child, catchScope, node, childKey) + return + } + if (node.type === 'ImportDeclaration') { + for (const specifier of node.specifiers ?? []) { + if (specifier.local) declare(specifier.local, scope, null) + } + for (const [child, childKey] of childNodes(node)) build(child, scope, node, childKey) + return + } + if (node.type === 'TSEnumDeclaration' && node.id) declare(node.id, scope, node) + for (const [child, childKey] of childNodes(node)) build(child, scope, node, childKey) + } + build(program, rootScope) + + function lookup(node) { + let scope = nodeScopes.get(node) + while (scope) { + const binding = scope.bindings.get(node.name) + if (binding) return binding + scope = scope.parent + } + return null + } + + function writtenBindings(node, bindings = []) { + if (!node || typeof node !== 'object') return bindings + if (node.type === 'Identifier') { + const binding = lookup(node) + if (binding) bindings.push(binding) + } else if (node.type === 'AssignmentPattern') { + writtenBindings(node.left, bindings) + } else if (node.type === 'ArrayPattern' || node.type === 'ArrayExpression') { + for (const element of node.elements) writtenBindings(element, bindings) + } else if (node.type === 'ObjectPattern' || node.type === 'ObjectExpression') { + for (const property of node.properties) { + if (property.type === 'Property') writtenBindings(property.value, bindings) + if (property.type === 'RestElement' || property.type === 'SpreadElement') { + writtenBindings(property.argument, bindings) + } + } + } else if (node.type === 'MemberExpression') { + writtenBindings(node.object, bindings) + } else if (node.type === 'ParenthesizedExpression') { + writtenBindings(node.expression, bindings) + } + return bindings + } + + function indexWrites(node) { + if (node.type === 'AssignmentExpression') { + for (const binding of writtenBindings(node.left)) binding.writes.push(node) + } + if (node.type === 'UpdateExpression') { + for (const binding of writtenBindings(node.argument)) binding.writes.push(node) + } + for (const [child] of childNodes(node)) indexWrites(child) + } + indexWrites(program) + + function isTypeReference(node) { + let current = node + while (nodeParents.has(current)) { + const { parent, key } = nodeParents.get(current) + if (parent.type.startsWith('TS')) { + if ( + (parent.type === 'TSAsExpression' + || parent.type === 'TSSatisfiesExpression' + || parent.type === 'TSNonNullExpression') + && key === 'expression' + ) { + current = parent + continue + } + return true + } + current = parent + } + return false + } + + function isReference(node) { + if (declaredIdentifiers.has(node) || isTypeReference(node)) return false + const relation = nodeParents.get(node) + if (!relation) return false + const { parent, key } = relation + if (node.type === 'JSXIdentifier') { + if (parent.type === 'JSXAttribute' && key === 'name') return false + if (parent.type === 'JSXMemberExpression' && key === 'property') return false + if (/^[a-z]/.test(node.name)) return false + return true + } + if (node.type !== 'Identifier') return false + if ( + (parent.type === 'MemberExpression' && key === 'property' && !parent.computed) + || (parent.type === 'Property' && key === 'key' && !parent.computed && !parent.shorthand) + || (parent.type === 'MethodDefinition' && key === 'key' && !parent.computed) + || parent.type === 'ImportSpecifier' + || parent.type === 'ImportDefaultSpecifier' + || parent.type === 'ImportNamespaceSpecifier' + || parent.type === 'ExportSpecifier' + || parent.type === 'LabeledStatement' + || parent.type === 'BreakStatement' + || parent.type === 'ContinueStatement' + ) return false + return true + } + + return { childNodes, isReference, lookup } } export function semanticSourceForDigest(path, source) { @@ -80,43 +276,62 @@ export function semanticSourceForDigest(path, source) { if (errors.length > 0) { throw new Error(`Unable to parse semantic source ${path}: ${errors[0].message}`) } - - const declarations = new Map() + const bindingGraph = semanticBindingGraph(program) const roots = [] - visitNodes(program, (node, parent) => { - if (node.type === 'FunctionDeclaration' && node.id?.name) { - declarations.set(node.id.name, node) - } - if (node.type === 'VariableDeclarator') { - const declaration = parent?.type === 'VariableDeclaration' ? parent : node - for (const name of boundNames(node.id)) declarations.set(name, declaration) - } + function findRoots(node) { if ( node.type === 'JSXElement' && source.slice(node.openingElement.name.start, node.openingElement.name.end) === rootName - ) { - roots.push(node) - } - }) + ) roots.push(node) + for (const [child] of bindingGraph.childNodes(node)) findRoots(child) + } + findRoots(program) if (roots.length !== 1) { throw new Error(`Expected one semantic JSX root ${rootName} in ${path}, found ${roots.length}`) } - const slices = new Map() - const pendingNames = [...referencedNames(roots[0])] - slices.set(`${roots[0].start}:${roots[0].end}`, roots[0]) - while (pendingNames.length > 0) { - const name = pendingNames.pop() - const declaration = declarations.get(name) - if (!declaration) continue - const key = `${declaration.start}:${declaration.end}` - if (slices.has(key)) continue - slices.set(key, declaration) - pendingNames.push(...referencedNames(declaration)) + const slices = new Set([roots[0]]) + const visitedBindings = new Set() + const pendingNodes = [roots[0]] + while (pendingNodes.length > 0) { + const selectedNode = pendingNodes.pop() + function collectReferences(node) { + if ( + (node.type === 'Identifier' || node.type === 'JSXIdentifier') + && bindingGraph.isReference(node) + ) { + const binding = bindingGraph.lookup(node) + if (binding && !visitedBindings.has(binding)) { + visitedBindings.add(binding) + for (const semanticNode of binding.semanticNodes) { + if (slices.has(semanticNode)) continue + slices.add(semanticNode) + pendingNodes.push(semanticNode) + } + for (const write of binding.writes) { + if (!slices.has(write)) { + slices.add(write) + pendingNodes.push(write) + } + } + } + } + for (const [child] of bindingGraph.childNodes(node)) collectReferences(child) + } + collectReferences(selectedNode) } - return [...slices.values()] + const orderedSlices = [...slices] .sort((left, right) => left.start - right.start) + const outermostSlices = orderedSlices.filter((node, index) => { + const { start, end } = node + return !orderedSlices.some((other, otherIndex) => ( + otherIndex !== index + && other.start <= start + && other.end >= end + )) + }) + return outermostSlices .map((node) => source.slice(node.start, node.end)) .join('\n') } diff --git a/site/scripts/semantic-source-graph.test.mjs b/site/scripts/semantic-source-graph.test.mjs index 05dd5e7..1734a2d 100644 --- a/site/scripts/semantic-source-graph.test.mjs +++ b/site/scripts/semantic-source-graph.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import test from 'node:test' import { PUBLIC_PATHS } from '../src/lib/publicRoutes.ts' @@ -72,6 +73,27 @@ test('all public route evidence excludes non-semantic client branches', () => { } }) +test('semantic site config contains only public representation inputs', () => { + const source = readFileSync( + new URL('../src/config/site.ts', import.meta.url), + 'utf8', + ) + for (const clientOnlyField of [ + 'analyticsEndpoint', + 'feedbackEndpoint', + 'copyrightHolder', + 'slogan', + ]) { + assert.doesNotMatch(source, new RegExp(`\\b${clientOnlyField}\\b`)) + } + + for (const pathname of PUBLIC_PATHS) { + const files = new Set(semanticRouteFiles(pathname)) + assert.equal(files.has('site/src/config/client-runtime.ts'), false, pathname) + assert.equal(files.has('site/src/config/sidebar-copy.ts'), false, pathname) + } +}) + test('mixed shell modules hash their AST-backed render dependencies', () => { const shellBefore = `export default function Shell() {\n useEffect(() => first())\n const mainRef = useRef(null)\n return
\n}` const shellAfterClientChange = `export default function Shell() {\n useEffect(() => second())\n const mainRef = useRef(null)\n return
\n}` @@ -125,3 +147,38 @@ test('mixed shell modules hash their AST-backed render dependencies', () => { /Expected one semantic JSX root/, ) }) + +test('mixed-module closure follows lexical symbols instead of bare names', () => { + const shadowBefore = `const label = '正文一'\nfunction AppContent() { return {label} }\nfunction clientOnly() { const label = '客户端一'; return label }` + const shadowAfterSemanticChange = shadowBefore.replace('正文一', '正文二') + const shadowAfterClientChange = shadowBefore.replace('客户端一', '客户端二') + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', shadowBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', shadowAfterSemanticChange), + ) + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', shadowBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', shadowAfterClientChange), + ) + + const classBefore = `class RouteLabel { static value = '正文一' }\nfunction AppContent() { return {RouteLabel.value} }` + const classAfter = classBefore.replace('正文一', '正文二') + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', classBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', classAfter), + ) + + const assignmentBefore = `let label\nlabel = '正文一'\nfunction AppContent() { return {label} }` + const assignmentAfter = assignmentBefore.replace('正文一', '正文二') + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', assignmentBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', assignmentAfter), + ) + + const attributeBefore = `const path = '客户端一'\nfunction AppContent() { return }` + const attributeAfter = attributeBefore.replace('客户端一', '客户端二') + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', attributeBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', attributeAfter), + ) +}) diff --git a/site/scripts/seo-contract.test.mjs b/site/scripts/seo-contract.test.mjs index 0761c6f..fbfa0f3 100644 --- a/site/scripts/seo-contract.test.mjs +++ b/site/scripts/seo-contract.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import test from 'node:test' +import { CLIENT_RUNTIME } from '../src/config/client-runtime.ts' import { SITE, SITE_ORIGIN } from '../src/config/site.ts' import { PARTS } from '../src/data/catalog.ts' import { @@ -51,11 +52,11 @@ test('the internal specimen is prerendered but excluded from public discovery', test('the single origin owns every canonical and emits no hreflang alternates', () => { assert.equal(SITE_ORIGIN, SITE.origin) assert.equal(SITE.origin, 'https://dp.round1.cc') - assert.equal(SITE.hostname, 'dp.round1.cc') + assert.equal(CLIENT_RUNTIME.productionHostname, 'dp.round1.cc') assert.equal(SITE.language, 'zh-Hans') // API 一律同源:没有跨域端点,也就没有跨站 CORS 面。 - assert.equal(SITE.analyticsEndpoint, '/api/analytics') - assert.equal(SITE.feedbackEndpoint, '/api/feedback') + assert.equal(CLIENT_RUNTIME.analyticsEndpoint, '/api/analytics') + assert.equal(CLIENT_RUNTIME.feedbackEndpoint, '/api/feedback') for (const path of PUBLIC_PATHS) { const meta = getPageMeta(path, SITE) @@ -229,7 +230,7 @@ test('discovery files expose the 47 approved URLs and real summaries', async () assert.match(lastModified, /--format=%cI/) assert.match(lastModified, /normalizeContentForDigest/) assert.match(lastModified, /semanticRouteFiles/) - assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 2) + assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 3) assert.equal(Object.keys(ROUTE_CONTENT_DIGESTS).length, PUBLIC_PATHS.length) assert.ok(Object.values(ROUTE_CONTENT_DIGESTS).every((digest) => /^[0-9a-f]{64}$/.test(digest))) assert.match(publicRoutes, /\.\.\/data\/catalog\.ts/) diff --git a/site/src/analytics/index.ts b/site/src/analytics/index.ts index 5cc6e84..cdebf05 100644 --- a/site/src/analytics/index.ts +++ b/site/src/analytics/index.ts @@ -1,4 +1,4 @@ -import { SITE } from '../config/site.ts' +import { CLIENT_RUNTIME } from '../config/client-runtime.ts' /** 唯一的统计 Provider。站点只发布到 Cloudflare Worker。 */ export const ANALYTICS_PROVIDER = 'cloudflare' @@ -29,7 +29,7 @@ export interface AnalyticsEvent { */ function sendFirstPartyEvent(event: AnalyticsEvent): void { // 只在生产域名上报:localhost、预览域和任何镜像都不写入数据集。 - if (window.location.hostname !== SITE.hostname) return + if (window.location.hostname !== CLIENT_RUNTIME.productionHostname) return const body = JSON.stringify({ provider: ANALYTICS_PROVIDER, @@ -47,7 +47,7 @@ function sendFirstPartyEvent(event: AnalyticsEvent): void { if ( typeof navigator.sendBeacon === 'function' && navigator.sendBeacon( - SITE.analyticsEndpoint, + CLIENT_RUNTIME.analyticsEndpoint, new Blob([body], { type: 'application/json' }), ) ) { @@ -57,7 +57,7 @@ function sendFirstPartyEvent(event: AnalyticsEvent): void { // sendBeacon 不可用时降级为 keepalive fetch。 } - void fetch(SITE.analyticsEndpoint, { + void fetch(CLIENT_RUNTIME.analyticsEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, diff --git a/site/src/components/feedback/FeedbackWidget.tsx b/site/src/components/feedback/FeedbackWidget.tsx index e561533..e60b406 100644 --- a/site/src/components/feedback/FeedbackWidget.tsx +++ b/site/src/components/feedback/FeedbackWidget.tsx @@ -3,7 +3,7 @@ import { useLocation } from 'react-router-dom' import { MessageSquarePlus, X, Send, Check, Loader2 } from 'lucide-react' import { platformLabel } from './platform.ts' import { getPart } from '../../data/catalog' -import { SITE } from '../../config/site.ts' +import { CLIENT_RUNTIME } from '../../config/client-runtime.ts' import { trackAnalyticsEvent } from '../../analytics/index.ts' import './feedback.css' @@ -267,7 +267,7 @@ export default function FeedbackWidget() { metadata: { kind }, }) try { - const res = await fetch(SITE.feedbackEndpoint, { + const res = await fetch(CLIENT_RUNTIME.feedbackEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(await payload()), diff --git a/site/src/components/layout/Sidebar.tsx b/site/src/components/layout/Sidebar.tsx index 1b0ac22..5c7cb96 100644 --- a/site/src/components/layout/Sidebar.tsx +++ b/site/src/components/layout/Sidebar.tsx @@ -4,6 +4,7 @@ import { NavLink, useMatch } from 'react-router-dom' import { BookOpen, Library } from 'lucide-react' import { PARTS } from '../../data/catalog' import { BRAND } from '../../config/site.ts' +import { SIDEBAR_COPY } from '../../config/sidebar-copy.ts' export default function Sidebar({ onNavigate }: { onNavigate?: () => void }) { const match = useMatch('/part/:pid/*') @@ -117,9 +118,9 @@ export default function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
-

{BRAND.slogan}

+

{SIDEBAR_COPY.slogan}

- © 2026 {BRAND.copyrightHolder} All rights reserved. + © 2026 {SIDEBAR_COPY.copyrightHolder} All rights reserved.

diff --git a/site/src/config/client-runtime.ts b/site/src/config/client-runtime.ts new file mode 100644 index 0000000..6264164 --- /dev/null +++ b/site/src/config/client-runtime.ts @@ -0,0 +1,8 @@ +import { SITE_ORIGIN } from './site.ts' + +/** 仅由浏览器端、且已从公开表示证据排除的分支消费。 */ +export const CLIENT_RUNTIME = Object.freeze({ + productionHostname: new URL(SITE_ORIGIN).hostname, + analyticsEndpoint: '/api/analytics', + feedbackEndpoint: '/api/feedback', +}) diff --git a/site/src/config/sidebar-copy.ts b/site/src/config/sidebar-copy.ts new file mode 100644 index 0000000..516646d --- /dev/null +++ b/site/src/config/sidebar-copy.ts @@ -0,0 +1,5 @@ +/** 导航与页脚文案不属于路线正文、head 或 Markdown 表示。 */ +export const SIDEBAR_COPY = Object.freeze({ + copyrightHolder: 'Round1', + slogan: '在算法的海洋中,我就是你的信标', +}) diff --git a/site/src/config/site.ts b/site/src/config/site.ts index 7f55f11..885c17e 100644 --- a/site/src/config/site.ts +++ b/site/src/config/site.ts @@ -3,20 +3,12 @@ export const BRAND = Object.freeze({ subtitle: 'DP Master', /** 结构化数据里的发布者 Organization,单一主体。 */ owner: 'AzureL蔚澜算法', - /** 页脚版权行署名,与 `owner` 分开。 */ - copyrightHolder: 'Round1', - slogan: '在算法的海洋中,我就是你的信标', }) export interface SiteConfig { origin: string - hostname: string /** `` 与结构化数据的 inLanguage。单语言站点用不带地区的脚本标签。 */ language: 'zh-Hans' - /** 第一方统计端点,同源。 */ - analyticsEndpoint: string - /** 反馈提交端点,同源。 */ - feedbackEndpoint: string } /** @@ -26,10 +18,7 @@ export interface SiteConfig { */ export const SITE: SiteConfig = Object.freeze({ origin: 'https://dp.round1.cc', - hostname: 'dp.round1.cc', language: 'zh-Hans', - analyticsEndpoint: '/api/analytics', - feedbackEndpoint: '/api/feedback', }) export const SITE_ORIGIN = SITE.origin diff --git a/site/src/data/routeLastModified.ts b/site/src/data/routeLastModified.ts index 01b9362..3d9c9ef 100644 --- a/site/src/data/routeLastModified.ts +++ b/site/src/data/routeLastModified.ts @@ -1,100 +1,100 @@ // 由 scripts/generate-seo.mjs 从源码时间证据生成,请勿手改。 -export const ROUTE_CONTENT_DIGEST_VERSION = 2 +export const ROUTE_CONTENT_DIGEST_VERSION = 3 export const ROUTE_LAST_MODIFIED: Readonly> = Object.freeze({ "/": "2026-08-22T23:46:11+08:00", - "/part/a": "2026-08-22T12:47:11+08:00", - "/part/b": "2026-08-22T12:47:11+08:00", - "/part/c": "2026-08-22T12:47:11+08:00", - "/part/d": "2026-08-22T12:47:11+08:00", - "/part/e": "2026-08-22T12:47:11+08:00", - "/part/f": "2026-08-22T12:47:11+08:00", - "/part/g": "2026-08-22T12:47:11+08:00", - "/part/a/01": "2026-08-22T12:47:11+08:00", - "/part/a/complete": "2026-08-22T12:47:11+08:00", - "/part/a/multiple": "2026-08-22T12:47:11+08:00", - "/part/a/group": "2026-08-22T12:47:11+08:00", - "/part/a/mixed": "2026-08-22T12:47:11+08:00", - "/part/a/cost2d": "2026-08-22T12:47:11+08:00", - "/part/a/dep": "2026-08-22T12:47:11+08:00", - "/part/a/variant": "2026-08-22T12:47:11+08:00", - "/part/a/fractional": "2026-08-22T12:47:11+08:00", - "/part/b/path": "2026-08-22T12:47:11+08:00", - "/part/b/maxseg": "2026-08-22T12:47:11+08:00", - "/part/b/lis": "2026-08-22T12:47:11+08:00", - "/part/b/lcs": "2026-08-22T12:47:11+08:00", - "/part/b/edit": "2026-08-22T12:47:11+08:00", - "/part/b/fsm": "2026-08-22T12:47:11+08:00", - "/part/b/count": "2026-08-22T12:47:11+08:00", - "/part/c/stone": "2026-08-22T12:47:11+08:00", - "/part/c/ring": "2026-08-22T12:47:11+08:00", - "/part/c/palindrome": "2026-08-22T12:47:11+08:00", - "/part/c/tree": "2026-08-22T12:47:11+08:00", - "/part/c/merge": "2026-08-22T12:47:11+08:00", - "/part/d/grid": "2026-08-22T12:47:11+08:00", - "/part/d/matpow": "2026-08-22T12:47:11+08:00", - "/part/e/basic": "2026-08-22T12:47:11+08:00", - "/part/e/distsum": "2026-08-22T12:47:11+08:00", - "/part/e/inout": "2026-08-22T12:47:11+08:00", - "/part/e/center": "2026-08-22T12:47:11+08:00", - "/part/f/select": "2026-08-22T12:47:11+08:00", - "/part/f/knapsack": "2026-08-22T12:47:11+08:00", - "/part/f/diameter": "2026-08-22T12:47:11+08:00", - "/part/f/cover": "2026-08-22T12:47:11+08:00", - "/part/f/count": "2026-08-22T12:47:11+08:00", - "/part/g/board": "2026-08-22T12:47:11+08:00", - "/part/g/tsp": "2026-08-22T12:47:11+08:00", - "/part/g/cover": "2026-08-22T12:47:11+08:00", - "/part/g/subset": "2026-08-22T12:47:11+08:00", - "/part/g/plug": "2026-08-22T12:47:11+08:00", - "/method": "2026-08-22T12:47:11+08:00", - "/problems": "2026-08-22T12:47:11+08:00" + "/part/a": "2026-08-22T11:47:40+08:00", + "/part/b": "2026-08-22T11:47:40+08:00", + "/part/c": "2026-08-22T11:47:40+08:00", + "/part/d": "2026-08-22T11:47:40+08:00", + "/part/e": "2026-08-22T11:47:40+08:00", + "/part/f": "2026-08-22T11:47:40+08:00", + "/part/g": "2026-08-22T11:47:40+08:00", + "/part/a/01": "2026-08-22T11:47:40+08:00", + "/part/a/complete": "2026-08-22T11:47:40+08:00", + "/part/a/multiple": "2026-08-22T11:47:40+08:00", + "/part/a/group": "2026-08-22T11:47:40+08:00", + "/part/a/mixed": "2026-08-22T11:47:40+08:00", + "/part/a/cost2d": "2026-08-22T11:47:40+08:00", + "/part/a/dep": "2026-08-22T11:47:40+08:00", + "/part/a/variant": "2026-08-22T11:47:40+08:00", + "/part/a/fractional": "2026-08-22T11:47:40+08:00", + "/part/b/path": "2026-08-22T11:47:40+08:00", + "/part/b/maxseg": "2026-08-22T11:47:40+08:00", + "/part/b/lis": "2026-08-22T11:47:40+08:00", + "/part/b/lcs": "2026-08-22T11:47:40+08:00", + "/part/b/edit": "2026-08-22T11:47:40+08:00", + "/part/b/fsm": "2026-08-22T11:47:40+08:00", + "/part/b/count": "2026-08-22T11:47:40+08:00", + "/part/c/stone": "2026-08-22T11:47:40+08:00", + "/part/c/ring": "2026-08-22T11:47:40+08:00", + "/part/c/palindrome": "2026-08-22T11:47:40+08:00", + "/part/c/tree": "2026-08-22T11:47:40+08:00", + "/part/c/merge": "2026-08-22T11:47:40+08:00", + "/part/d/grid": "2026-08-22T11:47:40+08:00", + "/part/d/matpow": "2026-08-22T11:47:40+08:00", + "/part/e/basic": "2026-08-22T11:47:40+08:00", + "/part/e/distsum": "2026-08-22T11:47:40+08:00", + "/part/e/inout": "2026-08-22T11:47:40+08:00", + "/part/e/center": "2026-08-22T11:47:40+08:00", + "/part/f/select": "2026-08-22T11:47:40+08:00", + "/part/f/knapsack": "2026-08-22T11:47:40+08:00", + "/part/f/diameter": "2026-08-22T11:47:40+08:00", + "/part/f/cover": "2026-08-22T11:47:40+08:00", + "/part/f/count": "2026-08-22T11:47:40+08:00", + "/part/g/board": "2026-08-22T11:47:40+08:00", + "/part/g/tsp": "2026-08-22T11:47:40+08:00", + "/part/g/cover": "2026-08-22T11:47:40+08:00", + "/part/g/subset": "2026-08-22T11:47:40+08:00", + "/part/g/plug": "2026-08-22T11:47:40+08:00", + "/method": "2026-08-22T11:47:40+08:00", + "/problems": "2026-08-22T11:47:40+08:00" }) export const ROUTE_CONTENT_DIGESTS: Readonly> = Object.freeze({ - "/": "95e9c0c6235252d7e08b3776259265bc01dfe3715b08e8708cb96660b9a04710", - "/part/a": "c2079022bad74a24044d1202be7c9f680cbb3587f0aae2fd6f99fb716574cf0c", - "/part/b": "edb96fc1ff406556a7d272e265ec3695089a1dd1e3ab2f352ba924e3307cb5a0", - "/part/c": "54b52763201a9c6c694b3cdac332070c5aa60e5ae265797fc90ceae9866eb9cd", - "/part/d": "3e7535710483ca736f2aebca95e1bdc6b3cb1482d221b56fefd9f4bb40d08297", - "/part/e": "2047860e51dbb4de203708a80fb544d18afbf63eaf0737d8bca8ca968ba90c5b", - "/part/f": "deb7a7e32a0e7feccc0719dc123d7dcd274fa774a65cc91cdfd3693a6ed0bda0", - "/part/g": "9dc688813d6d8e4c0a1dc02bd350ba398b746a00b6e8e36a995704be0c0e04dd", - "/part/a/01": "95c5a26f6164a4acf23e9eba5a957f39769d0cfa3b9fdb05498d2a962df2400f", - "/part/a/complete": "dad028b9c51d5a348f1209a76bb3397b37e0728a94b64ac2a9c6aa2ed0088061", - "/part/a/multiple": "f59f59d39a33fb31019f7f3e442b691cf7c8f57b176ea64cc973ca7572f0ec43", - "/part/a/group": "bcc3f010ae89308a0a63e670911fc65b0c7283297c506ed33e01b3ceef919fe2", - "/part/a/mixed": "2f675238a6a423bf459cfa855e8463e47c64453268756708243de241a772652a", - "/part/a/cost2d": "cc631f8a0eb6e007f09ff102575f555e37ee782e904623ba523fc60822a037f0", - "/part/a/dep": "98d88f0f1f015f5980d94de92b29ee1550a7ad6092a90991e558a9c2ca6fad30", - "/part/a/variant": "585ca92620c6a8e3de68f4025eddaf0fd9c5c8c64eb0afc44d8823666fe76589", - "/part/a/fractional": "e0e6cc5b93ef67cecef842da7c4664bf79ce6818f231fcbab32d8efe389a3b10", - "/part/b/path": "41d4524338b542348c6241f043540fd9e18535934992c70cfe7913619af8a1e7", - "/part/b/maxseg": "11965a785f9c8c948e07f34a6ab9a1fcb9963760c93400f38a59dda15fea6100", - "/part/b/lis": "003289eb132e1f80587a41b80d7a4671a0c0cf4a8e3751cd15f470277d483fd9", - "/part/b/lcs": "4f9a9572b683bbda6ddff5a86dfa99fad97127dfef1cc1ad87ddd72d72f205bb", - "/part/b/edit": "93296d563c4f8283c942620517a62d652d6b2bbd661a0d7f17fceff373fbc882", - "/part/b/fsm": "c5b03fd03c15ed6dda7bc581e1cc4bf1f9496dcb2fa0240d96a2d5741fcb7c47", - "/part/b/count": "7a53264b9090e82f78eccd8f41a8721546199249143512a8f33336420f176878", - "/part/c/stone": "ad45d24051837ca13dbb1dcae57541eb9205c80ccd13c05ae3b91c7e58a48964", - "/part/c/ring": "5106c7b96726cc2c2d3c35c7b042313a820fc32810c1d5bdb4c6e61499ee8572", - "/part/c/palindrome": "769af1aafc827d557dab863aba72056a437ad0d17d01ec276f588bd14f080d41", - "/part/c/tree": "d8e32120dd34096b78f32338727441839c53dcc99b9d0a6da3fc86f66dbccd65", - "/part/c/merge": "ed8c56d4e179117552b69056d378443f80424d0eb4b381fcf41d2ddfbfd34eaf", - "/part/d/grid": "7fcf844e9acb539a15e05aba8d78ecaa91cbc5d59b6c7e9e3d79af31a7f46741", - "/part/d/matpow": "bd4745128ca1151e8f8af0ab3d43ce319c1bd0b0a9983752fd28e9f888d46990", - "/part/e/basic": "a3e18be58e069925373254bdc446ff45d7589131aefac35e7a7142ed93ad42c6", - "/part/e/distsum": "9d461657d27fa37f203de7f8bb9b3a9766ddd1233ad042e398987d3e4c7a8e24", - "/part/e/inout": "2a1cf244b30f3708360098188e34adf309235fcadd2e105d93d8a823bf6a931c", - "/part/e/center": "36ce64c5d8b6080edb3ad85459568cd8410877b98cc10a5ae2ea34aed5e102f6", - "/part/f/select": "0ab3227b8fdcc61c7be1914ed3e175157d554808c00168502b082fa338a20b9c", - "/part/f/knapsack": "8a1e5d94d0d303d106d39c91d28fe8b6f08c414bc7950bd35ee182df5213a0fe", - "/part/f/diameter": "c1da7209ea351e86f4e93e8777485d11225eabe5aa07003d82f4cec3d9ed9c7a", - "/part/f/cover": "e477e0e3ad2c5c60dcf28ffebff843662c3028457c5645c30419b096ac480042", - "/part/f/count": "0f405dcbce6a83251fee3b4996dc6cd530b4c74d7ed24feca09706791defc8af", - "/part/g/board": "2626ded466cbe49412a76b74e369b5c4282c3ae9709ab469aebe4f507392f6dc", - "/part/g/tsp": "8241be33c05454bb56e18f0445cfa66058cbbf1396110ff05ec4de128746d822", - "/part/g/cover": "aff2804871b3a8e7c159e9278eb13146f9a8881ea4a3ff86ed2b2568ace984c6", - "/part/g/subset": "df1170969d0bbbdcbc02bab7f1705220c662e2bbb4ffc876b64e86c66e191af0", - "/part/g/plug": "cbce43f1a89b974f075f4b6fe30fc568d58439b59099232f39ad7283166f6826", - "/method": "26ff5759a5b241d2a5c233901d69d67dd2f28a886653c591c0888aae8c33990b", - "/problems": "8e73767135b4abaa37565e834cbecf7c35eecaf3d82cf18d9dbb6b098b20691b" + "/": "43e794b19a9d9ccc250589047cf7f6f2038fefca514f12c54ba6d12571291a84", + "/part/a": "a3e55088d24a9ad134179411deb3c7ba54427a8b393d1fe60597a919f2a1b7bb", + "/part/b": "bc62d99e22fa4e7f47e572b31a8023ab6dcf1afc8899627488403bb0306fd2b1", + "/part/c": "8ff5a38e7a51e4e3c35ff14a65bc0fe55b35208fcfd27decce81bee3b05671c4", + "/part/d": "2afdf5ad9c7d7f843ffa46a07faabb84e81e22d85aef9ea80cc817a278ff5029", + "/part/e": "7ef6ebb1e8f56d6297b81478221607b060de8382b8e1005da4ce98a6067785e3", + "/part/f": "756e56ab2b39b4eb47130c007c47adc418ade58659a5778d3554a74ba43c7ccb", + "/part/g": "96708135baf75e390980e6184b3d4faa02f78c1a255fb6dfceb17382f71b35dc", + "/part/a/01": "7d09d3837f2e5344b5095a318bfd07a93b0d0df7f3cf9607bde1ac2edcb239ae", + "/part/a/complete": "fe93b85425ede296b93c71803324b98515f42e5845445d62678c2ada5e8159c6", + "/part/a/multiple": "b11fe63acfcaa130f3aa4a82213f2355321f95f2d2fcab4842de4ef6f80f713d", + "/part/a/group": "d922c097ccbc255b8894869410d7e0141decc7dbb5cfca32cff14ef38c16db1c", + "/part/a/mixed": "e874d91290ccffadfe2db6709f11c383629e814b6393b296614d1da209c85da3", + "/part/a/cost2d": "fc180c823e2e3de06e4a34e25d7889b387082d1786da05c30fe4ab6299977dc5", + "/part/a/dep": "ae9176f4aa95e79f7aec0c6b22138e865c36e48698330e0d7f9bc56a2e2b7000", + "/part/a/variant": "1dc6227ac29df1b58f7a9f45443a967cf43130e651b3ff1178d9d0838cce7a06", + "/part/a/fractional": "4661fe60d0b794252259613b9475d0ca58ebab7378b1946a0bb7dd8daa34c68c", + "/part/b/path": "e3e00b08f9f27b9aaf2a23361a53a8e7c9555ffd7040f7d53cd982e8e76316ba", + "/part/b/maxseg": "57bd3600f1153ea355ffb8c64ecd6f65b1f42a309142f6ef44875b617d388abb", + "/part/b/lis": "cd9f45f6a85014ced73640d2d772c2b01c0f505c55889a80bdf912e26c2acd2e", + "/part/b/lcs": "846fa7393197e8ba9d5a08a20ce9c4c244db03f74c1c6a79ae9e39e84efd405a", + "/part/b/edit": "e1e8ed89e85491d8c4a433c470cd82020d74d6c3e6857aacff7567762d79ce77", + "/part/b/fsm": "8f04fdfe490d5aadd50891708d782c40b04f25cb5424c2e788aa05a8ebb2cb5a", + "/part/b/count": "655bf095d3d618b1b62683ec3d67dad3c21b6f6cb1c534112bdddaa41159a3ac", + "/part/c/stone": "9e20fd808ac3d3cecea835c2049a582fc203829d6a912fc364c92814e4a7f267", + "/part/c/ring": "1fb45f7356a3e8078a921cc8e1963fc9b4f4f1e60f8d7621df5f592ade42d1e0", + "/part/c/palindrome": "bb7fa1fe230f723bdde4ff80519974c7c5a1efe85485c23f62149c41d846b245", + "/part/c/tree": "409edba76ec96778f28330cd09ca2a4e84093a05a01ae5326f0662470c4b9dd2", + "/part/c/merge": "a2d0680c470b77871c63f6d35ae601ee96eb91f4ec4b8e5aa2e4d208cf07cf04", + "/part/d/grid": "0532c044f5b98b7daf7d2d3c83c57f0b0b2aed2b671294634b00bb235bf816bb", + "/part/d/matpow": "b38ae6b3a00d23fa356464d469d37b1b5a91f69b6841116a4cccb7e29ac899f9", + "/part/e/basic": "05bb98f51c93554f3cd46f192d78385637b8edc07730cc9fc499b2c50281b95c", + "/part/e/distsum": "877eabe4c02fa000b7f4a3c813800a16384f8e23dce87103eb29cf5d07d9ac26", + "/part/e/inout": "9d16e9f5b218c66bf940fa0d98d9e802e01ba8b00d1acd1309bbf79c907bdbc8", + "/part/e/center": "828ad517fe6b1af348a35099749f437390859dc4c7678e42ef9407b7b2a3b23a", + "/part/f/select": "d3114652d054b80ad5b9cca08a1b8def41cee65ee33c7e2ec1ad18b70edf38d1", + "/part/f/knapsack": "9ffc2e92fb07e44026bd10fa3d271daf6ee98ee9d416390840cd5b3f2772e970", + "/part/f/diameter": "7ea885b19e1044329267a48d688d98602950d9b0c7fddcd09de198d07f6e669e", + "/part/f/cover": "c23a5b2ffb3bf6feafe9c7186f9cf1a42b0049ff72bb8d832863beaba6ae5745", + "/part/f/count": "372a0c438acefbb3ffc5a4eea88bbe0c37704a6e4c5fd22d43d7d6819f67b640", + "/part/g/board": "fa470e82e8f1831a3e09cb887b165aa8834ef67c349ad1e2ed670577eda1c275", + "/part/g/tsp": "1382e014de1b4c98dfa4e74952395a39561a91fc5a96617a36cf0fd8534e9513", + "/part/g/cover": "6b69e5dae9b8ad54935266c25e9496bcf749ff50b3fec4dab08d6a56fa37546d", + "/part/g/subset": "143b84c6b4686a910844bdbd50180d5c1050b90d0e4aade2415f8be49777446a", + "/part/g/plug": "a2a513d763e06aa0d3d5f052af6483ddffdc8428d5713a2f3b5e986d7804f839", + "/method": "924abaa3903a1d31cd9f42ddb986d556bad1ea43a7d48f0fe59d6d3160096410", + "/problems": "f33824b7a3e7f3fdbb23db8772e5e0fae19208a7d5131bb95ce306ebe8ad5995" }) From defcb6494446b846ab5256b2438c83a0b19fd8e8 Mon Sep 17 00:00:00 2001 From: Shanire Date: Wed, 26 Aug 2026 12:22:12 +0800 Subject: [PATCH 07/23] fix: make semantic write closure fail closed Signed-off-by: Shanire --- docs/engineering/architecture.md | 2 +- docs/operations/verification.md | 2 +- site/scripts/last-modified.mjs | 2 +- site/scripts/semantic-source-graph.mjs | 87 ++++++++++++++++++--- site/scripts/semantic-source-graph.test.mjs | 49 ++++++++++++ site/scripts/seo-contract.test.mjs | 2 +- site/src/data/routeLastModified.ts | 2 +- 7 files changed, 131 insertions(+), 15 deletions(-) diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index a2f3262..fe55834 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -108,7 +108,7 @@ For a registered public `GET` or `HEAD`, `worker/content-negotiation.js` selects `seoHead.ts` renders the same head contract at build time that `RouteMeta` maintains after client navigation: title, description, citable abstract, robots, canonical, Open Graph, Twitter, and a Schema.org graph. Every indexable page includes Organization and WebSite nodes and states `isAccessibleForFree`; the WebSite node declares a `SearchAction` pointing at the real `?q=` problem search. Lessons add Course/LearningResource/TechArticle with `educationalUse` and an `EducationalAudience`, family pages add CollectionPage plus an ItemList of their ready lessons, other pages add WebPage, and routes with hierarchy add BreadcrumbList. Course duration is deliberately not declared: there is no trustworthy source for it, so `hasCourseInstance` is omitted rather than fabricated. -`site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. `semantic-source-graph.mjs` follows the real SSR injection chain from `entry-server.tsx` through the static application/providers and route tree, together with metadata roots and the exact route page, lesson, and family-art modules. Mixed shell modules use an AST-rooted projection: the route-rendering JSX plus the closure of local declarations and later assignments reached by lexical binding identity are hashed, so shadowed names, class helpers, render functions, and route/outlet bindings remain precise while unrelated effects and JSX attribute names do not leak in. The graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Public representation identity is structurally isolated in `config/site.ts`; client endpoints and sidebar-only copy are in modules reachable only through pruned branches. Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. Clean sources use the latest Git commit timestamp, dirty sources use the newest source-file modification timestamp, an unchanged digest preserves the previous value, and generation fails when changed content lacks strictly advancing source evidence; changing the digest schema preserves the prior content timestamp only when no newer source evidence exists, while a simultaneous real content update adopts its later timestamp. Version 3 was reseeded from the trusted `2026-08-22` history: the home route uses the visible hero change at `23:46:11+08:00`, and the other 46 routes use the single-origin metadata/content change at `11:47:40+08:00`. Version 1 had advanced every route for a shared client effect, while version 2 still admitted sidebar-only copyright copy through a shared configuration file. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. +`site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. `semantic-source-graph.mjs` follows the real SSR injection chain from `entry-server.tsx` through the static application/providers and route tree, together with metadata roots and the exact route page, lesson, and family-art modules. Mixed shell modules use an AST-rooted projection: the route-rendering JSX plus the closure of local declarations and render-phase assignments reached by lexical binding identity are hashed. Control predicates, loop/switch scopes, shadowed names, class helpers, route/outlet bindings, and class-field/member identity remain precise; effect-only callbacks and JSX attribute names do not leak in. A nested write outside the render or declaration execution scope is accepted only for a recognized client-effect callback; any other ambiguous nested write fails generation and must be refactored instead of silently producing incomplete evidence. The graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Public representation identity is structurally isolated in `config/site.ts`; client endpoints and sidebar-only copy are in modules reachable only through pruned branches. Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. Clean sources use the latest Git commit timestamp, dirty sources use the newest source-file modification timestamp, an unchanged digest preserves the previous value, and generation fails when changed content lacks strictly advancing source evidence; changing the digest schema preserves the prior content timestamp only when no newer source evidence exists, while a simultaneous real content update adopts its later timestamp. Version 4 was reseeded from the trusted `2026-08-22` history: the home route uses the visible hero change at `23:46:11+08:00`, and the other 46 routes use the single-origin metadata/content change at `11:47:40+08:00`. Earlier rejected schemas had admitted a shared client effect, sidebar-only copyright copy, or incomplete execution/scope context. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. # Analytics diff --git a/docs/operations/verification.md b/docs/operations/verification.md index eb9cb43..5a83309 100644 --- a/docs/operations/verification.md +++ b/docs/operations/verification.md @@ -44,7 +44,7 @@ Pure TypeScript Modules imported by Node tests must use explicit `.ts` extension # SEO And Accessibility Checks -`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. A line-ending-neutral SHA-256 digest follows each route's real SSR injection chain and exact semantic content graph while pruning navigation, feedback, analytics, theme, and motion-only branches. Mixed shell modules contribute an AST-rooted closure of route-rendering JSX, local declarations, and later assignments resolved by lexical binding identity; regression probes cover shadowing, class helpers, route/outlet bindings, effects, and non-reference JSX names. Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. Clean sources use their latest Git commit timestamp; dirty sources use their newest filesystem modification timestamp; and generation fails if the digest changes without that evidence advancing. A digest-schema migration preserves the prior timestamp only when it has no newer source evidence; a simultaneous content edit must still advance. Version 3 was explicitly reseeded from trusted `2026-08-22` Git history: `23:46:11+08:00` for the home hero change and `11:47:40+08:00` for the other 46 routes' latest single-origin metadata/content change. Version 1 was polluted by a shared client effect and version 2 by sidebar-only copyright copy in the former shared configuration module. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. +`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. A line-ending-neutral SHA-256 digest follows each route's real SSR injection chain and exact semantic content graph while pruning navigation, feedback, analytics, theme, and motion-only branches. Mixed shell modules contribute an AST-rooted closure of route-rendering JSX, local declarations, and render-phase assignments resolved by lexical binding identity. Regression probes cover control predicates, function/loop/switch shadowing, class fields, later assignments, route/outlet bindings, effect-only writes, and non-reference JSX names; ambiguous nested writes outside known client-effect callbacks fail closed. Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. Clean sources use their latest Git commit timestamp; dirty sources use their newest filesystem modification timestamp; and generation fails if the digest changes without that evidence advancing. A digest-schema migration preserves the prior timestamp only when it has no newer source evidence; a simultaneous content edit must still advance. Version 4 was explicitly reseeded from trusted `2026-08-22` Git history: `23:46:11+08:00` for the home hero change and `11:47:40+08:00` for the other 46 routes' latest single-origin metadata/content change. Earlier rejected schemas admitted a shared client effect, sidebar-only copyright copy, or incomplete execution/scope context. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. The browser gate runs Chromium against built `dist/` through a strict, non-reused custom preview server. Route tests directly open `/`, `/part/a`, `/part/a/01`, `/method`, and `/part/g/plug`, then exercise live client navigation and keyboard focus. They check HTTP status, prerendered HTML, absence of pending streamed-Suspense placeholders, route CSS at first paint, hydration without errors, CLS below `0.05`, title/description/abstract/canonical/zero-hreflang/Open Graph/JSON-LD metadata, one visible `h1`, route announcements, current-page semantics, initial-load focus, changed-route focus, and skip-link focus. Navigation-positioning tests click a representative lesson outline in each of the seven families, restore a direct lesson fragment, verify the mobile outline and an ordinary family-page fragment, and require a changed route to reset old-page scroll immediately. An unknown path must return 404 with `noindex,nofollow`, no canonical, and the themed not-found heading. Worker-level Node tests separately cover the Accept matrix, 406 behavior, HEAD parity, representation-specific ETags, hidden internal assets, and unaffected API/static/unknown paths. diff --git a/site/scripts/last-modified.mjs b/site/scripts/last-modified.mjs index 241b4bd..a6f50e3 100644 --- a/site/scripts/last-modified.mjs +++ b/site/scripts/last-modified.mjs @@ -9,7 +9,7 @@ import { } from './semantic-source-graph.mjs' const projectRoot = fileURLToPath(new URL('../../', import.meta.url)) -export const ROUTE_CONTENT_DIGEST_VERSION = 3 +export const ROUTE_CONTENT_DIGEST_VERSION = 4 function gitNames(args) { const result = spawnSync('git', args, { diff --git a/site/scripts/semantic-source-graph.mjs b/site/scripts/semantic-source-graph.mjs index 8aef861..f29fb14 100644 --- a/site/scripts/semantic-source-graph.mjs +++ b/site/scripts/semantic-source-graph.mjs @@ -25,6 +25,11 @@ const SEMANTIC_ELEMENT_ROOTS = new Map([ ['site/src/components/layout/Shell.tsx', 'main'], ['site/src/components/layout/RouteStage.tsx', 'motion.div'], ]) +const CLIENT_EFFECT_CALLEES = new Set([ + 'useEffect', + 'useInsertionEffect', + 'useLayoutEffect', +]) function isNonSemanticFile(path) { return ( @@ -71,8 +76,8 @@ function bindingIdentifiers(pattern, identifiers = []) { return identifiers } -function createScope(parent, kind) { - return { parent, kind, bindings: new Map() } +function createScope(parent, kind, ownerNode = null) { + return { parent, kind, ownerNode, bindings: new Map() } } function nearestFunctionScope(scope) { @@ -84,7 +89,7 @@ function nearestFunctionScope(scope) { } function semanticBindingGraph(program) { - const rootScope = createScope(null, 'program') + const rootScope = createScope(null, 'program', program) const nodeScopes = new WeakMap() const nodeParents = new WeakMap() const declaredIdentifiers = new WeakSet() @@ -97,6 +102,7 @@ function semanticBindingGraph(program) { if (semanticNode) existing.semanticNodes.add(semanticNode) } else { scope.bindings.set(identifier.name, { + executionScope: nearestFunctionScope(scope), identifier, semanticNodes: new Set(semanticNode ? [semanticNode] : []), writes: [], @@ -119,7 +125,7 @@ function semanticBindingGraph(program) { || node.type === 'ArrowFunctionExpression' ) { if (node.type === 'FunctionDeclaration' && node.id) declare(node.id, scope, node) - const functionScope = createScope(scope, 'function') + const functionScope = createScope(scope, 'function', node) if (node.type === 'FunctionExpression' && node.id) { declare(node.id, functionScope, node) } @@ -134,6 +140,16 @@ function semanticBindingGraph(program) { for (const [child, childKey] of childNodes(node)) build(child, blockScope, node, childKey) return } + if ( + node.type === 'ForStatement' + || node.type === 'ForInStatement' + || node.type === 'ForOfStatement' + || node.type === 'SwitchStatement' + ) { + const lexicalScope = createScope(scope, 'block') + for (const [child, childKey] of childNodes(node)) build(child, lexicalScope, node, childKey) + return + } if (node.type === 'VariableDeclaration') { const declarationScope = node.kind === 'var' ? nearestFunctionScope(scope) : scope for (const declarator of node.declarations) { @@ -207,12 +223,32 @@ function semanticBindingGraph(program) { return bindings } + function enclosingExecutionStatement(node, executionScope) { + const owner = executionScope.ownerNode + const boundary = owner.type === 'Program' ? owner : owner.body + let current = node + while (nodeParents.has(current)) { + const { parent } = nodeParents.get(current) + if (parent === boundary || parent === owner) return current + current = parent + } + return node + } + function indexWrites(node) { if (node.type === 'AssignmentExpression') { - for (const binding of writtenBindings(node.left)) binding.writes.push(node) + const executionScope = nearestFunctionScope(nodeScopes.get(node)) + const semanticNode = enclosingExecutionStatement(node, executionScope) + for (const binding of writtenBindings(node.left)) { + binding.writes.push({ executionScope, semanticNode }) + } } if (node.type === 'UpdateExpression') { - for (const binding of writtenBindings(node.argument)) binding.writes.push(node) + const executionScope = nearestFunctionScope(nodeScopes.get(node)) + const semanticNode = enclosingExecutionStatement(node, executionScope) + for (const binding of writtenBindings(node.argument)) { + binding.writes.push({ executionScope, semanticNode }) + } } for (const [child] of childNodes(node)) indexWrites(child) } @@ -255,6 +291,8 @@ function semanticBindingGraph(program) { (parent.type === 'MemberExpression' && key === 'property' && !parent.computed) || (parent.type === 'Property' && key === 'key' && !parent.computed && !parent.shorthand) || (parent.type === 'MethodDefinition' && key === 'key' && !parent.computed) + || (parent.type === 'PropertyDefinition' && key === 'key' && !parent.computed) + || (parent.type === 'AccessorProperty' && key === 'key' && !parent.computed) || parent.type === 'ImportSpecifier' || parent.type === 'ImportDefaultSpecifier' || parent.type === 'ImportNamespaceSpecifier' @@ -266,7 +304,26 @@ function semanticBindingGraph(program) { return true } - return { childNodes, isReference, lookup } + function isClientOnlyEffectScope(scope) { + const functionNode = scope.ownerNode + const relation = nodeParents.get(functionNode) + if (!relation || relation.parent.type !== 'CallExpression') return false + const callee = relation.parent.callee + const calleeName = callee.type === 'Identifier' + ? callee.name + : callee.type === 'MemberExpression' && !callee.computed + ? callee.property.name + : null + return CLIENT_EFFECT_CALLEES.has(calleeName) + } + + return { + childNodes, + executionScopeFor: (node) => nearestFunctionScope(nodeScopes.get(node)), + isClientOnlyEffectScope, + isReference, + lookup, + } } export function semanticSourceForDigest(path, source) { @@ -291,6 +348,7 @@ export function semanticSourceForDigest(path, source) { } const slices = new Set([roots[0]]) + const rootExecutionScope = bindingGraph.executionScopeFor(roots[0]) const visitedBindings = new Set() const pendingNodes = [roots[0]] while (pendingNodes.length > 0) { @@ -309,9 +367,18 @@ export function semanticSourceForDigest(path, source) { pendingNodes.push(semanticNode) } for (const write of binding.writes) { - if (!slices.has(write)) { - slices.add(write) - pendingNodes.push(write) + if ( + write.executionScope !== binding.executionScope + && write.executionScope !== rootExecutionScope + ) { + if (bindingGraph.isClientOnlyEffectScope(write.executionScope)) continue + throw new Error( + `Unsupported nested semantic write to ${binding.identifier.name} in ${path}`, + ) + } + if (!slices.has(write.semanticNode)) { + slices.add(write.semanticNode) + pendingNodes.push(write.semanticNode) } } } diff --git a/site/scripts/semantic-source-graph.test.mjs b/site/scripts/semantic-source-graph.test.mjs index 1734a2d..4e2bfdf 100644 --- a/site/scripts/semantic-source-graph.test.mjs +++ b/site/scripts/semantic-source-graph.test.mjs @@ -175,10 +175,59 @@ test('mixed-module closure follows lexical symbols instead of bare names', () => semanticSourceForDigest('site/src/app/AppContent.tsx', assignmentAfter), ) + const conditionalBefore = `function AppContent() { let label = '正文'; const enabled = true; if (enabled) label = '条件正文'; return {label} }` + const conditionalAfter = conditionalBefore.replace('enabled = true', 'enabled = false') + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', conditionalBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', conditionalAfter), + ) + + const effectBefore = `function AppContent() { let label = '正文'; useEffect(() => { label = '客户端一' }, []); return {label} }` + const effectAfter = effectBefore.replace('客户端一', '客户端二') + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', effectBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', effectAfter), + ) + + const ambiguousHelperWrite = `let label = '正文'\nfunction prepare() { label = '变化' }\nfunction AppContent() { prepare(); return {label} }` + assert.throws( + () => semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + ambiguousHelperWrite, + ), + /Unsupported nested semantic write to label/, + ) + const attributeBefore = `const path = '客户端一'\nfunction AppContent() { return }` const attributeAfter = attributeBefore.replace('客户端一', '客户端二') assert.equal( semanticSourceForDigest('site/src/app/AppContent.tsx', attributeBefore), semanticSourceForDigest('site/src/app/AppContent.tsx', attributeAfter), ) + + const loopBefore = `const label = '正文'\nfor (const label of ['客户端一']) consume(label)\nfunction AppContent() { return {label} }` + const loopAfter = loopBefore.replace('客户端一', '客户端二') + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', loopBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', loopAfter), + ) + + const switchBefore = `const label = '正文'\nswitch (mode) { case 1: const label = '客户端一'; consume(label) }\nfunction AppContent() { return {label} }` + const switchAfter = switchBefore.replace('客户端一', '客户端二') + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', switchBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', switchAfter), + ) + + const fieldBefore = `const value = '客户端一'\nclass RouteLabel { static value = '正文' }\nfunction AppContent() { return {RouteLabel.value} }` + const fieldAfterClientChange = fieldBefore.replace('客户端一', '客户端二') + const fieldAfterSemanticChange = fieldBefore.replace("static value = '正文'", "static value = '正文二'") + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', fieldBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', fieldAfterClientChange), + ) + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', fieldBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', fieldAfterSemanticChange), + ) }) diff --git a/site/scripts/seo-contract.test.mjs b/site/scripts/seo-contract.test.mjs index fbfa0f3..12b78e1 100644 --- a/site/scripts/seo-contract.test.mjs +++ b/site/scripts/seo-contract.test.mjs @@ -230,7 +230,7 @@ test('discovery files expose the 47 approved URLs and real summaries', async () assert.match(lastModified, /--format=%cI/) assert.match(lastModified, /normalizeContentForDigest/) assert.match(lastModified, /semanticRouteFiles/) - assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 3) + assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 4) assert.equal(Object.keys(ROUTE_CONTENT_DIGESTS).length, PUBLIC_PATHS.length) assert.ok(Object.values(ROUTE_CONTENT_DIGESTS).every((digest) => /^[0-9a-f]{64}$/.test(digest))) assert.match(publicRoutes, /\.\.\/data\/catalog\.ts/) diff --git a/site/src/data/routeLastModified.ts b/site/src/data/routeLastModified.ts index 3d9c9ef..b3a79be 100644 --- a/site/src/data/routeLastModified.ts +++ b/site/src/data/routeLastModified.ts @@ -1,5 +1,5 @@ // 由 scripts/generate-seo.mjs 从源码时间证据生成,请勿手改。 -export const ROUTE_CONTENT_DIGEST_VERSION = 3 +export const ROUTE_CONTENT_DIGEST_VERSION = 4 export const ROUTE_LAST_MODIFIED: Readonly> = Object.freeze({ "/": "2026-08-22T23:46:11+08:00", "/part/a": "2026-08-22T11:47:40+08:00", From 921f10c5a7b690ba92041b85ce5e163b8a226754 Mon Sep 17 00:00:00 2001 From: Shanire Date: Wed, 26 Aug 2026 12:36:19 +0800 Subject: [PATCH 08/23] fix: close semantic mutation evidence gaps Signed-off-by: Shanire --- docs/engineering/architecture.md | 2 +- docs/operations/verification.md | 2 +- site/scripts/last-modified.mjs | 2 +- site/scripts/semantic-source-graph.mjs | 183 +++++++++++++++++--- site/scripts/semantic-source-graph.test.mjs | 126 +++++++++++++- site/scripts/seo-contract.test.mjs | 2 +- site/src/data/routeLastModified.ts | 96 +++++----- 7 files changed, 338 insertions(+), 75 deletions(-) diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index fe55834..fce562b 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -108,7 +108,7 @@ For a registered public `GET` or `HEAD`, `worker/content-negotiation.js` selects `seoHead.ts` renders the same head contract at build time that `RouteMeta` maintains after client navigation: title, description, citable abstract, robots, canonical, Open Graph, Twitter, and a Schema.org graph. Every indexable page includes Organization and WebSite nodes and states `isAccessibleForFree`; the WebSite node declares a `SearchAction` pointing at the real `?q=` problem search. Lessons add Course/LearningResource/TechArticle with `educationalUse` and an `EducationalAudience`, family pages add CollectionPage plus an ItemList of their ready lessons, other pages add WebPage, and routes with hierarchy add BreadcrumbList. Course duration is deliberately not declared: there is no trustworthy source for it, so `hasCourseInstance` is omitted rather than fabricated. -`site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. `semantic-source-graph.mjs` follows the real SSR injection chain from `entry-server.tsx` through the static application/providers and route tree, together with metadata roots and the exact route page, lesson, and family-art modules. Mixed shell modules use an AST-rooted projection: the route-rendering JSX plus the closure of local declarations and render-phase assignments reached by lexical binding identity are hashed. Control predicates, loop/switch scopes, shadowed names, class helpers, route/outlet bindings, and class-field/member identity remain precise; effect-only callbacks and JSX attribute names do not leak in. A nested write outside the render or declaration execution scope is accepted only for a recognized client-effect callback; any other ambiguous nested write fails generation and must be refactored instead of silently producing incomplete evidence. The graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Public representation identity is structurally isolated in `config/site.ts`; client endpoints and sidebar-only copy are in modules reachable only through pruned branches. Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. Clean sources use the latest Git commit timestamp, dirty sources use the newest source-file modification timestamp, an unchanged digest preserves the previous value, and generation fails when changed content lacks strictly advancing source evidence; changing the digest schema preserves the prior content timestamp only when no newer source evidence exists, while a simultaneous real content update adopts its later timestamp. Version 4 was reseeded from the trusted `2026-08-22` history: the home route uses the visible hero change at `23:46:11+08:00`, and the other 46 routes use the single-origin metadata/content change at `11:47:40+08:00`. Earlier rejected schemas had admitted a shared client effect, sidebar-only copyright copy, or incomplete execution/scope context. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. +`site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. `semantic-source-graph.mjs` follows the real SSR injection chain from `entry-server.tsx` through the static application/providers and route tree, together with metadata roots and the exact route page, lesson, and family-art modules. Mixed shell modules use an AST-rooted projection: the route-rendering JSX plus the closure of local declarations, selected import declarations, and render-phase mutations reached by lexical binding identity are hashed. Control predicates, loop scopes, switch discriminants and case scopes, static blocks, shadowed names, class helpers, route/outlet bindings, and class-field/member identity remain precise. Assignments, update/delete expressions, non-declaration `for-in`/`for-of` targets, known mutating methods, and static target mutators such as `Object.assign` enter the closure; an unknown call that could mutate a selected binding fails closed unless the call is already inside a selected slice. Effect-only callbacks are excluded only when their callee is proven by lexical import provenance to be a React effect hook, including named aliases and default/namespace member calls; same-named local helpers are not trusted. Any other nested write fails generation and must be refactored instead of silently producing incomplete evidence. JSX attribute names do not leak in. The graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Public representation identity is structurally isolated in `config/site.ts`; client endpoints and sidebar-only copy are in modules reachable only through pruned branches. Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. Clean sources use the latest Git commit timestamp, dirty sources use the newest source-file modification timestamp, an unchanged digest preserves the previous value, and generation fails when changed content lacks strictly advancing source evidence; changing the digest schema preserves the prior content timestamp only when no newer source evidence exists, while a simultaneous real content update adopts its later timestamp. Version 5 retains the trusted `2026-08-22` evidence while closing import-provenance, mutation, switch, and static-block projection gaps: the home route uses the visible hero change at `23:46:11+08:00`, and the other 46 routes use the single-origin metadata/content change at `11:47:40+08:00`. Earlier rejected schemas had admitted a shared client effect, sidebar-only copyright copy, or incomplete execution/scope/mutation context. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. # Analytics diff --git a/docs/operations/verification.md b/docs/operations/verification.md index 5a83309..38a4c93 100644 --- a/docs/operations/verification.md +++ b/docs/operations/verification.md @@ -44,7 +44,7 @@ Pure TypeScript Modules imported by Node tests must use explicit `.ts` extension # SEO And Accessibility Checks -`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. A line-ending-neutral SHA-256 digest follows each route's real SSR injection chain and exact semantic content graph while pruning navigation, feedback, analytics, theme, and motion-only branches. Mixed shell modules contribute an AST-rooted closure of route-rendering JSX, local declarations, and render-phase assignments resolved by lexical binding identity. Regression probes cover control predicates, function/loop/switch shadowing, class fields, later assignments, route/outlet bindings, effect-only writes, and non-reference JSX names; ambiguous nested writes outside known client-effect callbacks fail closed. Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. Clean sources use their latest Git commit timestamp; dirty sources use their newest filesystem modification timestamp; and generation fails if the digest changes without that evidence advancing. A digest-schema migration preserves the prior timestamp only when it has no newer source evidence; a simultaneous content edit must still advance. Version 4 was explicitly reseeded from trusted `2026-08-22` Git history: `23:46:11+08:00` for the home hero change and `11:47:40+08:00` for the other 46 routes' latest single-origin metadata/content change. Earlier rejected schemas admitted a shared client effect, sidebar-only copyright copy, or incomplete execution/scope context. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. +`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. A line-ending-neutral SHA-256 digest follows each route's real SSR injection chain and exact semantic content graph while pruning navigation, feedback, analytics, theme, and motion-only branches. Mixed shell modules contribute an AST-rooted closure of route-rendering JSX, selected import declarations, and render-phase mutations resolved by lexical binding identity. Regression probes cover control predicates, function/loop/switch shadowing, switch discriminants, static blocks, class fields, imported-symbol swaps, later assignments, `for-in`/`for-of` targets, delete and object/collection mutations, route/outlet bindings, React-import-proven effect-only writes, fake same-named hooks, and non-reference JSX names. Unknown mutation contexts and ambiguous nested writes outside proven React client-effect callbacks fail closed. Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. Clean sources use their latest Git commit timestamp; dirty sources use their newest filesystem modification timestamp; and generation fails if the digest changes without that evidence advancing. A digest-schema migration preserves the prior timestamp only when it has no newer source evidence; a simultaneous content edit must still advance. Version 5 retains the trusted `2026-08-22` evidence while closing import-provenance, mutation, switch, and static-block projection gaps: `23:46:11+08:00` for the home hero change and `11:47:40+08:00` for the other 46 routes' latest single-origin metadata/content change. Earlier rejected schemas admitted a shared client effect, sidebar-only copyright copy, or incomplete execution/scope/mutation context. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. The browser gate runs Chromium against built `dist/` through a strict, non-reused custom preview server. Route tests directly open `/`, `/part/a`, `/part/a/01`, `/method`, and `/part/g/plug`, then exercise live client navigation and keyboard focus. They check HTTP status, prerendered HTML, absence of pending streamed-Suspense placeholders, route CSS at first paint, hydration without errors, CLS below `0.05`, title/description/abstract/canonical/zero-hreflang/Open Graph/JSON-LD metadata, one visible `h1`, route announcements, current-page semantics, initial-load focus, changed-route focus, and skip-link focus. Navigation-positioning tests click a representative lesson outline in each of the seven families, restore a direct lesson fragment, verify the mobile outline and an ordinary family-page fragment, and require a changed route to reset old-page scroll immediately. An unknown path must return 404 with `noindex,nofollow`, no canonical, and the themed not-found heading. Worker-level Node tests separately cover the Accept matrix, 406 behavior, HEAD parity, representation-specific ETags, hidden internal assets, and unaffected API/static/unknown paths. diff --git a/site/scripts/last-modified.mjs b/site/scripts/last-modified.mjs index a6f50e3..ed25a72 100644 --- a/site/scripts/last-modified.mjs +++ b/site/scripts/last-modified.mjs @@ -9,7 +9,7 @@ import { } from './semantic-source-graph.mjs' const projectRoot = fileURLToPath(new URL('../../', import.meta.url)) -export const ROUTE_CONTENT_DIGEST_VERSION = 4 +export const ROUTE_CONTENT_DIGEST_VERSION = 5 function gitNames(args) { const result = spawnSync('git', args, { diff --git a/site/scripts/semantic-source-graph.mjs b/site/scripts/semantic-source-graph.mjs index f29fb14..c93e7d3 100644 --- a/site/scripts/semantic-source-graph.mjs +++ b/site/scripts/semantic-source-graph.mjs @@ -30,6 +30,44 @@ const CLIENT_EFFECT_CALLEES = new Set([ 'useInsertionEffect', 'useLayoutEffect', ]) +const MUTATING_MEMBER_CALLEES = new Set([ + 'add', + 'clear', + 'copyWithin', + 'delete', + 'fill', + 'pop', + 'push', + 'reverse', + 'set', + 'shift', + 'sort', + 'splice', + 'unshift', +]) +const STATIC_TARGET_MUTATORS = new Map([ + ['Object', new Set([ + 'assign', + 'defineProperties', + 'defineProperty', + 'setPrototypeOf', + ])], + ['Reflect', new Set([ + 'defineProperty', + 'deleteProperty', + 'set', + 'setPrototypeOf', + ])], +]) +const EXPRESSION_WRAPPERS = new Set([ + 'ChainExpression', + 'ParenthesizedExpression', + 'TSAsExpression', + 'TSInstantiationExpression', + 'TSNonNullExpression', + 'TSSatisfiesExpression', + 'TSTypeAssertion', +]) function isNonSemanticFile(path) { return ( @@ -94,7 +132,7 @@ function semanticBindingGraph(program) { const nodeParents = new WeakMap() const declaredIdentifiers = new WeakSet() - function declare(pattern, scope, semanticNode) { + function declare(pattern, scope, semanticNode, metadata = {}) { for (const identifier of bindingIdentifiers(pattern)) { declaredIdentifiers.add(identifier) const existing = scope.bindings.get(identifier.name) @@ -104,6 +142,7 @@ function semanticBindingGraph(program) { scope.bindings.set(identifier.name, { executionScope: nearestFunctionScope(scope), identifier, + ...metadata, semanticNodes: new Set(semanticNode ? [semanticNode] : []), writes: [], }) @@ -140,11 +179,18 @@ function semanticBindingGraph(program) { for (const [child, childKey] of childNodes(node)) build(child, blockScope, node, childKey) return } + if (node.type === 'SwitchStatement') { + build(node.discriminant, scope, node, 'discriminant') + const lexicalScope = createScope(scope, 'block') + for (const switchCase of node.cases ?? []) { + build(switchCase, lexicalScope, node, 'cases') + } + return + } if ( node.type === 'ForStatement' || node.type === 'ForInStatement' || node.type === 'ForOfStatement' - || node.type === 'SwitchStatement' ) { const lexicalScope = createScope(scope, 'block') for (const [child, childKey] of childNodes(node)) build(child, lexicalScope, node, childKey) @@ -171,6 +217,13 @@ function semanticBindingGraph(program) { } return } + if (node.type === 'StaticBlock') { + const staticBlockScope = createScope(scope, 'block') + for (const [child, childKey] of childNodes(node)) { + build(child, staticBlockScope, node, childKey) + } + return + } if (node.type === 'CatchClause') { const catchScope = createScope(scope, 'block') if (node.param) declare(node.param, catchScope, node.param) @@ -179,7 +232,16 @@ function semanticBindingGraph(program) { } if (node.type === 'ImportDeclaration') { for (const specifier of node.specifiers ?? []) { - if (specifier.local) declare(specifier.local, scope, null) + if (!specifier.local) continue + const importedName = specifier.type === 'ImportSpecifier' + ? (specifier.imported?.name ?? specifier.imported?.value) + : specifier.type === 'ImportNamespaceSpecifier' + ? '*' + : 'default' + declare(specifier.local, scope, node, { + importSource: node.source?.value, + importedName, + }) } for (const [child, childKey] of childNodes(node)) build(child, scope, node, childKey) return @@ -217,12 +279,41 @@ function semanticBindingGraph(program) { } } else if (node.type === 'MemberExpression') { writtenBindings(node.object, bindings) - } else if (node.type === 'ParenthesizedExpression') { + } else if (node.type === 'RestElement' || node.type === 'SpreadElement') { + writtenBindings(node.argument, bindings) + } else if (EXPRESSION_WRAPPERS.has(node.type)) { writtenBindings(node.expression, bindings) } return bindings } + function memberName(member) { + if (member.type !== 'MemberExpression') return null + if (!member.computed && member.property.type === 'Identifier') { + return member.property.name + } + if (member.computed && member.property.type === 'StringLiteral') { + return member.property.value + } + return null + } + + function isReactEffectCall(call) { + const callee = call.callee + if (callee.type === 'Identifier') { + const binding = lookup(callee) + return binding?.importSource === 'react' + && CLIENT_EFFECT_CALLEES.has(binding.importedName) + } + if (callee.type !== 'MemberExpression') return false + const hookName = memberName(callee) + if (!CLIENT_EFFECT_CALLEES.has(hookName)) return false + if (callee.object.type !== 'Identifier') return false + const binding = lookup(callee.object) + return binding?.importSource === 'react' + && (binding.importedName === '*' || binding.importedName === 'default') + } + function enclosingExecutionStatement(node, executionScope) { const owner = executionScope.ownerNode const boundary = owner.type === 'Program' ? owner : owner.body @@ -236,18 +327,58 @@ function semanticBindingGraph(program) { } function indexWrites(node) { - if (node.type === 'AssignmentExpression') { - const executionScope = nearestFunctionScope(nodeScopes.get(node)) - const semanticNode = enclosingExecutionStatement(node, executionScope) - for (const binding of writtenBindings(node.left)) { - binding.writes.push({ executionScope, semanticNode }) + const executionScope = nearestFunctionScope(nodeScopes.get(node)) + const semanticNode = enclosingExecutionStatement(node, executionScope) + const record = (target, kind = 'write') => { + for (const binding of writtenBindings(target)) { + binding.writes.push({ + executionScope, + kind, + mutationNode: node, + semanticNode, + }) } } + if (node.type === 'AssignmentExpression') { + record(node.left) + } if (node.type === 'UpdateExpression') { - const executionScope = nearestFunctionScope(nodeScopes.get(node)) - const semanticNode = enclosingExecutionStatement(node, executionScope) - for (const binding of writtenBindings(node.argument)) { - binding.writes.push({ executionScope, semanticNode }) + record(node.argument) + } + if (node.type === 'UnaryExpression' && node.operator === 'delete') { + record(node.argument) + } + if ( + (node.type === 'ForInStatement' || node.type === 'ForOfStatement') + && node.left.type !== 'VariableDeclaration' + ) { + record(node.left) + } + if (node.type === 'CallExpression' && !isReactEffectCall(node)) { + const callee = node.callee + const method = callee.type === 'MemberExpression' ? memberName(callee) : null + const staticOwner = callee.type === 'MemberExpression' + && callee.object.type === 'Identifier' + ? callee.object.name + : null + const isStaticTargetMutator = method + && STATIC_TARGET_MUTATORS.get(staticOwner)?.has(method) + if (isStaticTargetMutator) { + record(node.arguments?.[0]) + } else if (callee.type === 'MemberExpression') { + record( + callee.object, + MUTATING_MEMBER_CALLEES.has(method) ? 'write' : 'ambiguous-mutation', + ) + if (!MUTATING_MEMBER_CALLEES.has(method)) { + for (const argument of node.arguments ?? []) { + record(argument, 'ambiguous-mutation') + } + } + } else { + for (const argument of node.arguments ?? []) { + record(argument, 'ambiguous-mutation') + } } } for (const [child] of childNodes(node)) indexWrites(child) @@ -307,14 +438,12 @@ function semanticBindingGraph(program) { function isClientOnlyEffectScope(scope) { const functionNode = scope.ownerNode const relation = nodeParents.get(functionNode) - if (!relation || relation.parent.type !== 'CallExpression') return false - const callee = relation.parent.callee - const calleeName = callee.type === 'Identifier' - ? callee.name - : callee.type === 'MemberExpression' && !callee.computed - ? callee.property.name - : null - return CLIENT_EFFECT_CALLEES.has(calleeName) + return Boolean( + relation + && relation.key === 'arguments' + && relation.parent.type === 'CallExpression' + && isReactEffectCall(relation.parent), + ) } return { @@ -367,11 +496,21 @@ export function semanticSourceForDigest(path, source) { pendingNodes.push(semanticNode) } for (const write of binding.writes) { + const mutationAlreadySelected = [...slices].some((slice) => ( + slice.start <= write.mutationNode.start + && slice.end >= write.mutationNode.end + )) + if (mutationAlreadySelected) continue + if (bindingGraph.isClientOnlyEffectScope(write.executionScope)) continue + if (write.kind === 'ambiguous-mutation') { + throw new Error( + `Unsupported ambiguous mutation of ${binding.identifier.name} in ${path}`, + ) + } if ( write.executionScope !== binding.executionScope && write.executionScope !== rootExecutionScope ) { - if (bindingGraph.isClientOnlyEffectScope(write.executionScope)) continue throw new Error( `Unsupported nested semantic write to ${binding.identifier.name} in ${path}`, ) diff --git a/site/scripts/semantic-source-graph.test.mjs b/site/scripts/semantic-source-graph.test.mjs index 4e2bfdf..01d9ac8 100644 --- a/site/scripts/semantic-source-graph.test.mjs +++ b/site/scripts/semantic-source-graph.test.mjs @@ -182,7 +182,7 @@ test('mixed-module closure follows lexical symbols instead of bare names', () => semanticSourceForDigest('site/src/app/AppContent.tsx', conditionalAfter), ) - const effectBefore = `function AppContent() { let label = '正文'; useEffect(() => { label = '客户端一' }, []); return {label} }` + const effectBefore = `import { useEffect } from 'react'\nfunction AppContent() { let label = '正文'; useEffect(() => { label = '客户端一' }, []); return {label} }` const effectAfter = effectBefore.replace('客户端一', '客户端二') assert.equal( semanticSourceForDigest('site/src/app/AppContent.tsx', effectBefore), @@ -230,4 +230,128 @@ test('mixed-module closure follows lexical symbols instead of bare names', () => semanticSourceForDigest('site/src/app/AppContent.tsx', fieldBefore), semanticSourceForDigest('site/src/app/AppContent.tsx', fieldAfterSemanticChange), ) + + const staticBlockBefore = `const label = '正文一'\nclass RouteLabel { static value = label; static { const label = '客户端' } }\nfunction AppContent() { return {RouteLabel.value} }` + const staticBlockAfter = staticBlockBefore.replace('正文一', '正文二') + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', staticBlockBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', staticBlockAfter), + ) + + const importBefore = `import { ViewA as View } from './views'\nfunction AppContent() { return }` + const importAfter = importBefore.replace('ViewA as View', 'ViewB as View') + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', importBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', importAfter), + ) +}) + +test('mixed-module closure tracks every supported mutation of selected bindings', () => { + const forOfBefore = `function AppContent() { let label = '正文'; for (label of ['循环一']) {} return {label} }` + const forOfAfter = forOfBefore.replace('循环一', '循环二') + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', forOfBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', forOfAfter), + ) + + const forInBefore = `function AppContent() { let label = '正文'; for (label in { 键一: true }) {} return {label} }` + const forInAfter = forInBefore.replace('键一', '键二') + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', forInBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', forInAfter), + ) + + const deleteBefore = `const state = { value: '正文' }\nfunction AppContent() { delete state.value; return {state.value} }` + const deleteAfter = deleteBefore.replace('delete state.value', 'delete state.other') + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', deleteBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', deleteAfter), + ) + + const pushBefore = `const items = []\nfunction AppContent() { items.push('正文一'); return {items.join(',')} }` + const pushAfter = pushBefore.replace('正文一', '正文二') + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', pushBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', pushAfter), + ) + + const assignBefore = `const state = {}\nfunction AppContent() { Object.assign(state, { value: '正文一' }); return {state.value} }` + const assignAfter = assignBefore.replace('正文一', '正文二') + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', assignBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', assignAfter), + ) + + const typedAssignmentBefore = `const state = { value: '正文' }\nfunction AppContent() { (state as { value: string }).value = '正文一'; return {state.value} }` + const typedAssignmentAfter = typedAssignmentBefore.replace('正文一', '正文二') + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', typedAssignmentBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', typedAssignmentAfter), + ) + + const ambiguousReceiverCall = `const state = { value: '正文' }\nfunction AppContent() { state.inspect(); return {state.value} }` + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', ambiguousReceiverCall), + /Unsupported ambiguous mutation of state/, + ) + + + const ambiguousArgumentCall = `const state = { value: '正文' }\nfunction mutate(value) { value.value = '变化' }\nfunction AppContent() { mutate(state); return {state.value} }` + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', ambiguousArgumentCall), + /Unsupported ambiguous mutation of state/, + ) +}) + +test('mixed-module closure recognizes React effects by import provenance', () => { + for (const hook of ['useEffect', 'useLayoutEffect', 'useInsertionEffect']) { + const before = `import { ${hook} as afterRender } from 'react'\nfunction AppContent() { let label = '正文'; afterRender(() => { label = '客户端一' }, []); return {label} }` + const after = before.replace('客户端一', '客户端二') + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', before), + semanticSourceForDigest('site/src/app/AppContent.tsx', after), + hook, + ) + } + + const namespaceBefore = `import * as React from 'react'\nfunction AppContent() { let label = '正文'; React.useEffect(() => { label = '客户端一' }, []); return {label} }` + const namespaceAfter = namespaceBefore.replace('客户端一', '客户端二') + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', namespaceBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', namespaceAfter), + ) + + const defaultBefore = `import React from 'react'\nfunction AppContent() { let label = '正文'; React.useLayoutEffect(() => { label = '客户端一' }, []); return {label} }` + const defaultAfter = defaultBefore.replace('客户端一', '客户端二') + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', defaultBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', defaultAfter), + ) + + const fakeLocalHook = `function useEffect(callback) { callback() }\nfunction AppContent() { let label = '正文'; useEffect(() => { label = '同步变化' }); return {label} }` + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', fakeLocalHook), + /Unsupported nested semantic write to label/, + ) + + const fakeMemberHook = `const scheduler = { useEffect(callback) { callback() } }\nfunction AppContent() { let label = '正文'; scheduler.useEffect(() => { label = '同步变化' }); return {label} }` + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', fakeMemberHook), + /Unsupported nested semantic write to label/, + ) + + const shadowedReactHook = `import { useEffect } from 'react'\nfunction AppContent() { function useEffect(callback) { callback() } let label = '正文'; useEffect(() => { label = '同步变化' }); return {label} }` + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', shadowedReactHook), + /Unsupported nested semantic write to label/, + ) +}) + +test('switch discriminants resolve outside the switch lexical scope', () => { + const before = `function AppContent() { const mode = '外层一'; let label = '正文'; switch (mode) { case 'x': const mode = '分支'; label = mode } return {label} }` + const after = before.replace('外层一', '外层二') + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', before), + semanticSourceForDigest('site/src/app/AppContent.tsx', after), + ) }) diff --git a/site/scripts/seo-contract.test.mjs b/site/scripts/seo-contract.test.mjs index 12b78e1..21e5a0b 100644 --- a/site/scripts/seo-contract.test.mjs +++ b/site/scripts/seo-contract.test.mjs @@ -230,7 +230,7 @@ test('discovery files expose the 47 approved URLs and real summaries', async () assert.match(lastModified, /--format=%cI/) assert.match(lastModified, /normalizeContentForDigest/) assert.match(lastModified, /semanticRouteFiles/) - assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 4) + assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 5) assert.equal(Object.keys(ROUTE_CONTENT_DIGESTS).length, PUBLIC_PATHS.length) assert.ok(Object.values(ROUTE_CONTENT_DIGESTS).every((digest) => /^[0-9a-f]{64}$/.test(digest))) assert.match(publicRoutes, /\.\.\/data\/catalog\.ts/) diff --git a/site/src/data/routeLastModified.ts b/site/src/data/routeLastModified.ts index b3a79be..41202cf 100644 --- a/site/src/data/routeLastModified.ts +++ b/site/src/data/routeLastModified.ts @@ -1,5 +1,5 @@ // 由 scripts/generate-seo.mjs 从源码时间证据生成,请勿手改。 -export const ROUTE_CONTENT_DIGEST_VERSION = 4 +export const ROUTE_CONTENT_DIGEST_VERSION = 5 export const ROUTE_LAST_MODIFIED: Readonly> = Object.freeze({ "/": "2026-08-22T23:46:11+08:00", "/part/a": "2026-08-22T11:47:40+08:00", @@ -50,51 +50,51 @@ export const ROUTE_LAST_MODIFIED: Readonly> = Object.free "/problems": "2026-08-22T11:47:40+08:00" }) export const ROUTE_CONTENT_DIGESTS: Readonly> = Object.freeze({ - "/": "43e794b19a9d9ccc250589047cf7f6f2038fefca514f12c54ba6d12571291a84", - "/part/a": "a3e55088d24a9ad134179411deb3c7ba54427a8b393d1fe60597a919f2a1b7bb", - "/part/b": "bc62d99e22fa4e7f47e572b31a8023ab6dcf1afc8899627488403bb0306fd2b1", - "/part/c": "8ff5a38e7a51e4e3c35ff14a65bc0fe55b35208fcfd27decce81bee3b05671c4", - "/part/d": "2afdf5ad9c7d7f843ffa46a07faabb84e81e22d85aef9ea80cc817a278ff5029", - "/part/e": "7ef6ebb1e8f56d6297b81478221607b060de8382b8e1005da4ce98a6067785e3", - "/part/f": "756e56ab2b39b4eb47130c007c47adc418ade58659a5778d3554a74ba43c7ccb", - "/part/g": "96708135baf75e390980e6184b3d4faa02f78c1a255fb6dfceb17382f71b35dc", - "/part/a/01": "7d09d3837f2e5344b5095a318bfd07a93b0d0df7f3cf9607bde1ac2edcb239ae", - "/part/a/complete": "fe93b85425ede296b93c71803324b98515f42e5845445d62678c2ada5e8159c6", - "/part/a/multiple": "b11fe63acfcaa130f3aa4a82213f2355321f95f2d2fcab4842de4ef6f80f713d", - "/part/a/group": "d922c097ccbc255b8894869410d7e0141decc7dbb5cfca32cff14ef38c16db1c", - "/part/a/mixed": "e874d91290ccffadfe2db6709f11c383629e814b6393b296614d1da209c85da3", - "/part/a/cost2d": "fc180c823e2e3de06e4a34e25d7889b387082d1786da05c30fe4ab6299977dc5", - "/part/a/dep": "ae9176f4aa95e79f7aec0c6b22138e865c36e48698330e0d7f9bc56a2e2b7000", - "/part/a/variant": "1dc6227ac29df1b58f7a9f45443a967cf43130e651b3ff1178d9d0838cce7a06", - "/part/a/fractional": "4661fe60d0b794252259613b9475d0ca58ebab7378b1946a0bb7dd8daa34c68c", - "/part/b/path": "e3e00b08f9f27b9aaf2a23361a53a8e7c9555ffd7040f7d53cd982e8e76316ba", - "/part/b/maxseg": "57bd3600f1153ea355ffb8c64ecd6f65b1f42a309142f6ef44875b617d388abb", - "/part/b/lis": "cd9f45f6a85014ced73640d2d772c2b01c0f505c55889a80bdf912e26c2acd2e", - "/part/b/lcs": "846fa7393197e8ba9d5a08a20ce9c4c244db03f74c1c6a79ae9e39e84efd405a", - "/part/b/edit": "e1e8ed89e85491d8c4a433c470cd82020d74d6c3e6857aacff7567762d79ce77", - "/part/b/fsm": "8f04fdfe490d5aadd50891708d782c40b04f25cb5424c2e788aa05a8ebb2cb5a", - "/part/b/count": "655bf095d3d618b1b62683ec3d67dad3c21b6f6cb1c534112bdddaa41159a3ac", - "/part/c/stone": "9e20fd808ac3d3cecea835c2049a582fc203829d6a912fc364c92814e4a7f267", - "/part/c/ring": "1fb45f7356a3e8078a921cc8e1963fc9b4f4f1e60f8d7621df5f592ade42d1e0", - "/part/c/palindrome": "bb7fa1fe230f723bdde4ff80519974c7c5a1efe85485c23f62149c41d846b245", - "/part/c/tree": "409edba76ec96778f28330cd09ca2a4e84093a05a01ae5326f0662470c4b9dd2", - "/part/c/merge": "a2d0680c470b77871c63f6d35ae601ee96eb91f4ec4b8e5aa2e4d208cf07cf04", - "/part/d/grid": "0532c044f5b98b7daf7d2d3c83c57f0b0b2aed2b671294634b00bb235bf816bb", - "/part/d/matpow": "b38ae6b3a00d23fa356464d469d37b1b5a91f69b6841116a4cccb7e29ac899f9", - "/part/e/basic": "05bb98f51c93554f3cd46f192d78385637b8edc07730cc9fc499b2c50281b95c", - "/part/e/distsum": "877eabe4c02fa000b7f4a3c813800a16384f8e23dce87103eb29cf5d07d9ac26", - "/part/e/inout": "9d16e9f5b218c66bf940fa0d98d9e802e01ba8b00d1acd1309bbf79c907bdbc8", - "/part/e/center": "828ad517fe6b1af348a35099749f437390859dc4c7678e42ef9407b7b2a3b23a", - "/part/f/select": "d3114652d054b80ad5b9cca08a1b8def41cee65ee33c7e2ec1ad18b70edf38d1", - "/part/f/knapsack": "9ffc2e92fb07e44026bd10fa3d271daf6ee98ee9d416390840cd5b3f2772e970", - "/part/f/diameter": "7ea885b19e1044329267a48d688d98602950d9b0c7fddcd09de198d07f6e669e", - "/part/f/cover": "c23a5b2ffb3bf6feafe9c7186f9cf1a42b0049ff72bb8d832863beaba6ae5745", - "/part/f/count": "372a0c438acefbb3ffc5a4eea88bbe0c37704a6e4c5fd22d43d7d6819f67b640", - "/part/g/board": "fa470e82e8f1831a3e09cb887b165aa8834ef67c349ad1e2ed670577eda1c275", - "/part/g/tsp": "1382e014de1b4c98dfa4e74952395a39561a91fc5a96617a36cf0fd8534e9513", - "/part/g/cover": "6b69e5dae9b8ad54935266c25e9496bcf749ff50b3fec4dab08d6a56fa37546d", - "/part/g/subset": "143b84c6b4686a910844bdbd50180d5c1050b90d0e4aade2415f8be49777446a", - "/part/g/plug": "a2a513d763e06aa0d3d5f052af6483ddffdc8428d5713a2f3b5e986d7804f839", - "/method": "924abaa3903a1d31cd9f42ddb986d556bad1ea43a7d48f0fe59d6d3160096410", - "/problems": "f33824b7a3e7f3fdbb23db8772e5e0fae19208a7d5131bb95ce306ebe8ad5995" + "/": "fb3a7a37e9c30029f66d3b021708534fe3ff5f430688e9dd091f35a651ab0ae4", + "/part/a": "37c0d2a206c638aa6d99285ea6506d728886ee5d8dc0cb63c17b82081b462031", + "/part/b": "86f625b9f8138c60bc2fbc98a4e5bf44beed52d8f077032069b369fa21f09004", + "/part/c": "dd701282587356541c06dff2845d2a17424a14854da9624fc00bc02c8ca29e06", + "/part/d": "af5ef5ebc74394a194c4234067c72e7b3fdcee422d3302b78911cae5192a9d0a", + "/part/e": "815d5eb4a29bb924091ffb493873059a361224a2d51b7e47115ba2ef4b64e11f", + "/part/f": "54fb52f97430b909c3e4df4fbe143bc474de7fd6847e1b20a27a07123fff4d0d", + "/part/g": "2597a487606c0e9133b5f8c556d94475e3607fb565225cf358a6a57d7c150de9", + "/part/a/01": "9374bcf827d6c047b6bf9db5f545265bb685bc558f20df4aed719206ad4adb6f", + "/part/a/complete": "3b8d57b8d896e89743add67c7961513c09b9ccb0be5d6a9159fc08c044f05f02", + "/part/a/multiple": "6ce30fd711c010421f00d5ac9e9f7f833ee4269807b58a0fe96f609ef79dbeb9", + "/part/a/group": "89262e1313e7a54e0484f0f57a9dba2ec337487b46a5163e8b485c9e86fb02b1", + "/part/a/mixed": "2c200cff90bbd58ed6be847a944bbf8274d192c5f21a80149731636d148a183d", + "/part/a/cost2d": "ad1d852cedb962ab266db37f5ba884f2e899b97d80d30404c3457916d790cedd", + "/part/a/dep": "fa7b3983eece4feed7be983392d5e6595475fd22596df6a533615459b6a762b3", + "/part/a/variant": "cb2d5651e7f2d6ca8869e0040c1daf3270e20d12280da1dff1a31bdc2a5b3c0d", + "/part/a/fractional": "1a79f0dcc16f8fcdd73779338272ed53363a8a30b0f613fa10a3f2a1fd5297df", + "/part/b/path": "26d7a82d89d94b5f3540e84a87cad4b638bd3652dcefec0d096cd7b9b4c3549f", + "/part/b/maxseg": "46f3a5c3c89a402fba0a67a53bf5ae778aedb4826d7ca080fc477538099f9ba2", + "/part/b/lis": "81fb518e25f3b13e4ffe52a7df4f3fa20780fd931809103014036e38cc907e15", + "/part/b/lcs": "e36850b7d87629c26eae005e60e92f83a00f072733c912776907a858aad2a070", + "/part/b/edit": "3100a8f791c42b7731ea79ea79986a490dca25cef3d7aa17f40ac15b792cb3bf", + "/part/b/fsm": "609d3d725009a9bef4c1826cf5d1d2f4eff4209899cf8bd2900ca1fc088563ef", + "/part/b/count": "49c1fa0957ced7a19f0aa3bb3a114ce3aa8bcc0cb64c32a83c009f7598687bac", + "/part/c/stone": "48f8823c36828edb71055ca5b034c4d166396f7e5faf945225a279877679d05f", + "/part/c/ring": "773076ded579e086d473a56ea457544349187017cb19da30f72a539d3d7cd09a", + "/part/c/palindrome": "6df33e887623a61ac6c10d0273f25153caac548219ec76e13fe42b3806cf2672", + "/part/c/tree": "4e66faa5e2ef515a190354bacf56f734fbc5bb8cc310ab9ded27c5b0cd0e5f04", + "/part/c/merge": "a4a3d767addc58be0cb2b52af38287da6d38c26ca2f0ff74b2ba95b640dfb706", + "/part/d/grid": "ca1380463cc17224606506de8de036d56ea09c13b3e0bc983323cf42eb68fe31", + "/part/d/matpow": "1a7c0771e79c895767caefff82722213f19fe4306fa81d6aff392fa3848f6199", + "/part/e/basic": "676b8c601fd453d7bca48a243cef3956f9bd7afb74c4243384af4992bf7a14d0", + "/part/e/distsum": "e67c4011b697f74aa5d28dd62b093fd1a49465c0817e3a5cdacf05acd760430b", + "/part/e/inout": "23357527735f459b18b91128ca19ea23b24de4919261329e776b97611a27ece3", + "/part/e/center": "59ece84c90585428c66b992f3b4d83641dfdfe2f1361b7b25012147efed2d374", + "/part/f/select": "5e35e073644f7910129eed6d6e0aa291c0f1cc3a9bc816c2faad69e5fae1f58d", + "/part/f/knapsack": "6e388854c8d1782d995e519184592e19450c4f74852eb7de353ca3fd2fa3f435", + "/part/f/diameter": "e1d67627e8578906ad6bf52354b00a7759d08771b4a63eda91888b0a1d9e2a8f", + "/part/f/cover": "dafcb0cd621e46cd9703b876faa5eacfacd736a5d9558d1e2279b8aacf1cf221", + "/part/f/count": "6d71d3f49b9b7f63767a4b4ba28aac6ab03a383fee0098ea99630f6a882d3b6b", + "/part/g/board": "ff04a44a95b78b846e4aa538ad1a02b673d74a2211b5ecf0c47e7d9880a9fcf5", + "/part/g/tsp": "421124c36333e58b3a9ffea553d2cf282438b8488b6e6e3098aead2266ff16ae", + "/part/g/cover": "7cad2690a2b90eade59dc061d604a6e9ee87ed14e17a7f879ffac8f86e97a2b4", + "/part/g/subset": "fb4a815216284cf3a9f58b36fb3ba95378bba0337a97af89aa2e3544d5f85d06", + "/part/g/plug": "7a8d5f67f1df694470a130668509338ffefa9915016390a13111fd74d4789ebc", + "/method": "e2b9ba8eddcc674dfa004d0e6ca699d7bd490a88725f12fb6493105294e403c4", + "/problems": "1f4dd896fa80d0718172383756cababe9ff766deeb86b9fc0381cf2bb6218346" }) From ee610af0d40f3423f7b46dedc2ae7bc9a70db1c0 Mon Sep 17 00:00:00 2001 From: Shanire Date: Wed, 26 Aug 2026 12:59:44 +0800 Subject: [PATCH 09/23] fix: make semantic mutation closure conservative Signed-off-by: Shanire --- docs/engineering/architecture.md | 2 +- docs/operations/verification.md | 2 +- site/scripts/last-modified.mjs | 22 +-- site/scripts/last-modified.test.mjs | 11 ++ site/scripts/semantic-source-graph.mjs | 183 ++++++++++++++++++-- site/scripts/semantic-source-graph.test.mjs | 86 +++++++++ site/scripts/seo-contract.test.mjs | 2 +- site/src/data/routeLastModified.ts | 96 +++++----- 8 files changed, 328 insertions(+), 76 deletions(-) diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index fce562b..9e187ca 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -108,7 +108,7 @@ For a registered public `GET` or `HEAD`, `worker/content-negotiation.js` selects `seoHead.ts` renders the same head contract at build time that `RouteMeta` maintains after client navigation: title, description, citable abstract, robots, canonical, Open Graph, Twitter, and a Schema.org graph. Every indexable page includes Organization and WebSite nodes and states `isAccessibleForFree`; the WebSite node declares a `SearchAction` pointing at the real `?q=` problem search. Lessons add Course/LearningResource/TechArticle with `educationalUse` and an `EducationalAudience`, family pages add CollectionPage plus an ItemList of their ready lessons, other pages add WebPage, and routes with hierarchy add BreadcrumbList. Course duration is deliberately not declared: there is no trustworthy source for it, so `hasCourseInstance` is omitted rather than fabricated. -`site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. `semantic-source-graph.mjs` follows the real SSR injection chain from `entry-server.tsx` through the static application/providers and route tree, together with metadata roots and the exact route page, lesson, and family-art modules. Mixed shell modules use an AST-rooted projection: the route-rendering JSX plus the closure of local declarations, selected import declarations, and render-phase mutations reached by lexical binding identity are hashed. Control predicates, loop scopes, switch discriminants and case scopes, static blocks, shadowed names, class helpers, route/outlet bindings, and class-field/member identity remain precise. Assignments, update/delete expressions, non-declaration `for-in`/`for-of` targets, known mutating methods, and static target mutators such as `Object.assign` enter the closure; an unknown call that could mutate a selected binding fails closed unless the call is already inside a selected slice. Effect-only callbacks are excluded only when their callee is proven by lexical import provenance to be a React effect hook, including named aliases and default/namespace member calls; same-named local helpers are not trusted. Any other nested write fails generation and must be refactored instead of silently producing incomplete evidence. JSX attribute names do not leak in. The graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Public representation identity is structurally isolated in `config/site.ts`; client endpoints and sidebar-only copy are in modules reachable only through pruned branches. Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. Clean sources use the latest Git commit timestamp, dirty sources use the newest source-file modification timestamp, an unchanged digest preserves the previous value, and generation fails when changed content lacks strictly advancing source evidence; changing the digest schema preserves the prior content timestamp only when no newer source evidence exists, while a simultaneous real content update adopts its later timestamp. Version 5 retains the trusted `2026-08-22` evidence while closing import-provenance, mutation, switch, and static-block projection gaps: the home route uses the visible hero change at `23:46:11+08:00`, and the other 46 routes use the single-origin metadata/content change at `11:47:40+08:00`. Earlier rejected schemas had admitted a shared client effect, sidebar-only copyright copy, or incomplete execution/scope/mutation context. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. +`site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. `semantic-source-graph.mjs` follows the real SSR injection chain from `entry-server.tsx` through the static application/providers and route tree, together with metadata roots and the exact route page, lesson, and family-art modules. Mixed shell modules use an AST-rooted projection: the route-rendering JSX plus the closure of local declarations, selected import specifiers and their source, and render-phase mutations reached by lexical binding identity are hashed. Control predicates, loop scopes, switch discriminants and case scopes, static-block lexical and `var` scopes, shadowed names, stable object aliases, class helpers, route/outlet bindings, and class-field/member identity remain precise. Assignments, update/delete expressions, non-declaration `for-in`/`for-of` targets, conditional/logical/sequence mutation targets, mutating receiver methods, and proven global static target mutators such as `Object.assign` enter the closure. Arguments to member calls remain ambiguous because a same-named custom method may mutate them; same-module relative imported calls also fail closed when they could mutate a selected sibling binding. An unknown call that could mutate a selected binding fails closed unless the call is already inside a selected slice. Effect-only callbacks are excluded only when their callee is proven by lexical import provenance to be a React effect hook, including named aliases and default/namespace member calls; same-named local helpers are not trusted. Any other nested write fails generation and must be refactored instead of silently producing incomplete evidence. Type-only and unselected import siblings plus JSX attribute names do not leak in. The graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Public representation identity is structurally isolated in `config/site.ts`; client endpoints and sidebar-only copy are in modules reachable only through pruned branches. Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. Clean sources use the latest Git commit timestamp, dirty sources use the newest source-file modification timestamp, an unchanged digest preserves the previous value, and generation fails when changed content lacks strictly advancing source evidence. A digest-schema migration with no dirty route-owned semantic source preserves the prior timestamp; a simultaneous dirty content edit must advance it. Version 6 retains the trusted `2026-08-22` evidence while closing alias, expression-target, import-sibling, ambient-call, mutator-provenance, and static-block-`var` gaps: the home route uses the visible hero change at `23:46:11+08:00`, and the other 46 routes use the single-origin metadata/content change at `11:47:40+08:00`. Earlier rejected schemas had admitted shared client effects, sidebar-only copyright copy, imported sibling noise, or incomplete execution/scope/mutation context. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. # Analytics diff --git a/docs/operations/verification.md b/docs/operations/verification.md index 38a4c93..525f00e 100644 --- a/docs/operations/verification.md +++ b/docs/operations/verification.md @@ -44,7 +44,7 @@ Pure TypeScript Modules imported by Node tests must use explicit `.ts` extension # SEO And Accessibility Checks -`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. A line-ending-neutral SHA-256 digest follows each route's real SSR injection chain and exact semantic content graph while pruning navigation, feedback, analytics, theme, and motion-only branches. Mixed shell modules contribute an AST-rooted closure of route-rendering JSX, selected import declarations, and render-phase mutations resolved by lexical binding identity. Regression probes cover control predicates, function/loop/switch shadowing, switch discriminants, static blocks, class fields, imported-symbol swaps, later assignments, `for-in`/`for-of` targets, delete and object/collection mutations, route/outlet bindings, React-import-proven effect-only writes, fake same-named hooks, and non-reference JSX names. Unknown mutation contexts and ambiguous nested writes outside proven React client-effect callbacks fail closed. Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. Clean sources use their latest Git commit timestamp; dirty sources use their newest filesystem modification timestamp; and generation fails if the digest changes without that evidence advancing. A digest-schema migration preserves the prior timestamp only when it has no newer source evidence; a simultaneous content edit must still advance. Version 5 retains the trusted `2026-08-22` evidence while closing import-provenance, mutation, switch, and static-block projection gaps: `23:46:11+08:00` for the home hero change and `11:47:40+08:00` for the other 46 routes' latest single-origin metadata/content change. Earlier rejected schemas admitted a shared client effect, sidebar-only copyright copy, or incomplete execution/scope/mutation context. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. +`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. A line-ending-neutral SHA-256 digest follows each route's real SSR injection chain and exact semantic content graph while pruning navigation, feedback, analytics, theme, and motion-only branches. Mixed shell modules contribute an AST-rooted closure of route-rendering JSX, selected import specifiers plus their source, and render-phase mutations resolved by lexical binding identity. Regression probes cover control predicates, function/loop/switch shadowing, switch discriminants, static-block lexical/`var` scopes, class fields, selected imported-symbol swaps, unselected/type-only import siblings, stable aliases, conditional/logical/sequence targets, later assignments, `for-in`/`for-of` targets, delete and object/collection mutations, custom same-named mutators, same-module imported ambient calls, route/outlet bindings, React-import-proven effect-only writes, fake same-named hooks, and non-reference JSX names. Unknown mutation contexts and ambiguous nested writes outside proven React client-effect callbacks fail closed. Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. Clean sources use their latest Git commit timestamp; dirty sources use their newest filesystem modification timestamp; and generation fails if the digest changes without that evidence advancing. A digest-schema migration with no dirty route-owned semantic source preserves the prior timestamp; a simultaneous dirty content edit must still advance. Version 6 retains the trusted `2026-08-22` evidence while closing alias, expression-target, import-sibling, ambient-call, mutator-provenance, and static-block-`var` gaps: `23:46:11+08:00` for the home hero change and `11:47:40+08:00` for the other 46 routes' latest single-origin metadata/content change. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, or incomplete execution/scope/mutation context. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. The browser gate runs Chromium against built `dist/` through a strict, non-reused custom preview server. Route tests directly open `/`, `/part/a`, `/part/a/01`, `/method`, and `/part/g/plug`, then exercise live client navigation and keyboard focus. They check HTTP status, prerendered HTML, absence of pending streamed-Suspense placeholders, route CSS at first paint, hydration without errors, CLS below `0.05`, title/description/abstract/canonical/zero-hreflang/Open Graph/JSON-LD metadata, one visible `h1`, route announcements, current-page semantics, initial-load focus, changed-route focus, and skip-link focus. Navigation-positioning tests click a representative lesson outline in each of the seven families, restore a direct lesson fragment, verify the mobile outline and an ordinary family-page fragment, and require a changed route to reset old-page scroll immediately. An unknown path must return 404 with `noindex,nofollow`, no canonical, and the themed not-found heading. Worker-level Node tests separately cover the Accept matrix, 406 behavior, HEAD parity, representation-specific ETags, hidden internal assets, and unaffected API/static/unknown paths. diff --git a/site/scripts/last-modified.mjs b/site/scripts/last-modified.mjs index ed25a72..76a97fb 100644 --- a/site/scripts/last-modified.mjs +++ b/site/scripts/last-modified.mjs @@ -9,7 +9,7 @@ import { } from './semantic-source-graph.mjs' const projectRoot = fileURLToPath(new URL('../../', import.meta.url)) -export const ROUTE_CONTENT_DIGEST_VERSION = 5 +export const ROUTE_CONTENT_DIGEST_VERSION = 6 function gitNames(args) { const result = spawnSync('git', args, { @@ -81,12 +81,16 @@ export function resolveContentLastModified({ previousLastModified, candidateLastModified, evidenceSchemaChanged = false, + hasWorkingTreeEvidence = false, pathname = '', }) { if (previousDigest && previousDigest === currentDigest && previousLastModified) { return previousLastModified } if (previousDigest && previousDigest !== currentDigest && previousLastModified) { + if (evidenceSchemaChanged && !hasWorkingTreeEvidence) { + return previousLastModified + } const previousTime = Date.parse(previousLastModified) const candidateTime = Date.parse(candidateLastModified) if ( @@ -95,9 +99,6 @@ export function resolveContentLastModified({ ) { throw new Error(`Semantic content changed without lastmod advancing for ${pathname}`) } - if (evidenceSchemaChanged && candidateTime <= previousTime) { - return previousLastModified - } if (candidateTime <= previousTime) { throw new Error(`Semantic content changed without lastmod advancing for ${pathname}`) } @@ -125,19 +126,20 @@ export function collectRouteContentEvidence({ ) const lastModified = Object.fromEntries( - [...filesByRoute].map(([pathname, files]) => [ - pathname, - resolveContentLastModified({ + [...filesByRoute].map(([pathname, files]) => { + const hasWorkingTreeEvidence = files.some((file) => dirtyFiles.has(file)) + return [pathname, resolveContentLastModified({ previousDigest: previousContentDigests[pathname], currentDigest: contentDigests[pathname], previousLastModified: previousLastModified[pathname], - candidateLastModified: files.some((file) => dirtyFiles.has(file)) + candidateLastModified: hasWorkingTreeEvidence ? workingTreeDate(files.filter((file) => dirtyFiles.has(file))) : gitDate(files), evidenceSchemaChanged, + hasWorkingTreeEvidence, pathname, - }), - ]), + })] + }), ) return { lastModified, diff --git a/site/scripts/last-modified.test.mjs b/site/scripts/last-modified.test.mjs index d09dc07..234ef43 100644 --- a/site/scripts/last-modified.test.mjs +++ b/site/scripts/last-modified.test.mjs @@ -70,6 +70,17 @@ test('a changed semantic digest requires source-backed lastmod evidence', () => candidateLastModified: '2026-08-26T03:29:00.000Z', evidenceSchemaChanged: true, }), + '2026-08-26T02:29:00.000Z', + ) + assert.equal( + resolveContentLastModified({ + previousDigest: 'old-schema-digest', + currentDigest: 'new-schema-and-content-digest', + previousLastModified: '2026-08-26T02:29:00.000Z', + candidateLastModified: '2026-08-26T03:29:00.000Z', + evidenceSchemaChanged: true, + hasWorkingTreeEvidence: true, + }), '2026-08-26T03:29:00.000Z', ) }) diff --git a/site/scripts/semantic-source-graph.mjs b/site/scripts/semantic-source-graph.mjs index c93e7d3..d31cf59 100644 --- a/site/scripts/semantic-source-graph.mjs +++ b/site/scripts/semantic-source-graph.mjs @@ -126,11 +126,25 @@ function nearestFunctionScope(scope) { return current } +function nearestVarScope(scope) { + let current = scope + while ( + current.parent + && current.kind !== 'function' + && current.kind !== 'program' + && current.kind !== 'static-block' + ) { + current = current.parent + } + return current +} + function semanticBindingGraph(program) { const rootScope = createScope(null, 'program', program) const nodeScopes = new WeakMap() const nodeParents = new WeakMap() const declaredIdentifiers = new WeakSet() + const importedBindingsBySource = new Map() function declare(pattern, scope, semanticNode, metadata = {}) { for (const identifier of bindingIdentifiers(pattern)) { @@ -139,13 +153,16 @@ function semanticBindingGraph(program) { if (existing) { if (semanticNode) existing.semanticNodes.add(semanticNode) } else { - scope.bindings.set(identifier.name, { + const binding = { + aliasUnstable: false, executionScope: nearestFunctionScope(scope), identifier, ...metadata, semanticNodes: new Set(semanticNode ? [semanticNode] : []), writes: [], - }) + } + binding.aliases = new Set([binding]) + scope.bindings.set(identifier.name, binding) } } } @@ -197,7 +214,7 @@ function semanticBindingGraph(program) { return } if (node.type === 'VariableDeclaration') { - const declarationScope = node.kind === 'var' ? nearestFunctionScope(scope) : scope + const declarationScope = node.kind === 'var' ? nearestVarScope(scope) : scope for (const declarator of node.declarations) { declare(declarator.id, declarationScope, node) } @@ -218,7 +235,7 @@ function semanticBindingGraph(program) { return } if (node.type === 'StaticBlock') { - const staticBlockScope = createScope(scope, 'block') + const staticBlockScope = createScope(scope, 'static-block') for (const [child, childKey] of childNodes(node)) { build(child, staticBlockScope, node, childKey) } @@ -238,10 +255,17 @@ function semanticBindingGraph(program) { : specifier.type === 'ImportNamespaceSpecifier' ? '*' : 'default' - declare(specifier.local, scope, node, { + declare(specifier.local, scope, specifier, { importSource: node.source?.value, importedName, }) + const binding = scope.bindings.get(specifier.local.name) + if (binding && node.source) { + binding.semanticNodes.add(node.source) + const sourceBindings = importedBindingsBySource.get(node.source.value) ?? new Set() + sourceBindings.add(binding) + importedBindingsBySource.set(node.source.value, sourceBindings) + } } for (const [child, childKey] of childNodes(node)) build(child, scope, node, childKey) return @@ -279,6 +303,16 @@ function semanticBindingGraph(program) { } } else if (node.type === 'MemberExpression') { writtenBindings(node.object, bindings) + } else if (node.type === 'ConditionalExpression') { + writtenBindings(node.consequent, bindings) + writtenBindings(node.alternate, bindings) + } else if (node.type === 'LogicalExpression') { + writtenBindings(node.left, bindings) + writtenBindings(node.right, bindings) + } else if (node.type === 'SequenceExpression') { + for (const expression of node.expressions) writtenBindings(expression, bindings) + } else if (node.type === 'AwaitExpression' || node.type === 'YieldExpression') { + writtenBindings(node.argument, bindings) } else if (node.type === 'RestElement' || node.type === 'SpreadElement') { writtenBindings(node.argument, bindings) } else if (EXPRESSION_WRAPPERS.has(node.type)) { @@ -287,6 +321,93 @@ function semanticBindingGraph(program) { return bindings } + function identityBindings(node, bindings = []) { + if (!node || typeof node !== 'object') return bindings + if (node.type === 'Identifier') { + const binding = lookup(node) + if (binding) bindings.push(binding) + } else if (node.type === 'MemberExpression') { + identityBindings(node.object, bindings) + } else if (node.type === 'ConditionalExpression') { + identityBindings(node.consequent, bindings) + identityBindings(node.alternate, bindings) + } else if (node.type === 'LogicalExpression') { + identityBindings(node.left, bindings) + identityBindings(node.right, bindings) + } else if (node.type === 'SequenceExpression') { + for (const expression of node.expressions) identityBindings(expression, bindings) + } else if (node.type === 'AwaitExpression' || node.type === 'YieldExpression') { + identityBindings(node.argument, bindings) + } else if (EXPRESSION_WRAPPERS.has(node.type)) { + identityBindings(node.expression, bindings) + } + return bindings + } + + function isMemberMutationTarget(node) { + if (!node || typeof node !== 'object') return false + if (node.type === 'MemberExpression') return true + if (EXPRESSION_WRAPPERS.has(node.type)) { + return isMemberMutationTarget(node.expression) + } + return false + } + + function mergeAliases(bindings, unstable = false) { + const uniqueBindings = [...new Set(bindings)] + if (uniqueBindings.length < 2) return + const merged = new Set(uniqueBindings.flatMap((binding) => [...binding.aliases])) + const aliasUnstable = unstable || [...merged].some((binding) => binding.aliasUnstable) + for (const binding of merged) { + binding.aliases = merged + binding.aliasUnstable = aliasUnstable + } + } + + function reassignedBindings(node, bindings = []) { + if (!node || typeof node !== 'object') return bindings + if (node.type === 'Identifier') { + const binding = lookup(node) + if (binding) bindings.push(binding) + } else if (node.type === 'AssignmentPattern') { + reassignedBindings(node.left, bindings) + } else if (node.type === 'ArrayPattern') { + for (const element of node.elements) reassignedBindings(element, bindings) + } else if (node.type === 'ObjectPattern') { + for (const property of node.properties) { + if (property.type === 'Property') reassignedBindings(property.value, bindings) + if (property.type === 'RestElement') reassignedBindings(property.argument, bindings) + } + } else if (node.type === 'RestElement') { + reassignedBindings(node.argument, bindings) + } else if (EXPRESSION_WRAPPERS.has(node.type)) { + reassignedBindings(node.expression, bindings) + } + return bindings + } + + function indexAliases(node) { + if (node.type === 'VariableDeclarator' && node.init) { + mergeAliases([ + ...reassignedBindings(node.id), + ...identityBindings(node.init), + ]) + } + if (node.type === 'AssignmentExpression' && node.operator === '=') { + const targets = reassignedBindings(node.left) + for (const target of targets) { + if (target.aliases.size < 2) continue + for (const binding of target.aliases) binding.aliasUnstable = true + } + mergeAliases([ + ...targets, + ...identityBindings(node.right), + ], true) + } + for (const [child] of childNodes(node)) indexAliases(child) + } + indexAliases(program) + function memberName(member) { if (member.type !== 'MemberExpression') return null if (!member.computed && member.property.type === 'Identifier') { @@ -329,21 +450,32 @@ function semanticBindingGraph(program) { function indexWrites(node) { const executionScope = nearestFunctionScope(nodeScopes.get(node)) const semanticNode = enclosingExecutionStatement(node, executionScope) - const record = (target, kind = 'write') => { - for (const binding of writtenBindings(target)) { + const record = (target, kind = 'write', { propagateAliases = true } = {}) => { + const directBindings = new Set(writtenBindings(target)) + const aliasedBindings = propagateAliases + ? new Set([...directBindings].flatMap((binding) => [...binding.aliases])) + : directBindings + const unstableAliasMutation = propagateAliases && [...directBindings].some((binding) => ( + binding.aliasUnstable && binding.aliases.size > 1 + )) + for (const binding of aliasedBindings) { binding.writes.push({ executionScope, - kind, + kind: unstableAliasMutation ? 'ambiguous-mutation' : kind, mutationNode: node, semanticNode, }) } } if (node.type === 'AssignmentExpression') { - record(node.left) + record(node.left, 'write', { + propagateAliases: isMemberMutationTarget(node.left), + }) } if (node.type === 'UpdateExpression') { - record(node.argument) + record(node.argument, 'write', { + propagateAliases: isMemberMutationTarget(node.argument), + }) } if (node.type === 'UnaryExpression' && node.operator === 'delete') { record(node.argument) @@ -352,7 +484,7 @@ function semanticBindingGraph(program) { (node.type === 'ForInStatement' || node.type === 'ForOfStatement') && node.left.type !== 'VariableDeclaration' ) { - record(node.left) + record(node.left, 'write', { propagateAliases: false }) } if (node.type === 'CallExpression' && !isReactEffectCall(node)) { const callee = node.callee @@ -362,6 +494,8 @@ function semanticBindingGraph(program) { ? callee.object.name : null const isStaticTargetMutator = method + && callee.object.type === 'Identifier' + && !lookup(callee.object) && STATIC_TARGET_MUTATORS.get(staticOwner)?.has(method) if (isStaticTargetMutator) { record(node.arguments?.[0]) @@ -370,15 +504,29 @@ function semanticBindingGraph(program) { callee.object, MUTATING_MEMBER_CALLEES.has(method) ? 'write' : 'ambiguous-mutation', ) - if (!MUTATING_MEMBER_CALLEES.has(method)) { - for (const argument of node.arguments ?? []) { - record(argument, 'ambiguous-mutation') - } + for (const argument of node.arguments ?? []) { + record(argument, 'ambiguous-mutation') } } else { for (const argument of node.arguments ?? []) { record(argument, 'ambiguous-mutation') } + if ( + callee.type === 'Identifier' + && lookup(callee)?.importSource?.startsWith('.') + ) { + const calleeBinding = lookup(callee) + for (const binding of importedBindingsBySource.get(calleeBinding.importSource) ?? []) { + if (binding === calleeBinding) continue + binding.writes.push({ + ambientCallee: callee.name, + executionScope, + kind: 'ambient-mutation', + mutationNode: node, + semanticNode, + }) + } + } } } for (const [child] of childNodes(node)) indexWrites(child) @@ -502,6 +650,11 @@ export function semanticSourceForDigest(path, source) { )) if (mutationAlreadySelected) continue if (bindingGraph.isClientOnlyEffectScope(write.executionScope)) continue + if (write.kind === 'ambient-mutation') { + throw new Error( + `Unsupported ambient mutation from ${write.ambientCallee} in ${path}`, + ) + } if (write.kind === 'ambiguous-mutation') { throw new Error( `Unsupported ambiguous mutation of ${binding.identifier.name} in ${path}`, diff --git a/site/scripts/semantic-source-graph.test.mjs b/site/scripts/semantic-source-graph.test.mjs index 01d9ac8..8199d88 100644 --- a/site/scripts/semantic-source-graph.test.mjs +++ b/site/scripts/semantic-source-graph.test.mjs @@ -244,6 +244,27 @@ test('mixed-module closure follows lexical symbols instead of bare names', () => semanticSourceForDigest('site/src/app/AppContent.tsx', importBefore), semanticSourceForDigest('site/src/app/AppContent.tsx', importAfter), ) + + const typeSiblingBefore = `import { Suspense, type ComponentType } from 'react'\nfunction AppContent() { return }` + const typeSiblingAfter = typeSiblingBefore.replace('ComponentType', 'OtherType') + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', typeSiblingBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', typeSiblingAfter), + ) + + const runtimeSiblingBefore = `import { View, clientHelper } from './views'\nfunction AppContent() { return }` + const runtimeSiblingAfter = runtimeSiblingBefore.replace('clientHelper', 'otherHelper') + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', runtimeSiblingBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', runtimeSiblingAfter), + ) + + const staticVarBefore = `const label = '正文'\nclass ClientOnly { static { var label = '客户端一' } }\nfunction AppContent() { return {label} }` + const staticVarAfter = staticVarBefore.replace('客户端一', '客户端二') + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', staticVarBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', staticVarAfter), + ) }) test('mixed-module closure tracks every supported mutation of selected bindings', () => { @@ -289,6 +310,53 @@ test('mixed-module closure tracks every supported mutation of selected bindings' semanticSourceForDigest('site/src/app/AppContent.tsx', typedAssignmentAfter), ) + const aliasBefore = `const state = { value: '正文' }\nconst alias = state\nfunction AppContent() { alias.value = '正文一'; return {state.value} }` + const aliasAfter = aliasBefore.replace('正文一', '正文二') + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', aliasBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', aliasAfter), + ) + + const destructuredAliasBefore = `const state = { nested: { value: '正文' } }\nconst { nested: alias } = state\nfunction AppContent() { alias.value = '正文一'; return {state.nested.value} }` + const destructuredAliasAfter = destructuredAliasBefore.replace('正文一', '正文二') + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', destructuredAliasBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', destructuredAliasAfter), + ) + + const aliasRebindBefore = `const state = { value: '正文' }\nlet alias = state\nfunction AppContent() { alias = { value: '客户端一' }; return {state.value} }` + const aliasRebindAfter = aliasRebindBefore.replace('客户端一', '客户端二') + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', aliasRebindBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', aliasRebindAfter), + ) + + const unstableAlias = `const state = { value: '正文' }\nlet alias\nalias = state\nfunction AppContent() { alias.value = '变化'; return {state.value} }` + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', unstableAlias), + /Unsupported ambiguous mutation of state/, + ) + + const reboundAliasMutation = `const state = { value: '正文' }\nlet alias = state\nalias = { value: '其他' }\nfunction AppContent() { alias.value = '变化'; return {state.value} }` + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', reboundAliasMutation), + /Unsupported ambiguous mutation of state/, + ) + + for (const target of [ + 'true ? state : other', + 'state || other', + 'other, state', + ]) { + const expressionBefore = `const state = { value: '正文' }\nconst other = { value: '其他' }\nfunction AppContent() { (${target}).value = '正文一'; return {state.value} }` + const expressionAfter = expressionBefore.replace('正文一', '正文二') + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', expressionBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', expressionAfter), + target, + ) + } + const ambiguousReceiverCall = `const state = { value: '正文' }\nfunction AppContent() { state.inspect(); return {state.value} }` assert.throws( () => semanticSourceForDigest('site/src/app/AppContent.tsx', ambiguousReceiverCall), @@ -301,6 +369,24 @@ test('mixed-module closure tracks every supported mutation of selected bindings' () => semanticSourceForDigest('site/src/app/AppContent.tsx', ambiguousArgumentCall), /Unsupported ambiguous mutation of state/, ) + + const importedClosureCall = `import { state, prepare } from './store'\nfunction AppContent() { prepare(); return {state.value} }` + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', importedClosureCall), + /Unsupported ambient mutation from prepare/, + ) + + const customNamedMutator = `const state = { value: '正文' }\nconst mutator = { set(target, value) { target.value = value } }\nfunction AppContent() { mutator.set(state, '正文一'); return {state.value} }` + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', customNamedMutator), + /Unsupported ambiguous mutation of state/, + ) + + const shadowedObjectMutator = `const state = { value: '正文' }\nconst Object = { assign(target, source) { target.value = source.value } }\nfunction AppContent() { Object.assign(state, { value: '正文一' }); return {state.value} }` + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', shadowedObjectMutator), + /Unsupported ambiguous mutation of state/, + ) }) test('mixed-module closure recognizes React effects by import provenance', () => { diff --git a/site/scripts/seo-contract.test.mjs b/site/scripts/seo-contract.test.mjs index 21e5a0b..694b666 100644 --- a/site/scripts/seo-contract.test.mjs +++ b/site/scripts/seo-contract.test.mjs @@ -230,7 +230,7 @@ test('discovery files expose the 47 approved URLs and real summaries', async () assert.match(lastModified, /--format=%cI/) assert.match(lastModified, /normalizeContentForDigest/) assert.match(lastModified, /semanticRouteFiles/) - assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 5) + assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 6) assert.equal(Object.keys(ROUTE_CONTENT_DIGESTS).length, PUBLIC_PATHS.length) assert.ok(Object.values(ROUTE_CONTENT_DIGESTS).every((digest) => /^[0-9a-f]{64}$/.test(digest))) assert.match(publicRoutes, /\.\.\/data\/catalog\.ts/) diff --git a/site/src/data/routeLastModified.ts b/site/src/data/routeLastModified.ts index 41202cf..609dcb6 100644 --- a/site/src/data/routeLastModified.ts +++ b/site/src/data/routeLastModified.ts @@ -1,5 +1,5 @@ // 由 scripts/generate-seo.mjs 从源码时间证据生成,请勿手改。 -export const ROUTE_CONTENT_DIGEST_VERSION = 5 +export const ROUTE_CONTENT_DIGEST_VERSION = 6 export const ROUTE_LAST_MODIFIED: Readonly> = Object.freeze({ "/": "2026-08-22T23:46:11+08:00", "/part/a": "2026-08-22T11:47:40+08:00", @@ -50,51 +50,51 @@ export const ROUTE_LAST_MODIFIED: Readonly> = Object.free "/problems": "2026-08-22T11:47:40+08:00" }) export const ROUTE_CONTENT_DIGESTS: Readonly> = Object.freeze({ - "/": "fb3a7a37e9c30029f66d3b021708534fe3ff5f430688e9dd091f35a651ab0ae4", - "/part/a": "37c0d2a206c638aa6d99285ea6506d728886ee5d8dc0cb63c17b82081b462031", - "/part/b": "86f625b9f8138c60bc2fbc98a4e5bf44beed52d8f077032069b369fa21f09004", - "/part/c": "dd701282587356541c06dff2845d2a17424a14854da9624fc00bc02c8ca29e06", - "/part/d": "af5ef5ebc74394a194c4234067c72e7b3fdcee422d3302b78911cae5192a9d0a", - "/part/e": "815d5eb4a29bb924091ffb493873059a361224a2d51b7e47115ba2ef4b64e11f", - "/part/f": "54fb52f97430b909c3e4df4fbe143bc474de7fd6847e1b20a27a07123fff4d0d", - "/part/g": "2597a487606c0e9133b5f8c556d94475e3607fb565225cf358a6a57d7c150de9", - "/part/a/01": "9374bcf827d6c047b6bf9db5f545265bb685bc558f20df4aed719206ad4adb6f", - "/part/a/complete": "3b8d57b8d896e89743add67c7961513c09b9ccb0be5d6a9159fc08c044f05f02", - "/part/a/multiple": "6ce30fd711c010421f00d5ac9e9f7f833ee4269807b58a0fe96f609ef79dbeb9", - "/part/a/group": "89262e1313e7a54e0484f0f57a9dba2ec337487b46a5163e8b485c9e86fb02b1", - "/part/a/mixed": "2c200cff90bbd58ed6be847a944bbf8274d192c5f21a80149731636d148a183d", - "/part/a/cost2d": "ad1d852cedb962ab266db37f5ba884f2e899b97d80d30404c3457916d790cedd", - "/part/a/dep": "fa7b3983eece4feed7be983392d5e6595475fd22596df6a533615459b6a762b3", - "/part/a/variant": "cb2d5651e7f2d6ca8869e0040c1daf3270e20d12280da1dff1a31bdc2a5b3c0d", - "/part/a/fractional": "1a79f0dcc16f8fcdd73779338272ed53363a8a30b0f613fa10a3f2a1fd5297df", - "/part/b/path": "26d7a82d89d94b5f3540e84a87cad4b638bd3652dcefec0d096cd7b9b4c3549f", - "/part/b/maxseg": "46f3a5c3c89a402fba0a67a53bf5ae778aedb4826d7ca080fc477538099f9ba2", - "/part/b/lis": "81fb518e25f3b13e4ffe52a7df4f3fa20780fd931809103014036e38cc907e15", - "/part/b/lcs": "e36850b7d87629c26eae005e60e92f83a00f072733c912776907a858aad2a070", - "/part/b/edit": "3100a8f791c42b7731ea79ea79986a490dca25cef3d7aa17f40ac15b792cb3bf", - "/part/b/fsm": "609d3d725009a9bef4c1826cf5d1d2f4eff4209899cf8bd2900ca1fc088563ef", - "/part/b/count": "49c1fa0957ced7a19f0aa3bb3a114ce3aa8bcc0cb64c32a83c009f7598687bac", - "/part/c/stone": "48f8823c36828edb71055ca5b034c4d166396f7e5faf945225a279877679d05f", - "/part/c/ring": "773076ded579e086d473a56ea457544349187017cb19da30f72a539d3d7cd09a", - "/part/c/palindrome": "6df33e887623a61ac6c10d0273f25153caac548219ec76e13fe42b3806cf2672", - "/part/c/tree": "4e66faa5e2ef515a190354bacf56f734fbc5bb8cc310ab9ded27c5b0cd0e5f04", - "/part/c/merge": "a4a3d767addc58be0cb2b52af38287da6d38c26ca2f0ff74b2ba95b640dfb706", - "/part/d/grid": "ca1380463cc17224606506de8de036d56ea09c13b3e0bc983323cf42eb68fe31", - "/part/d/matpow": "1a7c0771e79c895767caefff82722213f19fe4306fa81d6aff392fa3848f6199", - "/part/e/basic": "676b8c601fd453d7bca48a243cef3956f9bd7afb74c4243384af4992bf7a14d0", - "/part/e/distsum": "e67c4011b697f74aa5d28dd62b093fd1a49465c0817e3a5cdacf05acd760430b", - "/part/e/inout": "23357527735f459b18b91128ca19ea23b24de4919261329e776b97611a27ece3", - "/part/e/center": "59ece84c90585428c66b992f3b4d83641dfdfe2f1361b7b25012147efed2d374", - "/part/f/select": "5e35e073644f7910129eed6d6e0aa291c0f1cc3a9bc816c2faad69e5fae1f58d", - "/part/f/knapsack": "6e388854c8d1782d995e519184592e19450c4f74852eb7de353ca3fd2fa3f435", - "/part/f/diameter": "e1d67627e8578906ad6bf52354b00a7759d08771b4a63eda91888b0a1d9e2a8f", - "/part/f/cover": "dafcb0cd621e46cd9703b876faa5eacfacd736a5d9558d1e2279b8aacf1cf221", - "/part/f/count": "6d71d3f49b9b7f63767a4b4ba28aac6ab03a383fee0098ea99630f6a882d3b6b", - "/part/g/board": "ff04a44a95b78b846e4aa538ad1a02b673d74a2211b5ecf0c47e7d9880a9fcf5", - "/part/g/tsp": "421124c36333e58b3a9ffea553d2cf282438b8488b6e6e3098aead2266ff16ae", - "/part/g/cover": "7cad2690a2b90eade59dc061d604a6e9ee87ed14e17a7f879ffac8f86e97a2b4", - "/part/g/subset": "fb4a815216284cf3a9f58b36fb3ba95378bba0337a97af89aa2e3544d5f85d06", - "/part/g/plug": "7a8d5f67f1df694470a130668509338ffefa9915016390a13111fd74d4789ebc", - "/method": "e2b9ba8eddcc674dfa004d0e6ca699d7bd490a88725f12fb6493105294e403c4", - "/problems": "1f4dd896fa80d0718172383756cababe9ff766deeb86b9fc0381cf2bb6218346" + "/": "5ee01321113b2bc6114f23aa9074069d2953ba73ab2ccb9eca2ef5ae523f61c5", + "/part/a": "72c4a7036d5084cb3f6cc18e2bf25d37c3948b673f98c1baf89102ba11299269", + "/part/b": "59d8201af9b168f64c248518a851030b76bfb15c585d3604109eaf454b75cc33", + "/part/c": "330a3a3ac656790a731b6d7f077555fb7125637617723b0dcb42db875c415ef1", + "/part/d": "c51bcacb7c122248d74c843f0fdeaec44f1472400f78ec2d65af2309e3a2d92d", + "/part/e": "c5a95324fec99c7f0d36b7536653325469dd9dd91b05d4756d991f660904dd93", + "/part/f": "0d991f2b99ab1da13c02430fa992f7b6d52d59fdf35dfa9c00af7fa7b0d3a9ae", + "/part/g": "3632cb5992e7a3e79173c60314af732a1132b04ecb601ed058e0c4076d55af03", + "/part/a/01": "c99ad7e9b531abb46f4fd9b9ac4ceed5fa07686e9062f85f27d41b353e19382c", + "/part/a/complete": "f18ca241aad5b96f75442779ffdccea01eaada241b19aff83095be216b753a96", + "/part/a/multiple": "152f475087e614a50de4d6f58e3eb8e0b6abcacdb928da68b2a60723b78824ad", + "/part/a/group": "aaaf95502b37fd832e200bb6e0107bfc0aff0f252a8f755a80639992d013398e", + "/part/a/mixed": "ec7a548e51bee6dec3dd94bdf6e43c117893ce1d5d2eab03077111b9a4d51e59", + "/part/a/cost2d": "e0428be648844ba7092d487c636637ca58a0aec434e63d7ea32db05aa6a52086", + "/part/a/dep": "73e4497be79d37c881f2da8c82fc1bc43e17fb2a99a1e42c0044fb7e888ee376", + "/part/a/variant": "623fed8dffd767a87c777e1930c67be4d5b1000187ae79115caf1365121ed030", + "/part/a/fractional": "b51c879188035c1958e487bf66e220fa8b16c15d72ccac89f40eebf0773fbbc1", + "/part/b/path": "4b17e283e496e044ebdb4b11a16049b451b107d00e6c048d9558cb85b8faf27c", + "/part/b/maxseg": "beef95f4f2a9218befa0ad90746eb1f3d18d3b212a71e3cf780067aa563b9f7d", + "/part/b/lis": "b482b525fad6d24f62758a10a8efae52b29a8d1af7419c53884554fd4ea0ee89", + "/part/b/lcs": "e52aa4fd3506e31f850d812742ac369c63431e3ce2a0a66685f8cb8dd1ef22cd", + "/part/b/edit": "dfdd17c46f2a28a108b55e7525dcc199993be2590cef818719bbd55d6142b672", + "/part/b/fsm": "59729f9848fa945f923f2c74a9a7b472be89597220088a376034de83c012af3f", + "/part/b/count": "176d681ec7f9620bc595ee9f573e8f289145407276f8c74f3ac51384e8c9a1ea", + "/part/c/stone": "9777cbc55d8a44acb0962b7d497bd2428cd8a5449adf57cecd07fd4be926303c", + "/part/c/ring": "7e8cf47ee57749eff71c4bde1911d87bb53426e3aa39c2313d79d02c41f8f704", + "/part/c/palindrome": "edf64420f3faef955ec2430d1ba6f69bffb067bbd1a9b573c9c43ebb2189746f", + "/part/c/tree": "75f16f723d2b5b1177869ded3f17d06628c23ffeb3d26f5771f7f6e591120154", + "/part/c/merge": "7cd0273abca3c8f5a3207d6ba458cce70aedf9378d54420f8e7b8f55f43ef5b8", + "/part/d/grid": "27c05982bc73d92ffba2d95fde47d88234218695fc22cd223931cbc171c3dca0", + "/part/d/matpow": "de91dbdff2e5981ef00273fbe0441949de83310ea0b7917e8d1607a965975e27", + "/part/e/basic": "4575a14e8f92f1421ca7b10f824ec2602727324aa60231ecb5d1424dd2c69856", + "/part/e/distsum": "5ace623f100e02a5b4455913e9926f752328e31dd483e66be6edc50821cb2e21", + "/part/e/inout": "5eba02160c4af126fa647c918fe2809e5b881c8f216f3aaa2ed057821e6a7112", + "/part/e/center": "8f308afe90625821fd8950e654df499b3a1306dea18755b1b5e15c1065ee1a08", + "/part/f/select": "ade80f83d3eb9dcd7f7ec8fd73dfc08d046041d31540d4d370e15484d9daf840", + "/part/f/knapsack": "9a831730d6a7055235be3cf19a8fe1cfc509017f3810fc2644912b2c3e7827ff", + "/part/f/diameter": "272ee07dd94d0f89e9b10546da733009d9e7cc67f266bfac1ecde3a7060afd47", + "/part/f/cover": "62461089f56ae04e2fb90ed6d1304e6592697093e8519db8db4c1bca2e2f717d", + "/part/f/count": "f24a14b3264238b8426b85de438ae6b72d19b98b8eccf0fad85a766da2e40438", + "/part/g/board": "55d3cb9abd41c0302e4e503b282e1c13f9211fe8dd3951acf4ecd204488c118a", + "/part/g/tsp": "9eaa458c16cfb914e9b456b6c58d5448f32995c8b6ee11132b426413d421973a", + "/part/g/cover": "04387e6bef8db2aaea5e85d2a166dc8fdf5e9552f1dfb250142c3bd4cfb8bcd4", + "/part/g/subset": "4ed1029b5eba57878473796018fde3a0ba983ae98d8108e8e14efbb4bdd8e0aa", + "/part/g/plug": "ee35568516123794eac663226b1fe286d219cfafdd6264546257428fc58a5d5b", + "/method": "2848183eaeaf219fb07e5ab4ccca337cc3c46511807d546d1b2694a93fae38de", + "/problems": "ccaa2b93cef10715e6f7433c738a39567987ecbd1ebac54deac2214aeaeb9fab" }) From 435d6f590c1d0f69c293033c386fccec3c25fd1d Mon Sep 17 00:00:00 2001 From: Shanire Date: Wed, 26 Aug 2026 13:24:32 +0800 Subject: [PATCH 10/23] fix: close semantic evidence review gaps Signed-off-by: Shanire --- docs/engineering/architecture.md | 2 +- docs/operations/verification.md | 2 +- site/scripts/last-modified.mjs | 124 ++++++++- site/scripts/last-modified.test.mjs | 24 +- site/scripts/semantic-source-graph.mjs | 277 +++++++++++++++++--- site/scripts/semantic-source-graph.test.mjs | 82 ++++++ site/scripts/seo-contract.test.mjs | 6 +- site/src/data/routeLastModified.ts | 2 +- 8 files changed, 471 insertions(+), 48 deletions(-) diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index 9e187ca..de9ec50 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -108,7 +108,7 @@ For a registered public `GET` or `HEAD`, `worker/content-negotiation.js` selects `seoHead.ts` renders the same head contract at build time that `RouteMeta` maintains after client navigation: title, description, citable abstract, robots, canonical, Open Graph, Twitter, and a Schema.org graph. Every indexable page includes Organization and WebSite nodes and states `isAccessibleForFree`; the WebSite node declares a `SearchAction` pointing at the real `?q=` problem search. Lessons add Course/LearningResource/TechArticle with `educationalUse` and an `EducationalAudience`, family pages add CollectionPage plus an ItemList of their ready lessons, other pages add WebPage, and routes with hierarchy add BreadcrumbList. Course duration is deliberately not declared: there is no trustworthy source for it, so `hasCourseInstance` is omitted rather than fabricated. -`site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. `semantic-source-graph.mjs` follows the real SSR injection chain from `entry-server.tsx` through the static application/providers and route tree, together with metadata roots and the exact route page, lesson, and family-art modules. Mixed shell modules use an AST-rooted projection: the route-rendering JSX plus the closure of local declarations, selected import specifiers and their source, and render-phase mutations reached by lexical binding identity are hashed. Control predicates, loop scopes, switch discriminants and case scopes, static-block lexical and `var` scopes, shadowed names, stable object aliases, class helpers, route/outlet bindings, and class-field/member identity remain precise. Assignments, update/delete expressions, non-declaration `for-in`/`for-of` targets, conditional/logical/sequence mutation targets, mutating receiver methods, and proven global static target mutators such as `Object.assign` enter the closure. Arguments to member calls remain ambiguous because a same-named custom method may mutate them; same-module relative imported calls also fail closed when they could mutate a selected sibling binding. An unknown call that could mutate a selected binding fails closed unless the call is already inside a selected slice. Effect-only callbacks are excluded only when their callee is proven by lexical import provenance to be a React effect hook, including named aliases and default/namespace member calls; same-named local helpers are not trusted. Any other nested write fails generation and must be refactored instead of silently producing incomplete evidence. Type-only and unselected import siblings plus JSX attribute names do not leak in. The graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Public representation identity is structurally isolated in `config/site.ts`; client endpoints and sidebar-only copy are in modules reachable only through pruned branches. Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. Clean sources use the latest Git commit timestamp, dirty sources use the newest source-file modification timestamp, an unchanged digest preserves the previous value, and generation fails when changed content lacks strictly advancing source evidence. A digest-schema migration with no dirty route-owned semantic source preserves the prior timestamp; a simultaneous dirty content edit must advance it. Version 6 retains the trusted `2026-08-22` evidence while closing alias, expression-target, import-sibling, ambient-call, mutator-provenance, and static-block-`var` gaps: the home route uses the visible hero change at `23:46:11+08:00`, and the other 46 routes use the single-origin metadata/content change at `11:47:40+08:00`. Earlier rejected schemas had admitted shared client effects, sidebar-only copyright copy, imported sibling noise, or incomplete execution/scope/mutation context. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. +`site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. `semantic-source-graph.mjs` follows the real SSR injection chain from `entry-server.tsx` through the static application/providers and route tree, together with metadata roots and the exact route page, lesson, and family-art modules. Mixed shell modules use an AST-rooted projection: the route-rendering JSX plus the closure of local declarations, selected runtime import specifiers and their source, and render-phase mutations reached by lexical binding identity are hashed. Control predicates, loop scopes, switch discriminants and case scopes, static-block lexical and `var` scopes, shadowed names, stable direct and object/array/constructor aliases, class helpers, route/outlet bindings, and class-field/member identity remain precise. Assignments, update/delete expressions, non-declaration `for-in`/`for-of` targets, conditional/logical/sequence/constructor mutation targets, mutating receiver methods, and proven unpatched global static target mutators such as `Object.assign` enter the closure. Non-target arguments to those static mutators and all arguments to arbitrary member calls remain ambiguous because getters, proxies, or a same-named custom method may mutate selected state. Relative imported calls fail closed when they could mutate a selected sibling binding, including stable callee aliases, imported member receivers, and equivalent resolved module specifiers. An unknown call that could mutate a selected binding fails closed unless the call is already inside a selected slice. Effect-only callbacks are excluded only when their callee is proven by stable lexical import provenance to be a React effect hook, including named aliases, stable local aliases, default/namespace member calls, and TypeScript-wrapped callbacks; same-named local helpers are not trusted. Any other nested write fails generation and must be refactored instead of silently producing incomplete evidence. Declaration- and specifier-level type-only edges, unselected import siblings, and JSX attribute names do not leak in. The graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Public representation identity is structurally isolated in `config/site.ts`; client endpoints and sidebar-only copy are in modules reachable only through pruned branches. Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. An unchanged digest preserves the previous value; changed content uses the precise Git commit or working-tree timestamp of a semantically changed projection and must advance. During a digest-schema migration, the current projector is applied to historical sources at the previous `lastmod`: a clean committed semantic edit advances, while an edit confined to a pruned client branch does not. Version 7 retains the trusted `2026-08-22` evidence while closing container/constructor aliasing, imported-call provenance, mutator source/monkey-patch, specifier-level type-edge, effect-alias/wrapper, and migration-evidence gaps: the home route uses the visible hero change at `23:46:11+08:00`, and the other 46 routes use the single-origin metadata/content change at `11:47:40+08:00`. Earlier rejected schemas had admitted shared client effects, sidebar-only copyright copy, imported sibling noise, or incomplete execution/scope/mutation context. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. # Analytics diff --git a/docs/operations/verification.md b/docs/operations/verification.md index 525f00e..b73dc17 100644 --- a/docs/operations/verification.md +++ b/docs/operations/verification.md @@ -44,7 +44,7 @@ Pure TypeScript Modules imported by Node tests must use explicit `.ts` extension # SEO And Accessibility Checks -`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. A line-ending-neutral SHA-256 digest follows each route's real SSR injection chain and exact semantic content graph while pruning navigation, feedback, analytics, theme, and motion-only branches. Mixed shell modules contribute an AST-rooted closure of route-rendering JSX, selected import specifiers plus their source, and render-phase mutations resolved by lexical binding identity. Regression probes cover control predicates, function/loop/switch shadowing, switch discriminants, static-block lexical/`var` scopes, class fields, selected imported-symbol swaps, unselected/type-only import siblings, stable aliases, conditional/logical/sequence targets, later assignments, `for-in`/`for-of` targets, delete and object/collection mutations, custom same-named mutators, same-module imported ambient calls, route/outlet bindings, React-import-proven effect-only writes, fake same-named hooks, and non-reference JSX names. Unknown mutation contexts and ambiguous nested writes outside proven React client-effect callbacks fail closed. Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. Clean sources use their latest Git commit timestamp; dirty sources use their newest filesystem modification timestamp; and generation fails if the digest changes without that evidence advancing. A digest-schema migration with no dirty route-owned semantic source preserves the prior timestamp; a simultaneous dirty content edit must still advance. Version 6 retains the trusted `2026-08-22` evidence while closing alias, expression-target, import-sibling, ambient-call, mutator-provenance, and static-block-`var` gaps: `23:46:11+08:00` for the home hero change and `11:47:40+08:00` for the other 46 routes' latest single-origin metadata/content change. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, or incomplete execution/scope/mutation context. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. +`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. A line-ending-neutral SHA-256 digest follows each route's real SSR injection chain and exact semantic content graph while pruning navigation, feedback, analytics, theme, and motion-only branches. Mixed shell modules contribute an AST-rooted closure of route-rendering JSX, selected runtime import specifiers plus their source, and render-phase mutations resolved by lexical binding identity. Regression probes cover control predicates, function/loop/switch shadowing, switch discriminants, static-block lexical/`var` scopes, class fields, selected imported-symbol swaps, declaration- and specifier-level type-only edges, unselected import siblings, stable direct/container/constructor aliases, conditional/logical/sequence targets, later assignments, `for-in`/`for-of` targets, delete and object/collection mutations, patched or custom same-named mutators, mutator source arguments, aliased/member/equivalent-path imported ambient calls, route/outlet bindings, React-import-proven effect aliases and wrapped callbacks, fake same-named hooks, and non-reference JSX names. Unknown mutation contexts and ambiguous nested writes outside proven React client-effect callbacks fail closed. Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. An unchanged digest preserves its prior time; changed content uses the exact Git commit or working-tree timestamp of a semantically changed projection and must advance. A schema migration reapplies the current projector to source history at the previous `lastmod`, so clean committed semantic edits advance while dirty changes confined to pruned client branches do not. Version 7 retains the trusted `2026-08-22` evidence while closing container/constructor aliasing, imported-call provenance, mutator source/monkey-patch, specifier-level type-edge, effect-alias/wrapper, and migration-evidence gaps: `23:46:11+08:00` for the home hero change and `11:47:40+08:00` for the other 46 routes' latest single-origin metadata/content change. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, or incomplete execution/scope/mutation context. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. The browser gate runs Chromium against built `dist/` through a strict, non-reused custom preview server. Route tests directly open `/`, `/part/a`, `/part/a/01`, `/method`, and `/part/g/plug`, then exercise live client navigation and keyboard focus. They check HTTP status, prerendered HTML, absence of pending streamed-Suspense placeholders, route CSS at first paint, hydration without errors, CLS below `0.05`, title/description/abstract/canonical/zero-hreflang/Open Graph/JSON-LD metadata, one visible `h1`, route announcements, current-page semantics, initial-load focus, changed-route focus, and skip-link focus. Navigation-positioning tests click a representative lesson outline in each of the seven families, restore a direct lesson fragment, verify the mobile outline and an ordinary family-page fragment, and require a changed route to reset old-page scroll immediately. An unknown path must return 404 with `noindex,nofollow`, no canonical, and the themed not-found heading. Worker-level Node tests separately cover the Accept matrix, 406 behavior, HEAD parity, representation-specific ETags, hidden internal assets, and unaffected API/static/unknown paths. diff --git a/site/scripts/last-modified.mjs b/site/scripts/last-modified.mjs index 76a97fb..d6aa714 100644 --- a/site/scripts/last-modified.mjs +++ b/site/scripts/last-modified.mjs @@ -9,7 +9,10 @@ import { } from './semantic-source-graph.mjs' const projectRoot = fileURLToPath(new URL('../../', import.meta.url)) -export const ROUTE_CONTENT_DIGEST_VERSION = 6 +export const ROUTE_CONTENT_DIGEST_VERSION = 7 + +const historicalSourceCache = new Map() +const headSourceCache = new Map() function gitNames(args) { const result = spawnSync('git', args, { @@ -45,6 +48,97 @@ function routeContentDigest(files) { return digest.digest('hex') } +function gitSource(file, revision) { + const result = spawnSync('git', ['show', `${revision}:${file}`], { + cwd: projectRoot, + encoding: 'utf8', + }) + return result.status === 0 ? result.stdout : null +} + +function historicalSource(file, lastModified) { + const cacheKey = `${file}\0${lastModified}` + if (historicalSourceCache.has(cacheKey)) return historicalSourceCache.get(cacheKey) + const revision = spawnSync( + 'git', + ['log', '-1', '--format=%H', `--until=${lastModified}`, '--', file], + { cwd: projectRoot, encoding: 'utf8' }, + ) + if (revision.status !== 0) { + throw new Error(`Unable to inspect historical semantic source ${file}: ${revision.stderr.trim()}`) + } + const hash = revision.stdout.trim() + const source = hash ? gitSource(file, hash) : null + historicalSourceCache.set(cacheKey, source) + return source +} + +function headSource(file) { + if (!headSourceCache.has(file)) headSourceCache.set(file, gitSource(file, 'HEAD')) + return headSourceCache.get(file) +} + +function semanticProjection(path, source) { + return source === null + ? null + : normalizeContentForDigest(semanticSourceForDigest(path, source)) +} + +export function semanticProjectionChanged(path, currentSource, previousSource) { + return semanticProjection(path, currentSource) !== semanticProjection(path, previousSource) +} + +function latestDate(dates) { + return dates.reduce((latest, candidate) => ( + !latest || Date.parse(candidate) > Date.parse(latest) ? candidate : latest + ), null) +} + +function latestSemanticGitDate(file, previousLastModified) { + const history = spawnSync( + 'git', + ['log', `--since=${previousLastModified}`, '--format=%H%x09%cI', 'HEAD', '--', file], + { cwd: projectRoot, encoding: 'utf8' }, + ) + if (history.status !== 0) { + throw new Error(`Unable to inspect semantic history for ${file}: ${history.stderr.trim()}`) + } + for (const line of history.stdout.split('\n').filter(Boolean)) { + const [revision, committedAt] = line.split('\t') + if (semanticProjectionChanged( + file, + gitSource(file, revision), + gitSource(file, `${revision}^`), + )) return committedAt + } + throw new Error(`Semantic source changed without a matching Git revision for ${file}`) +} + +function semanticChangeEvidence(files, previousLastModified) { + const dates = [] + for (const file of files) { + const currentSource = readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8') + if (!semanticProjectionChanged( + file, + currentSource, + historicalSource(file, previousLastModified), + )) continue + + const currentDiffersFromHead = semanticProjectionChanged( + file, + currentSource, + headSource(file), + ) + dates.push(currentDiffersFromHead + ? new Date(statSync(new URL(`../../${file}`, import.meta.url)).mtimeMs).toISOString() + : latestSemanticGitDate(file, previousLastModified)) + } + return { + candidateLastModified: latestDate(dates), + hasSemanticContentChange: dates.length > 0, + } +} + function workingTreeDate(files) { const latest = Math.max(...files.map((file) => ( statSync(new URL(`../../${file}`, import.meta.url)).mtimeMs @@ -81,14 +175,14 @@ export function resolveContentLastModified({ previousLastModified, candidateLastModified, evidenceSchemaChanged = false, - hasWorkingTreeEvidence = false, + hasSemanticContentChange = false, pathname = '', }) { if (previousDigest && previousDigest === currentDigest && previousLastModified) { return previousLastModified } if (previousDigest && previousDigest !== currentDigest && previousLastModified) { - if (evidenceSchemaChanged && !hasWorkingTreeEvidence) { + if (evidenceSchemaChanged && !hasSemanticContentChange) { return previousLastModified } const previousTime = Date.parse(previousLastModified) @@ -127,16 +221,30 @@ export function collectRouteContentEvidence({ const lastModified = Object.fromEntries( [...filesByRoute].map(([pathname, files]) => { - const hasWorkingTreeEvidence = files.some((file) => dirtyFiles.has(file)) + const digestChanged = ( + previousContentDigests[pathname] + && previousContentDigests[pathname] !== contentDigests[pathname] + ) + const semanticEvidence = previousLastModified[pathname] && digestChanged + ? semanticChangeEvidence(files, previousLastModified[pathname]) + : null + if ( + digestChanged + && !evidenceSchemaChanged + && semanticEvidence + && !semanticEvidence.hasSemanticContentChange + ) { + throw new Error(`Digest changed without semantic source evidence for ${pathname}`) + } + const dirtyRouteFiles = files.filter((file) => dirtyFiles.has(file)) return [pathname, resolveContentLastModified({ previousDigest: previousContentDigests[pathname], currentDigest: contentDigests[pathname], previousLastModified: previousLastModified[pathname], - candidateLastModified: hasWorkingTreeEvidence - ? workingTreeDate(files.filter((file) => dirtyFiles.has(file))) - : gitDate(files), + candidateLastModified: semanticEvidence?.candidateLastModified + ?? (dirtyRouteFiles.length > 0 ? workingTreeDate(dirtyRouteFiles) : gitDate(files)), evidenceSchemaChanged, - hasWorkingTreeEvidence, + hasSemanticContentChange: semanticEvidence?.hasSemanticContentChange ?? false, pathname, })] }), diff --git a/site/scripts/last-modified.test.mjs b/site/scripts/last-modified.test.mjs index 234ef43..3ec12ca 100644 --- a/site/scripts/last-modified.test.mjs +++ b/site/scripts/last-modified.test.mjs @@ -4,6 +4,7 @@ import test from 'node:test' import { normalizeContentForDigest, resolveContentLastModified, + semanticProjectionChanged, } from './last-modified.mjs' test('semantic digests ignore checkout-specific CRLF conversion', () => { @@ -69,6 +70,7 @@ test('a changed semantic digest requires source-backed lastmod evidence', () => previousLastModified: '2026-08-26T02:29:00.000Z', candidateLastModified: '2026-08-26T03:29:00.000Z', evidenceSchemaChanged: true, + hasSemanticContentChange: false, }), '2026-08-26T02:29:00.000Z', ) @@ -79,8 +81,28 @@ test('a changed semantic digest requires source-backed lastmod evidence', () => previousLastModified: '2026-08-26T02:29:00.000Z', candidateLastModified: '2026-08-26T03:29:00.000Z', evidenceSchemaChanged: true, - hasWorkingTreeEvidence: true, + hasSemanticContentChange: true, }), '2026-08-26T03:29:00.000Z', ) }) + +test('schema migrations distinguish pruned client edits from semantic edits', () => { + const before = `import { useEffect } from 'react'\nfunction AppContent() { useEffect(() => first()); return

正文一

}` + assert.equal( + semanticProjectionChanged( + 'site/src/app/AppContent.tsx', + before.replace('first()', 'second()'), + before, + ), + false, + ) + assert.equal( + semanticProjectionChanged( + 'site/src/app/AppContent.tsx', + before.replace('正文一', '正文二'), + before, + ), + true, + ) +}) diff --git a/site/scripts/semantic-source-graph.mjs b/site/scripts/semantic-source-graph.mjs index d31cf59..9324edf 100644 --- a/site/scripts/semantic-source-graph.mjs +++ b/site/scripts/semantic-source-graph.mjs @@ -139,12 +139,24 @@ function nearestVarScope(scope) { return current } -function semanticBindingGraph(program) { +function canonicalImportIdentity(importerPath, specifier) { + if (!specifier.startsWith('.')) return specifier + const importer = resolve(projectRoot, importerPath) + const resolvedImport = resolveStaticImport(importer, specifier) + if (resolvedImport) return projectPath(resolvedImport) + + const unresolved = projectPath(resolve(dirname(importer), specifier)) + .replace(/\.(?:m?[jt]sx?|json)$/u, '') + return unresolved.endsWith('/index') ? unresolved.slice(0, -'/index'.length) : unresolved +} + +function semanticBindingGraph(program, path) { const rootScope = createScope(null, 'program', program) const nodeScopes = new WeakMap() const nodeParents = new WeakMap() const declaredIdentifiers = new WeakSet() - const importedBindingsBySource = new Map() + const importedBindingsByIdentity = new Map() + const unresolvedMutations = [] function declare(pattern, scope, semanticNode, metadata = {}) { for (const identifier of bindingIdentifiers(pattern)) { @@ -249,22 +261,27 @@ function semanticBindingGraph(program) { } if (node.type === 'ImportDeclaration') { for (const specifier of node.specifiers ?? []) { - if (!specifier.local) continue + if ( + !specifier.local + || node.importKind === 'type' + || specifier.importKind === 'type' + ) continue const importedName = specifier.type === 'ImportSpecifier' ? (specifier.imported?.name ?? specifier.imported?.value) : specifier.type === 'ImportNamespaceSpecifier' ? '*' : 'default' declare(specifier.local, scope, specifier, { + importIdentity: canonicalImportIdentity(path, node.source?.value), importSource: node.source?.value, importedName, }) const binding = scope.bindings.get(specifier.local.name) if (binding && node.source) { binding.semanticNodes.add(node.source) - const sourceBindings = importedBindingsBySource.get(node.source.value) ?? new Set() - sourceBindings.add(binding) - importedBindingsBySource.set(node.source.value, sourceBindings) + const identityBindings = importedBindingsByIdentity.get(binding.importIdentity) ?? new Set() + identityBindings.add(binding) + importedBindingsByIdentity.set(binding.importIdentity, identityBindings) } } for (const [child, childKey] of childNodes(node)) build(child, scope, node, childKey) @@ -303,6 +320,8 @@ function semanticBindingGraph(program) { } } else if (node.type === 'MemberExpression') { writtenBindings(node.object, bindings) + } else if (node.type === 'NewExpression') { + for (const argument of node.arguments ?? []) writtenBindings(argument, bindings) } else if (node.type === 'ConditionalExpression') { writtenBindings(node.consequent, bindings) writtenBindings(node.alternate, bindings) @@ -328,6 +347,15 @@ function semanticBindingGraph(program) { if (binding) bindings.push(binding) } else if (node.type === 'MemberExpression') { identityBindings(node.object, bindings) + } else if (node.type === 'ArrayExpression') { + for (const element of node.elements) identityBindings(element, bindings) + } else if (node.type === 'ObjectExpression') { + for (const property of node.properties) { + if (property.type === 'Property') identityBindings(property.value, bindings) + if (property.type === 'SpreadElement') identityBindings(property.argument, bindings) + } + } else if (node.type === 'NewExpression') { + for (const argument of node.arguments ?? []) identityBindings(argument, bindings) } else if (node.type === 'ConditionalExpression') { identityBindings(node.consequent, bindings) identityBindings(node.alternate, bindings) @@ -353,6 +381,24 @@ function semanticBindingGraph(program) { return false } + function containsDynamicIdentity(node) { + if (!node || typeof node !== 'object') return false + if (node.type === 'CallExpression') return true + if (node.type === 'MemberExpression') return containsDynamicIdentity(node.object) + if (node.type === 'ConditionalExpression') { + return containsDynamicIdentity(node.consequent) + || containsDynamicIdentity(node.alternate) + } + if (node.type === 'LogicalExpression') { + return containsDynamicIdentity(node.left) || containsDynamicIdentity(node.right) + } + if (node.type === 'SequenceExpression') { + return node.expressions.some(containsDynamicIdentity) + } + if (EXPRESSION_WRAPPERS.has(node.type)) return containsDynamicIdentity(node.expression) + return false + } + function mergeAliases(bindings, unstable = false) { const uniqueBindings = [...new Set(bindings)] if (uniqueBindings.length < 2) return @@ -423,16 +469,28 @@ function semanticBindingGraph(program) { const callee = call.callee if (callee.type === 'Identifier') { const binding = lookup(callee) - return binding?.importSource === 'react' - && CLIENT_EFFECT_CALLEES.has(binding.importedName) + return Boolean( + binding + && !binding.aliasUnstable + && [...binding.aliases].some((alias) => ( + alias.importSource === 'react' + && CLIENT_EFFECT_CALLEES.has(alias.importedName) + )), + ) } if (callee.type !== 'MemberExpression') return false const hookName = memberName(callee) if (!CLIENT_EFFECT_CALLEES.has(hookName)) return false if (callee.object.type !== 'Identifier') return false const binding = lookup(callee.object) - return binding?.importSource === 'react' - && (binding.importedName === '*' || binding.importedName === 'default') + return Boolean( + binding + && !binding.aliasUnstable + && [...binding.aliases].some((alias) => ( + alias.importSource === 'react' + && (alias.importedName === '*' || alias.importedName === 'default') + )), + ) } function enclosingExecutionStatement(node, executionScope) { @@ -447,11 +505,93 @@ function semanticBindingGraph(program) { return node } + const patchedStaticOwners = new Set() + const staticOwnerAliases = new Map() + + function unboundGlobalContainer(node) { + if (!node || typeof node !== 'object') return false + if (EXPRESSION_WRAPPERS.has(node.type)) return unboundGlobalContainer(node.expression) + return node.type === 'Identifier' + && ['global', 'globalThis', 'self', 'window'].includes(node.name) + && !lookup(node) + } + + function globalStaticOwner(node) { + if (!node || typeof node !== 'object') return null + if (EXPRESSION_WRAPPERS.has(node.type)) return globalStaticOwner(node.expression) + if (node.type === 'Identifier') { + const binding = lookup(node) + if (!binding && STATIC_TARGET_MUTATORS.has(node.name)) return node.name + return staticOwnerAliases.get(binding) ?? null + } + if (node.type === 'MemberExpression' && unboundGlobalContainer(node.object)) { + const name = memberName(node) + return STATIC_TARGET_MUTATORS.has(name) ? name : null + } + return null + } + + function indexStaticOwnerAliases() { + let changed = true + while (changed) { + changed = false + function visit(node) { + if (node.type === 'VariableDeclarator' && node.id.type === 'Identifier' && node.init) { + const owner = globalStaticOwner(node.init) + const binding = lookup(node.id) + if (owner && binding && staticOwnerAliases.get(binding) !== owner) { + staticOwnerAliases.set(binding, owner) + changed = true + } + } + for (const [child] of childNodes(node)) visit(child) + } + visit(program) + } + } + indexStaticOwnerAliases() + + function mutationMemberOwner(node) { + if (!node || typeof node !== 'object') return null + if (EXPRESSION_WRAPPERS.has(node.type)) return mutationMemberOwner(node.expression) + if (node.type !== 'MemberExpression') return null + return globalStaticOwner(node.object) + } + + function indexPatchedStaticOwners(node) { + if (node.type === 'AssignmentExpression' || node.type === 'UpdateExpression') { + const owner = mutationMemberOwner( + node.type === 'AssignmentExpression' ? node.left : node.argument, + ) + if (owner) patchedStaticOwners.add(owner) + } + if (node.type === 'UnaryExpression' && node.operator === 'delete') { + const owner = mutationMemberOwner(node.argument) + if (owner) patchedStaticOwners.add(owner) + } + if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') { + const owner = globalStaticOwner(node.callee.object) + const method = memberName(node.callee) + if ( + owner + && STATIC_TARGET_MUTATORS.get(owner)?.has(method) + && globalStaticOwner(node.arguments?.[0]) + ) { + patchedStaticOwners.add(globalStaticOwner(node.arguments[0])) + } + } + for (const [child] of childNodes(node)) indexPatchedStaticOwners(child) + } + indexPatchedStaticOwners(program) + function indexWrites(node) { const executionScope = nearestFunctionScope(nodeScopes.get(node)) const semanticNode = enclosingExecutionStatement(node, executionScope) const record = (target, kind = 'write', { propagateAliases = true } = {}) => { const directBindings = new Set(writtenBindings(target)) + if (directBindings.size === 0 && containsDynamicIdentity(target)) { + unresolvedMutations.push({ executionScope, mutationNode: node }) + } const aliasedBindings = propagateAliases ? new Set([...directBindings].flatMap((binding) => [...binding.aliases])) : directBindings @@ -496,9 +636,13 @@ function semanticBindingGraph(program) { const isStaticTargetMutator = method && callee.object.type === 'Identifier' && !lookup(callee.object) + && !patchedStaticOwners.has(staticOwner) && STATIC_TARGET_MUTATORS.get(staticOwner)?.has(method) if (isStaticTargetMutator) { record(node.arguments?.[0]) + for (const argument of node.arguments?.slice(1) ?? []) { + record(argument, 'ambiguous-mutation') + } } else if (callee.type === 'MemberExpression') { record( callee.object, @@ -507,19 +651,20 @@ function semanticBindingGraph(program) { for (const argument of node.arguments ?? []) { record(argument, 'ambiguous-mutation') } - } else { - for (const argument of node.arguments ?? []) { - record(argument, 'ambiguous-mutation') - } - if ( - callee.type === 'Identifier' - && lookup(callee)?.importSource?.startsWith('.') - ) { - const calleeBinding = lookup(callee) - for (const binding of importedBindingsBySource.get(calleeBinding.importSource) ?? []) { - if (binding === calleeBinding) continue + const receiverBindings = new Set(identityBindings(callee.object)) + const receiverAliases = new Set( + [...receiverBindings].flatMap((binding) => [...binding.aliases]), + ) + const importIdentities = new Set( + [...receiverAliases] + .map((binding) => binding.importIdentity) + .filter((identity) => typeof identity === 'string' && identity.startsWith('site/')), + ) + for (const importIdentity of importIdentities) { + for (const binding of importedBindingsByIdentity.get(importIdentity) ?? []) { + if (receiverAliases.has(binding)) continue binding.writes.push({ - ambientCallee: callee.name, + ambientCallee: method ?? '', executionScope, kind: 'ambient-mutation', mutationNode: node, @@ -527,6 +672,31 @@ function semanticBindingGraph(program) { }) } } + } else { + for (const argument of node.arguments ?? []) { + record(argument, 'ambiguous-mutation') + } + if (callee.type === 'Identifier') { + const calleeBinding = lookup(callee) + const calleeAliases = new Set(calleeBinding?.aliases ?? []) + const importIdentities = new Set( + [...calleeAliases] + .map((binding) => binding.importIdentity) + .filter((identity) => typeof identity === 'string' && identity.startsWith('site/')), + ) + for (const importIdentity of importIdentities) { + for (const binding of importedBindingsByIdentity.get(importIdentity) ?? []) { + if (calleeAliases.has(binding)) continue + binding.writes.push({ + ambientCallee: callee.name, + executionScope, + kind: 'ambient-mutation', + mutationNode: node, + semanticNode, + }) + } + } + } } } for (const [child] of childNodes(node)) indexWrites(child) @@ -585,7 +755,16 @@ function semanticBindingGraph(program) { function isClientOnlyEffectScope(scope) { const functionNode = scope.ownerNode - const relation = nodeParents.get(functionNode) + let callbackNode = functionNode + let relation = nodeParents.get(callbackNode) + while ( + relation + && EXPRESSION_WRAPPERS.has(relation.parent.type) + && relation.key === 'expression' + ) { + callbackNode = relation.parent + relation = nodeParents.get(callbackNode) + } return Boolean( relation && relation.key === 'arguments' @@ -600,6 +779,7 @@ function semanticBindingGraph(program) { isClientOnlyEffectScope, isReference, lookup, + unresolvedMutations, } } @@ -610,7 +790,7 @@ export function semanticSourceForDigest(path, source) { if (errors.length > 0) { throw new Error(`Unable to parse semantic source ${path}: ${errors[0].message}`) } - const bindingGraph = semanticBindingGraph(program) + const bindingGraph = semanticBindingGraph(program, path) const roots = [] function findRoots(node) { if ( @@ -680,6 +860,18 @@ export function semanticSourceForDigest(path, source) { collectReferences(selectedNode) } + for (const write of bindingGraph.unresolvedMutations) { + if (write.executionScope !== rootExecutionScope) continue + if (bindingGraph.isClientOnlyEffectScope(write.executionScope)) continue + const mutationAlreadySelected = [...slices].some((slice) => ( + slice.start <= write.mutationNode.start + && slice.end >= write.mutationNode.end + )) + if (!mutationAlreadySelected) { + throw new Error(`Unsupported dynamic mutation target in ${path}`) + } + } + const orderedSlices = [...slices] .sort((left, right) => left.start - right.start) const outermostSlices = orderedSlices.filter((node, index) => { @@ -715,24 +907,41 @@ function resolveStaticImport(importer, specifier) { return resolved } -function staticImportSpecifiers(path) { - if (!PARSED_EXTENSIONS.has(extname(path))) return [] - const source = readFileSync(path, 'utf8') +export function staticImportSpecifiersForSource(path, source) { const { program, errors } = parseSync(path, source) if (errors.length > 0) { throw new Error(`Unable to parse semantic dependency ${path}: ${errors[0].message}`) } return program.body.flatMap((statement) => { - if ( - statement.type !== 'ImportDeclaration' - && statement.type !== 'ExportNamedDeclaration' - && statement.type !== 'ExportAllDeclaration' - ) return [] - if (statement.importKind === 'type' || statement.exportKind === 'type') return [] - return typeof statement.source?.value === 'string' ? [statement.source.value] : [] + if (typeof statement.source?.value !== 'string') return [] + if (statement.type === 'ImportDeclaration') { + if (statement.importKind === 'type') return [] + if ( + statement.specifiers.length > 0 + && statement.specifiers.every((specifier) => specifier.importKind === 'type') + ) return [] + return [statement.source.value] + } + if (statement.type === 'ExportNamedDeclaration') { + if (statement.exportKind === 'type') return [] + if ( + statement.specifiers.length > 0 + && statement.specifiers.every((specifier) => specifier.exportKind === 'type') + ) return [] + return [statement.source.value] + } + if (statement.type === 'ExportAllDeclaration') { + return statement.exportKind === 'type' ? [] : [statement.source.value] + } + return [] }) } +function staticImportSpecifiers(path) { + if (!PARSED_EXTENSIONS.has(extname(path))) return [] + return staticImportSpecifiersForSource(path, readFileSync(path, 'utf8')) +} + export function semanticRouteFiles(pathname) { const routeModules = routeModuleIds(pathname) const routePages = new Set(ROUTE_PAGE_MODULES.map((moduleId) => `site/${moduleId}`)) diff --git a/site/scripts/semantic-source-graph.test.mjs b/site/scripts/semantic-source-graph.test.mjs index 8199d88..03df687 100644 --- a/site/scripts/semantic-source-graph.test.mjs +++ b/site/scripts/semantic-source-graph.test.mjs @@ -6,6 +6,7 @@ import { PUBLIC_PATHS } from '../src/lib/publicRoutes.ts' import { semanticRouteFiles, semanticSourceForDigest, + staticImportSpecifiersForSource, } from './semantic-source-graph.mjs' test('lesson evidence follows the full static semantic dependency graph', () => { @@ -324,6 +325,17 @@ test('mixed-module closure tracks every supported mutation of selected bindings' semanticSourceForDigest('site/src/app/AppContent.tsx', destructuredAliasAfter), ) + for (const containerBefore of [ + `const state = { value: '正文' }\nconst holder = { current: state }\nfunction AppContent() { holder.current.value = '正文一'; return {state.value} }`, + `const state = { value: '正文' }\nconst holder = [state]\nfunction AppContent() { holder[0].value = '正文一'; return {state.value} }`, + ]) { + const containerAfter = containerBefore.replace('正文一', '正文二') + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', containerBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', containerAfter), + ) + } + const aliasRebindBefore = `const state = { value: '正文' }\nlet alias = state\nfunction AppContent() { alias = { value: '客户端一' }; return {state.value} }` const aliasRebindAfter = aliasRebindBefore.replace('客户端一', '客户端二') assert.equal( @@ -376,6 +388,36 @@ test('mixed-module closure tracks every supported mutation of selected bindings' /Unsupported ambient mutation from prepare/, ) + for (const importedAliasCall of [ + `import { state, prepare } from './store'\nconst run = prepare\nfunction AppContent() { run(); return {state.value} }`, + `import { state, controller } from './store'\nfunction AppContent() { controller.prepare(); return {state.value} }`, + `import { state } from './store'\nimport { prepare } from './store/index'\nfunction AppContent() { prepare(); return {state.value} }`, + ]) { + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', importedAliasCall), + /Unsupported ambient mutation/, + ) + } + + const mutatingAssignSource = `const state = { value: '正文', get leak() { this.value = '变化'; return 1 } }\nfunction AppContent() { Object.assign({}, state); return {state.value} }` + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', mutatingAssignSource), + /Unsupported ambiguous mutation of state/, + ) + + const proxyTargetBefore = `const state = { value: '正文' }\nfunction AppContent() { new Proxy(state, {}).value = '正文一'; return {state.value} }` + const proxyTargetAfter = proxyTargetBefore.replace('正文一', '正文二') + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', proxyTargetBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', proxyTargetAfter), + ) + + const dynamicTarget = `const state = { value: '正文' }\nfunction current() { return state }\nfunction AppContent() { current().value = '变化'; return {state.value} }` + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', dynamicTarget), + /Unsupported dynamic mutation target/, + ) + const customNamedMutator = `const state = { value: '正文' }\nconst mutator = { set(target, value) { target.value = value } }\nfunction AppContent() { mutator.set(state, '正文一'); return {state.value} }` assert.throws( () => semanticSourceForDigest('site/src/app/AppContent.tsx', customNamedMutator), @@ -387,6 +429,22 @@ test('mixed-module closure tracks every supported mutation of selected bindings' () => semanticSourceForDigest('site/src/app/AppContent.tsx', shadowedObjectMutator), /Unsupported ambiguous mutation of state/, ) + + const patchedGlobalObject = `const state = { value: '正文' }\nconst other = {}\nObject.assign = (_target, source) => { source.value = '变化' }\nfunction AppContent() { Object.assign(other, state); return {state.value} }` + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', patchedGlobalObject), + /Unsupported ambiguous mutation of state/, + ) + + for (const indirectGlobalPatch of [ + `const state = { value: '正文' }\nglobalThis.Object.assign = target => { target.value = '变化' }\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`, + `const state = { value: '正文' }\nconst BuiltinObject = Object\nBuiltinObject.assign = target => { target.value = '变化' }\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`, + ]) { + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', indirectGlobalPatch), + /Unsupported ambiguous mutation of state/, + ) + } }) test('mixed-module closure recognizes React effects by import provenance', () => { @@ -414,6 +472,20 @@ test('mixed-module closure recognizes React effects by import provenance', () => semanticSourceForDigest('site/src/app/AppContent.tsx', defaultAfter), ) + const stableAliasBefore = `import { useEffect } from 'react'\nconst useAfterRender = useEffect\nfunction AppContent() { let label = '正文'; useAfterRender(() => { label = '客户端一' }, []); return {label} }` + const stableAliasAfter = stableAliasBefore.replace('客户端一', '客户端二') + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', stableAliasBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', stableAliasAfter), + ) + + const wrappedCallbackBefore = `import { useEffect } from 'react'\nfunction AppContent() { let label = '正文'; useEffect((() => { label = '客户端一' }) as () => void, []); return {label} }` + const wrappedCallbackAfter = wrappedCallbackBefore.replace('客户端一', '客户端二') + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', wrappedCallbackBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', wrappedCallbackAfter), + ) + const fakeLocalHook = `function useEffect(callback) { callback() }\nfunction AppContent() { let label = '正文'; useEffect(() => { label = '同步变化' }); return {label} }` assert.throws( () => semanticSourceForDigest('site/src/app/AppContent.tsx', fakeLocalHook), @@ -433,6 +505,16 @@ test('mixed-module closure recognizes React effects by import provenance', () => ) }) +test('static dependency discovery excludes specifier-level type-only edges', () => { + assert.deepEqual( + staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `import { type ClientOnly } from './client'\nimport { Runtime, type RuntimeType } from './runtime'\nexport { type ExportedType } from './types'\nexport { RuntimeValue } from './values'`, + ), + ['./runtime', './values'], + ) +}) + test('switch discriminants resolve outside the switch lexical scope', () => { const before = `function AppContent() { const mode = '外层一'; let label = '正文'; switch (mode) { case 'x': const mode = '分支'; label = mode } return {label} }` const after = before.replace('外层一', '外层二') diff --git a/site/scripts/seo-contract.test.mjs b/site/scripts/seo-contract.test.mjs index 694b666..4c19fe6 100644 --- a/site/scripts/seo-contract.test.mjs +++ b/site/scripts/seo-contract.test.mjs @@ -225,12 +225,14 @@ test('discovery files expose the 47 approved URLs and real summaries', async () assert.match(generator, /replaceRouteHead/) assert.match(lastModified, /gitNames\(\['diff', '--name-only', '-z', 'HEAD'/) assert.match(lastModified, /gitNames\(\['ls-files', '--others', '--exclude-standard', '-z'/) - assert.match(lastModified, /files\.some\(\(file\) => dirtyFiles\.has\(file\)\)/) + assert.match(lastModified, /semanticChangeEvidence/) + assert.match(lastModified, /latestSemanticGitDate/) + assert.match(lastModified, /files\.filter\(\(file\) => dirtyFiles\.has\(file\)\)/) assert.match(lastModified, /workingTreeDate/) assert.match(lastModified, /--format=%cI/) assert.match(lastModified, /normalizeContentForDigest/) assert.match(lastModified, /semanticRouteFiles/) - assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 6) + assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 7) assert.equal(Object.keys(ROUTE_CONTENT_DIGESTS).length, PUBLIC_PATHS.length) assert.ok(Object.values(ROUTE_CONTENT_DIGESTS).every((digest) => /^[0-9a-f]{64}$/.test(digest))) assert.match(publicRoutes, /\.\.\/data\/catalog\.ts/) diff --git a/site/src/data/routeLastModified.ts b/site/src/data/routeLastModified.ts index 609dcb6..8cb0d07 100644 --- a/site/src/data/routeLastModified.ts +++ b/site/src/data/routeLastModified.ts @@ -1,5 +1,5 @@ // 由 scripts/generate-seo.mjs 从源码时间证据生成,请勿手改。 -export const ROUTE_CONTENT_DIGEST_VERSION = 6 +export const ROUTE_CONTENT_DIGEST_VERSION = 7 export const ROUTE_LAST_MODIFIED: Readonly> = Object.freeze({ "/": "2026-08-22T23:46:11+08:00", "/part/a": "2026-08-22T11:47:40+08:00", From 2f41534adb0fa8639a5092b05290e827d23ff475 Mon Sep 17 00:00:00 2001 From: Shanire Date: Wed, 26 Aug 2026 14:25:17 +0800 Subject: [PATCH 11/23] fix: close transitive semantic evidence gaps Signed-off-by: Shanire --- docs/engineering/architecture.md | 8 +- docs/operations/verification.md | 4 +- site/scripts/last-modified.mjs | 2 +- site/scripts/last-modified.test.mjs | 48 ++ site/scripts/semantic-source-graph.mjs | 561 +++++++++++++++++--- site/scripts/semantic-source-graph.test.mjs | 204 ++++++- site/scripts/seo-contract.test.mjs | 2 +- site/src/data/routeLastModified.ts | 96 ++-- 8 files changed, 776 insertions(+), 149 deletions(-) diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index de9ec50..9cfabd9 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -108,7 +108,13 @@ For a registered public `GET` or `HEAD`, `worker/content-negotiation.js` selects `seoHead.ts` renders the same head contract at build time that `RouteMeta` maintains after client navigation: title, description, citable abstract, robots, canonical, Open Graph, Twitter, and a Schema.org graph. Every indexable page includes Organization and WebSite nodes and states `isAccessibleForFree`; the WebSite node declares a `SearchAction` pointing at the real `?q=` problem search. Lessons add Course/LearningResource/TechArticle with `educationalUse` and an `EducationalAudience`, family pages add CollectionPage plus an ItemList of their ready lessons, other pages add WebPage, and routes with hierarchy add BreadcrumbList. Course duration is deliberately not declared: there is no trustworthy source for it, so `hasCourseInstance` is omitted rather than fabricated. -`site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. `semantic-source-graph.mjs` follows the real SSR injection chain from `entry-server.tsx` through the static application/providers and route tree, together with metadata roots and the exact route page, lesson, and family-art modules. Mixed shell modules use an AST-rooted projection: the route-rendering JSX plus the closure of local declarations, selected runtime import specifiers and their source, and render-phase mutations reached by lexical binding identity are hashed. Control predicates, loop scopes, switch discriminants and case scopes, static-block lexical and `var` scopes, shadowed names, stable direct and object/array/constructor aliases, class helpers, route/outlet bindings, and class-field/member identity remain precise. Assignments, update/delete expressions, non-declaration `for-in`/`for-of` targets, conditional/logical/sequence/constructor mutation targets, mutating receiver methods, and proven unpatched global static target mutators such as `Object.assign` enter the closure. Non-target arguments to those static mutators and all arguments to arbitrary member calls remain ambiguous because getters, proxies, or a same-named custom method may mutate selected state. Relative imported calls fail closed when they could mutate a selected sibling binding, including stable callee aliases, imported member receivers, and equivalent resolved module specifiers. An unknown call that could mutate a selected binding fails closed unless the call is already inside a selected slice. Effect-only callbacks are excluded only when their callee is proven by stable lexical import provenance to be a React effect hook, including named aliases, stable local aliases, default/namespace member calls, and TypeScript-wrapped callbacks; same-named local helpers are not trusted. Any other nested write fails generation and must be refactored instead of silently producing incomplete evidence. Declaration- and specifier-level type-only edges, unselected import siblings, and JSX attribute names do not leak in. The graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Public representation identity is structurally isolated in `config/site.ts`; client endpoints and sidebar-only copy are in modules reachable only through pruned branches. Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. An unchanged digest preserves the previous value; changed content uses the precise Git commit or working-tree timestamp of a semantically changed projection and must advance. During a digest-schema migration, the current projector is applied to historical sources at the previous `lastmod`: a clean committed semantic edit advances, while an edit confined to a pruned client branch does not. Version 7 retains the trusted `2026-08-22` evidence while closing container/constructor aliasing, imported-call provenance, mutator source/monkey-patch, specifier-level type-edge, effect-alias/wrapper, and migration-evidence gaps: the home route uses the visible hero change at `23:46:11+08:00`, and the other 46 routes use the single-origin metadata/content change at `11:47:40+08:00`. Earlier rejected schemas had admitted shared client effects, sidebar-only copyright copy, imported sibling noise, or incomplete execution/scope/mutation context. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. +`site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. `semantic-source-graph.mjs` follows the real SSR injection chain from `entry-server.tsx` through the static application/providers and route tree, together with metadata roots and the exact route page, lesson, and family-art modules. + +Every parsed source contribution is reduced to a runtime AST projection, so TypeScript-only syntax and formatting do not advance public-content evidence. `config/site.ts` has a narrower public projection containing only the brand and origin fields that can change a public representation. Mixed shell modules use an AST-rooted closure: the route-rendering JSX plus local declarations, selected runtime import specifiers and their source, root/program synchronous calls, and render-phase mutations reached by lexical binding identity are hashed. The closure tracks stable direct/container aliases, later property aliases, call/getter-created targets, control-flow scopes, class helpers, route/outlet bindings, and patched or replaced global static mutator owners. Local and relative callable targets are pulled into the closure; unsupported dynamic targets, imported member receivers, custom constructors/setters, arbitrary external mutations, and other ambiguous writes fail closed. + +Effect-only callbacks are excluded only when the callee has one exact, stable React-effect origin through named, default, namespace, member, or destructured aliases. Mixed aliases, reassigned hooks, patched React namespaces, and same-named local helpers are never pruned as effects. Runtime import edges remain while declaration- and specifier-level type-only edges, unselected import siblings, object-spread copies, and JSX attribute names do not leak in. The graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Public representation identity is structurally isolated in `config/site.ts`; client endpoints and sidebar-only copy are in modules reachable only through pruned branches. + +Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. An unchanged digest preserves the previous value; changed content uses the precise Git commit or working-tree timestamp of a semantically changed projection and must advance. During a digest-schema migration, the current projector is applied to historical sources at the previous `lastmod`: a clean committed semantic edit advances, while an edit confined to a pruned client branch does not. Version 8 retains the trusted `2026-08-22` evidence while adding runtime-AST/type erasure, the public-config projection, synchronous-call closure, exact React-effect provenance, and fail-closed dynamic/global mutation handling. It also prevents historical client-only `config/site.ts` fields from falsely advancing the home route. The home route therefore remains at `23:46:11+08:00`, and the other 46 routes remain at `11:47:40+08:00`. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, type-only noise, or incomplete execution/scope/mutation context. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. # Analytics diff --git a/docs/operations/verification.md b/docs/operations/verification.md index b73dc17..b6a1605 100644 --- a/docs/operations/verification.md +++ b/docs/operations/verification.md @@ -44,7 +44,9 @@ Pure TypeScript Modules imported by Node tests must use explicit `.ts` extension # SEO And Accessibility Checks -`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. A line-ending-neutral SHA-256 digest follows each route's real SSR injection chain and exact semantic content graph while pruning navigation, feedback, analytics, theme, and motion-only branches. Mixed shell modules contribute an AST-rooted closure of route-rendering JSX, selected runtime import specifiers plus their source, and render-phase mutations resolved by lexical binding identity. Regression probes cover control predicates, function/loop/switch shadowing, switch discriminants, static-block lexical/`var` scopes, class fields, selected imported-symbol swaps, declaration- and specifier-level type-only edges, unselected import siblings, stable direct/container/constructor aliases, conditional/logical/sequence targets, later assignments, `for-in`/`for-of` targets, delete and object/collection mutations, patched or custom same-named mutators, mutator source arguments, aliased/member/equivalent-path imported ambient calls, route/outlet bindings, React-import-proven effect aliases and wrapped callbacks, fake same-named hooks, and non-reference JSX names. Unknown mutation contexts and ambiguous nested writes outside proven React client-effect callbacks fail closed. Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. An unchanged digest preserves its prior time; changed content uses the exact Git commit or working-tree timestamp of a semantically changed projection and must advance. A schema migration reapplies the current projector to source history at the previous `lastmod`, so clean committed semantic edits advance while dirty changes confined to pruned client branches do not. Version 7 retains the trusted `2026-08-22` evidence while closing container/constructor aliasing, imported-call provenance, mutator source/monkey-patch, specifier-level type-edge, effect-alias/wrapper, and migration-evidence gaps: `23:46:11+08:00` for the home hero change and `11:47:40+08:00` for the other 46 routes' latest single-origin metadata/content change. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, or incomplete execution/scope/mutation context. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. +`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. Version 8 follows each route's real SSR injection chain with a line-ending-neutral runtime-AST digest: TypeScript-only syntax is erased, public `config/site.ts` evidence is field-projected, and mixed shell modules contribute route-rendering JSX plus the lexical closure of runtime imports, root/program synchronous calls, aliases, and render-phase mutations. Regression probes cover scope/control flow, stable and late aliases, spread-copy precision, call/getter-created targets, local and cross-module callables, `Reflect.apply`, patched/replaced global mutators, exact React-effect aliases, mixed or patched hooks, and historical public-config projection. Unsupported dynamic targets, imported member receivers, custom constructors/setters, arbitrary external mutations, and ambiguous nested writes outside proven React client-effect callbacks fail closed. + +Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. An unchanged digest preserves its prior time; changed content uses the exact Git commit or working-tree timestamp of a semantically changed projection and must advance. A schema migration reapplies the current projector to source history at the previous `lastmod`, so clean committed semantic edits advance while dirty changes confined to pruned client branches do not. Version 8 retains `23:46:11+08:00` for the home hero change and `11:47:40+08:00` for the other 46 routes; the historical regression proves that removed client-only fields in the older site config do not advance home evidence. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. The browser gate runs Chromium against built `dist/` through a strict, non-reused custom preview server. Route tests directly open `/`, `/part/a`, `/part/a/01`, `/method`, and `/part/g/plug`, then exercise live client navigation and keyboard focus. They check HTTP status, prerendered HTML, absence of pending streamed-Suspense placeholders, route CSS at first paint, hydration without errors, CLS below `0.05`, title/description/abstract/canonical/zero-hreflang/Open Graph/JSON-LD metadata, one visible `h1`, route announcements, current-page semantics, initial-load focus, changed-route focus, and skip-link focus. Navigation-positioning tests click a representative lesson outline in each of the seven families, restore a direct lesson fragment, verify the mobile outline and an ordinary family-page fragment, and require a changed route to reset old-page scroll immediately. An unknown path must return 404 with `noindex,nofollow`, no canonical, and the themed not-found heading. Worker-level Node tests separately cover the Accept matrix, 406 behavior, HEAD parity, representation-specific ETags, hidden internal assets, and unaffected API/static/unknown paths. diff --git a/site/scripts/last-modified.mjs b/site/scripts/last-modified.mjs index d6aa714..288040b 100644 --- a/site/scripts/last-modified.mjs +++ b/site/scripts/last-modified.mjs @@ -9,7 +9,7 @@ import { } from './semantic-source-graph.mjs' const projectRoot = fileURLToPath(new URL('../../', import.meta.url)) -export const ROUTE_CONTENT_DIGEST_VERSION = 7 +export const ROUTE_CONTENT_DIGEST_VERSION = 8 const historicalSourceCache = new Map() const headSourceCache = new Map() diff --git a/site/scripts/last-modified.test.mjs b/site/scripts/last-modified.test.mjs index 3ec12ca..fd7d6b1 100644 --- a/site/scripts/last-modified.test.mjs +++ b/site/scripts/last-modified.test.mjs @@ -1,4 +1,6 @@ import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' import test from 'node:test' import { @@ -105,4 +107,50 @@ test('schema migrations distinguish pruned client edits from semantic edits', () ), true, ) + + assert.equal( + semanticProjectionChanged( + 'site/src/lib/pageMeta.ts', + `interface Meta { readonly title: string }\nexport const value = '正文'`, + `interface Meta { title: string }\nexport const value = '正文'`, + ), + false, + ) + + const currentSiteConfig = readFileSync( + new URL('../src/config/site.ts', import.meta.url), + 'utf8', + ) + const historicalSiteConfig = execFileSync( + 'git', + ['show', '359245c:site/src/config/site.ts'], + { encoding: 'utf8', cwd: new URL('../../', import.meta.url) }, + ) + assert.equal( + semanticProjectionChanged( + 'site/src/config/site.ts', + currentSiteConfig, + historicalSiteConfig, + ), + false, + ) +}) + +test('historical client-effect maintenance stays outside public lastmod evidence', () => { + for (const file of [ + 'site/src/components/demos/knapsack/KnapsackDemo.tsx', + 'site/src/components/dp-engine/playback/useStepPlayer.ts', + ]) { + const before = execFileSync( + 'git', + ['show', `6cc3a36^:${file}`], + { cwd: new URL('../../', import.meta.url), encoding: 'utf8' }, + ) + const after = execFileSync( + 'git', + ['show', `6cc3a36:${file}`], + { cwd: new URL('../../', import.meta.url), encoding: 'utf8' }, + ) + assert.equal(semanticProjectionChanged(file, after, before), false, file) + } }) diff --git a/site/scripts/semantic-source-graph.mjs b/site/scripts/semantic-source-graph.mjs index 9324edf..a3dbd84 100644 --- a/site/scripts/semantic-source-graph.mjs +++ b/site/scripts/semantic-source-graph.mjs @@ -68,6 +68,21 @@ const EXPRESSION_WRAPPERS = new Set([ 'TSSatisfiesExpression', 'TSTypeAssertion', ]) +const TYPE_ONLY_KEYS = new Set([ + 'accessibility', + 'abstract', + 'declare', + 'definite', + 'implements', + 'importKind', + 'optional', + 'override', + 'readonly', + 'returnType', + 'typeAnnotation', + 'typeArguments', + 'typeParameters', +]) function isNonSemanticFile(path) { return ( @@ -93,6 +108,132 @@ function childNodes(node) { return children } +function projectRuntimeObject(value, overrides = {}, options = {}) { + const projected = {} + const source = { ...value, ...overrides } + for (const key of Object.keys(source).sort()) { + if ( + key === 'end' + || key === 'loc' + || key === 'raw' + || key === 'start' + || TYPE_ONLY_KEYS.has(key) + ) continue + const child = runtimeAstProjection(source[key], options) + if (child !== null) projected[key] = child + } + return projected +} + +function runtimeAstProjection(value, options = {}) { + if (Array.isArray(value)) { + return value + .map((item) => runtimeAstProjection(item, options)) + .filter((item) => item !== null) + } + if (typeof value === 'string') { + return value.replaceAll('\r\n', '\n').replaceAll('\r', '\n') + } + if (!value || typeof value !== 'object') return value + if (value.type === 'CallExpression' && options.isClientEffectCall?.(value)) { + return projectRuntimeObject(value, { + arguments: [ + { type: 'ClientEffectCallback' }, + ...(value.arguments?.slice(1) ?? []), + ], + }, options) + } + if (EXPRESSION_WRAPPERS.has(value.type)) { + return runtimeAstProjection(value.expression, options) + } + if (value.type === 'TSParameterProperty') { + return runtimeAstProjection(value.parameter, options) + } + if ( + typeof value.type === 'string' + && value.type.startsWith('TS') + && value.type !== 'TSEnumDeclaration' + && value.type !== 'TSModuleDeclaration' + ) return null + if (value.type === 'ImportDeclaration') { + const specifiers = value.specifiers.filter((specifier) => specifier.importKind !== 'type') + if (value.importKind === 'type' || (value.specifiers.length > 0 && specifiers.length === 0)) { + return null + } + return projectRuntimeObject(value, { specifiers }, options) + } + if (value.type === 'ExportNamedDeclaration') { + const specifiers = value.specifiers.filter((specifier) => specifier.exportKind !== 'type') + if (value.exportKind === 'type' || (value.specifiers.length > 0 && specifiers.length === 0)) { + return null + } + return projectRuntimeObject(value, { specifiers }, options) + } + if (value.type === 'ExportAllDeclaration' && value.exportKind === 'type') return null + + return projectRuntimeObject(value, {}, options) +} + +function objectExpression(node) { + if (node?.type === 'ObjectExpression') return node + if ( + node?.type === 'CallExpression' + && node.callee.type === 'MemberExpression' + && node.callee.object.type === 'Identifier' + && node.callee.object.name === 'Object' + && memberNameFromNode(node.callee) === 'freeze' + ) return objectExpression(node.arguments?.[0]) + return null +} + +function memberNameFromNode(member) { + if (member?.type !== 'MemberExpression') return null + if (!member.computed && member.property.type === 'Identifier') return member.property.name + if ( + member.computed + && (member.property.type === 'StringLiteral' || member.property.type === 'Literal') + ) return member.property.value + return null +} + +function publicSiteConfigProjection(program) { + const selectedKeys = new Map([ + ['BRAND', new Set(['name', 'owner', 'subtitle'])], + ['SITE', new Set(['language', 'origin'])], + ]) + const projection = {} + for (const statement of program.body) { + const declaration = statement.type === 'ExportNamedDeclaration' + ? statement.declaration + : statement + if (declaration?.type !== 'VariableDeclaration') continue + for (const declarator of declaration.declarations) { + if (declarator.id.type !== 'Identifier') continue + if (declarator.id.name === 'SITE_ORIGIN') { + projection.SITE_ORIGIN = runtimeAstProjection(declarator.init) + continue + } + const keys = selectedKeys.get(declarator.id.name) + if (!keys) continue + const object = objectExpression(declarator.init) + if (!object) throw new Error(`Expected ${declarator.id.name} to be an object literal`) + projection[declarator.id.name] = object.properties.flatMap((property) => { + if (property.type !== 'Property') return [] + const name = property.key.type === 'Identifier' + ? property.key.name + : property.key.value + return keys.has(name) + ? [{ name, value: runtimeAstProjection(property.value) }] + : [] + }) + } + } + if (!projection.BRAND || !projection.SITE || !projection.SITE_ORIGIN) { + throw new Error('Missing public site configuration projection') + } + return projection +} + function bindingIdentifiers(pattern, identifiers = []) { if (!pattern || typeof pattern !== 'object') return identifiers if (pattern.type === 'Identifier') { @@ -156,9 +297,11 @@ function semanticBindingGraph(program, path) { const nodeParents = new WeakMap() const declaredIdentifiers = new WeakSet() const importedBindingsByIdentity = new Map() + const synchronousCalls = [] const unresolvedMutations = [] function declare(pattern, scope, semanticNode, metadata = {}) { + const { valueOrigins = [], ...bindingMetadata } = metadata for (const identifier of bindingIdentifiers(pattern)) { declaredIdentifiers.add(identifier) const existing = scope.bindings.get(identifier.name) @@ -167,9 +310,11 @@ function semanticBindingGraph(program, path) { } else { const binding = { aliasUnstable: false, + dynamicIdentity: false, executionScope: nearestFunctionScope(scope), identifier, - ...metadata, + valueOrigins: new Set(valueOrigins), + ...bindingMetadata, semanticNodes: new Set(semanticNode ? [semanticNode] : []), writes: [], } @@ -192,15 +337,23 @@ function semanticBindingGraph(program, path) { || node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression' ) { - if (node.type === 'FunctionDeclaration' && node.id) declare(node.id, scope, node) + if (node.type === 'FunctionDeclaration' && node.id) { + declare(node.id, scope, node, { valueOrigins: ['other'] }) + } const functionScope = createScope(scope, 'function', node) if (node.type === 'FunctionExpression' && node.id) { - declare(node.id, functionScope, node) + declare(node.id, functionScope, node, { valueOrigins: ['other'] }) + } + for (const parameter of node.params ?? []) { + declare(parameter, functionScope, parameter, { valueOrigins: ['other'] }) } - for (const parameter of node.params ?? []) declare(parameter, functionScope, parameter) if (node.id) build(node.id, functionScope, node, 'id') for (const parameter of node.params ?? []) build(parameter, functionScope, node, 'params') if (node.body) build(node.body, functionScope, node, 'body', true) + for (const [child, childKey] of childNodes(node)) { + if (child === node.id || child === node.body || childKey === 'params') continue + build(child, functionScope, node, childKey) + } return } if (node.type === 'BlockStatement') { @@ -234,13 +387,17 @@ function semanticBindingGraph(program, path) { return } if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') { - if (node.type === 'ClassDeclaration' && node.id) declare(node.id, scope, node) + if (node.type === 'ClassDeclaration' && node.id) { + declare(node.id, scope, node, { valueOrigins: ['other'] }) + } const classScope = createScope(scope, 'class') if (node.type === 'ClassDeclaration' && node.id) { const classBinding = scope.bindings.get(node.id.name) if (classBinding) classScope.bindings.set(node.id.name, classBinding) } - if (node.type === 'ClassExpression' && node.id) declare(node.id, classScope, node) + if (node.type === 'ClassExpression' && node.id) { + declare(node.id, classScope, node, { valueOrigins: ['other'] }) + } for (const [child, childKey] of childNodes(node)) { build(child, child === node.id ? scope : classScope, node, childKey) } @@ -275,6 +432,13 @@ function semanticBindingGraph(program, path) { importIdentity: canonicalImportIdentity(path, node.source?.value), importSource: node.source?.value, importedName, + valueOrigins: node.source?.value === 'react' + && CLIENT_EFFECT_CALLEES.has(importedName) + ? ['react-effect'] + : node.source?.value === 'react' + && (importedName === '*' || importedName === 'default') + ? ['react-namespace'] + : ['other'], }) const binding = scope.bindings.get(specifier.local.name) if (binding && node.source) { @@ -352,7 +516,6 @@ function semanticBindingGraph(program, path) { } else if (node.type === 'ObjectExpression') { for (const property of node.properties) { if (property.type === 'Property') identityBindings(property.value, bindings) - if (property.type === 'SpreadElement') identityBindings(property.argument, bindings) } } else if (node.type === 'NewExpression') { for (const argument of node.arguments ?? []) identityBindings(argument, bindings) @@ -399,6 +562,29 @@ function semanticBindingGraph(program, path) { return false } + function hasDynamicInitializer(node) { + if (!node || typeof node !== 'object') return false + if (node.type === 'CallExpression' || node.type === 'NewExpression') return true + if (node.type === 'ObjectExpression') { + return node.properties.some((property) => ( + property.type === 'Property' + && (property.kind === 'get' || property.kind === 'set' || hasDynamicInitializer(property.value)) + )) + } + if (node.type === 'ArrayExpression') return node.elements.some(hasDynamicInitializer) + if (node.type === 'ConditionalExpression') { + return hasDynamicInitializer(node.consequent) || hasDynamicInitializer(node.alternate) + } + if (node.type === 'LogicalExpression') { + return hasDynamicInitializer(node.left) || hasDynamicInitializer(node.right) + } + if (node.type === 'SequenceExpression') { + return node.expressions.some(hasDynamicInitializer) + } + if (EXPRESSION_WRAPPERS.has(node.type)) return hasDynamicInitializer(node.expression) + return false + } + function mergeAliases(bindings, unstable = false) { const uniqueBindings = [...new Set(bindings)] if (uniqueBindings.length < 2) return @@ -434,17 +620,24 @@ function semanticBindingGraph(program, path) { function indexAliases(node) { if (node.type === 'VariableDeclarator' && node.init) { + const targets = reassignedBindings(node.id) + if (hasDynamicInitializer(node.init)) { + for (const target of targets) target.dynamicIdentity = true + } mergeAliases([ - ...reassignedBindings(node.id), + ...targets, ...identityBindings(node.init), ]) } if (node.type === 'AssignmentExpression' && node.operator === '=') { const targets = reassignedBindings(node.left) for (const target of targets) { - if (target.aliases.size < 2) continue + target.aliasUnstable = true for (const binding of target.aliases) binding.aliasUnstable = true } + if (hasDynamicInitializer(node.right)) { + for (const target of targets) target.dynamicIdentity = true + } mergeAliases([ ...targets, ...identityBindings(node.right), @@ -454,12 +647,136 @@ function semanticBindingGraph(program, path) { } indexAliases(program) + function exactOrigin(binding, origin) { + return binding?.valueOrigins.size === 1 && binding.valueOrigins.has(origin) + } + + function expressionValueOrigins(node) { + if (!node || typeof node !== 'object') return new Set(['other']) + if (node.type === 'Identifier') { + const binding = lookup(node) + return binding ? new Set(binding.valueOrigins) : new Set(['other']) + } + if (node.type === 'MemberExpression') { + const objectOrigins = expressionValueOrigins(node.object) + return objectOrigins.size === 1 + && objectOrigins.has('react-namespace') + && CLIENT_EFFECT_CALLEES.has(memberNameFromNode(node)) + ? new Set(['react-effect']) + : new Set(['other']) + } + if (node.type === 'ConditionalExpression') { + return new Set([ + ...expressionValueOrigins(node.consequent), + ...expressionValueOrigins(node.alternate), + ]) + } + if (node.type === 'LogicalExpression') { + return new Set([ + ...expressionValueOrigins(node.left), + ...expressionValueOrigins(node.right), + ]) + } + if (node.type === 'SequenceExpression') { + return new Set(node.expressions.flatMap((expression) => [ + ...expressionValueOrigins(expression), + ])) + } + if (EXPRESSION_WRAPPERS.has(node.type)) return expressionValueOrigins(node.expression) + return new Set(['other']) + } + + function addValueOrigins(bindings, origins) { + let changed = false + for (const binding of bindings) { + for (const origin of origins) { + if (binding.valueOrigins.has(origin)) continue + binding.valueOrigins.add(origin) + changed = true + } + } + return changed + } + + function indexValueOrigins() { + let changed = true + while (changed) { + changed = false + function visit(node) { + if (node.type === 'VariableDeclarator' && node.init) { + const initOrigins = expressionValueOrigins(node.init) + if ( + node.id.type === 'ObjectPattern' + && initOrigins.size === 1 + && initOrigins.has('react-namespace') + ) { + for (const property of node.id.properties) { + if (property.type !== 'Property') continue + const name = property.key.type === 'Identifier' + ? property.key.name + : property.key.value + changed = addValueOrigins( + reassignedBindings(property.value), + new Set(CLIENT_EFFECT_CALLEES.has(name) ? ['react-effect'] : ['other']), + ) || changed + } + } else { + changed = addValueOrigins(reassignedBindings(node.id), initOrigins) || changed + } + } + if (node.type === 'AssignmentExpression' && node.operator === '=') { + changed = addValueOrigins( + reassignedBindings(node.left), + expressionValueOrigins(node.right), + ) || changed + } + for (const [child] of childNodes(node)) visit(child) + } + visit(program) + } + } + indexValueOrigins() + + const patchedReactBindings = new Set() + function indexPatchedReactBindings(node) { + const target = node.type === 'AssignmentExpression' + ? node.left + : node.type === 'UpdateExpression' + ? node.argument + : node.type === 'UnaryExpression' && node.operator === 'delete' + ? node.argument + : null + if (target?.type === 'MemberExpression') { + for (const binding of identityBindings(target.object)) { + if (!exactOrigin(binding, 'react-namespace')) continue + for (const alias of binding.aliases) patchedReactBindings.add(alias) + } + } + const possiblyEscapedValues = [ + ...(node.type === 'CallExpression' ? node.arguments ?? [] : []), + ...(node.type === 'AssignmentExpression' && isMemberMutationTarget(node.left) + ? [node.right] + : []), + ] + for (const value of possiblyEscapedValues) { + for (const binding of identityBindings(value)) { + if (!exactOrigin(binding, 'react-namespace')) continue + for (const alias of binding.aliases) patchedReactBindings.add(alias) + } + } + for (const [child] of childNodes(node)) indexPatchedReactBindings(child) + } + indexPatchedReactBindings(program) + function memberName(member) { if (member.type !== 'MemberExpression') return null if (!member.computed && member.property.type === 'Identifier') { return member.property.name } - if (member.computed && member.property.type === 'StringLiteral') { + if ( + member.computed + && (member.property.type === 'StringLiteral' || member.property.type === 'Literal') + ) { return member.property.value } return null @@ -472,10 +789,8 @@ function semanticBindingGraph(program, path) { return Boolean( binding && !binding.aliasUnstable - && [...binding.aliases].some((alias) => ( - alias.importSource === 'react' - && CLIENT_EFFECT_CALLEES.has(alias.importedName) - )), + && exactOrigin(binding, 'react-effect') + && ![...binding.aliases].some((alias) => patchedReactBindings.has(alias)), ) } if (callee.type !== 'MemberExpression') return false @@ -486,10 +801,8 @@ function semanticBindingGraph(program, path) { return Boolean( binding && !binding.aliasUnstable - && [...binding.aliases].some((alias) => ( - alias.importSource === 'react' - && (alias.importedName === '*' || alias.importedName === 'default') - )), + && exactOrigin(binding, 'react-namespace') + && ![...binding.aliases].some((alias) => patchedReactBindings.has(alias)), ) } @@ -560,13 +873,12 @@ function semanticBindingGraph(program, path) { function indexPatchedStaticOwners(node) { if (node.type === 'AssignmentExpression' || node.type === 'UpdateExpression') { - const owner = mutationMemberOwner( - node.type === 'AssignmentExpression' ? node.left : node.argument, - ) + const target = node.type === 'AssignmentExpression' ? node.left : node.argument + const owner = mutationMemberOwner(target) ?? globalStaticOwner(target) if (owner) patchedStaticOwners.add(owner) } if (node.type === 'UnaryExpression' && node.operator === 'delete') { - const owner = mutationMemberOwner(node.argument) + const owner = mutationMemberOwner(node.argument) ?? globalStaticOwner(node.argument) if (owner) patchedStaticOwners.add(owner) } if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') { @@ -579,6 +891,19 @@ function semanticBindingGraph(program, path) { ) { patchedStaticOwners.add(globalStaticOwner(node.arguments[0])) } + const replacedOwner = ( + node.arguments?.[1]?.type === 'StringLiteral' + || node.arguments?.[1]?.type === 'Literal' + ) + && STATIC_TARGET_MUTATORS.has(node.arguments[1].value) + && unboundGlobalContainer(node.arguments?.[0]) + && ( + (owner === 'Object' && (method === 'defineProperty' || method === 'defineProperties')) + || (owner === 'Reflect' && method === 'set') + ) + ? node.arguments[1].value + : null + if (replacedOwner) patchedStaticOwners.add(replacedOwner) } for (const [child] of childNodes(node)) indexPatchedStaticOwners(child) } @@ -587,10 +912,25 @@ function semanticBindingGraph(program, path) { function indexWrites(node) { const executionScope = nearestFunctionScope(nodeScopes.get(node)) const semanticNode = enclosingExecutionStatement(node, executionScope) - const record = (target, kind = 'write', { propagateAliases = true } = {}) => { + const record = ( + target, + kind = 'write', + { propagateAliases = true, trackDynamic = true } = {}, + ) => { const directBindings = new Set(writtenBindings(target)) - if (directBindings.size === 0 && containsDynamicIdentity(target)) { - unresolvedMutations.push({ executionScope, mutationNode: node }) + if ( + trackDynamic + && ( + (directBindings.size === 0 && containsDynamicIdentity(target)) + || [...directBindings].some((binding) => binding.dynamicIdentity) + ) + ) { + unresolvedMutations.push({ + executionScope, + message: 'Unsupported dynamic mutation target', + mutationNode: node, + semanticNode, + }) } const aliasedBindings = propagateAliases ? new Set([...directBindings].flatMap((binding) => [...binding.aliases])) @@ -611,6 +951,9 @@ function semanticBindingGraph(program, path) { record(node.left, 'write', { propagateAliases: isMemberMutationTarget(node.left), }) + if (isMemberMutationTarget(node.left)) { + record(node.right, 'ambiguous-mutation', { trackDynamic: false }) + } } if (node.type === 'UpdateExpression') { record(node.argument, 'write', { @@ -628,6 +971,60 @@ function semanticBindingGraph(program, path) { } if (node.type === 'CallExpression' && !isReactEffectCall(node)) { const callee = node.callee + const calleeInputs = callee.type === 'MemberExpression' + ? [callee.object] + : [callee] + const isGlobalReflectApply = callee.type === 'MemberExpression' + && callee.object.type === 'Identifier' + && callee.object.name === 'Reflect' + && !lookup(callee.object) + && memberName(callee) === 'apply' + const reflectApplyCallable = isGlobalReflectApply + && !patchedStaticOwners.has('Reflect') + ? node.arguments?.[0] + : null + const invocationInputs = [ + callee.type === 'MemberExpression' ? callee.object : callee, + ...(reflectApplyCallable ? [reflectApplyCallable] : []), + ] + const calleeBindings = new Set( + calleeInputs.flatMap((input) => identityBindings(input)), + ) + const invocationBindings = new Set( + invocationInputs.flatMap((input) => identityBindings(input)), + ) + const calleeAliases = new Set( + [...calleeBindings].flatMap((binding) => [...binding.aliases]), + ) + const invocationAliases = new Set( + [...invocationBindings].flatMap((binding) => [...binding.aliases]), + ) + const callableIsInspectable = [...invocationAliases].some((binding) => ( + !binding.importSource || binding.importSource.startsWith('.') + )) + if ([...calleeAliases].some((binding) => binding.dynamicIdentity)) { + unresolvedMutations.push({ + executionScope, + message: 'Unsupported dynamic call target', + mutationNode: node, + semanticNode, + }) + } + if (isGlobalReflectApply && patchedStaticOwners.has('Reflect')) { + unresolvedMutations.push({ + executionScope, + message: 'Unsupported patched Reflect.apply target', + mutationNode: node, + semanticNode, + }) + } + if (callableIsInspectable) { + synchronousCalls.push({ + executionScope, + mutationNode: node, + semanticNode, + }) + } const method = callee.type === 'MemberExpression' ? memberName(callee) : null const staticOwner = callee.type === 'MemberExpression' && callee.object.type === 'Identifier' @@ -641,15 +1038,19 @@ function semanticBindingGraph(program, path) { if (isStaticTargetMutator) { record(node.arguments?.[0]) for (const argument of node.arguments?.slice(1) ?? []) { - record(argument, 'ambiguous-mutation') + record(argument, 'ambiguous-mutation', { trackDynamic: false }) } } else if (callee.type === 'MemberExpression') { record( callee.object, MUTATING_MEMBER_CALLEES.has(method) ? 'write' : 'ambiguous-mutation', + { trackDynamic: false }, ) - for (const argument of node.arguments ?? []) { - record(argument, 'ambiguous-mutation') + const possiblyMutatedArguments = reflectApplyCallable + ? node.arguments?.slice(1) ?? [] + : node.arguments ?? [] + for (const argument of possiblyMutatedArguments) { + record(argument, 'ambiguous-mutation', { trackDynamic: false }) } const receiverBindings = new Set(identityBindings(callee.object)) const receiverAliases = new Set( @@ -673,8 +1074,10 @@ function semanticBindingGraph(program, path) { } } } else { - for (const argument of node.arguments ?? []) { - record(argument, 'ambiguous-mutation') + if (!callableIsInspectable) { + for (const argument of node.arguments ?? []) { + record(argument, 'ambiguous-mutation', { trackDynamic: false }) + } } if (callee.type === 'Identifier') { const calleeBinding = lookup(callee) @@ -699,6 +1102,13 @@ function semanticBindingGraph(program, path) { } } } + if (node.type === 'VariableDeclarator' && node.init?.type === 'ObjectExpression') { + for (const property of node.init.properties) { + if (property.type === 'SpreadElement') { + record(property.argument, 'write', { trackDynamic: false }) + } + } + } for (const [child] of childNodes(node)) indexWrites(child) } indexWrites(program) @@ -754,43 +1164,56 @@ function semanticBindingGraph(program, path) { } function isClientOnlyEffectScope(scope) { - const functionNode = scope.ownerNode - let callbackNode = functionNode - let relation = nodeParents.get(callbackNode) - while ( - relation - && EXPRESSION_WRAPPERS.has(relation.parent.type) - && relation.key === 'expression' - ) { - callbackNode = relation.parent - relation = nodeParents.get(callbackNode) + let currentScope = scope + while (currentScope) { + const functionNode = currentScope.ownerNode + if (functionNode?.type === 'Program') return false + let callbackNode = functionNode + let relation = nodeParents.get(callbackNode) + while ( + relation + && EXPRESSION_WRAPPERS.has(relation.parent.type) + && relation.key === 'expression' + ) { + callbackNode = relation.parent + relation = nodeParents.get(callbackNode) + } + if ( + relation + && relation.key === 'arguments' + && relation.parent.type === 'CallExpression' + && isReactEffectCall(relation.parent) + ) return true + currentScope = currentScope.parent } - return Boolean( - relation - && relation.key === 'arguments' - && relation.parent.type === 'CallExpression' - && isReactEffectCall(relation.parent), - ) + return false } return { childNodes, executionScopeFor: (node) => nearestFunctionScope(nodeScopes.get(node)), isClientOnlyEffectScope, + isClientEffectCall: isReactEffectCall, isReference, lookup, + synchronousCalls, unresolvedMutations, } } export function semanticSourceForDigest(path, source) { const rootName = SEMANTIC_ELEMENT_ROOTS.get(path) - if (!rootName) return source + if (!PARSED_EXTENSIONS.has(extname(path))) return source const { program, errors } = parseSync(path, source) if (errors.length > 0) { throw new Error(`Unable to parse semantic source ${path}: ${errors[0].message}`) } + if (path === 'site/src/config/site.ts') { + return JSON.stringify(publicSiteConfigProjection(program)) + } const bindingGraph = semanticBindingGraph(program, path) + const projectionOptions = { isClientEffectCall: bindingGraph.isClientEffectCall } + if (!rootName) return JSON.stringify(runtimeAstProjection(program, projectionOptions)) const roots = [] function findRoots(node) { if ( @@ -808,6 +1231,20 @@ export function semanticSourceForDigest(path, source) { const rootExecutionScope = bindingGraph.executionScopeFor(roots[0]) const visitedBindings = new Set() const pendingNodes = [roots[0]] + const executesDuringRootRender = (entry) => ( + entry.executionScope === rootExecutionScope + || entry.executionScope.ownerNode?.type === 'Program' + ) + for (const entry of [ + ...bindingGraph.synchronousCalls, + ...bindingGraph.unresolvedMutations, + ]) { + if (!executesDuringRootRender(entry)) continue + if (bindingGraph.isClientOnlyEffectScope(entry.executionScope)) continue + if (slices.has(entry.semanticNode)) continue + slices.add(entry.semanticNode) + pendingNodes.push(entry.semanticNode) + } while (pendingNodes.length > 0) { const selectedNode = pendingNodes.pop() function collectReferences(node) { @@ -824,22 +1261,22 @@ export function semanticSourceForDigest(path, source) { pendingNodes.push(semanticNode) } for (const write of binding.writes) { + if (bindingGraph.isClientOnlyEffectScope(write.executionScope)) continue + if (write.kind === 'ambiguous-mutation') { + throw new Error( + `Unsupported ambiguous mutation of ${binding.identifier.name} in ${path}`, + ) + } const mutationAlreadySelected = [...slices].some((slice) => ( slice.start <= write.mutationNode.start && slice.end >= write.mutationNode.end )) if (mutationAlreadySelected) continue - if (bindingGraph.isClientOnlyEffectScope(write.executionScope)) continue if (write.kind === 'ambient-mutation') { throw new Error( `Unsupported ambient mutation from ${write.ambientCallee} in ${path}`, ) } - if (write.kind === 'ambiguous-mutation') { - throw new Error( - `Unsupported ambiguous mutation of ${binding.identifier.name} in ${path}`, - ) - } if ( write.executionScope !== binding.executionScope && write.executionScope !== rootExecutionScope @@ -860,18 +1297,6 @@ export function semanticSourceForDigest(path, source) { collectReferences(selectedNode) } - for (const write of bindingGraph.unresolvedMutations) { - if (write.executionScope !== rootExecutionScope) continue - if (bindingGraph.isClientOnlyEffectScope(write.executionScope)) continue - const mutationAlreadySelected = [...slices].some((slice) => ( - slice.start <= write.mutationNode.start - && slice.end >= write.mutationNode.end - )) - if (!mutationAlreadySelected) { - throw new Error(`Unsupported dynamic mutation target in ${path}`) - } - } - const orderedSlices = [...slices] .sort((left, right) => left.start - right.start) const outermostSlices = orderedSlices.filter((node, index) => { @@ -882,9 +1307,9 @@ export function semanticSourceForDigest(path, source) { && other.end >= end )) }) - return outermostSlices - .map((node) => source.slice(node.start, node.end)) - .join('\n') + return JSON.stringify(outermostSlices.map((node) => ( + runtimeAstProjection(node, projectionOptions) + ))) } function projectPath(path) { diff --git a/site/scripts/semantic-source-graph.test.mjs b/site/scripts/semantic-source-graph.test.mjs index 03df687..25fd3bc 100644 --- a/site/scripts/semantic-source-graph.test.mjs +++ b/site/scripts/semantic-source-graph.test.mjs @@ -149,6 +149,37 @@ test('mixed shell modules hash their AST-backed render dependencies', () => { ) }) +test('runtime AST projection ignores proven client-effect callback bodies', () => { + const before = `import { useEffect } from 'react'\nexport function Demo({ value }) { useEffect(() => { queueMicrotask(() => sync('客户端一')) }, [value]); return

{value}

}` + const afterClientChange = before.replace('客户端一', '客户端二') + const afterDependencyChange = before.replace('[value]', '[value, enabled]') + const fakeHookBefore = `function useEffect(callback) { callback() }\nexport function Demo() { useEffect(() => sync('同步一')); return

正文

}` + const patchedNamespaceBefore = `import * as React from 'react'\nObject.assign(React, { useEffect: callback => callback() })\nexport function Demo() { React.useEffect(() => sync('同步一')); return

正文

}` + + assert.equal( + semanticSourceForDigest('site/src/components/Demo.tsx', before), + semanticSourceForDigest('site/src/components/Demo.tsx', afterClientChange), + ) + assert.notEqual( + semanticSourceForDigest('site/src/components/Demo.tsx', before), + semanticSourceForDigest('site/src/components/Demo.tsx', afterDependencyChange), + ) + assert.notEqual( + semanticSourceForDigest('site/src/components/Demo.tsx', fakeHookBefore), + semanticSourceForDigest( + 'site/src/components/Demo.tsx', + fakeHookBefore.replace('同步一', '同步二'), + ), + ) + assert.notEqual( + semanticSourceForDigest('site/src/components/Demo.tsx', patchedNamespaceBefore), + semanticSourceForDigest( + 'site/src/components/Demo.tsx', + patchedNamespaceBefore.replace('同步一', '同步二'), + ), + ) +}) + test('mixed-module closure follows lexical symbols instead of bare names', () => { const shadowBefore = `const label = '正文一'\nfunction AppContent() { return {label} }\nfunction clientOnly() { const label = '客户端一'; return label }` const shadowAfterSemanticChange = shadowBefore.replace('正文一', '正文二') @@ -191,12 +222,15 @@ test('mixed-module closure follows lexical symbols instead of bare names', () => ) const ambiguousHelperWrite = `let label = '正文'\nfunction prepare() { label = '变化' }\nfunction AppContent() { prepare(); return {label} }` - assert.throws( - () => semanticSourceForDigest( + assert.notEqual( + semanticSourceForDigest( 'site/src/app/AppContent.tsx', ambiguousHelperWrite, ), - /Unsupported nested semantic write to label/, + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + ambiguousHelperWrite.replace('变化', '变化二'), + ), ) const attributeBefore = `const path = '客户端一'\nfunction AppContent() { return }` @@ -290,7 +324,7 @@ test('mixed-module closure tracks every supported mutation of selected bindings' semanticSourceForDigest('site/src/app/AppContent.tsx', deleteAfter), ) - const pushBefore = `const items = []\nfunction AppContent() { items.push('正文一'); return {items.join(',')} }` + const pushBefore = `const items = []\nfunction AppContent() { items.push('正文一'); return {items[0]} }` const pushAfter = pushBefore.replace('正文一', '正文二') assert.notEqual( semanticSourceForDigest('site/src/app/AppContent.tsx', pushBefore), @@ -336,6 +370,19 @@ test('mixed-module closure tracks every supported mutation of selected bindings' ) } + const lateContainerAlias = `const state = { value: '正文' }\nconst holder = {}\nholder.current = state\nfunction AppContent() { holder.current.value = '变化'; return {state.value} }` + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', lateContainerAlias), + /Unsupported ambiguous mutation of state/, + ) + + const spreadCopyBefore = `const state = { value: '正文' }\nconst holder = { current: { ...state } }\nfunction AppContent() { holder.current.value = '副本一'; return {state.value} }` + const spreadCopyAfter = spreadCopyBefore.replace('副本一', '副本二') + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', spreadCopyBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', spreadCopyAfter), + ) + const aliasRebindBefore = `const state = { value: '正文' }\nlet alias = state\nfunction AppContent() { alias = { value: '客户端一' }; return {state.value} }` const aliasRebindAfter = aliasRebindBefore.replace('客户端一', '客户端二') assert.equal( @@ -377,25 +424,53 @@ test('mixed-module closure tracks every supported mutation of selected bindings' const ambiguousArgumentCall = `const state = { value: '正文' }\nfunction mutate(value) { value.value = '变化' }\nfunction AppContent() { mutate(state); return {state.value} }` - assert.throws( - () => semanticSourceForDigest('site/src/app/AppContent.tsx', ambiguousArgumentCall), - /Unsupported ambiguous mutation of state/, + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', ambiguousArgumentCall), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + ambiguousArgumentCall.replace('变化', '变化二'), + ), ) const importedClosureCall = `import { state, prepare } from './store'\nfunction AppContent() { prepare(); return {state.value} }` + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', importedClosureCall), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + importedClosureCall.replace('prepare(); ', ''), + ), + ) + + for (const [call, importedAliasCall] of [ + ['run(); ', `import { state, prepare } from './store'\nconst run = prepare\nfunction AppContent() { run(); return {state.value} }`], + ['prepare(); ', `import { state } from './store'\nimport { prepare } from './store/index'\nfunction AppContent() { prepare(); return {state.value} }`], + ]) { + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', importedAliasCall), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + importedAliasCall.replace(call, ''), + ), + ) + } + + const importedMemberCall = `import { state, controller } from './store'\nfunction AppContent() { controller.prepare(); return {state.value} }` assert.throws( - () => semanticSourceForDigest('site/src/app/AppContent.tsx', importedClosureCall), - /Unsupported ambient mutation from prepare/, + () => semanticSourceForDigest('site/src/app/AppContent.tsx', importedMemberCall), + /Unsupported ambiguous mutation of controller/, ) - for (const importedAliasCall of [ - `import { state, prepare } from './store'\nconst run = prepare\nfunction AppContent() { run(); return {state.value} }`, - `import { state, controller } from './store'\nfunction AppContent() { controller.prepare(); return {state.value} }`, - `import { state } from './store'\nimport { prepare } from './store/index'\nfunction AppContent() { prepare(); return {state.value} }`, + for (const [call, crossModuleCall] of [ + ['prepare(); ', `import { state } from './store'\nimport { prepare } from './prepare'\nfunction AppContent() { prepare(); return {state.value} }`], + ['prepare(); ', `import { state } from './store'\nimport { prepare } from './barrel'\nfunction AppContent() { prepare(); return {state.value} }`], + ['Reflect.apply(prepare, null, []); ', `import { state, prepare } from './store'\nfunction AppContent() { Reflect.apply(prepare, null, []); return {state.value} }`], ]) { - assert.throws( - () => semanticSourceForDigest('site/src/app/AppContent.tsx', importedAliasCall), - /Unsupported ambient mutation/, + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', crossModuleCall), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + crossModuleCall.replace(call, ''), + ), ) } @@ -413,9 +488,39 @@ test('mixed-module closure tracks every supported mutation of selected bindings' ) const dynamicTarget = `const state = { value: '正文' }\nfunction current() { return state }\nfunction AppContent() { current().value = '变化'; return {state.value} }` + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', dynamicTarget), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + dynamicTarget.replace('变化', '变化二'), + ), + ) + + for (const hiddenDynamicTarget of [ + `const state = { value: '正文' }\nfunction current() { return state }\ncurrent().value = '变化'\nfunction AppContent() { return {state.value} }`, + `const state = { value: '正文' }\nfunction current() { return state }\nfunction prepare() { current().value = '变化' }\nfunction AppContent() { prepare(); return {state.value} }`, + `const state = { value: '正文' }\nfunction current() { return state }\nfunction AppContent() { const alias = current(); alias.value = '变化'; return {state.value} }`, + `const state = { value: '正文' }\nconst holder = { get current() { return state } }\nfunction AppContent() { holder.current.value = '变化'; return {state.value} }`, + ]) { + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', hiddenDynamicTarget), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + hiddenDynamicTarget.replace('变化', '变化二'), + ), + ) + } + + const customConstructorTarget = `const state = { value: '正文' }\nclass Holder { constructor() { this.current = state } }\nconst holder = new Holder()\nfunction AppContent() { holder.current.value = '变化'; return {state.value} }` + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', customConstructorTarget), + /Unsupported ambiguous mutation of state/, + ) + + const dynamicImportedGetter = `import { state, prepare } from './store'\nconst controller = { get run() { return prepare } }\nfunction AppContent() { controller.run(); return {state.value} }` assert.throws( - () => semanticSourceForDigest('site/src/app/AppContent.tsx', dynamicTarget), - /Unsupported dynamic mutation target/, + () => semanticSourceForDigest('site/src/app/AppContent.tsx', dynamicImportedGetter), + /Unsupported ambiguous mutation of controller/, ) const customNamedMutator = `const state = { value: '正文' }\nconst mutator = { set(target, value) { target.value = value } }\nfunction AppContent() { mutator.set(state, '正文一'); return {state.value} }` @@ -427,7 +532,7 @@ test('mixed-module closure tracks every supported mutation of selected bindings' const shadowedObjectMutator = `const state = { value: '正文' }\nconst Object = { assign(target, source) { target.value = source.value } }\nfunction AppContent() { Object.assign(state, { value: '正文一' }); return {state.value} }` assert.throws( () => semanticSourceForDigest('site/src/app/AppContent.tsx', shadowedObjectMutator), - /Unsupported ambiguous mutation of state/, + /Unsupported ambiguous mutation of Object/, ) const patchedGlobalObject = `const state = { value: '正文' }\nconst other = {}\nObject.assign = (_target, source) => { source.value = '变化' }\nfunction AppContent() { Object.assign(other, state); return {state.value} }` @@ -436,15 +541,24 @@ test('mixed-module closure tracks every supported mutation of selected bindings' /Unsupported ambiguous mutation of state/, ) - for (const indirectGlobalPatch of [ - `const state = { value: '正文' }\nglobalThis.Object.assign = target => { target.value = '变化' }\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`, - `const state = { value: '正文' }\nconst BuiltinObject = Object\nBuiltinObject.assign = target => { target.value = '变化' }\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`, + for (const [name, indirectGlobalPatch] of [ + ['member', `const state = { value: '正文' }\nglobalThis.Object.assign = target => { target.value = '变化' }\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`], + ['alias', `const state = { value: '正文' }\nconst BuiltinObject = Object\nBuiltinObject.assign = target => { target.value = '变化' }\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`], + ['owner', `const state = { value: '正文' }\nglobalThis.Object = { assign(target) { target.value = '变化' } }\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`], + ['defineProperty', `const state = { value: '正文' }\nObject.defineProperty(globalThis, 'Object', { value: { assign(target) { target.value = '变化' } } })\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`], ]) { assert.throws( () => semanticSourceForDigest('site/src/app/AppContent.tsx', indirectGlobalPatch), /Unsupported ambiguous mutation of state/, + name, ) } + + const patchedReflectApply = `import { state, prepare } from './store'\nglobalThis.Reflect = { apply(callable) { return callable() } }\nfunction AppContent() { Reflect.apply(prepare, null, []); return {state.value} }` + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', patchedReflectApply), + /Unsupported ambiguous mutation of prepare/, + ) }) test('mixed-module closure recognizes React effects by import provenance', () => { @@ -479,6 +593,32 @@ test('mixed-module closure recognizes React effects by import provenance', () => semanticSourceForDigest('site/src/app/AppContent.tsx', stableAliasAfter), ) + for (const memberAliasBefore of [ + `import * as React from 'react'\nconst useAfterRender = React.useEffect\nfunction AppContent() { let label = '正文'; useAfterRender(() => { label = '客户端一' }, []); return {label} }`, + `import React from 'react'\nconst { useEffect: useAfterRender } = React\nfunction AppContent() { let label = '正文'; useAfterRender(() => { label = '客户端一' }, []); return {label} }`, + ]) { + const memberAliasAfter = memberAliasBefore.replace('客户端一', '客户端二') + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', memberAliasBefore), + semanticSourceForDigest('site/src/app/AppContent.tsx', memberAliasAfter), + ) + } + + const mixedEffectAlias = `import { useEffect } from 'react'\nfunction runNow(callback) { callback() }\nconst afterRender = false ? useEffect : runNow\nfunction AppContent() { let label = '正文'; afterRender(() => { label = '同步变化' }, []); return {label} }` + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', mixedEffectAlias), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + mixedEffectAlias.replace('同步变化', '同步变化二'), + ), + ) + + const patchedReactEffect = `import * as React from 'react'\nReact.useEffect = callback => callback()\nfunction AppContent() { let label = '正文'; React.useEffect(() => { label = '同步变化' }, []); return {label} }` + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', patchedReactEffect), + /Unsupported nested semantic write to label/, + ) + const wrappedCallbackBefore = `import { useEffect } from 'react'\nfunction AppContent() { let label = '正文'; useEffect((() => { label = '客户端一' }) as () => void, []); return {label} }` const wrappedCallbackAfter = wrappedCallbackBefore.replace('客户端一', '客户端二') assert.equal( @@ -487,21 +627,27 @@ test('mixed-module closure recognizes React effects by import provenance', () => ) const fakeLocalHook = `function useEffect(callback) { callback() }\nfunction AppContent() { let label = '正文'; useEffect(() => { label = '同步变化' }); return {label} }` - assert.throws( - () => semanticSourceForDigest('site/src/app/AppContent.tsx', fakeLocalHook), - /Unsupported nested semantic write to label/, + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', fakeLocalHook), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + fakeLocalHook.replace('同步变化', '同步变化二'), + ), ) const fakeMemberHook = `const scheduler = { useEffect(callback) { callback() } }\nfunction AppContent() { let label = '正文'; scheduler.useEffect(() => { label = '同步变化' }); return {label} }` assert.throws( () => semanticSourceForDigest('site/src/app/AppContent.tsx', fakeMemberHook), - /Unsupported nested semantic write to label/, + /Unsupported ambiguous mutation of scheduler/, ) const shadowedReactHook = `import { useEffect } from 'react'\nfunction AppContent() { function useEffect(callback) { callback() } let label = '正文'; useEffect(() => { label = '同步变化' }); return {label} }` - assert.throws( - () => semanticSourceForDigest('site/src/app/AppContent.tsx', shadowedReactHook), - /Unsupported nested semantic write to label/, + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', shadowedReactHook), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + shadowedReactHook.replace('同步变化', '同步变化二'), + ), ) }) diff --git a/site/scripts/seo-contract.test.mjs b/site/scripts/seo-contract.test.mjs index 4c19fe6..c2b4e33 100644 --- a/site/scripts/seo-contract.test.mjs +++ b/site/scripts/seo-contract.test.mjs @@ -232,7 +232,7 @@ test('discovery files expose the 47 approved URLs and real summaries', async () assert.match(lastModified, /--format=%cI/) assert.match(lastModified, /normalizeContentForDigest/) assert.match(lastModified, /semanticRouteFiles/) - assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 7) + assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 8) assert.equal(Object.keys(ROUTE_CONTENT_DIGESTS).length, PUBLIC_PATHS.length) assert.ok(Object.values(ROUTE_CONTENT_DIGESTS).every((digest) => /^[0-9a-f]{64}$/.test(digest))) assert.match(publicRoutes, /\.\.\/data\/catalog\.ts/) diff --git a/site/src/data/routeLastModified.ts b/site/src/data/routeLastModified.ts index 8cb0d07..1fa5464 100644 --- a/site/src/data/routeLastModified.ts +++ b/site/src/data/routeLastModified.ts @@ -1,5 +1,5 @@ // 由 scripts/generate-seo.mjs 从源码时间证据生成,请勿手改。 -export const ROUTE_CONTENT_DIGEST_VERSION = 7 +export const ROUTE_CONTENT_DIGEST_VERSION = 8 export const ROUTE_LAST_MODIFIED: Readonly> = Object.freeze({ "/": "2026-08-22T23:46:11+08:00", "/part/a": "2026-08-22T11:47:40+08:00", @@ -50,51 +50,51 @@ export const ROUTE_LAST_MODIFIED: Readonly> = Object.free "/problems": "2026-08-22T11:47:40+08:00" }) export const ROUTE_CONTENT_DIGESTS: Readonly> = Object.freeze({ - "/": "5ee01321113b2bc6114f23aa9074069d2953ba73ab2ccb9eca2ef5ae523f61c5", - "/part/a": "72c4a7036d5084cb3f6cc18e2bf25d37c3948b673f98c1baf89102ba11299269", - "/part/b": "59d8201af9b168f64c248518a851030b76bfb15c585d3604109eaf454b75cc33", - "/part/c": "330a3a3ac656790a731b6d7f077555fb7125637617723b0dcb42db875c415ef1", - "/part/d": "c51bcacb7c122248d74c843f0fdeaec44f1472400f78ec2d65af2309e3a2d92d", - "/part/e": "c5a95324fec99c7f0d36b7536653325469dd9dd91b05d4756d991f660904dd93", - "/part/f": "0d991f2b99ab1da13c02430fa992f7b6d52d59fdf35dfa9c00af7fa7b0d3a9ae", - "/part/g": "3632cb5992e7a3e79173c60314af732a1132b04ecb601ed058e0c4076d55af03", - "/part/a/01": "c99ad7e9b531abb46f4fd9b9ac4ceed5fa07686e9062f85f27d41b353e19382c", - "/part/a/complete": "f18ca241aad5b96f75442779ffdccea01eaada241b19aff83095be216b753a96", - "/part/a/multiple": "152f475087e614a50de4d6f58e3eb8e0b6abcacdb928da68b2a60723b78824ad", - "/part/a/group": "aaaf95502b37fd832e200bb6e0107bfc0aff0f252a8f755a80639992d013398e", - "/part/a/mixed": "ec7a548e51bee6dec3dd94bdf6e43c117893ce1d5d2eab03077111b9a4d51e59", - "/part/a/cost2d": "e0428be648844ba7092d487c636637ca58a0aec434e63d7ea32db05aa6a52086", - "/part/a/dep": "73e4497be79d37c881f2da8c82fc1bc43e17fb2a99a1e42c0044fb7e888ee376", - "/part/a/variant": "623fed8dffd767a87c777e1930c67be4d5b1000187ae79115caf1365121ed030", - "/part/a/fractional": "b51c879188035c1958e487bf66e220fa8b16c15d72ccac89f40eebf0773fbbc1", - "/part/b/path": "4b17e283e496e044ebdb4b11a16049b451b107d00e6c048d9558cb85b8faf27c", - "/part/b/maxseg": "beef95f4f2a9218befa0ad90746eb1f3d18d3b212a71e3cf780067aa563b9f7d", - "/part/b/lis": "b482b525fad6d24f62758a10a8efae52b29a8d1af7419c53884554fd4ea0ee89", - "/part/b/lcs": "e52aa4fd3506e31f850d812742ac369c63431e3ce2a0a66685f8cb8dd1ef22cd", - "/part/b/edit": "dfdd17c46f2a28a108b55e7525dcc199993be2590cef818719bbd55d6142b672", - "/part/b/fsm": "59729f9848fa945f923f2c74a9a7b472be89597220088a376034de83c012af3f", - "/part/b/count": "176d681ec7f9620bc595ee9f573e8f289145407276f8c74f3ac51384e8c9a1ea", - "/part/c/stone": "9777cbc55d8a44acb0962b7d497bd2428cd8a5449adf57cecd07fd4be926303c", - "/part/c/ring": "7e8cf47ee57749eff71c4bde1911d87bb53426e3aa39c2313d79d02c41f8f704", - "/part/c/palindrome": "edf64420f3faef955ec2430d1ba6f69bffb067bbd1a9b573c9c43ebb2189746f", - "/part/c/tree": "75f16f723d2b5b1177869ded3f17d06628c23ffeb3d26f5771f7f6e591120154", - "/part/c/merge": "7cd0273abca3c8f5a3207d6ba458cce70aedf9378d54420f8e7b8f55f43ef5b8", - "/part/d/grid": "27c05982bc73d92ffba2d95fde47d88234218695fc22cd223931cbc171c3dca0", - "/part/d/matpow": "de91dbdff2e5981ef00273fbe0441949de83310ea0b7917e8d1607a965975e27", - "/part/e/basic": "4575a14e8f92f1421ca7b10f824ec2602727324aa60231ecb5d1424dd2c69856", - "/part/e/distsum": "5ace623f100e02a5b4455913e9926f752328e31dd483e66be6edc50821cb2e21", - "/part/e/inout": "5eba02160c4af126fa647c918fe2809e5b881c8f216f3aaa2ed057821e6a7112", - "/part/e/center": "8f308afe90625821fd8950e654df499b3a1306dea18755b1b5e15c1065ee1a08", - "/part/f/select": "ade80f83d3eb9dcd7f7ec8fd73dfc08d046041d31540d4d370e15484d9daf840", - "/part/f/knapsack": "9a831730d6a7055235be3cf19a8fe1cfc509017f3810fc2644912b2c3e7827ff", - "/part/f/diameter": "272ee07dd94d0f89e9b10546da733009d9e7cc67f266bfac1ecde3a7060afd47", - "/part/f/cover": "62461089f56ae04e2fb90ed6d1304e6592697093e8519db8db4c1bca2e2f717d", - "/part/f/count": "f24a14b3264238b8426b85de438ae6b72d19b98b8eccf0fad85a766da2e40438", - "/part/g/board": "55d3cb9abd41c0302e4e503b282e1c13f9211fe8dd3951acf4ecd204488c118a", - "/part/g/tsp": "9eaa458c16cfb914e9b456b6c58d5448f32995c8b6ee11132b426413d421973a", - "/part/g/cover": "04387e6bef8db2aaea5e85d2a166dc8fdf5e9552f1dfb250142c3bd4cfb8bcd4", - "/part/g/subset": "4ed1029b5eba57878473796018fde3a0ba983ae98d8108e8e14efbb4bdd8e0aa", - "/part/g/plug": "ee35568516123794eac663226b1fe286d219cfafdd6264546257428fc58a5d5b", - "/method": "2848183eaeaf219fb07e5ab4ccca337cc3c46511807d546d1b2694a93fae38de", - "/problems": "ccaa2b93cef10715e6f7433c738a39567987ecbd1ebac54deac2214aeaeb9fab" + "/": "1c7858402d9c4047cefc647a73e66cc217e6044b1e98a47c265920b87a280d4a", + "/part/a": "120920f752aeda06480825fe8426acf1286fb12ed50ebb6b132e68974147d8bf", + "/part/b": "1f9f43482e384c6353b665998ef044748cff0dfd23a0de743057e9b1e13304eb", + "/part/c": "40863170861ca3180f1d1e5d99b8889d920ef6f0ff4c49a5bc355e59c35ed657", + "/part/d": "3ac23127c71ae0875f4dd2ed9736775dbf8b7e9c69d80bc7b7aa8be04455e1d4", + "/part/e": "75731c476b329c590699a468eec66ed85791c7cf39b41ff7a97c19c270ff8d8d", + "/part/f": "2d929c2f6ece8236546ded325c5b98012d7dcc43e43445c43738fa588d93fa00", + "/part/g": "09c2bffef2ef39e38d5c22b2cacbf397425741f8ca9022345253f3a6d5524b65", + "/part/a/01": "036fd844e6e8d3aa89860a2592f6f5c6d8575e529a86c7f48f5148b5b8251cee", + "/part/a/complete": "6f07959072b523a791bd80f4216d09d1b1a02b53ae882835134f8ba546cdef28", + "/part/a/multiple": "673b36428e64c9e1bc181c2563ea342fd469e714a8094f56a95f3358887c3e56", + "/part/a/group": "b056573cf98d07058d594f8064196f5d5e7d10355cabfca5d3e9c9a8312c0a6c", + "/part/a/mixed": "08ea26e06b4cbfb00b997c25615fc23b2d9e422b7caa0af2f2e0854764a3a7a5", + "/part/a/cost2d": "2262fc138d011a852698091547846113c1a734a709556d51f9a66be5f476044a", + "/part/a/dep": "72ea3c7aaf9df5d56b9a696841830c33b69642cc663f6369481446ed8fdb567a", + "/part/a/variant": "aea292d2fe4fb49d8086194d416d9089185e12cc352108c140cfac40199e4b99", + "/part/a/fractional": "1685da81996a6e4f86e468a1149c86e068f3dffd1c50d29edf044846943d11b5", + "/part/b/path": "6f922b990a9e652319a115255c535ac60cea44775901e55300e9fa670939ebec", + "/part/b/maxseg": "01adc5f3ecd405e5e7a5dac1ffd18e5b76eff1644d8a50924214aa2b2ed25963", + "/part/b/lis": "2406b2f09590d943f8a7c713e287db5f954498f94ad01ae82de03d8c6760799c", + "/part/b/lcs": "ef4b7c72627c663cea62adc3a297f68bfe2cbdc6cf89d8c1c0a45a5827ea9836", + "/part/b/edit": "0034d3ae2b2a149471b334c7489f4684c6d22fc4e24cb5777f6a28e56dcb3888", + "/part/b/fsm": "1f603e286fc8baf43a4a1137128a8e88d8d4450fd948e24ae44213406f97c9dd", + "/part/b/count": "62ae2d2c665092e08dd6a4e76100f42f7d6676a5619713137261b4cab17acd12", + "/part/c/stone": "b89611adaa8b694fa4ddb000405a3cc5b8f103ccb6f1708895f7fcb02871f7ae", + "/part/c/ring": "a1fc57c51976c7452e1f247bae747c30ff5e0eeb21aef827a58f4b1a08cb0c4c", + "/part/c/palindrome": "4c59c482df1a533db341ce3926df577c106f7b229517495c5021e3f17ff0aad3", + "/part/c/tree": "eccd7e1db5cc6d2551689db48d6268211fd06f0d5e547b83dc930ea0af045490", + "/part/c/merge": "bb3fbb45ce888fbeaab1184fce454a5b01ca1fe59d80551364a3124e0e6446f1", + "/part/d/grid": "4d7a205fb2fb75f67e1b40bd4bc7b706c73445ec114604d04effb27054ce5332", + "/part/d/matpow": "db9791896b4973bea490a680122e7a4a5e05f7f594f79fc1c3bc405901e40900", + "/part/e/basic": "cfac9dd939971f7ba0818c1d892d69b8d557608de926c1f987af7e86a2606a50", + "/part/e/distsum": "ba091e96c12fde09fb97439dcfe95f08c2c310022e408a5d70a343e38eeca93a", + "/part/e/inout": "5603360c4447948c53c2e9a8cb1cc4e90528de18d41471d8c144f63506faebae", + "/part/e/center": "785496f4f76d2d1320a7ffc5c3e26920355b9765fd09eb1b485021d2fc2214fa", + "/part/f/select": "7b297683ee5a5a446aa403db3236e04cb0d496bd4e9bd0fead79eae758962d52", + "/part/f/knapsack": "b49819c2d6f3cde9a2ee0aa5c65ac5af84341443fb86f46fd19d8f00e688fa53", + "/part/f/diameter": "87638f355475535cabdc0ba51c9d268aaede2f4425d1b8d24760198888122a40", + "/part/f/cover": "52adb23202557a44ef1e34e0f737e917154c488aea668772c9bb2942e8752d9c", + "/part/f/count": "712b08b528be941ba7ff520c6ec1201018295163f1dadb75881a2da498715263", + "/part/g/board": "8e160b34972b19fee7f43361f8c789589908813a1328bbc884a8a0c5192c3ee2", + "/part/g/tsp": "9b0e21a6e9d31586dd418c44bd447919fa7ff36a6aafbc929f599cee5794268d", + "/part/g/cover": "6b7a9425802bf69c9288c16aa42298c0fca347b733629e8b62c620c47ec2df71", + "/part/g/subset": "8184312ac3dce6009c93be86c41515f56683d7fa272a0882245e50178a1ae9b8", + "/part/g/plug": "9d847f0c495d6cf996c848bea618d290735c1df9b0834627e9a3c2463b906b76", + "/method": "6aff15cf7963a3f511ce1f04fdfe41e61d65836545607bdaa1ee4e7ed6f396a8", + "/problems": "4f5db57e4627f94f37b0562819caadf1633fca1065b7e8cf0b19bb344992e44d" }) From 6c98fcc460b29819e9b3908e4593ed1bc1ecaf53 Mon Sep 17 00:00:00 2001 From: Shanire Date: Wed, 26 Aug 2026 15:18:21 +0800 Subject: [PATCH 12/23] fix: harden semantic evidence boundaries Signed-off-by: Shanire --- docs/engineering/architecture.md | 4 +- docs/operations/verification.md | 4 +- site/scripts/last-modified.mjs | 2 +- site/scripts/semantic-source-graph.mjs | 421 +++++++++++++++++--- site/scripts/semantic-source-graph.test.mjs | 147 ++++++- site/scripts/seo-contract.test.mjs | 2 +- site/src/data/routeLastModified.ts | 96 ++--- 7 files changed, 555 insertions(+), 121 deletions(-) diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index 9cfabd9..da4bb88 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -110,11 +110,11 @@ For a registered public `GET` or `HEAD`, `worker/content-negotiation.js` selects `site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. `semantic-source-graph.mjs` follows the real SSR injection chain from `entry-server.tsx` through the static application/providers and route tree, together with metadata roots and the exact route page, lesson, and family-art modules. -Every parsed source contribution is reduced to a runtime AST projection, so TypeScript-only syntax and formatting do not advance public-content evidence. `config/site.ts` has a narrower public projection containing only the brand and origin fields that can change a public representation. Mixed shell modules use an AST-rooted closure: the route-rendering JSX plus local declarations, selected runtime import specifiers and their source, root/program synchronous calls, and render-phase mutations reached by lexical binding identity are hashed. The closure tracks stable direct/container aliases, later property aliases, call/getter-created targets, control-flow scopes, class helpers, route/outlet bindings, and patched or replaced global static mutator owners. Local and relative callable targets are pulled into the closure; unsupported dynamic targets, imported member receivers, custom constructors/setters, arbitrary external mutations, and other ambiguous writes fail closed. +Every parsed source contribution is reduced to a runtime AST projection, so TypeScript-only syntax and formatting do not advance public-content evidence while runtime-bearing TypeScript parameter properties and optional-chain semantics remain visible. `config/site.ts` has a narrower public projection containing only the brand and origin fields that can change a public representation. That projection follows immutable local bindings, object spreads, and static computed keys; mutable, escaped, cyclic, or dynamic dependencies fail closed. Mixed shell modules use an AST-rooted closure: the route-rendering JSX plus local declarations, selected runtime and side-effect imports, root/program synchronous calls, callback-capable external calls, constructors, and render-phase mutations reached by lexical binding identity are hashed. The closure tracks stable direct/container aliases, destructuring defaults without conflating object-rest copies, later property aliases, call/getter-created targets, control-flow scopes, class helpers, route/outlet bindings, and patched or replaced React/global static owners. Local and relative callable or constructable targets are pulled into the closure; unsupported dynamic targets, imported member receivers, arbitrary external mutations, and other ambiguous writes fail closed. Effect-only callbacks are excluded only when the callee has one exact, stable React-effect origin through named, default, namespace, member, or destructured aliases. Mixed aliases, reassigned hooks, patched React namespaces, and same-named local helpers are never pruned as effects. Runtime import edges remain while declaration- and specifier-level type-only edges, unselected import siblings, object-spread copies, and JSX attribute names do not leak in. The graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Public representation identity is structurally isolated in `config/site.ts`; client endpoints and sidebar-only copy are in modules reachable only through pruned branches. -Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. An unchanged digest preserves the previous value; changed content uses the precise Git commit or working-tree timestamp of a semantically changed projection and must advance. During a digest-schema migration, the current projector is applied to historical sources at the previous `lastmod`: a clean committed semantic edit advances, while an edit confined to a pruned client branch does not. Version 8 retains the trusted `2026-08-22` evidence while adding runtime-AST/type erasure, the public-config projection, synchronous-call closure, exact React-effect provenance, and fail-closed dynamic/global mutation handling. It also prevents historical client-only `config/site.ts` fields from falsely advancing the home route. The home route therefore remains at `23:46:11+08:00`, and the other 46 routes remain at `11:47:40+08:00`. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, type-only noise, or incomplete execution/scope/mutation context. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. +Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. An unchanged digest preserves the previous value; changed content uses the precise Git commit or working-tree timestamp of a semantically changed projection and must advance. During a digest-schema migration, the current projector is applied to historical sources at the previous `lastmod`: a clean committed semantic edit advances, while an edit confined to a pruned client branch does not. Version 9 retains the trusted `2026-08-22` evidence while closing constructor, parameter-property, optional-chain, callback, destructuring, side-effect-import, React/global-owner alias, and public-config dependency gaps. It also prevents historical client-only `config/site.ts` fields from falsely advancing the home route. The home route therefore remains at `23:46:11+08:00`, and the other 46 routes remain at `11:47:40+08:00`. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, type-only noise, or incomplete execution/scope/mutation context. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. # Analytics diff --git a/docs/operations/verification.md b/docs/operations/verification.md index b6a1605..1449496 100644 --- a/docs/operations/verification.md +++ b/docs/operations/verification.md @@ -44,9 +44,9 @@ Pure TypeScript Modules imported by Node tests must use explicit `.ts` extension # SEO And Accessibility Checks -`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. Version 8 follows each route's real SSR injection chain with a line-ending-neutral runtime-AST digest: TypeScript-only syntax is erased, public `config/site.ts` evidence is field-projected, and mixed shell modules contribute route-rendering JSX plus the lexical closure of runtime imports, root/program synchronous calls, aliases, and render-phase mutations. Regression probes cover scope/control flow, stable and late aliases, spread-copy precision, call/getter-created targets, local and cross-module callables, `Reflect.apply`, patched/replaced global mutators, exact React-effect aliases, mixed or patched hooks, and historical public-config projection. Unsupported dynamic targets, imported member receivers, custom constructors/setters, arbitrary external mutations, and ambiguous nested writes outside proven React client-effect callbacks fail closed. +`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. Version 9 follows each route's real SSR injection chain with a line-ending-neutral runtime-AST digest: type-only syntax is erased without dropping runtime-bearing TypeScript parameter properties or optional-chain behavior; public `config/site.ts` evidence follows immutable field dependencies; and mixed shell modules contribute route-rendering JSX plus the lexical closure of runtime and side-effect imports, root/program synchronous and callback-capable calls, constructors, aliases, and render-phase mutations. Regression probes cover scope/control flow, stable and late aliases, destructuring defaults, object-rest and spread-copy precision, call/getter/constructor-created targets, local and cross-module callables, external callbacks, `Reflect.apply`, patched/replaced global mutators, exact React-effect aliases, mixed or patched hooks, and historical public-config projection. Unsupported mutable or dynamic config, dynamic targets, imported member receivers, arbitrary external mutations, and ambiguous nested writes outside proven React client-effect callbacks fail closed. -Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. An unchanged digest preserves its prior time; changed content uses the exact Git commit or working-tree timestamp of a semantically changed projection and must advance. A schema migration reapplies the current projector to source history at the previous `lastmod`, so clean committed semantic edits advance while dirty changes confined to pruned client branches do not. Version 8 retains `23:46:11+08:00` for the home hero change and `11:47:40+08:00` for the other 46 routes; the historical regression proves that removed client-only fields in the older site config do not advance home evidence. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. +Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. An unchanged digest preserves its prior time; changed content uses the exact Git commit or working-tree timestamp of a semantically changed projection and must advance. A schema migration reapplies the current projector to source history at the previous `lastmod`, so clean committed semantic edits advance while dirty changes confined to pruned client branches do not. Version 9 retains `23:46:11+08:00` for the home hero change and `11:47:40+08:00` for the other 46 routes; the historical regression proves that removed client-only fields in the older site config do not advance home evidence. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. The browser gate runs Chromium against built `dist/` through a strict, non-reused custom preview server. Route tests directly open `/`, `/part/a`, `/part/a/01`, `/method`, and `/part/g/plug`, then exercise live client navigation and keyboard focus. They check HTTP status, prerendered HTML, absence of pending streamed-Suspense placeholders, route CSS at first paint, hydration without errors, CLS below `0.05`, title/description/abstract/canonical/zero-hreflang/Open Graph/JSON-LD metadata, one visible `h1`, route announcements, current-page semantics, initial-load focus, changed-route focus, and skip-link focus. Navigation-positioning tests click a representative lesson outline in each of the seven families, restore a direct lesson fragment, verify the mobile outline and an ordinary family-page fragment, and require a changed route to reset old-page scroll immediately. An unknown path must return 404 with `noindex,nofollow`, no canonical, and the themed not-found heading. Worker-level Node tests separately cover the Accept matrix, 406 behavior, HEAD parity, representation-specific ETags, hidden internal assets, and unaffected API/static/unknown paths. diff --git a/site/scripts/last-modified.mjs b/site/scripts/last-modified.mjs index 288040b..b38708c 100644 --- a/site/scripts/last-modified.mjs +++ b/site/scripts/last-modified.mjs @@ -9,7 +9,7 @@ import { } from './semantic-source-graph.mjs' const projectRoot = fileURLToPath(new URL('../../', import.meta.url)) -export const ROUTE_CONTENT_DIGEST_VERSION = 8 +export const ROUTE_CONTENT_DIGEST_VERSION = 9 const historicalSourceCache = new Map() const headSourceCache = new Map() diff --git a/site/scripts/semantic-source-graph.mjs b/site/scripts/semantic-source-graph.mjs index a3dbd84..f19bede 100644 --- a/site/scripts/semantic-source-graph.mjs +++ b/site/scripts/semantic-source-graph.mjs @@ -60,7 +60,6 @@ const STATIC_TARGET_MUTATORS = new Map([ ])], ]) const EXPRESSION_WRAPPERS = new Set([ - 'ChainExpression', 'ParenthesizedExpression', 'TSAsExpression', 'TSInstantiationExpression', @@ -75,7 +74,6 @@ const TYPE_ONLY_KEYS = new Set([ 'definite', 'implements', 'importKind', - 'optional', 'override', 'readonly', 'returnType', @@ -117,6 +115,9 @@ function projectRuntimeObject(value, overrides = {}, options = {}) { || key === 'loc' || key === 'raw' || key === 'start' + || (key === 'optional' + && value.type !== 'CallExpression' + && value.type !== 'MemberExpression') || TYPE_ONLY_KEYS.has(key) ) continue const child = runtimeAstProjection(source[key], options) @@ -147,7 +148,7 @@ function runtimeAstProjection(value, options = {}) { return runtimeAstProjection(value.expression, options) } if (value.type === 'TSParameterProperty') { - return runtimeAstProjection(value.parameter, options) + return projectRuntimeObject(value, {}, options) } if ( typeof value.type === 'string' @@ -174,18 +175,6 @@ function runtimeAstProjection(value, options = {}) { return projectRuntimeObject(value, {}, options) } -function objectExpression(node) { - if (node?.type === 'ObjectExpression') return node - if ( - node?.type === 'CallExpression' - && node.callee.type === 'MemberExpression' - && node.callee.object.type === 'Identifier' - && node.callee.object.name === 'Object' - && memberNameFromNode(node.callee) === 'freeze' - ) return objectExpression(node.arguments?.[0]) - return null -} - function memberNameFromNode(member) { if (member?.type !== 'MemberExpression') return null if (!member.computed && member.property.type === 'Identifier') return member.property.name @@ -201,36 +190,182 @@ function publicSiteConfigProjection(program) { ['BRAND', new Set(['name', 'owner', 'subtitle'])], ['SITE', new Set(['language', 'origin'])], ]) - const projection = {} + const bindings = new Map() + const bindingKinds = new Map() for (const statement of program.body) { const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement if (declaration?.type !== 'VariableDeclaration') continue for (const declarator of declaration.declarations) { - if (declarator.id.type !== 'Identifier') continue - if (declarator.id.name === 'SITE_ORIGIN') { - projection.SITE_ORIGIN = runtimeAstProjection(declarator.init) + if (declarator.id.type === 'Identifier' && declarator.init) { + bindings.set(declarator.id.name, declarator.init) + bindingKinds.set(declarator.id.name, declaration.kind) + } + } + } + + const unsupported = (detail) => { + throw new Error(`Unsupported public site configuration: ${detail}`) + } + const usedBindings = new Set() + const bindingValue = (name) => { + if (bindingKinds.get(name) !== 'const') return unsupported(`mutable binding ${name}`) + usedBindings.add(name) + return bindings.get(name) + } + const unwrap = (node) => ( + node && EXPRESSION_WRAPPERS.has(node.type) ? unwrap(node.expression) : node + ) + const staticKey = (node, stack = new Set()) => { + const value = unwrap(node) + if (value?.type === 'Literal' || value?.type === 'StringLiteral') { + return typeof value.value === 'string' ? value.value : unsupported('non-string key') + } + if (value?.type === 'Identifier' && bindings.has(value.name)) { + if (stack.has(value.name)) return unsupported(`cyclic binding ${value.name}`) + return staticKey(bindingValue(value.name), new Set([...stack, value.name])) + } + return unsupported('dynamic computed key') + } + const propertyKey = (property, stack) => ( + property.computed + ? staticKey(property.key, stack) + : property.key.type === 'Identifier' + ? property.key.name + : property.key.value + ) + const resolveObject = (node, stack = new Set()) => { + const value = unwrap(node) + if (value?.type === 'Identifier' && bindings.has(value.name)) { + if (stack.has(value.name)) return unsupported(`cyclic binding ${value.name}`) + return resolveObject(bindingValue(value.name), new Set([...stack, value.name])) + } + if ( + value?.type === 'CallExpression' + && value.callee.type === 'MemberExpression' + && value.callee.object.type === 'Identifier' + && value.callee.object.name === 'Object' + && !bindings.has('Object') + && memberNameFromNode(value.callee) === 'freeze' + && value.arguments?.length === 1 + ) return resolveObject(value.arguments[0], stack) + if (value?.type !== 'ObjectExpression') return unsupported('expected a static object') + const properties = new Map() + for (const property of value.properties) { + if (property.type === 'SpreadElement') { + for (const [name, spreadValue] of resolveObject(property.argument, stack)) { + properties.set(name, spreadValue) + } continue } - const keys = selectedKeys.get(declarator.id.name) - if (!keys) continue - const object = objectExpression(declarator.init) - if (!object) throw new Error(`Expected ${declarator.id.name} to be an object literal`) - projection[declarator.id.name] = object.properties.flatMap((property) => { - if (property.type !== 'Property') return [] - const name = property.key.type === 'Identifier' - ? property.key.name - : property.key.value - return keys.has(name) - ? [{ name, value: runtimeAstProjection(property.value) }] - : [] - }) + if (property.type !== 'Property' || property.kind !== 'init' || property.method) { + return unsupported('getter, setter, or method property') + } + properties.set(propertyKey(property, stack), property.value) } + return properties } + const projectValue = (node, stack = new Set()) => { + const value = unwrap(node) + if (value?.type === 'Identifier' && bindings.has(value.name)) { + if (stack.has(value.name)) return unsupported(`cyclic binding ${value.name}`) + return projectValue(bindingValue(value.name), new Set([...stack, value.name])) + } + if (value?.type === 'MemberExpression') { + const name = memberNameFromNode(value) + if (name === null) return unsupported('dynamic member lookup') + if ( + value.object.type === 'Identifier' + && selectedKeys.get(value.object.name)?.has(name) + ) return runtimeAstProjection(value) + const memberValue = resolveObject(value.object, stack).get(name) + if (!memberValue) return unsupported(`missing member ${name}`) + return projectValue(memberValue, stack) + } + if ( + value?.type === 'Literal' + || value?.type === 'StringLiteral' + || (value?.type === 'TemplateLiteral' && value.expressions.length === 0) + ) return runtimeAstProjection(value) + return unsupported(`dynamic value ${value?.type ?? ''}`) + } + + const projection = {} + for (const [bindingName, keys] of selectedKeys) { + if (!bindings.has(bindingName)) return unsupported(`missing ${bindingName}`) + const object = resolveObject(bindingValue(bindingName), new Set([bindingName])) + projection[bindingName] = [...object].flatMap(([name, value]) => ( + keys.has(name) ? [{ name, value: projectValue(value) }] : [] + )) + for (const name of keys) { + if (!object.has(name)) return unsupported(`missing ${bindingName}.${name}`) + } + } + if (!bindings.has('SITE_ORIGIN')) return unsupported('missing SITE_ORIGIN') + projection.SITE_ORIGIN = projectValue(bindingValue('SITE_ORIGIN')) if (!projection.BRAND || !projection.SITE || !projection.SITE_ORIGIN) { throw new Error('Missing public site configuration projection') } + + const identityNames = (node, names = new Set()) => { + const value = unwrap(node) + if (!value || typeof value !== 'object') return names + if (value.type === 'Identifier') { + names.add(value.name) + } else if (value.type === 'MemberExpression') { + identityNames(value.object, names) + } else if (value.type === 'ArrayExpression') { + for (const element of value.elements) identityNames(element, names) + } else if (value.type === 'ObjectExpression') { + for (const property of value.properties) { + identityNames( + property.type === 'SpreadElement' ? property.argument : property.value, + names, + ) + } + } else if (value.type === 'ConditionalExpression') { + identityNames(value.consequent, names) + identityNames(value.alternate, names) + } else if (value.type === 'LogicalExpression') { + identityNames(value.left, names) + identityNames(value.right, names) + } else if (value.type === 'SequenceExpression') { + for (const expression of value.expressions) identityNames(expression, names) + } else if (value.type === 'AwaitExpression' || value.type === 'YieldExpression') { + identityNames(value.argument, names) + } + return names + } + const rejectUsedIdentity = (node, detail) => { + for (const name of identityNames(node)) { + if (usedBindings.has(name)) return unsupported(`${detail} ${name}`) + } + } + const scanMutations = (node) => { + if (node.type === 'AssignmentExpression' || node.type === 'UpdateExpression') { + rejectUsedIdentity(node.type === 'AssignmentExpression' ? node.left : node.argument, 'mutation of') + } else if (node.type === 'UnaryExpression' && node.operator === 'delete') { + rejectUsedIdentity(node.argument, 'mutation of') + } else if (node.type === 'CallExpression' || node.type === 'NewExpression') { + const safeObjectFreeze = node.type === 'CallExpression' + && node.callee.type === 'MemberExpression' + && node.callee.object.type === 'Identifier' + && node.callee.object.name === 'Object' + && !bindings.has('Object') + && memberNameFromNode(node.callee) === 'freeze' + if (!safeObjectFreeze) { + if (node.callee.type === 'MemberExpression') { + rejectUsedIdentity(node.callee.object, 'call through') + } + for (const argument of node.arguments ?? []) { + rejectUsedIdentity(argument, 'escape of') + } + } + } + for (const [child] of childNodes(node)) scanMutations(child) + } + scanMutations(program) return projection } @@ -297,6 +432,8 @@ function semanticBindingGraph(program, path) { const nodeParents = new WeakMap() const declaredIdentifiers = new WeakSet() const importedBindingsByIdentity = new Map() + const reactNamespaceBindings = new Set() + const sideEffectImports = [] const synchronousCalls = [] const unresolvedMutations = [] @@ -310,6 +447,7 @@ function semanticBindingGraph(program, path) { } else { const binding = { aliasUnstable: false, + callable: false, dynamicIdentity: false, executionScope: nearestFunctionScope(scope), identifier, @@ -338,11 +476,11 @@ function semanticBindingGraph(program, path) { || node.type === 'ArrowFunctionExpression' ) { if (node.type === 'FunctionDeclaration' && node.id) { - declare(node.id, scope, node, { valueOrigins: ['other'] }) + declare(node.id, scope, node, { callable: true, valueOrigins: ['other'] }) } const functionScope = createScope(scope, 'function', node) if (node.type === 'FunctionExpression' && node.id) { - declare(node.id, functionScope, node, { valueOrigins: ['other'] }) + declare(node.id, functionScope, node, { callable: true, valueOrigins: ['other'] }) } for (const parameter of node.params ?? []) { declare(parameter, functionScope, parameter, { valueOrigins: ['other'] }) @@ -417,6 +555,9 @@ function semanticBindingGraph(program, path) { return } if (node.type === 'ImportDeclaration') { + if (node.importKind !== 'type' && (node.specifiers?.length ?? 0) === 0) { + sideEffectImports.push(node) + } for (const specifier of node.specifiers ?? []) { if ( !specifier.local @@ -442,6 +583,10 @@ function semanticBindingGraph(program, path) { }) const binding = scope.bindings.get(specifier.local.name) if (binding && node.source) { + if ( + node.source.value === 'react' + && (importedName === '*' || importedName === 'default') + ) reactNamespaceBindings.add(binding) binding.semanticNodes.add(node.source) const identityBindings = importedBindingsByIdentity.get(binding.importIdentity) ?? new Set() identityBindings.add(binding) @@ -509,6 +654,17 @@ function semanticBindingGraph(program, path) { if (node.type === 'Identifier') { const binding = lookup(node) if (binding) bindings.push(binding) + } else if (node.type === 'AssignmentPattern') { + identityBindings(node.left, bindings) + identityBindings(node.right, bindings) + } else if (node.type === 'ArrayPattern') { + for (const element of node.elements) { + if (element?.type !== 'RestElement') identityBindings(element, bindings) + } + } else if (node.type === 'ObjectPattern') { + for (const property of node.properties) { + if (property.type === 'Property') identityBindings(property.value, bindings) + } } else if (node.type === 'MemberExpression') { identityBindings(node.object, bindings) } else if (node.type === 'ArrayExpression') { @@ -618,14 +774,64 @@ function semanticBindingGraph(program, path) { return bindings } + function aliasTargetBindings(node, bindings = []) { + if (!node || typeof node !== 'object') return bindings + if (node.type === 'Identifier') { + const binding = lookup(node) + if (binding) bindings.push(binding) + } else if (node.type === 'AssignmentPattern') { + aliasTargetBindings(node.left, bindings) + } else if (node.type === 'ArrayPattern') { + for (const element of node.elements) { + if (element?.type !== 'RestElement') aliasTargetBindings(element, bindings) + } + } else if (node.type === 'ObjectPattern') { + for (const property of node.properties) { + if (property.type === 'Property') aliasTargetBindings(property.value, bindings) + } + } else if (EXPRESSION_WRAPPERS.has(node.type)) { + aliasTargetBindings(node.expression, bindings) + } + return bindings + } + + function patternDefaultBindings(node, bindings = []) { + if (!node || typeof node !== 'object') return bindings + if (node.type === 'AssignmentPattern') { + identityBindings(node.right, bindings) + patternDefaultBindings(node.left, bindings) + } else if (node.type === 'ArrayPattern') { + for (const element of node.elements) { + if (element?.type !== 'RestElement') patternDefaultBindings(element, bindings) + } + } else if (node.type === 'ObjectPattern') { + for (const property of node.properties) { + if (property.type === 'Property') patternDefaultBindings(property.value, bindings) + } + } else if (EXPRESSION_WRAPPERS.has(node.type)) { + patternDefaultBindings(node.expression, bindings) + } + return bindings + } + function indexAliases(node) { if (node.type === 'VariableDeclarator' && node.init) { const targets = reassignedBindings(node.id) + const initializer = EXPRESSION_WRAPPERS.has(node.init.type) + ? node.init.expression + : node.init + if ( + initializer.type === 'ArrowFunctionExpression' + || initializer.type === 'FunctionExpression' + ) { + for (const target of targets) target.callable = true + } if (hasDynamicInitializer(node.init)) { for (const target of targets) target.dynamicIdentity = true } mergeAliases([ - ...targets, + ...aliasTargetBindings(node.id), + ...patternDefaultBindings(node.id), ...identityBindings(node.init), ]) } @@ -639,7 +845,8 @@ function semanticBindingGraph(program, path) { for (const target of targets) target.dynamicIdentity = true } mergeAliases([ - ...targets, + ...aliasTargetBindings(node.left), + ...patternDefaultBindings(node.left), ...identityBindings(node.right), ], true) } @@ -738,6 +945,18 @@ function semanticBindingGraph(program, path) { indexValueOrigins() const patchedReactBindings = new Set() + function markReactBindingsPatched(bindings) { + const aliases = new Set( + [...bindings].flatMap((binding) => [...binding.aliases]), + ) + if (![...aliases].some((binding) => exactOrigin(binding, 'react-namespace'))) return + for (const binding of aliases) patchedReactBindings.add(binding) + } + function markAllReactBindingsPatched() { + for (const binding of reactNamespaceBindings) { + for (const alias of binding.aliases) patchedReactBindings.add(alias) + } + } function indexPatchedReactBindings(node) { const target = node.type === 'AssignmentExpression' ? node.left @@ -747,10 +966,8 @@ function semanticBindingGraph(program, path) { ? node.argument : null if (target?.type === 'MemberExpression') { - for (const binding of identityBindings(target.object)) { - if (!exactOrigin(binding, 'react-namespace')) continue - for (const alias of binding.aliases) patchedReactBindings.add(alias) - } + markReactBindingsPatched(identityBindings(target.object)) + if (containsDynamicIdentity(target.object)) markAllReactBindingsPatched() } const possiblyEscapedValues = [ ...(node.type === 'CallExpression' ? node.arguments ?? [] : []), @@ -759,10 +976,8 @@ function semanticBindingGraph(program, path) { : []), ] for (const value of possiblyEscapedValues) { - for (const binding of identityBindings(value)) { - if (!exactOrigin(binding, 'react-namespace')) continue - for (const alias of binding.aliases) patchedReactBindings.add(alias) - } + markReactBindingsPatched(identityBindings(value)) + if (containsDynamicIdentity(value)) markAllReactBindingsPatched() } for (const [child] of childNodes(node)) indexPatchedReactBindings(child) } @@ -857,6 +1072,18 @@ function semanticBindingGraph(program, path) { changed = true } } + if ( + node.type === 'AssignmentExpression' + && node.operator === '=' + && node.left.type === 'Identifier' + ) { + const owner = globalStaticOwner(node.right) + const binding = lookup(node.left) + if (owner && binding && staticOwnerAliases.get(binding) !== owner) { + staticOwnerAliases.set(binding, owner) + changed = true + } + } for (const [child] of childNodes(node)) visit(child) } visit(program) @@ -891,19 +1118,46 @@ function semanticBindingGraph(program, path) { ) { patchedStaticOwners.add(globalStaticOwner(node.arguments[0])) } - const replacedOwner = ( - node.arguments?.[1]?.type === 'StringLiteral' - || node.arguments?.[1]?.type === 'Literal' - ) - && STATIC_TARGET_MUTATORS.has(node.arguments[1].value) - && unboundGlobalContainer(node.arguments?.[0]) - && ( - (owner === 'Object' && (method === 'defineProperty' || method === 'defineProperties')) - || (owner === 'Reflect' && method === 'set') - ) - ? node.arguments[1].value - : null - if (replacedOwner) patchedStaticOwners.add(replacedOwner) + if (unboundGlobalContainer(node.arguments?.[0])) { + const patchAllOwners = () => { + for (const name of STATIC_TARGET_MUTATORS.keys()) patchedStaticOwners.add(name) + } + const patchOwnerKey = (key) => { + const name = ( + key?.type === 'StringLiteral' + || key?.type === 'Literal' + ) && typeof key.value === 'string' + ? key.value + : null + if (name && STATIC_TARGET_MUTATORS.has(name)) patchedStaticOwners.add(name) + else if (!name) patchAllOwners() + } + const patchObjectKeys = (value) => { + if (value?.type !== 'ObjectExpression') { + patchAllOwners() + return + } + for (const property of value.properties) { + if (property.type !== 'Property' || property.computed) { + patchAllOwners() + continue + } + patchOwnerKey(property.key) + } + } + if (owner === 'Object' && method === 'assign') { + for (const source of node.arguments?.slice(1) ?? []) patchObjectKeys(source) + } else if (owner === 'Object' && method === 'defineProperties') { + patchObjectKeys(node.arguments?.[1]) + } else if ( + (owner === 'Object' && method === 'defineProperty') + || (owner === 'Reflect' && (method === 'defineProperty' || method === 'set')) + ) { + patchOwnerKey(node.arguments?.[1]) + } else if (owner && STATIC_TARGET_MUTATORS.get(owner)?.has(method)) { + patchAllOwners() + } + } } for (const [child] of childNodes(node)) indexPatchedStaticOwners(child) } @@ -1002,6 +1256,21 @@ function semanticBindingGraph(program, path) { const callableIsInspectable = [...invocationAliases].some((binding) => ( !binding.importSource || binding.importSource.startsWith('.') )) + const hasPotentialCallback = (node.arguments ?? []).some((argument) => { + let value = argument + while (EXPRESSION_WRAPPERS.has(value.type)) value = value.expression + if ( + value.type === 'ArrowFunctionExpression' + || value.type === 'FunctionExpression' + ) return true + const aliases = new Set( + identityBindings(value).flatMap((binding) => [...binding.aliases]), + ) + return [...aliases].some((binding) => ( + binding.callable + || (typeof binding.importSource === 'string' && binding.importSource.startsWith('.')) + )) + }) if ([...calleeAliases].some((binding) => binding.dynamicIdentity)) { unresolvedMutations.push({ executionScope, @@ -1018,7 +1287,7 @@ function semanticBindingGraph(program, path) { semanticNode, }) } - if (callableIsInspectable) { + if ((callableIsInspectable || hasPotentialCallback) && !isReactEffectCall(node)) { synchronousCalls.push({ executionScope, mutationNode: node, @@ -1102,6 +1371,37 @@ function semanticBindingGraph(program, path) { } } } + if (node.type === 'NewExpression') { + const calleeBindings = new Set(identityBindings(node.callee)) + const calleeAliases = new Set( + [...calleeBindings].flatMap((binding) => [...binding.aliases]), + ) + const callableIsInspectable = [...calleeAliases].some((binding) => ( + !binding.importSource || binding.importSource.startsWith('.') + )) + if ( + calleeBindings.size === 0 && containsDynamicIdentity(node.callee) + || [...calleeAliases].some((binding) => binding.dynamicIdentity) + ) { + unresolvedMutations.push({ + executionScope, + message: 'Unsupported dynamic constructor target', + mutationNode: node, + semanticNode, + }) + } + if (callableIsInspectable) { + synchronousCalls.push({ + executionScope, + mutationNode: node, + semanticNode, + }) + } else { + for (const argument of node.arguments ?? []) { + record(argument, 'ambiguous-mutation', { trackDynamic: false }) + } + } + } if (node.type === 'VariableDeclarator' && node.init?.type === 'ObjectExpression') { for (const property of node.init.properties) { if (property.type === 'SpreadElement') { @@ -1196,6 +1496,7 @@ function semanticBindingGraph(program, path) { isClientEffectCall: isReactEffectCall, isReference, lookup, + sideEffectImports, synchronousCalls, unresolvedMutations, } @@ -1231,6 +1532,10 @@ export function semanticSourceForDigest(path, source) { const rootExecutionScope = bindingGraph.executionScopeFor(roots[0]) const visitedBindings = new Set() const pendingNodes = [roots[0]] + for (const sideEffectImport of bindingGraph.sideEffectImports) { + slices.add(sideEffectImport) + pendingNodes.push(sideEffectImport) + } const executesDuringRootRender = (entry) => ( entry.executionScope === rootExecutionScope || entry.executionScope.ownerNode?.type === 'Program' diff --git a/site/scripts/semantic-source-graph.test.mjs b/site/scripts/semantic-source-graph.test.mjs index 25fd3bc..bbd5249 100644 --- a/site/scripts/semantic-source-graph.test.mjs +++ b/site/scripts/semantic-source-graph.test.mjs @@ -9,6 +9,10 @@ import { staticImportSpecifiersForSource, } from './semantic-source-graph.mjs' +function publicConfigSource(prefix, brandProperties) { + return `${prefix}\nexport const BRAND = Object.freeze({ ${brandProperties}, subtitle: 'DP Master', owner: 'AzureL蔚澜算法' })\nexport const SITE = Object.freeze({ origin: 'https://dp.round1.cc', language: 'zh-Hans' })\nexport const SITE_ORIGIN = SITE.origin` +} + test('lesson evidence follows the full static semantic dependency graph', () => { const files = new Set(semanticRouteFiles('/part/a/01')) @@ -93,12 +97,53 @@ test('semantic site config contains only public representation inputs', () => { assert.equal(files.has('site/src/config/client-runtime.ts'), false, pathname) assert.equal(files.has('site/src/config/sidebar-copy.ts'), false, pathname) } + + for (const [before, after] of [ + [ + publicConfigSource(`const BRAND_NAME = '一'`, 'name: BRAND_NAME'), + publicConfigSource(`const BRAND_NAME = '二'`, 'name: BRAND_NAME'), + ], + [ + publicConfigSource(`const shared = { name: '一' }`, '...shared'), + publicConfigSource(`const shared = { name: '二' }`, '...shared'), + ], + [ + publicConfigSource(`const key = 'name'`, `[key]: '一'`), + publicConfigSource(`const key = 'name'`, `[key]: '二'`), + ], + ]) { + assert.notEqual( + semanticSourceForDigest('site/src/config/site.ts', before), + semanticSourceForDigest('site/src/config/site.ts', after), + ) + } + + assert.throws( + () => semanticSourceForDigest( + 'site/src/config/site.ts', + publicConfigSource(`const key = getPublicKey()`, `[key]: '正文'`), + ), + /Unsupported public site configuration/, + ) + + for (const mutableConfig of [ + publicConfigSource(`let BRAND_NAME = '一'\nBRAND_NAME = '二'`, 'name: BRAND_NAME'), + publicConfigSource( + `const shared = { name: '一' }\nshared.name = '二'`, + '...shared', + ), + ]) { + assert.throws( + () => semanticSourceForDigest('site/src/config/site.ts', mutableConfig), + /Unsupported public site configuration/, + ) + } }) test('mixed shell modules hash their AST-backed render dependencies', () => { - const shellBefore = `export default function Shell() {\n useEffect(() => first())\n const mainRef = useRef(null)\n return
\n}` - const shellAfterClientChange = `export default function Shell() {\n useEffect(() => second())\n const mainRef = useRef(null)\n return
\n}` - const shellAfterSemanticChange = `export default function Shell() {\n useEffect(() => second())\n const mainRef = useRef(null)\n return

正文

\n}` + const shellBefore = `import { useEffect, useRef } from 'react'\nexport default function Shell() {\n useEffect(() => first())\n const mainRef = useRef(null)\n return
\n}` + const shellAfterClientChange = `import { useEffect, useRef } from 'react'\nexport default function Shell() {\n useEffect(() => second())\n const mainRef = useRef(null)\n return
\n}` + const shellAfterSemanticChange = `import { useEffect, useRef } from 'react'\nexport default function Shell() {\n useEffect(() => second())\n const mainRef = useRef(null)\n return

正文

\n}` assert.equal( semanticSourceForDigest('site/src/components/layout/Shell.tsx', shellBefore), @@ -129,7 +174,7 @@ test('mixed shell modules hash their AST-backed render dependencies', () => { semanticSourceForDigest('site/src/app/AppContent.tsx', appAfterRouteBinding), ) - const stageBefore = `const routeEase = [0, 1]\nexport default function RouteStage() {\n const outlet = useOutlet()\n const hasMounted = useState(false)[0]\n useEffect(() => first())\n return {outlet}\n}` + const stageBefore = `import { useEffect, useState } from 'react'\nconst routeEase = [0, 1]\nexport default function RouteStage() {\n const outlet = useOutlet()\n const hasMounted = useState(false)[0]\n useEffect(() => first())\n return {outlet}\n}` const stageAfterClientChange = stageBefore.replace('first()', 'second()') const stageAfterOutletChange = stageBefore.replace('useOutlet()', '

替换正文

') assert.equal( @@ -481,10 +526,9 @@ test('mixed-module closure tracks every supported mutation of selected bindings' ) const proxyTargetBefore = `const state = { value: '正文' }\nfunction AppContent() { new Proxy(state, {}).value = '正文一'; return {state.value} }` - const proxyTargetAfter = proxyTargetBefore.replace('正文一', '正文二') - assert.notEqual( - semanticSourceForDigest('site/src/app/AppContent.tsx', proxyTargetBefore), - semanticSourceForDigest('site/src/app/AppContent.tsx', proxyTargetAfter), + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', proxyTargetBefore), + /Unsupported ambiguous mutation of state/, ) const dynamicTarget = `const state = { value: '正文' }\nfunction current() { return state }\nfunction AppContent() { current().value = '变化'; return {state.value} }` @@ -616,7 +660,7 @@ test('mixed-module closure recognizes React effects by import provenance', () => const patchedReactEffect = `import * as React from 'react'\nReact.useEffect = callback => callback()\nfunction AppContent() { let label = '正文'; React.useEffect(() => { label = '同步变化' }, []); return {label} }` assert.throws( () => semanticSourceForDigest('site/src/app/AppContent.tsx', patchedReactEffect), - /Unsupported nested semantic write to label/, + /Unsupported ambiguous mutation of React/, ) const wrappedCallbackBefore = `import { useEffect } from 'react'\nfunction AppContent() { let label = '正文'; useEffect((() => { label = '客户端一' }) as () => void, []); return {label} }` @@ -651,6 +695,91 @@ test('mixed-module closure recognizes React effects by import provenance', () => ) }) +test('reviewed runtime boundaries cannot hide public render changes', () => { + const constructorBefore = `const state = { value: '正文' }\nclass Writer { constructor(target) { target.value = '变化一' } }\nfunction AppContent() { new Writer(state); return {state.value} }` + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', constructorBefore), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + constructorBefore.replace('变化一', '变化二'), + ), + ) + + const parameterProperty = `class Model { constructor(public label = '正文') {} }\nconst model = new Model()\nfunction AppContent() { return {model.label} }` + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', parameterProperty), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + parameterProperty.replace('public label', 'label'), + ), + ) + + const optionalChain = `function AppContent() { let label = 'before'; const state = null; try { state?.value } catch { label = 'caught' } return {label} }` + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', optionalChain), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + optionalChain.replace('state?.value', 'state.value'), + ), + ) + + const synchronousCallback = `const state = { value: '正文' }\nfunction current() { return state }\nfunction AppContent() { Array.from([1], () => { current().value = '变化一' }); return {state.value} }` + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', synchronousCallback), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + synchronousCallback.replace('变化一', '变化二'), + ), + ) + + const destructuringDefault = `const state = {}\nconst fallback = { value: '正文' }\nconst { nested = fallback } = state\nfunction AppContent() { nested.value = '变化一'; return {fallback.value} }` + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', destructuringDefault), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + destructuringDefault.replace('变化一', '变化二'), + ), + ) + + const objectRestBefore = `const state = { value: '正文' }\nconst { ...copy } = state\nfunction AppContent() { copy.value = '副本一'; return {state.value} }` + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', objectRestBefore), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + objectRestBefore.replace('副本一', '副本二'), + ), + ) + + for (const patchedReact of [ + `import React from 'react'\nconst holder = { react: React }\nholder.react.useEffect = callback => callback()\nfunction AppContent() { let label = '正文'; React.useEffect(() => { label = '同步一' }, []); return {label} }`, + `import React from 'react'\nconst getReact = () => React\ngetReact().useEffect = callback => callback()\nfunction AppContent() { let label = '正文'; React.useEffect(() => { label = '同步一' }, []); return {label} }`, + ]) { + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', patchedReact), + /Unsupported/, + ) + } + + for (const patchedOwner of [ + `const state = { value: '正文' }\nlet O\nO = Object\nO.assign = target => { target.value = '变化' }\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`, + `const state = { value: '正文' }\nObject.assign(globalThis, { Object: { assign(target) { target.value = '变化' } } })\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`, + ]) { + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', patchedOwner), + /Unsupported ambiguous mutation of state/, + ) + } + + const sideEffectImportBefore = `import 'ssr-patch-a'\nfunction AppContent() { return

正文

}` + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', sideEffectImportBefore), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + sideEffectImportBefore.replace('ssr-patch-a', 'ssr-patch-b'), + ), + ) +}) + test('static dependency discovery excludes specifier-level type-only edges', () => { assert.deepEqual( staticImportSpecifiersForSource( diff --git a/site/scripts/seo-contract.test.mjs b/site/scripts/seo-contract.test.mjs index c2b4e33..9b2dbfd 100644 --- a/site/scripts/seo-contract.test.mjs +++ b/site/scripts/seo-contract.test.mjs @@ -232,7 +232,7 @@ test('discovery files expose the 47 approved URLs and real summaries', async () assert.match(lastModified, /--format=%cI/) assert.match(lastModified, /normalizeContentForDigest/) assert.match(lastModified, /semanticRouteFiles/) - assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 8) + assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 9) assert.equal(Object.keys(ROUTE_CONTENT_DIGESTS).length, PUBLIC_PATHS.length) assert.ok(Object.values(ROUTE_CONTENT_DIGESTS).every((digest) => /^[0-9a-f]{64}$/.test(digest))) assert.match(publicRoutes, /\.\.\/data\/catalog\.ts/) diff --git a/site/src/data/routeLastModified.ts b/site/src/data/routeLastModified.ts index 1fa5464..1b92770 100644 --- a/site/src/data/routeLastModified.ts +++ b/site/src/data/routeLastModified.ts @@ -1,5 +1,5 @@ // 由 scripts/generate-seo.mjs 从源码时间证据生成,请勿手改。 -export const ROUTE_CONTENT_DIGEST_VERSION = 8 +export const ROUTE_CONTENT_DIGEST_VERSION = 9 export const ROUTE_LAST_MODIFIED: Readonly> = Object.freeze({ "/": "2026-08-22T23:46:11+08:00", "/part/a": "2026-08-22T11:47:40+08:00", @@ -50,51 +50,51 @@ export const ROUTE_LAST_MODIFIED: Readonly> = Object.free "/problems": "2026-08-22T11:47:40+08:00" }) export const ROUTE_CONTENT_DIGESTS: Readonly> = Object.freeze({ - "/": "1c7858402d9c4047cefc647a73e66cc217e6044b1e98a47c265920b87a280d4a", - "/part/a": "120920f752aeda06480825fe8426acf1286fb12ed50ebb6b132e68974147d8bf", - "/part/b": "1f9f43482e384c6353b665998ef044748cff0dfd23a0de743057e9b1e13304eb", - "/part/c": "40863170861ca3180f1d1e5d99b8889d920ef6f0ff4c49a5bc355e59c35ed657", - "/part/d": "3ac23127c71ae0875f4dd2ed9736775dbf8b7e9c69d80bc7b7aa8be04455e1d4", - "/part/e": "75731c476b329c590699a468eec66ed85791c7cf39b41ff7a97c19c270ff8d8d", - "/part/f": "2d929c2f6ece8236546ded325c5b98012d7dcc43e43445c43738fa588d93fa00", - "/part/g": "09c2bffef2ef39e38d5c22b2cacbf397425741f8ca9022345253f3a6d5524b65", - "/part/a/01": "036fd844e6e8d3aa89860a2592f6f5c6d8575e529a86c7f48f5148b5b8251cee", - "/part/a/complete": "6f07959072b523a791bd80f4216d09d1b1a02b53ae882835134f8ba546cdef28", - "/part/a/multiple": "673b36428e64c9e1bc181c2563ea342fd469e714a8094f56a95f3358887c3e56", - "/part/a/group": "b056573cf98d07058d594f8064196f5d5e7d10355cabfca5d3e9c9a8312c0a6c", - "/part/a/mixed": "08ea26e06b4cbfb00b997c25615fc23b2d9e422b7caa0af2f2e0854764a3a7a5", - "/part/a/cost2d": "2262fc138d011a852698091547846113c1a734a709556d51f9a66be5f476044a", - "/part/a/dep": "72ea3c7aaf9df5d56b9a696841830c33b69642cc663f6369481446ed8fdb567a", - "/part/a/variant": "aea292d2fe4fb49d8086194d416d9089185e12cc352108c140cfac40199e4b99", - "/part/a/fractional": "1685da81996a6e4f86e468a1149c86e068f3dffd1c50d29edf044846943d11b5", - "/part/b/path": "6f922b990a9e652319a115255c535ac60cea44775901e55300e9fa670939ebec", - "/part/b/maxseg": "01adc5f3ecd405e5e7a5dac1ffd18e5b76eff1644d8a50924214aa2b2ed25963", - "/part/b/lis": "2406b2f09590d943f8a7c713e287db5f954498f94ad01ae82de03d8c6760799c", - "/part/b/lcs": "ef4b7c72627c663cea62adc3a297f68bfe2cbdc6cf89d8c1c0a45a5827ea9836", - "/part/b/edit": "0034d3ae2b2a149471b334c7489f4684c6d22fc4e24cb5777f6a28e56dcb3888", - "/part/b/fsm": "1f603e286fc8baf43a4a1137128a8e88d8d4450fd948e24ae44213406f97c9dd", - "/part/b/count": "62ae2d2c665092e08dd6a4e76100f42f7d6676a5619713137261b4cab17acd12", - "/part/c/stone": "b89611adaa8b694fa4ddb000405a3cc5b8f103ccb6f1708895f7fcb02871f7ae", - "/part/c/ring": "a1fc57c51976c7452e1f247bae747c30ff5e0eeb21aef827a58f4b1a08cb0c4c", - "/part/c/palindrome": "4c59c482df1a533db341ce3926df577c106f7b229517495c5021e3f17ff0aad3", - "/part/c/tree": "eccd7e1db5cc6d2551689db48d6268211fd06f0d5e547b83dc930ea0af045490", - "/part/c/merge": "bb3fbb45ce888fbeaab1184fce454a5b01ca1fe59d80551364a3124e0e6446f1", - "/part/d/grid": "4d7a205fb2fb75f67e1b40bd4bc7b706c73445ec114604d04effb27054ce5332", - "/part/d/matpow": "db9791896b4973bea490a680122e7a4a5e05f7f594f79fc1c3bc405901e40900", - "/part/e/basic": "cfac9dd939971f7ba0818c1d892d69b8d557608de926c1f987af7e86a2606a50", - "/part/e/distsum": "ba091e96c12fde09fb97439dcfe95f08c2c310022e408a5d70a343e38eeca93a", - "/part/e/inout": "5603360c4447948c53c2e9a8cb1cc4e90528de18d41471d8c144f63506faebae", - "/part/e/center": "785496f4f76d2d1320a7ffc5c3e26920355b9765fd09eb1b485021d2fc2214fa", - "/part/f/select": "7b297683ee5a5a446aa403db3236e04cb0d496bd4e9bd0fead79eae758962d52", - "/part/f/knapsack": "b49819c2d6f3cde9a2ee0aa5c65ac5af84341443fb86f46fd19d8f00e688fa53", - "/part/f/diameter": "87638f355475535cabdc0ba51c9d268aaede2f4425d1b8d24760198888122a40", - "/part/f/cover": "52adb23202557a44ef1e34e0f737e917154c488aea668772c9bb2942e8752d9c", - "/part/f/count": "712b08b528be941ba7ff520c6ec1201018295163f1dadb75881a2da498715263", - "/part/g/board": "8e160b34972b19fee7f43361f8c789589908813a1328bbc884a8a0c5192c3ee2", - "/part/g/tsp": "9b0e21a6e9d31586dd418c44bd447919fa7ff36a6aafbc929f599cee5794268d", - "/part/g/cover": "6b7a9425802bf69c9288c16aa42298c0fca347b733629e8b62c620c47ec2df71", - "/part/g/subset": "8184312ac3dce6009c93be86c41515f56683d7fa272a0882245e50178a1ae9b8", - "/part/g/plug": "9d847f0c495d6cf996c848bea618d290735c1df9b0834627e9a3c2463b906b76", - "/method": "6aff15cf7963a3f511ce1f04fdfe41e61d65836545607bdaa1ee4e7ed6f396a8", - "/problems": "4f5db57e4627f94f37b0562819caadf1633fca1065b7e8cf0b19bb344992e44d" + "/": "8bedf7f5019e3131f52074076d3437affb2491dc7b421d7997f9bfe07b2a487a", + "/part/a": "7f892f41a366db13ed3b6916d6a9643e81681cda91f8e8087cb2e757b41480bd", + "/part/b": "200a52fee93bfc705b67cf379f9b00370fc6e0b7eba5d1eb34c6c5f2a7972342", + "/part/c": "8bc114d37d037a3cd07427abde61db7dc7388c702228a1722b98aae4bb131d58", + "/part/d": "d489bdc0d4a10283fef240fd45ccf231f5e147ca1762ff7c1e86087479bf42d4", + "/part/e": "358cecd1e747080119908160abe869549054ec3369e269945baef7474ea9a61a", + "/part/f": "0d0c9c7594e2329c41c067708756c29a30535c081fffed26ad5200b717ede07a", + "/part/g": "20def84c52b55c7a1bd1d643c30cf376e9c1d63ace9c0f2cef6ddef29155ef3d", + "/part/a/01": "130fd31beb0de00df49aecb433bfd0c8f54420001f08d695a0be27f5c7221331", + "/part/a/complete": "c2c2311925a8c733b1f69eecd22e0e9417cdebd973a59efbb6b41444031b0af4", + "/part/a/multiple": "3db6cdfab8cf8eb41b17090dab3e917fb16a6a9be7648c9647a56a4151a2723c", + "/part/a/group": "6efd8f25ac26e4f10f6c2415c66f9d49c6ffa5dc5ba3a6c5bee538deb3639a86", + "/part/a/mixed": "b57f08a81c13a31c2ddd5e060c7c10c4acb8e498a340a0353ab811b847b766f2", + "/part/a/cost2d": "0bc95de3b2b03e57a4a8dc0846f7aa45469315cf4812aecb3b1c8018c3a44b60", + "/part/a/dep": "3fd85259441d11b37a499829d0b42bd25bf4a5a1f495e7fe6365ca437b494060", + "/part/a/variant": "d7efbed7be374272f9aa974dd3fe40e9640a19797996d970906f3c7b2921cb8d", + "/part/a/fractional": "921f756682c996afe773ee47567cd2dbfdeb339fb84827bf2fba49e70609b0d7", + "/part/b/path": "85e13149ff16d753e12a06c56b65efeee1732de09161a7da6a7ea18d71190f92", + "/part/b/maxseg": "7004fd5fe4f947caca074cc2437f07e02d743a99c9e24fe32a6ab58a1e3bded7", + "/part/b/lis": "36ebcbe90680d3a8a74aa3dea37796c975333caf09fc2e1b03bcdd6631f66cec", + "/part/b/lcs": "07b82fe1f93d7ad84157d6395565ed0d556316c44b929264c242628175de2ee3", + "/part/b/edit": "f2c53e6f43fa2e099569e472522e60686df5944681e39a3664a003cf26897530", + "/part/b/fsm": "9ba4ace249bb9e004c4a52dd7dba88b9a354d90d25de613c9894b3252f4b8ab8", + "/part/b/count": "e83fb7cf0e215ec4f96cd65f7cef9d158a0ae36e8eda3499d24064c7cba5c67a", + "/part/c/stone": "7745612d24ad770da135bddbd9b64b83c147aa9037aff3668795906600506da7", + "/part/c/ring": "d821c4cdaabfafbda914f526f78b9ff558f4ec100d06a7033fcfeacd0111b8e7", + "/part/c/palindrome": "2ea60d7b33379dea5054714c1d9c80acf263b06929fb4e09c64ab9ccbb40de0d", + "/part/c/tree": "85e633c7856709553fbafb4bae3c19862edb421a22beb2c7adc667a01a0bb995", + "/part/c/merge": "b5a28245d0b3f64fa042368adcf8e590eb22cb346b24f93eeef52e283d4c6158", + "/part/d/grid": "633f7942ef30b6bd1688abe06e74567eb08bbea2f52af9ce821cdacc7c6c965a", + "/part/d/matpow": "9cea494cf6834c3bf8b469dc37c487c4ac1e2aac96ae42f7dcc7a4f31d8b8bed", + "/part/e/basic": "836fc38485c999b50ceb5286d59a4fa10cf78b235dfde7ad45d51274c977b713", + "/part/e/distsum": "dbd85df5503757d74c5dbffdaaf4da1c4a7b04d6415f1a37d8a36eb5d5a38c85", + "/part/e/inout": "045e3047e943df7c2e033afe29835966a253074918aa6072d1da9af517547bdb", + "/part/e/center": "5a5cf03b448b696d1f6166f93a6c0802c3979fbea5a4974945bcccf33dcf408f", + "/part/f/select": "3c467669c773355d2925f3c0ef5c68f68c3e2daf197195476ff1394eb62f6969", + "/part/f/knapsack": "4495bcaf7f526f8052bbc4397509e0c998319c93fa1e43d70d2159e789b2d26a", + "/part/f/diameter": "a2ebd4496c96d9d39f18e44964b7ad866edc0958afbeae8680f4c943fcdf96c9", + "/part/f/cover": "5256123fe9e59c6baed4286fb880c09fc1cd394beb18159781b16916afbcf014", + "/part/f/count": "a5ad5e079aa3903a838560ed225e1c9f18bcafaa7f7af8ae958e68e4880cbad2", + "/part/g/board": "36541b6654ac0a28ca6c789d9bea3b75fa661f5f30f3f87636745e98a35cd995", + "/part/g/tsp": "771e4e564eeab4604a4d148352173472c0d16238b4d3c85d5424e8ef5ccd8d9f", + "/part/g/cover": "0f4bbcaeb82402e0cc5c5d0c647588f52d3e144fe491202755ea70532fc5b7e8", + "/part/g/subset": "e178cf6b4eb02b42c51b06df99d87ee2fa2534f68669ec081074e05bfe779f2b", + "/part/g/plug": "7621e7734d23a8dcec6f18b3fac338f6029af5cbd9e9b4994b080682a4f76286", + "/method": "131cd9ea4a72688c4feef6bb148937c710d238b5368e160dcb864823317640c6", + "/problems": "07afec08919aabad5c2e0ea9ae27d07d94a66c90a84eb180910993cc630a768c" }) From f4e474dfdfe050315ddb0c305390dae2a680fba3 Mon Sep 17 00:00:00 2001 From: Shanire Date: Wed, 26 Aug 2026 15:56:49 +0800 Subject: [PATCH 13/23] fix: close remaining semantic evidence gaps Signed-off-by: Shanire --- docs/engineering/architecture.md | 4 +- docs/operations/verification.md | 4 +- site/scripts/last-modified.mjs | 2 +- site/scripts/semantic-source-graph.mjs | 636 ++++++++++++++++++-- site/scripts/semantic-source-graph.test.mjs | 145 ++++- site/scripts/seo-contract.test.mjs | 2 +- site/src/data/routeLastModified.ts | 96 +-- 7 files changed, 786 insertions(+), 103 deletions(-) diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index da4bb88..ac8da9c 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -110,11 +110,11 @@ For a registered public `GET` or `HEAD`, `worker/content-negotiation.js` selects `site/scripts/generate-seo.mjs` writes the checked-in baseline for `sitemap.xml`, `robots.txt`, `llms.txt`, `route-summaries.json`, and the home-route head inside `index.html`; the prerender step regenerates the four discovery files into the build output. All of them derive from the same 47-path catalog contract: home, seven families, 37 completed lessons, method, and problem index. Sitemap entries are sorted by canonical URL. `semantic-source-graph.mjs` follows the real SSR injection chain from `entry-server.tsx` through the static application/providers and route tree, together with metadata roots and the exact route page, lesson, and family-art modules. -Every parsed source contribution is reduced to a runtime AST projection, so TypeScript-only syntax and formatting do not advance public-content evidence while runtime-bearing TypeScript parameter properties and optional-chain semantics remain visible. `config/site.ts` has a narrower public projection containing only the brand and origin fields that can change a public representation. That projection follows immutable local bindings, object spreads, and static computed keys; mutable, escaped, cyclic, or dynamic dependencies fail closed. Mixed shell modules use an AST-rooted closure: the route-rendering JSX plus local declarations, selected runtime and side-effect imports, root/program synchronous calls, callback-capable external calls, constructors, and render-phase mutations reached by lexical binding identity are hashed. The closure tracks stable direct/container aliases, destructuring defaults without conflating object-rest copies, later property aliases, call/getter-created targets, control-flow scopes, class helpers, route/outlet bindings, and patched or replaced React/global static owners. Local and relative callable or constructable targets are pulled into the closure; unsupported dynamic targets, imported member receivers, arbitrary external mutations, and other ambiguous writes fail closed. +Every parsed source contribution is reduced to a runtime AST projection, so TypeScript-only syntax and formatting do not advance public-content evidence while runtime-bearing TypeScript parameter properties and optional-chain semantics remain visible. Intrinsic-element event handlers, refs, and keys are excluded because React never serializes them, while custom-component props and SSR-bearing attributes remain. `config/site.ts` has a narrower public projection containing only the brand and origin fields that can change a public representation. That projection follows immutable local bindings, object spreads, and static computed keys; mutable, escaped, cyclic, aliased, globally patched, or dynamic dependencies fail closed. Mixed shell modules use an AST-rooted closure: the route-rendering JSX plus local declarations, every runtime module-evaluation edge, root/program synchronous calls, proven synchronous callbacks, constructors, tagged templates, and render-phase mutations reached by lexical binding identity are hashed. Known asynchronous platform and Promise callbacks remain outside the public digest; callback timing that cannot be proved fails closed. The closure tracks stable direct/container aliases, destructuring defaults, shallow-copy nested references without conflating direct object-rest copies, later property aliases, call/getter-created targets, control-flow scopes, class helpers, route/outlet bindings, and patched or replaced React/global static owners. Local and relative callable or constructable targets are pulled into the closure; unsupported dynamic targets, imported member receivers, arbitrary external mutations, and other ambiguous writes fail closed. Effect-only callbacks are excluded only when the callee has one exact, stable React-effect origin through named, default, namespace, member, or destructured aliases. Mixed aliases, reassigned hooks, patched React namespaces, and same-named local helpers are never pruned as effects. Runtime import edges remain while declaration- and specifier-level type-only edges, unselected import siblings, object-spread copies, and JSX attribute names do not leak in. The graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Public representation identity is structurally isolated in `config/site.ts`; client endpoints and sidebar-only copy are in modules reachable only through pruned branches. -Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. An unchanged digest preserves the previous value; changed content uses the precise Git commit or working-tree timestamp of a semantically changed projection and must advance. During a digest-schema migration, the current projector is applied to historical sources at the previous `lastmod`: a clean committed semantic edit advances, while an edit confined to a pruned client branch does not. Version 9 retains the trusted `2026-08-22` evidence while closing constructor, parameter-property, optional-chain, callback, destructuring, side-effect-import, React/global-owner alias, and public-config dependency gaps. It also prevents historical client-only `config/site.ts` fields from falsely advancing the home route. The home route therefore remains at `23:46:11+08:00`, and the other 46 routes remain at `11:47:40+08:00`. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, type-only noise, or incomplete execution/scope/mutation context. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. +Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. An unchanged digest preserves the previous value; changed content uses the precise Git commit or working-tree timestamp of a semantically changed projection and must advance. During a digest-schema migration, the current projector is applied to historical sources at the previous `lastmod`: a clean committed semantic edit advances, while an edit confined to a pruned client branch does not. Version 11 retains the trusted `2026-08-22` evidence while closing tagged-template, inline constructor/IIFE, callback-timing (including nested asynchronous callbacks inside selected synchronous slices), shallow-copy, module-evaluation, intrinsic-client-attribute, cross-module React-patch, global-container alias, and public-config alias/provenance gaps. It also prevents historical client-only `config/site.ts` fields from falsely advancing the home route. The home route therefore remains at `23:46:11+08:00`, and the other 46 routes remain at `11:47:40+08:00`. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, type-only noise, or incomplete execution/scope/mutation context. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. # Analytics diff --git a/docs/operations/verification.md b/docs/operations/verification.md index 1449496..ffee3b7 100644 --- a/docs/operations/verification.md +++ b/docs/operations/verification.md @@ -44,9 +44,9 @@ Pure TypeScript Modules imported by Node tests must use explicit `.ts` extension # SEO And Accessibility Checks -`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. Version 9 follows each route's real SSR injection chain with a line-ending-neutral runtime-AST digest: type-only syntax is erased without dropping runtime-bearing TypeScript parameter properties or optional-chain behavior; public `config/site.ts` evidence follows immutable field dependencies; and mixed shell modules contribute route-rendering JSX plus the lexical closure of runtime and side-effect imports, root/program synchronous and callback-capable calls, constructors, aliases, and render-phase mutations. Regression probes cover scope/control flow, stable and late aliases, destructuring defaults, object-rest and spread-copy precision, call/getter/constructor-created targets, local and cross-module callables, external callbacks, `Reflect.apply`, patched/replaced global mutators, exact React-effect aliases, mixed or patched hooks, and historical public-config projection. Unsupported mutable or dynamic config, dynamic targets, imported member receivers, arbitrary external mutations, and ambiguous nested writes outside proven React client-effect callbacks fail closed. +`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. Version 11 follows each route's real SSR injection chain with a line-ending-neutral runtime-AST digest: type-only syntax is erased without dropping runtime-bearing TypeScript parameter properties or optional-chain behavior; intrinsic event/ref/key props are erased without dropping custom-component or SSR-bearing props; public `config/site.ts` evidence follows immutable field dependencies and validates global provenance; and mixed shell modules contribute route-rendering JSX plus the lexical closure of all runtime module-evaluation edges, root/program synchronous calls, proven synchronous callbacks, constructors, tagged templates, aliases, and render-phase mutations. Known asynchronous callbacks are erased even when nested inside an otherwise selected synchronous slice. Regression probes cover scope/control flow, stable and late aliases, destructuring defaults, direct and nested shallow-copy precision, call/getter/constructor/tag-created targets, local and cross-module callables, synchronous versus known asynchronous callbacks, `Reflect.apply`, patched/replaced global mutators and containers, exact React-effect aliases, side-effect module patches, mixed or patched hooks, and historical public-config projection. Unsupported mutable, escaped, aliased, globally patched, or dynamic config, unproved callback timing, dynamic targets, imported member receivers, arbitrary external mutations, and ambiguous nested writes outside proven React client-effect callbacks fail closed. -Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. An unchanged digest preserves its prior time; changed content uses the exact Git commit or working-tree timestamp of a semantically changed projection and must advance. A schema migration reapplies the current projector to source history at the previous `lastmod`, so clean committed semantic edits advance while dirty changes confined to pruned client branches do not. Version 9 retains `23:46:11+08:00` for the home hero change and `11:47:40+08:00` for the other 46 routes; the historical regression proves that removed client-only fields in the older site config do not advance home evidence. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. +Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. An unchanged digest preserves its prior time; changed content uses the exact Git commit or working-tree timestamp of a semantically changed projection and must advance. A schema migration reapplies the current projector to source history at the previous `lastmod`, so clean committed semantic edits advance while dirty changes confined to pruned client branches do not. Version 11 retains `23:46:11+08:00` for the home hero change and `11:47:40+08:00` for the other 46 routes; the historical regression proves that removed client-only fields in the older site config do not advance home evidence. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. The browser gate runs Chromium against built `dist/` through a strict, non-reused custom preview server. Route tests directly open `/`, `/part/a`, `/part/a/01`, `/method`, and `/part/g/plug`, then exercise live client navigation and keyboard focus. They check HTTP status, prerendered HTML, absence of pending streamed-Suspense placeholders, route CSS at first paint, hydration without errors, CLS below `0.05`, title/description/abstract/canonical/zero-hreflang/Open Graph/JSON-LD metadata, one visible `h1`, route announcements, current-page semantics, initial-load focus, changed-route focus, and skip-link focus. Navigation-positioning tests click a representative lesson outline in each of the seven families, restore a direct lesson fragment, verify the mobile outline and an ordinary family-page fragment, and require a changed route to reset old-page scroll immediately. An unknown path must return 404 with `noindex,nofollow`, no canonical, and the themed not-found heading. Worker-level Node tests separately cover the Accept matrix, 406 behavior, HEAD parity, representation-specific ETags, hidden internal assets, and unaffected API/static/unknown paths. diff --git a/site/scripts/last-modified.mjs b/site/scripts/last-modified.mjs index b38708c..30b41fb 100644 --- a/site/scripts/last-modified.mjs +++ b/site/scripts/last-modified.mjs @@ -9,7 +9,7 @@ import { } from './semantic-source-graph.mjs' const projectRoot = fileURLToPath(new URL('../../', import.meta.url)) -export const ROUTE_CONTENT_DIGEST_VERSION = 9 +export const ROUTE_CONTENT_DIGEST_VERSION = 11 const historicalSourceCache = new Map() const headSourceCache = new Map() diff --git a/site/scripts/semantic-source-graph.mjs b/site/scripts/semantic-source-graph.mjs index f19bede..c0a07cc 100644 --- a/site/scripts/semantic-source-graph.mjs +++ b/site/scripts/semantic-source-graph.mjs @@ -30,6 +30,32 @@ const CLIENT_EFFECT_CALLEES = new Set([ 'useInsertionEffect', 'useLayoutEffect', ]) +const KNOWN_ASYNC_CALLBACK_CALLEES = new Set([ + 'queueMicrotask', + 'requestAnimationFrame', + 'requestIdleCallback', + 'setImmediate', + 'setInterval', + 'setTimeout', +]) +const KNOWN_ASYNC_MEMBER_CALLEES = new Set(['catch', 'finally', 'then']) +const KNOWN_SYNC_CALLBACK_MEMBER_CALLEES = new Set([ + 'every', + 'filter', + 'find', + 'findIndex', + 'findLast', + 'findLastIndex', + 'flatMap', + 'forEach', + 'map', + 'reduce', + 'reduceRight', + 'replace', + 'replaceAll', + 'some', + 'sort', +]) const MUTATING_MEMBER_CALLEES = new Set([ 'add', 'clear', @@ -106,6 +132,21 @@ function childNodes(node) { return children } +function isIntrinsicJsxName(node) { + return node?.type === 'JSXIdentifier' && /^[a-z]/u.test(node.name) +} + +function isClientOnlyIntrinsicJsxAttribute(attribute, openingElement) { + if ( + attribute?.type !== 'JSXAttribute' + || !isIntrinsicJsxName(openingElement?.name) + || attribute.name?.type !== 'JSXIdentifier' + ) return false + return attribute.name.name === 'key' + || attribute.name.name === 'ref' + || /^on[A-Z]/u.test(attribute.name.name) +} + function projectRuntimeObject(value, overrides = {}, options = {}) { const projected = {} const source = { ...value, ...overrides } @@ -144,6 +185,24 @@ function runtimeAstProjection(value, options = {}) { ], }, options) } + if (value.type === 'CallExpression') { + const deferredIndexes = options.clientAsyncCallbackIndexes?.(value) ?? [] + if (deferredIndexes.length > 0) { + const deferred = new Set(deferredIndexes) + return projectRuntimeObject(value, { + arguments: value.arguments.map((argument, index) => ( + deferred.has(index) ? { type: 'ClientAsyncCallback' } : argument + )), + }, options) + } + } + if (value.type === 'JSXOpeningElement' && isIntrinsicJsxName(value.name)) { + return projectRuntimeObject(value, { + attributes: value.attributes.filter((attribute) => ( + !isClientOnlyIntrinsicJsxAttribute(attribute, value) + )), + }, options) + } if (EXPRESSION_WRAPPERS.has(value.type)) { return runtimeAstProjection(value.expression, options) } @@ -217,6 +276,131 @@ function publicSiteConfigProjection(program) { const unwrap = (node) => ( node && EXPRESSION_WRAPPERS.has(node.type) ? unwrap(node.expression) : node ) + const localMemberName = (node) => memberNameFromNode(unwrap(node)) + const globalContainerAliases = new Set() + const globalObjectAliases = new Set() + const isGlobalContainer = (node) => { + const value = unwrap(node) + if (value?.type !== 'Identifier') return false + return bindings.has(value.name) + ? globalContainerAliases.has(value.name) + : ['global', 'globalThis', 'self', 'window'].includes(value.name) + } + let containerAliasesChanged = true + while (containerAliasesChanged) { + containerAliasesChanged = false + const indexGlobalContainerAlias = (node) => { + const target = node.type === 'VariableDeclarator' + && node.id.type === 'Identifier' + && node.init + ? node.id + : node.type === 'AssignmentExpression' + && node.operator === '=' + && node.left.type === 'Identifier' + ? node.left + : null + const source = node.type === 'VariableDeclarator' + ? node.init + : node.type === 'AssignmentExpression' + ? node.right + : null + if ( + target + && source + && isGlobalContainer(source) + && !globalContainerAliases.has(target.name) + ) { + globalContainerAliases.add(target.name) + containerAliasesChanged = true + } + for (const [child] of childNodes(node)) indexGlobalContainerAlias(child) + } + indexGlobalContainerAlias(program) + } + const isGlobalObject = (node) => { + const value = unwrap(node) + if (value?.type === 'Identifier') { + return value.name === 'Object' + ? !bindings.has('Object') + : globalObjectAliases.has(value.name) + } + return value?.type === 'MemberExpression' + && isGlobalContainer(value.object) + && localMemberName(value) === 'Object' + } + let aliasesChanged = true + while (aliasesChanged) { + aliasesChanged = false + const indexGlobalObjectAlias = (node) => { + const target = node.type === 'VariableDeclarator' + && node.id.type === 'Identifier' + && node.init + ? node.id + : node.type === 'AssignmentExpression' + && node.operator === '=' + && node.left.type === 'Identifier' + ? node.left + : null + const source = node.type === 'VariableDeclarator' + ? node.init + : node.type === 'AssignmentExpression' + ? node.right + : null + if ( + target + && source + && isGlobalObject(source) + && !globalObjectAliases.has(target.name) + ) { + globalObjectAliases.add(target.name) + aliasesChanged = true + } + for (const [child] of childNodes(node)) indexGlobalObjectAlias(child) + } + indexGlobalObjectAlias(program) + } + let trustedObjectFreeze = !bindings.has('Object') + const inspectObjectPatches = (node) => { + const target = node.type === 'AssignmentExpression' + ? node.left + : node.type === 'UpdateExpression' + ? node.argument + : node.type === 'UnaryExpression' && node.operator === 'delete' + ? node.argument + : null + const value = unwrap(target) + if (value?.type === 'Identifier' && isGlobalObject(value)) trustedObjectFreeze = false + if (value?.type === 'MemberExpression') { + if ( + isGlobalObject(value.object) + && (localMemberName(value) === 'freeze' || localMemberName(value) === null) + ) trustedObjectFreeze = false + if ( + isGlobalContainer(value.object) + && (localMemberName(value) === 'Object' || localMemberName(value) === null) + ) trustedObjectFreeze = false + } + if (node.type === 'CallExpression') { + const callee = unwrap(node.callee) + const safeFreezeCall = callee?.type === 'MemberExpression' + && isGlobalObject(callee.object) + && localMemberName(callee) === 'freeze' + if ( + !safeFreezeCall + && (node.arguments ?? []).some((argument) => isGlobalObject(argument)) + ) trustedObjectFreeze = false + if ( + callee?.type === 'MemberExpression' + && isGlobalObject(callee.object) + && ['assign', 'defineProperties', 'defineProperty', 'setPrototypeOf'].includes( + localMemberName(callee), + ) + && (node.arguments ?? []).some((argument) => isGlobalObject(argument)) + ) trustedObjectFreeze = false + } + for (const [child] of childNodes(node)) inspectObjectPatches(child) + } + inspectObjectPatches(program) const staticKey = (node, stack = new Set()) => { const value = unwrap(node) if (value?.type === 'Literal' || value?.type === 'StringLiteral') { @@ -246,7 +430,7 @@ function publicSiteConfigProjection(program) { && value.callee.type === 'MemberExpression' && value.callee.object.type === 'Identifier' && value.callee.object.name === 'Object' - && !bindings.has('Object') + && trustedObjectFreeze && memberNameFromNode(value.callee) === 'freeze' && value.arguments?.length === 1 ) return resolveObject(value.arguments[0], stack) @@ -313,6 +497,20 @@ function publicSiteConfigProjection(program) { if (!value || typeof value !== 'object') return names if (value.type === 'Identifier') { names.add(value.name) + } else if (value.type === 'AssignmentPattern') { + identityNames(value.left, names) + identityNames(value.right, names) + } else if (value.type === 'RestElement' || value.type === 'SpreadElement') { + identityNames(value.argument, names) + } else if (value.type === 'ArrayPattern') { + for (const element of value.elements) identityNames(element, names) + } else if (value.type === 'ObjectPattern') { + for (const property of value.properties) { + identityNames( + property.type === 'RestElement' ? property.argument : property.value, + names, + ) + } } else if (value.type === 'MemberExpression') { identityNames(value.object, names) } else if (value.type === 'ArrayExpression') { @@ -334,12 +532,44 @@ function publicSiteConfigProjection(program) { for (const expression of value.expressions) identityNames(expression, names) } else if (value.type === 'AwaitExpression' || value.type === 'YieldExpression') { identityNames(value.argument, names) + } else if (value.type === 'CallExpression' || value.type === 'NewExpression') { + for (const argument of value.arguments ?? []) identityNames(argument, names) } return names } + const taintedBindings = new Set(usedBindings) + let taintChanged = true + while (taintChanged) { + taintChanged = false + const propagateTaint = (node) => { + const source = node.type === 'VariableDeclarator' + ? node.init + : node.type === 'AssignmentExpression' && node.operator === '=' + ? node.right + : null + const target = node.type === 'VariableDeclarator' + ? node.id + : node.type === 'AssignmentExpression' && node.operator === '=' + ? node.left + : null + if ( + source + && target + && [...identityNames(source)].some((name) => taintedBindings.has(name)) + ) { + for (const name of identityNames(target)) { + if (taintedBindings.has(name)) continue + taintedBindings.add(name) + taintChanged = true + } + } + for (const [child] of childNodes(node)) propagateTaint(child) + } + propagateTaint(program) + } const rejectUsedIdentity = (node, detail) => { for (const name of identityNames(node)) { - if (usedBindings.has(name)) return unsupported(`${detail} ${name}`) + if (taintedBindings.has(name)) return unsupported(`${detail} ${name}`) } } const scanMutations = (node) => { @@ -352,7 +582,7 @@ function publicSiteConfigProjection(program) { && node.callee.type === 'MemberExpression' && node.callee.object.type === 'Identifier' && node.callee.object.name === 'Object' - && !bindings.has('Object') + && trustedObjectFreeze && memberNameFromNode(node.callee) === 'freeze' if (!safeObjectFreeze) { if (node.callee.type === 'MemberExpression') { @@ -433,8 +663,10 @@ function semanticBindingGraph(program, path) { const declaredIdentifiers = new WeakSet() const importedBindingsByIdentity = new Map() const reactNamespaceBindings = new Set() - const sideEffectImports = [] + const moduleEvaluationEdges = [] + let hasOpaqueReactSideEffect = false const synchronousCalls = [] + const unsupportedRootCalls = [] const unresolvedMutations = [] function declare(pattern, scope, semanticNode, metadata = {}) { @@ -451,6 +683,7 @@ function semanticBindingGraph(program, path) { dynamicIdentity: false, executionScope: nearestFunctionScope(scope), identifier, + shallowCopySources: new Set(), valueOrigins: new Set(valueOrigins), ...bindingMetadata, semanticNodes: new Set(semanticNode ? [semanticNode] : []), @@ -555,8 +788,22 @@ function semanticBindingGraph(program, path) { return } if (node.type === 'ImportDeclaration') { - if (node.importKind !== 'type' && (node.specifiers?.length ?? 0) === 0) { - sideEffectImports.push(node) + const runtimeSpecifiers = (node.specifiers ?? []).filter((specifier) => ( + specifier.importKind !== 'type' + )) + if ( + node.importKind !== 'type' + && ((node.specifiers?.length ?? 0) === 0 || runtimeSpecifiers.length > 0) + ) { + moduleEvaluationEdges.push(node.source) + } + if ( + node.importKind !== 'type' + && (node.specifiers?.length ?? 0) === 0 + && node.source?.value !== 'react' + && !String(node.source?.value ?? '').endsWith('.css') + ) { + hasOpaqueReactSideEffect = true } for (const specifier of node.specifiers ?? []) { if ( @@ -596,6 +843,24 @@ function semanticBindingGraph(program, path) { for (const [child, childKey] of childNodes(node)) build(child, scope, node, childKey) return } + if ( + node.type === 'ExportAllDeclaration' + && node.exportKind !== 'type' + && node.source + ) { + moduleEvaluationEdges.push(node.source) + } + if ( + node.type === 'ExportNamedDeclaration' + && node.source + && node.exportKind !== 'type' + && ( + (node.specifiers?.length ?? 0) === 0 + || node.specifiers.some((specifier) => specifier.exportKind !== 'type') + ) + ) { + moduleEvaluationEdges.push(node.source) + } if (node.type === 'TSEnumDeclaration' && node.id) declare(node.id, scope, node) for (const [child, childKey] of childNodes(node)) build(child, scope, node, childKey) } @@ -854,6 +1119,70 @@ function semanticBindingGraph(program, path) { } indexAliases(program) + function restTargetBindings(pattern, bindings = []) { + if (!pattern || typeof pattern !== 'object') return bindings + if (pattern.type === 'RestElement') { + reassignedBindings(pattern.argument, bindings) + } else if (pattern.type === 'ArrayPattern') { + for (const element of pattern.elements) restTargetBindings(element, bindings) + } else if (pattern.type === 'ObjectPattern') { + for (const property of pattern.properties) restTargetBindings(property, bindings) + } else if (pattern.type === 'Property') { + restTargetBindings(pattern.value, bindings) + } else if (pattern.type === 'AssignmentPattern') { + restTargetBindings(pattern.left, bindings) + } + return bindings + } + + function spreadSourceBindings(expression, bindings = []) { + if (!expression || typeof expression !== 'object') return bindings + if (expression.type === 'ArrayExpression') { + for (const element of expression.elements) { + if (element?.type === 'SpreadElement') identityBindings(element.argument, bindings) + } + } else if (expression.type === 'ObjectExpression') { + for (const property of expression.properties) { + if (property.type === 'SpreadElement') identityBindings(property.argument, bindings) + } + } + return bindings + } + + function addShallowCopySources(targets, sources) { + const sourceAliases = new Set( + sources.flatMap((binding) => [...binding.aliases]), + ) + for (const target of targets) { + for (const source of sourceAliases) target.shallowCopySources.add(source) + } + } + + function indexShallowCopies(node) { + if (node.type === 'VariableDeclarator' && node.init) { + addShallowCopySources( + reassignedBindings(node.id), + spreadSourceBindings(node.init), + ) + addShallowCopySources( + restTargetBindings(node.id), + identityBindings(node.init), + ) + } + if (node.type === 'AssignmentExpression' && node.operator === '=') { + addShallowCopySources( + reassignedBindings(node.left), + spreadSourceBindings(node.right), + ) + addShallowCopySources( + restTargetBindings(node.left), + identityBindings(node.right), + ) + } + for (const [child] of childNodes(node)) indexShallowCopies(child) + } + indexShallowCopies(program) + function exactOrigin(binding, origin) { return binding?.valueOrigins.size === 1 && binding.valueOrigins.has(origin) } @@ -957,6 +1286,7 @@ function semanticBindingGraph(program, path) { for (const alias of binding.aliases) patchedReactBindings.add(alias) } } + if (hasOpaqueReactSideEffect) markAllReactBindingsPatched() function indexPatchedReactBindings(node) { const target = node.type === 'AssignmentExpression' ? node.left @@ -966,8 +1296,12 @@ function semanticBindingGraph(program, path) { ? node.argument : null if (target?.type === 'MemberExpression') { - markReactBindingsPatched(identityBindings(target.object)) - if (containsDynamicIdentity(target.object)) markAllReactBindingsPatched() + const bindings = identityBindings(target.object) + markReactBindingsPatched(bindings) + if ( + containsDynamicIdentity(target.object) + || bindings.some((binding) => binding.dynamicIdentity) + ) markAllReactBindingsPatched() } const possiblyEscapedValues = [ ...(node.type === 'CallExpression' ? node.arguments ?? [] : []), @@ -976,8 +1310,12 @@ function semanticBindingGraph(program, path) { : []), ] for (const value of possiblyEscapedValues) { - markReactBindingsPatched(identityBindings(value)) - if (containsDynamicIdentity(value)) markAllReactBindingsPatched() + const bindings = identityBindings(value) + markReactBindingsPatched(bindings) + if ( + containsDynamicIdentity(value) + || bindings.some((binding) => binding.dynamicIdentity) + ) markAllReactBindingsPatched() } for (const [child] of childNodes(node)) indexPatchedReactBindings(child) } @@ -997,6 +1335,84 @@ function semanticBindingGraph(program, path) { return null } + function unwrapExpression(node) { + let value = node + while (value && EXPRESSION_WRAPPERS.has(value.type)) value = value.expression + return value + } + + function isUnboundGlobalIdentifier(node, names) { + const value = unwrapExpression(node) + return value?.type === 'Identifier' + && names.has(value.name) + && !lookup(value) + } + + function isKnownPromiseExpression(node) { + const value = unwrapExpression(node) + if (!value) return false + if ( + value.type === 'NewExpression' + && isUnboundGlobalIdentifier(value.callee, new Set(['Promise'])) + ) return true + if (value.type !== 'CallExpression' || value.callee.type !== 'MemberExpression') return false + const method = memberName(value.callee) + if ( + isUnboundGlobalIdentifier(value.callee.object, new Set(['Promise'])) + && ['all', 'allSettled', 'any', 'race', 'reject', 'resolve', 'try', 'withResolvers'].includes(method) + ) return true + return KNOWN_ASYNC_MEMBER_CALLEES.has(method) + && isKnownPromiseExpression(value.callee.object) + } + + function isKnownAsyncCallbackCall(call) { + const callee = unwrapExpression(call.callee) + if (isUnboundGlobalIdentifier(callee, KNOWN_ASYNC_CALLBACK_CALLEES)) return true + return callee?.type === 'MemberExpression' + && KNOWN_ASYNC_MEMBER_CALLEES.has(memberName(callee)) + && isKnownPromiseExpression(callee.object) + } + + function clientAsyncCallbackIndexes(call) { + if (!isKnownAsyncCallbackCall(call)) return [] + return (call.arguments ?? []).flatMap((argument, index) => ( + containsPotentialCallback(argument) ? [index] : [] + )) + } + + function isKnownSynchronousCallbackCall(call) { + const callee = unwrapExpression(call.callee) + if (callee?.type !== 'MemberExpression') return false + if (KNOWN_SYNC_CALLBACK_MEMBER_CALLEES.has(memberName(callee))) return true + return isUnboundGlobalIdentifier(callee.object, new Set(['Array'])) + && memberName(callee) === 'from' + } + + function containsPotentialCallback(node) { + const value = unwrapExpression(node) + if (!value || typeof value !== 'object') return false + if (value.type === 'ArrowFunctionExpression' || value.type === 'FunctionExpression') return true + if (value.type === 'MemberExpression' || value.type === 'CallExpression') return true + if (value.type === 'ConditionalExpression') { + return containsPotentialCallback(value.consequent) + || containsPotentialCallback(value.alternate) + } + if (value.type === 'LogicalExpression') { + return containsPotentialCallback(value.left) || containsPotentialCallback(value.right) + } + if (value.type === 'SequenceExpression') { + return value.expressions.some(containsPotentialCallback) + } + if (value.type !== 'Identifier') return false + const aliases = new Set( + identityBindings(value).flatMap((binding) => [...binding.aliases]), + ) + return [...aliases].some((binding) => ( + binding.callable + || (typeof binding.importSource === 'string' && binding.importSource.startsWith('.')) + )) + } + function isReactEffectCall(call) { const callee = call.callee if (callee.type === 'Identifier') { @@ -1035,14 +1451,53 @@ function semanticBindingGraph(program, path) { const patchedStaticOwners = new Set() const staticOwnerAliases = new Map() + const globalContainerAliases = new Set() function unboundGlobalContainer(node) { if (!node || typeof node !== 'object') return false if (EXPRESSION_WRAPPERS.has(node.type)) return unboundGlobalContainer(node.expression) - return node.type === 'Identifier' - && ['global', 'globalThis', 'self', 'window'].includes(node.name) - && !lookup(node) + if (node.type !== 'Identifier') return false + const binding = lookup(node) + return binding + ? globalContainerAliases.has(binding) + : ['global', 'globalThis', 'self', 'window'].includes(node.name) + } + + function indexGlobalContainerAliases() { + let changed = true + while (changed) { + changed = false + function visit(node) { + const target = node.type === 'VariableDeclarator' + && node.id.type === 'Identifier' + && node.init + ? node.id + : node.type === 'AssignmentExpression' + && node.operator === '=' + && node.left.type === 'Identifier' + ? node.left + : null + const source = node.type === 'VariableDeclarator' + ? node.init + : node.type === 'AssignmentExpression' + ? node.right + : null + const binding = target ? lookup(target) : null + if ( + binding + && source + && unboundGlobalContainer(source) + && !globalContainerAliases.has(binding) + ) { + globalContainerAliases.add(binding) + changed = true + } + for (const [child] of childNodes(node)) visit(child) + } + visit(program) + } } + indexGlobalContainerAliases() function globalStaticOwner(node) { if (!node || typeof node !== 'object') return null @@ -1098,11 +1553,21 @@ function semanticBindingGraph(program, path) { return globalStaticOwner(node.object) } + function markAllStaticOwnersPatched() { + for (const name of STATIC_TARGET_MUTATORS.keys()) patchedStaticOwners.add(name) + } + function indexPatchedStaticOwners(node) { if (node.type === 'AssignmentExpression' || node.type === 'UpdateExpression') { const target = node.type === 'AssignmentExpression' ? node.left : node.argument const owner = mutationMemberOwner(target) ?? globalStaticOwner(target) if (owner) patchedStaticOwners.add(owner) + if ( + target.type === 'MemberExpression' + && unboundGlobalContainer(target.object) + && target.computed + && memberName(target) === null + ) markAllStaticOwnersPatched() } if (node.type === 'UnaryExpression' && node.operator === 'delete') { const owner = mutationMemberOwner(node.argument) ?? globalStaticOwner(node.argument) @@ -1119,9 +1584,6 @@ function semanticBindingGraph(program, path) { patchedStaticOwners.add(globalStaticOwner(node.arguments[0])) } if (unboundGlobalContainer(node.arguments?.[0])) { - const patchAllOwners = () => { - for (const name of STATIC_TARGET_MUTATORS.keys()) patchedStaticOwners.add(name) - } const patchOwnerKey = (key) => { const name = ( key?.type === 'StringLiteral' @@ -1130,16 +1592,16 @@ function semanticBindingGraph(program, path) { ? key.value : null if (name && STATIC_TARGET_MUTATORS.has(name)) patchedStaticOwners.add(name) - else if (!name) patchAllOwners() + else if (!name) markAllStaticOwnersPatched() } const patchObjectKeys = (value) => { if (value?.type !== 'ObjectExpression') { - patchAllOwners() + markAllStaticOwnersPatched() return } for (const property of value.properties) { if (property.type !== 'Property' || property.computed) { - patchAllOwners() + markAllStaticOwnersPatched() continue } patchOwnerKey(property.key) @@ -1155,7 +1617,7 @@ function semanticBindingGraph(program, path) { ) { patchOwnerKey(node.arguments?.[1]) } else if (owner && STATIC_TARGET_MUTATORS.get(owner)?.has(method)) { - patchAllOwners() + markAllStaticOwnersPatched() } } } @@ -1163,6 +1625,26 @@ function semanticBindingGraph(program, path) { } indexPatchedStaticOwners(program) + function shallowCopyMutationSources(target) { + let value = target + while (value && EXPRESSION_WRAPPERS.has(value.type)) value = value.expression + let depth = 0 + while (value?.type === 'MemberExpression') { + depth += 1 + value = value.object + while (value && EXPRESSION_WRAPPERS.has(value.type)) value = value.expression + } + if (depth < 2) return new Set() + const roots = new Set(identityBindings(value)) + const sources = new Set() + for (const root of roots) { + for (const alias of root.aliases) { + for (const source of alias.shallowCopySources) sources.add(source) + } + } + return sources + } + function indexWrites(node) { const executionScope = nearestFunctionScope(nodeScopes.get(node)) const semanticNode = enclosingExecutionStatement(node, executionScope) @@ -1200,6 +1682,16 @@ function semanticBindingGraph(program, path) { semanticNode, }) } + for (const source of shallowCopyMutationSources(target)) { + for (const alias of source.aliases) { + alias.writes.push({ + executionScope, + kind: 'ambiguous-mutation', + mutationNode: node, + semanticNode, + }) + } + } } if (node.type === 'AssignmentExpression') { record(node.left, 'write', { @@ -1256,21 +1748,10 @@ function semanticBindingGraph(program, path) { const callableIsInspectable = [...invocationAliases].some((binding) => ( !binding.importSource || binding.importSource.startsWith('.') )) - const hasPotentialCallback = (node.arguments ?? []).some((argument) => { - let value = argument - while (EXPRESSION_WRAPPERS.has(value.type)) value = value.expression - if ( - value.type === 'ArrowFunctionExpression' - || value.type === 'FunctionExpression' - ) return true - const aliases = new Set( - identityBindings(value).flatMap((binding) => [...binding.aliases]), - ) - return [...aliases].some((binding) => ( - binding.callable - || (typeof binding.importSource === 'string' && binding.importSource.startsWith('.')) - )) - }) + const hasPotentialCallback = (node.arguments ?? []).some(containsPotentialCallback) + const inlineCallee = unwrapExpression(callee) + const inlineCallable = inlineCallee?.type === 'ArrowFunctionExpression' + || inlineCallee?.type === 'FunctionExpression' if ([...calleeAliases].some((binding) => binding.dynamicIdentity)) { unresolvedMutations.push({ executionScope, @@ -1287,12 +1768,23 @@ function semanticBindingGraph(program, path) { semanticNode, }) } - if ((callableIsInspectable || hasPotentialCallback) && !isReactEffectCall(node)) { + if ( + callableIsInspectable + || inlineCallable + || (hasPotentialCallback && isKnownSynchronousCallbackCall(node)) + ) { synchronousCalls.push({ executionScope, mutationNode: node, semanticNode, }) + } else if (hasPotentialCallback && !isKnownAsyncCallbackCall(node)) { + unsupportedRootCalls.push({ + executionScope, + message: 'Unsupported callback execution timing', + mutationNode: node, + semanticNode, + }) } const method = callee.type === 'MemberExpression' ? memberName(callee) : null const staticOwner = callee.type === 'MemberExpression' @@ -1390,15 +1882,44 @@ function semanticBindingGraph(program, path) { semanticNode, }) } - if (callableIsInspectable) { - synchronousCalls.push({ + synchronousCalls.push({ + executionScope, + mutationNode: node, + semanticNode, + }) + if (!callableIsInspectable) { + for (const argument of node.arguments ?? []) { + record(argument, 'ambiguous-mutation', { trackDynamic: false }) + } + } + } + if (node.type === 'TaggedTemplateExpression') { + const tagBindings = new Set(identityBindings(node.tag)) + const tagAliases = new Set( + [...tagBindings].flatMap((binding) => [...binding.aliases]), + ) + const tagIsInspectable = [...tagAliases].some((binding) => ( + !binding.importSource || binding.importSource.startsWith('.') + )) + if ( + tagBindings.size === 0 && containsDynamicIdentity(node.tag) + || [...tagAliases].some((binding) => binding.dynamicIdentity) + ) { + unresolvedMutations.push({ executionScope, + message: 'Unsupported dynamic tag target', mutationNode: node, semanticNode, }) - } else { - for (const argument of node.arguments ?? []) { - record(argument, 'ambiguous-mutation', { trackDynamic: false }) + } + synchronousCalls.push({ + executionScope, + mutationNode: node, + semanticNode, + }) + if (!tagIsInspectable) { + for (const expression of node.quasi.expressions ?? []) { + record(expression, 'ambiguous-mutation', { trackDynamic: false }) } } } @@ -1482,22 +2003,34 @@ function semanticBindingGraph(program, path) { relation && relation.key === 'arguments' && relation.parent.type === 'CallExpression' - && isReactEffectCall(relation.parent) + && ( + isReactEffectCall(relation.parent) + || isKnownAsyncCallbackCall(relation.parent) + ) ) return true currentScope = currentScope.parent } return false } + function isClientOnlyIntrinsicAttribute(node) { + const relation = nodeParents.get(node) + return relation?.parent.type === 'JSXOpeningElement' + && isClientOnlyIntrinsicJsxAttribute(node, relation.parent) + } + return { childNodes, + clientAsyncCallbackIndexes, executionScopeFor: (node) => nearestFunctionScope(nodeScopes.get(node)), isClientOnlyEffectScope, isClientEffectCall: isReactEffectCall, + isClientOnlyIntrinsicAttribute, isReference, lookup, - sideEffectImports, + moduleEvaluationEdges, synchronousCalls, + unsupportedRootCalls, unresolvedMutations, } } @@ -1513,7 +2046,10 @@ export function semanticSourceForDigest(path, source) { return JSON.stringify(publicSiteConfigProjection(program)) } const bindingGraph = semanticBindingGraph(program, path) - const projectionOptions = { isClientEffectCall: bindingGraph.isClientEffectCall } + const projectionOptions = { + clientAsyncCallbackIndexes: bindingGraph.clientAsyncCallbackIndexes, + isClientEffectCall: bindingGraph.isClientEffectCall, + } if (!rootName) return JSON.stringify(runtimeAstProjection(program, projectionOptions)) const roots = [] function findRoots(node) { @@ -1532,14 +2068,19 @@ export function semanticSourceForDigest(path, source) { const rootExecutionScope = bindingGraph.executionScopeFor(roots[0]) const visitedBindings = new Set() const pendingNodes = [roots[0]] - for (const sideEffectImport of bindingGraph.sideEffectImports) { - slices.add(sideEffectImport) - pendingNodes.push(sideEffectImport) + for (const moduleEvaluationEdge of bindingGraph.moduleEvaluationEdges) { + slices.add(moduleEvaluationEdge) + pendingNodes.push(moduleEvaluationEdge) } const executesDuringRootRender = (entry) => ( entry.executionScope === rootExecutionScope || entry.executionScope.ownerNode?.type === 'Program' ) + for (const entry of bindingGraph.unsupportedRootCalls) { + if (!executesDuringRootRender(entry)) continue + if (bindingGraph.isClientOnlyEffectScope(entry.executionScope)) continue + throw new Error(`${entry.message} in ${path}`) + } for (const entry of [ ...bindingGraph.synchronousCalls, ...bindingGraph.unresolvedMutations, @@ -1553,6 +2094,7 @@ export function semanticSourceForDigest(path, source) { while (pendingNodes.length > 0) { const selectedNode = pendingNodes.pop() function collectReferences(node) { + if (bindingGraph.isClientOnlyIntrinsicAttribute(node)) return if ( (node.type === 'Identifier' || node.type === 'JSXIdentifier') && bindingGraph.isReference(node) diff --git a/site/scripts/semantic-source-graph.test.mjs b/site/scripts/semantic-source-graph.test.mjs index bbd5249..9ba6168 100644 --- a/site/scripts/semantic-source-graph.test.mjs +++ b/site/scripts/semantic-source-graph.test.mjs @@ -601,7 +601,7 @@ test('mixed-module closure tracks every supported mutation of selected bindings' const patchedReflectApply = `import { state, prepare } from './store'\nglobalThis.Reflect = { apply(callable) { return callable() } }\nfunction AppContent() { Reflect.apply(prepare, null, []); return {state.value} }` assert.throws( () => semanticSourceForDigest('site/src/app/AppContent.tsx', patchedReflectApply), - /Unsupported ambiguous mutation of prepare/, + /Unsupported/, ) }) @@ -660,7 +660,7 @@ test('mixed-module closure recognizes React effects by import provenance', () => const patchedReactEffect = `import * as React from 'react'\nReact.useEffect = callback => callback()\nfunction AppContent() { let label = '正文'; React.useEffect(() => { label = '同步变化' }, []); return {label} }` assert.throws( () => semanticSourceForDigest('site/src/app/AppContent.tsx', patchedReactEffect), - /Unsupported ambiguous mutation of React/, + /Unsupported/, ) const wrappedCallbackBefore = `import { useEffect } from 'react'\nfunction AppContent() { let label = '正文'; useEffect((() => { label = '客户端一' }) as () => void, []); return {label} }` @@ -780,6 +780,147 @@ test('reviewed runtime boundaries cannot hide public render changes', () => { ) }) +test('secondary review boundaries preserve SSR precision and fail closed', () => { + for (const runtimeSource of [ + `const state = { value: '正文' }\nfunction current() { return state }\nfunction tag(_strings, target) { target.value = '变化一' }\nfunction AppContent() { tag\`${'${state}'}\`; return {state.value} }`, + `const state = { value: '正文' }\nfunction current() { return state }\nfunction AppContent() { (() => { current().value = '变化一' })(); return {state.value} }`, + `const state = { value: '正文' }\nfunction current() { return state }\nfunction AppContent() { new (class { constructor() { current().value = '变化一' } })(); return {state.value} }`, + `const state = { value: '正文' }\nfunction current() { return state }\nfunction AppContent() { new Promise(resolve => { current().value = '变化一'; resolve() }); return {state.value} }`, + `const state = { value: '正文' }\nfunction current() { return state }\nfunction AppContent() { Array.from([1], true ? () => { current().value = '变化一' } : () => {}); return {state.value} }`, + ]) { + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', runtimeSource), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + runtimeSource.replace('变化一', '变化二'), + ), + ) + } + assert.throws( + () => semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + `const state = { value: '正文' }\nfunction current() { return state }\nconst callbacks = { run: () => { current().value = '变化一' } }\nfunction AppContent() { Array.from([1], callbacks.run); return {state.value} }`, + ), + /Unsupported ambiguous mutation of callbacks/, + ) + + for (const shallowCopy of [ + `const state = { nested: { value: '正文' } }\nconst copy = { ...state }\nfunction AppContent() { copy.nested.value = '变化一'; return {state.nested.value} }`, + `const state = { nested: { value: '正文' } }\nconst { ...copy } = state\nfunction AppContent() { copy.nested.value = '变化一'; return {state.nested.value} }`, + ]) { + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', shallowCopy), + /Unsupported ambiguous mutation of state/, + ) + } + + for (const moduleEvaluation of [ + `import { unused } from 'ssr-patch-a'\nfunction AppContent() { return 正文 }`, + `export * from 'ssr-patch-a'\nfunction AppContent() { return 正文 }`, + ]) { + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', moduleEvaluation), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + moduleEvaluation.replace('ssr-patch-a', 'ssr-patch-b'), + ), + ) + } + + for (const patchedReact of [ + `import React from 'react'\nfunction getReact() { return React }\nconst alias = getReact()\nalias.useEffect = callback => callback()\nfunction AppContent() { let label = '正文'; React.useEffect(() => { label = '同步一' }, []); return {label} }`, + `import './patch-react'\nimport React from 'react'\nfunction AppContent() { let label = '正文'; React.useEffect(() => { label = '同步一' }, []); return {label} }`, + ]) { + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', patchedReact), + /Unsupported/, + ) + } + + for (const patchedOwner of [ + `const state = { value: '正文' }\nconst root = globalThis\nroot.Object = { assign(target) { target.value = '变化' } }\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`, + `const state = { value: '正文' }\nglobalThis['Ob' + 'ject'] = { assign(target) { target.value = '变化' } }\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`, + ]) { + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', patchedOwner), + /Unsupported ambiguous mutation of state/, + ) + } + + const intrinsicClientBefore = `function AppContent() { return }` + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', intrinsicClientBefore), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + intrinsicClientBefore.replace('客户端一', '客户端二').replace('引用一', '引用二'), + ), + ) + const intrinsicDeferredWrite = `function AppContent() { let label = '正文'; return }` + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', intrinsicDeferredWrite), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + intrinsicDeferredWrite.replace('客户端一', '客户端二'), + ), + ) + assert.notEqual( + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + `function AppContent() { return track('正文一')} /> }`, + ), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + `function AppContent() { return track('正文二')} /> }`, + ), + ) + + for (const asyncCallback of [ + `function AppContent() { setTimeout(() => track('客户端一'), 0); return 正文 }`, + `function AppContent() { queueMicrotask(() => track('客户端一')); return 正文 }`, + `function AppContent() { Promise.resolve().then(() => track('客户端一')); return 正文 }`, + `function AppContent() { let label = '正文'; setTimeout(() => { label = '客户端一' }, 0); return {label} }`, + `function AppContent() { new Promise(resolve => { setTimeout(() => track('客户端一'), 0); resolve() }); return 正文 }`, + ]) { + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', asyncCallback), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + asyncCallback.replace('客户端一', '客户端二'), + ), + ) + } +}) + +test('public config rejects alias mutation, pattern writes, and patched freeze', () => { + for (const mutableConfig of [ + publicConfigSource( + `const shared = { name: '初始' }\nconst alias = shared\nalias.name = '运行时'`, + '...shared', + ), + publicConfigSource( + `const shared = { name: '初始' }\n({ name: shared.name } = { name: '运行时' })`, + '...shared', + ), + publicConfigSource( + `Object.freeze = value => { value.name = '运行时'; return value }`, + `name: '初始'`, + ), + publicConfigSource( + `const root = globalThis\nroot.Object = { freeze(value) { value.name = '运行时'; return value } }`, + `name: '初始'`, + ), + publicConfigSource( + `Reflect.set(Object, 'freeze', value => { value.name = '运行时'; return value })`, + `name: '初始'`, + ), + ]) { + assert.throws( + () => semanticSourceForDigest('site/src/config/site.ts', mutableConfig), + /Unsupported public site configuration/, + ) + } +}) + test('static dependency discovery excludes specifier-level type-only edges', () => { assert.deepEqual( staticImportSpecifiersForSource( diff --git a/site/scripts/seo-contract.test.mjs b/site/scripts/seo-contract.test.mjs index 9b2dbfd..95e3be2 100644 --- a/site/scripts/seo-contract.test.mjs +++ b/site/scripts/seo-contract.test.mjs @@ -232,7 +232,7 @@ test('discovery files expose the 47 approved URLs and real summaries', async () assert.match(lastModified, /--format=%cI/) assert.match(lastModified, /normalizeContentForDigest/) assert.match(lastModified, /semanticRouteFiles/) - assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 9) + assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 11) assert.equal(Object.keys(ROUTE_CONTENT_DIGESTS).length, PUBLIC_PATHS.length) assert.ok(Object.values(ROUTE_CONTENT_DIGESTS).every((digest) => /^[0-9a-f]{64}$/.test(digest))) assert.match(publicRoutes, /\.\.\/data\/catalog\.ts/) diff --git a/site/src/data/routeLastModified.ts b/site/src/data/routeLastModified.ts index 1b92770..e42a188 100644 --- a/site/src/data/routeLastModified.ts +++ b/site/src/data/routeLastModified.ts @@ -1,5 +1,5 @@ // 由 scripts/generate-seo.mjs 从源码时间证据生成,请勿手改。 -export const ROUTE_CONTENT_DIGEST_VERSION = 9 +export const ROUTE_CONTENT_DIGEST_VERSION = 11 export const ROUTE_LAST_MODIFIED: Readonly> = Object.freeze({ "/": "2026-08-22T23:46:11+08:00", "/part/a": "2026-08-22T11:47:40+08:00", @@ -50,51 +50,51 @@ export const ROUTE_LAST_MODIFIED: Readonly> = Object.free "/problems": "2026-08-22T11:47:40+08:00" }) export const ROUTE_CONTENT_DIGESTS: Readonly> = Object.freeze({ - "/": "8bedf7f5019e3131f52074076d3437affb2491dc7b421d7997f9bfe07b2a487a", - "/part/a": "7f892f41a366db13ed3b6916d6a9643e81681cda91f8e8087cb2e757b41480bd", - "/part/b": "200a52fee93bfc705b67cf379f9b00370fc6e0b7eba5d1eb34c6c5f2a7972342", - "/part/c": "8bc114d37d037a3cd07427abde61db7dc7388c702228a1722b98aae4bb131d58", - "/part/d": "d489bdc0d4a10283fef240fd45ccf231f5e147ca1762ff7c1e86087479bf42d4", - "/part/e": "358cecd1e747080119908160abe869549054ec3369e269945baef7474ea9a61a", - "/part/f": "0d0c9c7594e2329c41c067708756c29a30535c081fffed26ad5200b717ede07a", - "/part/g": "20def84c52b55c7a1bd1d643c30cf376e9c1d63ace9c0f2cef6ddef29155ef3d", - "/part/a/01": "130fd31beb0de00df49aecb433bfd0c8f54420001f08d695a0be27f5c7221331", - "/part/a/complete": "c2c2311925a8c733b1f69eecd22e0e9417cdebd973a59efbb6b41444031b0af4", - "/part/a/multiple": "3db6cdfab8cf8eb41b17090dab3e917fb16a6a9be7648c9647a56a4151a2723c", - "/part/a/group": "6efd8f25ac26e4f10f6c2415c66f9d49c6ffa5dc5ba3a6c5bee538deb3639a86", - "/part/a/mixed": "b57f08a81c13a31c2ddd5e060c7c10c4acb8e498a340a0353ab811b847b766f2", - "/part/a/cost2d": "0bc95de3b2b03e57a4a8dc0846f7aa45469315cf4812aecb3b1c8018c3a44b60", - "/part/a/dep": "3fd85259441d11b37a499829d0b42bd25bf4a5a1f495e7fe6365ca437b494060", - "/part/a/variant": "d7efbed7be374272f9aa974dd3fe40e9640a19797996d970906f3c7b2921cb8d", - "/part/a/fractional": "921f756682c996afe773ee47567cd2dbfdeb339fb84827bf2fba49e70609b0d7", - "/part/b/path": "85e13149ff16d753e12a06c56b65efeee1732de09161a7da6a7ea18d71190f92", - "/part/b/maxseg": "7004fd5fe4f947caca074cc2437f07e02d743a99c9e24fe32a6ab58a1e3bded7", - "/part/b/lis": "36ebcbe90680d3a8a74aa3dea37796c975333caf09fc2e1b03bcdd6631f66cec", - "/part/b/lcs": "07b82fe1f93d7ad84157d6395565ed0d556316c44b929264c242628175de2ee3", - "/part/b/edit": "f2c53e6f43fa2e099569e472522e60686df5944681e39a3664a003cf26897530", - "/part/b/fsm": "9ba4ace249bb9e004c4a52dd7dba88b9a354d90d25de613c9894b3252f4b8ab8", - "/part/b/count": "e83fb7cf0e215ec4f96cd65f7cef9d158a0ae36e8eda3499d24064c7cba5c67a", - "/part/c/stone": "7745612d24ad770da135bddbd9b64b83c147aa9037aff3668795906600506da7", - "/part/c/ring": "d821c4cdaabfafbda914f526f78b9ff558f4ec100d06a7033fcfeacd0111b8e7", - "/part/c/palindrome": "2ea60d7b33379dea5054714c1d9c80acf263b06929fb4e09c64ab9ccbb40de0d", - "/part/c/tree": "85e633c7856709553fbafb4bae3c19862edb421a22beb2c7adc667a01a0bb995", - "/part/c/merge": "b5a28245d0b3f64fa042368adcf8e590eb22cb346b24f93eeef52e283d4c6158", - "/part/d/grid": "633f7942ef30b6bd1688abe06e74567eb08bbea2f52af9ce821cdacc7c6c965a", - "/part/d/matpow": "9cea494cf6834c3bf8b469dc37c487c4ac1e2aac96ae42f7dcc7a4f31d8b8bed", - "/part/e/basic": "836fc38485c999b50ceb5286d59a4fa10cf78b235dfde7ad45d51274c977b713", - "/part/e/distsum": "dbd85df5503757d74c5dbffdaaf4da1c4a7b04d6415f1a37d8a36eb5d5a38c85", - "/part/e/inout": "045e3047e943df7c2e033afe29835966a253074918aa6072d1da9af517547bdb", - "/part/e/center": "5a5cf03b448b696d1f6166f93a6c0802c3979fbea5a4974945bcccf33dcf408f", - "/part/f/select": "3c467669c773355d2925f3c0ef5c68f68c3e2daf197195476ff1394eb62f6969", - "/part/f/knapsack": "4495bcaf7f526f8052bbc4397509e0c998319c93fa1e43d70d2159e789b2d26a", - "/part/f/diameter": "a2ebd4496c96d9d39f18e44964b7ad866edc0958afbeae8680f4c943fcdf96c9", - "/part/f/cover": "5256123fe9e59c6baed4286fb880c09fc1cd394beb18159781b16916afbcf014", - "/part/f/count": "a5ad5e079aa3903a838560ed225e1c9f18bcafaa7f7af8ae958e68e4880cbad2", - "/part/g/board": "36541b6654ac0a28ca6c789d9bea3b75fa661f5f30f3f87636745e98a35cd995", - "/part/g/tsp": "771e4e564eeab4604a4d148352173472c0d16238b4d3c85d5424e8ef5ccd8d9f", - "/part/g/cover": "0f4bbcaeb82402e0cc5c5d0c647588f52d3e144fe491202755ea70532fc5b7e8", - "/part/g/subset": "e178cf6b4eb02b42c51b06df99d87ee2fa2534f68669ec081074e05bfe779f2b", - "/part/g/plug": "7621e7734d23a8dcec6f18b3fac338f6029af5cbd9e9b4994b080682a4f76286", - "/method": "131cd9ea4a72688c4feef6bb148937c710d238b5368e160dcb864823317640c6", - "/problems": "07afec08919aabad5c2e0ea9ae27d07d94a66c90a84eb180910993cc630a768c" + "/": "5b92f2a3051cacc9a6ca1b0254938d610febd387ca072362afbf1bd3089be359", + "/part/a": "54fbccbe4a943f05dc0f5b0ff837d76aceabd4740819a82bb164137c674ee4a6", + "/part/b": "d7e072c76db9717893104f5407ef459528e56fbd2a52003e5d9bcc2d0683ca6f", + "/part/c": "a81765be39ac925c8c3826c21ffe5041759f3150d4db131087f7856fe3652401", + "/part/d": "bec16b28d86172f02e4f66ad2ad614e657fb0e4f23a93a8855008a965e35af29", + "/part/e": "58f32f7a33e79eb7bf2e41ff49d47c1660caa1f2ba783ac64b463d3a206f6eb6", + "/part/f": "d0991c67d1db79998e12ad37ef0095ea6331a6d9db2aa4e77fba2fbda5c7ef25", + "/part/g": "7cb9499923ac96266ced3b9ba42f01434df344f97521fe54884d28f16aecd7c9", + "/part/a/01": "3c5792904ed5bb4ab9916981b027bbc10fe1606e4fa94819f373bcc19afd4eea", + "/part/a/complete": "1811291a53bcce925bd84d01794f24b30f352d26c4f79db6087bda0b16555395", + "/part/a/multiple": "3031c3a8fc8b9ead70d272c91b918922b00279a89432e0279707c461e87706c6", + "/part/a/group": "29a4f617bc59bb74faa51d65df8140414fb5eaaf785648c343893589aea28119", + "/part/a/mixed": "e3caa2d98d9795f2bdb02c86ac23edcab2e5bb047cbd835325804e487eaeca46", + "/part/a/cost2d": "cb7b028a96abb4f235bad839f370ad1f9d3de078e1d7bdfd3f159c80d9131b91", + "/part/a/dep": "9ac27bb781665cde81fe7b564ee8708a0ae6ef053a84810c3d5e333f8aa66b6a", + "/part/a/variant": "ad8ff88ba296d4a21735d0bc25938ba023924526cad5bf25db758502fd9f599a", + "/part/a/fractional": "c672ca6b48915d180cbbfbbefcc4563ebb47f6ed52cbbec7c42cf4c4cb6031e9", + "/part/b/path": "a7fdf87df5bc2db70d288ee1b79a5c95bc370a8a6d41caf8708264b4cf10ebda", + "/part/b/maxseg": "51fee070dfc7f6c7c41d40a7109bd5935875218173a66a1ebf74322440dfc8de", + "/part/b/lis": "c4400caebcc138a257090119ac203e886fc90a703ce3ca2424b59de7493f96de", + "/part/b/lcs": "7762c4c49e42878eea574012303238159c9da4ffbdccea18a684769074f14d89", + "/part/b/edit": "e809af30a6f49b6765d3f34a86ffe34cd0fd0883715aced4d8465c710caaa56c", + "/part/b/fsm": "36318dfbd7a63f2fa6ec526c11a77a3fdbb85bfc13dd5c0f450d2745928270ad", + "/part/b/count": "ffcd0e0e4df81124a6860c0a4961f8128333311b4f8ec6c5c520577b18171f4c", + "/part/c/stone": "182cb6883b87340127aa526365206838508cb5e0ce0716bf9272b60eb01c53bf", + "/part/c/ring": "7828597294e0458eeeab3f1ffad378d4d03a44ecef38e5202afb0dee3109001f", + "/part/c/palindrome": "133e69adae07927b6bfde3ff1a2c30eaf9f1cea61dbface9d442a5e0e55e468d", + "/part/c/tree": "abd18681b04f2a39f671a0d02f001e21158ade7a3e29152a878fe5b7723686cd", + "/part/c/merge": "295eac4cc8e3d9f953c15165824129ca63d6289f21bb9c9d78b26a6920504b8a", + "/part/d/grid": "f10d90793e9e0fca366b53b61b9bf44e5d06d2bf6369b12e0ad3754a37710c4f", + "/part/d/matpow": "314b8ee76d6dfe6c56a7913fb9d0922d782c32105b278870efa86d94731539bc", + "/part/e/basic": "9797b32c05255b64c07e56bcaa7cb1071f3c7ad2be6afab5479553d942f8cce2", + "/part/e/distsum": "3202e75a7dfb796f10aeebad9e14f2bb9e9b37092dacf73a64c2cd2fbb7e0d65", + "/part/e/inout": "96cfb9860ac6331322b95f792ff65a11adcac458aa8d828b46caf21cc324b743", + "/part/e/center": "f68e225ba578e5c51683559ee3e73c22dc0014a8d7d1480a953b6b7ac0bfa9f6", + "/part/f/select": "6202ff47fd99d71c6e99d327346c0ed400eeeb8c938c850a6fde1a8cf4cb45ac", + "/part/f/knapsack": "02c70f7726a3fe387ca3582b36ce43ebe91ef7fa95a2e2ae9d029beefd135253", + "/part/f/diameter": "24a716df998f04ef6ed867531b1e69840e6b73300ba751752618d2b263d41d4c", + "/part/f/cover": "e2985dabd895273e0844ab1d139f40df2fae66e5c5cac19c5676a54ac019cdc5", + "/part/f/count": "a4cd7a916cff682d105481599e07fd377be868644dac58e84e86460d9a22e280", + "/part/g/board": "4520f021ea95bde804d999e44c4ebd5b3cf83a8411988454b13ebd771cc9b677", + "/part/g/tsp": "51cc477c7edc022cc9058fcca48f2bd583a2f46dc609e9c8a346e44bf8554fc3", + "/part/g/cover": "f0b3820a6cefb07ae964d6f7190f0e4bc50326ff1c224ff307a82c0cf81f6f8e", + "/part/g/subset": "affb056a469c88d2010a149444dd57f305f10dd93b5066fdaa4c1a1d1ae80ed7", + "/part/g/plug": "612f6a700a027932de7eaffc6b788b330e8c44d3d18bb64bcd175c48c7c01ef9", + "/method": "a0fee483e6308ce8dbcdaa6ea8a889ace667143e9d975bd3e60eb6dabbb3006c", + "/problems": "438a2a33fefed906dd3407926d5e2fb0ac1cfbea882d94abd3c4c103ab77e8e8" }) From e0b015b4de646e8e44a0fe35e7a8f379cb6153a4 Mon Sep 17 00:00:00 2001 From: Shanire Date: Wed, 26 Aug 2026 16:25:07 +0800 Subject: [PATCH 14/23] fix: preserve semantic callback acquisition Signed-off-by: Shanire --- docs/engineering/architecture.md | 2 +- docs/operations/verification.md | 4 +- site/scripts/last-modified.mjs | 2 +- site/scripts/semantic-source-graph.mjs | 570 ++++++++++++++++++-- site/scripts/semantic-source-graph.test.mjs | 111 ++++ site/scripts/seo-contract.test.mjs | 2 +- site/src/data/routeLastModified.ts | 96 ++-- 7 files changed, 703 insertions(+), 84 deletions(-) diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index ac8da9c..7f332ab 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -114,7 +114,7 @@ Every parsed source contribution is reduced to a runtime AST projection, so Type Effect-only callbacks are excluded only when the callee has one exact, stable React-effect origin through named, default, namespace, member, or destructured aliases. Mixed aliases, reassigned hooks, patched React namespaces, and same-named local helpers are never pruned as effects. Runtime import edges remain while declaration- and specifier-level type-only edges, unselected import siblings, object-spread copies, and JSX attribute names do not leak in. The graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Public representation identity is structurally isolated in `config/site.ts`; client endpoints and sidebar-only copy are in modules reachable only through pruned branches. -Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. An unchanged digest preserves the previous value; changed content uses the precise Git commit or working-tree timestamp of a semantically changed projection and must advance. During a digest-schema migration, the current projector is applied to historical sources at the previous `lastmod`: a clean committed semantic edit advances, while an edit confined to a pruned client branch does not. Version 11 retains the trusted `2026-08-22` evidence while closing tagged-template, inline constructor/IIFE, callback-timing (including nested asynchronous callbacks inside selected synchronous slices), shallow-copy, module-evaluation, intrinsic-client-attribute, cross-module React-patch, global-container alias, and public-config alias/provenance gaps. It also prevents historical client-only `config/site.ts` fields from falsely advancing the home route. The home route therefore remains at `23:46:11+08:00`, and the other 46 routes remain at `11:47:40+08:00`. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, type-only noise, or incomplete execution/scope/mutation context. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. +Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. An unchanged digest preserves the previous value; changed content uses the precise Git commit or working-tree timestamp of a semantically changed projection and must advance. During a digest-schema migration, the current projector is applied to historical sources at the previous `lastmod`: a clean committed semantic edit advances, while an edit confined to a pruned client branch does not. Version 12 retains the trusted `2026-08-22` evidence while closing tagged-template, inline constructor/IIFE, callback-timing (including synchronous callback factories and stable named asynchronous callbacks), shallow-copy, module-evaluation, intrinsic-client-attribute and spread, cross-module React-patch, global-owner/prototype mutation and destructuring, external synchronous call, unstable-alias, and public-config provenance gaps. It also prevents historical client-only `config/site.ts` fields from falsely advancing the home route. The home route therefore remains at `23:46:11+08:00`, and the other 46 routes remain at `11:47:40+08:00`. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, type-only noise, or incomplete execution/scope/mutation context. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. # Analytics diff --git a/docs/operations/verification.md b/docs/operations/verification.md index ffee3b7..67758cd 100644 --- a/docs/operations/verification.md +++ b/docs/operations/verification.md @@ -44,9 +44,9 @@ Pure TypeScript Modules imported by Node tests must use explicit `.ts` extension # SEO And Accessibility Checks -`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. Version 11 follows each route's real SSR injection chain with a line-ending-neutral runtime-AST digest: type-only syntax is erased without dropping runtime-bearing TypeScript parameter properties or optional-chain behavior; intrinsic event/ref/key props are erased without dropping custom-component or SSR-bearing props; public `config/site.ts` evidence follows immutable field dependencies and validates global provenance; and mixed shell modules contribute route-rendering JSX plus the lexical closure of all runtime module-evaluation edges, root/program synchronous calls, proven synchronous callbacks, constructors, tagged templates, aliases, and render-phase mutations. Known asynchronous callbacks are erased even when nested inside an otherwise selected synchronous slice. Regression probes cover scope/control flow, stable and late aliases, destructuring defaults, direct and nested shallow-copy precision, call/getter/constructor/tag-created targets, local and cross-module callables, synchronous versus known asynchronous callbacks, `Reflect.apply`, patched/replaced global mutators and containers, exact React-effect aliases, side-effect module patches, mixed or patched hooks, and historical public-config projection. Unsupported mutable, escaped, aliased, globally patched, or dynamic config, unproved callback timing, dynamic targets, imported member receivers, arbitrary external mutations, and ambiguous nested writes outside proven React client-effect callbacks fail closed. +`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. Version 12 follows each route's real SSR injection chain with a line-ending-neutral runtime-AST digest: type-only syntax is erased without dropping runtime-bearing TypeScript parameter properties or optional-chain behavior; intrinsic event/ref/key props and statically proven spreads erase only deferred callback values while retaining synchronous factories; public `config/site.ts` evidence follows immutable field dependencies and validates global provenance; and mixed shell modules contribute route-rendering JSX plus the lexical closure of all runtime module-evaluation edges, root/program synchronous calls, proven synchronous callbacks, constructors, tagged templates, aliases, and render-phase mutations. Known asynchronous callbacks, including stable named callbacks, are erased even when nested inside an otherwise selected synchronous slice, while callback acquisition remains synchronous evidence. Regression probes cover scope/control flow, stable and unstable aliases, destructuring defaults and global owners, direct and nested shallow-copy precision, call/getter/constructor/tag-created targets, local and cross-module callables, synchronous versus known asynchronous callbacks, patched timer/Promise/collection semantics, `Reflect.apply`, patched/replaced global mutators and containers, exact React-effect aliases, side-effect module patches, mixed or patched hooks, and historical public-config projection. Unsupported mutable, escaped, aliased, globally patched, or dynamic config, unproved callback timing, dynamic targets or intrinsic spreads, imported member receivers, arbitrary external mutations, and ambiguous nested writes outside proven client callbacks fail closed. -Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. An unchanged digest preserves its prior time; changed content uses the exact Git commit or working-tree timestamp of a semantically changed projection and must advance. A schema migration reapplies the current projector to source history at the previous `lastmod`, so clean committed semantic edits advance while dirty changes confined to pruned client branches do not. Version 11 retains `23:46:11+08:00` for the home hero change and `11:47:40+08:00` for the other 46 routes; the historical regression proves that removed client-only fields in the older site config do not advance home evidence. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. +Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. An unchanged digest preserves its prior time; changed content uses the exact Git commit or working-tree timestamp of a semantically changed projection and must advance. A schema migration reapplies the current projector to source history at the previous `lastmod`, so clean committed semantic edits advance while dirty changes confined to pruned client branches do not. Version 12 retains `23:46:11+08:00` for the home hero change and `11:47:40+08:00` for the other 46 routes; the historical regression proves that removed client-only fields in the older site config do not advance home evidence. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. The browser gate runs Chromium against built `dist/` through a strict, non-reused custom preview server. Route tests directly open `/`, `/part/a`, `/part/a/01`, `/method`, and `/part/g/plug`, then exercise live client navigation and keyboard focus. They check HTTP status, prerendered HTML, absence of pending streamed-Suspense placeholders, route CSS at first paint, hydration without errors, CLS below `0.05`, title/description/abstract/canonical/zero-hreflang/Open Graph/JSON-LD metadata, one visible `h1`, route announcements, current-page semantics, initial-load focus, changed-route focus, and skip-link focus. Navigation-positioning tests click a representative lesson outline in each of the seven families, restore a direct lesson fragment, verify the mobile outline and an ordinary family-page fragment, and require a changed route to reset old-page scroll immediately. An unknown path must return 404 with `noindex,nofollow`, no canonical, and the themed not-found heading. Worker-level Node tests separately cover the Accept matrix, 406 behavior, HEAD parity, representation-specific ETags, hidden internal assets, and unaffected API/static/unknown paths. diff --git a/site/scripts/last-modified.mjs b/site/scripts/last-modified.mjs index 30b41fb..c7d6a86 100644 --- a/site/scripts/last-modified.mjs +++ b/site/scripts/last-modified.mjs @@ -9,7 +9,7 @@ import { } from './semantic-source-graph.mjs' const projectRoot = fileURLToPath(new URL('../../', import.meta.url)) -export const ROUTE_CONTENT_DIGEST_VERSION = 11 +export const ROUTE_CONTENT_DIGEST_VERSION = 12 const historicalSourceCache = new Map() const headSourceCache = new Map() diff --git a/site/scripts/semantic-source-graph.mjs b/site/scripts/semantic-source-graph.mjs index c0a07cc..2648720 100644 --- a/site/scripts/semantic-source-graph.mjs +++ b/site/scripts/semantic-source-graph.mjs @@ -85,6 +85,16 @@ const STATIC_TARGET_MUTATORS = new Map([ 'setPrototypeOf', ])], ]) +const CALLBACK_SEMANTIC_OWNERS = new Set([ + 'Array', + 'Promise', + 'String', + ...KNOWN_ASYNC_CALLBACK_CALLEES, +]) +const TRACKED_GLOBAL_OWNERS = new Set([ + ...STATIC_TARGET_MUTATORS.keys(), + ...CALLBACK_SEMANTIC_OWNERS, +]) const EXPRESSION_WRAPPERS = new Set([ 'ParenthesizedExpression', 'TSAsExpression', @@ -136,15 +146,29 @@ function isIntrinsicJsxName(node) { return node?.type === 'JSXIdentifier' && /^[a-z]/u.test(node.name) } -function isClientOnlyIntrinsicJsxAttribute(attribute, openingElement) { +function isClientOnlyIntrinsicPropertyName(name) { + return name === 'key' || name === 'ref' || /^on[A-Z]/u.test(name) +} + +function isPureDeferredValueSyntax(node) { + const value = node?.type === 'JSXExpressionContainer' ? node.expression : node + if (!value || value.type === 'JSXEmptyExpression') return true + if (EXPRESSION_WRAPPERS.has(value.type)) return isPureDeferredValueSyntax(value.expression) + return value.type === 'ArrowFunctionExpression' + || value.type === 'FunctionExpression' + || value.type === 'Identifier' + || value.type === 'Literal' + || value.type === 'StringLiteral' +} + +function isDroppableClientOnlyIntrinsicJsxAttribute(attribute, openingElement, options = {}) { if ( attribute?.type !== 'JSXAttribute' || !isIntrinsicJsxName(openingElement?.name) || attribute.name?.type !== 'JSXIdentifier' ) return false - return attribute.name.name === 'key' - || attribute.name.name === 'ref' - || /^on[A-Z]/u.test(attribute.name.name) + if (!isClientOnlyIntrinsicPropertyName(attribute.name.name)) return false + return options.isPureDeferredValue?.(attribute.value) ?? isPureDeferredValueSyntax(attribute.value) } function projectRuntimeObject(value, overrides = {}, options = {}) { @@ -180,7 +204,8 @@ function runtimeAstProjection(value, options = {}) { if (value.type === 'CallExpression' && options.isClientEffectCall?.(value)) { return projectRuntimeObject(value, { arguments: [ - { type: 'ClientEffectCallback' }, + options.projectDeferredCallbackValue?.(value.arguments?.[0], 'ClientEffectCallback') + ?? { type: 'ClientEffectCallback' }, ...(value.arguments?.slice(1) ?? []), ], }, options) @@ -191,18 +216,32 @@ function runtimeAstProjection(value, options = {}) { const deferred = new Set(deferredIndexes) return projectRuntimeObject(value, { arguments: value.arguments.map((argument, index) => ( - deferred.has(index) ? { type: 'ClientAsyncCallback' } : argument + deferred.has(index) + ? options.projectDeferredCallbackValue?.(argument, 'ClientAsyncCallback') + ?? { type: 'ClientAsyncCallback' } + : argument )), }, options) } } if (value.type === 'JSXOpeningElement' && isIntrinsicJsxName(value.name)) { return projectRuntimeObject(value, { - attributes: value.attributes.filter((attribute) => ( - !isClientOnlyIntrinsicJsxAttribute(attribute, value) - )), + attributes: value.attributes.flatMap((attribute) => { + if (isDroppableClientOnlyIntrinsicJsxAttribute(attribute, value, options)) return [] + if (attribute.type !== 'JSXSpreadAttribute') return [attribute] + return [{ + ...attribute, + argument: options.projectIntrinsicJsxSpread?.(attribute.argument) ?? attribute.argument, + }] + }), }, options) } + if (value.type === 'VariableDeclarator') { + const projectedInitializer = options.projectIntrinsicSpreadInitializer?.(value) + if (projectedInitializer) { + return projectRuntimeObject(value, { init: projectedInitializer }, options) + } + } if (EXPRESSION_WRAPPERS.has(value.type)) { return runtimeAstProjection(value.expression, options) } @@ -279,6 +318,21 @@ function publicSiteConfigProjection(program) { const localMemberName = (node) => memberNameFromNode(unwrap(node)) const globalContainerAliases = new Set() const globalObjectAliases = new Set() + let dynamicGlobalObjectAlias = false + const staticPatternPropertyName = (property) => { + if (property?.type !== 'Property') return null + if (!property.computed && property.key.type === 'Identifier') return property.key.name + if ( + (property.key.type === 'StringLiteral' || property.key.type === 'Literal') + && typeof property.key.value === 'string' + ) return property.key.value + return null + } + const patternTargetNames = (property) => ( + property?.type === 'Property' + ? bindingIdentifiers(property.value).map((identifier) => identifier.name) + : [] + ) const isGlobalContainer = (node) => { const value = unwrap(node) if (value?.type !== 'Identifier') return false @@ -313,6 +367,23 @@ function publicSiteConfigProjection(program) { globalContainerAliases.add(target.name) containerAliasesChanged = true } + const pattern = node.type === 'VariableDeclarator' + ? node.id + : node.type === 'AssignmentExpression' && node.operator === '=' + ? node.left + : null + if (pattern?.type === 'ObjectPattern' && source && isGlobalContainer(source)) { + for (const property of pattern.properties) { + if (!['global', 'globalThis', 'self', 'window'].includes( + staticPatternPropertyName(property), + )) continue + for (const name of patternTargetNames(property)) { + if (globalContainerAliases.has(name)) continue + globalContainerAliases.add(name) + containerAliasesChanged = true + } + } + } for (const [child] of childNodes(node)) indexGlobalContainerAlias(child) } indexGlobalContainerAlias(program) @@ -355,11 +426,31 @@ function publicSiteConfigProjection(program) { globalObjectAliases.add(target.name) aliasesChanged = true } + const pattern = node.type === 'VariableDeclarator' + ? node.id + : node.type === 'AssignmentExpression' && node.operator === '=' + ? node.left + : null + if (pattern?.type === 'ObjectPattern' && source && isGlobalContainer(source)) { + for (const property of pattern.properties) { + const owner = staticPatternPropertyName(property) + if (owner === null) { + dynamicGlobalObjectAlias = true + continue + } + if (owner !== 'Object') continue + for (const name of patternTargetNames(property)) { + if (globalObjectAliases.has(name)) continue + globalObjectAliases.add(name) + aliasesChanged = true + } + } + } for (const [child] of childNodes(node)) indexGlobalObjectAlias(child) } indexGlobalObjectAlias(program) } - let trustedObjectFreeze = !bindings.has('Object') + let trustedObjectFreeze = !bindings.has('Object') && !dynamicGlobalObjectAlias const inspectObjectPatches = (node) => { const target = node.type === 'AssignmentExpression' ? node.left @@ -783,7 +874,13 @@ function semanticBindingGraph(program, path) { } if (node.type === 'CatchClause') { const catchScope = createScope(scope, 'block') - if (node.param) declare(node.param, catchScope, node.param) + if (node.param) { + declare(node.param, catchScope, node.param) + for (const identifier of bindingIdentifiers(node.param)) { + const binding = catchScope.bindings.get(identifier.name) + if (binding) binding.dynamicIdentity = true + } + } for (const [child, childKey] of childNodes(node)) build(child, catchScope, node, childKey) return } @@ -1100,7 +1197,10 @@ function semanticBindingGraph(program, path) { ...identityBindings(node.init), ]) } - if (node.type === 'AssignmentExpression' && node.operator === '=') { + if ( + node.type === 'AssignmentExpression' + && ['=', '&&=', '??=', '||='].includes(node.operator) + ) { const targets = reassignedBindings(node.left) for (const target of targets) { target.aliasUnstable = true @@ -1115,6 +1215,19 @@ function semanticBindingGraph(program, path) { ...identityBindings(node.right), ], true) } + if (node.type === 'ForInStatement' || node.type === 'ForOfStatement') { + const targets = node.left.type === 'VariableDeclaration' + ? node.left.declarations.flatMap((declarator) => aliasTargetBindings(declarator.id)) + : aliasTargetBindings(node.left) + for (const target of targets) { + target.aliasUnstable = true + for (const binding of target.aliases) binding.aliasUnstable = true + } + mergeAliases([ + ...targets, + ...identityBindings(node.right), + ], true) + } for (const [child] of childNodes(node)) indexAliases(child) } indexAliases(program) @@ -1350,7 +1463,7 @@ function semanticBindingGraph(program, path) { function isKnownPromiseExpression(node) { const value = unwrapExpression(node) - if (!value) return false + if (!value || patchedStaticOwners.has('Promise')) return false if ( value.type === 'NewExpression' && isUnboundGlobalIdentifier(value.callee, new Set(['Promise'])) @@ -1367,7 +1480,10 @@ function semanticBindingGraph(program, path) { function isKnownAsyncCallbackCall(call) { const callee = unwrapExpression(call.callee) - if (isUnboundGlobalIdentifier(callee, KNOWN_ASYNC_CALLBACK_CALLEES)) return true + if ( + isUnboundGlobalIdentifier(callee, KNOWN_ASYNC_CALLBACK_CALLEES) + && !patchedStaticOwners.has(callee.name) + ) return true return callee?.type === 'MemberExpression' && KNOWN_ASYNC_MEMBER_CALLEES.has(memberName(callee)) && isKnownPromiseExpression(callee.object) @@ -1383,9 +1499,42 @@ function semanticBindingGraph(program, path) { function isKnownSynchronousCallbackCall(call) { const callee = unwrapExpression(call.callee) if (callee?.type !== 'MemberExpression') return false - if (KNOWN_SYNC_CALLBACK_MEMBER_CALLEES.has(memberName(callee))) return true - return isUnboundGlobalIdentifier(callee.object, new Set(['Array'])) + if ( + isUnboundGlobalIdentifier(callee.object, new Set(['Array'])) && memberName(callee) === 'from' + ) return !patchedStaticOwners.has('Array') + const owner = knownBuiltinCallbackReceiverOwner(callee.object) + if (!owner || patchedStaticOwners.has(owner)) return false + const method = memberName(callee) + return KNOWN_SYNC_CALLBACK_MEMBER_CALLEES.has(method) + && (owner === 'Array' || (owner === 'String' && ['replace', 'replaceAll'].includes(method))) + } + + function knownBuiltinCallbackReceiverOwner(node) { + const value = unwrapExpression(node) + if (!value) return null + if (value.type === 'ArrayExpression') return 'Array' + if (value.type === 'StringLiteral' || value.type === 'TemplateLiteral') return 'String' + if ( + value.type === 'NewExpression' + && isUnboundGlobalIdentifier(value.callee, new Set(['Array'])) + ) return 'Array' + if (value.type !== 'CallExpression' || value.callee.type !== 'MemberExpression') return null + const method = memberName(value.callee) + if ( + isUnboundGlobalIdentifier(value.callee.object, new Set(['Array'])) + && method === 'from' + && !patchedStaticOwners.has('Array') + ) return 'Array' + if ( + isUnboundGlobalIdentifier(value.callee.object, new Set(['Object'])) + && ['entries', 'keys', 'values'].includes(method) + && !patchedStaticOwners.has('Object') + ) return 'Array' + const chainedOwner = knownBuiltinCallbackReceiverOwner(value.callee.object) + return chainedOwner === 'Array' && KNOWN_SYNC_CALLBACK_MEMBER_CALLEES.has(method) + ? 'Array' + : null } function containsPotentialCallback(node) { @@ -1413,6 +1562,91 @@ function semanticBindingGraph(program, path) { )) } + function stableCallableBinding(node) { + const value = unwrapExpression(node) + if (value?.type !== 'Identifier') return null + const binding = lookup(value) + if (!binding || binding.aliasUnstable || binding.writes.length > 0) return null + return [...binding.aliases].some((alias) => alias.callable) ? binding : null + } + + function isPureDeferredValue(node) { + const containerValue = node?.type === 'JSXExpressionContainer' ? node.expression : node + const value = unwrapExpression(containerValue) + if (!value || value.type === 'JSXEmptyExpression') return true + return value.type === 'ArrowFunctionExpression' + || value.type === 'FunctionExpression' + || value.type === 'Literal' + || value.type === 'StringLiteral' + || Boolean(stableCallableBinding(value)) + } + + function projectDeferredCallbackValue(node, markerType) { + const value = unwrapExpression(node) + if (!value) return { type: markerType } + if (isPureDeferredValue(value)) return { type: markerType } + if (value.type === 'SequenceExpression' && value.expressions.length > 0) { + return { + ...value, + expressions: [ + ...value.expressions.slice(0, -1), + projectDeferredCallbackValue(value.expressions.at(-1), markerType), + ], + } + } + if (value.type === 'ConditionalExpression') { + return { + ...value, + consequent: projectDeferredCallbackValue(value.consequent, markerType), + alternate: projectDeferredCallbackValue(value.alternate, markerType), + } + } + if (value.type === 'LogicalExpression') { + return { + ...value, + left: isPureDeferredValue(value.left) + ? { type: markerType } + : value.left, + right: projectDeferredCallbackValue(value.right, markerType), + } + } + if (value.type === 'AssignmentExpression' && value.operator === '=') { + return { + ...value, + right: projectDeferredCallbackValue(value.right, markerType), + } + } + return node + } + + const clientOnlyDeferredValues = new WeakSet() + const clientOnlyDeferredFunctions = new WeakSet() + const deferredBindingCandidates = new Set() + function markClientOnlyDeferredValue(node) { + const value = unwrapExpression(node) + if (!value) return + if (isPureDeferredValue(value)) { + clientOnlyDeferredValues.add(value) + if (value.type === 'ArrowFunctionExpression' || value.type === 'FunctionExpression') { + clientOnlyDeferredFunctions.add(value) + } + const binding = stableCallableBinding(value) + if (binding) deferredBindingCandidates.add(binding) + return + } + if (value.type === 'SequenceExpression' && value.expressions.length > 0) { + markClientOnlyDeferredValue(value.expressions.at(-1)) + } else if (value.type === 'ConditionalExpression') { + markClientOnlyDeferredValue(value.consequent) + markClientOnlyDeferredValue(value.alternate) + } else if (value.type === 'LogicalExpression') { + if (isPureDeferredValue(value.left)) markClientOnlyDeferredValue(value.left) + markClientOnlyDeferredValue(value.right) + } else if (value.type === 'AssignmentExpression' && value.operator === '=') { + markClientOnlyDeferredValue(value.right) + } + } + function isReactEffectCall(call) { const callee = call.callee if (callee.type === 'Identifier') { @@ -1451,8 +1685,23 @@ function semanticBindingGraph(program, path) { const patchedStaticOwners = new Set() const staticOwnerAliases = new Map() + const ambiguousStaticOwnerAliases = new Set() const globalContainerAliases = new Set() + function staticPatternOwner(property) { + if (property?.type !== 'Property') return null + if (!property.computed && property.key.type === 'Identifier') return property.key.name + if ( + (property.key.type === 'StringLiteral' || property.key.type === 'Literal') + && typeof property.key.value === 'string' + ) return property.key.value + return null + } + + function patternBindings(property) { + return property?.type === 'Property' ? reassignedBindings(property.value) : [] + } + function unboundGlobalContainer(node) { if (!node || typeof node !== 'object') return false if (EXPRESSION_WRAPPERS.has(node.type)) return unboundGlobalContainer(node.expression) @@ -1492,6 +1741,23 @@ function semanticBindingGraph(program, path) { globalContainerAliases.add(binding) changed = true } + const pattern = node.type === 'VariableDeclarator' + ? node.id + : node.type === 'AssignmentExpression' && node.operator === '=' + ? node.left + : null + if (pattern?.type === 'ObjectPattern' && source && unboundGlobalContainer(source)) { + for (const property of pattern.properties) { + if (!['global', 'globalThis', 'self', 'window'].includes( + staticPatternOwner(property), + )) continue + for (const targetBinding of patternBindings(property)) { + if (globalContainerAliases.has(targetBinding)) continue + globalContainerAliases.add(targetBinding) + changed = true + } + } + } for (const [child] of childNodes(node)) visit(child) } visit(program) @@ -1504,12 +1770,13 @@ function semanticBindingGraph(program, path) { if (EXPRESSION_WRAPPERS.has(node.type)) return globalStaticOwner(node.expression) if (node.type === 'Identifier') { const binding = lookup(node) - if (!binding && STATIC_TARGET_MUTATORS.has(node.name)) return node.name + if (!binding && TRACKED_GLOBAL_OWNERS.has(node.name)) return node.name + if (ambiguousStaticOwnerAliases.has(binding)) return '*' return staticOwnerAliases.get(binding) ?? null } if (node.type === 'MemberExpression' && unboundGlobalContainer(node.object)) { const name = memberName(node) - return STATIC_TARGET_MUTATORS.has(name) ? name : null + return TRACKED_GLOBAL_OWNERS.has(name) ? name : null } return null } @@ -1539,6 +1806,31 @@ function semanticBindingGraph(program, path) { changed = true } } + const pattern = node.type === 'VariableDeclarator' + ? node.id + : node.type === 'AssignmentExpression' && node.operator === '=' + ? node.left + : null + const source = node.type === 'VariableDeclarator' + ? node.init + : node.type === 'AssignmentExpression' + ? node.right + : null + if (pattern?.type === 'ObjectPattern' && source && unboundGlobalContainer(source)) { + for (const property of pattern.properties) { + const owner = staticPatternOwner(property) + for (const binding of patternBindings(property)) { + if (owner !== null && TRACKED_GLOBAL_OWNERS.has(owner)) { + if (staticOwnerAliases.get(binding) === owner) continue + staticOwnerAliases.set(binding, owner) + changed = true + } else if (owner === null && !ambiguousStaticOwnerAliases.has(binding)) { + ambiguousStaticOwnerAliases.add(binding) + changed = true + } + } + } + } for (const [child] of childNodes(node)) visit(child) } visit(program) @@ -1546,22 +1838,37 @@ function semanticBindingGraph(program, path) { } indexStaticOwnerAliases() + function globalOwnerRoot(node) { + if (!node || typeof node !== 'object') return null + if (EXPRESSION_WRAPPERS.has(node.type)) return globalOwnerRoot(node.expression) + if (node.type === 'MemberExpression') { + return globalOwnerRoot(node.object) ?? globalStaticOwner(node) + } + return globalStaticOwner(node) + } + function mutationMemberOwner(node) { if (!node || typeof node !== 'object') return null if (EXPRESSION_WRAPPERS.has(node.type)) return mutationMemberOwner(node.expression) if (node.type !== 'MemberExpression') return null - return globalStaticOwner(node.object) + return globalOwnerRoot(node.object) } function markAllStaticOwnersPatched() { - for (const name of STATIC_TARGET_MUTATORS.keys()) patchedStaticOwners.add(name) + for (const name of TRACKED_GLOBAL_OWNERS) patchedStaticOwners.add(name) + } + + function markStaticOwnerPatched(owner) { + if (!owner) return + if (owner === '*') markAllStaticOwnersPatched() + else patchedStaticOwners.add(owner) } function indexPatchedStaticOwners(node) { if (node.type === 'AssignmentExpression' || node.type === 'UpdateExpression') { const target = node.type === 'AssignmentExpression' ? node.left : node.argument const owner = mutationMemberOwner(target) ?? globalStaticOwner(target) - if (owner) patchedStaticOwners.add(owner) + markStaticOwnerPatched(owner) if ( target.type === 'MemberExpression' && unboundGlobalContainer(target.object) @@ -1571,7 +1878,7 @@ function semanticBindingGraph(program, path) { } if (node.type === 'UnaryExpression' && node.operator === 'delete') { const owner = mutationMemberOwner(node.argument) ?? globalStaticOwner(node.argument) - if (owner) patchedStaticOwners.add(owner) + markStaticOwnerPatched(owner) } if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') { const owner = globalStaticOwner(node.callee.object) @@ -1579,9 +1886,9 @@ function semanticBindingGraph(program, path) { if ( owner && STATIC_TARGET_MUTATORS.get(owner)?.has(method) - && globalStaticOwner(node.arguments?.[0]) + && globalOwnerRoot(node.arguments?.[0]) ) { - patchedStaticOwners.add(globalStaticOwner(node.arguments[0])) + markStaticOwnerPatched(globalOwnerRoot(node.arguments[0])) } if (unboundGlobalContainer(node.arguments?.[0])) { const patchOwnerKey = (key) => { @@ -1591,7 +1898,7 @@ function semanticBindingGraph(program, path) { ) && typeof key.value === 'string' ? key.value : null - if (name && STATIC_TARGET_MUTATORS.has(name)) patchedStaticOwners.add(name) + if (name && TRACKED_GLOBAL_OWNERS.has(name)) patchedStaticOwners.add(name) else if (!name) markAllStaticOwnersPatched() } const patchObjectKeys = (value) => { @@ -1625,6 +1932,56 @@ function semanticBindingGraph(program, path) { } indexPatchedStaticOwners(program) + function indexClientOnlyDeferredValues(node) { + if (node.type === 'CallExpression') { + if (isReactEffectCall(node)) { + markClientOnlyDeferredValue(node.arguments?.[0]) + } else if (isKnownAsyncCallbackCall(node)) { + for (const index of clientAsyncCallbackIndexes(node)) { + markClientOnlyDeferredValue(node.arguments[index]) + } + } + } + for (const [child] of childNodes(node)) indexClientOnlyDeferredValues(child) + } + + function finalizeClientOnlyDeferredBindings() { + for (const binding of deferredBindingCandidates) { + let usedOnlyDeferred = true + function inspectReferences(node) { + if (!usedOnlyDeferred) return + if ( + node.type === 'Identifier' + && !declaredIdentifiers.has(node) + && lookup(node)?.aliases === binding.aliases + && isReference(node) + && !clientOnlyDeferredValues.has(unwrapExpression(node)) + ) { + usedOnlyDeferred = false + return + } + for (const [child] of childNodes(node)) inspectReferences(child) + } + inspectReferences(program) + if (!usedOnlyDeferred) continue + for (const alias of binding.aliases) { + const relation = nodeParents.get(alias.identifier) + if ( + relation?.parent.type === 'FunctionDeclaration' + || relation?.parent.type === 'FunctionExpression' + ) { + clientOnlyDeferredFunctions.add(relation.parent) + } else if (relation?.parent.type === 'VariableDeclarator' && relation.key === 'id') { + const initializer = unwrapExpression(relation.parent.init) + if ( + initializer?.type === 'ArrowFunctionExpression' + || initializer?.type === 'FunctionExpression' + ) clientOnlyDeferredFunctions.add(initializer) + } + } + } + } + function shallowCopyMutationSources(target) { let value = target while (value && EXPRESSION_WRAPPERS.has(value.type)) value = value.expression @@ -1717,6 +2074,9 @@ function semanticBindingGraph(program, path) { } if (node.type === 'CallExpression' && !isReactEffectCall(node)) { const callee = node.callee + const provenAsyncCallbackIndexes = new Set( + isKnownAsyncCallbackCall(node) ? clientAsyncCallbackIndexes(node) : [], + ) const calleeInputs = callee.type === 'MemberExpression' ? [callee.object] : [callee] @@ -1771,6 +2131,11 @@ function semanticBindingGraph(program, path) { if ( callableIsInspectable || inlineCallable + || ( + !hasPotentialCallback + && executionScope.ownerNode?.type !== 'Program' + && !(callee.type === 'MemberExpression' && globalOwnerRoot(callee.object)) + ) || (hasPotentialCallback && isKnownSynchronousCallbackCall(node)) ) { synchronousCalls.push({ @@ -1807,10 +2172,14 @@ function semanticBindingGraph(program, path) { MUTATING_MEMBER_CALLEES.has(method) ? 'write' : 'ambiguous-mutation', { trackDynamic: false }, ) - const possiblyMutatedArguments = reflectApplyCallable - ? node.arguments?.slice(1) ?? [] - : node.arguments ?? [] - for (const argument of possiblyMutatedArguments) { + const possiblyMutatedArguments = (node.arguments ?? []).map((argument, index) => ({ + argument, + index, + })).filter(({ index }) => ( + !provenAsyncCallbackIndexes.has(index) + && (!reflectApplyCallable || index > 0) + )) + for (const { argument } of possiblyMutatedArguments) { record(argument, 'ambiguous-mutation', { trackDynamic: false }) } const receiverBindings = new Set(identityBindings(callee.object)) @@ -1836,7 +2205,8 @@ function semanticBindingGraph(program, path) { } } else { if (!callableIsInspectable) { - for (const argument of node.arguments ?? []) { + for (const [index, argument] of (node.arguments ?? []).entries()) { + if (provenAsyncCallbackIndexes.has(index)) continue record(argument, 'ambiguous-mutation', { trackDynamic: false }) } } @@ -1933,6 +2303,130 @@ function semanticBindingGraph(program, path) { for (const [child] of childNodes(node)) indexWrites(child) } indexWrites(program) + indexClientOnlyDeferredValues(program) + finalizeClientOnlyDeferredBindings() + + const clientOnlyIntrinsicSpreadProperties = new WeakSet() + const intrinsicSpreadInitializers = new WeakMap() + + function staticIntrinsicSpreadPropertyName(property) { + if (property?.type !== 'Property') return null + if (!property.computed && property.key.type === 'Identifier') return property.key.name + if ( + (property.key.type === 'StringLiteral' || property.key.type === 'Literal') + && typeof property.key.value === 'string' + ) return property.key.value + return null + } + + function filteredIntrinsicSpreadObject(node) { + const value = unwrapExpression(node) + if (value?.type !== 'ObjectExpression') { + throw new Error(`Unsupported intrinsic JSX spread in ${path}`) + } + const properties = [] + for (const property of value.properties) { + if (property.type === 'SpreadElement') { + properties.push(...filteredIntrinsicSpreadObject(property.argument).properties) + continue + } + if (property.type !== 'Property') { + throw new Error(`Unsupported intrinsic JSX spread property in ${path}`) + } + const name = staticIntrinsicSpreadPropertyName(property) + if (name === null) { + throw new Error(`Unsupported dynamic intrinsic JSX spread property in ${path}`) + } + if ( + property.kind === 'init' + && isClientOnlyIntrinsicPropertyName(name) + && isPureDeferredValue(property.value) + ) { + clientOnlyIntrinsicSpreadProperties.add(property) + continue + } + properties.push(property) + } + return { ...value, properties } + } + + function directConstDeclarator(binding) { + const relation = nodeParents.get(binding?.identifier) + if (relation?.parent.type !== 'VariableDeclarator' || relation.key !== 'id') return null + const declarationRelation = nodeParents.get(relation.parent) + if ( + declarationRelation?.parent.type !== 'VariableDeclaration' + || declarationRelation.parent.kind !== 'const' + ) return null + return relation.parent + } + + function intrinsicSpreadReference(node) { + const relation = nodeParents.get(node) + if (relation?.parent.type !== 'JSXSpreadAttribute' || relation.key !== 'argument') return false + const openingRelation = nodeParents.get(relation.parent) + return openingRelation?.parent.type === 'JSXOpeningElement' + && isIntrinsicJsxName(openingRelation.parent.name) + } + + function bindingUsedOnlyByIntrinsicSpreads(binding) { + let safe = true + function visit(node) { + if (!safe) return + if ( + node.type === 'Identifier' + && !declaredIdentifiers.has(node) + && lookup(node) === binding + && isReference(node) + && !intrinsicSpreadReference(node) + ) { + safe = false + return + } + for (const [child] of childNodes(node)) visit(child) + } + visit(program) + return safe + } + + function projectIntrinsicJsxSpread(node) { + const value = unwrapExpression(node) + if (value?.type === 'ObjectExpression') return filteredIntrinsicSpreadObject(value) + if (value?.type !== 'Identifier') { + throw new Error(`Unsupported dynamic intrinsic JSX spread in ${path}`) + } + const binding = lookup(value) + const declarator = directConstDeclarator(binding) + if ( + !binding + || binding.aliasUnstable + || binding.dynamicIdentity + || binding.writes.length > 0 + || !declarator?.init + || !bindingUsedOnlyByIntrinsicSpreads(binding) + ) { + throw new Error(`Unsupported mutable intrinsic JSX spread in ${path}`) + } + if (!intrinsicSpreadInitializers.has(declarator)) { + intrinsicSpreadInitializers.set( + declarator, + filteredIntrinsicSpreadObject(declarator.init), + ) + } + return value + } + + function indexIntrinsicJsxSpreads(node) { + if (node.type === 'JSXOpeningElement' && isIntrinsicJsxName(node.name)) { + for (const attribute of node.attributes) { + if (attribute.type === 'JSXSpreadAttribute') { + projectIntrinsicJsxSpread(attribute.argument) + } + } + } + for (const [child] of childNodes(node)) indexIntrinsicJsxSpreads(child) + } + indexIntrinsicJsxSpreads(program) function isTypeReference(node) { let current = node @@ -1989,6 +2483,7 @@ function semanticBindingGraph(program, path) { while (currentScope) { const functionNode = currentScope.ownerNode if (functionNode?.type === 'Program') return false + if (clientOnlyDeferredFunctions.has(functionNode)) return true let callbackNode = functionNode let relation = nodeParents.get(callbackNode) while ( @@ -2014,9 +2509,12 @@ function semanticBindingGraph(program, path) { } function isClientOnlyIntrinsicAttribute(node) { + if (clientOnlyIntrinsicSpreadProperties.has(node)) return true const relation = nodeParents.get(node) return relation?.parent.type === 'JSXOpeningElement' - && isClientOnlyIntrinsicJsxAttribute(node, relation.parent) + && isDroppableClientOnlyIntrinsicJsxAttribute(node, relation.parent, { + isPureDeferredValue, + }) } return { @@ -2024,12 +2522,17 @@ function semanticBindingGraph(program, path) { clientAsyncCallbackIndexes, executionScopeFor: (node) => nearestFunctionScope(nodeScopes.get(node)), isClientOnlyEffectScope, + isClientOnlyDeferredValue: (node) => clientOnlyDeferredValues.has(unwrapExpression(node)), isClientEffectCall: isReactEffectCall, isClientOnlyIntrinsicAttribute, + isPureDeferredValue, isReference, lookup, moduleEvaluationEdges, synchronousCalls, + projectDeferredCallbackValue, + projectIntrinsicJsxSpread, + projectIntrinsicSpreadInitializer: (node) => intrinsicSpreadInitializers.get(node), unsupportedRootCalls, unresolvedMutations, } @@ -2049,6 +2552,10 @@ export function semanticSourceForDigest(path, source) { const projectionOptions = { clientAsyncCallbackIndexes: bindingGraph.clientAsyncCallbackIndexes, isClientEffectCall: bindingGraph.isClientEffectCall, + isPureDeferredValue: bindingGraph.isPureDeferredValue, + projectDeferredCallbackValue: bindingGraph.projectDeferredCallbackValue, + projectIntrinsicJsxSpread: bindingGraph.projectIntrinsicJsxSpread, + projectIntrinsicSpreadInitializer: bindingGraph.projectIntrinsicSpreadInitializer, } if (!rootName) return JSON.stringify(runtimeAstProjection(program, projectionOptions)) const roots = [] @@ -2094,6 +2601,7 @@ export function semanticSourceForDigest(path, source) { while (pendingNodes.length > 0) { const selectedNode = pendingNodes.pop() function collectReferences(node) { + if (bindingGraph.isClientOnlyDeferredValue(node)) return if (bindingGraph.isClientOnlyIntrinsicAttribute(node)) return if ( (node.type === 'Identifier' || node.type === 'JSXIdentifier') diff --git a/site/scripts/semantic-source-graph.test.mjs b/site/scripts/semantic-source-graph.test.mjs index 9ba6168..ed2ef05 100644 --- a/site/scripts/semantic-source-graph.test.mjs +++ b/site/scripts/semantic-source-graph.test.mjs @@ -863,6 +863,26 @@ test('secondary review boundaries preserve SSR precision and fail closed', () => intrinsicDeferredWrite.replace('客户端一', '客户端二'), ), ) + const intrinsicCallbackFactory = `function AppContent() { let label = '正文'; return }` + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', intrinsicCallbackFactory), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + intrinsicCallbackFactory.replace('变化一', '变化二'), + ), + ) + for (const intrinsicSpread of [ + `function AppContent() { return }`, + `const props = { onClick: () => track('客户端一') }\nfunction AppContent() { return }`, + ]) { + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', intrinsicSpread), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + intrinsicSpread.replace('客户端一', '客户端二'), + ), + ) + } assert.notEqual( semanticSourceForDigest( 'site/src/app/AppContent.tsx', @@ -889,6 +909,89 @@ test('secondary review boundaries preserve SSR precision and fail closed', () => ), ) } + for (const namedAsyncCallback of [ + `const callback = () => { label = '客户端一' }\nlet label = '正文'\nfunction AppContent() { setTimeout(callback, 0); return {label} }`, + `import { useEffect } from 'react'\nconst callback = () => { label = '客户端一' }\nlet label = '正文'\nfunction AppContent() { useEffect(callback, []); return {label} }`, + ]) { + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', namedAsyncCallback), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + namedAsyncCallback.replace('客户端一', '客户端二'), + ), + ) + } + + for (const callbackFactory of [ + `function AppContent() { let label = '正文'; setTimeout((label = '变化一', () => {}), 0); return {label} }`, + `import { useEffect } from 'react'\nfunction AppContent() { let label = '正文'; useEffect((() => { label = '变化一'; return () => {} })(), []); return {label} }`, + ]) { + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', callbackFactory), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + callbackFactory.replace('变化一', '变化二'), + ), + ) + } + + for (const patchedCallbackSemantic of [ + `globalThis.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => track('同步一'), 0); return 正文 }`, + `Promise.prototype.then = callback => { callback(); return Promise.resolve() }\nfunction AppContent() { Promise.resolve().then(() => track('同步一')); return 正文 }`, + `Array.prototype.map = callback => { callback('同步一'); return [] }\nfunction AppContent() { [1].map(value => track(value)); return 正文 }`, + ]) { + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', patchedCallbackSemantic), + /Unsupported callback execution timing/, + ) + } + + for (const destructuredOwner of [ + `const state = { value: '正文' }\nconst { Object: O } = globalThis\nO.assign = target => { target.value = '变化' }\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`, + `const state = { value: '正文' }\nconst { ['Object']: O } = globalThis\nO.assign = target => { target.value = '变化' }\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`, + ]) { + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', destructuredOwner), + /Unsupported ambiguous mutation of state/, + ) + } + + const externalCallBefore = `import { mutateA as mutate, state } from 'pkg'\nfunction AppContent() { mutate(); return {state.value} }` + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', externalCallBefore), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + externalCallBefore.replace('mutateA', 'mutateB'), + ), + ) + assert.notEqual( + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + `function AppContent() { mutateA(); return {globalThis.state.value} }`, + ), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + `function AppContent() { mutateB(); return {globalThis.state.value} }`, + ), + ) + + for (const unstableAlias of [ + `const state = { value: '正文' }\nlet alias = {}\nfunction AppContent() { alias ||= state; alias.value = '变化一'; return {state.value} }`, + `const state = { value: '正文' }\nlet alias = {}\nfunction AppContent() { for (alias of [state]) alias.value = '变化一'; return {state.value} }`, + ]) { + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', unstableAlias), + /Unsupported/, + ) + } + const catchAlias = `const state = { value: '正文' }\nfunction AppContent() { try { throw state } catch (alias) { alias.value = '变化一' } return {state.value} }` + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', catchAlias), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + catchAlias.replace('变化一', '变化二'), + ), + ) }) test('public config rejects alias mutation, pattern writes, and patched freeze', () => { @@ -913,6 +1016,14 @@ test('public config rejects alias mutation, pattern writes, and patched freeze', `Reflect.set(Object, 'freeze', value => { value.name = '运行时'; return value })`, `name: '初始'`, ), + publicConfigSource( + `const { Object: O } = globalThis\nO.freeze = value => { value.name = '运行时'; return value }`, + `name: '初始'`, + ), + publicConfigSource( + `const { ['Object']: O } = globalThis\nO.freeze = value => { value.name = '运行时'; return value }`, + `name: '初始'`, + ), ]) { assert.throws( () => semanticSourceForDigest('site/src/config/site.ts', mutableConfig), diff --git a/site/scripts/seo-contract.test.mjs b/site/scripts/seo-contract.test.mjs index 95e3be2..16e44c0 100644 --- a/site/scripts/seo-contract.test.mjs +++ b/site/scripts/seo-contract.test.mjs @@ -232,7 +232,7 @@ test('discovery files expose the 47 approved URLs and real summaries', async () assert.match(lastModified, /--format=%cI/) assert.match(lastModified, /normalizeContentForDigest/) assert.match(lastModified, /semanticRouteFiles/) - assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 11) + assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 12) assert.equal(Object.keys(ROUTE_CONTENT_DIGESTS).length, PUBLIC_PATHS.length) assert.ok(Object.values(ROUTE_CONTENT_DIGESTS).every((digest) => /^[0-9a-f]{64}$/.test(digest))) assert.match(publicRoutes, /\.\.\/data\/catalog\.ts/) diff --git a/site/src/data/routeLastModified.ts b/site/src/data/routeLastModified.ts index e42a188..ca4c37b 100644 --- a/site/src/data/routeLastModified.ts +++ b/site/src/data/routeLastModified.ts @@ -1,5 +1,5 @@ // 由 scripts/generate-seo.mjs 从源码时间证据生成,请勿手改。 -export const ROUTE_CONTENT_DIGEST_VERSION = 11 +export const ROUTE_CONTENT_DIGEST_VERSION = 12 export const ROUTE_LAST_MODIFIED: Readonly> = Object.freeze({ "/": "2026-08-22T23:46:11+08:00", "/part/a": "2026-08-22T11:47:40+08:00", @@ -50,51 +50,51 @@ export const ROUTE_LAST_MODIFIED: Readonly> = Object.free "/problems": "2026-08-22T11:47:40+08:00" }) export const ROUTE_CONTENT_DIGESTS: Readonly> = Object.freeze({ - "/": "5b92f2a3051cacc9a6ca1b0254938d610febd387ca072362afbf1bd3089be359", - "/part/a": "54fbccbe4a943f05dc0f5b0ff837d76aceabd4740819a82bb164137c674ee4a6", - "/part/b": "d7e072c76db9717893104f5407ef459528e56fbd2a52003e5d9bcc2d0683ca6f", - "/part/c": "a81765be39ac925c8c3826c21ffe5041759f3150d4db131087f7856fe3652401", - "/part/d": "bec16b28d86172f02e4f66ad2ad614e657fb0e4f23a93a8855008a965e35af29", - "/part/e": "58f32f7a33e79eb7bf2e41ff49d47c1660caa1f2ba783ac64b463d3a206f6eb6", - "/part/f": "d0991c67d1db79998e12ad37ef0095ea6331a6d9db2aa4e77fba2fbda5c7ef25", - "/part/g": "7cb9499923ac96266ced3b9ba42f01434df344f97521fe54884d28f16aecd7c9", - "/part/a/01": "3c5792904ed5bb4ab9916981b027bbc10fe1606e4fa94819f373bcc19afd4eea", - "/part/a/complete": "1811291a53bcce925bd84d01794f24b30f352d26c4f79db6087bda0b16555395", - "/part/a/multiple": "3031c3a8fc8b9ead70d272c91b918922b00279a89432e0279707c461e87706c6", - "/part/a/group": "29a4f617bc59bb74faa51d65df8140414fb5eaaf785648c343893589aea28119", - "/part/a/mixed": "e3caa2d98d9795f2bdb02c86ac23edcab2e5bb047cbd835325804e487eaeca46", - "/part/a/cost2d": "cb7b028a96abb4f235bad839f370ad1f9d3de078e1d7bdfd3f159c80d9131b91", - "/part/a/dep": "9ac27bb781665cde81fe7b564ee8708a0ae6ef053a84810c3d5e333f8aa66b6a", - "/part/a/variant": "ad8ff88ba296d4a21735d0bc25938ba023924526cad5bf25db758502fd9f599a", - "/part/a/fractional": "c672ca6b48915d180cbbfbbefcc4563ebb47f6ed52cbbec7c42cf4c4cb6031e9", - "/part/b/path": "a7fdf87df5bc2db70d288ee1b79a5c95bc370a8a6d41caf8708264b4cf10ebda", - "/part/b/maxseg": "51fee070dfc7f6c7c41d40a7109bd5935875218173a66a1ebf74322440dfc8de", - "/part/b/lis": "c4400caebcc138a257090119ac203e886fc90a703ce3ca2424b59de7493f96de", - "/part/b/lcs": "7762c4c49e42878eea574012303238159c9da4ffbdccea18a684769074f14d89", - "/part/b/edit": "e809af30a6f49b6765d3f34a86ffe34cd0fd0883715aced4d8465c710caaa56c", - "/part/b/fsm": "36318dfbd7a63f2fa6ec526c11a77a3fdbb85bfc13dd5c0f450d2745928270ad", - "/part/b/count": "ffcd0e0e4df81124a6860c0a4961f8128333311b4f8ec6c5c520577b18171f4c", - "/part/c/stone": "182cb6883b87340127aa526365206838508cb5e0ce0716bf9272b60eb01c53bf", - "/part/c/ring": "7828597294e0458eeeab3f1ffad378d4d03a44ecef38e5202afb0dee3109001f", - "/part/c/palindrome": "133e69adae07927b6bfde3ff1a2c30eaf9f1cea61dbface9d442a5e0e55e468d", - "/part/c/tree": "abd18681b04f2a39f671a0d02f001e21158ade7a3e29152a878fe5b7723686cd", - "/part/c/merge": "295eac4cc8e3d9f953c15165824129ca63d6289f21bb9c9d78b26a6920504b8a", - "/part/d/grid": "f10d90793e9e0fca366b53b61b9bf44e5d06d2bf6369b12e0ad3754a37710c4f", - "/part/d/matpow": "314b8ee76d6dfe6c56a7913fb9d0922d782c32105b278870efa86d94731539bc", - "/part/e/basic": "9797b32c05255b64c07e56bcaa7cb1071f3c7ad2be6afab5479553d942f8cce2", - "/part/e/distsum": "3202e75a7dfb796f10aeebad9e14f2bb9e9b37092dacf73a64c2cd2fbb7e0d65", - "/part/e/inout": "96cfb9860ac6331322b95f792ff65a11adcac458aa8d828b46caf21cc324b743", - "/part/e/center": "f68e225ba578e5c51683559ee3e73c22dc0014a8d7d1480a953b6b7ac0bfa9f6", - "/part/f/select": "6202ff47fd99d71c6e99d327346c0ed400eeeb8c938c850a6fde1a8cf4cb45ac", - "/part/f/knapsack": "02c70f7726a3fe387ca3582b36ce43ebe91ef7fa95a2e2ae9d029beefd135253", - "/part/f/diameter": "24a716df998f04ef6ed867531b1e69840e6b73300ba751752618d2b263d41d4c", - "/part/f/cover": "e2985dabd895273e0844ab1d139f40df2fae66e5c5cac19c5676a54ac019cdc5", - "/part/f/count": "a4cd7a916cff682d105481599e07fd377be868644dac58e84e86460d9a22e280", - "/part/g/board": "4520f021ea95bde804d999e44c4ebd5b3cf83a8411988454b13ebd771cc9b677", - "/part/g/tsp": "51cc477c7edc022cc9058fcca48f2bd583a2f46dc609e9c8a346e44bf8554fc3", - "/part/g/cover": "f0b3820a6cefb07ae964d6f7190f0e4bc50326ff1c224ff307a82c0cf81f6f8e", - "/part/g/subset": "affb056a469c88d2010a149444dd57f305f10dd93b5066fdaa4c1a1d1ae80ed7", - "/part/g/plug": "612f6a700a027932de7eaffc6b788b330e8c44d3d18bb64bcd175c48c7c01ef9", - "/method": "a0fee483e6308ce8dbcdaa6ea8a889ace667143e9d975bd3e60eb6dabbb3006c", - "/problems": "438a2a33fefed906dd3407926d5e2fb0ac1cfbea882d94abd3c4c103ab77e8e8" + "/": "12cfb56f67d2e7135952971d1e410d81afa6bc3e4d381bc982270c2828973e16", + "/part/a": "4f5ea61b2d81aedc06329889198d7497c7586709cd82da0fb22eca45d4437304", + "/part/b": "17db7aac56ec38577374762a1be74a4ac72aa983b2711a15555fda1ea4978a2d", + "/part/c": "b727d367a71ec275c91f91258cf81f6baa8c4b3f39eb356416262f536a3e645d", + "/part/d": "14f2580cbe3c57e79ba1569e312e548b888e9ddfb850105889c615a47be99ad8", + "/part/e": "bd8f689faae0721b0320f63fef733b464afbf869dbcd6af60d252334ab03a54b", + "/part/f": "906d7e80fedf71fb7ea5035ffc10e10032b48fc4ed777181e73e6bc975c4a99b", + "/part/g": "96fef6c17bd780d90989404ff5c6f580b4a3d53c1e074d70768ce7ee45f2a94e", + "/part/a/01": "b1ec1735009d33ebf8ef7205caa94b9a3535deda8dfb245e06478555d89dad45", + "/part/a/complete": "ac73ba5b708f9fde996cc25de80621408c4e97956c33d3173710a3ca6762a7e2", + "/part/a/multiple": "2dbe93d29cec071fa430b31226235267c3f0408ab2cbcaf7f2d4f463988715e1", + "/part/a/group": "4d6675b2d6e8f430589b72c652422bf081874af0fbd022cb10661467d62a373b", + "/part/a/mixed": "cedf48ec414e3d34bfa2f4291649e5a16a1218c51eac892d9a2026acb4e1aa17", + "/part/a/cost2d": "82b8948a2e735652fb5c161d46df794f49854f65d19c7f8d933197f82aa1c8cc", + "/part/a/dep": "a1e4ad1f7ed80fea66524f793919263644f832359069eaaf61516fb0ddc12fb6", + "/part/a/variant": "d1f2649a6216c7ed67760bec75d47638a61418bcaa4845316843b5c3daed3be1", + "/part/a/fractional": "9925e4591c303e3dff254f8e39a6c3b437980cc743f56798ecb72eb54e5dd1ab", + "/part/b/path": "5725febb9f43dcbc31761550fd71584307d2b08189ba47cde8b5eeea1bda8850", + "/part/b/maxseg": "93e3d761ab5cc2523c71e12647dcdff8070996c9a45ca63e406168a06b0f6784", + "/part/b/lis": "e9ca59988c98dab3e0994969d205478a24d1ed4e9b08b782ecd852d30df529ac", + "/part/b/lcs": "8e5f6264b3777d6ae4febf5f5c707774ecd2012722a52275a9b15de543556f15", + "/part/b/edit": "7fa9540475b0280943d9954f4ddca008f7d7bb3bb0eb7d639894f3e7bdb1c34b", + "/part/b/fsm": "1e4f2fb692d55e3e4fddbf1b11b4c65e3dcfd7600f64e8900912b9c20f4c3143", + "/part/b/count": "78beb42911c74f88ab5dbf7c7d0ddec8698eee32dd4a2572f98ece64f5e7f81d", + "/part/c/stone": "659cfb97675387a9155cb5e2edc1a6eadd88917f5ed995411db88847c056ad41", + "/part/c/ring": "9b5928f33abce65f0c224916d522485c2bd505a0d276df0f884c37d05cef41ff", + "/part/c/palindrome": "07e44ba0a76288aabbdc76d2aeb3baced62d255766386e78217c85afdf5e0bd8", + "/part/c/tree": "3f2462f4a01b1e8e3fcb396442d56791ddaeb67d0dfa30ef58b1c1832d803fb6", + "/part/c/merge": "a249bdb7650b3bd37a78a7ea82af4ca91830bdf250f3fc728e33f5107f205eaf", + "/part/d/grid": "a717bc0470abb4cff3667773bb07dc88083e2c97c7f43ae4bf1b45ec593b18f1", + "/part/d/matpow": "746c8911b669e2bb65a6bb56036af226c502f86363d4e54b714603ee820a104e", + "/part/e/basic": "5a11da168dc93a62a7462ece534a492e194a64cdf89306c2000190ccaf1593be", + "/part/e/distsum": "99b609f8c6c51c969e56665d4fb324bd9fe9b14757cd7402d79185f6a01a4b65", + "/part/e/inout": "31b8713c210d4ea94fd1fd16fe34f4adc87bf49047a2c3b5650af4c34a5d1df3", + "/part/e/center": "688088cfe1ab07a8ae84ce937ef6337b55c4e89b47b8679cf159a7c5245ccdf7", + "/part/f/select": "09273dd8af76199acfc797d73083480844bb975beafcf61862d55634b9a576a3", + "/part/f/knapsack": "6cb59f767cbc7ea1d16739097c539d222feca48317402beb9fb7f8c82c6fc708", + "/part/f/diameter": "ce51245a2bf7c5812bfe2a17802600432283e0f29e4b0b728b6d5df0e627da7a", + "/part/f/cover": "ae508a124f358dc42ae3388c1daaa3c5ea25131a420719fc89d39bdad53a4e77", + "/part/f/count": "9755f399ca2cc80a573c6781f700dec992b9cc64f24fa80df765ca034b56aa11", + "/part/g/board": "7e505483f408a148dfd1d1526125187fd0f60a8a688327d4aedaec2bcb35089b", + "/part/g/tsp": "899de6dbeffad335a9868a18ae4e3a8d9e86dd0ea137e468e20c67bd204ca619", + "/part/g/cover": "446c3307a6e6eaa20c76c5ec04b3976984527ac2e9d3883890cc62691f742762", + "/part/g/subset": "cab56d4272342fd5a77e77b83035b91c89e373f2e6725aab65314b75a395d41e", + "/part/g/plug": "e4a20f03297e79e121bf561857852af2a565a6cd8393438409559d6773b7de12", + "/method": "27dac5989daab8eaeaeac912b54ab02021153e987b76e8ddb1d2a9b2d32fc97d", + "/problems": "b9d1c455a7e34b4fbe6a2a4f284a3390c8299be66043222839a27029d9427772" }) From c7d87a8d465ebc02ee1b961e8b2ba7e206d3f875 Mon Sep 17 00:00:00 2001 From: Shanire Date: Wed, 26 Aug 2026 17:10:50 +0800 Subject: [PATCH 15/23] fix: close semantic evidence review gaps Signed-off-by: Shanire --- docs/engineering/architecture.md | 2 +- docs/operations/verification.md | 4 +- .../scripts/fixtures/semantic-patch-react.mjs | 6 + .../scripts/fixtures/semantic-patch-timer.mjs | 1 + site/scripts/last-modified.mjs | 2 +- site/scripts/semantic-source-graph.mjs | 671 +++++++++++++++++- site/scripts/semantic-source-graph.test.mjs | 127 +++- site/scripts/seo-contract.test.mjs | 2 +- site/src/data/routeLastModified.ts | 10 +- 9 files changed, 767 insertions(+), 58 deletions(-) create mode 100644 site/scripts/fixtures/semantic-patch-react.mjs create mode 100644 site/scripts/fixtures/semantic-patch-timer.mjs diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index 7f332ab..c110eb3 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -114,7 +114,7 @@ Every parsed source contribution is reduced to a runtime AST projection, so Type Effect-only callbacks are excluded only when the callee has one exact, stable React-effect origin through named, default, namespace, member, or destructured aliases. Mixed aliases, reassigned hooks, patched React namespaces, and same-named local helpers are never pruned as effects. Runtime import edges remain while declaration- and specifier-level type-only edges, unselected import siblings, object-spread copies, and JSX attribute names do not leak in. The graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Public representation identity is structurally isolated in `config/site.ts`; client endpoints and sidebar-only copy are in modules reachable only through pruned branches. -Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. An unchanged digest preserves the previous value; changed content uses the precise Git commit or working-tree timestamp of a semantically changed projection and must advance. During a digest-schema migration, the current projector is applied to historical sources at the previous `lastmod`: a clean committed semantic edit advances, while an edit confined to a pruned client branch does not. Version 12 retains the trusted `2026-08-22` evidence while closing tagged-template, inline constructor/IIFE, callback-timing (including synchronous callback factories and stable named asynchronous callbacks), shallow-copy, module-evaluation, intrinsic-client-attribute and spread, cross-module React-patch, global-owner/prototype mutation and destructuring, external synchronous call, unstable-alias, and public-config provenance gaps. It also prevents historical client-only `config/site.ts` fields from falsely advancing the home route. The home route therefore remains at `23:46:11+08:00`, and the other 46 routes remain at `11:47:40+08:00`. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, type-only noise, or incomplete execution/scope/mutation context. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. +Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. An unchanged digest preserves the previous value; changed content uses the precise Git commit or working-tree timestamp of a semantically changed projection and must advance. During a digest-schema migration, the current projector is applied to historical sources at the previous `lastmod`: a clean committed semantic edit advances, while an edit confined to a pruned client branch does not. Version 13 retains the trusted `2026-08-22` evidence and extends the Version 12 closure across stable callback aliases, callback factories, conditional intrinsic handlers, exported callback bindings, recursively inspected module patches, indirect global-owner containers and patterns, and standalone external calls during module or root evaluation. Unsupported or unresolved provenance fails closed. It also prevents historical client-only `config/site.ts` fields from falsely advancing the home route. The home route therefore remains at `23:46:11+08:00`, and the other 46 routes remain at `11:47:40+08:00`. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, type-only noise, or incomplete execution/scope/mutation context. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. # Analytics diff --git a/docs/operations/verification.md b/docs/operations/verification.md index 67758cd..9592ced 100644 --- a/docs/operations/verification.md +++ b/docs/operations/verification.md @@ -44,9 +44,9 @@ Pure TypeScript Modules imported by Node tests must use explicit `.ts` extension # SEO And Accessibility Checks -`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. Version 12 follows each route's real SSR injection chain with a line-ending-neutral runtime-AST digest: type-only syntax is erased without dropping runtime-bearing TypeScript parameter properties or optional-chain behavior; intrinsic event/ref/key props and statically proven spreads erase only deferred callback values while retaining synchronous factories; public `config/site.ts` evidence follows immutable field dependencies and validates global provenance; and mixed shell modules contribute route-rendering JSX plus the lexical closure of all runtime module-evaluation edges, root/program synchronous calls, proven synchronous callbacks, constructors, tagged templates, aliases, and render-phase mutations. Known asynchronous callbacks, including stable named callbacks, are erased even when nested inside an otherwise selected synchronous slice, while callback acquisition remains synchronous evidence. Regression probes cover scope/control flow, stable and unstable aliases, destructuring defaults and global owners, direct and nested shallow-copy precision, call/getter/constructor/tag-created targets, local and cross-module callables, synchronous versus known asynchronous callbacks, patched timer/Promise/collection semantics, `Reflect.apply`, patched/replaced global mutators and containers, exact React-effect aliases, side-effect module patches, mixed or patched hooks, and historical public-config projection. Unsupported mutable, escaped, aliased, globally patched, or dynamic config, unproved callback timing, dynamic targets or intrinsic spreads, imported member receivers, arbitrary external mutations, and ambiguous nested writes outside proven client callbacks fail closed. +`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. Version 13 follows each route's real SSR injection chain with a line-ending-neutral runtime-AST digest: type-only syntax is erased without dropping runtime-bearing TypeScript parameter properties or optional-chain behavior; intrinsic event/ref/key props and statically proven spreads erase only deferred callback values while retaining synchronous factories; public `config/site.ts` evidence follows immutable field dependencies and validates global provenance; and mixed shell modules contribute route-rendering JSX plus the lexical closure of all runtime module-evaluation edges, root/program synchronous calls, proven synchronous callbacks, constructors, tagged templates, aliases, and render-phase mutations. Known asynchronous callbacks, including stable aliases and locally private callback-factory results, are erased even when nested inside an otherwise selected synchronous slice, while callback acquisition remains synchronous evidence and exported callbacks remain conservative. Regression probes cover scope/control flow, stable and unstable aliases, destructuring defaults and indirect global owners, direct and nested shallow-copy precision, call/getter/constructor/tag-created targets, local and cross-module callables, synchronous versus known asynchronous callbacks, patched timer/Promise/collection and React semantics across recursively inspected runtime imports, conditional direct and spread event handlers, `Reflect.apply`, patched/replaced global mutators and containers, exact React-effect aliases, side-effect module patches, mixed or patched hooks, standalone external calls, and historical public-config projection. Unsupported mutable, escaped, aliased, globally patched, unresolved, or dynamic config, unproved callback timing, dynamic targets or intrinsic spreads, imported member receivers, arbitrary external mutations, and ambiguous nested writes outside proven client callbacks fail closed. -Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. An unchanged digest preserves its prior time; changed content uses the exact Git commit or working-tree timestamp of a semantically changed projection and must advance. A schema migration reapplies the current projector to source history at the previous `lastmod`, so clean committed semantic edits advance while dirty changes confined to pruned client branches do not. Version 12 retains `23:46:11+08:00` for the home hero change and `11:47:40+08:00` for the other 46 routes; the historical regression proves that removed client-only fields in the older site config do not advance home evidence. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. +Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. An unchanged digest preserves its prior time; changed content uses the exact Git commit or working-tree timestamp of a semantically changed projection and must advance. A schema migration reapplies the current projector to source history at the previous `lastmod`, so clean committed semantic edits advance while dirty changes confined to pruned client branches do not. Version 13 retains `23:46:11+08:00` for the home hero change and `11:47:40+08:00` for the other 46 routes; the historical regression proves that removed client-only fields in the older site config do not advance home evidence. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. The browser gate runs Chromium against built `dist/` through a strict, non-reused custom preview server. Route tests directly open `/`, `/part/a`, `/part/a/01`, `/method`, and `/part/g/plug`, then exercise live client navigation and keyboard focus. They check HTTP status, prerendered HTML, absence of pending streamed-Suspense placeholders, route CSS at first paint, hydration without errors, CLS below `0.05`, title/description/abstract/canonical/zero-hreflang/Open Graph/JSON-LD metadata, one visible `h1`, route announcements, current-page semantics, initial-load focus, changed-route focus, and skip-link focus. Navigation-positioning tests click a representative lesson outline in each of the seven families, restore a direct lesson fragment, verify the mobile outline and an ordinary family-page fragment, and require a changed route to reset old-page scroll immediately. An unknown path must return 404 with `noindex,nofollow`, no canonical, and the themed not-found heading. Worker-level Node tests separately cover the Accept matrix, 406 behavior, HEAD parity, representation-specific ETags, hidden internal assets, and unaffected API/static/unknown paths. diff --git a/site/scripts/fixtures/semantic-patch-react.mjs b/site/scripts/fixtures/semantic-patch-react.mjs new file mode 100644 index 0000000..d3c66e2 --- /dev/null +++ b/site/scripts/fixtures/semantic-patch-react.mjs @@ -0,0 +1,6 @@ +import * as React from 'react' + +const holder = { React } +Reflect.set(holder.React, 'useEffect', (callback) => callback()) + +export const unused = true diff --git a/site/scripts/fixtures/semantic-patch-timer.mjs b/site/scripts/fixtures/semantic-patch-timer.mjs new file mode 100644 index 0000000..1ef9262 --- /dev/null +++ b/site/scripts/fixtures/semantic-patch-timer.mjs @@ -0,0 +1 @@ +globalThis.setTimeout = (callback) => callback() diff --git a/site/scripts/last-modified.mjs b/site/scripts/last-modified.mjs index c7d6a86..c7da1e5 100644 --- a/site/scripts/last-modified.mjs +++ b/site/scripts/last-modified.mjs @@ -9,7 +9,7 @@ import { } from './semantic-source-graph.mjs' const projectRoot = fileURLToPath(new URL('../../', import.meta.url)) -export const ROUTE_CONTENT_DIGEST_VERSION = 12 +export const ROUTE_CONTENT_DIGEST_VERSION = 13 const historicalSourceCache = new Map() const headSourceCache = new Map() diff --git a/site/scripts/semantic-source-graph.mjs b/site/scripts/semantic-source-graph.mjs index 2648720..b98a6a7 100644 --- a/site/scripts/semantic-source-graph.mjs +++ b/site/scripts/semantic-source-graph.mjs @@ -228,7 +228,9 @@ function runtimeAstProjection(value, options = {}) { return projectRuntimeObject(value, { attributes: value.attributes.flatMap((attribute) => { if (isDroppableClientOnlyIntrinsicJsxAttribute(attribute, value, options)) return [] - if (attribute.type !== 'JSXSpreadAttribute') return [attribute] + if (attribute.type !== 'JSXSpreadAttribute') { + return [options.projectIntrinsicClientAttribute?.(attribute) ?? attribute] + } return [{ ...attribute, argument: options.projectIntrinsicJsxSpread?.(attribute.argument) ?? attribute.argument, @@ -236,6 +238,24 @@ function runtimeAstProjection(value, options = {}) { }), }, options) } + if (value.type === 'ReturnStatement') { + const projectedReturnValue = options.projectDeferredReturnValue?.(value) + if (projectedReturnValue) { + return projectRuntimeObject(value, { argument: projectedReturnValue }, options) + } + } + if (value.type === 'ArrowFunctionExpression') { + const projectedBody = options.projectDeferredArrowBody?.(value) + if (projectedBody) { + return projectRuntimeObject(value, { body: projectedBody }, options) + } + } + if (value.type === 'Property') { + const projectedPropertyValue = options.projectIntrinsicSpreadPropertyValue?.(value) + if (projectedPropertyValue) { + return projectRuntimeObject(value, { value: projectedPropertyValue }, options) + } + } if (value.type === 'VariableDeclarator') { const projectedInitializer = options.projectIntrinsicSpreadInitializer?.(value) if (projectedInitializer) { @@ -333,12 +353,32 @@ function publicSiteConfigProjection(program) { ? bindingIdentifiers(property.value).map((identifier) => identifier.name) : [] ) - const isGlobalContainer = (node) => { + const constBindingExpression = (name, seen) => ( + bindingKinds.get(name) === 'const' && !seen.has(name) + ? bindings.get(name) + : null + ) + const isGlobalContainer = (node, seen = new Set()) => { const value = unwrap(node) + if (value?.type === 'ConditionalExpression') { + return isGlobalContainer(value.consequent, seen) + && isGlobalContainer(value.alternate, seen) + } + if (value?.type === 'LogicalExpression') { + return isGlobalContainer(value.left, seen) && isGlobalContainer(value.right, seen) + } + if (value?.type === 'SequenceExpression') { + return isGlobalContainer(value.expressions.at(-1), seen) + } if (value?.type !== 'Identifier') return false - return bindings.has(value.name) - ? globalContainerAliases.has(value.name) - : ['global', 'globalThis', 'self', 'window'].includes(value.name) + if (!bindings.has(value.name)) { + return ['global', 'globalThis', 'self', 'window'].includes(value.name) + } + if (globalContainerAliases.has(value.name)) return true + const initializer = constBindingExpression(value.name, seen) + return initializer + ? isGlobalContainer(initializer, new Set([...seen, value.name])) + : false } let containerAliasesChanged = true while (containerAliasesChanged) { @@ -388,16 +428,102 @@ function publicSiteConfigProjection(program) { } indexGlobalContainerAlias(program) } - const isGlobalObject = (node) => { + const staticMemberExpressions = (node, key, seen = new Set()) => { const value = unwrap(node) + if (!value || typeof value !== 'object') return [] + if (value.type === 'Identifier' && bindings.has(value.name)) { + const initializer = constBindingExpression(value.name, seen) + return initializer + ? staticMemberExpressions(initializer, key, new Set([...seen, value.name])) + : [] + } + if (value.type === 'ConditionalExpression' || value.type === 'LogicalExpression') { + const branches = value.type === 'ConditionalExpression' + ? [value.consequent, value.alternate] + : [value.left, value.right] + return branches.flatMap((branch) => staticMemberExpressions(branch, key, seen)) + } + if (value.type === 'SequenceExpression') { + return staticMemberExpressions(value.expressions.at(-1), key, seen) + } + if (value.type === 'ArrayExpression' && /^\d+$/u.test(String(key))) { + const element = value.elements[Number(key)] + return element && element.type !== 'SpreadElement' ? [element] : [] + } + if (value.type !== 'ObjectExpression') return [] + const matches = [] + for (const property of value.properties) { + if (property.type === 'SpreadElement') { + matches.push(...staticMemberExpressions(property.argument, key, seen)) + } else if (property.type === 'Property' && staticPatternPropertyName(property) === String(key)) { + matches.push(property.value) + } + } + return matches + } + const isGlobalObject = (node, seen = new Set()) => { + const value = unwrap(node) + if (value?.type === 'ConditionalExpression') { + return isGlobalObject(value.consequent, seen) && isGlobalObject(value.alternate, seen) + } + if (value?.type === 'LogicalExpression') { + return isGlobalObject(value.left, seen) && isGlobalObject(value.right, seen) + } + if (value?.type === 'SequenceExpression') { + return isGlobalObject(value.expressions.at(-1), seen) + } if (value?.type === 'Identifier') { - return value.name === 'Object' - ? !bindings.has('Object') - : globalObjectAliases.has(value.name) + if (value.name === 'Object' && !bindings.has('Object')) return true + if (globalObjectAliases.has(value.name)) return true + const initializer = constBindingExpression(value.name, seen) + return initializer + ? isGlobalObject(initializer, new Set([...seen, value.name])) + : false + } + if (value?.type !== 'MemberExpression') return false + const name = localMemberName(value) + if (isGlobalContainer(value.object, seen) && name === 'Object') return true + if (name === null) return false + const members = staticMemberExpressions(value.object, name, seen) + return members.length > 0 && members.every((member) => isGlobalObject(member, seen)) + } + const containsGlobalObjectIdentity = (node, seen = new Set()) => { + const value = unwrap(node) + if (!value || typeof value !== 'object') return false + if (isGlobalObject(value, seen)) return true + if (value.type === 'Identifier' && bindings.has(value.name)) { + const initializer = constBindingExpression(value.name, seen) + return initializer + ? containsGlobalObjectIdentity(initializer, new Set([...seen, value.name])) + : false + } + if (value.type === 'MemberExpression') { + const name = localMemberName(value) + return name !== null + && staticMemberExpressions(value.object, name, seen) + .some((member) => containsGlobalObjectIdentity(member, seen)) + } + if (value.type === 'ConditionalExpression') { + return containsGlobalObjectIdentity(value.consequent, seen) + || containsGlobalObjectIdentity(value.alternate, seen) } - return value?.type === 'MemberExpression' - && isGlobalContainer(value.object) - && localMemberName(value) === 'Object' + if (value.type === 'LogicalExpression') { + return containsGlobalObjectIdentity(value.left, seen) + || containsGlobalObjectIdentity(value.right, seen) + } + if (value.type === 'SequenceExpression') { + return value.expressions.some((expression) => containsGlobalObjectIdentity(expression, seen)) + } + if (value.type === 'ArrayExpression') { + return value.elements.some((element) => containsGlobalObjectIdentity(element, seen)) + } + if (value.type === 'ObjectExpression') { + return value.properties.some((property) => containsGlobalObjectIdentity( + property.type === 'SpreadElement' ? property.argument : property.value, + seen, + )) + } + return false } let aliasesChanged = true while (aliasesChanged) { @@ -425,6 +551,8 @@ function publicSiteConfigProjection(program) { ) { globalObjectAliases.add(target.name) aliasesChanged = true + } else if (target && source && containsGlobalObjectIdentity(source)) { + dynamicGlobalObjectAlias = true } const pattern = node.type === 'VariableDeclarator' ? node.id @@ -445,6 +573,34 @@ function publicSiteConfigProjection(program) { aliasesChanged = true } } + } else if (pattern?.type === 'ObjectPattern' && source) { + for (const property of pattern.properties) { + const key = staticPatternPropertyName(property) + const members = key === null ? [] : staticMemberExpressions(source, key) + if (members.length > 0 && members.every((member) => isGlobalObject(member))) { + for (const name of patternTargetNames(property)) { + if (globalObjectAliases.has(name)) continue + globalObjectAliases.add(name) + aliasesChanged = true + } + } else if (members.some((member) => containsGlobalObjectIdentity(member))) { + dynamicGlobalObjectAlias = true + } + } + } else if (pattern?.type === 'ArrayPattern' && source) { + for (const [index, element] of pattern.elements.entries()) { + if (!element) continue + const members = staticMemberExpressions(source, index) + if (members.length > 0 && members.every((member) => isGlobalObject(member))) { + for (const identifier of bindingIdentifiers(element)) { + if (globalObjectAliases.has(identifier.name)) continue + globalObjectAliases.add(identifier.name) + aliasesChanged = true + } + } else if (members.some((member) => containsGlobalObjectIdentity(member))) { + dynamicGlobalObjectAlias = true + } + } } for (const [child] of childNodes(node)) indexGlobalObjectAlias(child) } @@ -747,13 +903,14 @@ function canonicalImportIdentity(importerPath, specifier) { return unresolved.endsWith('/index') ? unresolved.slice(0, -'/index'.length) : unresolved } -function semanticBindingGraph(program, path) { +function semanticBindingGraph(program, path, analysisContext = {}) { const rootScope = createScope(null, 'program', program) const nodeScopes = new WeakMap() const nodeParents = new WeakMap() const declaredIdentifiers = new WeakSet() const importedBindingsByIdentity = new Map() const reactNamespaceBindings = new Set() + const exportedBindings = new Set() const moduleEvaluationEdges = [] let hasOpaqueReactSideEffect = false const synchronousCalls = [] @@ -973,6 +1130,43 @@ function semanticBindingGraph(program, path) { return null } + function indexExportedBindings(node) { + if (node.type === 'ExportNamedDeclaration') { + const declaration = node.declaration + if (declaration?.id) { + for (const identifier of bindingIdentifiers(declaration.id)) { + const binding = lookup(identifier) + if (binding) exportedBindings.add(binding) + } + } + if (declaration?.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations) { + for (const identifier of bindingIdentifiers(declarator.id)) { + const binding = lookup(identifier) + if (binding) exportedBindings.add(binding) + } + } + } + for (const specifier of node.specifiers ?? []) { + const local = specifier.local + const binding = local?.type === 'Identifier' ? lookup(local) : null + if (binding) exportedBindings.add(binding) + } + } + if (node.type === 'ExportDefaultDeclaration') { + const declaration = node.declaration + if (declaration?.type === 'Identifier') { + const binding = lookup(declaration) + if (binding) exportedBindings.add(binding) + } else if (declaration?.id) { + const binding = lookup(declaration.id) + if (binding) exportedBindings.add(binding) + } + } + for (const [child] of childNodes(node)) indexExportedBindings(child) + } + indexExportedBindings(program) + function writtenBindings(node, bindings = []) { if (!node || typeof node !== 'object') return bindings if (node.type === 'Identifier') { @@ -1387,11 +1581,13 @@ function semanticBindingGraph(program, path) { indexValueOrigins() const patchedReactBindings = new Set() + let patchesGlobalReact = false function markReactBindingsPatched(bindings) { const aliases = new Set( [...bindings].flatMap((binding) => [...binding.aliases]), ) if (![...aliases].some((binding) => exactOrigin(binding, 'react-namespace'))) return + patchesGlobalReact = true for (const binding of aliases) patchedReactBindings.add(binding) } function markAllReactBindingsPatched() { @@ -1399,7 +1595,12 @@ function semanticBindingGraph(program, path) { for (const alias of binding.aliases) patchedReactBindings.add(alias) } } - if (hasOpaqueReactSideEffect) markAllReactBindingsPatched() + function markAllReactImportsPatched() { + for (const binding of importedBindingsByIdentity.get('react') ?? []) { + for (const alias of binding.aliases) patchedReactBindings.add(alias) + } + } + if (hasOpaqueReactSideEffect) markAllReactImportsPatched() function indexPatchedReactBindings(node) { const target = node.type === 'AssignmentExpression' ? node.left @@ -1417,7 +1618,7 @@ function semanticBindingGraph(program, path) { ) markAllReactBindingsPatched() } const possiblyEscapedValues = [ - ...(node.type === 'CallExpression' ? node.arguments ?? [] : []), + ...(node.type === 'CallExpression' && !isReactEffectCall(node) ? node.arguments ?? [] : []), ...(node.type === 'AssignmentExpression' && isMemberMutationTarget(node.left) ? [node.right] : []), @@ -1622,6 +1823,10 @@ function semanticBindingGraph(program, path) { const clientOnlyDeferredValues = new WeakSet() const clientOnlyDeferredFunctions = new WeakSet() const deferredBindingCandidates = new Set() + const deferredFactoryCandidates = new Set() + const deferredFactoryCallees = new WeakSet() + const deferredReturnValues = new WeakMap() + const deferredArrowBodies = new WeakMap() function markClientOnlyDeferredValue(node) { const value = unwrapExpression(node) if (!value) return @@ -1634,6 +1839,14 @@ function semanticBindingGraph(program, path) { if (binding) deferredBindingCandidates.add(binding) return } + if (value.type === 'CallExpression') { + const factoryBinding = stableCallableBinding(value.callee) + if (factoryBinding) { + deferredFactoryCandidates.add(factoryBinding) + deferredFactoryCallees.add(unwrapExpression(value.callee)) + } + return + } if (value.type === 'SequenceExpression' && value.expressions.length > 0) { markClientOnlyDeferredValue(value.expressions.at(-1)) } else if (value.type === 'ConditionalExpression') { @@ -1702,14 +1915,39 @@ function semanticBindingGraph(program, path) { return property?.type === 'Property' ? reassignedBindings(property.value) : [] } - function unboundGlobalContainer(node) { + function directConstInitializer(binding) { + const relation = nodeParents.get(binding?.identifier) + if (relation?.parent.type !== 'VariableDeclarator' || relation.key !== 'id') return null + const declaration = nodeParents.get(relation.parent)?.parent + return declaration?.type === 'VariableDeclaration' && declaration.kind === 'const' + ? relation.parent.init + : null + } + + function unboundGlobalContainer(node, seen = new Set()) { if (!node || typeof node !== 'object') return false - if (EXPRESSION_WRAPPERS.has(node.type)) return unboundGlobalContainer(node.expression) + if (EXPRESSION_WRAPPERS.has(node.type)) return unboundGlobalContainer(node.expression, seen) + if (node.type === 'ConditionalExpression') { + return unboundGlobalContainer(node.consequent, seen) + && unboundGlobalContainer(node.alternate, seen) + } + if (node.type === 'LogicalExpression') { + return unboundGlobalContainer(node.left, seen) + && unboundGlobalContainer(node.right, seen) + } + if (node.type === 'SequenceExpression') { + return unboundGlobalContainer(node.expressions.at(-1), seen) + } if (node.type !== 'Identifier') return false const binding = lookup(node) - return binding - ? globalContainerAliases.has(binding) - : ['global', 'globalThis', 'self', 'window'].includes(node.name) + if (!binding) return ['global', 'globalThis', 'self', 'window'].includes(node.name) + if (globalContainerAliases.has(binding) || seen.has(binding)) { + return globalContainerAliases.has(binding) + } + const initializer = directConstInitializer(binding) + return initializer + ? unboundGlobalContainer(initializer, new Set([...seen, binding])) + : false } function indexGlobalContainerAliases() { @@ -1765,18 +2003,80 @@ function semanticBindingGraph(program, path) { } indexGlobalContainerAliases() - function globalStaticOwner(node) { + function staticMemberExpressions(node, key, seen = new Set()) { + if (!node || typeof node !== 'object') return [] + if (EXPRESSION_WRAPPERS.has(node.type)) return staticMemberExpressions(node.expression, key, seen) + if (node.type === 'Identifier') { + const binding = lookup(node) + if (!binding || seen.has(binding)) return [] + const initializer = directConstInitializer(binding) + return initializer + ? staticMemberExpressions(initializer, key, new Set([...seen, binding])) + : [] + } + if (node.type === 'ConditionalExpression' || node.type === 'LogicalExpression') { + const branches = node.type === 'ConditionalExpression' + ? [node.consequent, node.alternate] + : [node.left, node.right] + return branches.flatMap((branch) => staticMemberExpressions(branch, key, seen)) + } + if (node.type === 'SequenceExpression') { + return staticMemberExpressions(node.expressions.at(-1), key, seen) + } + if (node.type === 'ArrayExpression' && /^\d+$/u.test(String(key))) { + const element = node.elements[Number(key)] + return element && element.type !== 'SpreadElement' ? [element] : [] + } + if (node.type !== 'ObjectExpression') return [] + const matches = [] + for (const property of node.properties) { + if (property.type === 'SpreadElement') { + matches.push(...staticMemberExpressions(property.argument, key, seen)) + } else if (property.type === 'Property' && staticPatternOwner(property) === String(key)) { + matches.push(property.value) + } + } + return matches + } + + function combinedStaticOwner(nodes) { + const resolvedOwners = nodes.map((node) => globalStaticOwner(node)) + const owners = new Set(resolvedOwners.filter(Boolean)) + if (owners.size > 0 && resolvedOwners.some((owner) => !owner)) return '*' + if (owners.has('*') || owners.size > 1) return '*' + return owners.values().next().value ?? null + } + + function globalStaticOwner(node, seen = new Set()) { if (!node || typeof node !== 'object') return null - if (EXPRESSION_WRAPPERS.has(node.type)) return globalStaticOwner(node.expression) + if (EXPRESSION_WRAPPERS.has(node.type)) return globalStaticOwner(node.expression, seen) + if (node.type === 'ConditionalExpression' || node.type === 'LogicalExpression') { + const branches = node.type === 'ConditionalExpression' + ? [node.consequent, node.alternate] + : [node.left, node.right] + return combinedStaticOwner(branches) + } + if (node.type === 'SequenceExpression') return globalStaticOwner(node.expressions.at(-1), seen) if (node.type === 'Identifier') { const binding = lookup(node) if (!binding && TRACKED_GLOBAL_OWNERS.has(node.name)) return node.name if (ambiguousStaticOwnerAliases.has(binding)) return '*' - return staticOwnerAliases.get(binding) ?? null + const aliasOwner = staticOwnerAliases.get(binding) + if (aliasOwner || !binding || seen.has(binding)) return aliasOwner ?? null + const initializer = directConstInitializer(binding) + return initializer + ? globalStaticOwner(initializer, new Set([...seen, binding])) + : null } - if (node.type === 'MemberExpression' && unboundGlobalContainer(node.object)) { + if (node.type === 'MemberExpression') { const name = memberName(node) - return TRACKED_GLOBAL_OWNERS.has(name) ? name : null + if (unboundGlobalContainer(node.object)) { + return TRACKED_GLOBAL_OWNERS.has(name) ? name : name === null ? '*' : null + } + const nestedOwner = globalStaticOwner(node.object, seen) + if (nestedOwner) return nestedOwner + if (name === null) return null + return combinedStaticOwner(staticMemberExpressions(node.object, name, seen)) } return null } @@ -1830,6 +2130,32 @@ function semanticBindingGraph(program, path) { } } } + } else if (pattern?.type === 'ObjectPattern' && source) { + for (const property of pattern.properties) { + const key = staticPatternOwner(property) + const owner = key === null + ? '*' + : combinedStaticOwner(staticMemberExpressions(source, key)) + for (const binding of patternBindings(property)) { + if (owner && staticOwnerAliases.get(binding) !== owner) { + staticOwnerAliases.set(binding, owner) + changed = true + } else if (!owner && key === null && !ambiguousStaticOwnerAliases.has(binding)) { + ambiguousStaticOwnerAliases.add(binding) + changed = true + } + } + } + } else if (pattern?.type === 'ArrayPattern' && source) { + for (const [index, element] of pattern.elements.entries()) { + if (!element) continue + const owner = combinedStaticOwner(staticMemberExpressions(source, index)) + for (const binding of reassignedBindings(element)) { + if (!owner || staticOwnerAliases.get(binding) === owner) continue + staticOwnerAliases.set(binding, owner) + changed = true + } + } } for (const [child] of childNodes(node)) visit(child) } @@ -1851,6 +2177,7 @@ function semanticBindingGraph(program, path) { if (!node || typeof node !== 'object') return null if (EXPRESSION_WRAPPERS.has(node.type)) return mutationMemberOwner(node.expression) if (node.type !== 'MemberExpression') return null + if (unboundGlobalContainer(node.object)) return globalStaticOwner(node) return globalOwnerRoot(node.object) } @@ -1932,6 +2259,122 @@ function semanticBindingGraph(program, path) { } indexPatchedStaticOwners(program) + function importedPatchSummary() { + const cache = analysisContext.patchSummaryCache ?? new Map() + const currentPath = resolve(projectRoot, path) + const stack = analysisContext.patchSummaryStack ?? new Set([currentPath]) + const summary = { patchedStaticOwners: new Set(), patchesReact: false } + const mergeSummary = (imported) => { + for (const owner of imported.patchedStaticOwners) summary.patchedStaticOwners.add(owner) + summary.patchesReact ||= imported.patchesReact + } + for (const edge of moduleEvaluationEdges) { + const specifier = edge?.value + if (typeof specifier !== 'string' || specifier.endsWith('.css')) continue + if (!specifier.startsWith('.')) { + if (edge === nodeParents.get(edge)?.parent?.source) { + const declaration = nodeParents.get(edge)?.parent + if (declaration?.type === 'ImportDeclaration' && declaration.specifiers.length === 0) { + for (const owner of TRACKED_GLOBAL_OWNERS) summary.patchedStaticOwners.add(owner) + } + } + continue + } + const resolvedImport = resolveStaticImport(currentPath, specifier) + if (!resolvedImport) { + const declaration = nodeParents.get(edge)?.parent + const runtimeBindings = (declaration?.specifiers ?? []).flatMap((specifier) => { + const binding = specifier.local ? lookup(specifier.local) : null + return binding ? [binding] : [] + }) + let hasRuntimeReference = false + function findRuntimeReference(node) { + if (hasRuntimeReference) return + if ( + node.type === 'Identifier' + && !declaredIdentifiers.has(node) + && runtimeBindings.some((binding) => lookup(node)?.aliases === binding.aliases) + && isReference(node) + ) { + hasRuntimeReference = true + return + } + for (const [child] of childNodes(node)) findRuntimeReference(child) + } + findRuntimeReference(program) + if ((declaration?.specifiers.length ?? 0) === 0 || !hasRuntimeReference) { + for (const owner of TRACKED_GLOBAL_OWNERS) summary.patchedStaticOwners.add(owner) + summary.patchesReact = true + } + continue + } + if (!PARSED_EXTENSIONS.has(extname(resolvedImport)) || stack.has(resolvedImport)) continue + if (cache.has(resolvedImport)) { + mergeSummary(cache.get(resolvedImport)) + continue + } + let imported + try { + const importedPath = projectPath(resolvedImport) + const parsed = parseSync(importedPath, readFileSync(resolvedImport, 'utf8')) + if (parsed.errors.length > 0) throw new Error(parsed.errors[0].message) + imported = semanticBindingGraph(parsed.program, importedPath, { + patchSummaryCache: cache, + patchSummaryOnly: true, + patchSummaryStack: new Set([...stack, resolvedImport]), + }) + } catch { + imported = { + patchedStaticOwners: new Set(TRACKED_GLOBAL_OWNERS), + patchesReact: true, + } + } + cache.set(resolvedImport, imported) + mergeSummary(imported) + } + return summary + } + + const dependencyPatchSummary = importedPatchSummary() + for (const owner of dependencyPatchSummary.patchedStaticOwners) { + patchedStaticOwners.add(owner) + } + if (dependencyPatchSummary.patchesReact) markAllReactImportsPatched() + if (analysisContext.patchSummaryOnly) { + return { + patchedStaticOwners: new Set(patchedStaticOwners), + patchesReact: patchesGlobalReact + || hasOpaqueReactSideEffect + || dependencyPatchSummary.patchesReact, + } + } + + function indexIntrinsicSpreadDeferredValues(node, seen = new Set()) { + const value = unwrapExpression(node) + if (!value || typeof value !== 'object') return + if (value.type === 'Identifier') { + const binding = lookup(value) + if (!binding || seen.has(binding)) return + const initializer = directConstInitializer(binding) + if (initializer) { + indexIntrinsicSpreadDeferredValues(initializer, new Set([...seen, binding])) + } + return + } + if (value.type !== 'ObjectExpression') return + for (const property of value.properties) { + if (property.type === 'SpreadElement') { + indexIntrinsicSpreadDeferredValues(property.argument, seen) + } else if ( + property.type === 'Property' + && property.kind === 'init' + && isClientOnlyIntrinsicPropertyName(staticPatternOwner(property)) + ) { + markClientOnlyDeferredValue(property.value) + } + } + } + function indexClientOnlyDeferredValues(node) { if (node.type === 'CallExpression') { if (isReactEffectCall(node)) { @@ -1942,11 +2385,123 @@ function semanticBindingGraph(program, path) { } } } + if (node.type === 'JSXOpeningElement' && isIntrinsicJsxName(node.name)) { + for (const attribute of node.attributes) { + if ( + attribute.type === 'JSXAttribute' + && attribute.name.type === 'JSXIdentifier' + && isClientOnlyIntrinsicPropertyName(attribute.name.name) + && attribute.value?.type === 'JSXExpressionContainer' + ) markClientOnlyDeferredValue(attribute.value.expression) + if (attribute.type === 'JSXSpreadAttribute') { + indexIntrinsicSpreadDeferredValues(attribute.argument) + } + } + } for (const [child] of childNodes(node)) indexClientOnlyDeferredValues(child) } + function stableAliasPlumbingReference(node, aliases) { + const relation = nodeParents.get(node) + if (relation?.parent.type !== 'VariableDeclarator' || relation.key !== 'init') return false + const declaration = nodeParents.get(relation.parent)?.parent + if (declaration?.type !== 'VariableDeclaration' || declaration.kind !== 'const') return false + const targets = aliasTargetBindings(relation.parent.id) + return targets.length > 0 && targets.every((target) => target.aliases === aliases) + } + + function bindingReferencesAreDeferredFactoryCalls(binding) { + let safe = true + function inspect(node) { + if (!safe) return + if ( + node.type === 'Identifier' + && !declaredIdentifiers.has(node) + && lookup(node)?.aliases === binding.aliases + && isReference(node) + && !deferredFactoryCallees.has(unwrapExpression(node)) + && !stableAliasPlumbingReference(node, binding.aliases) + ) { + safe = false + return + } + for (const [child] of childNodes(node)) inspect(child) + } + inspect(program) + return safe + } + + function callableNodes(binding) { + const nodes = new Set() + for (const alias of binding.aliases) { + const relation = nodeParents.get(alias.identifier) + if ( + relation?.parent.type === 'FunctionDeclaration' + || relation?.parent.type === 'FunctionExpression' + ) { + nodes.add(relation.parent) + } else if (relation?.parent.type === 'VariableDeclarator' && relation.key === 'id') { + const initializer = unwrapExpression(relation.parent.init) + if ( + initializer?.type === 'ArrowFunctionExpression' + || initializer?.type === 'FunctionExpression' + ) nodes.add(initializer) + } + } + return nodes + } + + function returnStatementsForFunction(functionNode) { + const returns = [] + function visit(node) { + if (node !== functionNode && ( + node.type === 'ArrowFunctionExpression' + || node.type === 'FunctionDeclaration' + || node.type === 'FunctionExpression' + )) return + if (node.type === 'ReturnStatement' && node.argument) returns.push(node) + for (const [child] of childNodes(node)) visit(child) + } + if (functionNode.body?.type === 'BlockStatement') visit(functionNode.body) + else if (functionNode.body) { + markClientOnlyDeferredValue(functionNode.body) + deferredArrowBodies.set(functionNode, projectDeferredCallbackValue( + functionNode.body, + 'ClientDeferredFactoryResult', + )) + } + return returns + } + + function finalizeDeferredFactories() { + const processed = new Set() + while ([...deferredFactoryCandidates].some((binding) => !processed.has(binding))) { + for (const binding of [...deferredFactoryCandidates]) { + if (processed.has(binding)) continue + processed.add(binding) + if ( + [...binding.aliases].some((alias) => exportedBindings.has(alias)) + || !bindingReferencesAreDeferredFactoryCalls(binding) + ) continue + for (const functionNode of callableNodes(binding)) { + for (const returnStatement of returnStatementsForFunction(functionNode)) { + markClientOnlyDeferredValue(returnStatement.argument) + deferredReturnValues.set( + returnStatement, + projectDeferredCallbackValue( + returnStatement.argument, + 'ClientDeferredFactoryResult', + ), + ) + } + } + } + } + } + function finalizeClientOnlyDeferredBindings() { for (const binding of deferredBindingCandidates) { + if ([...binding.aliases].some((alias) => exportedBindings.has(alias))) continue let usedOnlyDeferred = true function inspectReferences(node) { if (!usedOnlyDeferred) return @@ -1956,6 +2511,7 @@ function semanticBindingGraph(program, path) { && lookup(node)?.aliases === binding.aliases && isReference(node) && !clientOnlyDeferredValues.has(unwrapExpression(node)) + && !stableAliasPlumbingReference(node, binding.aliases) ) { usedOnlyDeferred = false return @@ -2112,6 +2668,13 @@ function semanticBindingGraph(program, path) { const inlineCallee = unwrapExpression(callee) const inlineCallable = inlineCallee?.type === 'ArrowFunctionExpression' || inlineCallee?.type === 'FunctionExpression' + const unknownExternalStandaloneCall = !hasPotentialCallback + && !callableIsInspectable + && !inlineCallable + && semanticNode.type === 'ExpressionStatement' + && !isKnownAsyncCallbackCall(node) + && !isKnownSynchronousCallbackCall(node) + && !(callee.type === 'MemberExpression' && globalOwnerRoot(callee.object)) if ([...calleeAliases].some((binding) => binding.dynamicIdentity)) { unresolvedMutations.push({ executionScope, @@ -2128,7 +2691,14 @@ function semanticBindingGraph(program, path) { semanticNode, }) } - if ( + if (unknownExternalStandaloneCall) { + unsupportedRootCalls.push({ + executionScope, + message: 'Unsupported external synchronous call', + mutationNode: node, + semanticNode, + }) + } else if ( callableIsInspectable || inlineCallable || ( @@ -2144,9 +2714,14 @@ function semanticBindingGraph(program, path) { semanticNode, }) } else if (hasPotentialCallback && !isKnownAsyncCallbackCall(node)) { + const calleeName = callee.type === 'Identifier' + ? callee.name + : callee.type === 'MemberExpression' + ? memberName(callee) ?? '' + : callee.type unsupportedRootCalls.push({ executionScope, - message: 'Unsupported callback execution timing', + message: `Unsupported callback execution timing for ${calleeName}`, mutationNode: node, semanticNode, }) @@ -2304,9 +2879,11 @@ function semanticBindingGraph(program, path) { } indexWrites(program) indexClientOnlyDeferredValues(program) + finalizeDeferredFactories() finalizeClientOnlyDeferredBindings() const clientOnlyIntrinsicSpreadProperties = new WeakSet() + const intrinsicClientSpreadValues = new WeakMap() const intrinsicSpreadInitializers = new WeakMap() function staticIntrinsicSpreadPropertyName(property) { @@ -2340,10 +2917,16 @@ function semanticBindingGraph(program, path) { if ( property.kind === 'init' && isClientOnlyIntrinsicPropertyName(name) - && isPureDeferredValue(property.value) ) { - clientOnlyIntrinsicSpreadProperties.add(property) - continue + markClientOnlyDeferredValue(property.value) + if (isPureDeferredValue(property.value)) { + clientOnlyIntrinsicSpreadProperties.add(property) + continue + } + intrinsicClientSpreadValues.set( + property, + projectDeferredCallbackValue(property.value, 'ClientEventCallback'), + ) } properties.push(property) } @@ -2400,7 +2983,6 @@ function semanticBindingGraph(program, path) { if ( !binding || binding.aliasUnstable - || binding.dynamicIdentity || binding.writes.length > 0 || !declarator?.init || !bindingUsedOnlyByIntrinsicSpreads(binding) @@ -2428,6 +3010,25 @@ function semanticBindingGraph(program, path) { } indexIntrinsicJsxSpreads(program) + function projectIntrinsicClientAttribute(attribute) { + if ( + attribute?.type !== 'JSXAttribute' + || attribute.name?.type !== 'JSXIdentifier' + || !isClientOnlyIntrinsicPropertyName(attribute.name.name) + || attribute.value?.type !== 'JSXExpressionContainer' + ) return attribute + return { + ...attribute, + value: { + ...attribute.value, + expression: projectDeferredCallbackValue( + attribute.value.expression, + 'ClientEventCallback', + ), + }, + } + } + function isTypeReference(node) { let current = node while (nodeParents.has(current)) { @@ -2531,7 +3132,11 @@ function semanticBindingGraph(program, path) { moduleEvaluationEdges, synchronousCalls, projectDeferredCallbackValue, + projectDeferredArrowBody: (node) => deferredArrowBodies.get(node), + projectDeferredReturnValue: (node) => deferredReturnValues.get(node), + projectIntrinsicClientAttribute, projectIntrinsicJsxSpread, + projectIntrinsicSpreadPropertyValue: (node) => intrinsicClientSpreadValues.get(node), projectIntrinsicSpreadInitializer: (node) => intrinsicSpreadInitializers.get(node), unsupportedRootCalls, unresolvedMutations, @@ -2554,7 +3159,11 @@ export function semanticSourceForDigest(path, source) { isClientEffectCall: bindingGraph.isClientEffectCall, isPureDeferredValue: bindingGraph.isPureDeferredValue, projectDeferredCallbackValue: bindingGraph.projectDeferredCallbackValue, + projectDeferredArrowBody: bindingGraph.projectDeferredArrowBody, + projectDeferredReturnValue: bindingGraph.projectDeferredReturnValue, + projectIntrinsicClientAttribute: bindingGraph.projectIntrinsicClientAttribute, projectIntrinsicJsxSpread: bindingGraph.projectIntrinsicJsxSpread, + projectIntrinsicSpreadPropertyValue: bindingGraph.projectIntrinsicSpreadPropertyValue, projectIntrinsicSpreadInitializer: bindingGraph.projectIntrinsicSpreadInitializer, } if (!rootName) return JSON.stringify(runtimeAstProjection(program, projectionOptions)) diff --git a/site/scripts/semantic-source-graph.test.mjs b/site/scripts/semantic-source-graph.test.mjs index ed2ef05..9842fb9 100644 --- a/site/scripts/semantic-source-graph.test.mjs +++ b/site/scripts/semantic-source-graph.test.mjs @@ -921,6 +921,19 @@ test('secondary review boundaries preserve SSR precision and fail closed', () => ), ) } + for (const aliasedAsyncCallback of [ + `const callback = () => { label = '客户端一' }\nconst alias = callback\nlet label = '正文'\nfunction AppContent() { setTimeout(alias, 0); return {label} }`, + `import { useEffect } from 'react'\nconst callback = () => { label = '客户端一' }\nconst alias = callback\nlet label = '正文'\nfunction AppContent() { useEffect(alias, []); return {label} }`, + `const callback = () => track('客户端一')\nconst alias = callback\nconst props = { onClick: alias }\nfunction AppContent() { return }`, + ]) { + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', aliasedAsyncCallback), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + aliasedAsyncCallback.replace('客户端一', '客户端二'), + ), + ) + } for (const callbackFactory of [ `function AppContent() { let label = '正文'; setTimeout((label = '变化一', () => {}), 0); return {label} }`, @@ -934,6 +947,27 @@ test('secondary review boundaries preserve SSR precision and fail closed', () => ), ) } + for (const deferredFactory of [ + `function make() { track('同步获取'); return () => track('客户端一') }\nfunction AppContent() { setTimeout(make(), 0); return 正文 }`, + `import { useEffect } from 'react'\nfunction make() { track('同步获取'); return () => track('客户端一') }\nfunction AppContent() { useEffect(make(), []); return 正文 }`, + `function make() { track('同步获取'); return () => track('客户端一') }\nfunction AppContent() { return }`, + `function make() { track('同步获取'); return () => track('客户端一') }\nconst props = { onClick: make() }\nfunction AppContent() { return }`, + ]) { + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', deferredFactory), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + deferredFactory.replace('客户端一', '客户端二'), + ), + ) + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', deferredFactory), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + deferredFactory.replace('同步获取', '同步变化'), + ), + ) + } for (const patchedCallbackSemantic of [ `globalThis.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => track('同步一'), 0); return 正文 }`, @@ -945,6 +979,15 @@ test('secondary review boundaries preserve SSR precision and fail closed', () => /Unsupported callback execution timing/, ) } + for (const importedPatch of [ + `import '../../scripts/fixtures/semantic-patch-timer.mjs'\nfunction AppContent() { setTimeout(() => track('同步一'), 0); return 正文 }`, + `import { unused } from '../../scripts/fixtures/semantic-patch-react.mjs'\nimport { useEffect } from 'react'\nfunction AppContent() { useEffect(() => track('同步一'), []); return 正文 }`, + ]) { + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', importedPatch), + /Unsupported/, + ) + } for (const destructuredOwner of [ `const state = { value: '正文' }\nconst { Object: O } = globalThis\nO.assign = target => { target.value = '变化' }\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`, @@ -955,24 +998,45 @@ test('secondary review boundaries preserve SSR precision and fail closed', () => /Unsupported ambiguous mutation of state/, ) } + for (const indirectOwner of [ + `const state = { value: '正文' }\nconst [O] = [Object]\nO.assign = target => { target.value = '变化' }\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`, + `const state = { value: '正文' }\nconst holder = { O: Object }\nconst { O } = holder\nO.assign = target => { target.value = '变化' }\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`, + `const state = { value: '正文' }\nconst O = true ? Object : Object\nO.assign = target => { target.value = '变化' }\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`, + `const state = { value: '正文' }\nconst O = [Object][0]\nO.assign = target => { target.value = '变化' }\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`, + `let label = '正文'\nconst proto = Promise.prototype\nproto.then = callback => { callback(); return Promise.resolve() }\nfunction AppContent() { Promise.resolve().then(() => { label = '变化' }); return {label} }`, + `const state = { value: '正文' }\nconst G = true ? globalThis : globalThis\nG.Object = { assign(target) { target.value = '变化' } }\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`, + ]) { + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', indirectOwner), + /Unsupported/, + ) + } - const externalCallBefore = `import { mutateA as mutate, state } from 'pkg'\nfunction AppContent() { mutate(); return {state.value} }` - assert.notEqual( - semanticSourceForDigest('site/src/app/AppContent.tsx', externalCallBefore), - semanticSourceForDigest( - 'site/src/app/AppContent.tsx', - externalCallBefore.replace('mutateA', 'mutateB'), - ), - ) - assert.notEqual( - semanticSourceForDigest( - 'site/src/app/AppContent.tsx', - `function AppContent() { mutateA(); return {globalThis.state.value} }`, - ), - semanticSourceForDigest( - 'site/src/app/AppContent.tsx', - `function AppContent() { mutateB(); return {globalThis.state.value} }`, - ), + for (const externalCall of [ + `import { mutate as externalMutate, state } from 'pkg'\nfunction AppContent() { externalMutate(); return {state.value} }`, + `function AppContent() { externalMutate(); return {globalThis.state.value} }`, + ]) { + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', externalCall), + /Unsupported external synchronous call/, + ) + } + for (const externalStandalone of [ + `import { mutateOne as mutate, state } from 'pkg'\nmutate()\nfunction AppContent() { return {state.value} }`, + `mutateOne()\nfunction AppContent() { return {globalThis.state.value} }`, + `import api from 'pkg'\napi.mutateOne()\nfunction AppContent() { return {globalThis.state.value} }`, + `import { patch } from 'pkg'\nfunction AppContent() { let label = '正文'; patch(); Promise.resolve().then(() => { label = '变化' }); return {label} }`, + ]) { + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', externalStandalone), + /Unsupported external synchronous call/, + ) + } + + const exportedDeferred = `let label = '正文'\nexport function callback() { label = '客户端一' }\nfunction AppContent() { setTimeout(callback, 0); return {label} }` + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', exportedDeferred), + /Unsupported nested semantic write/, ) for (const unstableAlias of [ @@ -992,6 +1056,19 @@ test('secondary review boundaries preserve SSR precision and fail closed', () => catchAlias.replace('变化一', '变化二'), ), ) + + for (const conditionalClientValue of [ + `const enabled = true\nfunction AppContent() { return }`, + `const enabled = true\nconst props = { onClick: enabled ? () => track('客户端一') : () => {} }\nfunction AppContent() { return }`, + ]) { + assert.equal( + semanticSourceForDigest('site/src/app/AppContent.tsx', conditionalClientValue), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + conditionalClientValue.replace('客户端一', '客户端二'), + ), + ) + } }) test('public config rejects alias mutation, pattern writes, and patched freeze', () => { @@ -1024,6 +1101,22 @@ test('public config rejects alias mutation, pattern writes, and patched freeze', `const { ['Object']: O } = globalThis\nO.freeze = value => { value.name = '运行时'; return value }`, `name: '初始'`, ), + publicConfigSource( + `const [O] = [Object]\nO.freeze = value => { value.name = '运行时'; return value }`, + `name: '初始'`, + ), + publicConfigSource( + `const holder = { O: Object }\nconst { O } = holder\nO.freeze = value => { value.name = '运行时'; return value }`, + `name: '初始'`, + ), + publicConfigSource( + `const O = true ? Object : Object\nO.freeze = value => { value.name = '运行时'; return value }`, + `name: '初始'`, + ), + publicConfigSource( + `const O = [Object][0]\nO.freeze = value => { value.name = '运行时'; return value }`, + `name: '初始'`, + ), ]) { assert.throws( () => semanticSourceForDigest('site/src/config/site.ts', mutableConfig), diff --git a/site/scripts/seo-contract.test.mjs b/site/scripts/seo-contract.test.mjs index 16e44c0..9d286ab 100644 --- a/site/scripts/seo-contract.test.mjs +++ b/site/scripts/seo-contract.test.mjs @@ -232,7 +232,7 @@ test('discovery files expose the 47 approved URLs and real summaries', async () assert.match(lastModified, /--format=%cI/) assert.match(lastModified, /normalizeContentForDigest/) assert.match(lastModified, /semanticRouteFiles/) - assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 12) + assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 13) assert.equal(Object.keys(ROUTE_CONTENT_DIGESTS).length, PUBLIC_PATHS.length) assert.ok(Object.values(ROUTE_CONTENT_DIGESTS).every((digest) => /^[0-9a-f]{64}$/.test(digest))) assert.match(publicRoutes, /\.\.\/data\/catalog\.ts/) diff --git a/site/src/data/routeLastModified.ts b/site/src/data/routeLastModified.ts index ca4c37b..014cf51 100644 --- a/site/src/data/routeLastModified.ts +++ b/site/src/data/routeLastModified.ts @@ -1,5 +1,5 @@ // 由 scripts/generate-seo.mjs 从源码时间证据生成,请勿手改。 -export const ROUTE_CONTENT_DIGEST_VERSION = 12 +export const ROUTE_CONTENT_DIGEST_VERSION = 13 export const ROUTE_LAST_MODIFIED: Readonly> = Object.freeze({ "/": "2026-08-22T23:46:11+08:00", "/part/a": "2026-08-22T11:47:40+08:00", @@ -81,10 +81,10 @@ export const ROUTE_CONTENT_DIGESTS: Readonly> = Object.fr "/part/c/merge": "a249bdb7650b3bd37a78a7ea82af4ca91830bdf250f3fc728e33f5107f205eaf", "/part/d/grid": "a717bc0470abb4cff3667773bb07dc88083e2c97c7f43ae4bf1b45ec593b18f1", "/part/d/matpow": "746c8911b669e2bb65a6bb56036af226c502f86363d4e54b714603ee820a104e", - "/part/e/basic": "5a11da168dc93a62a7462ece534a492e194a64cdf89306c2000190ccaf1593be", - "/part/e/distsum": "99b609f8c6c51c969e56665d4fb324bd9fe9b14757cd7402d79185f6a01a4b65", - "/part/e/inout": "31b8713c210d4ea94fd1fd16fe34f4adc87bf49047a2c3b5650af4c34a5d1df3", - "/part/e/center": "688088cfe1ab07a8ae84ce937ef6337b55c4e89b47b8679cf159a7c5245ccdf7", + "/part/e/basic": "44c42ea991a4f1358e964aa457778631e7f89e3e129152b34684ab8ce5081f6e", + "/part/e/distsum": "ae87f7f503ba41b64ff21ba7801b64acfc40787efcaf0f76a58cbd9c6059eb1a", + "/part/e/inout": "ed9e66baa6920e768ba35fee13a892eb225e945377d1cc259bfac2e9d41fc183", + "/part/e/center": "e29c6a3fcf034ece8fe7dd2b7f3b3e544c1337be2f0fb592a6616963e4a25d1d", "/part/f/select": "09273dd8af76199acfc797d73083480844bb975beafcf61862d55634b9a576a3", "/part/f/knapsack": "6cb59f767cbc7ea1d16739097c539d222feca48317402beb9fb7f8c82c6fc708", "/part/f/diameter": "ce51245a2bf7c5812bfe2a17802600432283e0f29e4b0b728b6d5df0e627da7a", From 6c7bb5523ea35a1ff96bdee41ac05be201470a74 Mon Sep 17 00:00:00 2001 From: Shanire Date: Wed, 26 Aug 2026 17:34:21 +0800 Subject: [PATCH 16/23] fix: fail closed on unresolved semantic provenance Signed-off-by: Shanire --- docs/engineering/architecture.md | 2 +- docs/operations/verification.md | 4 +- site/scripts/fixtures/semantic-copy.txt | 1 + site/scripts/last-modified.mjs | 2 +- site/scripts/semantic-source-graph.mjs | 213 +++++++++++++++++--- site/scripts/semantic-source-graph.test.mjs | 54 ++++- site/scripts/seo-contract.test.mjs | 2 +- site/src/data/routeLastModified.ts | 2 +- 8 files changed, 244 insertions(+), 36 deletions(-) create mode 100644 site/scripts/fixtures/semantic-copy.txt diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index c110eb3..8064b37 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -114,7 +114,7 @@ Every parsed source contribution is reduced to a runtime AST projection, so Type Effect-only callbacks are excluded only when the callee has one exact, stable React-effect origin through named, default, namespace, member, or destructured aliases. Mixed aliases, reassigned hooks, patched React namespaces, and same-named local helpers are never pruned as effects. Runtime import edges remain while declaration- and specifier-level type-only edges, unselected import siblings, object-spread copies, and JSX attribute names do not leak in. The graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Public representation identity is structurally isolated in `config/site.ts`; client endpoints and sidebar-only copy are in modules reachable only through pruned branches. -Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. An unchanged digest preserves the previous value; changed content uses the precise Git commit or working-tree timestamp of a semantically changed projection and must advance. During a digest-schema migration, the current projector is applied to historical sources at the previous `lastmod`: a clean committed semantic edit advances, while an edit confined to a pruned client branch does not. Version 13 retains the trusted `2026-08-22` evidence and extends the Version 12 closure across stable callback aliases, callback factories, conditional intrinsic handlers, exported callback bindings, recursively inspected module patches, indirect global-owner containers and patterns, and standalone external calls during module or root evaluation. Unsupported or unresolved provenance fails closed. It also prevents historical client-only `config/site.ts` fields from falsely advancing the home route. The home route therefore remains at `23:46:11+08:00`, and the other 46 routes remain at `11:47:40+08:00`. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, type-only noise, or incomplete execution/scope/mutation context. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. +Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. An unchanged digest preserves the previous value; changed content uses the precise Git commit or working-tree timestamp of a semantically changed projection and must advance. During a digest-schema migration, the current projector is applied to historical sources at the previous `lastmod`: a clean committed semantic edit advances, while an edit confined to a pruned client branch does not. Version 14 retains the trusted `2026-08-22` evidence and extends the Version 13 closure across external synchronous calls in every expression position, indirect and factory-returned global containers or owners, unbound global property writes, all runtime package-import side effects outside an explicit audited dependency set, and Vite query/hash asset imports. Unsupported or unresolved provenance fails closed. It also prevents historical client-only `config/site.ts` fields from falsely advancing the home route. The home route therefore remains at `23:46:11+08:00`, and the other 46 routes remain at `11:47:40+08:00`. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, type-only noise, or incomplete execution/scope/mutation context. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. # Analytics diff --git a/docs/operations/verification.md b/docs/operations/verification.md index 9592ced..cdd9c6c 100644 --- a/docs/operations/verification.md +++ b/docs/operations/verification.md @@ -44,9 +44,9 @@ Pure TypeScript Modules imported by Node tests must use explicit `.ts` extension # SEO And Accessibility Checks -`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. Version 13 follows each route's real SSR injection chain with a line-ending-neutral runtime-AST digest: type-only syntax is erased without dropping runtime-bearing TypeScript parameter properties or optional-chain behavior; intrinsic event/ref/key props and statically proven spreads erase only deferred callback values while retaining synchronous factories; public `config/site.ts` evidence follows immutable field dependencies and validates global provenance; and mixed shell modules contribute route-rendering JSX plus the lexical closure of all runtime module-evaluation edges, root/program synchronous calls, proven synchronous callbacks, constructors, tagged templates, aliases, and render-phase mutations. Known asynchronous callbacks, including stable aliases and locally private callback-factory results, are erased even when nested inside an otherwise selected synchronous slice, while callback acquisition remains synchronous evidence and exported callbacks remain conservative. Regression probes cover scope/control flow, stable and unstable aliases, destructuring defaults and indirect global owners, direct and nested shallow-copy precision, call/getter/constructor/tag-created targets, local and cross-module callables, synchronous versus known asynchronous callbacks, patched timer/Promise/collection and React semantics across recursively inspected runtime imports, conditional direct and spread event handlers, `Reflect.apply`, patched/replaced global mutators and containers, exact React-effect aliases, side-effect module patches, mixed or patched hooks, standalone external calls, and historical public-config projection. Unsupported mutable, escaped, aliased, globally patched, unresolved, or dynamic config, unproved callback timing, dynamic targets or intrinsic spreads, imported member receivers, arbitrary external mutations, and ambiguous nested writes outside proven client callbacks fail closed. +`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. Version 14 follows each route's real SSR injection chain with a line-ending-neutral runtime-AST digest: type-only syntax is erased without dropping runtime-bearing TypeScript parameter properties or optional-chain behavior; intrinsic event/ref/key props and statically proven spreads erase only deferred callback values while retaining synchronous factories; public `config/site.ts` evidence follows immutable field dependencies and validates global provenance; and mixed shell modules contribute route-rendering JSX plus the lexical closure of all runtime module-evaluation edges, root/program synchronous calls, proven synchronous callbacks, constructors, tagged templates, aliases, and render-phase mutations. Known asynchronous callbacks, including stable aliases and locally private callback-factory results, are erased even when nested inside an otherwise selected synchronous slice, while callback acquisition remains synchronous evidence and exported callbacks remain conservative. Regression probes cover scope/control flow, stable and unstable aliases, destructuring defaults and indirect or factory-returned global owners, direct and nested shallow-copy precision, call/getter/constructor/tag-created targets, local and cross-module callables, synchronous versus known asynchronous callbacks, patched timer/Promise/collection and React semantics across recursively inspected runtime imports, conditional direct and spread event handlers, `Reflect.apply`, patched/replaced global mutators and containers, exact React-effect aliases, side-effect module patches with and without import specifiers, mixed or patched hooks, external calls in statement, initializer, control-flow, and JSX positions, unbound global writes, Vite query/hash assets, and historical public-config projection. Runtime package imports are trusted only through the audited dependency set used by this product. Unsupported mutable, escaped, aliased, globally patched, unresolved, or dynamic config, unproved callback timing, dynamic targets or intrinsic spreads, imported member receivers, arbitrary external mutations, and ambiguous nested writes outside proven client callbacks fail closed. -Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. An unchanged digest preserves its prior time; changed content uses the exact Git commit or working-tree timestamp of a semantically changed projection and must advance. A schema migration reapplies the current projector to source history at the previous `lastmod`, so clean committed semantic edits advance while dirty changes confined to pruned client branches do not. Version 13 retains `23:46:11+08:00` for the home hero change and `11:47:40+08:00` for the other 46 routes; the historical regression proves that removed client-only fields in the older site config do not advance home evidence. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. +Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. An unchanged digest preserves its prior time; changed content uses the exact Git commit or working-tree timestamp of a semantically changed projection and must advance. A schema migration reapplies the current projector to source history at the previous `lastmod`, so clean committed semantic edits advance while dirty changes confined to pruned client branches do not. Version 14 retains `23:46:11+08:00` for the home hero change and `11:47:40+08:00` for the other 46 routes; the historical regression proves that removed client-only fields in the older site config do not advance home evidence. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. The browser gate runs Chromium against built `dist/` through a strict, non-reused custom preview server. Route tests directly open `/`, `/part/a`, `/part/a/01`, `/method`, and `/part/g/plug`, then exercise live client navigation and keyboard focus. They check HTTP status, prerendered HTML, absence of pending streamed-Suspense placeholders, route CSS at first paint, hydration without errors, CLS below `0.05`, title/description/abstract/canonical/zero-hreflang/Open Graph/JSON-LD metadata, one visible `h1`, route announcements, current-page semantics, initial-load focus, changed-route focus, and skip-link focus. Navigation-positioning tests click a representative lesson outline in each of the seven families, restore a direct lesson fragment, verify the mobile outline and an ordinary family-page fragment, and require a changed route to reset old-page scroll immediately. An unknown path must return 404 with `noindex,nofollow`, no canonical, and the themed not-found heading. Worker-level Node tests separately cover the Accept matrix, 406 behavior, HEAD parity, representation-specific ETags, hidden internal assets, and unaffected API/static/unknown paths. diff --git a/site/scripts/fixtures/semantic-copy.txt b/site/scripts/fixtures/semantic-copy.txt new file mode 100644 index 0000000..403317e --- /dev/null +++ b/site/scripts/fixtures/semantic-copy.txt @@ -0,0 +1 @@ +semantic query asset fixture diff --git a/site/scripts/last-modified.mjs b/site/scripts/last-modified.mjs index c7da1e5..e5580c0 100644 --- a/site/scripts/last-modified.mjs +++ b/site/scripts/last-modified.mjs @@ -9,7 +9,7 @@ import { } from './semantic-source-graph.mjs' const projectRoot = fileURLToPath(new URL('../../', import.meta.url)) -export const ROUTE_CONTENT_DIGEST_VERSION = 13 +export const ROUTE_CONTENT_DIGEST_VERSION = 14 const historicalSourceCache = new Map() const headSourceCache = new Map() diff --git a/site/scripts/semantic-source-graph.mjs b/site/scripts/semantic-source-graph.mjs index b98a6a7..9b6d64f 100644 --- a/site/scripts/semantic-source-graph.mjs +++ b/site/scripts/semantic-source-graph.mjs @@ -9,6 +9,21 @@ const projectRoot = fileURLToPath(new URL('../../', import.meta.url)) const PARSED_EXTENSIONS = new Set(['.js', '.jsx', '.mjs', '.ts', '.tsx']) const RESOLVED_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json'] const SELF_GENERATED_FILES = new Set(['site/src/data/routeLastModified.ts']) +const TRUSTED_RUNTIME_PACKAGES = new Set([ + 'gsap', + 'gsap/ScrollTrigger', + 'katex', + 'lucide-react', + 'motion/react', + 'react', + 'react-dom/static', + 'react-router-dom', + 'shiki/core', + 'shiki/engine/javascript', + 'shiki/langs/cpp.mjs', + 'shiki/themes/github-dark.mjs', + 'shiki/themes/github-light.mjs', +]) const NON_SEMANTIC_FILES = new Set([ 'site/src/components/feedback/FeedbackWidget.tsx', 'site/src/components/layout/ErrorBoundary.tsx', @@ -95,6 +110,13 @@ const TRACKED_GLOBAL_OWNERS = new Set([ ...STATIC_TARGET_MUTATORS.keys(), ...CALLBACK_SEMANTIC_OWNERS, ]) +const TRACKED_OWNER_MUTATION_KEYS = new Set([ + ...TRACKED_GLOBAL_OWNERS, + ...KNOWN_ASYNC_MEMBER_CALLEES, + ...KNOWN_SYNC_CALLBACK_MEMBER_CALLEES, + ...[...STATIC_TARGET_MUTATORS.values()].flatMap((methods) => [...methods]), + 'prototype', +]) const EXPRESSION_WRAPPERS = new Set([ 'ParenthesizedExpression', 'TSAsExpression', @@ -370,20 +392,28 @@ function publicSiteConfigProjection(program) { if (value?.type === 'SequenceExpression') { return isGlobalContainer(value.expressions.at(-1), seen) } + if (value?.type === 'MemberExpression') { + const name = localMemberName(value) + if (name === null) return false + const members = staticMemberExpressions(value.object, name, seen) + return members.length > 0 + && members.every((member) => isGlobalContainer(member, seen)) + } if (value?.type !== 'Identifier') return false + if (globalContainerAliases.has(value.name)) return true if (!bindings.has(value.name)) { return ['global', 'globalThis', 'self', 'window'].includes(value.name) } - if (globalContainerAliases.has(value.name)) return true const initializer = constBindingExpression(value.name, seen) return initializer ? isGlobalContainer(initializer, new Set([...seen, value.name])) : false } - let containerAliasesChanged = true - while (containerAliasesChanged) { - containerAliasesChanged = false - const indexGlobalContainerAlias = (node) => { + const indexGlobalContainerAliases = () => { + let containerAliasesChanged = true + while (containerAliasesChanged) { + containerAliasesChanged = false + const indexGlobalContainerAlias = (node) => { const target = node.type === 'VariableDeclarator' && node.id.type === 'Identifier' && node.init @@ -412,21 +442,50 @@ function publicSiteConfigProjection(program) { : node.type === 'AssignmentExpression' && node.operator === '=' ? node.left : null - if (pattern?.type === 'ObjectPattern' && source && isGlobalContainer(source)) { - for (const property of pattern.properties) { - if (!['global', 'globalThis', 'self', 'window'].includes( - staticPatternPropertyName(property), - )) continue - for (const name of patternTargetNames(property)) { - if (globalContainerAliases.has(name)) continue - globalContainerAliases.add(name) - containerAliasesChanged = true + if (pattern?.type === 'ObjectPattern' && source && isGlobalContainer(source)) { + for (const property of pattern.properties) { + if (!['global', 'globalThis', 'self', 'window'].includes( + staticPatternPropertyName(property), + )) continue + for (const name of patternTargetNames(property)) { + if (globalContainerAliases.has(name)) continue + globalContainerAliases.add(name) + containerAliasesChanged = true + } + } + } else if (pattern?.type === 'ObjectPattern' && source) { + for (const property of pattern.properties) { + const key = staticPatternPropertyName(property) + const members = key === null ? [] : staticMemberExpressions(source, key) + if ( + members.length === 0 + || !members.every((member) => isGlobalContainer(member)) + ) continue + for (const name of patternTargetNames(property)) { + if (globalContainerAliases.has(name)) continue + globalContainerAliases.add(name) + containerAliasesChanged = true + } + } + } else if (pattern?.type === 'ArrayPattern' && source) { + for (const [index, element] of pattern.elements.entries()) { + if (!element) continue + const members = staticMemberExpressions(source, index) + if ( + members.length === 0 + || !members.every((member) => isGlobalContainer(member)) + ) continue + for (const identifier of bindingIdentifiers(element)) { + if (globalContainerAliases.has(identifier.name)) continue + globalContainerAliases.add(identifier.name) + containerAliasesChanged = true + } } } + for (const [child] of childNodes(node)) indexGlobalContainerAlias(child) } - for (const [child] of childNodes(node)) indexGlobalContainerAlias(child) + indexGlobalContainerAlias(program) } - indexGlobalContainerAlias(program) } const staticMemberExpressions = (node, key, seen = new Set()) => { const value = unwrap(node) @@ -461,6 +520,7 @@ function publicSiteConfigProjection(program) { } return matches } + indexGlobalContainerAliases() const isGlobalObject = (node, seen = new Set()) => { const value = unwrap(node) if (value?.type === 'ConditionalExpression') { @@ -607,6 +667,30 @@ function publicSiteConfigProjection(program) { indexGlobalObjectAlias(program) } let trustedObjectFreeze = !bindings.has('Object') && !dynamicGlobalObjectAlias + const containsDynamicObjectIdentity = (node, seen = new Set()) => { + const value = unwrap(node) + if (!value || typeof value !== 'object') return false + if (value.type === 'CallExpression' || value.type === 'NewExpression') return true + if (value.type === 'Identifier' && bindings.has(value.name)) { + const initializer = constBindingExpression(value.name, seen) + return initializer + ? containsDynamicObjectIdentity(initializer, new Set([...seen, value.name])) + : false + } + if (value.type === 'MemberExpression') return containsDynamicObjectIdentity(value.object, seen) + if (value.type === 'ConditionalExpression') { + return containsDynamicObjectIdentity(value.consequent, seen) + || containsDynamicObjectIdentity(value.alternate, seen) + } + if (value.type === 'LogicalExpression') { + return containsDynamicObjectIdentity(value.left, seen) + || containsDynamicObjectIdentity(value.right, seen) + } + if (value.type === 'SequenceExpression') { + return value.expressions.some((expression) => containsDynamicObjectIdentity(expression, seen)) + } + return false + } const inspectObjectPatches = (node) => { const target = node.type === 'AssignmentExpression' ? node.left @@ -626,6 +710,10 @@ function publicSiteConfigProjection(program) { isGlobalContainer(value.object) && (localMemberName(value) === 'Object' || localMemberName(value) === null) ) trustedObjectFreeze = false + if ( + containsDynamicObjectIdentity(value.object) + && ['Object', 'freeze', null].includes(localMemberName(value)) + ) trustedObjectFreeze = false } if (node.type === 'CallExpression') { const callee = unwrap(node.callee) @@ -1938,6 +2026,13 @@ function semanticBindingGraph(program, path, analysisContext = {}) { if (node.type === 'SequenceExpression') { return unboundGlobalContainer(node.expressions.at(-1), seen) } + if (node.type === 'MemberExpression') { + const name = memberName(node) + if (name === null) return false + const members = staticMemberExpressions(node.object, name, seen) + return members.length > 0 + && members.every((member) => unboundGlobalContainer(member, seen)) + } if (node.type !== 'Identifier') return false const binding = lookup(node) if (!binding) return ['global', 'globalThis', 'self', 'window'].includes(node.name) @@ -1995,13 +2090,40 @@ function semanticBindingGraph(program, path, analysisContext = {}) { changed = true } } + } else if (pattern?.type === 'ObjectPattern' && source) { + for (const property of pattern.properties) { + const key = staticPatternOwner(property) + const members = key === null ? [] : staticMemberExpressions(source, key) + if ( + members.length === 0 + || !members.every((member) => unboundGlobalContainer(member)) + ) continue + for (const targetBinding of patternBindings(property)) { + if (globalContainerAliases.has(targetBinding)) continue + globalContainerAliases.add(targetBinding) + changed = true + } + } + } else if (pattern?.type === 'ArrayPattern' && source) { + for (const [index, element] of pattern.elements.entries()) { + if (!element) continue + const members = staticMemberExpressions(source, index) + if ( + members.length === 0 + || !members.every((member) => unboundGlobalContainer(member)) + ) continue + for (const targetBinding of reassignedBindings(element)) { + if (globalContainerAliases.has(targetBinding)) continue + globalContainerAliases.add(targetBinding) + changed = true + } + } } for (const [child] of childNodes(node)) visit(child) } visit(program) } } - indexGlobalContainerAliases() function staticMemberExpressions(node, key, seen = new Set()) { if (!node || typeof node !== 'object') return [] @@ -2038,6 +2160,7 @@ function semanticBindingGraph(program, path, analysisContext = {}) { } return matches } + indexGlobalContainerAliases() function combinedStaticOwner(nodes) { const resolvedOwners = nodes.map((node) => globalStaticOwner(node)) @@ -2191,11 +2314,29 @@ function semanticBindingGraph(program, path, analysisContext = {}) { else patchedStaticOwners.add(owner) } + function dynamicMutationMayPatchTrackedOwner(node) { + let value = unwrapExpression(node) + while (value?.type === 'MemberExpression') { + const name = memberName(value) + if (name === null || TRACKED_OWNER_MUTATION_KEYS.has(name)) return true + value = unwrapExpression(value.object) + } + return false + } + function indexPatchedStaticOwners(node) { if (node.type === 'AssignmentExpression' || node.type === 'UpdateExpression') { const target = node.type === 'AssignmentExpression' ? node.left : node.argument const owner = mutationMemberOwner(target) ?? globalStaticOwner(target) markStaticOwnerPatched(owner) + if ( + !owner + && ( + containsDynamicIdentity(target) + || identityBindings(target).some((binding) => binding.dynamicIdentity) + ) + && dynamicMutationMayPatchTrackedOwner(target) + ) markAllStaticOwnersPatched() if ( target.type === 'MemberExpression' && unboundGlobalContainer(target.object) @@ -2272,12 +2413,9 @@ function semanticBindingGraph(program, path, analysisContext = {}) { const specifier = edge?.value if (typeof specifier !== 'string' || specifier.endsWith('.css')) continue if (!specifier.startsWith('.')) { - if (edge === nodeParents.get(edge)?.parent?.source) { - const declaration = nodeParents.get(edge)?.parent - if (declaration?.type === 'ImportDeclaration' && declaration.specifiers.length === 0) { - for (const owner of TRACKED_GLOBAL_OWNERS) summary.patchedStaticOwners.add(owner) - } - } + if (TRUSTED_RUNTIME_PACKAGES.has(specifier)) continue + for (const owner of TRACKED_GLOBAL_OWNERS) summary.patchedStaticOwners.add(owner) + summary.patchesReact = true continue } const resolvedImport = resolveStaticImport(currentPath, specifier) @@ -2581,6 +2719,19 @@ function semanticBindingGraph(program, path, analysisContext = {}) { semanticNode, }) } + const memberTarget = unwrapExpression(target) + if ( + directBindings.size === 0 + && memberTarget?.type === 'MemberExpression' + && unboundGlobalContainer(memberTarget.object) + ) { + unresolvedMutations.push({ + executionScope, + message: 'Unbound global property mutation', + mutationNode: node, + semanticNode, + }) + } const aliasedBindings = propagateAliases ? new Set([...directBindings].flatMap((binding) => [...binding.aliases])) : directBindings @@ -2661,6 +2812,13 @@ function semanticBindingGraph(program, path, analysisContext = {}) { const invocationAliases = new Set( [...invocationBindings].flatMap((binding) => [...binding.aliases]), ) + const externalImportSources = new Set( + [...invocationAliases] + .map((binding) => binding.importSource) + .filter((source) => typeof source === 'string' && !source.startsWith('.')), + ) + const trustedExternalCall = externalImportSources.size > 0 + && [...externalImportSources].every((source) => TRUSTED_RUNTIME_PACKAGES.has(source)) const callableIsInspectable = [...invocationAliases].some((binding) => ( !binding.importSource || binding.importSource.startsWith('.') )) @@ -2671,7 +2829,7 @@ function semanticBindingGraph(program, path, analysisContext = {}) { const unknownExternalStandaloneCall = !hasPotentialCallback && !callableIsInspectable && !inlineCallable - && semanticNode.type === 'ExpressionStatement' + && !trustedExternalCall && !isKnownAsyncCallbackCall(node) && !isKnownSynchronousCallbackCall(node) && !(callee.type === 'MemberExpression' && globalOwnerRoot(callee.object)) @@ -3280,9 +3438,10 @@ function projectPath(path) { return relative(projectRoot, path).replaceAll('\\', '/') } -function resolveStaticImport(importer, specifier) { - if (!specifier.startsWith('.')) return null - const unresolved = resolve(dirname(importer), specifier) +export function resolveStaticImport(importer, specifier) { + const pathSpecifier = specifier.match(/^[^?#]*/u)?.[0] ?? specifier + if (!pathSpecifier.startsWith('.')) return null + const unresolved = resolve(dirname(importer), pathSpecifier) const candidates = extname(unresolved) ? [unresolved] : [ diff --git a/site/scripts/semantic-source-graph.test.mjs b/site/scripts/semantic-source-graph.test.mjs index 9842fb9..e32dfdf 100644 --- a/site/scripts/semantic-source-graph.test.mjs +++ b/site/scripts/semantic-source-graph.test.mjs @@ -1,11 +1,13 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import { fileURLToPath } from 'node:url' import { PUBLIC_PATHS } from '../src/lib/publicRoutes.ts' import { semanticRouteFiles, semanticSourceForDigest, + resolveStaticImport, staticImportSpecifiersForSource, } from './semantic-source-graph.mjs' @@ -174,7 +176,7 @@ test('mixed shell modules hash their AST-backed render dependencies', () => { semanticSourceForDigest('site/src/app/AppContent.tsx', appAfterRouteBinding), ) - const stageBefore = `import { useEffect, useState } from 'react'\nconst routeEase = [0, 1]\nexport default function RouteStage() {\n const outlet = useOutlet()\n const hasMounted = useState(false)[0]\n useEffect(() => first())\n return {outlet}\n}` + const stageBefore = `import { useEffect, useState } from 'react'\nimport { useOutlet } from 'react-router-dom'\nconst routeEase = [0, 1]\nexport default function RouteStage() {\n const outlet = useOutlet()\n const hasMounted = useState(false)[0]\n useEffect(() => first())\n return {outlet}\n}` const stageAfterClientChange = stageBefore.replace('first()', 'second()') const stageAfterOutletChange = stageBefore.replace('useOutlet()', '

替换正文

') assert.equal( @@ -285,14 +287,14 @@ test('mixed-module closure follows lexical symbols instead of bare names', () => semanticSourceForDigest('site/src/app/AppContent.tsx', attributeAfter), ) - const loopBefore = `const label = '正文'\nfor (const label of ['客户端一']) consume(label)\nfunction AppContent() { return {label} }` + const loopBefore = `const label = '正文'\nfor (const label of ['客户端一']) void label\nfunction AppContent() { return {label} }` const loopAfter = loopBefore.replace('客户端一', '客户端二') assert.equal( semanticSourceForDigest('site/src/app/AppContent.tsx', loopBefore), semanticSourceForDigest('site/src/app/AppContent.tsx', loopAfter), ) - const switchBefore = `const label = '正文'\nswitch (mode) { case 1: const label = '客户端一'; consume(label) }\nfunction AppContent() { return {label} }` + const switchBefore = `const label = '正文'\nswitch (mode) { case 1: const label = '客户端一'; void label }\nfunction AppContent() { return {label} }` const switchAfter = switchBefore.replace('客户端一', '客户端二') assert.equal( semanticSourceForDigest('site/src/app/AppContent.tsx', switchBefore), @@ -1005,10 +1007,15 @@ test('secondary review boundaries preserve SSR precision and fail closed', () => `const state = { value: '正文' }\nconst O = [Object][0]\nO.assign = target => { target.value = '变化' }\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`, `let label = '正文'\nconst proto = Promise.prototype\nproto.then = callback => { callback(); return Promise.resolve() }\nfunction AppContent() { Promise.resolve().then(() => { label = '变化' }); return {label} }`, `const state = { value: '正文' }\nconst G = true ? globalThis : globalThis\nG.Object = { assign(target) { target.value = '变化' } }\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`, + `let label = '正文'\nconst [g] = [globalThis]\ng.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, + `let label = '正文'\nconst { g } = { g: globalThis }\ng.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, + `let label = '正文'\nconst P = (() => Promise)()\nP.prototype.then = callback => { callback(); return Promise.resolve() }\nfunction AppContent() { Promise.resolve().then(() => { label = '变化' }); return {label} }`, + `let label = '正文'\ngetGlobal().setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, ]) { assert.throws( () => semanticSourceForDigest('site/src/app/AppContent.tsx', indirectOwner), /Unsupported/, + indirectOwner, ) } @@ -1026,6 +1033,9 @@ test('secondary review boundaries preserve SSR precision and fail closed', () => `mutateOne()\nfunction AppContent() { return {globalThis.state.value} }`, `import api from 'pkg'\napi.mutateOne()\nfunction AppContent() { return {globalThis.state.value} }`, `import { patch } from 'pkg'\nfunction AppContent() { let label = '正文'; patch(); Promise.resolve().then(() => { label = '变化' }); return {label} }`, + `import { setLabel } from 'external-package'\nglobalThis.label = '正文'\nif (true) setLabel(globalThis)\nfunction AppContent() { return

{globalThis.label}

}`, + `import { read } from 'external-package'\nconst label = read()\nfunction AppContent() { return

{label}

}`, + `import { read } from 'external-package'\nfunction AppContent() { return

{read()}

}`, ]) { assert.throws( () => semanticSourceForDigest('site/src/app/AppContent.tsx', externalStandalone), @@ -1033,6 +1043,23 @@ test('secondary review boundaries preserve SSR precision and fail closed', () => ) } + const globalPropertyWrite = `globalThis.label = '变化一'\nfunction AppContent() { return

{globalThis.label}

}` + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', globalPropertyWrite), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + globalPropertyWrite.replace('变化一', '变化二'), + ), + ) + + assert.throws( + () => semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + `import { unused } from 'opaque-patcher'\nfunction AppContent() { let label = '正文'; setTimeout(() => { label = '变化' }, 0); return {label} }`, + ), + /Unsupported callback execution timing/, + ) + const exportedDeferred = `let label = '正文'\nexport function callback() { label = '客户端一' }\nfunction AppContent() { setTimeout(callback, 0); return {label} }` assert.throws( () => semanticSourceForDigest('site/src/app/AppContent.tsx', exportedDeferred), @@ -1117,10 +1144,19 @@ test('public config rejects alias mutation, pattern writes, and patched freeze', `const O = [Object][0]\nO.freeze = value => { value.name = '运行时'; return value }`, `name: '初始'`, ), + publicConfigSource( + `const { g } = { g: globalThis }\ng.Object = { freeze(value) { value.name = '运行时'; return value } }`, + `name: '初始'`, + ), + publicConfigSource( + `const O = (() => Object)()\nO.freeze = value => { value.name = '运行时'; return value }`, + `name: '初始'`, + ), ]) { assert.throws( () => semanticSourceForDigest('site/src/config/site.ts', mutableConfig), /Unsupported public site configuration/, + mutableConfig, ) } }) @@ -1135,6 +1171,18 @@ test('static dependency discovery excludes specifier-level type-only edges', () ) }) +test('static dependency discovery strips Vite query and hash suffixes', () => { + const importer = fileURLToPath( + new URL('./fixtures/semantic-query-importer.ts', import.meta.url), + ) + const expected = fileURLToPath( + new URL('./fixtures/semantic-copy.txt', import.meta.url), + ) + + assert.equal(resolveStaticImport(importer, './semantic-copy.txt?raw'), expected) + assert.equal(resolveStaticImport(importer, './semantic-copy.txt?url#copy'), expected) +}) + test('switch discriminants resolve outside the switch lexical scope', () => { const before = `function AppContent() { const mode = '外层一'; let label = '正文'; switch (mode) { case 'x': const mode = '分支'; label = mode } return {label} }` const after = before.replace('外层一', '外层二') diff --git a/site/scripts/seo-contract.test.mjs b/site/scripts/seo-contract.test.mjs index 9d286ab..c29ee24 100644 --- a/site/scripts/seo-contract.test.mjs +++ b/site/scripts/seo-contract.test.mjs @@ -232,7 +232,7 @@ test('discovery files expose the 47 approved URLs and real summaries', async () assert.match(lastModified, /--format=%cI/) assert.match(lastModified, /normalizeContentForDigest/) assert.match(lastModified, /semanticRouteFiles/) - assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 13) + assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 14) assert.equal(Object.keys(ROUTE_CONTENT_DIGESTS).length, PUBLIC_PATHS.length) assert.ok(Object.values(ROUTE_CONTENT_DIGESTS).every((digest) => /^[0-9a-f]{64}$/.test(digest))) assert.match(publicRoutes, /\.\.\/data\/catalog\.ts/) diff --git a/site/src/data/routeLastModified.ts b/site/src/data/routeLastModified.ts index 014cf51..77d6724 100644 --- a/site/src/data/routeLastModified.ts +++ b/site/src/data/routeLastModified.ts @@ -1,5 +1,5 @@ // 由 scripts/generate-seo.mjs 从源码时间证据生成,请勿手改。 -export const ROUTE_CONTENT_DIGEST_VERSION = 13 +export const ROUTE_CONTENT_DIGEST_VERSION = 14 export const ROUTE_LAST_MODIFIED: Readonly> = Object.freeze({ "/": "2026-08-22T23:46:11+08:00", "/part/a": "2026-08-22T11:47:40+08:00", From 8bca2b1e9d5f23f55adb06e26dae6441b42cb96f Mon Sep 17 00:00:00 2001 From: Shanire Date: Thu, 27 Aug 2026 08:41:46 +0800 Subject: [PATCH 17/23] fix(web): close content governance review gaps Signed-off-by: Shanire --- deploy.md | 3 +- docs/engineering/architecture.md | 6 +- docs/operations/verification.md | 6 +- site/index.html | 2 +- site/public/llms.txt | 94 ++--- site/public/route-summaries.json | 94 ++--- site/public/sitemap.xml | 94 ++--- site/scripts/content-negotiation.test.mjs | 2 + site/scripts/fixtures/semantic-copy.css | 3 + site/scripts/last-modified.mjs | 80 +++-- site/scripts/last-modified.test.mjs | 19 ++ site/scripts/semantic-source-graph.mjs | 361 +++++++++++++++----- site/scripts/semantic-source-graph.test.mjs | 86 +++++ site/scripts/seo-contract.test.mjs | 3 +- site/src/data/routeLastModified.ts | 190 +++++------ site/worker/content-negotiation.js | 59 +++- site/wrangler.jsonc | 10 +- 17 files changed, 749 insertions(+), 363 deletions(-) create mode 100644 site/scripts/fixtures/semantic-copy.css diff --git a/deploy.md b/deploy.md index c4baaa6..10e9e5a 100644 --- a/deploy.md +++ b/deploy.md @@ -144,13 +144,14 @@ pnpm preview "assets": { "directory": "./dist/", "binding": "ASSETS", + "run_worker_first": true, "html_handling": "drop-trailing-slash", "not_found_handling": "404-page" } } ``` -已登记公开路由默认命中预渲染 HTML;当 `Accept` 明确更偏好 `text/markdown` 时,Worker 从隔离的内部资产路径读取同页 Markdown。直接请求 `/_representations/` 固定返回 404,内部标本、未知路由、API 与普通静态资产不参与协商。未知静态路径由 `404-page` 返回 `404.html` 与 HTTP 404;`/api/feedback`、`/api/analytics` 与 `/api/_diag/egress` 进入 `worker.js`。 +`run_worker_first: true` 保证匹配到静态文件的请求也先经过 `worker.js`,否则平台的默认静态资产优先路由会绕过同 URL 内容协商。已登记公开路由默认读取预渲染 HTML;当 `Accept` 明确更偏好 `text/markdown` 时,Worker 从隔离的内部资产路径读取同页 Markdown。直接请求 `/_representations/` 固定返回 404,内部标本、未知路由、API 与普通静态资产不参与协商,但仍先经过 Worker 路由层再按各自规则处理。未知静态路径由 `404-page` 返回 `404.html` 与 HTTP 404;`/api/feedback`、`/api/analytics` 与 `/api/_diag/egress` 进入 `worker.js`。 内容协商发布冒烟: diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index 8064b37..19f0181 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -98,9 +98,9 @@ Problem metadata is extracted from lesson JSX by `site/scripts/generate-problems All 29 teaching solver surfaces are Adapters over the algorithm boundary: public callers import `site/src/algorithms//index.ts`, while the adjacent internal Module owns the sole transition loop and emits UI-neutral domain events. Teaching code records those events and adapts them to `VizModel`; games and ordinary readouts use public typed results and must not import internal Modules or recover answers from teaching frames. Executable architecture tests enumerate the 29 Adapters. The 39 public solver entry points that return one of the 38 named `*Result` Interfaces each have an independent small-case oracle or property for their primary outcome. Key witness and auxiliary fields also have explicit legality or consistency invariants, including `chosen`, `guards`, and `layout`, plus representative path, index, argument, and ordering fields; this coverage claim does not extend to every incidental field. The UI-only `solveRerootDistanceBrute` helper returns an anonymous object and is outside this 39-entry named-Result count. -Known deep links are physical prerendered HTML assets. Cloudflare Workers Static Assets serves them and uses `404-page` for anything unmatched, returning the themed `404.html` with a real HTTP 404. See root [deploy.md](../../deploy.md) for the platform contract. +Known deep links are physical prerendered HTML assets. Cloudflare Workers Static Assets uses `run_worker_first: true`, so every request reaches `worker.js` before the `ASSETS` binding serves a matching file; this is required because an asset-first match would bypass same-URL content negotiation. The binding uses `404-page` for anything unmatched, returning the themed `404.html` with a real HTTP 404. See root [deploy.md](../../deploy.md) for the platform contract. -For a registered public `GET` or `HEAD`, `worker/content-negotiation.js` selects HTML or Markdown from `Accept`; HTML wins equal preferences and the Worker returns 406 when both supported media types are explicitly unacceptable. Markdown is fetched only through a distinct internal asset URL, so its ETag and static-asset cache key cannot collide with HTML. Both representations add `Vary: Accept`, alternate links, `nosniff`, and the approved public `Content-Signal`; direct requests to the internal prefix return 404. `HEAD` executes the same matrix and headers but never returns a body. APIs, ordinary assets, the internal specimen, and unknown routes bypass negotiation. The internal path mapper and public signal live in `publicWebContract.ts`, preventing the generator, discovery files, and Worker from silently diverging. +For a registered public `GET` or `HEAD`, `worker/content-negotiation.js` selects HTML or Markdown from `Accept`; HTML wins equal preferences and the Worker returns 406 when both supported media types are explicitly unacceptable. Invalid media-range members, including empty or unterminated parameters, are ignored rather than accepted. Markdown is fetched only through a distinct internal asset URL, so its ETag and static-asset cache key cannot collide with HTML. Both representations add `Vary: Accept`, alternate links, `nosniff`, and the approved public `Content-Signal`; direct requests to the internal prefix return 404. `HEAD` executes the same matrix and headers but never returns a body. APIs, ordinary assets, the internal specimen, and unknown routes pass through the Worker routing layer without content negotiation. The internal path mapper and public signal live in `publicWebContract.ts`, preventing the generator, discovery files, and Worker from silently diverging. # SEO And Accessibility @@ -114,7 +114,7 @@ Every parsed source contribution is reduced to a runtime AST projection, so Type Effect-only callbacks are excluded only when the callee has one exact, stable React-effect origin through named, default, namespace, member, or destructured aliases. Mixed aliases, reassigned hooks, patched React namespaces, and same-named local helpers are never pruned as effects. Runtime import edges remain while declaration- and specifier-level type-only edges, unselected import siblings, object-spread copies, and JSX attribute names do not leak in. The graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Public representation identity is structurally isolated in `config/site.ts`; client endpoints and sidebar-only copy are in modules reachable only through pruned branches. -Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. An unchanged digest preserves the previous value; changed content uses the precise Git commit or working-tree timestamp of a semantically changed projection and must advance. During a digest-schema migration, the current projector is applied to historical sources at the previous `lastmod`: a clean committed semantic edit advances, while an edit confined to a pruned client branch does not. Version 14 retains the trusted `2026-08-22` evidence and extends the Version 13 closure across external synchronous calls in every expression position, indirect and factory-returned global containers or owners, unbound global property writes, all runtime package-import side effects outside an explicit audited dependency set, and Vite query/hash asset imports. Unsupported or unresolved provenance fails closed. It also prevents historical client-only `config/site.ts` fields from falsely advancing the home route. The home route therefore remains at `23:46:11+08:00`, and the other 46 routes remain at `11:47:40+08:00`. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, type-only noise, or incomplete execution/scope/mutation context. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. +Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. An unchanged digest preserves the previous value; changed content uses the precise Git commit or working-tree timestamp of a semantically changed projection and must advance. During a digest-schema migration, the current projector is applied to historical sources at the previous `lastmod`: a clean committed semantic edit advances, while an edit confined to a pruned client branch does not. Version 15 extends Version 14 with byte-exact binary-asset evidence, semantic CSS `?raw`/`?inline`/`?url` imports, nested global-container provenance, top-level dynamic-import evaluation edges, and all-candidate validation for mixed local/external call targets. The audited runtime-package allowlist is now bound to the selected declarations in `package.json` and their recursively reachable exact resolutions and integrities in `pnpm-lock.yaml`; unrelated development-only lock entries remain outside the public digest. This newly visible input correctly advances all 47 routes to the runtime-dependency maintenance commit at `2026-08-23T21:18:04+08:00`. Unsupported or unresolved provenance fails closed. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, type-only noise, incomplete execution/scope/mutation context, lossy UTF-8 decoding of binary assets, or an unpinned trusted runtime package graph. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. # Analytics diff --git a/docs/operations/verification.md b/docs/operations/verification.md index cdd9c6c..0dd4e48 100644 --- a/docs/operations/verification.md +++ b/docs/operations/verification.md @@ -44,11 +44,11 @@ Pure TypeScript Modules imported by Node tests must use explicit `.ts` extension # SEO And Accessibility Checks -`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. Version 14 follows each route's real SSR injection chain with a line-ending-neutral runtime-AST digest: type-only syntax is erased without dropping runtime-bearing TypeScript parameter properties or optional-chain behavior; intrinsic event/ref/key props and statically proven spreads erase only deferred callback values while retaining synchronous factories; public `config/site.ts` evidence follows immutable field dependencies and validates global provenance; and mixed shell modules contribute route-rendering JSX plus the lexical closure of all runtime module-evaluation edges, root/program synchronous calls, proven synchronous callbacks, constructors, tagged templates, aliases, and render-phase mutations. Known asynchronous callbacks, including stable aliases and locally private callback-factory results, are erased even when nested inside an otherwise selected synchronous slice, while callback acquisition remains synchronous evidence and exported callbacks remain conservative. Regression probes cover scope/control flow, stable and unstable aliases, destructuring defaults and indirect or factory-returned global owners, direct and nested shallow-copy precision, call/getter/constructor/tag-created targets, local and cross-module callables, synchronous versus known asynchronous callbacks, patched timer/Promise/collection and React semantics across recursively inspected runtime imports, conditional direct and spread event handlers, `Reflect.apply`, patched/replaced global mutators and containers, exact React-effect aliases, side-effect module patches with and without import specifiers, mixed or patched hooks, external calls in statement, initializer, control-flow, and JSX positions, unbound global writes, Vite query/hash assets, and historical public-config projection. Runtime package imports are trusted only through the audited dependency set used by this product. Unsupported mutable, escaped, aliased, globally patched, unresolved, or dynamic config, unproved callback timing, dynamic targets or intrinsic spreads, imported member receivers, arbitrary external mutations, and ambiguous nested writes outside proven client callbacks fail closed. +`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. Version 15 follows each route's real SSR injection chain with a line-ending-neutral runtime-AST digest: type-only syntax is erased without dropping runtime-bearing TypeScript parameter properties or optional-chain behavior; intrinsic event/ref/key props and statically proven spreads erase only deferred callback values while retaining synchronous factories; public `config/site.ts` evidence follows immutable field dependencies and validates global provenance; and mixed shell modules contribute route-rendering JSX plus the lexical closure of all runtime module-evaluation edges, root/program synchronous calls, proven synchronous callbacks, constructors, tagged templates, aliases, and render-phase mutations. Known asynchronous callbacks, including stable aliases and locally private callback-factory results, are erased even when nested inside an otherwise selected synchronous slice, while callback acquisition remains synchronous evidence and exported callbacks remain conservative. Regression probes additionally cover all-candidate mixed call targets, nested global-container holders, top-level dynamic imports, semantic CSS query imports, and byte-distinct binary assets. Runtime package imports are trusted only through the audited dependency set used by this product, and that set is bound to selected `package.json` declarations plus recursively reachable exact `pnpm-lock.yaml` resolutions and integrities. Unsupported mutable, escaped, aliased, globally patched, unresolved, or dynamic config, unproved callback timing, dynamic targets or intrinsic spreads, imported member receivers, arbitrary external mutations, and ambiguous nested writes outside proven client callbacks fail closed. -Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. An unchanged digest preserves its prior time; changed content uses the exact Git commit or working-tree timestamp of a semantically changed projection and must advance. A schema migration reapplies the current projector to source history at the previous `lastmod`, so clean committed semantic edits advance while dirty changes confined to pruned client branches do not. Version 14 retains `23:46:11+08:00` for the home hero change and `11:47:40+08:00` for the other 46 routes; the historical regression proves that removed client-only fields in the older site config do not advance home evidence. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. +Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. An unchanged digest preserves its prior time; changed content uses the exact Git commit or working-tree timestamp of a semantically changed projection and must advance. A schema migration reapplies the current projector to source history at the previous `lastmod`, so clean committed semantic edits advance while dirty changes confined to pruned client branches do not. Version 15 makes the runtime dependency graph visible for the first time, so all 47 routes correctly advance to the dependency-maintenance commit at `2026-08-23T21:18:04+08:00`. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, Worker-first static-asset routing, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. -The browser gate runs Chromium against built `dist/` through a strict, non-reused custom preview server. Route tests directly open `/`, `/part/a`, `/part/a/01`, `/method`, and `/part/g/plug`, then exercise live client navigation and keyboard focus. They check HTTP status, prerendered HTML, absence of pending streamed-Suspense placeholders, route CSS at first paint, hydration without errors, CLS below `0.05`, title/description/abstract/canonical/zero-hreflang/Open Graph/JSON-LD metadata, one visible `h1`, route announcements, current-page semantics, initial-load focus, changed-route focus, and skip-link focus. Navigation-positioning tests click a representative lesson outline in each of the seven families, restore a direct lesson fragment, verify the mobile outline and an ordinary family-page fragment, and require a changed route to reset old-page scroll immediately. An unknown path must return 404 with `noindex,nofollow`, no canonical, and the themed not-found heading. Worker-level Node tests separately cover the Accept matrix, 406 behavior, HEAD parity, representation-specific ETags, hidden internal assets, and unaffected API/static/unknown paths. +The browser gate runs Chromium against built `dist/` through a strict, non-reused custom preview server. Route tests directly open `/`, `/part/a`, `/part/a/01`, `/method`, and `/part/g/plug`, then exercise live client navigation and keyboard focus. They check HTTP status, prerendered HTML, absence of pending streamed-Suspense placeholders, route CSS at first paint, hydration without errors, CLS below `0.05`, title/description/abstract/canonical/zero-hreflang/Open Graph/JSON-LD metadata, one visible `h1`, route announcements, current-page semantics, initial-load focus, changed-route focus, and skip-link focus. Navigation-positioning tests click a representative lesson outline in each of the seven families, restore a direct lesson fragment, verify the mobile outline and an ordinary family-page fragment, and require a changed route to reset old-page scroll immediately. An unknown path must return 404 with `noindex,nofollow`, no canonical, and the themed not-found heading. Worker-level Node tests separately cover the Accept matrix, malformed parameter members, 406 behavior, HEAD parity, representation-specific ETags, hidden internal assets, and unaffected API/static/unknown paths. Two game tests cover the catalog-owned lazy boundary and shared runtime contracts. Pack must keep its chunk absent before the near-viewport gate, auto-load without a manual control, replay the currently displayed seed into the identical round with cleared interaction state, and preserve exact played/matched totals across replay, shuffle, difficulty, and reveal. BitBoard must suppress duplicate completion counts and rearm after clear. These tests do not provide arbitrary-seed entry or pixel-regression coverage. diff --git a/site/index.html b/site/index.html index 3e9c867..ca89ca3 100644 --- a/site/index.html +++ b/site/index.html @@ -51,7 +51,7 @@ - + diff --git a/site/public/llms.txt b/site/public/llms.txt index 62bfba5..d93321d 100644 --- a/site/public/llms.txt +++ b/site/public/llms.txt @@ -12,74 +12,74 @@ ## 入口 -- [DP大师 · DP Master](https://dp.round1.cc/): DP大师面向算法学习者,用精讲、逐帧可视化、题目索引和小游戏讲清状态定义、转移顺序与模型迁移。(最近更新:2026-08-22T23:46:11+08:00) -- [通用方法论 · DP大师](https://dp.round1.cc/method): 用状态设计、转移方程、计算顺序、空间优化和调试清单,建立可复用的动态规划解题方法。(最近更新:2026-08-22T11:47:40+08:00) -- [题目索引 · DP大师](https://dp.round1.cc/problems): 按 DP 家族、课程、难度和关键词检索教程中的例题与练习,快速定位对应的洛谷题目和学习路径。(最近更新:2026-08-22T11:47:40+08:00) +- [DP大师 · DP Master](https://dp.round1.cc/): DP大师面向算法学习者,用精讲、逐帧可视化、题目索引和小游戏讲清状态定义、转移顺序与模型迁移。(最近更新:2026-08-23T21:18:04+08:00) +- [通用方法论 · DP大师](https://dp.round1.cc/method): 用状态设计、转移方程、计算顺序、空间优化和调试清单,建立可复用的动态规划解题方法。(最近更新:2026-08-23T21:18:04+08:00) +- [题目索引 · DP大师](https://dp.round1.cc/problems): 按 DP 家族、课程、难度和关键词检索教程中的例题与练习,快速定位对应的洛谷题目和学习路径。(最近更新:2026-08-23T21:18:04+08:00) ## 背包 DP(A) -- [背包 DP · DP大师](https://dp.round1.cc/part/a): 背包 DP学习路径以“容量受限下的取舍:物品件数属性决定了背包的谱系。”为主线,串联 9 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T11:47:40+08:00) -- [01 背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/01): 01 背包课程回答“每件物品只能取一次时,为什么容量必须逆序更新”。内容以取或不取·一维逆推·恰好装满为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [完全背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/complete): 完全背包课程回答“物品可无限取用时,正序更新如何复用本轮状态”。内容以无限件·一维正推为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [多重背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/multiple): 多重背包课程回答“有限件物品怎样从朴素枚举优化到二进制或单调队列”。内容以朴素·二进制·单调队列为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [分组背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/group): 分组背包课程回答“每组至多选一件时,如何隔离组内选择”。内容以每组至多选一件为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [混合背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/mixed): 混合背包课程回答“01、完全和多重物品怎样共用一套转移框架”。内容以01/完全/多重同题为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [二维费用背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/cost2d): 二维费用背包课程回答“同时受两种容量约束时,状态维度与枚举顺序如何设计”。内容以两种费用同时受限为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [有依赖的背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/dep): 有依赖的背包课程回答“带主件附件依赖的选择怎样转化为合法组合”。内容以主件-附件·依赖→分组为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [背包综合变形 · 背包 DP · DP大师](https://dp.round1.cc/part/a/variant): 背包综合变形课程回答“背包模型如何扩展到计数、撤销和方案恢复”。内容以方案数·撤销·具体方案为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [辨析:分数背包=贪心 · 背包 DP · DP大师](https://dp.round1.cc/part/a/fractional): 辨析:分数背包=贪心课程回答“为什么可分割物品属于贪心而不是 01 背包”。内容以可分割⇒贪心 vs 整取⇒DP为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [背包 DP · DP大师](https://dp.round1.cc/part/a): 背包 DP学习路径以“容量受限下的取舍:物品件数属性决定了背包的谱系。”为主线,串联 9 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-23T21:18:04+08:00) +- [01 背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/01): 01 背包课程回答“每件物品只能取一次时,为什么容量必须逆序更新”。内容以取或不取·一维逆推·恰好装满为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [完全背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/complete): 完全背包课程回答“物品可无限取用时,正序更新如何复用本轮状态”。内容以无限件·一维正推为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [多重背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/multiple): 多重背包课程回答“有限件物品怎样从朴素枚举优化到二进制或单调队列”。内容以朴素·二进制·单调队列为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [分组背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/group): 分组背包课程回答“每组至多选一件时,如何隔离组内选择”。内容以每组至多选一件为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [混合背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/mixed): 混合背包课程回答“01、完全和多重物品怎样共用一套转移框架”。内容以01/完全/多重同题为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [二维费用背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/cost2d): 二维费用背包课程回答“同时受两种容量约束时,状态维度与枚举顺序如何设计”。内容以两种费用同时受限为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [有依赖的背包 · 背包 DP · DP大师](https://dp.round1.cc/part/a/dep): 有依赖的背包课程回答“带主件附件依赖的选择怎样转化为合法组合”。内容以主件-附件·依赖→分组为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [背包综合变形 · 背包 DP · DP大师](https://dp.round1.cc/part/a/variant): 背包综合变形课程回答“背包模型如何扩展到计数、撤销和方案恢复”。内容以方案数·撤销·具体方案为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [辨析:分数背包=贪心 · 背包 DP · DP大师](https://dp.round1.cc/part/a/fractional): 辨析:分数背包=贪心课程回答“为什么可分割物品属于贪心而不是 01 背包”。内容以可分割⇒贪心 vs 整取⇒DP为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) ## 线性 DP(B) -- [线性 DP · DP大师](https://dp.round1.cc/part/b): 线性 DP学习路径以“把问题排成一条推进的序列,dp[i] 只依赖更早的状态。”为主线,串联 7 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T11:47:40+08:00) -- [路径型 / 递推入门 · 线性 DP · DP大师](https://dp.round1.cc/part/b/path): 路径型 / 递推入门课程回答“沿序列或网格推进时,如何定义最小充分状态”。内容以数字三角形·过河卒·方格取数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [最大子段和 · 线性 DP · DP大师](https://dp.round1.cc/part/b/maxseg): 最大子段和课程回答“如何用一个局部状态维护最大连续子段”。内容以Kadane·环形·两段不相交为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [最长上升子序列 LIS · 线性 DP · DP大师](https://dp.round1.cc/part/b/lis): 最长上升子序列 LIS课程回答“最长上升子序列如何从二次转移优化到对数查找”。内容以O(n²) 与 O(n log n)·导弹拦截为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [最长公共子序列 LCS · 线性 DP · DP大师](https://dp.round1.cc/part/b/lcs): 最长公共子序列 LCS课程回答“两个序列的公共结构如何通过二维状态刻画”。内容以排列 LCS→LIS·计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [编辑距离 · 线性 DP · DP大师](https://dp.round1.cc/part/b/edit): 编辑距离课程回答“插入、删除和替换如何统一进编辑距离转移”。内容以删/插/改三向转移为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [线性状态机 DP · 线性 DP · DP大师](https://dp.round1.cc/part/b/fsm): 线性状态机 DP课程回答“带持有或选择限制的问题怎样画成有限状态机”。内容以受限选取·股票买卖为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [计数 / 划分型 · 线性 DP · DP大师](https://dp.round1.cc/part/b/count): 计数 / 划分型课程回答“计数与划分问题如何避免重复或遗漏方案”。内容以方案数·高精度·整数划分为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [线性 DP · DP大师](https://dp.round1.cc/part/b): 线性 DP学习路径以“把问题排成一条推进的序列,dp[i] 只依赖更早的状态。”为主线,串联 7 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-23T21:18:04+08:00) +- [路径型 / 递推入门 · 线性 DP · DP大师](https://dp.round1.cc/part/b/path): 路径型 / 递推入门课程回答“沿序列或网格推进时,如何定义最小充分状态”。内容以数字三角形·过河卒·方格取数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [最大子段和 · 线性 DP · DP大师](https://dp.round1.cc/part/b/maxseg): 最大子段和课程回答“如何用一个局部状态维护最大连续子段”。内容以Kadane·环形·两段不相交为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [最长上升子序列 LIS · 线性 DP · DP大师](https://dp.round1.cc/part/b/lis): 最长上升子序列 LIS课程回答“最长上升子序列如何从二次转移优化到对数查找”。内容以O(n²) 与 O(n log n)·导弹拦截为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [最长公共子序列 LCS · 线性 DP · DP大师](https://dp.round1.cc/part/b/lcs): 最长公共子序列 LCS课程回答“两个序列的公共结构如何通过二维状态刻画”。内容以排列 LCS→LIS·计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [编辑距离 · 线性 DP · DP大师](https://dp.round1.cc/part/b/edit): 编辑距离课程回答“插入、删除和替换如何统一进编辑距离转移”。内容以删/插/改三向转移为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [线性状态机 DP · 线性 DP · DP大师](https://dp.round1.cc/part/b/fsm): 线性状态机 DP课程回答“带持有或选择限制的问题怎样画成有限状态机”。内容以受限选取·股票买卖为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [计数 / 划分型 · 线性 DP · DP大师](https://dp.round1.cc/part/b/count): 计数 / 划分型课程回答“计数与划分问题如何避免重复或遗漏方案”。内容以方案数·高精度·整数划分为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) ## 区间 DP(C) -- [区间 DP · DP大师](https://dp.round1.cc/part/c): 区间 DP学习路径以“dp[l][r] 表示区间最优,枚举分割/合并点,按长度递推。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T11:47:40+08:00) -- [石子合并(链形) · 区间 DP · DP大师](https://dp.round1.cc/part/c/stone): 石子合并(链形)课程回答“区间合并代价为何要按长度递增计算”。内容以区间合并基础模型为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [环形区间 DP · 区间 DP · DP大师](https://dp.round1.cc/part/c/ring): 环形区间 DP课程回答“环形区间怎样通过复制序列转化为链形区间”。内容以断环为链·能量项链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [回文 / 括号 · 区间 DP · DP大师](https://dp.round1.cc/part/c/palindrome): 回文 / 括号课程回答“端点匹配如何组织回文与括号类转移”。内容以收缩扩展·端点匹配为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [加分二叉树型 · 区间 DP · DP大师](https://dp.round1.cc/part/c/tree): 加分二叉树型课程回答“枚举区间根节点时,子区间如何对应左右子树”。内容以枚举根·区间即子树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [合并 / 删除类 · 区间 DP · DP大师](https://dp.round1.cc/part/c/merge): 合并 / 删除类课程回答“合并与删除过程怎样压缩成区间状态”。内容以2048·区间删除代价为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [区间 DP · DP大师](https://dp.round1.cc/part/c): 区间 DP学习路径以“dp[l][r] 表示区间最优,枚举分割/合并点,按长度递推。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-23T21:18:04+08:00) +- [石子合并(链形) · 区间 DP · DP大师](https://dp.round1.cc/part/c/stone): 石子合并(链形)课程回答“区间合并代价为何要按长度递增计算”。内容以区间合并基础模型为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [环形区间 DP · 区间 DP · DP大师](https://dp.round1.cc/part/c/ring): 环形区间 DP课程回答“环形区间怎样通过复制序列转化为链形区间”。内容以断环为链·能量项链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [回文 / 括号 · 区间 DP · DP大师](https://dp.round1.cc/part/c/palindrome): 回文 / 括号课程回答“端点匹配如何组织回文与括号类转移”。内容以收缩扩展·端点匹配为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [加分二叉树型 · 区间 DP · DP大师](https://dp.round1.cc/part/c/tree): 加分二叉树型课程回答“枚举区间根节点时,子区间如何对应左右子树”。内容以枚举根·区间即子树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [合并 / 删除类 · 区间 DP · DP大师](https://dp.round1.cc/part/c/merge): 合并 / 删除类课程回答“合并与删除过程怎样压缩成区间状态”。内容以2048·区间删除代价为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) ## 矩阵 DP(D) -- [矩阵 DP · DP大师](https://dp.round1.cc/part/d): 矩阵 DP学习路径以“两条主线:网格坐标上的 DP,与矩阵快速幂加速的递推。”为主线,串联 2 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T11:47:40+08:00) -- [网格 / 矩阵上的 DP · 矩阵 DP · DP大师](https://dp.round1.cc/part/d/grid): 网格 / 矩阵上的 DP课程回答“网格路径、最大正方形和双路径如何选择状态维度”。内容以路径·最大正方形·双线程为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [矩阵快速幂加速 · 矩阵 DP · DP大师](https://dp.round1.cc/part/d/matpow): 矩阵快速幂加速课程回答“线性递推怎样写成矩阵并用快速幂加速”。内容以递推→矩阵幂·O(k³log n)为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [矩阵 DP · DP大师](https://dp.round1.cc/part/d): 矩阵 DP学习路径以“两条主线:网格坐标上的 DP,与矩阵快速幂加速的递推。”为主线,串联 2 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-23T21:18:04+08:00) +- [网格 / 矩阵上的 DP · 矩阵 DP · DP大师](https://dp.round1.cc/part/d/grid): 网格 / 矩阵上的 DP课程回答“网格路径、最大正方形和双路径如何选择状态维度”。内容以路径·最大正方形·双线程为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [矩阵快速幂加速 · 矩阵 DP · DP大师](https://dp.round1.cc/part/d/matpow): 矩阵快速幂加速课程回答“线性递推怎样写成矩阵并用快速幂加速”。内容以递推→矩阵幂·O(k³log n)为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) ## 换根 DP(E) -- [换根 DP · DP大师](https://dp.round1.cc/part/e): 换根 DP学习路径以“二次扫描:固定根一遍 DFS,再一遍换根 O(1) 推每个点。”为主线,串联 4 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T11:47:40+08:00) -- [换根基础模型 · 换根 DP · DP大师](https://dp.round1.cc/part/e/basic): 换根基础模型课程回答“一次定根结果如何在线性时间转移到所有根”。内容以二次扫描骨架为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [距离和换根 · 换根 DP · DP大师](https://dp.round1.cc/part/e/distsum): 距离和换根课程回答“换根时全树距离和为什么只需常数时间更新”。内容以深度和·带权距离和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [子树内外合并 · 换根 DP · DP大师](https://dp.round1.cc/part/e/inout): 子树内外合并课程回答“子树内外贡献如何在第二遍 DFS 中合并”。内容以距离≤k 点权和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [中心 / 偏心距 · 换根 DP · DP大师](https://dp.round1.cc/part/e/center): 中心 / 偏心距课程回答“偏心距与树中心如何由向下和向上信息共同得到”。内容以树的直径·核为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [换根 DP · DP大师](https://dp.round1.cc/part/e): 换根 DP学习路径以“二次扫描:固定根一遍 DFS,再一遍换根 O(1) 推每个点。”为主线,串联 4 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-23T21:18:04+08:00) +- [换根基础模型 · 换根 DP · DP大师](https://dp.round1.cc/part/e/basic): 换根基础模型课程回答“一次定根结果如何在线性时间转移到所有根”。内容以二次扫描骨架为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [距离和换根 · 换根 DP · DP大师](https://dp.round1.cc/part/e/distsum): 距离和换根课程回答“换根时全树距离和为什么只需常数时间更新”。内容以深度和·带权距离和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [子树内外合并 · 换根 DP · DP大师](https://dp.round1.cc/part/e/inout): 子树内外合并课程回答“子树内外贡献如何在第二遍 DFS 中合并”。内容以距离≤k 点权和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [中心 / 偏心距 · 换根 DP · DP大师](https://dp.round1.cc/part/e/center): 中心 / 偏心距课程回答“偏心距与树中心如何由向下和向上信息共同得到”。内容以树的直径·核为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) ## 树形 DP(F) -- [树形 DP · DP大师](https://dp.round1.cc/part/f): 树形 DP学习路径以“dp[u][…] 表示子树最优,后序遍历自底向上合并。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T11:47:40+08:00) -- [选点 / 最大独立集 · 树形 DP · DP大师](https://dp.round1.cc/part/f/select): 选点 / 最大独立集课程回答“父子不能同时选择时,选与不选状态如何配合”。内容以没有上司的舞会为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [树上背包 · 树形 DP · DP大师](https://dp.round1.cc/part/f/knapsack): 树上背包课程回答“树上选取数量约束如何在子树间做背包合并”。内容以二叉苹果树·选课为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [直径 / 重心 DP · 树形 DP · DP大师](https://dp.round1.cc/part/f/diameter): 直径 / 重心 DP课程回答“过当前节点的多条链如何组合出直径与重心信息”。内容以过点最长链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [覆盖 / 支配 / 染色 · 树形 DP · DP大师](https://dp.round1.cc/part/f/cover): 覆盖 / 支配 / 染色课程回答“覆盖、支配和染色约束需要哪些互斥状态”。内容以三状态·染色计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [方案数 / 距离统计 · 树形 DP · DP大师](https://dp.round1.cc/part/f/count): 方案数 / 距离统计课程回答“树上方案数与距离统计如何在合并时避免重复”。内容以联合权值·括号树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [树形 DP · DP大师](https://dp.round1.cc/part/f): 树形 DP学习路径以“dp[u][…] 表示子树最优,后序遍历自底向上合并。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-23T21:18:04+08:00) +- [选点 / 最大独立集 · 树形 DP · DP大师](https://dp.round1.cc/part/f/select): 选点 / 最大独立集课程回答“父子不能同时选择时,选与不选状态如何配合”。内容以没有上司的舞会为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [树上背包 · 树形 DP · DP大师](https://dp.round1.cc/part/f/knapsack): 树上背包课程回答“树上选取数量约束如何在子树间做背包合并”。内容以二叉苹果树·选课为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [直径 / 重心 DP · 树形 DP · DP大师](https://dp.round1.cc/part/f/diameter): 直径 / 重心 DP课程回答“过当前节点的多条链如何组合出直径与重心信息”。内容以过点最长链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [覆盖 / 支配 / 染色 · 树形 DP · DP大师](https://dp.round1.cc/part/f/cover): 覆盖 / 支配 / 染色课程回答“覆盖、支配和染色约束需要哪些互斥状态”。内容以三状态·染色计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [方案数 / 距离统计 · 树形 DP · DP大师](https://dp.round1.cc/part/f/count): 方案数 / 距离统计课程回答“树上方案数与距离统计如何在合并时避免重复”。内容以联合权值·括号树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) ## 状压 DP(G) -- [状压 DP · DP大师](https://dp.round1.cc/part/g): 状压 DP学习路径以“状态是一个集合,用二进制整数表示;转移在 mask 间进行。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-22T11:47:40+08:00) -- [棋盘 / 轮廓状压 · 状压 DP · DP大师](https://dp.round1.cc/part/g/board): 棋盘 / 轮廓状压课程回答“逐行棋盘约束怎样编码为兼容位掩码”。内容以互不侵犯·炮兵阵地为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [集合状压 / TSP · 状压 DP · DP大师](https://dp.round1.cc/part/g/tsp): 集合状压 / TSP课程回答“集合访问状态如何保证 Hamilton 路径不重不漏”。内容以最短 Hamilton·吃奶酪为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [状压 + 覆盖 · 状压 DP · DP大师](https://dp.round1.cc/part/g/cover): 状压 + 覆盖课程回答“几何覆盖选择怎样预处理成可转移的状态集合”。内容以愤怒的小鸟·宝藏为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [综合技巧 · 状压 DP · DP大师](https://dp.round1.cc/part/g/subset): 综合技巧课程回答“子集枚举与计数变形有哪些可复用的位运算技巧”。内容以枚举子集·计数变形为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) -- [插头 DP · 状压 DP · DP大师](https://dp.round1.cc/part/g/plug): 插头 DP课程回答“轮廓线上的连通性怎样压缩为插头状态”。内容以轮廓线连通性为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-22T11:47:40+08:00) +- [状压 DP · DP大师](https://dp.round1.cc/part/g): 状压 DP学习路径以“状态是一个集合,用二进制整数表示;转移在 mask 间进行。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。(最近更新:2026-08-23T21:18:04+08:00) +- [棋盘 / 轮廓状压 · 状压 DP · DP大师](https://dp.round1.cc/part/g/board): 棋盘 / 轮廓状压课程回答“逐行棋盘约束怎样编码为兼容位掩码”。内容以互不侵犯·炮兵阵地为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [集合状压 / TSP · 状压 DP · DP大师](https://dp.round1.cc/part/g/tsp): 集合状压 / TSP课程回答“集合访问状态如何保证 Hamilton 路径不重不漏”。内容以最短 Hamilton·吃奶酪为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [状压 + 覆盖 · 状压 DP · DP大师](https://dp.round1.cc/part/g/cover): 状压 + 覆盖课程回答“几何覆盖选择怎样预处理成可转移的状态集合”。内容以愤怒的小鸟·宝藏为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [综合技巧 · 状压 DP · DP大师](https://dp.round1.cc/part/g/subset): 综合技巧课程回答“子集枚举与计数变形有哪些可复用的位运算技巧”。内容以枚举子集·计数变形为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) +- [插头 DP · 状压 DP · DP大师](https://dp.round1.cc/part/g/plug): 插头 DP课程回答“轮廓线上的连通性怎样压缩为插头状态”。内容以轮廓线连通性为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。(最近更新:2026-08-23T21:18:04+08:00) ## 使用说明 diff --git a/site/public/route-summaries.json b/site/public/route-summaries.json index 7c34b03..b783459 100644 --- a/site/public/route-summaries.json +++ b/site/public/route-summaries.json @@ -9,7 +9,7 @@ "title": "DP大师 · DP Master", "summary": "DP大师面向算法学习者,用精讲、逐帧可视化、题目索引和小游戏讲清状态定义、转移顺序与模型迁移。", "type": "home", - "lastModified": "2026-08-22T23:46:11+08:00" + "lastModified": "2026-08-23T21:18:04+08:00" }, { "path": "/part/a", @@ -17,7 +17,7 @@ "title": "背包 DP · DP大师", "summary": "背包 DP学习路径以“容量受限下的取舍:物品件数属性决定了背包的谱系。”为主线,串联 9 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-22T11:47:40+08:00" + "lastModified": "2026-08-23T21:18:04+08:00" }, { "path": "/part/b", @@ -25,7 +25,7 @@ "title": "线性 DP · DP大师", "summary": "线性 DP学习路径以“把问题排成一条推进的序列,dp[i] 只依赖更早的状态。”为主线,串联 7 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-22T11:47:40+08:00" + "lastModified": "2026-08-23T21:18:04+08:00" }, { "path": "/part/c", @@ -33,7 +33,7 @@ "title": "区间 DP · DP大师", "summary": "区间 DP学习路径以“dp[l][r] 表示区间最优,枚举分割/合并点,按长度递推。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-22T11:47:40+08:00" + "lastModified": "2026-08-23T21:18:04+08:00" }, { "path": "/part/d", @@ -41,7 +41,7 @@ "title": "矩阵 DP · DP大师", "summary": "矩阵 DP学习路径以“两条主线:网格坐标上的 DP,与矩阵快速幂加速的递推。”为主线,串联 2 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-22T11:47:40+08:00" + "lastModified": "2026-08-23T21:18:04+08:00" }, { "path": "/part/e", @@ -49,7 +49,7 @@ "title": "换根 DP · DP大师", "summary": "换根 DP学习路径以“二次扫描:固定根一遍 DFS,再一遍换根 O(1) 推每个点。”为主线,串联 4 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-22T11:47:40+08:00" + "lastModified": "2026-08-23T21:18:04+08:00" }, { "path": "/part/f", @@ -57,7 +57,7 @@ "title": "树形 DP · DP大师", "summary": "树形 DP学习路径以“dp[u][…] 表示子树最优,后序遍历自底向上合并。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-22T11:47:40+08:00" + "lastModified": "2026-08-23T21:18:04+08:00" }, { "path": "/part/g", @@ -65,7 +65,7 @@ "title": "状压 DP · DP大师", "summary": "状压 DP学习路径以“状态是一个集合,用二进制整数表示;转移在 mask 间进行。”为主线,串联 5 门课程的状态模型、转移顺序、可视化验证与配套题目。", "type": "family", - "lastModified": "2026-08-22T11:47:40+08:00" + "lastModified": "2026-08-23T21:18:04+08:00" }, { "path": "/part/a/01", @@ -73,7 +73,7 @@ "title": "01 背包 · 背包 DP · DP大师", "summary": "01 背包课程回答“每件物品只能取一次时,为什么容量必须逆序更新”。内容以取或不取·一维逆推·恰好装满为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -83,7 +83,7 @@ "title": "完全背包 · 背包 DP · DP大师", "summary": "完全背包课程回答“物品可无限取用时,正序更新如何复用本轮状态”。内容以无限件·一维正推为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -93,7 +93,7 @@ "title": "多重背包 · 背包 DP · DP大师", "summary": "多重背包课程回答“有限件物品怎样从朴素枚举优化到二进制或单调队列”。内容以朴素·二进制·单调队列为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -103,7 +103,7 @@ "title": "分组背包 · 背包 DP · DP大师", "summary": "分组背包课程回答“每组至多选一件时,如何隔离组内选择”。内容以每组至多选一件为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -113,7 +113,7 @@ "title": "混合背包 · 背包 DP · DP大师", "summary": "混合背包课程回答“01、完全和多重物品怎样共用一套转移框架”。内容以01/完全/多重同题为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -123,7 +123,7 @@ "title": "二维费用背包 · 背包 DP · DP大师", "summary": "二维费用背包课程回答“同时受两种容量约束时,状态维度与枚举顺序如何设计”。内容以两种费用同时受限为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -133,7 +133,7 @@ "title": "有依赖的背包 · 背包 DP · DP大师", "summary": "有依赖的背包课程回答“带主件附件依赖的选择怎样转化为合法组合”。内容以主件-附件·依赖→分组为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -143,7 +143,7 @@ "title": "背包综合变形 · 背包 DP · DP大师", "summary": "背包综合变形课程回答“背包模型如何扩展到计数、撤销和方案恢复”。内容以方案数·撤销·具体方案为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -153,7 +153,7 @@ "title": "辨析:分数背包=贪心 · 背包 DP · DP大师", "summary": "辨析:分数背包=贪心课程回答“为什么可分割物品属于贪心而不是 01 背包”。内容以可分割⇒贪心 vs 整取⇒DP为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -163,7 +163,7 @@ "title": "路径型 / 递推入门 · 线性 DP · DP大师", "summary": "路径型 / 递推入门课程回答“沿序列或网格推进时,如何定义最小充分状态”。内容以数字三角形·过河卒·方格取数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -173,7 +173,7 @@ "title": "最大子段和 · 线性 DP · DP大师", "summary": "最大子段和课程回答“如何用一个局部状态维护最大连续子段”。内容以Kadane·环形·两段不相交为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -183,7 +183,7 @@ "title": "最长上升子序列 LIS · 线性 DP · DP大师", "summary": "最长上升子序列 LIS课程回答“最长上升子序列如何从二次转移优化到对数查找”。内容以O(n²) 与 O(n log n)·导弹拦截为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -193,7 +193,7 @@ "title": "最长公共子序列 LCS · 线性 DP · DP大师", "summary": "最长公共子序列 LCS课程回答“两个序列的公共结构如何通过二维状态刻画”。内容以排列 LCS→LIS·计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -203,7 +203,7 @@ "title": "编辑距离 · 线性 DP · DP大师", "summary": "编辑距离课程回答“插入、删除和替换如何统一进编辑距离转移”。内容以删/插/改三向转移为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -213,7 +213,7 @@ "title": "线性状态机 DP · 线性 DP · DP大师", "summary": "线性状态机 DP课程回答“带持有或选择限制的问题怎样画成有限状态机”。内容以受限选取·股票买卖为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -223,7 +223,7 @@ "title": "计数 / 划分型 · 线性 DP · DP大师", "summary": "计数 / 划分型课程回答“计数与划分问题如何避免重复或遗漏方案”。内容以方案数·高精度·整数划分为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -233,7 +233,7 @@ "title": "石子合并(链形) · 区间 DP · DP大师", "summary": "石子合并(链形)课程回答“区间合并代价为何要按长度递增计算”。内容以区间合并基础模型为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -243,7 +243,7 @@ "title": "环形区间 DP · 区间 DP · DP大师", "summary": "环形区间 DP课程回答“环形区间怎样通过复制序列转化为链形区间”。内容以断环为链·能量项链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -253,7 +253,7 @@ "title": "回文 / 括号 · 区间 DP · DP大师", "summary": "回文 / 括号课程回答“端点匹配如何组织回文与括号类转移”。内容以收缩扩展·端点匹配为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -263,7 +263,7 @@ "title": "加分二叉树型 · 区间 DP · DP大师", "summary": "加分二叉树型课程回答“枚举区间根节点时,子区间如何对应左右子树”。内容以枚举根·区间即子树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -273,7 +273,7 @@ "title": "合并 / 删除类 · 区间 DP · DP大师", "summary": "合并 / 删除类课程回答“合并与删除过程怎样压缩成区间状态”。内容以2048·区间删除代价为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -283,7 +283,7 @@ "title": "网格 / 矩阵上的 DP · 矩阵 DP · DP大师", "summary": "网格 / 矩阵上的 DP课程回答“网格路径、最大正方形和双路径如何选择状态维度”。内容以路径·最大正方形·双线程为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -293,7 +293,7 @@ "title": "矩阵快速幂加速 · 矩阵 DP · DP大师", "summary": "矩阵快速幂加速课程回答“线性递推怎样写成矩阵并用快速幂加速”。内容以递推→矩阵幂·O(k³log n)为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -303,7 +303,7 @@ "title": "换根基础模型 · 换根 DP · DP大师", "summary": "换根基础模型课程回答“一次定根结果如何在线性时间转移到所有根”。内容以二次扫描骨架为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -313,7 +313,7 @@ "title": "距离和换根 · 换根 DP · DP大师", "summary": "距离和换根课程回答“换根时全树距离和为什么只需常数时间更新”。内容以深度和·带权距离和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -323,7 +323,7 @@ "title": "子树内外合并 · 换根 DP · DP大师", "summary": "子树内外合并课程回答“子树内外贡献如何在第二遍 DFS 中合并”。内容以距离≤k 点权和为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -333,7 +333,7 @@ "title": "中心 / 偏心距 · 换根 DP · DP大师", "summary": "中心 / 偏心距课程回答“偏心距与树中心如何由向下和向上信息共同得到”。内容以树的直径·核为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -343,7 +343,7 @@ "title": "选点 / 最大独立集 · 树形 DP · DP大师", "summary": "选点 / 最大独立集课程回答“父子不能同时选择时,选与不选状态如何配合”。内容以没有上司的舞会为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -353,7 +353,7 @@ "title": "树上背包 · 树形 DP · DP大师", "summary": "树上背包课程回答“树上选取数量约束如何在子树间做背包合并”。内容以二叉苹果树·选课为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -363,7 +363,7 @@ "title": "直径 / 重心 DP · 树形 DP · DP大师", "summary": "直径 / 重心 DP课程回答“过当前节点的多条链如何组合出直径与重心信息”。内容以过点最长链为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -373,7 +373,7 @@ "title": "覆盖 / 支配 / 染色 · 树形 DP · DP大师", "summary": "覆盖 / 支配 / 染色课程回答“覆盖、支配和染色约束需要哪些互斥状态”。内容以三状态·染色计数为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -383,7 +383,7 @@ "title": "方案数 / 距离统计 · 树形 DP · DP大师", "summary": "方案数 / 距离统计课程回答“树上方案数与距离统计如何在合并时避免重复”。内容以联合权值·括号树为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -393,7 +393,7 @@ "title": "棋盘 / 轮廓状压 · 状压 DP · DP大师", "summary": "棋盘 / 轮廓状压课程回答“逐行棋盘约束怎样编码为兼容位掩码”。内容以互不侵犯·炮兵阵地为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -403,7 +403,7 @@ "title": "集合状压 / TSP · 状压 DP · DP大师", "summary": "集合状压 / TSP课程回答“集合访问状态如何保证 Hamilton 路径不重不漏”。内容以最短 Hamilton·吃奶酪为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -413,7 +413,7 @@ "title": "状压 + 覆盖 · 状压 DP · DP大师", "summary": "状压 + 覆盖课程回答“几何覆盖选择怎样预处理成可转移的状态集合”。内容以愤怒的小鸟·宝藏为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -423,7 +423,7 @@ "title": "综合技巧 · 状压 DP · DP大师", "summary": "综合技巧课程回答“子集枚举与计数变形有哪些可复用的位运算技巧”。内容以枚举子集·计数变形为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -433,7 +433,7 @@ "title": "插头 DP · 状压 DP · DP大师", "summary": "插头 DP课程回答“轮廓线上的连通性怎样压缩为插头状态”。内容以轮廓线连通性为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。", "type": "lesson", - "lastModified": "2026-08-22T11:47:40+08:00", + "lastModified": "2026-08-23T21:18:04+08:00", "reviewedBy": "AzureL蔚澜算法", "reviewStatus": "持续复核" }, @@ -443,7 +443,7 @@ "title": "通用方法论 · DP大师", "summary": "用状态设计、转移方程、计算顺序、空间优化和调试清单,建立可复用的动态规划解题方法。", "type": "static", - "lastModified": "2026-08-22T11:47:40+08:00" + "lastModified": "2026-08-23T21:18:04+08:00" }, { "path": "/problems", @@ -451,7 +451,7 @@ "title": "题目索引 · DP大师", "summary": "按 DP 家族、课程、难度和关键词检索教程中的例题与练习,快速定位对应的洛谷题目和学习路径。", "type": "static", - "lastModified": "2026-08-22T11:47:40+08:00" + "lastModified": "2026-08-23T21:18:04+08:00" } ] } diff --git a/site/public/sitemap.xml b/site/public/sitemap.xml index f8bdfbf..50c2e6f 100644 --- a/site/public/sitemap.xml +++ b/site/public/sitemap.xml @@ -2,190 +2,190 @@ https://dp.round1.cc/ - 2026-08-22T23:46:11+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/method - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/a - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/a/01 - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/a/complete - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/a/cost2d - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/a/dep - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/a/fractional - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/a/group - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/a/mixed - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/a/multiple - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/a/variant - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/b - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/b/count - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/b/edit - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/b/fsm - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/b/lcs - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/b/lis - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/b/maxseg - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/b/path - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/c - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/c/merge - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/c/palindrome - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/c/ring - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/c/stone - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/c/tree - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/d - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/d/grid - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/d/matpow - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/e - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/e/basic - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/e/center - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/e/distsum - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/e/inout - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/f - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/f/count - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/f/cover - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/f/diameter - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/f/knapsack - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/f/select - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/g - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/g/board - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/g/cover - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/g/plug - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/g/subset - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/part/g/tsp - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 https://dp.round1.cc/problems - 2026-08-22T11:47:40+08:00 + 2026-08-23T21:18:04+08:00 diff --git a/site/scripts/content-negotiation.test.mjs b/site/scripts/content-negotiation.test.mjs index 2c29da4..2bb129a 100644 --- a/site/scripts/content-negotiation.test.mjs +++ b/site/scripts/content-negotiation.test.mjs @@ -17,6 +17,8 @@ test('content negotiation follows the approved HTML and Markdown Accept matrix', ['text/markdown;q=0, */*;q=1', 'html'], ['not a media range, text/html', 'html'], ['not a media range', null], + ['text/markdown;foo=', null], + ['text/markdown;foo="unterminated', null], ] for (const [accept, expected] of vectors) { diff --git a/site/scripts/fixtures/semantic-copy.css b/site/scripts/fixtures/semantic-copy.css new file mode 100644 index 0000000..2473787 --- /dev/null +++ b/site/scripts/fixtures/semantic-copy.css @@ -0,0 +1,3 @@ +.semantic-copy { + color: rebeccapurple; +} diff --git a/site/scripts/last-modified.mjs b/site/scripts/last-modified.mjs index e5580c0..f7ca12a 100644 --- a/site/scripts/last-modified.mjs +++ b/site/scripts/last-modified.mjs @@ -1,6 +1,7 @@ import { spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' import { readFileSync, statSync } from 'node:fs' +import { extname } from 'node:path' import { fileURLToPath } from 'node:url' import { PUBLIC_PATHS } from '../src/lib/publicRoutes.ts' import { @@ -9,10 +10,28 @@ import { } from './semantic-source-graph.mjs' const projectRoot = fileURLToPath(new URL('../../', import.meta.url)) -export const ROUTE_CONTENT_DIGEST_VERSION = 14 +export const ROUTE_CONTENT_DIGEST_VERSION = 15 const historicalSourceCache = new Map() const headSourceCache = new Map() +const routeProjectionCache = new Map() +const semanticFileEvidenceCache = new Map() +const TEXT_SOURCE_EXTENSIONS = new Set([ + '.css', + '.html', + '.js', + '.jsx', + '.json', + '.md', + '.mjs', + '.svg', + '.ts', + '.tsx', + '.txt', + '.xml', + '.yaml', + '.yml', +]) function gitNames(args) { const result = spawnSync('git', args, { @@ -41,8 +60,11 @@ function routeContentDigest(files) { for (const file of files) { digest.update(file) digest.update('\0') - const source = readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8') - digest.update(normalizeContentForDigest(semanticSourceForDigest(file, source))) + if (!routeProjectionCache.has(file)) { + const source = readFileSync(new URL(`../../${file}`, import.meta.url)) + routeProjectionCache.set(file, semanticProjection(file, source)) + } + digest.update(routeProjectionCache.get(file)) digest.update('\0') } return digest.digest('hex') @@ -51,7 +73,6 @@ function routeContentDigest(files) { function gitSource(file, revision) { const result = spawnSync('git', ['show', `${revision}:${file}`], { cwd: projectRoot, - encoding: 'utf8', }) return result.status === 0 ? result.stdout : null } @@ -79,13 +100,19 @@ function headSource(file) { } function semanticProjection(path, source) { - return source === null - ? null - : normalizeContentForDigest(semanticSourceForDigest(path, source)) + if (source === null) return null + if (Buffer.isBuffer(source) && !TEXT_SOURCE_EXTENSIONS.has(extname(path))) return source + const decodedSource = Buffer.isBuffer(source) ? source.toString('utf8') : source + return normalizeContentForDigest(semanticSourceForDigest(path, decodedSource)) } export function semanticProjectionChanged(path, currentSource, previousSource) { - return semanticProjection(path, currentSource) !== semanticProjection(path, previousSource) + const currentProjection = semanticProjection(path, currentSource) + const previousProjection = semanticProjection(path, previousSource) + if (Buffer.isBuffer(currentProjection) && Buffer.isBuffer(previousProjection)) { + return !currentProjection.equals(previousProjection) + } + return currentProjection !== previousProjection } function latestDate(dates) { @@ -117,21 +144,28 @@ function latestSemanticGitDate(file, previousLastModified) { function semanticChangeEvidence(files, previousLastModified) { const dates = [] for (const file of files) { - const currentSource = readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8') - if (!semanticProjectionChanged( - file, - currentSource, - historicalSource(file, previousLastModified), - )) continue - - const currentDiffersFromHead = semanticProjectionChanged( - file, - currentSource, - headSource(file), - ) - dates.push(currentDiffersFromHead - ? new Date(statSync(new URL(`../../${file}`, import.meta.url)).mtimeMs).toISOString() - : latestSemanticGitDate(file, previousLastModified)) + const cacheKey = `${file}\0${previousLastModified}` + if (!semanticFileEvidenceCache.has(cacheKey)) { + const currentSource = readFileSync(new URL(`../../${file}`, import.meta.url)) + let date = null + if (semanticProjectionChanged( + file, + currentSource, + historicalSource(file, previousLastModified), + )) { + const currentDiffersFromHead = semanticProjectionChanged( + file, + currentSource, + headSource(file), + ) + date = currentDiffersFromHead + ? new Date(statSync(new URL(`../../${file}`, import.meta.url)).mtimeMs).toISOString() + : latestSemanticGitDate(file, previousLastModified) + } + semanticFileEvidenceCache.set(cacheKey, date) + } + const date = semanticFileEvidenceCache.get(cacheKey) + if (date) dates.push(date) } return { candidateLastModified: latestDate(dates), diff --git a/site/scripts/last-modified.test.mjs b/site/scripts/last-modified.test.mjs index fd7d6b1..27a38ab 100644 --- a/site/scripts/last-modified.test.mjs +++ b/site/scripts/last-modified.test.mjs @@ -89,6 +89,25 @@ test('a changed semantic digest requires source-backed lastmod evidence', () => ) }) +test('binary route dependencies compare their exact bytes', () => { + assert.equal( + semanticProjectionChanged( + 'site/src/assets/example.avif', + Buffer.from([0x80]), + Buffer.from([0x81]), + ), + true, + ) + assert.equal( + semanticProjectionChanged( + 'site/src/assets/example.avif', + Buffer.from([0x80]), + Buffer.from([0x80]), + ), + false, + ) +}) + test('schema migrations distinguish pruned client edits from semantic edits', () => { const before = `import { useEffect } from 'react'\nfunction AppContent() { useEffect(() => first()); return

正文一

}` assert.equal( diff --git a/site/scripts/semantic-source-graph.mjs b/site/scripts/semantic-source-graph.mjs index 9b6d64f..0dbea1e 100644 --- a/site/scripts/semantic-source-graph.mjs +++ b/site/scripts/semantic-source-graph.mjs @@ -24,6 +24,13 @@ const TRUSTED_RUNTIME_PACKAGES = new Set([ 'shiki/themes/github-dark.mjs', 'shiki/themes/github-light.mjs', ]) +const TRUSTED_RUNTIME_PACKAGE_NAMES = new Set( + [...TRUSTED_RUNTIME_PACKAGES].map((specifier) => ( + specifier.startsWith('@') + ? specifier.split('/').slice(0, 2).join('/') + : specifier.split('/')[0] + )), +) const NON_SEMANTIC_FILES = new Set([ 'site/src/components/feedback/FeedbackWidget.tsx', 'site/src/components/layout/ErrorBoundary.tsx', @@ -325,6 +332,95 @@ function memberNameFromNode(member) { return null } +function staticPropertyNameFromNode(property) { + if (property?.type !== 'Property') return null + if (!property.computed && property.key.type === 'Identifier') return property.key.name + if ( + (property.key.type === 'StringLiteral' || property.key.type === 'Literal') + && typeof property.key.value === 'string' + ) return property.key.value + return null +} + +function resolveStaticMemberExpressions( + node, + key, + { bindingForIdentifier, initializerForBinding, unwrap }, + seen = new Set(), +) { + const value = unwrap(node) + if (!value || typeof value !== 'object') return [] + if (value.type === 'Identifier') { + const binding = bindingForIdentifier(value) + if (!binding || seen.has(binding)) return [] + const initializer = initializerForBinding(binding) + return initializer + ? resolveStaticMemberExpressions( + initializer, + key, + { bindingForIdentifier, initializerForBinding, unwrap }, + new Set([...seen, binding]), + ) + : [] + } + if (value.type === 'ConditionalExpression' || value.type === 'LogicalExpression') { + const branches = value.type === 'ConditionalExpression' + ? [value.consequent, value.alternate] + : [value.left, value.right] + return branches.flatMap((branch) => resolveStaticMemberExpressions( + branch, + key, + { bindingForIdentifier, initializerForBinding, unwrap }, + seen, + )) + } + if (value.type === 'SequenceExpression') { + return resolveStaticMemberExpressions( + value.expressions.at(-1), + key, + { bindingForIdentifier, initializerForBinding, unwrap }, + seen, + ) + } + if (value.type === 'MemberExpression') { + const nestedKey = memberNameFromNode(value) + if (nestedKey === null) return [] + return resolveStaticMemberExpressions( + value.object, + nestedKey, + { bindingForIdentifier, initializerForBinding, unwrap }, + seen, + ).flatMap((member) => resolveStaticMemberExpressions( + member, + key, + { bindingForIdentifier, initializerForBinding, unwrap }, + seen, + )) + } + if (value.type === 'ArrayExpression' && /^\d+$/u.test(String(key))) { + const element = value.elements[Number(key)] + return element && element.type !== 'SpreadElement' ? [element] : [] + } + if (value.type !== 'ObjectExpression') return [] + const matches = [] + for (const property of value.properties) { + if (property.type === 'SpreadElement') { + matches.push(...resolveStaticMemberExpressions( + property.argument, + key, + { bindingForIdentifier, initializerForBinding, unwrap }, + seen, + )) + } else if ( + property.type === 'Property' + && staticPropertyNameFromNode(property) === String(key) + ) { + matches.push(property.value) + } + } + return matches +} + function publicSiteConfigProjection(program) { const selectedKeys = new Map([ ['BRAND', new Set(['name', 'owner', 'subtitle'])], @@ -361,15 +457,7 @@ function publicSiteConfigProjection(program) { const globalContainerAliases = new Set() const globalObjectAliases = new Set() let dynamicGlobalObjectAlias = false - const staticPatternPropertyName = (property) => { - if (property?.type !== 'Property') return null - if (!property.computed && property.key.type === 'Identifier') return property.key.name - if ( - (property.key.type === 'StringLiteral' || property.key.type === 'Literal') - && typeof property.key.value === 'string' - ) return property.key.value - return null - } + const staticPatternPropertyName = staticPropertyNameFromNode const patternTargetNames = (property) => ( property?.type === 'Property' ? bindingIdentifiers(property.value).map((identifier) => identifier.name) @@ -487,39 +575,15 @@ function publicSiteConfigProjection(program) { indexGlobalContainerAlias(program) } } - const staticMemberExpressions = (node, key, seen = new Set()) => { - const value = unwrap(node) - if (!value || typeof value !== 'object') return [] - if (value.type === 'Identifier' && bindings.has(value.name)) { - const initializer = constBindingExpression(value.name, seen) - return initializer - ? staticMemberExpressions(initializer, key, new Set([...seen, value.name])) - : [] - } - if (value.type === 'ConditionalExpression' || value.type === 'LogicalExpression') { - const branches = value.type === 'ConditionalExpression' - ? [value.consequent, value.alternate] - : [value.left, value.right] - return branches.flatMap((branch) => staticMemberExpressions(branch, key, seen)) - } - if (value.type === 'SequenceExpression') { - return staticMemberExpressions(value.expressions.at(-1), key, seen) - } - if (value.type === 'ArrayExpression' && /^\d+$/u.test(String(key))) { - const element = value.elements[Number(key)] - return element && element.type !== 'SpreadElement' ? [element] : [] - } - if (value.type !== 'ObjectExpression') return [] - const matches = [] - for (const property of value.properties) { - if (property.type === 'SpreadElement') { - matches.push(...staticMemberExpressions(property.argument, key, seen)) - } else if (property.type === 'Property' && staticPatternPropertyName(property) === String(key)) { - matches.push(property.value) - } - } - return matches - } + const staticMemberExpressions = (node, key, seen = new Set()) => ( + resolveStaticMemberExpressions(node, key, { + bindingForIdentifier: (identifier) => ( + bindings.has(identifier.name) ? identifier.name : null + ), + initializerForBinding: (name) => bindings.get(name), + unwrap, + }, seen) + ) indexGlobalContainerAliases() const isGlobalObject = (node, seen = new Set()) => { const value = unwrap(node) @@ -1185,6 +1249,13 @@ function semanticBindingGraph(program, path, analysisContext = {}) { for (const [child, childKey] of childNodes(node)) build(child, scope, node, childKey) return } + if ( + node.type === 'ImportExpression' + && node.source + && nearestFunctionScope(scope).kind === 'program' + ) { + moduleEvaluationEdges.push(node.source) + } if ( node.type === 'ExportAllDeclaration' && node.exportKind !== 'type' @@ -1990,13 +2061,7 @@ function semanticBindingGraph(program, path, analysisContext = {}) { const globalContainerAliases = new Set() function staticPatternOwner(property) { - if (property?.type !== 'Property') return null - if (!property.computed && property.key.type === 'Identifier') return property.key.name - if ( - (property.key.type === 'StringLiteral' || property.key.type === 'Literal') - && typeof property.key.value === 'string' - ) return property.key.value - return null + return staticPropertyNameFromNode(property) } function patternBindings(property) { @@ -2045,6 +2110,12 @@ function semanticBindingGraph(program, path, analysisContext = {}) { : false } + function unboundGlobalMemberRoot(node) { + let value = unwrapExpression(node) + while (value?.type === 'MemberExpression') value = unwrapExpression(value.object) + return unboundGlobalContainer(value) + } + function indexGlobalContainerAliases() { let changed = true while (changed) { @@ -2126,39 +2197,11 @@ function semanticBindingGraph(program, path, analysisContext = {}) { } function staticMemberExpressions(node, key, seen = new Set()) { - if (!node || typeof node !== 'object') return [] - if (EXPRESSION_WRAPPERS.has(node.type)) return staticMemberExpressions(node.expression, key, seen) - if (node.type === 'Identifier') { - const binding = lookup(node) - if (!binding || seen.has(binding)) return [] - const initializer = directConstInitializer(binding) - return initializer - ? staticMemberExpressions(initializer, key, new Set([...seen, binding])) - : [] - } - if (node.type === 'ConditionalExpression' || node.type === 'LogicalExpression') { - const branches = node.type === 'ConditionalExpression' - ? [node.consequent, node.alternate] - : [node.left, node.right] - return branches.flatMap((branch) => staticMemberExpressions(branch, key, seen)) - } - if (node.type === 'SequenceExpression') { - return staticMemberExpressions(node.expressions.at(-1), key, seen) - } - if (node.type === 'ArrayExpression' && /^\d+$/u.test(String(key))) { - const element = node.elements[Number(key)] - return element && element.type !== 'SpreadElement' ? [element] : [] - } - if (node.type !== 'ObjectExpression') return [] - const matches = [] - for (const property of node.properties) { - if (property.type === 'SpreadElement') { - matches.push(...staticMemberExpressions(property.argument, key, seen)) - } else if (property.type === 'Property' && staticPatternOwner(property) === String(key)) { - matches.push(property.value) - } - } - return matches + return resolveStaticMemberExpressions(node, key, { + bindingForIdentifier: lookup, + initializerForBinding: directConstInitializer, + unwrap: unwrapExpression, + }, seen) } indexGlobalContainerAliases() @@ -2723,7 +2766,7 @@ function semanticBindingGraph(program, path, analysisContext = {}) { if ( directBindings.size === 0 && memberTarget?.type === 'MemberExpression' - && unboundGlobalContainer(memberTarget.object) + && unboundGlobalMemberRoot(memberTarget.object) ) { unresolvedMutations.push({ executionScope, @@ -2821,7 +2864,11 @@ function semanticBindingGraph(program, path, analysisContext = {}) { && [...externalImportSources].every((source) => TRUSTED_RUNTIME_PACKAGES.has(source)) const callableIsInspectable = [...invocationAliases].some((binding) => ( !binding.importSource || binding.importSource.startsWith('.') - )) + )) && [...invocationAliases].every((binding) => ( + !binding.importSource + || binding.importSource.startsWith('.') + || TRUSTED_RUNTIME_PACKAGES.has(binding.importSource) + )) const hasPotentialCallback = (node.arguments ?? []).some(containsPotentialCallback) const inlineCallee = unwrapExpression(callee) const inlineCallable = inlineCallee?.type === 'ArrowFunctionExpression' @@ -2973,7 +3020,11 @@ function semanticBindingGraph(program, path, analysisContext = {}) { ) const callableIsInspectable = [...calleeAliases].some((binding) => ( !binding.importSource || binding.importSource.startsWith('.') - )) + )) && [...calleeAliases].every((binding) => ( + !binding.importSource + || binding.importSource.startsWith('.') + || TRUSTED_RUNTIME_PACKAGES.has(binding.importSource) + )) if ( calleeBindings.size === 0 && containsDynamicIdentity(node.callee) || [...calleeAliases].some((binding) => binding.dynamicIdentity) @@ -3003,7 +3054,11 @@ function semanticBindingGraph(program, path, analysisContext = {}) { ) const tagIsInspectable = [...tagAliases].some((binding) => ( !binding.importSource || binding.importSource.startsWith('.') - )) + )) && [...tagAliases].every((binding) => ( + !binding.importSource + || binding.importSource.startsWith('.') + || TRUSTED_RUNTIME_PACKAGES.has(binding.importSource) + )) if ( tagBindings.size === 0 && containsDynamicIdentity(node.tag) || [...tagAliases].some((binding) => binding.dynamicIdentity) @@ -3301,7 +3356,132 @@ function semanticBindingGraph(program, path, analysisContext = {}) { } } +function yamlIndent(line) { + return line.length - line.trimStart().length +} + +function yamlScalar(value) { + const trimmed = value.trim() + if ( + trimmed.length >= 2 + && ((trimmed[0] === "'" && trimmed.at(-1) === "'") + || (trimmed[0] === '"' && trimmed.at(-1) === '"')) + ) return trimmed.slice(1, -1) + return trimmed +} + +function yamlMapEntry(line, indent) { + if (yamlIndent(line) !== indent) return null + const content = line.slice(indent) + const separator = content.indexOf(':') + if (separator < 1) return null + return { + key: yamlScalar(content.slice(0, separator)), + value: yamlScalar(content.slice(separator + 1)), + } +} + +function yamlSectionEntries(lines, section, sectionIndent, entryIndent) { + const sectionLine = `${' '.repeat(sectionIndent)}${section}:` + const start = lines.findLastIndex((line) => line === sectionLine) + if (start < 0) return [] + const entries = [] + for (let index = start + 1; index < lines.length;) { + const line = lines[index] + if (line.trim() && yamlIndent(line) <= sectionIndent) break + const parsed = yamlMapEntry(line, entryIndent) + if (!parsed) { + index += 1 + continue + } + let end = index + 1 + while ( + end < lines.length + && (!lines[end].trim() || yamlIndent(lines[end]) > entryIndent) + ) end += 1 + entries.push({ ...parsed, lines: lines.slice(index, end) }) + index = end + } + return entries +} + +function nestedYamlValue(entry, name, sectionIndent) { + for (const line of entry.lines) { + const field = yamlMapEntry(line, sectionIndent + 2) + if (field?.key === name) return field.value || null + } + return null +} + +function runtimePackageJsonProjection(source) { + const parsed = JSON.parse(source) + return JSON.stringify(Object.fromEntries( + [...TRUSTED_RUNTIME_PACKAGE_NAMES] + .sort() + .filter((name) => Object.hasOwn(parsed.dependencies ?? {}, name)) + .map((name) => [name, parsed.dependencies[name]]), + )) +} + +function runtimeLockProjection(source) { + const lines = source.replaceAll('\r\n', '\n').replaceAll('\r', '\n').split('\n') + const importerEntries = yamlSectionEntries(lines, 'importers', 0, 2) + const rootImporter = importerEntries.filter((entry) => entry.key === '.').at(-1) + const roots = new Map() + if (rootImporter) { + for (const dependency of yamlSectionEntries(rootImporter.lines, 'dependencies', 4, 6)) { + if (!TRUSTED_RUNTIME_PACKAGE_NAMES.has(dependency.key)) continue + const specifier = nestedYamlValue(dependency, 'specifier', 6) + const version = nestedYamlValue(dependency, 'version', 6) + if (version) roots.set(dependency.key, { specifier, version }) + } + } + + const packageEntries = new Map( + yamlSectionEntries(lines, 'packages', 0, 2).map((entry) => [entry.key, entry]), + ) + const snapshotEntries = new Map( + yamlSectionEntries(lines, 'snapshots', 0, 2).map((entry) => [entry.key, entry]), + ) + const selectedPackages = new Map() + const selectedSnapshots = new Map() + const pending = [...roots].map(([name, { version }]) => `${name}@${version}`) + const visited = new Set() + while (pending.length > 0) { + const key = pending.pop() + if (!key || visited.has(key) || /^(?:link|workspace):/u.test(key)) continue + visited.add(key) + const snapshot = snapshotEntries.get(key) + if (snapshot) { + selectedSnapshots.set(key, snapshot.lines.join('\n')) + for (const section of ['dependencies', 'optionalDependencies']) { + for (const dependency of yamlSectionEntries(snapshot.lines, section, 4, 6)) { + const version = dependency.value + || nestedYamlValue(dependency, 'version', 6) + if (version && !/^(?:link|workspace):/u.test(version)) { + pending.push(`${dependency.key}@${version}`) + } + } + } + } + const packageKey = key.replace(/\(.+$/u, '') + const packageEntry = packageEntries.get(key) ?? packageEntries.get(packageKey) + if (packageEntry) selectedPackages.set(packageEntry.key, packageEntry.lines.join('\n')) + } + + const sortedObject = (entries) => Object.fromEntries([...entries].sort(([left], [right]) => ( + left.localeCompare(right) + ))) + return JSON.stringify({ + roots: sortedObject(roots), + packages: sortedObject(selectedPackages), + snapshots: sortedObject(selectedSnapshots), + }) +} + export function semanticSourceForDigest(path, source) { + if (path === 'site/package.json') return runtimePackageJsonProjection(source) + if (path === 'site/pnpm-lock.yaml') return runtimeLockProjection(source) const rootName = SEMANTIC_ELEMENT_ROOTS.get(path) if (!PARSED_EXTENSIONS.has(extname(path))) return source const { program, errors } = parseSync(path, source) @@ -3440,6 +3620,11 @@ function projectPath(path) { export function resolveStaticImport(importer, specifier) { const pathSpecifier = specifier.match(/^[^?#]*/u)?.[0] ?? specifier + const query = specifier.slice(pathSpecifier.length).match(/^\?([^#]*)/u)?.[1] ?? '' + const queryParameters = new URLSearchParams(query) + const importsCssAsValue = ['inline', 'raw', 'url'].some((name) => ( + queryParameters.has(name) + )) if (!pathSpecifier.startsWith('.')) return null const unresolved = resolve(dirname(importer), pathSpecifier) const candidates = extname(unresolved) @@ -3451,7 +3636,7 @@ export function resolveStaticImport(importer, specifier) { const resolved = candidates.find((candidate) => ( existsSync(candidate) && statSync(candidate).isFile() )) - if (!resolved || extname(resolved) === '.css') return null + if (!resolved || (extname(resolved) === '.css' && !importsCssAsValue)) return null return resolved } @@ -3499,6 +3684,8 @@ export function semanticRouteFiles(pathname) { .map((moduleId) => `site/${moduleId}`), ) const pending = [ + resolve(projectRoot, 'site/package.json'), + resolve(projectRoot, 'site/pnpm-lock.yaml'), resolve(projectRoot, 'site/src/entry-server.tsx'), resolve(projectRoot, 'site/src/lib/pageMeta.ts'), resolve(projectRoot, 'site/src/lib/seoHead.ts'), diff --git a/site/scripts/semantic-source-graph.test.mjs b/site/scripts/semantic-source-graph.test.mjs index e32dfdf..87263ac 100644 --- a/site/scripts/semantic-source-graph.test.mjs +++ b/site/scripts/semantic-source-graph.test.mjs @@ -19,6 +19,8 @@ test('lesson evidence follows the full static semantic dependency graph', () => const files = new Set(semanticRouteFiles('/part/a/01')) for (const file of [ + 'site/package.json', + 'site/pnpm-lock.yaml', 'site/src/entry-server.tsx', 'site/src/app/StaticApp.tsx', 'site/src/app/AppContent.tsx', @@ -984,6 +986,7 @@ test('secondary review boundaries preserve SSR precision and fail closed', () => for (const importedPatch of [ `import '../../scripts/fixtures/semantic-patch-timer.mjs'\nfunction AppContent() { setTimeout(() => track('同步一'), 0); return 正文 }`, `import { unused } from '../../scripts/fixtures/semantic-patch-react.mjs'\nimport { useEffect } from 'react'\nfunction AppContent() { useEffect(() => track('同步一'), []); return 正文 }`, + `await import('opaque-patcher')\nfunction AppContent() { setTimeout(() => track('同步一'), 0); return 正文 }`, ]) { assert.throws( () => semanticSourceForDigest('site/src/app/AppContent.tsx', importedPatch), @@ -1009,6 +1012,7 @@ test('secondary review boundaries preserve SSR precision and fail closed', () => `const state = { value: '正文' }\nconst G = true ? globalThis : globalThis\nG.Object = { assign(target) { target.value = '变化' } }\nfunction AppContent() { Object.assign(state, {}); return {state.value} }`, `let label = '正文'\nconst [g] = [globalThis]\ng.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, `let label = '正文'\nconst { g } = { g: globalThis }\ng.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, + `let label = '正文'\nconst holder = { nested: { g: globalThis } }\nholder.nested.g.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, `let label = '正文'\nconst P = (() => Promise)()\nP.prototype.then = callback => { callback(); return Promise.resolve() }\nfunction AppContent() { Promise.resolve().then(() => { label = '变化' }); return {label} }`, `let label = '正文'\ngetGlobal().setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, ]) { @@ -1022,6 +1026,7 @@ test('secondary review boundaries preserve SSR precision and fail closed', () => for (const externalCall of [ `import { mutate as externalMutate, state } from 'pkg'\nfunction AppContent() { externalMutate(); return {state.value} }`, `function AppContent() { externalMutate(); return {globalThis.state.value} }`, + `import { opaque } from 'opaque-patcher'\nfunction local() { return '正文' }\nconst selected = true ? opaque : local\nfunction AppContent() { return {selected()} }`, ]) { assert.throws( () => semanticSourceForDigest('site/src/app/AppContent.tsx', externalCall), @@ -1052,6 +1057,15 @@ test('secondary review boundaries preserve SSR precision and fail closed', () => ), ) + const nestedGlobalPropertyWrite = `globalThis.state.label = '变化一'\nfunction AppContent() { return

{globalThis.state.label}

}` + assert.notEqual( + semanticSourceForDigest('site/src/app/AppContent.tsx', nestedGlobalPropertyWrite), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + nestedGlobalPropertyWrite.replace('变化一', '变化二'), + ), + ) + assert.throws( () => semanticSourceForDigest( 'site/src/app/AppContent.tsx', @@ -1148,6 +1162,10 @@ test('public config rejects alias mutation, pattern writes, and patched freeze', `const { g } = { g: globalThis }\ng.Object = { freeze(value) { value.name = '运行时'; return value } }`, `name: '初始'`, ), + publicConfigSource( + `const holder = { nested: { g: globalThis } }\nholder.nested.g.Object = { freeze(value) { value.name = '运行时'; return value } }`, + `name: '初始'`, + ), publicConfigSource( `const O = (() => Object)()\nO.freeze = value => { value.name = '运行时'; return value }`, `name: '初始'`, @@ -1178,9 +1196,77 @@ test('static dependency discovery strips Vite query and hash suffixes', () => { const expected = fileURLToPath( new URL('./fixtures/semantic-copy.txt', import.meta.url), ) + const expectedCss = fileURLToPath( + new URL('./fixtures/semantic-copy.css', import.meta.url), + ) assert.equal(resolveStaticImport(importer, './semantic-copy.txt?raw'), expected) assert.equal(resolveStaticImport(importer, './semantic-copy.txt?url#copy'), expected) + assert.equal(resolveStaticImport(importer, './semantic-copy.css'), null) + assert.equal(resolveStaticImport(importer, './semantic-copy.css?raw'), expectedCss) + assert.equal(resolveStaticImport(importer, './semantic-copy.css?inline#copy'), expectedCss) + assert.equal(resolveStaticImport(importer, './semantic-copy.css?url'), expectedCss) +}) + +test('trusted runtime packages are bound to their declared and locked resolutions', () => { + const packageSource = JSON.stringify({ + dependencies: { react: '^19.2.8' }, + devDependencies: { vitest: '^4.1.11' }, + }) + assert.notEqual( + semanticSourceForDigest('site/package.json', packageSource), + semanticSourceForDigest( + 'site/package.json', + packageSource.replace('^19.2.8', '^20.0.0'), + ), + ) + assert.equal( + semanticSourceForDigest('site/package.json', packageSource), + semanticSourceForDigest( + 'site/package.json', + packageSource.replace('^4.1.11', '^5.0.0'), + ), + ) + + const lockSource = `lockfileVersion: '9.0' +importers: + .: + dependencies: + react: + specifier: ^19.2.8 + version: 19.2.8 + devDependencies: + vitest: + specifier: ^4.1.11 + version: 4.1.11 +packages: + loose-envify@1.4.0: + resolution: {integrity: sha512-runtime-one} + react@19.2.8: + resolution: {integrity: sha512-react-one} + vitest@4.1.11: + resolution: {integrity: sha512-test-one} +snapshots: + loose-envify@1.4.0: {} + react@19.2.8: + dependencies: + loose-envify: 1.4.0 + vitest@4.1.11: {}` + const lockProjection = semanticSourceForDigest('site/pnpm-lock.yaml', lockSource) + assert.notEqual( + lockProjection, + semanticSourceForDigest( + 'site/pnpm-lock.yaml', + lockSource.replace('sha512-runtime-one', 'sha512-runtime-two'), + ), + ) + assert.equal( + lockProjection, + semanticSourceForDigest( + 'site/pnpm-lock.yaml', + lockSource.replace('sha512-test-one', 'sha512-test-two'), + ), + ) }) test('switch discriminants resolve outside the switch lexical scope', () => { diff --git a/site/scripts/seo-contract.test.mjs b/site/scripts/seo-contract.test.mjs index c29ee24..fe0696d 100644 --- a/site/scripts/seo-contract.test.mjs +++ b/site/scripts/seo-contract.test.mjs @@ -232,7 +232,7 @@ test('discovery files expose the 47 approved URLs and real summaries', async () assert.match(lastModified, /--format=%cI/) assert.match(lastModified, /normalizeContentForDigest/) assert.match(lastModified, /semanticRouteFiles/) - assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 14) + assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 15) assert.equal(Object.keys(ROUTE_CONTENT_DIGESTS).length, PUBLIC_PATHS.length) assert.ok(Object.values(ROUTE_CONTENT_DIGESTS).every((digest) => /^[0-9a-f]{64}$/.test(digest))) assert.match(publicRoutes, /\.\.\/data\/catalog\.ts/) @@ -285,6 +285,7 @@ test('build contracts provide one Cloudflare output, SSR prerendering, hydration assert.match(wrangler, /"pattern":\s*"dp\.round1\.cc"/) assert.match(wrangler, /"zone_name":\s*"round1\.cc"/) assert.match(wrangler, /"directory":\s*"\.\/dist\/"/) + assert.match(wrangler, /"run_worker_first":\s*true/) assert.match(preview, /const status = file \? 200 : 404/) assert.match(preview, /new URL\('\.\.\/dist\/', import\.meta\.url\)/) assert.equal(packageJson.scripts.build, 'tsc -b && node scripts/build.mjs') diff --git a/site/src/data/routeLastModified.ts b/site/src/data/routeLastModified.ts index 77d6724..1f3f225 100644 --- a/site/src/data/routeLastModified.ts +++ b/site/src/data/routeLastModified.ts @@ -1,100 +1,100 @@ // 由 scripts/generate-seo.mjs 从源码时间证据生成,请勿手改。 -export const ROUTE_CONTENT_DIGEST_VERSION = 14 +export const ROUTE_CONTENT_DIGEST_VERSION = 15 export const ROUTE_LAST_MODIFIED: Readonly> = Object.freeze({ - "/": "2026-08-22T23:46:11+08:00", - "/part/a": "2026-08-22T11:47:40+08:00", - "/part/b": "2026-08-22T11:47:40+08:00", - "/part/c": "2026-08-22T11:47:40+08:00", - "/part/d": "2026-08-22T11:47:40+08:00", - "/part/e": "2026-08-22T11:47:40+08:00", - "/part/f": "2026-08-22T11:47:40+08:00", - "/part/g": "2026-08-22T11:47:40+08:00", - "/part/a/01": "2026-08-22T11:47:40+08:00", - "/part/a/complete": "2026-08-22T11:47:40+08:00", - "/part/a/multiple": "2026-08-22T11:47:40+08:00", - "/part/a/group": "2026-08-22T11:47:40+08:00", - "/part/a/mixed": "2026-08-22T11:47:40+08:00", - "/part/a/cost2d": "2026-08-22T11:47:40+08:00", - "/part/a/dep": "2026-08-22T11:47:40+08:00", - "/part/a/variant": "2026-08-22T11:47:40+08:00", - "/part/a/fractional": "2026-08-22T11:47:40+08:00", - "/part/b/path": "2026-08-22T11:47:40+08:00", - "/part/b/maxseg": "2026-08-22T11:47:40+08:00", - "/part/b/lis": "2026-08-22T11:47:40+08:00", - "/part/b/lcs": "2026-08-22T11:47:40+08:00", - "/part/b/edit": "2026-08-22T11:47:40+08:00", - "/part/b/fsm": "2026-08-22T11:47:40+08:00", - "/part/b/count": "2026-08-22T11:47:40+08:00", - "/part/c/stone": "2026-08-22T11:47:40+08:00", - "/part/c/ring": "2026-08-22T11:47:40+08:00", - "/part/c/palindrome": "2026-08-22T11:47:40+08:00", - "/part/c/tree": "2026-08-22T11:47:40+08:00", - "/part/c/merge": "2026-08-22T11:47:40+08:00", - "/part/d/grid": "2026-08-22T11:47:40+08:00", - "/part/d/matpow": "2026-08-22T11:47:40+08:00", - "/part/e/basic": "2026-08-22T11:47:40+08:00", - "/part/e/distsum": "2026-08-22T11:47:40+08:00", - "/part/e/inout": "2026-08-22T11:47:40+08:00", - "/part/e/center": "2026-08-22T11:47:40+08:00", - "/part/f/select": "2026-08-22T11:47:40+08:00", - "/part/f/knapsack": "2026-08-22T11:47:40+08:00", - "/part/f/diameter": "2026-08-22T11:47:40+08:00", - "/part/f/cover": "2026-08-22T11:47:40+08:00", - "/part/f/count": "2026-08-22T11:47:40+08:00", - "/part/g/board": "2026-08-22T11:47:40+08:00", - "/part/g/tsp": "2026-08-22T11:47:40+08:00", - "/part/g/cover": "2026-08-22T11:47:40+08:00", - "/part/g/subset": "2026-08-22T11:47:40+08:00", - "/part/g/plug": "2026-08-22T11:47:40+08:00", - "/method": "2026-08-22T11:47:40+08:00", - "/problems": "2026-08-22T11:47:40+08:00" + "/": "2026-08-23T21:18:04+08:00", + "/part/a": "2026-08-23T21:18:04+08:00", + "/part/b": "2026-08-23T21:18:04+08:00", + "/part/c": "2026-08-23T21:18:04+08:00", + "/part/d": "2026-08-23T21:18:04+08:00", + "/part/e": "2026-08-23T21:18:04+08:00", + "/part/f": "2026-08-23T21:18:04+08:00", + "/part/g": "2026-08-23T21:18:04+08:00", + "/part/a/01": "2026-08-23T21:18:04+08:00", + "/part/a/complete": "2026-08-23T21:18:04+08:00", + "/part/a/multiple": "2026-08-23T21:18:04+08:00", + "/part/a/group": "2026-08-23T21:18:04+08:00", + "/part/a/mixed": "2026-08-23T21:18:04+08:00", + "/part/a/cost2d": "2026-08-23T21:18:04+08:00", + "/part/a/dep": "2026-08-23T21:18:04+08:00", + "/part/a/variant": "2026-08-23T21:18:04+08:00", + "/part/a/fractional": "2026-08-23T21:18:04+08:00", + "/part/b/path": "2026-08-23T21:18:04+08:00", + "/part/b/maxseg": "2026-08-23T21:18:04+08:00", + "/part/b/lis": "2026-08-23T21:18:04+08:00", + "/part/b/lcs": "2026-08-23T21:18:04+08:00", + "/part/b/edit": "2026-08-23T21:18:04+08:00", + "/part/b/fsm": "2026-08-23T21:18:04+08:00", + "/part/b/count": "2026-08-23T21:18:04+08:00", + "/part/c/stone": "2026-08-23T21:18:04+08:00", + "/part/c/ring": "2026-08-23T21:18:04+08:00", + "/part/c/palindrome": "2026-08-23T21:18:04+08:00", + "/part/c/tree": "2026-08-23T21:18:04+08:00", + "/part/c/merge": "2026-08-23T21:18:04+08:00", + "/part/d/grid": "2026-08-23T21:18:04+08:00", + "/part/d/matpow": "2026-08-23T21:18:04+08:00", + "/part/e/basic": "2026-08-23T21:18:04+08:00", + "/part/e/distsum": "2026-08-23T21:18:04+08:00", + "/part/e/inout": "2026-08-23T21:18:04+08:00", + "/part/e/center": "2026-08-23T21:18:04+08:00", + "/part/f/select": "2026-08-23T21:18:04+08:00", + "/part/f/knapsack": "2026-08-23T21:18:04+08:00", + "/part/f/diameter": "2026-08-23T21:18:04+08:00", + "/part/f/cover": "2026-08-23T21:18:04+08:00", + "/part/f/count": "2026-08-23T21:18:04+08:00", + "/part/g/board": "2026-08-23T21:18:04+08:00", + "/part/g/tsp": "2026-08-23T21:18:04+08:00", + "/part/g/cover": "2026-08-23T21:18:04+08:00", + "/part/g/subset": "2026-08-23T21:18:04+08:00", + "/part/g/plug": "2026-08-23T21:18:04+08:00", + "/method": "2026-08-23T21:18:04+08:00", + "/problems": "2026-08-23T21:18:04+08:00" }) export const ROUTE_CONTENT_DIGESTS: Readonly> = Object.freeze({ - "/": "12cfb56f67d2e7135952971d1e410d81afa6bc3e4d381bc982270c2828973e16", - "/part/a": "4f5ea61b2d81aedc06329889198d7497c7586709cd82da0fb22eca45d4437304", - "/part/b": "17db7aac56ec38577374762a1be74a4ac72aa983b2711a15555fda1ea4978a2d", - "/part/c": "b727d367a71ec275c91f91258cf81f6baa8c4b3f39eb356416262f536a3e645d", - "/part/d": "14f2580cbe3c57e79ba1569e312e548b888e9ddfb850105889c615a47be99ad8", - "/part/e": "bd8f689faae0721b0320f63fef733b464afbf869dbcd6af60d252334ab03a54b", - "/part/f": "906d7e80fedf71fb7ea5035ffc10e10032b48fc4ed777181e73e6bc975c4a99b", - "/part/g": "96fef6c17bd780d90989404ff5c6f580b4a3d53c1e074d70768ce7ee45f2a94e", - "/part/a/01": "b1ec1735009d33ebf8ef7205caa94b9a3535deda8dfb245e06478555d89dad45", - "/part/a/complete": "ac73ba5b708f9fde996cc25de80621408c4e97956c33d3173710a3ca6762a7e2", - "/part/a/multiple": "2dbe93d29cec071fa430b31226235267c3f0408ab2cbcaf7f2d4f463988715e1", - "/part/a/group": "4d6675b2d6e8f430589b72c652422bf081874af0fbd022cb10661467d62a373b", - "/part/a/mixed": "cedf48ec414e3d34bfa2f4291649e5a16a1218c51eac892d9a2026acb4e1aa17", - "/part/a/cost2d": "82b8948a2e735652fb5c161d46df794f49854f65d19c7f8d933197f82aa1c8cc", - "/part/a/dep": "a1e4ad1f7ed80fea66524f793919263644f832359069eaaf61516fb0ddc12fb6", - "/part/a/variant": "d1f2649a6216c7ed67760bec75d47638a61418bcaa4845316843b5c3daed3be1", - "/part/a/fractional": "9925e4591c303e3dff254f8e39a6c3b437980cc743f56798ecb72eb54e5dd1ab", - "/part/b/path": "5725febb9f43dcbc31761550fd71584307d2b08189ba47cde8b5eeea1bda8850", - "/part/b/maxseg": "93e3d761ab5cc2523c71e12647dcdff8070996c9a45ca63e406168a06b0f6784", - "/part/b/lis": "e9ca59988c98dab3e0994969d205478a24d1ed4e9b08b782ecd852d30df529ac", - "/part/b/lcs": "8e5f6264b3777d6ae4febf5f5c707774ecd2012722a52275a9b15de543556f15", - "/part/b/edit": "7fa9540475b0280943d9954f4ddca008f7d7bb3bb0eb7d639894f3e7bdb1c34b", - "/part/b/fsm": "1e4f2fb692d55e3e4fddbf1b11b4c65e3dcfd7600f64e8900912b9c20f4c3143", - "/part/b/count": "78beb42911c74f88ab5dbf7c7d0ddec8698eee32dd4a2572f98ece64f5e7f81d", - "/part/c/stone": "659cfb97675387a9155cb5e2edc1a6eadd88917f5ed995411db88847c056ad41", - "/part/c/ring": "9b5928f33abce65f0c224916d522485c2bd505a0d276df0f884c37d05cef41ff", - "/part/c/palindrome": "07e44ba0a76288aabbdc76d2aeb3baced62d255766386e78217c85afdf5e0bd8", - "/part/c/tree": "3f2462f4a01b1e8e3fcb396442d56791ddaeb67d0dfa30ef58b1c1832d803fb6", - "/part/c/merge": "a249bdb7650b3bd37a78a7ea82af4ca91830bdf250f3fc728e33f5107f205eaf", - "/part/d/grid": "a717bc0470abb4cff3667773bb07dc88083e2c97c7f43ae4bf1b45ec593b18f1", - "/part/d/matpow": "746c8911b669e2bb65a6bb56036af226c502f86363d4e54b714603ee820a104e", - "/part/e/basic": "44c42ea991a4f1358e964aa457778631e7f89e3e129152b34684ab8ce5081f6e", - "/part/e/distsum": "ae87f7f503ba41b64ff21ba7801b64acfc40787efcaf0f76a58cbd9c6059eb1a", - "/part/e/inout": "ed9e66baa6920e768ba35fee13a892eb225e945377d1cc259bfac2e9d41fc183", - "/part/e/center": "e29c6a3fcf034ece8fe7dd2b7f3b3e544c1337be2f0fb592a6616963e4a25d1d", - "/part/f/select": "09273dd8af76199acfc797d73083480844bb975beafcf61862d55634b9a576a3", - "/part/f/knapsack": "6cb59f767cbc7ea1d16739097c539d222feca48317402beb9fb7f8c82c6fc708", - "/part/f/diameter": "ce51245a2bf7c5812bfe2a17802600432283e0f29e4b0b728b6d5df0e627da7a", - "/part/f/cover": "ae508a124f358dc42ae3388c1daaa3c5ea25131a420719fc89d39bdad53a4e77", - "/part/f/count": "9755f399ca2cc80a573c6781f700dec992b9cc64f24fa80df765ca034b56aa11", - "/part/g/board": "7e505483f408a148dfd1d1526125187fd0f60a8a688327d4aedaec2bcb35089b", - "/part/g/tsp": "899de6dbeffad335a9868a18ae4e3a8d9e86dd0ea137e468e20c67bd204ca619", - "/part/g/cover": "446c3307a6e6eaa20c76c5ec04b3976984527ac2e9d3883890cc62691f742762", - "/part/g/subset": "cab56d4272342fd5a77e77b83035b91c89e373f2e6725aab65314b75a395d41e", - "/part/g/plug": "e4a20f03297e79e121bf561857852af2a565a6cd8393438409559d6773b7de12", - "/method": "27dac5989daab8eaeaeac912b54ab02021153e987b76e8ddb1d2a9b2d32fc97d", - "/problems": "b9d1c455a7e34b4fbe6a2a4f284a3390c8299be66043222839a27029d9427772" + "/": "0262386989c64bf668f35392b0a8ae373673a7bf285e4e7b79b8d2a1eaee7734", + "/part/a": "7e32ed21a8e3f3e31c4bbdda8290934cc65ee19b0f2d165883b1a7c115a912df", + "/part/b": "f7cdc6d406a4a72b4170d02e634dac8c9941ee4a6301323d7c702f3a06742808", + "/part/c": "3a4ac525f95353e75700d547db56c6c68e39db040cadcc5be87151b8add6d091", + "/part/d": "4911ced68fbb17921d5f33e6100d34556b8ec5169c15d99e25eb80c903adb183", + "/part/e": "394e72181e560ef1a50e35910e64ce2af3724ee86569356ae982aa6b6c78b0db", + "/part/f": "9bef3b7a4ac3f2881f88b19ce244b09fbf39e7bee2106d05479f213532748cd6", + "/part/g": "a5584c6203acb98a0145c91247d9dbdf512dd83d30df22c595db058548b32979", + "/part/a/01": "5e4a14f54373eb150a92952feea9914a5942db349f1f6275136d03d5c946d034", + "/part/a/complete": "ba064c4bb5551291ddae70ec3eb4d709ad8bd97484807d8be986d6ecd7cfe207", + "/part/a/multiple": "b0cc8e73aef0ce64b275c27c9142b9f3f89972cc7a903192d1a3c1fc8a0e4eaf", + "/part/a/group": "36b381ee27af2c4b4574a7aa81638c4ebc55ae45a50a274e3dd4e01e60d85d1c", + "/part/a/mixed": "b9f1bfe3ceb781b9733a4573049bc3332d5646a264d403efed85705875fd04ba", + "/part/a/cost2d": "dcf5225c49f33d0bf5a4df5f42b679ad67e703db6ec51dc276fe81f093a8f5ba", + "/part/a/dep": "495b2a06c5bb1bb11b93a7834b745a13033b2b85dc67f1fb2b72d885cdd151f0", + "/part/a/variant": "fa04a97c6be983af9ca3862f9d26463c8f072fb9e8df156b0732956bfcea6795", + "/part/a/fractional": "f16d12385aee2679547cca52778252ea73302e7912b943393f5c7e735ec4aaeb", + "/part/b/path": "db72987608a24fbe00fde5a4c51667ede4a73a251ed8fa6cf026760193ef075b", + "/part/b/maxseg": "f7f78de0c37e21032f6554e8d738cf4a36569898faad7731fed7f9c5a0500047", + "/part/b/lis": "d9ce7e560d1e0609396ff47d4c451bbfa4101f2b007592274be6f260fea5e506", + "/part/b/lcs": "dfeaa3540133568b59e03216de83d735e64897cde2a78894e12a865b06409c08", + "/part/b/edit": "973d8a542cc56257c82c21b67c0af4f62aa7cb197e883756edc195a2b805da0b", + "/part/b/fsm": "9bedaefc9589ab8db056f3965ed97bf442703f632c1ee67f375477813ce31db2", + "/part/b/count": "f83c72e45d1a2a20e30936c928e7d98d978e326a0ff421ea3a4812290edd493e", + "/part/c/stone": "945d8d2415882ac265fdd41c1a2d9ba72238f2f5bf658212a81269cd19f44386", + "/part/c/ring": "5426eb28e856a657c1f1c292b626ea2f08d553653bce54ad6fd0497f7de05b26", + "/part/c/palindrome": "05f17a6f630af4d25121d05ff04fea9b705458912c25268209bd78c2d51c1f3b", + "/part/c/tree": "404ab9ee3643e040b78647645079ef50358e1821907376c98be159ad9705fa0a", + "/part/c/merge": "42d2411539aaf771ca6a42800feeaa4c798519cafc6fca82dd3d1351069615d7", + "/part/d/grid": "af335f297ab394eae14857253977492bdbe3d1d1a2e2310475ffafc90cb445ea", + "/part/d/matpow": "8fbea5ddefb28692e1dad2c25a88a4848b4569e8fec544c7602e8d8cbb1449b7", + "/part/e/basic": "d47100992ea71202929df23e8ce77216c7db2d87332683bde4ad467990ad16b3", + "/part/e/distsum": "489640867984bf163acce29dbe25b34618581fdd47e61d44cbba687817fd00ec", + "/part/e/inout": "c085b95ccae50a2beeccc8664dae56f2bbb4e8c2cea0e68c5e89dafabb4cc7d3", + "/part/e/center": "6a036b445da01483a48ef9841e299c55b05b2e3812a02941f05c78d6da2626db", + "/part/f/select": "531c43315808c07790a06bc53e918a77e635786801a3192c097112df4a4b68b1", + "/part/f/knapsack": "c855fb15e73e754b93e19891257fdd16fa85f077a5ed452416c02fdd3b1a191f", + "/part/f/diameter": "de4945c3fed9405b5d73d2ab215bd51193f623c8c9bbac9eee5f76f64625dbc1", + "/part/f/cover": "0c849ae62a27c6f69468b83a09bd80677e792a5f8599e437c06be70ea555dbf4", + "/part/f/count": "e97d686c1cbaba8da32e6a7a49ca98951da955a03ec447c9ca180dfb28ef544a", + "/part/g/board": "f11248cfac8541986332d4d34cebc3c48ad2ea707d36c1563d4d07a553160415", + "/part/g/tsp": "4f7e4c866c7830d31b3ca666171a7bf4ae637333ceb8ab384dbc8f80454f5af0", + "/part/g/cover": "37584c79cfdde83ab0b745cbfcddd781448c81a9d49293333d6f1e9fca62a072", + "/part/g/subset": "a7c0df25c82c373fc41a2384ba56962c0e3e1abce4d4f0b730bce916c646318e", + "/part/g/plug": "e9410a2ae41780327cc3e5056a27e1f0dd5b24be1f6bf29545ff125f1c4efa9e", + "/method": "38df14db7f5e2c43f3fc0ab1637c813e26485af1fba11c210ac25c9189a6d594", + "/problems": "27c16b46e3645c2aef86acd79678100f5260cc3463024b4eb48e65b34892169c" }) diff --git a/site/worker/content-negotiation.js b/site/worker/content-negotiation.js index 9815286..c526948 100644 --- a/site/worker/content-negotiation.js +++ b/site/worker/content-negotiation.js @@ -1,5 +1,52 @@ const TOKEN = "[!#$%&'*+.^_`|~0-9a-z-]+" const MEDIA_RANGE = new RegExp(`^(${TOKEN}|\\*)/(${TOKEN}|\\*)$`, 'i') +const TOKEN_VALUE = new RegExp(`^${TOKEN}$`, 'i') + +function splitDelimited(value, delimiter) { + const parts = [] + let start = 0 + let quoted = false + let escaped = false + for (let index = 0; index < value.length; index += 1) { + const character = value[index] + if (escaped) { + escaped = false + continue + } + if (quoted && character === '\\') { + escaped = true + continue + } + if (character === '"') { + quoted = !quoted + continue + } + if (!quoted && character === delimiter) { + parts.push(value.slice(start, index)) + start = index + 1 + } + } + if (quoted || escaped) return null + parts.push(value.slice(start)) + return parts +} + +function validParameterValue(value) { + if (TOKEN_VALUE.test(value)) return true + if (value.length < 2 || value[0] !== '"' || value.at(-1) !== '"') return false + for (let index = 1; index < value.length - 1; index += 1) { + const code = value.charCodeAt(index) + if (value[index] === '\\') { + index += 1 + if (index >= value.length - 1) return false + const escapedCode = value.charCodeAt(index) + if (escapedCode !== 9 && (escapedCode < 32 || escapedCode === 127)) return false + continue + } + if (value[index] === '"' || (code !== 9 && (code < 32 || code === 127))) return false + } + return true +} function parseQuality(value) { if (value === undefined) return 1 @@ -9,7 +56,9 @@ function parseQuality(value) { } function parseRange(member) { - const [mediaType, ...parameters] = member.split(';').map((part) => part.trim()) + const split = splitDelimited(member, ';') + if (!split) return null + const [mediaType, ...parameters] = split.map((part) => part.trim()) const match = MEDIA_RANGE.exec(mediaType) if (!match) return null const type = match[1].toLowerCase() @@ -22,9 +71,11 @@ function parseRange(member) { const separator = parameter.indexOf('=') if (separator < 1) return null const name = parameter.slice(0, separator).trim().toLowerCase() + const value = parameter.slice(separator + 1).trim() + if (!TOKEN_VALUE.test(name) || !validParameterValue(value)) return null if (name !== 'q') continue if (quality !== undefined) return null - quality = parseQuality(parameter.slice(separator + 1)) + quality = parseQuality(value) if (quality === null) return null } @@ -53,8 +104,8 @@ function effectiveMatch(ranges, subtype) { export function negotiateRepresentation(accept) { if (accept === null || accept === undefined) return 'html' - const ranges = accept - .split(',') + const members = splitDelimited(accept, ',') + const ranges = (members ?? []) .map(parseRange) .filter((range) => range !== null) diff --git a/site/wrangler.jsonc b/site/wrangler.jsonc index 9a5e855..22352ca 100644 --- a/site/wrangler.jsonc +++ b/site/wrangler.jsonc @@ -1,8 +1,8 @@ { // Cloudflare Workers 静态资源托管(Workers Static Assets)+ 一个极薄 Worker。 - // 这是站点唯一的发布目标:静态文件由资源绑定直出;/api/feedback、/api/analytics - // 与诊断端点由 worker.js 接住,其余已知路由直出预渲染 HTML,未知路径返回 - // 404.html + HTTP 404。文档: + // 这是站点唯一的发布目标:所有请求先经过 worker.js,API 由 Worker 处理, + // 公开路由再从资源绑定读取预渲染 HTML 或协商后的 Markdown;未知路径返回 + // 404.html + HTTP 404,内部表示路径固定隐藏。文档: // https://developers.cloudflare.com/workers/static-assets/binding/ "$schema": "node_modules/wrangler/config-schema.json", "name": "dpmaster", @@ -54,7 +54,9 @@ "directory": "./dist/", // 让 Worker 能通过 env.ASSETS.fetch() 取静态资源和 404 页面。 "binding": "ASSETS", - // 已知路由由预渲染的 .html 文件直出;未知路径返回 404.html + HTTP 404。 + // Cloudflare 默认资产优先会绕过内容协商与内部路径保护;必须先执行 Worker。 + "run_worker_first": true, + // Worker 读取已知预渲染路线;未知路径返回 404.html + HTTP 404。 "html_handling": "drop-trailing-slash", "not_found_handling": "404-page" } From c72f4b36f2384de4a8844d4d7f78f206630a25da Mon Sep 17 00:00:00 2001 From: Shanire Date: Thu, 27 Aug 2026 09:23:03 +0800 Subject: [PATCH 18/23] fix(web): harden route evidence and negotiation Signed-off-by: Shanire --- docs/engineering/architecture.md | 4 +- docs/operations/verification.md | 6 +- site/scripts/content-negotiation.test.mjs | 5 + site/scripts/last-modified.mjs | 55 ++-- site/scripts/semantic-source-graph.mjs | 288 +++++++++++++++++--- site/scripts/semantic-source-graph.test.mjs | 117 +++++++- site/scripts/seo-contract.test.mjs | 2 +- site/src/data/routeLastModified.ts | 96 +++---- site/worker/content-negotiation.js | 90 +++++- 9 files changed, 545 insertions(+), 118 deletions(-) diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index 19f0181..e34e688 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -100,7 +100,7 @@ All 29 teaching solver surfaces are Adapters over the algorithm boundary: public Known deep links are physical prerendered HTML assets. Cloudflare Workers Static Assets uses `run_worker_first: true`, so every request reaches `worker.js` before the `ASSETS` binding serves a matching file; this is required because an asset-first match would bypass same-URL content negotiation. The binding uses `404-page` for anything unmatched, returning the themed `404.html` with a real HTTP 404. See root [deploy.md](../../deploy.md) for the platform contract. -For a registered public `GET` or `HEAD`, `worker/content-negotiation.js` selects HTML or Markdown from `Accept`; HTML wins equal preferences and the Worker returns 406 when both supported media types are explicitly unacceptable. Invalid media-range members, including empty or unterminated parameters, are ignored rather than accepted. Markdown is fetched only through a distinct internal asset URL, so its ETag and static-asset cache key cannot collide with HTML. Both representations add `Vary: Accept`, alternate links, `nosniff`, and the approved public `Content-Signal`; direct requests to the internal prefix return 404. `HEAD` executes the same matrix and headers but never returns a body. APIs, ordinary assets, the internal specimen, and unknown routes pass through the Worker routing layer without content negotiation. The internal path mapper and public signal live in `publicWebContract.ts`, preventing the generator, discovery files, and Worker from silently diverging. +For a registered public `GET` or `HEAD`, `worker/content-negotiation.js` selects HTML or Markdown from `Accept`; HTML wins equal preferences and the Worker returns 406 when both supported media types are explicitly unacceptable. Media parameters before `q` participate in representation matching, including the UTF-8 charset, while accept extensions after `q` do not. Invalid media-range members, including empty or unterminated parameters, are ignored rather than accepted; if a broken quoted member precedes another syntactically valid media range, parsing recovers at that later member instead of discarding the whole header. Markdown is fetched only through a distinct internal asset URL, so its ETag and static-asset cache key cannot collide with HTML. Both representations add `Vary: Accept`, alternate links, `nosniff`, and the approved public `Content-Signal`; direct requests to the internal prefix return 404. `HEAD` executes the same matrix and headers but never returns a body. APIs, ordinary assets, the internal specimen, and unknown routes pass through the Worker routing layer without content negotiation. The internal path mapper and public signal live in `publicWebContract.ts`, preventing the generator, discovery files, and Worker from silently diverging. # SEO And Accessibility @@ -114,7 +114,7 @@ Every parsed source contribution is reduced to a runtime AST projection, so Type Effect-only callbacks are excluded only when the callee has one exact, stable React-effect origin through named, default, namespace, member, or destructured aliases. Mixed aliases, reassigned hooks, patched React namespaces, and same-named local helpers are never pruned as effects. Runtime import edges remain while declaration- and specifier-level type-only edges, unselected import siblings, object-spread copies, and JSX attribute names do not leak in. The graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Public representation identity is structurally isolated in `config/site.ts`; client endpoints and sidebar-only copy are in modules reachable only through pruned branches. -Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. An unchanged digest preserves the previous value; changed content uses the precise Git commit or working-tree timestamp of a semantically changed projection and must advance. During a digest-schema migration, the current projector is applied to historical sources at the previous `lastmod`: a clean committed semantic edit advances, while an edit confined to a pruned client branch does not. Version 15 extends Version 14 with byte-exact binary-asset evidence, semantic CSS `?raw`/`?inline`/`?url` imports, nested global-container provenance, top-level dynamic-import evaluation edges, and all-candidate validation for mixed local/external call targets. The audited runtime-package allowlist is now bound to the selected declarations in `package.json` and their recursively reachable exact resolutions and integrities in `pnpm-lock.yaml`; unrelated development-only lock entries remain outside the public digest. This newly visible input correctly advances all 47 routes to the runtime-dependency maintenance commit at `2026-08-23T21:18:04+08:00`. Unsupported or unresolved provenance fails closed. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, type-only noise, incomplete execution/scope/mutation context, lossy UTF-8 decoding of binary assets, or an unpinned trusted runtime package graph. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. +Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. An unchanged digest preserves the previous value; changed content uses the precise Git commit or working-tree timestamp of a semantically changed projection and must advance. During a digest-schema migration, the current projector is applied to historical sources at the previous `lastmod`: a clean committed semantic edit advances, while an edit confined to a pruned client branch does not. Version 15 first added byte-exact binary assets, semantic CSS queries, nested global-container provenance, top-level dynamic imports, mixed-target validation, and the audited runtime package graph. Version 16 narrows that package evidence to the trusted package roots actually reachable from each route, then requires every selected root and recursively reachable registry package to have an exact importer entry, snapshot, package record, and integrity in `pnpm-lock.yaml`; missing evidence fails closed. Its module-evaluation walk follows literal dynamic imports reached through top-level awaited callbacks, prunes statically dead branches, and rejects non-literal module-evaluation targets. Member-stored global containers and getter-derived dynamic call targets also fail closed. The schema refinement preserves all 47 route times at the dependency-maintenance commit `2026-08-23T21:18:04+08:00`, because it changes evidence precision without adding a later semantic source change. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, type-only noise, incomplete execution/scope/mutation context, lossy UTF-8 decoding of binary assets, or an unpinned or route-unrelated trusted runtime package graph. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. # Analytics diff --git a/docs/operations/verification.md b/docs/operations/verification.md index 0dd4e48..af2cfba 100644 --- a/docs/operations/verification.md +++ b/docs/operations/verification.md @@ -44,11 +44,11 @@ Pure TypeScript Modules imported by Node tests must use explicit `.ts` extension # SEO And Accessibility Checks -`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. Version 15 follows each route's real SSR injection chain with a line-ending-neutral runtime-AST digest: type-only syntax is erased without dropping runtime-bearing TypeScript parameter properties or optional-chain behavior; intrinsic event/ref/key props and statically proven spreads erase only deferred callback values while retaining synchronous factories; public `config/site.ts` evidence follows immutable field dependencies and validates global provenance; and mixed shell modules contribute route-rendering JSX plus the lexical closure of all runtime module-evaluation edges, root/program synchronous calls, proven synchronous callbacks, constructors, tagged templates, aliases, and render-phase mutations. Known asynchronous callbacks, including stable aliases and locally private callback-factory results, are erased even when nested inside an otherwise selected synchronous slice, while callback acquisition remains synchronous evidence and exported callbacks remain conservative. Regression probes additionally cover all-candidate mixed call targets, nested global-container holders, top-level dynamic imports, semantic CSS query imports, and byte-distinct binary assets. Runtime package imports are trusted only through the audited dependency set used by this product, and that set is bound to selected `package.json` declarations plus recursively reachable exact `pnpm-lock.yaml` resolutions and integrities. Unsupported mutable, escaped, aliased, globally patched, unresolved, or dynamic config, unproved callback timing, dynamic targets or intrinsic spreads, imported member receivers, arbitrary external mutations, and ambiguous nested writes outside proven client callbacks fail closed. +`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. Version 16 follows each route's real SSR injection chain with a line-ending-neutral runtime-AST digest: type-only syntax is erased without dropping runtime-bearing TypeScript parameter properties or optional-chain behavior; intrinsic event/ref/key props and statically proven spreads erase only deferred callback values while retaining synchronous factories; public `config/site.ts` evidence follows immutable field dependencies and validates global provenance; and mixed shell modules contribute route-rendering JSX plus the lexical closure of all runtime module-evaluation edges, root/program synchronous calls, proven synchronous callbacks, constructors, tagged templates, aliases, and render-phase mutations. Known asynchronous callbacks, including stable aliases and locally private callback-factory results, are erased even when nested inside an otherwise selected synchronous slice, while callback acquisition remains synchronous evidence and exported callbacks remain conservative. Regression probes additionally cover all-candidate mixed call targets, getter-derived targets, global containers stored by later member assignment, literal dynamic imports reached through top-level awaited callbacks, dead dynamic-import branches, semantic CSS query imports, and byte-distinct binary assets. Runtime packages are trusted only through the audited dependency set used by this product; each route projects only its reachable trusted roots from `package.json`, and every selected root and transitive registry node must have its exact importer, snapshot, package record, and integrity in `pnpm-lock.yaml`. Unsupported mutable, escaped, aliased, globally patched, unresolved, or dynamic config, unproved callback timing, dynamic targets or intrinsic spreads, imported member receivers, arbitrary external mutations, ambiguous nested writes, and incomplete lock evidence outside proven client callbacks fail closed. -Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. An unchanged digest preserves its prior time; changed content uses the exact Git commit or working-tree timestamp of a semantically changed projection and must advance. A schema migration reapplies the current projector to source history at the previous `lastmod`, so clean committed semantic edits advance while dirty changes confined to pruned client branches do not. Version 15 makes the runtime dependency graph visible for the first time, so all 47 routes correctly advance to the dependency-maintenance commit at `2026-08-23T21:18:04+08:00`. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, Worker-first static-asset routing, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. +Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. An unchanged digest preserves its prior time; changed content uses the exact Git commit or working-tree timestamp of a semantically changed projection and must advance. A schema migration reapplies the current projector to source history at the previous `lastmod`, so clean committed semantic edits advance while dirty changes confined to pruned client branches do not. Version 15 made the runtime dependency graph visible and advanced all 47 routes to the dependency-maintenance commit at `2026-08-23T21:18:04+08:00`; Version 16 makes that evidence route-specific and fail-closed without inventing a later content time, so the 47 timestamps remain there. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, Worker-first static-asset routing, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. -The browser gate runs Chromium against built `dist/` through a strict, non-reused custom preview server. Route tests directly open `/`, `/part/a`, `/part/a/01`, `/method`, and `/part/g/plug`, then exercise live client navigation and keyboard focus. They check HTTP status, prerendered HTML, absence of pending streamed-Suspense placeholders, route CSS at first paint, hydration without errors, CLS below `0.05`, title/description/abstract/canonical/zero-hreflang/Open Graph/JSON-LD metadata, one visible `h1`, route announcements, current-page semantics, initial-load focus, changed-route focus, and skip-link focus. Navigation-positioning tests click a representative lesson outline in each of the seven families, restore a direct lesson fragment, verify the mobile outline and an ordinary family-page fragment, and require a changed route to reset old-page scroll immediately. An unknown path must return 404 with `noindex,nofollow`, no canonical, and the themed not-found heading. Worker-level Node tests separately cover the Accept matrix, malformed parameter members, 406 behavior, HEAD parity, representation-specific ETags, hidden internal assets, and unaffected API/static/unknown paths. +The browser gate runs Chromium against built `dist/` through a strict, non-reused custom preview server. Route tests directly open `/`, `/part/a`, `/part/a/01`, `/method`, and `/part/g/plug`, then exercise live client navigation and keyboard focus. They check HTTP status, prerendered HTML, absence of pending streamed-Suspense placeholders, route CSS at first paint, hydration without errors, CLS below `0.05`, title/description/abstract/canonical/zero-hreflang/Open Graph/JSON-LD metadata, one visible `h1`, route announcements, current-page semantics, initial-load focus, changed-route focus, and skip-link focus. Navigation-positioning tests click a representative lesson outline in each of the seven families, restore a direct lesson fragment, verify the mobile outline and an ordinary family-page fragment, and require a changed route to reset old-page scroll immediately. An unknown path must return 404 with `noindex,nofollow`, no canonical, and the themed not-found heading. Worker-level Node tests separately cover the Accept matrix, UTF-8 media-parameter matching, recovery after a broken quoted member, 406 behavior, HEAD parity, representation-specific ETags, hidden internal assets, and unaffected API/static/unknown paths. Two game tests cover the catalog-owned lazy boundary and shared runtime contracts. Pack must keep its chunk absent before the near-viewport gate, auto-load without a manual control, replay the currently displayed seed into the identical round with cleared interaction state, and preserve exact played/matched totals across replay, shuffle, difficulty, and reveal. BitBoard must suppress duplicate completion counts and rearm after clear. These tests do not provide arbitrary-seed entry or pixel-regression coverage. diff --git a/site/scripts/content-negotiation.test.mjs b/site/scripts/content-negotiation.test.mjs index 2bb129a..81c0614 100644 --- a/site/scripts/content-negotiation.test.mjs +++ b/site/scripts/content-negotiation.test.mjs @@ -19,6 +19,11 @@ test('content negotiation follows the approved HTML and Markdown Accept matrix', ['not a media range', null], ['text/markdown;foo=', null], ['text/markdown;foo="unterminated', null], + ['text/html, text/markdown;foo="unterminated', 'html'], + ['text/markdown;foo="unterminated, text/html', 'html'], + ['text/markdown;charset=iso-8859-1, text/html;q=0.5', 'html'], + ['text/markdown;charset=utf-8, text/html;q=0.5', 'markdown'], + ['text/markdown;q=1;charset=iso-8859-1, text/html;q=0.5', 'markdown'], ] for (const [accept, expected] of vectors) { diff --git a/site/scripts/last-modified.mjs b/site/scripts/last-modified.mjs index f7ca12a..9a973eb 100644 --- a/site/scripts/last-modified.mjs +++ b/site/scripts/last-modified.mjs @@ -5,12 +5,13 @@ import { extname } from 'node:path' import { fileURLToPath } from 'node:url' import { PUBLIC_PATHS } from '../src/lib/publicRoutes.ts' import { + semanticRuntimePackageNamesForRoute, semanticRouteFiles, semanticSourceForDigest, } from './semantic-source-graph.mjs' const projectRoot = fileURLToPath(new URL('../../', import.meta.url)) -export const ROUTE_CONTENT_DIGEST_VERSION = 15 +export const ROUTE_CONTENT_DIGEST_VERSION = 16 const historicalSourceCache = new Map() const headSourceCache = new Map() @@ -55,16 +56,24 @@ export function normalizeContentForDigest(content) { return content.replaceAll('\r\n', '\n').replaceAll('\r', '\n') } -function routeContentDigest(files) { +function runtimePackageKey(runtimePackages) { + return [...runtimePackages].sort().join(',') +} + +function routeContentDigest(files, runtimePackages) { const digest = createHash('sha256') for (const file of files) { digest.update(file) digest.update('\0') - if (!routeProjectionCache.has(file)) { + const cacheKey = `${file}\0${runtimePackageKey(runtimePackages)}` + if (!routeProjectionCache.has(cacheKey)) { const source = readFileSync(new URL(`../../${file}`, import.meta.url)) - routeProjectionCache.set(file, semanticProjection(file, source)) + routeProjectionCache.set( + cacheKey, + semanticProjection(file, source, { runtimePackages }), + ) } - digest.update(routeProjectionCache.get(file)) + digest.update(routeProjectionCache.get(cacheKey)) digest.update('\0') } return digest.digest('hex') @@ -99,16 +108,16 @@ function headSource(file) { return headSourceCache.get(file) } -function semanticProjection(path, source) { +function semanticProjection(path, source, options = {}) { if (source === null) return null if (Buffer.isBuffer(source) && !TEXT_SOURCE_EXTENSIONS.has(extname(path))) return source const decodedSource = Buffer.isBuffer(source) ? source.toString('utf8') : source - return normalizeContentForDigest(semanticSourceForDigest(path, decodedSource)) + return normalizeContentForDigest(semanticSourceForDigest(path, decodedSource, options)) } -export function semanticProjectionChanged(path, currentSource, previousSource) { - const currentProjection = semanticProjection(path, currentSource) - const previousProjection = semanticProjection(path, previousSource) +export function semanticProjectionChanged(path, currentSource, previousSource, options = {}) { + const currentProjection = semanticProjection(path, currentSource, options) + const previousProjection = semanticProjection(path, previousSource, options) if (Buffer.isBuffer(currentProjection) && Buffer.isBuffer(previousProjection)) { return !currentProjection.equals(previousProjection) } @@ -121,7 +130,7 @@ function latestDate(dates) { ), null) } -function latestSemanticGitDate(file, previousLastModified) { +function latestSemanticGitDate(file, previousLastModified, options) { const history = spawnSync( 'git', ['log', `--since=${previousLastModified}`, '--format=%H%x09%cI', 'HEAD', '--', file], @@ -136,15 +145,17 @@ function latestSemanticGitDate(file, previousLastModified) { file, gitSource(file, revision), gitSource(file, `${revision}^`), + options, )) return committedAt } throw new Error(`Semantic source changed without a matching Git revision for ${file}`) } -function semanticChangeEvidence(files, previousLastModified) { +function semanticChangeEvidence(files, previousLastModified, runtimePackages) { const dates = [] for (const file of files) { - const cacheKey = `${file}\0${previousLastModified}` + const options = { runtimePackages } + const cacheKey = `${file}\0${previousLastModified}\0${runtimePackageKey(runtimePackages)}` if (!semanticFileEvidenceCache.has(cacheKey)) { const currentSource = readFileSync(new URL(`../../${file}`, import.meta.url)) let date = null @@ -152,15 +163,17 @@ function semanticChangeEvidence(files, previousLastModified) { file, currentSource, historicalSource(file, previousLastModified), + options, )) { const currentDiffersFromHead = semanticProjectionChanged( file, currentSource, headSource(file), + options, ) date = currentDiffersFromHead ? new Date(statSync(new URL(`../../${file}`, import.meta.url)).mtimeMs).toISOString() - : latestSemanticGitDate(file, previousLastModified) + : latestSemanticGitDate(file, previousLastModified, options) } semanticFileEvidenceCache.set(cacheKey, date) } @@ -245,11 +258,17 @@ export function collectRouteContentEvidence({ const filesByRoute = new Map( PUBLIC_PATHS.map((pathname) => [pathname, semanticRouteFiles(pathname)]), ) + const packagesByRoute = new Map( + PUBLIC_PATHS.map((pathname) => [ + pathname, + semanticRuntimePackageNamesForRoute(pathname), + ]), + ) const dirtyFiles = dirtyRouteFiles(new Set([...filesByRoute.values()].flat())) const contentDigests = Object.fromEntries( [...filesByRoute].map(([pathname, files]) => [ pathname, - routeContentDigest(files), + routeContentDigest(files, packagesByRoute.get(pathname)), ]), ) @@ -260,7 +279,11 @@ export function collectRouteContentEvidence({ && previousContentDigests[pathname] !== contentDigests[pathname] ) const semanticEvidence = previousLastModified[pathname] && digestChanged - ? semanticChangeEvidence(files, previousLastModified[pathname]) + ? semanticChangeEvidence( + files, + previousLastModified[pathname], + packagesByRoute.get(pathname), + ) : null if ( digestChanged diff --git a/site/scripts/semantic-source-graph.mjs b/site/scripts/semantic-source-graph.mjs index 0dbea1e..5132623 100644 --- a/site/scripts/semantic-source-graph.mjs +++ b/site/scripts/semantic-source-graph.mjs @@ -973,6 +973,11 @@ function publicSiteConfigProjection(program) { } const scanMutations = (node) => { if (node.type === 'AssignmentExpression' || node.type === 'UpdateExpression') { + if ( + node.type === 'AssignmentExpression' + && unwrap(node.left)?.type === 'MemberExpression' + && isGlobalContainer(node.right) + ) unsupported('member-stored global container') rejectUsedIdentity(node.type === 'AssignmentExpression' ? node.left : node.argument, 'mutation of') } else if (node.type === 'UnaryExpression' && node.operator === 'delete') { rejectUsedIdentity(node.argument, 'mutation of') @@ -1055,6 +1060,91 @@ function canonicalImportIdentity(importerPath, specifier) { return unresolved.endsWith('/index') ? unresolved.slice(0, -'/index'.length) : unresolved } +function staticBooleanValue(node) { + const value = node && EXPRESSION_WRAPPERS.has(node.type) + ? staticBooleanValue(node.expression) + : node + if ( + (value?.type === 'Literal' || value?.type === 'BooleanLiteral') + && typeof value.value === 'boolean' + ) return value.value + if (value?.type === 'UnaryExpression' && value.operator === '!') { + const argument = staticBooleanValue(value.argument) + return argument === null ? null : !argument + } + return null +} + +function moduleEvaluationDynamicImportSources(program, path) { + const sources = [] + const functionTypes = new Set([ + 'ArrowFunctionExpression', + 'FunctionDeclaration', + 'FunctionExpression', + ]) + + function visit(node, { executeFunction = false, awaitedCall = false } = {}) { + if (!node || typeof node !== 'object') return + if (node.type === 'ImportExpression') { + if (typeof node.source?.value !== 'string') { + throw new Error(`Unsupported dynamic module-evaluation import in ${path}`) + } + sources.push(node.source) + return + } + if (functionTypes.has(node.type)) { + if (!executeFunction) return + for (const parameter of node.params ?? []) visit(parameter) + visit(node.body) + return + } + if (node.type === 'IfStatement') { + visit(node.test) + const condition = staticBooleanValue(node.test) + if (condition !== false) visit(node.consequent) + if (condition !== true) visit(node.alternate) + return + } + if (node.type === 'ConditionalExpression') { + visit(node.test) + const condition = staticBooleanValue(node.test) + if (condition !== false) visit(node.consequent) + if (condition !== true) visit(node.alternate) + return + } + if (node.type === 'LogicalExpression') { + visit(node.left) + const condition = staticBooleanValue(node.left) + if ( + (node.operator === '&&' && condition !== false) + || (node.operator === '||' && condition !== true) + || (node.operator === '??' && condition === null) + ) visit(node.right) + return + } + if (node.type === 'AwaitExpression') { + visit(node.argument, { awaitedCall: true }) + return + } + if (node.type === 'CallExpression' || node.type === 'NewExpression') { + const immediateFunction = functionTypes.has(node.callee?.type) + visit(node.callee, { executeFunction: immediateFunction, awaitedCall }) + for (const argument of node.arguments ?? []) { + if (functionTypes.has(argument?.type)) { + if (awaitedCall) visit(argument, { executeFunction: true, awaitedCall: true }) + } else { + visit(argument) + } + } + return + } + for (const [child] of childNodes(node)) visit(child) + } + + visit(program) + return sources +} + function semanticBindingGraph(program, path, analysisContext = {}) { const rootScope = createScope(null, 'program', program) const nodeScopes = new WeakMap() @@ -1249,13 +1339,6 @@ function semanticBindingGraph(program, path, analysisContext = {}) { for (const [child, childKey] of childNodes(node)) build(child, scope, node, childKey) return } - if ( - node.type === 'ImportExpression' - && node.source - && nearestFunctionScope(scope).kind === 'program' - ) { - moduleEvaluationEdges.push(node.source) - } if ( node.type === 'ExportAllDeclaration' && node.exportKind !== 'type' @@ -1278,6 +1361,7 @@ function semanticBindingGraph(program, path, analysisContext = {}) { for (const [child, childKey] of childNodes(node)) build(child, scope, node, childKey) } build(program, rootScope) + moduleEvaluationEdges.push(...moduleEvaluationDynamicImportSources(program, path)) function lookup(node) { let scope = nodeScopes.get(node) @@ -1433,9 +1517,55 @@ function semanticBindingGraph(program, path, analysisContext = {}) { return false } + function staticMemberMayInvokeGetter(node, key, seen = new Set()) { + const value = unwrapExpression(node) + if (!value || typeof value !== 'object') return false + if (value.type === 'Identifier') { + const binding = lookup(value) + if (!binding || seen.has(binding)) return false + const initializer = directConstInitializer(binding) + return initializer + ? staticMemberMayInvokeGetter(initializer, key, new Set([...seen, binding])) + : false + } + if (value.type === 'ConditionalExpression') { + return staticMemberMayInvokeGetter(value.consequent, key, seen) + || staticMemberMayInvokeGetter(value.alternate, key, seen) + } + if (value.type === 'LogicalExpression') { + return staticMemberMayInvokeGetter(value.left, key, seen) + || staticMemberMayInvokeGetter(value.right, key, seen) + } + if (value.type === 'SequenceExpression') { + return staticMemberMayInvokeGetter(value.expressions.at(-1), key, seen) + } + if (value.type === 'MemberExpression') { + const nestedName = memberName(value) + if (nestedName === null) return true + return staticMemberExpressions(value.object, nestedName, seen).some((member) => ( + staticMemberMayInvokeGetter(member, key, seen) + )) + } + if (value.type !== 'ObjectExpression') return false + return value.properties.some((property) => { + if (property.type === 'SpreadElement') { + return staticMemberMayInvokeGetter(property.argument, key, seen) + } + if ( + property.type !== 'Property' + || staticPropertyNameFromNode(property) !== String(key) + ) return false + return property.kind === 'get' + }) + } + function hasDynamicInitializer(node) { if (!node || typeof node !== 'object') return false if (node.type === 'CallExpression' || node.type === 'NewExpression') return true + if (node.type === 'MemberExpression') { + const name = memberName(node) + return name === null || staticMemberMayInvokeGetter(node.object, name) + } if (node.type === 'ObjectExpression') { return node.properties.some((property) => ( property.type === 'Property' @@ -2801,6 +2931,17 @@ function semanticBindingGraph(program, path, analysisContext = {}) { } } if (node.type === 'AssignmentExpression') { + if ( + isMemberMutationTarget(node.left) + && unboundGlobalContainer(node.right) + ) { + unsupportedRootCalls.push({ + executionScope, + message: 'Unsupported member-stored global container', + mutationNode: node, + semanticNode, + }) + } record(node.left, 'write', { propagateAliases: isMemberMutationTarget(node.left), }) @@ -2881,7 +3022,7 @@ function semanticBindingGraph(program, path, analysisContext = {}) { && !isKnownSynchronousCallbackCall(node) && !(callee.type === 'MemberExpression' && globalOwnerRoot(callee.object)) if ([...calleeAliases].some((binding) => binding.dynamicIdentity)) { - unresolvedMutations.push({ + unsupportedRootCalls.push({ executionScope, message: 'Unsupported dynamic call target', mutationNode: node, @@ -3413,29 +3554,59 @@ function nestedYamlValue(entry, name, sectionIndent) { return null } -function runtimePackageJsonProjection(source) { +function selectedRuntimePackageNames(runtimePackages, availableNames) { + const selected = runtimePackages === undefined + ? [...availableNames].filter((name) => TRUSTED_RUNTIME_PACKAGE_NAMES.has(name)) + : [...runtimePackages] + for (const name of selected) { + if (!TRUSTED_RUNTIME_PACKAGE_NAMES.has(name)) { + throw new Error(`Unsupported trusted runtime package ${name}`) + } + } + return [...new Set(selected)].sort() +} + +function runtimePackageJsonProjection(source, runtimePackages) { const parsed = JSON.parse(source) - return JSON.stringify(Object.fromEntries( - [...TRUSTED_RUNTIME_PACKAGE_NAMES] - .sort() - .filter((name) => Object.hasOwn(parsed.dependencies ?? {}, name)) - .map((name) => [name, parsed.dependencies[name]]), - )) + const dependencies = parsed.dependencies ?? {} + const selected = selectedRuntimePackageNames(runtimePackages, Object.keys(dependencies)) + for (const name of selected) { + if (!Object.hasOwn(dependencies, name)) { + throw new Error(`Missing runtime dependency ${name}`) + } + } + return JSON.stringify(Object.fromEntries(selected.map((name) => [name, dependencies[name]]))) } -function runtimeLockProjection(source) { +function runtimeLockProjection(source, runtimePackages) { const lines = source.replaceAll('\r\n', '\n').replaceAll('\r', '\n').split('\n') const importerEntries = yamlSectionEntries(lines, 'importers', 0, 2) const rootImporter = importerEntries.filter((entry) => entry.key === '.').at(-1) + const rootDependencies = rootImporter + ? yamlSectionEntries(rootImporter.lines, 'dependencies', 4, 6) + : [] + const selectedNames = selectedRuntimePackageNames( + runtimePackages, + rootDependencies.map((dependency) => dependency.key), + ) + if (selectedNames.length > 0 && !rootImporter) { + throw new Error('Missing root lock importer') + } const roots = new Map() if (rootImporter) { - for (const dependency of yamlSectionEntries(rootImporter.lines, 'dependencies', 4, 6)) { - if (!TRUSTED_RUNTIME_PACKAGE_NAMES.has(dependency.key)) continue + for (const dependency of rootDependencies) { + if (!selectedNames.includes(dependency.key)) continue const specifier = nestedYamlValue(dependency, 'specifier', 6) const version = nestedYamlValue(dependency, 'version', 6) - if (version) roots.set(dependency.key, { specifier, version }) + if (!specifier || !version) { + throw new Error(`Missing locked root ${dependency.key}`) + } + roots.set(dependency.key, { specifier, version }) } } + for (const name of selectedNames) { + if (!roots.has(name)) throw new Error(`Missing locked root ${name}`) + } const packageEntries = new Map( yamlSectionEntries(lines, 'packages', 0, 2).map((entry) => [entry.key, entry]), @@ -3452,21 +3623,25 @@ function runtimeLockProjection(source) { if (!key || visited.has(key) || /^(?:link|workspace):/u.test(key)) continue visited.add(key) const snapshot = snapshotEntries.get(key) - if (snapshot) { - selectedSnapshots.set(key, snapshot.lines.join('\n')) - for (const section of ['dependencies', 'optionalDependencies']) { - for (const dependency of yamlSectionEntries(snapshot.lines, section, 4, 6)) { - const version = dependency.value - || nestedYamlValue(dependency, 'version', 6) - if (version && !/^(?:link|workspace):/u.test(version)) { - pending.push(`${dependency.key}@${version}`) - } + if (!snapshot) throw new Error(`Missing locked snapshot ${key}`) + selectedSnapshots.set(key, snapshot.lines.join('\n')) + for (const section of ['dependencies', 'optionalDependencies']) { + for (const dependency of yamlSectionEntries(snapshot.lines, section, 4, 6)) { + const version = dependency.value + || nestedYamlValue(dependency, 'version', 6) + if (version && !/^(?:link|workspace):/u.test(version)) { + pending.push(`${dependency.key}@${version}`) } } } const packageKey = key.replace(/\(.+$/u, '') const packageEntry = packageEntries.get(key) ?? packageEntries.get(packageKey) - if (packageEntry) selectedPackages.set(packageEntry.key, packageEntry.lines.join('\n')) + if (!packageEntry) throw new Error(`Missing locked package ${packageKey}`) + const packageSource = packageEntry.lines.join('\n') + if (!/\bintegrity:\s*[^\s}]+/u.test(packageSource)) { + throw new Error(`Missing registry integrity ${packageEntry.key}`) + } + selectedPackages.set(packageEntry.key, packageSource) } const sortedObject = (entries) => Object.fromEntries([...entries].sort(([left], [right]) => ( @@ -3479,9 +3654,13 @@ function runtimeLockProjection(source) { }) } -export function semanticSourceForDigest(path, source) { - if (path === 'site/package.json') return runtimePackageJsonProjection(source) - if (path === 'site/pnpm-lock.yaml') return runtimeLockProjection(source) +export function semanticSourceForDigest(path, source, options = {}) { + if (path === 'site/package.json') { + return runtimePackageJsonProjection(source, options.runtimePackages) + } + if (path === 'site/pnpm-lock.yaml') { + return runtimeLockProjection(source, options.runtimePackages) + } const rootName = SEMANTIC_ELEMENT_ROOTS.get(path) if (!PARSED_EXTENSIONS.has(extname(path))) return source const { program, errors } = parseSync(path, source) @@ -3645,7 +3824,7 @@ export function staticImportSpecifiersForSource(path, source) { if (errors.length > 0) { throw new Error(`Unable to parse semantic dependency ${path}: ${errors[0].message}`) } - return program.body.flatMap((statement) => { + const staticSpecifiers = program.body.flatMap((statement) => { if (typeof statement.source?.value !== 'string') return [] if (statement.type === 'ImportDeclaration') { if (statement.importKind === 'type') return [] @@ -3668,6 +3847,10 @@ export function staticImportSpecifiersForSource(path, source) { } return [] }) + return [ + ...staticSpecifiers, + ...moduleEvaluationDynamicImportSources(program, path).map((sourceNode) => sourceNode.value), + ] } function staticImportSpecifiers(path) { @@ -3675,7 +3858,17 @@ function staticImportSpecifiers(path) { return staticImportSpecifiersForSource(path, readFileSync(path, 'utf8')) } -export function semanticRouteFiles(pathname) { +function trustedRuntimePackageName(specifier) { + if (!TRUSTED_RUNTIME_PACKAGES.has(specifier)) return null + return specifier.startsWith('@') + ? specifier.split('/').slice(0, 2).join('/') + : specifier.split('/')[0] +} + +const semanticRouteEvidenceCache = new Map() + +function semanticRouteEvidence(pathname) { + if (semanticRouteEvidenceCache.has(pathname)) return semanticRouteEvidenceCache.get(pathname) const routeModules = routeModuleIds(pathname) const routePages = new Set(ROUTE_PAGE_MODULES.map((moduleId) => `site/${moduleId}`)) const allowedPages = new Set( @@ -3684,14 +3877,13 @@ export function semanticRouteFiles(pathname) { .map((moduleId) => `site/${moduleId}`), ) const pending = [ - resolve(projectRoot, 'site/package.json'), - resolve(projectRoot, 'site/pnpm-lock.yaml'), resolve(projectRoot, 'site/src/entry-server.tsx'), resolve(projectRoot, 'site/src/lib/pageMeta.ts'), resolve(projectRoot, 'site/src/lib/seoHead.ts'), ...routeModules.map((moduleId) => resolve(projectRoot, 'site', moduleId)), ] const visited = new Set() + const runtimePackages = new Set() while (pending.length > 0) { const path = pending.pop() @@ -3707,10 +3899,32 @@ export function semanticRouteFiles(pathname) { visited.add(relativePath) for (const specifier of staticImportSpecifiers(path)) { + const runtimePackage = trustedRuntimePackageName(specifier) + if (runtimePackage) { + runtimePackages.add(runtimePackage) + continue + } const dependency = resolveStaticImport(path, specifier) if (dependency) pending.push(dependency) } } - return [...visited].sort() + if (runtimePackages.size > 0) { + visited.add('site/package.json') + visited.add('site/pnpm-lock.yaml') + } + const evidence = { + files: [...visited].sort(), + runtimePackages: [...runtimePackages].sort(), + } + semanticRouteEvidenceCache.set(pathname, evidence) + return evidence +} + +export function semanticRouteFiles(pathname) { + return [...semanticRouteEvidence(pathname).files] +} + +export function semanticRuntimePackageNamesForRoute(pathname) { + return [...semanticRouteEvidence(pathname).runtimePackages] } diff --git a/site/scripts/semantic-source-graph.test.mjs b/site/scripts/semantic-source-graph.test.mjs index 87263ac..a139d56 100644 --- a/site/scripts/semantic-source-graph.test.mjs +++ b/site/scripts/semantic-source-graph.test.mjs @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url' import { PUBLIC_PATHS } from '../src/lib/publicRoutes.ts' import { + semanticRuntimePackageNamesForRoute, semanticRouteFiles, semanticSourceForDigest, resolveStaticImport, @@ -568,7 +569,7 @@ test('mixed-module closure tracks every supported mutation of selected bindings' const dynamicImportedGetter = `import { state, prepare } from './store'\nconst controller = { get run() { return prepare } }\nfunction AppContent() { controller.run(); return {state.value} }` assert.throws( () => semanticSourceForDigest('site/src/app/AppContent.tsx', dynamicImportedGetter), - /Unsupported ambiguous mutation of controller/, + /Unsupported (?:dynamic call target|ambiguous mutation of controller)/, ) const customNamedMutator = `const state = { value: '正文' }\nconst mutator = { set(target, value) { target.value = value } }\nfunction AppContent() { mutator.set(state, '正文一'); return {state.value} }` @@ -987,6 +988,7 @@ test('secondary review boundaries preserve SSR precision and fail closed', () => `import '../../scripts/fixtures/semantic-patch-timer.mjs'\nfunction AppContent() { setTimeout(() => track('同步一'), 0); return 正文 }`, `import { unused } from '../../scripts/fixtures/semantic-patch-react.mjs'\nimport { useEffect } from 'react'\nfunction AppContent() { useEffect(() => track('同步一'), []); return 正文 }`, `await import('opaque-patcher')\nfunction AppContent() { setTimeout(() => track('同步一'), 0); return 正文 }`, + `await Promise.resolve().then(() => import('../../scripts/fixtures/semantic-patch-timer.mjs'))\nfunction AppContent() { setTimeout(() => track('同步一'), 0); return 正文 }`, ]) { assert.throws( () => semanticSourceForDigest('site/src/app/AppContent.tsx', importedPatch), @@ -1013,6 +1015,7 @@ test('secondary review boundaries preserve SSR precision and fail closed', () => `let label = '正文'\nconst [g] = [globalThis]\ng.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, `let label = '正文'\nconst { g } = { g: globalThis }\ng.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, `let label = '正文'\nconst holder = { nested: { g: globalThis } }\nholder.nested.g.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, + `let label = '正文'\nconst holder = { nested: { g: null } }\nholder.nested.g = globalThis\nholder.nested.g.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, `let label = '正文'\nconst P = (() => Promise)()\nP.prototype.then = callback => { callback(); return Promise.resolve() }\nfunction AppContent() { Promise.resolve().then(() => { label = '变化' }); return {label} }`, `let label = '正文'\ngetGlobal().setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, ]) { @@ -1033,6 +1036,13 @@ test('secondary review boundaries preserve SSR precision and fail closed', () => /Unsupported external synchronous call/, ) } + assert.throws( + () => semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + `import { opaque } from 'opaque-patcher'\nconst choices = { get selected() { return opaque } }\nconst selected = choices.selected\nfunction AppContent() { return {selected()} }`, + ), + /Unsupported dynamic call target/, + ) for (const externalStandalone of [ `import { mutateOne as mutate, state } from 'pkg'\nmutate()\nfunction AppContent() { return {state.value} }`, `mutateOne()\nfunction AppContent() { return {globalThis.state.value} }`, @@ -1166,6 +1176,10 @@ test('public config rejects alias mutation, pattern writes, and patched freeze', `const holder = { nested: { g: globalThis } }\nholder.nested.g.Object = { freeze(value) { value.name = '运行时'; return value } }`, `name: '初始'`, ), + publicConfigSource( + `const holder = { nested: { g: null } }\nholder.nested.g = globalThis\nholder.nested.g.Object = { freeze(value) { value.name = '运行时'; return value } }`, + `name: '初始'`, + ), publicConfigSource( `const O = (() => Object)()\nO.freeze = value => { value.name = '运行时'; return value }`, `name: '初始'`, @@ -1189,6 +1203,47 @@ test('static dependency discovery excludes specifier-level type-only edges', () ) }) +test('static dependency discovery follows literal top-level dynamic imports and rejects dynamic targets', () => { + assert.deepEqual( + staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `await import('./runtime')\nfunction later() { return import('./client-only') }`, + ), + ['./runtime'], + ) + assert.throws( + () => staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `const target = './runtime'\nawait import(target)`, + ), + /Unsupported dynamic module-evaluation import/, + ) + assert.throws( + () => semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + `const target = './runtime'\nawait import(target)\nfunction AppContent() { return 正文 }`, + ), + /Unsupported dynamic module-evaluation import/, + ) + assert.deepEqual( + staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `await Promise.resolve().then(() => import('./runtime'))\nif (false) import('opaque-one')`, + ), + ['./runtime'], + ) + assert.equal( + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + `if (false) import('opaque-one')\nfunction AppContent() { return 正文 }`, + ), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + `if (false) import('opaque-two')\nfunction AppContent() { return 正文 }`, + ), + ) +}) + test('static dependency discovery strips Vite query and hash suffixes', () => { const importer = fileURLToPath( new URL('./fixtures/semantic-query-importer.ts', import.meta.url), @@ -1210,7 +1265,7 @@ test('static dependency discovery strips Vite query and hash suffixes', () => { test('trusted runtime packages are bound to their declared and locked resolutions', () => { const packageSource = JSON.stringify({ - dependencies: { react: '^19.2.8' }, + dependencies: { gsap: '^3.15.0', react: '^19.2.8' }, devDependencies: { vitest: '^4.1.11' }, }) assert.notEqual( @@ -1227,6 +1282,26 @@ test('trusted runtime packages are bound to their declared and locked resolution packageSource.replace('^4.1.11', '^5.0.0'), ), ) + assert.equal( + semanticSourceForDigest( + 'site/package.json', + packageSource, + { runtimePackages: new Set(['react']) }, + ), + semanticSourceForDigest( + 'site/package.json', + packageSource.replace('^3.15.0', '^4.0.0'), + { runtimePackages: new Set(['react']) }, + ), + ) + assert.throws( + () => semanticSourceForDigest( + 'site/package.json', + packageSource, + { runtimePackages: new Set(['lucide-react']) }, + ), + /Missing runtime dependency/, + ) const lockSource = `lockfileVersion: '9.0' importers: @@ -1267,6 +1342,44 @@ snapshots: lockSource.replace('sha512-test-one', 'sha512-test-two'), ), ) + assert.throws( + () => semanticSourceForDigest( + 'site/pnpm-lock.yaml', + lockSource.replace(/snapshots:[\s\S]*/u, 'snapshots:'), + ), + /Missing locked snapshot/, + ) + assert.throws( + () => semanticSourceForDigest( + 'site/pnpm-lock.yaml', + lockSource.replace( + ' loose-envify@1.4.0:\n resolution: {integrity: sha512-runtime-one}\n', + '', + ), + ), + /Missing locked package/, + ) + assert.throws( + () => semanticSourceForDigest( + 'site/pnpm-lock.yaml', + lockSource.replace('resolution: {integrity: sha512-react-one}', 'resolution: {}'), + ), + /Missing registry integrity/, + ) + assert.throws( + () => semanticSourceForDigest( + 'site/pnpm-lock.yaml', + lockSource, + { runtimePackages: new Set(['gsap']) }, + ), + /Missing locked root/, + ) + + const homePackages = new Set(semanticRuntimePackageNamesForRoute('/')) + assert.ok(homePackages.has('react')) + assert.equal(homePackages.has('gsap'), false) + assert.equal(homePackages.has('katex'), false) + assert.equal(homePackages.has('shiki'), false) }) test('switch discriminants resolve outside the switch lexical scope', () => { diff --git a/site/scripts/seo-contract.test.mjs b/site/scripts/seo-contract.test.mjs index fe0696d..e92a667 100644 --- a/site/scripts/seo-contract.test.mjs +++ b/site/scripts/seo-contract.test.mjs @@ -232,7 +232,7 @@ test('discovery files expose the 47 approved URLs and real summaries', async () assert.match(lastModified, /--format=%cI/) assert.match(lastModified, /normalizeContentForDigest/) assert.match(lastModified, /semanticRouteFiles/) - assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 15) + assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 16) assert.equal(Object.keys(ROUTE_CONTENT_DIGESTS).length, PUBLIC_PATHS.length) assert.ok(Object.values(ROUTE_CONTENT_DIGESTS).every((digest) => /^[0-9a-f]{64}$/.test(digest))) assert.match(publicRoutes, /\.\.\/data\/catalog\.ts/) diff --git a/site/src/data/routeLastModified.ts b/site/src/data/routeLastModified.ts index 1f3f225..8c7af3b 100644 --- a/site/src/data/routeLastModified.ts +++ b/site/src/data/routeLastModified.ts @@ -1,5 +1,5 @@ // 由 scripts/generate-seo.mjs 从源码时间证据生成,请勿手改。 -export const ROUTE_CONTENT_DIGEST_VERSION = 15 +export const ROUTE_CONTENT_DIGEST_VERSION = 16 export const ROUTE_LAST_MODIFIED: Readonly> = Object.freeze({ "/": "2026-08-23T21:18:04+08:00", "/part/a": "2026-08-23T21:18:04+08:00", @@ -50,51 +50,51 @@ export const ROUTE_LAST_MODIFIED: Readonly> = Object.free "/problems": "2026-08-23T21:18:04+08:00" }) export const ROUTE_CONTENT_DIGESTS: Readonly> = Object.freeze({ - "/": "0262386989c64bf668f35392b0a8ae373673a7bf285e4e7b79b8d2a1eaee7734", - "/part/a": "7e32ed21a8e3f3e31c4bbdda8290934cc65ee19b0f2d165883b1a7c115a912df", - "/part/b": "f7cdc6d406a4a72b4170d02e634dac8c9941ee4a6301323d7c702f3a06742808", - "/part/c": "3a4ac525f95353e75700d547db56c6c68e39db040cadcc5be87151b8add6d091", - "/part/d": "4911ced68fbb17921d5f33e6100d34556b8ec5169c15d99e25eb80c903adb183", - "/part/e": "394e72181e560ef1a50e35910e64ce2af3724ee86569356ae982aa6b6c78b0db", - "/part/f": "9bef3b7a4ac3f2881f88b19ce244b09fbf39e7bee2106d05479f213532748cd6", - "/part/g": "a5584c6203acb98a0145c91247d9dbdf512dd83d30df22c595db058548b32979", - "/part/a/01": "5e4a14f54373eb150a92952feea9914a5942db349f1f6275136d03d5c946d034", - "/part/a/complete": "ba064c4bb5551291ddae70ec3eb4d709ad8bd97484807d8be986d6ecd7cfe207", - "/part/a/multiple": "b0cc8e73aef0ce64b275c27c9142b9f3f89972cc7a903192d1a3c1fc8a0e4eaf", - "/part/a/group": "36b381ee27af2c4b4574a7aa81638c4ebc55ae45a50a274e3dd4e01e60d85d1c", - "/part/a/mixed": "b9f1bfe3ceb781b9733a4573049bc3332d5646a264d403efed85705875fd04ba", - "/part/a/cost2d": "dcf5225c49f33d0bf5a4df5f42b679ad67e703db6ec51dc276fe81f093a8f5ba", - "/part/a/dep": "495b2a06c5bb1bb11b93a7834b745a13033b2b85dc67f1fb2b72d885cdd151f0", - "/part/a/variant": "fa04a97c6be983af9ca3862f9d26463c8f072fb9e8df156b0732956bfcea6795", - "/part/a/fractional": "f16d12385aee2679547cca52778252ea73302e7912b943393f5c7e735ec4aaeb", - "/part/b/path": "db72987608a24fbe00fde5a4c51667ede4a73a251ed8fa6cf026760193ef075b", - "/part/b/maxseg": "f7f78de0c37e21032f6554e8d738cf4a36569898faad7731fed7f9c5a0500047", - "/part/b/lis": "d9ce7e560d1e0609396ff47d4c451bbfa4101f2b007592274be6f260fea5e506", - "/part/b/lcs": "dfeaa3540133568b59e03216de83d735e64897cde2a78894e12a865b06409c08", - "/part/b/edit": "973d8a542cc56257c82c21b67c0af4f62aa7cb197e883756edc195a2b805da0b", - "/part/b/fsm": "9bedaefc9589ab8db056f3965ed97bf442703f632c1ee67f375477813ce31db2", - "/part/b/count": "f83c72e45d1a2a20e30936c928e7d98d978e326a0ff421ea3a4812290edd493e", - "/part/c/stone": "945d8d2415882ac265fdd41c1a2d9ba72238f2f5bf658212a81269cd19f44386", - "/part/c/ring": "5426eb28e856a657c1f1c292b626ea2f08d553653bce54ad6fd0497f7de05b26", - "/part/c/palindrome": "05f17a6f630af4d25121d05ff04fea9b705458912c25268209bd78c2d51c1f3b", - "/part/c/tree": "404ab9ee3643e040b78647645079ef50358e1821907376c98be159ad9705fa0a", - "/part/c/merge": "42d2411539aaf771ca6a42800feeaa4c798519cafc6fca82dd3d1351069615d7", - "/part/d/grid": "af335f297ab394eae14857253977492bdbe3d1d1a2e2310475ffafc90cb445ea", - "/part/d/matpow": "8fbea5ddefb28692e1dad2c25a88a4848b4569e8fec544c7602e8d8cbb1449b7", - "/part/e/basic": "d47100992ea71202929df23e8ce77216c7db2d87332683bde4ad467990ad16b3", - "/part/e/distsum": "489640867984bf163acce29dbe25b34618581fdd47e61d44cbba687817fd00ec", - "/part/e/inout": "c085b95ccae50a2beeccc8664dae56f2bbb4e8c2cea0e68c5e89dafabb4cc7d3", - "/part/e/center": "6a036b445da01483a48ef9841e299c55b05b2e3812a02941f05c78d6da2626db", - "/part/f/select": "531c43315808c07790a06bc53e918a77e635786801a3192c097112df4a4b68b1", - "/part/f/knapsack": "c855fb15e73e754b93e19891257fdd16fa85f077a5ed452416c02fdd3b1a191f", - "/part/f/diameter": "de4945c3fed9405b5d73d2ab215bd51193f623c8c9bbac9eee5f76f64625dbc1", - "/part/f/cover": "0c849ae62a27c6f69468b83a09bd80677e792a5f8599e437c06be70ea555dbf4", - "/part/f/count": "e97d686c1cbaba8da32e6a7a49ca98951da955a03ec447c9ca180dfb28ef544a", - "/part/g/board": "f11248cfac8541986332d4d34cebc3c48ad2ea707d36c1563d4d07a553160415", - "/part/g/tsp": "4f7e4c866c7830d31b3ca666171a7bf4ae637333ceb8ab384dbc8f80454f5af0", - "/part/g/cover": "37584c79cfdde83ab0b745cbfcddd781448c81a9d49293333d6f1e9fca62a072", - "/part/g/subset": "a7c0df25c82c373fc41a2384ba56962c0e3e1abce4d4f0b730bce916c646318e", - "/part/g/plug": "e9410a2ae41780327cc3e5056a27e1f0dd5b24be1f6bf29545ff125f1c4efa9e", - "/method": "38df14db7f5e2c43f3fc0ab1637c813e26485af1fba11c210ac25c9189a6d594", - "/problems": "27c16b46e3645c2aef86acd79678100f5260cc3463024b4eb48e65b34892169c" + "/": "682f5d25703320b6c97aca5e9e1d4f292e67325b84d8fcc8b8888b2b6d0fb34c", + "/part/a": "8979f4fc256fcb9bbbc12525e40f8ff311606864251643facdae2f9d6b225de9", + "/part/b": "54dc9df1e934a21f50603d3c4ef130b40c2597f7f5bbb55dfc976e817528d0dd", + "/part/c": "cbe18d232491d3d458b1b3450e1900045792e981311df5e3e7b18c9eb79e7b61", + "/part/d": "d8c193a454c907271599c0dabf40c57a0e55b9a111e2a7feaad0f70b228a4da0", + "/part/e": "91fd27fe4c911cc354896fc38ad5e2b46b7f74940dbb1ab9e6898f16627f034d", + "/part/f": "4621b866bcb4edee102a5950975da5b2c17e38b13726a14f177e9788db525568", + "/part/g": "fe56dd93b6eab109e1afd480ae3813e899537c14d30ca886d1a5591a8a20b9b7", + "/part/a/01": "b8496f8c8b1d9cec3382c315ca126bf805ce5a06d107b30900842b2b916dcd1a", + "/part/a/complete": "83d566a9f8cde651812bd2f32f294fc1d01cd4a61312ee1f7fa4acb23698a390", + "/part/a/multiple": "3ccdd7583bf1dd44a5b9d669e1f6d5177a8d7df0846808791a585b1db57fcbc7", + "/part/a/group": "49e476b8d8ec720f11ccf15110b4a76c8e0fa8c5d5142e973ecb5223cbba62f3", + "/part/a/mixed": "d2f5e4571aa9c5a77bde069b4af4bd98a01c4cc2a3446a353131e561f329ff0b", + "/part/a/cost2d": "711448bafc22149bf4da25d8153818ff2cd6e2e46f9ff3ecb35e2e482d8f2d1e", + "/part/a/dep": "cdfde259ee224873dbe183fee4aeaad9fa7d04caeb7f86b58058ddf86ad2ec7b", + "/part/a/variant": "dad5d0e8cfe609667b7adf0d2826738251f837c2113d9b010c9c3a2098326f37", + "/part/a/fractional": "47593481cb6fb3473e25389ff0431e3304bfdce635d1f7af5a898cc2932a86bd", + "/part/b/path": "9dc35dc7c32e6923adb250328f2e729d428940f10ce706fbb53766756873e87e", + "/part/b/maxseg": "d790495204ed878091a86585d8d17934fbc481bd07e14e4978b8c081cee55625", + "/part/b/lis": "3123ed2974e91b3d072dae4efce2ea76deea5083ade8c5acaf66cc23a6469b6c", + "/part/b/lcs": "7fbe92c3652f8217eee2d450e79fa8b5a214061cc96ead2a64e500204dff6256", + "/part/b/edit": "69acb8a225fcec35a28a1be7ded79f618a93189d66ab01271ce3aad5fde9f191", + "/part/b/fsm": "4f5ef5cf988e8f936b8b589c34e3270edfd3861c61f6139e121660b097b93cce", + "/part/b/count": "376d37279feefc80b8a98c28ea116417ac5c87ab7ddf684aa32182689bd75f36", + "/part/c/stone": "8b8dc1f476a49b7786aab2d5e7a6c26a3af46330f6f039b2520ba6c92e6c75a5", + "/part/c/ring": "074c46c6ee38769693c61784e5a5474f67c3ff48227150c36b19b6471390dc0d", + "/part/c/palindrome": "bd3494df1f82c5c205237be664c62d378bc5895dc7fc95d61ca249fd68d7a187", + "/part/c/tree": "57261999473bfaa41f216ba165fb272a9b66cd4c85ee30b4301018b09e8e1278", + "/part/c/merge": "77c2f431bab90ae72aa768e23506a256e1b67037ee9561c9d29eef0fb197614a", + "/part/d/grid": "ad743c975a9dc29a0229c01284b999b1be764924e0f71ef75260d56081205777", + "/part/d/matpow": "18ba5812015e71a9e80fc6561b054e8c6633d6015e11666135071358988863db", + "/part/e/basic": "2355ad8d5172d0d83f4add44da597e8e1d335d771bd58f3fd42032a377b2c2db", + "/part/e/distsum": "4fb7a28b39fce8d1959ed80f99fc5a434b7dc2c0e68e1f65fec08987922444d7", + "/part/e/inout": "78320ba4e85fb3a6ca50b0aad8ba90f91c2bac3ab1f13fb30f5c4929dc887cd5", + "/part/e/center": "a972d922db64fa2838f052f10039ed317ca1f6d4c662f6bd81cb62042969e161", + "/part/f/select": "33f5cd5c82b1a72819447a2a85cfa1333d63cd66077247dfea9505d95dc4de82", + "/part/f/knapsack": "918040420e0932198f5da649863a2de17cbd2a0f426e98a819f6f7e1c3d4ecab", + "/part/f/diameter": "94211f53aaa166b07cbaa500fe595420c4155e2fe1b30c41399d174fd3ff1880", + "/part/f/cover": "1ea33061cff0d26593a97dedbdd3777a441f20d52548da87925802f24a115718", + "/part/f/count": "0001c713ef65adacbe29fb1a07bd4137ec6cf6f38d7f3c619da58b13b25d62ff", + "/part/g/board": "bd3679b5ea20dd48e55b9036567d77bf3b56b1e0b1d63b59e6f6ce4071d9a6b9", + "/part/g/tsp": "acff8b9b784531e48f682f400988d3f733ee7a5f22e6aa0157176110f526009c", + "/part/g/cover": "bdae99d8c98b279a169c5f33fef66ae51e5af7b69bb5903d8b0c64ce730549bf", + "/part/g/subset": "4c7ec6066f60a78e13c1da45e4aeebe9731c4badf63499acc1bc8763793f17d8", + "/part/g/plug": "86631b296d9c3656d1c0e4576677a87a4e7b8a1d9bb5e1cf76cfd878c1385d09", + "/method": "5d4e67a2d6211d4ada30e011dedabf1df917b9c2199fd63108bb87ee875e4180", + "/problems": "03671fba244a78f18399baada2f18c7fa6c68da60a714bf61e07c44e336b52d0" }) diff --git a/site/worker/content-negotiation.js b/site/worker/content-negotiation.js index c526948..ca4bbb0 100644 --- a/site/worker/content-negotiation.js +++ b/site/worker/content-negotiation.js @@ -31,6 +31,44 @@ function splitDelimited(value, delimiter) { return parts } +function splitAcceptMembers(value) { + const parts = [] + let start = 0 + let quoted = false + let escaped = false + const recoveryCommas = [] + for (let index = 0; index < value.length; index += 1) { + const character = value[index] + if (escaped) { + escaped = false + continue + } + if (quoted && character === '\\') { + escaped = true + continue + } + if (character === '"') { + quoted = !quoted + continue + } + if (character !== ',') continue + if (!quoted) { + parts.push(value.slice(start, index)) + start = index + 1 + recoveryCommas.length = 0 + } else if (/^\s*(?:[!#$%&'*+.^_`|~0-9a-z-]+|\*)\//iu.test(value.slice(index + 1))) { + recoveryCommas.push(index) + } + } + if ((quoted || escaped) && recoveryCommas.length > 0) { + const recovery = recoveryCommas[0] + parts.push(value.slice(start, recovery)) + return [...parts, ...splitAcceptMembers(value.slice(recovery + 1))] + } + parts.push(value.slice(start)) + return parts +} + function validParameterValue(value) { if (TOKEN_VALUE.test(value)) return true if (value.length < 2 || value[0] !== '"' || value.at(-1) !== '"') return false @@ -48,6 +86,16 @@ function validParameterValue(value) { return true } +function parameterValue(value) { + if (TOKEN_VALUE.test(value)) return value.toLowerCase() + let decoded = '' + for (let index = 1; index < value.length - 1; index += 1) { + if (value[index] === '\\') index += 1 + decoded += value[index] + } + return decoded.toLowerCase() +} + function parseQuality(value) { if (value === undefined) return 1 const normalized = value.trim() @@ -66,6 +114,8 @@ function parseRange(member) { if (type === '*' && subtype !== '*') return null let quality + let qualitySeen = false + const mediaParameters = new Map() for (const parameter of parameters) { if (!parameter) return null const separator = parameter.indexOf('=') @@ -73,10 +123,16 @@ function parseRange(member) { const name = parameter.slice(0, separator).trim().toLowerCase() const value = parameter.slice(separator + 1).trim() if (!TOKEN_VALUE.test(name) || !validParameterValue(value)) return null - if (name !== 'q') continue - if (quality !== undefined) return null - quality = parseQuality(value) - if (quality === null) return null + if (name === 'q') { + if (qualitySeen) return null + qualitySeen = true + quality = parseQuality(value) + if (quality === null) return null + continue + } + if (qualitySeen) continue + if (mediaParameters.has(name)) return null + mediaParameters.set(name, parameterValue(value)) } return { @@ -84,40 +140,56 @@ function parseRange(member) { subtype, quality: quality ?? 1, specificity: type === '*' ? 0 : subtype === '*' ? 1 : 2, + mediaParameters, } } -function effectiveMatch(ranges, subtype) { +function effectiveMatch(ranges, subtype, representationParameters) { const matches = ranges.filter((range) => ( (range.type === '*' || range.type === 'text') && (range.subtype === '*' || range.subtype === subtype) + && [...range.mediaParameters].every(([name, value]) => ( + representationParameters.get(name) === value + )) )) if (matches.length === 0) return null const specificity = Math.max(...matches.map((range) => range.specificity)) const mostSpecific = matches.filter((range) => range.specificity === specificity) + const parameterCount = Math.max(...mostSpecific.map((range) => range.mediaParameters.size)) + const mostParameterized = mostSpecific.filter((range) => ( + range.mediaParameters.size === parameterCount + )) return { - quality: Math.max(...mostSpecific.map((range) => range.quality)), + quality: Math.max(...mostParameterized.map((range) => range.quality)), specificity, + parameterCount, } } export function negotiateRepresentation(accept) { if (accept === null || accept === undefined) return 'html' - const members = splitDelimited(accept, ',') + const members = splitAcceptMembers(accept) const ranges = (members ?? []) .map(parseRange) .filter((range) => range !== null) const candidates = [ - { representation: 'html', ...effectiveMatch(ranges, 'html') }, - { representation: 'markdown', ...effectiveMatch(ranges, 'markdown') }, + { + representation: 'html', + ...effectiveMatch(ranges, 'html', new Map([['charset', 'utf-8']])), + }, + { + representation: 'markdown', + ...effectiveMatch(ranges, 'markdown', new Map([['charset', 'utf-8']])), + }, ].filter((candidate) => candidate.quality > 0) if (candidates.length === 0) return null candidates.sort((left, right) => ( right.quality - left.quality || right.specificity - left.specificity + || right.parameterCount - left.parameterCount || (left.representation === 'html' ? -1 : 1) )) return candidates[0].representation From 01c5cf0c2ed2d2a2dd2dc9a9ff076c572537e39e Mon Sep 17 00:00:00 2001 From: Shanire Date: Thu, 27 Aug 2026 10:15:47 +0800 Subject: [PATCH 19/23] fix(web): close semantic review gaps Signed-off-by: Shanire --- docs/engineering/architecture.md | 4 +- docs/operations/verification.md | 6 +- site/scripts/content-negotiation.test.mjs | 6 +- site/scripts/last-modified.mjs | 2 +- site/scripts/semantic-source-graph.mjs | 426 ++++++++++++++++++-- site/scripts/semantic-source-graph.test.mjs | 144 ++++++- site/scripts/seo-contract.test.mjs | 2 +- site/scripts/worker-routing.test.mjs | 5 + site/src/data/routeLastModified.ts | 78 ++-- site/worker/content-negotiation.js | 16 +- 10 files changed, 579 insertions(+), 110 deletions(-) diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index e34e688..4763ff3 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -100,7 +100,7 @@ All 29 teaching solver surfaces are Adapters over the algorithm boundary: public Known deep links are physical prerendered HTML assets. Cloudflare Workers Static Assets uses `run_worker_first: true`, so every request reaches `worker.js` before the `ASSETS` binding serves a matching file; this is required because an asset-first match would bypass same-URL content negotiation. The binding uses `404-page` for anything unmatched, returning the themed `404.html` with a real HTTP 404. See root [deploy.md](../../deploy.md) for the platform contract. -For a registered public `GET` or `HEAD`, `worker/content-negotiation.js` selects HTML or Markdown from `Accept`; HTML wins equal preferences and the Worker returns 406 when both supported media types are explicitly unacceptable. Media parameters before `q` participate in representation matching, including the UTF-8 charset, while accept extensions after `q` do not. Invalid media-range members, including empty or unterminated parameters, are ignored rather than accepted; if a broken quoted member precedes another syntactically valid media range, parsing recovers at that later member instead of discarding the whole header. Markdown is fetched only through a distinct internal asset URL, so its ETag and static-asset cache key cannot collide with HTML. Both representations add `Vary: Accept`, alternate links, `nosniff`, and the approved public `Content-Signal`; direct requests to the internal prefix return 404. `HEAD` executes the same matrix and headers but never returns a body. APIs, ordinary assets, the internal specimen, and unknown routes pass through the Worker routing layer without content negotiation. The internal path mapper and public signal live in `publicWebContract.ts`, preventing the generator, discovery files, and Worker from silently diverging. +For a registered public `GET` or `HEAD`, `worker/content-negotiation.js` selects HTML or Markdown from `Accept`; HTML wins equal preferences and the Worker returns 406 when both supported media types are explicitly unacceptable. Under RFC 9110, every non-`q` media parameter participates in representation matching regardless of whether it appears before or after `q`, while `q` is recognized in either position. Empty semicolon parameter slots are permitted, but a bare parameter, whitespace around `=`, an empty value, or an unterminated quoted value makes that media-range member invalid. Invalid members are ignored rather than accepted; if a broken quoted member precedes another syntactically valid media range, parsing recovers at that later member instead of discarding the whole header. Markdown is fetched only through a distinct internal asset URL, so its ETag and static-asset cache key cannot collide with HTML. Both representations add `Vary: Accept`, alternate links, `nosniff`, and the approved public `Content-Signal`; direct requests to the internal prefix return 404. `HEAD` executes the same matrix and headers but never returns a body. APIs, ordinary assets, the internal specimen, and unknown routes pass through the Worker routing layer without content negotiation. The internal path mapper and public signal live in `publicWebContract.ts`, preventing the generator, discovery files, and Worker from silently diverging. # SEO And Accessibility @@ -114,7 +114,7 @@ Every parsed source contribution is reduced to a runtime AST projection, so Type Effect-only callbacks are excluded only when the callee has one exact, stable React-effect origin through named, default, namespace, member, or destructured aliases. Mixed aliases, reassigned hooks, patched React namespaces, and same-named local helpers are never pruned as effects. Runtime import edges remain while declaration- and specifier-level type-only edges, unselected import siblings, object-spread copies, and JSX attribute names do not leak in. The graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Public representation identity is structurally isolated in `config/site.ts`; client endpoints and sidebar-only copy are in modules reachable only through pruned branches. -Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. An unchanged digest preserves the previous value; changed content uses the precise Git commit or working-tree timestamp of a semantically changed projection and must advance. During a digest-schema migration, the current projector is applied to historical sources at the previous `lastmod`: a clean committed semantic edit advances, while an edit confined to a pruned client branch does not. Version 15 first added byte-exact binary assets, semantic CSS queries, nested global-container provenance, top-level dynamic imports, mixed-target validation, and the audited runtime package graph. Version 16 narrows that package evidence to the trusted package roots actually reachable from each route, then requires every selected root and recursively reachable registry package to have an exact importer entry, snapshot, package record, and integrity in `pnpm-lock.yaml`; missing evidence fails closed. Its module-evaluation walk follows literal dynamic imports reached through top-level awaited callbacks, prunes statically dead branches, and rejects non-literal module-evaluation targets. Member-stored global containers and getter-derived dynamic call targets also fail closed. The schema refinement preserves all 47 route times at the dependency-maintenance commit `2026-08-23T21:18:04+08:00`, because it changes evidence precision without adding a later semantic source change. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, type-only noise, incomplete execution/scope/mutation context, lossy UTF-8 decoding of binary assets, or an unpinned or route-unrelated trusted runtime package graph. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. +Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. An unchanged digest preserves the previous value; changed content uses the precise Git commit or working-tree timestamp of a semantically changed projection and must advance. During a digest-schema migration, the current projector is applied to historical sources at the previous `lastmod`: a clean committed semantic edit advances, while an edit confined to a pruned client branch does not. Version 15 first added byte-exact binary assets, semantic CSS queries, nested global-container provenance, top-level dynamic imports, mixed-target validation, and the audited runtime package graph. Version 16 narrowed that package evidence to the trusted package roots actually reachable from each route, then required every selected root and recursively reachable registry package to have an exact importer entry, snapshot, package record, and integrity in `pnpm-lock.yaml`; missing evidence failed closed. Version 17 follows named and aliased module-evaluation callbacks, prunes statically false literal branches and loops, includes selected React `lazy` package imports, and rejects every semantically reachable package outside the audited allowlist. Registry evidence must now place a canonical SHA-256/384/512 SRI value specifically under `resolution.integrity`; empty, malformed, sibling, or unsupported-algorithm values fail closed. Potential global containers stored through conditional, nested-object, factory, or getter values fail closed in both runtime and public-config projections, as do class getter-derived dynamic call targets. This schema refinement preserves all 47 route times at the dependency-maintenance commit `2026-08-23T21:18:04+08:00`, because it changes evidence precision without adding a later semantic source change. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, type-only noise, incomplete execution/scope/mutation context, lossy UTF-8 decoding of binary assets, or an unpinned or route-unrelated trusted runtime package graph. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. # Analytics diff --git a/docs/operations/verification.md b/docs/operations/verification.md index af2cfba..cce046b 100644 --- a/docs/operations/verification.md +++ b/docs/operations/verification.md @@ -44,11 +44,11 @@ Pure TypeScript Modules imported by Node tests must use explicit `.ts` extension # SEO And Accessibility Checks -`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. Version 16 follows each route's real SSR injection chain with a line-ending-neutral runtime-AST digest: type-only syntax is erased without dropping runtime-bearing TypeScript parameter properties or optional-chain behavior; intrinsic event/ref/key props and statically proven spreads erase only deferred callback values while retaining synchronous factories; public `config/site.ts` evidence follows immutable field dependencies and validates global provenance; and mixed shell modules contribute route-rendering JSX plus the lexical closure of all runtime module-evaluation edges, root/program synchronous calls, proven synchronous callbacks, constructors, tagged templates, aliases, and render-phase mutations. Known asynchronous callbacks, including stable aliases and locally private callback-factory results, are erased even when nested inside an otherwise selected synchronous slice, while callback acquisition remains synchronous evidence and exported callbacks remain conservative. Regression probes additionally cover all-candidate mixed call targets, getter-derived targets, global containers stored by later member assignment, literal dynamic imports reached through top-level awaited callbacks, dead dynamic-import branches, semantic CSS query imports, and byte-distinct binary assets. Runtime packages are trusted only through the audited dependency set used by this product; each route projects only its reachable trusted roots from `package.json`, and every selected root and transitive registry node must have its exact importer, snapshot, package record, and integrity in `pnpm-lock.yaml`. Unsupported mutable, escaped, aliased, globally patched, unresolved, or dynamic config, unproved callback timing, dynamic targets or intrinsic spreads, imported member receivers, arbitrary external mutations, ambiguous nested writes, and incomplete lock evidence outside proven client callbacks fail closed. +`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. Version 17 follows each route's real SSR injection chain with a line-ending-neutral runtime-AST digest: type-only syntax is erased without dropping runtime-bearing TypeScript parameter properties or optional-chain behavior; intrinsic event/ref/key props and statically proven spreads erase only deferred callback values while retaining synchronous factories; public `config/site.ts` evidence follows immutable field dependencies and validates global provenance; and mixed shell modules contribute route-rendering JSX plus the lexical closure of all runtime module-evaluation edges, root/program synchronous calls, proven synchronous callbacks and selected React `lazy` loaders, constructors, tagged templates, aliases, and render-phase mutations. Known asynchronous callbacks, including stable aliases and locally private callback-factory results, are erased even when nested inside an otherwise selected synchronous slice, while callback acquisition remains synchronous evidence and exported callbacks remain conservative. Regression probes additionally cover all-candidate mixed call targets, object and class getter-derived targets, conditional/nested/factory/getter global containers stored by later member assignment, named and aliased literal dynamic imports reached through module evaluation, statically false literal branches and loops, semantic CSS query imports, and byte-distinct binary assets. Runtime packages are trusted only through the audited dependency set used by this product; each route projects only its semantically reachable trusted roots from `package.json`, rejects reachable packages outside that allowlist, and requires every selected root and transitive registry node to have its exact importer, snapshot, package record, and canonical SHA-256/384/512 `resolution.integrity` value in `pnpm-lock.yaml`. Unsupported mutable, escaped, aliased, globally patched, unresolved, or dynamic config, unproved callback timing, dynamic targets or intrinsic spreads, imported member receivers, arbitrary external mutations, ambiguous nested writes, and incomplete lock evidence outside proven client callbacks fail closed. -Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. An unchanged digest preserves its prior time; changed content uses the exact Git commit or working-tree timestamp of a semantically changed projection and must advance. A schema migration reapplies the current projector to source history at the previous `lastmod`, so clean committed semantic edits advance while dirty changes confined to pruned client branches do not. Version 15 made the runtime dependency graph visible and advanced all 47 routes to the dependency-maintenance commit at `2026-08-23T21:18:04+08:00`; Version 16 makes that evidence route-specific and fail-closed without inventing a later content time, so the 47 timestamps remain there. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, Worker-first static-asset routing, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. +Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. An unchanged digest preserves its prior time; changed content uses the exact Git commit or working-tree timestamp of a semantically changed projection and must advance. A schema migration reapplies the current projector to source history at the previous `lastmod`, so clean committed semantic edits advance while dirty changes confined to pruned client branches do not. Version 15 made the runtime dependency graph visible and advanced all 47 routes to the dependency-maintenance commit at `2026-08-23T21:18:04+08:00`; Version 16 made that evidence route-specific and fail-closed; Version 17 closes the remaining callback, package-root, integrity, global-container, and getter provenance gaps without inventing a later content time, so the 47 timestamps remain there. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, Worker-first static-asset routing, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. -The browser gate runs Chromium against built `dist/` through a strict, non-reused custom preview server. Route tests directly open `/`, `/part/a`, `/part/a/01`, `/method`, and `/part/g/plug`, then exercise live client navigation and keyboard focus. They check HTTP status, prerendered HTML, absence of pending streamed-Suspense placeholders, route CSS at first paint, hydration without errors, CLS below `0.05`, title/description/abstract/canonical/zero-hreflang/Open Graph/JSON-LD metadata, one visible `h1`, route announcements, current-page semantics, initial-load focus, changed-route focus, and skip-link focus. Navigation-positioning tests click a representative lesson outline in each of the seven families, restore a direct lesson fragment, verify the mobile outline and an ordinary family-page fragment, and require a changed route to reset old-page scroll immediately. An unknown path must return 404 with `noindex,nofollow`, no canonical, and the themed not-found heading. Worker-level Node tests separately cover the Accept matrix, UTF-8 media-parameter matching, recovery after a broken quoted member, 406 behavior, HEAD parity, representation-specific ETags, hidden internal assets, and unaffected API/static/unknown paths. +The browser gate runs Chromium against built `dist/` through a strict, non-reused custom preview server. Route tests directly open `/`, `/part/a`, `/part/a/01`, `/method`, and `/part/g/plug`, then exercise live client navigation and keyboard focus. They check HTTP status, prerendered HTML, absence of pending streamed-Suspense placeholders, route CSS at first paint, hydration without errors, CLS below `0.05`, title/description/abstract/canonical/zero-hreflang/Open Graph/JSON-LD metadata, one visible `h1`, route announcements, current-page semantics, initial-load focus, changed-route focus, and skip-link focus. Navigation-positioning tests click a representative lesson outline in each of the seven families, restore a direct lesson fragment, verify the mobile outline and an ordinary family-page fragment, and require a changed route to reset old-page scroll immediately. An unknown path must return 404 with `noindex,nofollow`, no canonical, and the themed not-found heading. Worker-level Node tests separately cover the RFC 9110 Accept matrix, UTF-8 media-parameter matching on either side of `q`, empty parameter slots, invalid whitespace and bare parameters, recovery after a broken quoted member, 406 behavior, GET/HEAD/conditional parity, representation-specific ETags, hidden internal assets, and unaffected API/static/unknown paths. Two game tests cover the catalog-owned lazy boundary and shared runtime contracts. Pack must keep its chunk absent before the near-viewport gate, auto-load without a manual control, replay the currently displayed seed into the identical round with cleared interaction state, and preserve exact played/matched totals across replay, shuffle, difficulty, and reveal. BitBoard must suppress duplicate completion counts and rearm after clear. These tests do not provide arbitrary-seed entry or pixel-regression coverage. diff --git a/site/scripts/content-negotiation.test.mjs b/site/scripts/content-negotiation.test.mjs index 81c0614..217c2b7 100644 --- a/site/scripts/content-negotiation.test.mjs +++ b/site/scripts/content-negotiation.test.mjs @@ -23,7 +23,11 @@ test('content negotiation follows the approved HTML and Markdown Accept matrix', ['text/markdown;foo="unterminated, text/html', 'html'], ['text/markdown;charset=iso-8859-1, text/html;q=0.5', 'html'], ['text/markdown;charset=utf-8, text/html;q=0.5', 'markdown'], - ['text/markdown;q=1;charset=iso-8859-1, text/html;q=0.5', 'markdown'], + ['text/markdown;q=1;charset=iso-8859-1, text/html;q=0.5', 'html'], + ['text/markdown;q=1;charset=utf-8, text/html;q=0.5', 'markdown'], + ['text/markdown;charset =utf-8, text/html;q=0.5', 'html'], + ['text/markdown;;q=1, text/html;q=0.5', 'markdown'], + ['text/markdown;q=1;foo, text/html;q=0.5', 'html'], ] for (const [accept, expected] of vectors) { diff --git a/site/scripts/last-modified.mjs b/site/scripts/last-modified.mjs index 9a973eb..bc71fab 100644 --- a/site/scripts/last-modified.mjs +++ b/site/scripts/last-modified.mjs @@ -11,7 +11,7 @@ import { } from './semantic-source-graph.mjs' const projectRoot = fileURLToPath(new URL('../../', import.meta.url)) -export const ROUTE_CONTENT_DIGEST_VERSION = 16 +export const ROUTE_CONTENT_DIGEST_VERSION = 17 const historicalSourceCache = new Map() const headSourceCache = new Map() diff --git a/site/scripts/semantic-source-graph.mjs b/site/scripts/semantic-source-graph.mjs index 5132623..128f9f4 100644 --- a/site/scripts/semantic-source-graph.mjs +++ b/site/scripts/semantic-source-graph.mjs @@ -497,6 +497,85 @@ function publicSiteConfigProjection(program) { ? isGlobalContainer(initializer, new Set([...seen, value.name])) : false } + const publicStaticMemberMayInvokeGetter = (node, key, seen = new Set()) => { + const value = unwrap(node) + if (!value || typeof value !== 'object') return false + if (value.type === 'Identifier' && bindings.has(value.name)) { + const initializer = constBindingExpression(value.name, seen) + return initializer + ? publicStaticMemberMayInvokeGetter( + initializer, + key, + new Set([...seen, value.name]), + ) + : false + } + if (value.type === 'ConditionalExpression') { + return publicStaticMemberMayInvokeGetter(value.consequent, key, seen) + || publicStaticMemberMayInvokeGetter(value.alternate, key, seen) + } + if (value.type === 'LogicalExpression') { + return publicStaticMemberMayInvokeGetter(value.left, key, seen) + || publicStaticMemberMayInvokeGetter(value.right, key, seen) + } + if (value.type === 'SequenceExpression') { + return publicStaticMemberMayInvokeGetter(value.expressions.at(-1), key, seen) + } + if (value.type !== 'ObjectExpression') return false + return value.properties.some((property) => { + if (property.type === 'SpreadElement') { + return publicStaticMemberMayInvokeGetter(property.argument, key, seen) + } + return property.type === 'Property' + && staticPropertyNameFromNode(property) === String(key) + && property.kind === 'get' + }) + } + const mayContainGlobalContainer = (node, seen = new Set()) => { + const value = unwrap(node) + if (!value || typeof value !== 'object') return false + if (isGlobalContainer(value, seen)) return true + if (value.type === 'CallExpression' || value.type === 'NewExpression') return true + if (value.type === 'Identifier' && bindings.has(value.name)) { + const initializer = constBindingExpression(value.name, seen) + return initializer + ? mayContainGlobalContainer(initializer, new Set([...seen, value.name])) + : false + } + if (value.type === 'ConditionalExpression') { + return mayContainGlobalContainer(value.consequent, seen) + || mayContainGlobalContainer(value.alternate, seen) + } + if (value.type === 'LogicalExpression') { + return mayContainGlobalContainer(value.left, seen) + || mayContainGlobalContainer(value.right, seen) + } + if (value.type === 'SequenceExpression') { + return mayContainGlobalContainer(value.expressions.at(-1), seen) + } + if (value.type === 'ArrayExpression') { + return value.elements.some((element) => mayContainGlobalContainer( + element?.type === 'SpreadElement' ? element.argument : element, + seen, + )) + } + if (value.type === 'ObjectExpression') { + return value.properties.some((property) => mayContainGlobalContainer( + property.type === 'SpreadElement' ? property.argument : property.value, + seen, + )) + } + if (value.type === 'MemberExpression') { + const name = localMemberName(value) + if (name === null || publicStaticMemberMayInvokeGetter(value.object, name, seen)) { + return true + } + const members = staticMemberExpressions(value.object, name, seen) + return members.length === 0 + || members.some((member) => mayContainGlobalContainer(member, seen)) + } + return false + } const indexGlobalContainerAliases = () => { let containerAliasesChanged = true while (containerAliasesChanged) { @@ -976,7 +1055,7 @@ function publicSiteConfigProjection(program) { if ( node.type === 'AssignmentExpression' && unwrap(node.left)?.type === 'MemberExpression' - && isGlobalContainer(node.right) + && mayContainGlobalContainer(node.right) ) unsupported('member-stored global container') rejectUsedIdentity(node.type === 'AssignmentExpression' ? node.left : node.argument, 'mutation of') } else if (node.type === 'UnaryExpression' && node.operator === 'delete') { @@ -1061,17 +1140,40 @@ function canonicalImportIdentity(importerPath, specifier) { } function staticBooleanValue(node) { - const value = node && EXPRESSION_WRAPPERS.has(node.type) - ? staticBooleanValue(node.expression) - : node - if ( - (value?.type === 'Literal' || value?.type === 'BooleanLiteral') - && typeof value.value === 'boolean' - ) return value.value + if (node && EXPRESSION_WRAPPERS.has(node.type)) { + return staticBooleanValue(node.expression) + } + const value = node + if (value?.type === 'Literal' || value?.type === 'BooleanLiteral') { + return Boolean(value.value) + } + if (value?.type === 'TemplateLiteral' && value.expressions.length === 0) { + return Boolean(value.quasis[0]?.value?.cooked ?? value.quasis[0]?.value?.raw ?? '') + } if (value?.type === 'UnaryExpression' && value.operator === '!') { const argument = staticBooleanValue(value.argument) return argument === null ? null : !argument } + if (value?.type === 'UnaryExpression' && value.operator === 'void') return false + if (value?.type === 'SequenceExpression') { + return staticBooleanValue(value.expressions.at(-1)) + } + return null +} + +function staticNullishValue(node) { + if (node && EXPRESSION_WRAPPERS.has(node.type)) { + return staticNullishValue(node.expression) + } + const value = node + if (value?.type === 'Literal' || value?.type === 'BooleanLiteral') { + return value.value === null + } + if (value?.type === 'UnaryExpression' && value.operator === 'void') return true + if (value?.type === 'TemplateLiteral' && value.expressions.length === 0) return false + if (value?.type === 'SequenceExpression') { + return staticNullishValue(value.expressions.at(-1)) + } return null } @@ -1082,6 +1184,54 @@ function moduleEvaluationDynamicImportSources(program, path) { 'FunctionDeclaration', 'FunctionExpression', ]) + const callableDefinitions = new Map() + const callableAliases = new Map() + const activeFunctions = new Set() + + const register = (map, name, value) => { + if (!name) return + const values = map.get(name) ?? new Set() + values.add(value) + map.set(name, values) + } + for (const statement of program.body ?? []) { + const declaration = statement.type === 'ExportNamedDeclaration' + ? statement.declaration + : statement + if (declaration?.type === 'FunctionDeclaration') { + register(callableDefinitions, declaration.id?.name, declaration) + } + if (declaration?.type !== 'VariableDeclaration') continue + for (const declarator of declaration.declarations ?? []) { + if (declarator.id?.type !== 'Identifier' || !declarator.init) continue + if (functionTypes.has(declarator.init.type)) { + register(callableDefinitions, declarator.id.name, declarator.init) + } else if (declarator.init.type === 'Identifier') { + register(callableAliases, declarator.id.name, declarator.init.name) + } + } + } + + function resolvedCallables(name, seen = new Set()) { + if (!name || seen.has(name)) return [] + const nextSeen = new Set([...seen, name]) + return [ + ...(callableDefinitions.get(name) ?? []), + ...[...(callableAliases.get(name) ?? [])].flatMap((alias) => ( + resolvedCallables(alias, nextSeen) + )), + ] + } + + function executeCallable(callable, options = {}) { + if (activeFunctions.has(callable)) return + activeFunctions.add(callable) + try { + visit(callable, { ...options, executeFunction: true }) + } finally { + activeFunctions.delete(callable) + } + } function visit(node, { executeFunction = false, awaitedCall = false } = {}) { if (!node || typeof node !== 'object') return @@ -1115,13 +1265,28 @@ function moduleEvaluationDynamicImportSources(program, path) { if (node.type === 'LogicalExpression') { visit(node.left) const condition = staticBooleanValue(node.left) + const nullish = staticNullishValue(node.left) if ( (node.operator === '&&' && condition !== false) || (node.operator === '||' && condition !== true) - || (node.operator === '??' && condition === null) + || (node.operator === '??' && nullish !== false) ) visit(node.right) return } + if (node.type === 'WhileStatement') { + visit(node.test) + if (staticBooleanValue(node.test) !== false) visit(node.body) + return + } + if (node.type === 'ForStatement') { + visit(node.init) + visit(node.test) + if (!node.test || staticBooleanValue(node.test) !== false) { + visit(node.body) + visit(node.update) + } + return + } if (node.type === 'AwaitExpression') { visit(node.argument, { awaitedCall: true }) return @@ -1129,9 +1294,18 @@ function moduleEvaluationDynamicImportSources(program, path) { if (node.type === 'CallExpression' || node.type === 'NewExpression') { const immediateFunction = functionTypes.has(node.callee?.type) visit(node.callee, { executeFunction: immediateFunction, awaitedCall }) + if (node.callee?.type === 'Identifier') { + for (const callable of resolvedCallables(node.callee.name)) { + executeCallable(callable, { awaitedCall }) + } + } for (const argument of node.arguments ?? []) { if (functionTypes.has(argument?.type)) { if (awaitedCall) visit(argument, { executeFunction: true, awaitedCall: true }) + } else if (awaitedCall && argument?.type === 'Identifier') { + for (const callable of resolvedCallables(argument.name)) { + executeCallable(callable, { awaitedCall: true }) + } } else { visit(argument) } @@ -1319,10 +1493,12 @@ function semanticBindingGraph(program, path, analysisContext = {}) { valueOrigins: node.source?.value === 'react' && CLIENT_EFFECT_CALLEES.has(importedName) ? ['react-effect'] - : node.source?.value === 'react' - && (importedName === '*' || importedName === 'default') - ? ['react-namespace'] - : ['other'], + : node.source?.value === 'react' && importedName === 'lazy' + ? ['react-lazy'] + : node.source?.value === 'react' + && (importedName === '*' || importedName === 'default') + ? ['react-namespace'] + : ['other'], }) const binding = scope.bindings.get(specifier.local.name) if (binding && node.source) { @@ -1524,9 +1700,17 @@ function semanticBindingGraph(program, path, analysisContext = {}) { const binding = lookup(value) if (!binding || seen.has(binding)) return false const initializer = directConstInitializer(binding) - return initializer - ? staticMemberMayInvokeGetter(initializer, key, new Set([...seen, binding])) - : false + if (initializer) { + return staticMemberMayInvokeGetter(initializer, key, new Set([...seen, binding])) + } + const relation = nodeParents.get(binding.identifier) + if ( + relation?.key === 'id' + && (relation.parent.type === 'ClassDeclaration' || relation.parent.type === 'ClassExpression') + ) { + return staticMemberMayInvokeGetter(relation.parent, key, new Set([...seen, binding])) + } + return false } if (value.type === 'ConditionalExpression') { return staticMemberMayInvokeGetter(value.consequent, key, seen) @@ -1546,6 +1730,19 @@ function semanticBindingGraph(program, path, analysisContext = {}) { staticMemberMayInvokeGetter(member, key, seen) )) } + if (value.type === 'ClassDeclaration' || value.type === 'ClassExpression') { + return (value.body?.body ?? []).some((member) => { + if (member.type !== 'MethodDefinition' || !member.static || member.kind !== 'get') { + return false + } + const name = !member.computed && member.key.type === 'Identifier' + ? member.key.name + : (member.key.type === 'Literal' || member.key.type === 'StringLiteral') + ? member.key.value + : null + return String(name) === String(key) + }) + } if (value.type !== 'ObjectExpression') return false return value.properties.some((property) => { if (property.type === 'SpreadElement') { @@ -1791,11 +1988,13 @@ function semanticBindingGraph(program, path, analysisContext = {}) { } if (node.type === 'MemberExpression') { const objectOrigins = expressionValueOrigins(node.object) - return objectOrigins.size === 1 - && objectOrigins.has('react-namespace') - && CLIENT_EFFECT_CALLEES.has(memberNameFromNode(node)) - ? new Set(['react-effect']) - : new Set(['other']) + if (objectOrigins.size === 1 && objectOrigins.has('react-namespace')) { + if (CLIENT_EFFECT_CALLEES.has(memberNameFromNode(node))) { + return new Set(['react-effect']) + } + if (memberNameFromNode(node) === 'lazy') return new Set(['react-lazy']) + } + return new Set(['other']) } if (node.type === 'ConditionalExpression') { return new Set([ @@ -1849,7 +2048,13 @@ function semanticBindingGraph(program, path, analysisContext = {}) { : property.key.value changed = addValueOrigins( reassignedBindings(property.value), - new Set(CLIENT_EFFECT_CALLEES.has(name) ? ['react-effect'] : ['other']), + new Set( + CLIENT_EFFECT_CALLEES.has(name) + ? ['react-effect'] + : name === 'lazy' + ? ['react-lazy'] + : ['other'], + ), ) || changed } } else { @@ -1988,6 +2193,28 @@ function semanticBindingGraph(program, path, analysisContext = {}) { function isKnownSynchronousCallbackCall(call) { const callee = unwrapExpression(call.callee) + if (callee?.type === 'Identifier') { + const binding = lookup(callee) + if ( + binding + && !binding.aliasUnstable + && exactOrigin(binding, 'react-lazy') + && ![...binding.aliases].some((alias) => patchedReactBindings.has(alias)) + ) return true + } + if ( + callee?.type === 'MemberExpression' + && memberName(callee) === 'lazy' + && callee.object.type === 'Identifier' + ) { + const binding = lookup(callee.object) + if ( + binding + && !binding.aliasUnstable + && exactOrigin(binding, 'react-namespace') + && ![...binding.aliases].some((alias) => patchedReactBindings.has(alias)) + ) return true + } if (callee?.type !== 'MemberExpression') return false if ( isUnboundGlobalIdentifier(callee.object, new Set(['Array'])) @@ -2240,6 +2467,53 @@ function semanticBindingGraph(program, path, analysisContext = {}) { : false } + function mayContainUnboundGlobalContainer(node, seen = new Set()) { + const value = unwrapExpression(node) + if (!value || typeof value !== 'object') return false + if (unboundGlobalContainer(value, seen)) return true + if (value.type === 'CallExpression' || value.type === 'NewExpression') return true + if (value.type === 'Identifier') { + const binding = lookup(value) + if (!binding || seen.has(binding)) return false + const initializer = directConstInitializer(binding) + return initializer + ? mayContainUnboundGlobalContainer(initializer, new Set([...seen, binding])) + : false + } + if (value.type === 'ConditionalExpression') { + return mayContainUnboundGlobalContainer(value.consequent, seen) + || mayContainUnboundGlobalContainer(value.alternate, seen) + } + if (value.type === 'LogicalExpression') { + return mayContainUnboundGlobalContainer(value.left, seen) + || mayContainUnboundGlobalContainer(value.right, seen) + } + if (value.type === 'SequenceExpression') { + return mayContainUnboundGlobalContainer(value.expressions.at(-1), seen) + } + if (value.type === 'ArrayExpression') { + return value.elements.some((element) => ( + element?.type === 'SpreadElement' + ? mayContainUnboundGlobalContainer(element.argument, seen) + : mayContainUnboundGlobalContainer(element, seen) + )) + } + if (value.type === 'ObjectExpression') { + return value.properties.some((property) => mayContainUnboundGlobalContainer( + property.type === 'SpreadElement' ? property.argument : property.value, + seen, + )) + } + if (value.type === 'MemberExpression') { + const name = memberName(value) + if (name === null || staticMemberMayInvokeGetter(value.object, name)) return true + const members = staticMemberExpressions(value.object, name, seen) + return members.length === 0 + || members.some((member) => mayContainUnboundGlobalContainer(member, seen)) + } + return false + } + function unboundGlobalMemberRoot(node) { let value = unwrapExpression(node) while (value?.type === 'MemberExpression') value = unwrapExpression(value.object) @@ -2933,7 +3207,7 @@ function semanticBindingGraph(program, path, analysisContext = {}) { if (node.type === 'AssignmentExpression') { if ( isMemberMutationTarget(node.left) - && unboundGlobalContainer(node.right) + && mayContainUnboundGlobalContainer(node.right) ) { unsupportedRootCalls.push({ executionScope, @@ -3554,6 +3828,41 @@ function nestedYamlValue(entry, name, sectionIndent) { return null } +function registryResolutionIntegrity(entry) { + const supportedIntegrity = /^(?:sha256|sha384|sha512)-([A-Za-z0-9+/]+={0,2})$/u + for (let index = 1; index < entry.lines.length; index += 1) { + const resolution = yamlMapEntry(entry.lines[index], 4) + if (resolution?.key !== 'resolution') continue + let integrity = null + if (resolution.value.startsWith('{') && resolution.value.endsWith('}')) { + const inline = resolution.value.slice(1, -1) + const match = inline.match( + /(?:^|,)\s*integrity\s*:\s*(?:"([^"]*)"|'([^']*)'|([^,\s}]+))/u, + ) + integrity = match ? (match[1] ?? match[2] ?? match[3] ?? '') : null + } else if (!resolution.value) { + for (let nestedIndex = index + 1; nestedIndex < entry.lines.length; nestedIndex += 1) { + if (entry.lines[nestedIndex].trim() && yamlIndent(entry.lines[nestedIndex]) <= 4) break + const field = yamlMapEntry(entry.lines[nestedIndex], 6) + if (field?.key === 'integrity') { + integrity = field.value + break + } + } + } + const match = integrity?.match(supportedIntegrity) + if (!match) return null + try { + return Buffer.from(match[1], 'base64').toString('base64') === match[1] + ? integrity + : null + } catch { + return null + } + } + return null +} + function selectedRuntimePackageNames(runtimePackages, availableNames) { const selected = runtimePackages === undefined ? [...availableNames].filter((name) => TRUSTED_RUNTIME_PACKAGE_NAMES.has(name)) @@ -3638,7 +3947,7 @@ function runtimeLockProjection(source, runtimePackages) { const packageEntry = packageEntries.get(key) ?? packageEntries.get(packageKey) if (!packageEntry) throw new Error(`Missing locked package ${packageKey}`) const packageSource = packageEntry.lines.join('\n') - if (!/\bintegrity:\s*[^\s}]+/u.test(packageSource)) { + if (!registryResolutionIntegrity(packageEntry)) { throw new Error(`Missing registry integrity ${packageEntry.key}`) } selectedPackages.set(packageEntry.key, packageSource) @@ -3671,6 +3980,39 @@ export function semanticSourceForDigest(path, source, options = {}) { return JSON.stringify(publicSiteConfigProjection(program)) } const bindingGraph = semanticBindingGraph(program, path) + const runtimePackageSpecifiers = options.runtimePackageSpecifiers ?? new Set() + const recordRuntimePackageSpecifier = (specifier) => { + if (typeof specifier !== 'string' || specifier.startsWith('.')) { + return + } + runtimePackageSpecifiers.add(specifier) + } + for (const sourceNode of bindingGraph.moduleEvaluationEdges) { + recordRuntimePackageSpecifier(sourceNode.value) + } + const finishProjection = (projection) => { + for (const specifier of runtimePackageSpecifiers) { + if (!trustedRuntimePackageName(specifier)) { + throw new Error(`Unsupported semantic runtime package ${specifier}`) + } + } + return projection + } + const collectRuntimePackageSpecifiers = (node) => { + if (!runtimePackageSpecifiers || !node || typeof node !== 'object') return + if (bindingGraph.isClientOnlyDeferredValue(node)) return + if (bindingGraph.isClientOnlyIntrinsicAttribute(node)) return + if (node.type === 'ImportExpression') { + if (typeof node.source?.value !== 'string') { + throw new Error(`Unsupported dynamic semantic runtime import in ${path}`) + } + recordRuntimePackageSpecifier(node.source.value) + return + } + for (const [child] of bindingGraph.childNodes(node)) { + collectRuntimePackageSpecifiers(child) + } + } const projectionOptions = { clientAsyncCallbackIndexes: bindingGraph.clientAsyncCallbackIndexes, isClientEffectCall: bindingGraph.isClientEffectCall, @@ -3683,7 +4025,10 @@ export function semanticSourceForDigest(path, source, options = {}) { projectIntrinsicSpreadPropertyValue: bindingGraph.projectIntrinsicSpreadPropertyValue, projectIntrinsicSpreadInitializer: bindingGraph.projectIntrinsicSpreadInitializer, } - if (!rootName) return JSON.stringify(runtimeAstProjection(program, projectionOptions)) + if (!rootName) { + collectRuntimePackageSpecifiers(program) + return finishProjection(JSON.stringify(runtimeAstProjection(program, projectionOptions))) + } const roots = [] function findRoots(node) { if ( @@ -3788,9 +4133,10 @@ export function semanticSourceForDigest(path, source, options = {}) { && other.end >= end )) }) - return JSON.stringify(outermostSlices.map((node) => ( + for (const node of outermostSlices) collectRuntimePackageSpecifiers(node) + return finishProjection(JSON.stringify(outermostSlices.map((node) => ( runtimeAstProjection(node, projectionOptions) - ))) + )))) } function projectPath(path) { @@ -3853,11 +4199,6 @@ export function staticImportSpecifiersForSource(path, source) { ] } -function staticImportSpecifiers(path) { - if (!PARSED_EXTENSIONS.has(extname(path))) return [] - return staticImportSpecifiersForSource(path, readFileSync(path, 'utf8')) -} - function trustedRuntimePackageName(specifier) { if (!TRUSTED_RUNTIME_PACKAGES.has(specifier)) return null return specifier.startsWith('@') @@ -3898,14 +4239,23 @@ function semanticRouteEvidence(pathname) { if (visited.has(relativePath)) continue visited.add(relativePath) - for (const specifier of staticImportSpecifiers(path)) { - const runtimePackage = trustedRuntimePackageName(specifier) - if (runtimePackage) { + if (PARSED_EXTENSIONS.has(extname(relativePath))) { + const source = readFileSync(path, 'utf8') + const runtimePackageSpecifiers = new Set() + semanticSourceForDigest(relativePath, source, { runtimePackageSpecifiers }) + for (const specifier of runtimePackageSpecifiers) { + const runtimePackage = trustedRuntimePackageName(specifier) + if (!runtimePackage) { + throw new Error(`Unsupported semantic runtime package ${specifier}`) + } runtimePackages.add(runtimePackage) - continue } - const dependency = resolveStaticImport(path, specifier) - if (dependency) pending.push(dependency) + + for (const specifier of staticImportSpecifiersForSource(relativePath, source)) { + if (!specifier.startsWith('.')) continue + const dependency = resolveStaticImport(path, specifier) + if (dependency) pending.push(dependency) + } } } diff --git a/site/scripts/semantic-source-graph.test.mjs b/site/scripts/semantic-source-graph.test.mjs index a139d56..d13c950 100644 --- a/site/scripts/semantic-source-graph.test.mjs +++ b/site/scripts/semantic-source-graph.test.mjs @@ -603,6 +603,19 @@ test('mixed-module closure tracks every supported mutation of selected bindings' ) } + for (const indirectMemberStoredGlobal of [ + `let label = '正文'\nconst holder = {}\nholder.g = true ? globalThis : {}\nholder.g.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, + `let label = '正文'\nconst holder = {}\nholder.nested = { g: globalThis }\nholder.nested.g.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, + `let label = '正文'\nconst holder = {}\nfunction getGlobal() { return globalThis }\nholder.g = getGlobal()\nholder.g.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, + `let label = '正文'\nconst holder = {}\nconst source = { get value() { return globalThis } }\nholder.g = source.value\nholder.g.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, + ]) { + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', indirectMemberStoredGlobal), + /Unsupported member-stored global container/, + indirectMemberStoredGlobal, + ) + } + const patchedReflectApply = `import { state, prepare } from './store'\nglobalThis.Reflect = { apply(callable) { return callable() } }\nfunction AppContent() { Reflect.apply(prepare, null, []); return {state.value} }` assert.throws( () => semanticSourceForDigest('site/src/app/AppContent.tsx', patchedReflectApply), @@ -776,12 +789,9 @@ test('reviewed runtime boundaries cannot hide public render changes', () => { } const sideEffectImportBefore = `import 'ssr-patch-a'\nfunction AppContent() { return

正文

}` - assert.notEqual( - semanticSourceForDigest('site/src/app/AppContent.tsx', sideEffectImportBefore), - semanticSourceForDigest( - 'site/src/app/AppContent.tsx', - sideEffectImportBefore.replace('ssr-patch-a', 'ssr-patch-b'), - ), + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', sideEffectImportBefore), + /Unsupported semantic runtime package ssr-patch-a/, ) }) @@ -823,12 +833,9 @@ test('secondary review boundaries preserve SSR precision and fail closed', () => `import { unused } from 'ssr-patch-a'\nfunction AppContent() { return 正文 }`, `export * from 'ssr-patch-a'\nfunction AppContent() { return 正文 }`, ]) { - assert.notEqual( - semanticSourceForDigest('site/src/app/AppContent.tsx', moduleEvaluation), - semanticSourceForDigest( - 'site/src/app/AppContent.tsx', - moduleEvaluation.replace('ssr-patch-a', 'ssr-patch-b'), - ), + assert.throws( + () => semanticSourceForDigest('site/src/app/AppContent.tsx', moduleEvaluation), + /Unsupported semantic runtime package ssr-patch-a/, ) } @@ -1043,6 +1050,13 @@ test('secondary review boundaries preserve SSR precision and fail closed', () => ), /Unsupported dynamic call target/, ) + assert.throws( + () => semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + `import { opaque } from 'opaque-patcher'\nclass Choices { static get selected() { return opaque } }\nconst selected = Choices.selected\nfunction AppContent() { return {selected()} }`, + ), + /Unsupported dynamic call target/, + ) for (const externalStandalone of [ `import { mutateOne as mutate, state } from 'pkg'\nmutate()\nfunction AppContent() { return {state.value} }`, `mutateOne()\nfunction AppContent() { return {globalThis.state.value} }`, @@ -1184,6 +1198,22 @@ test('public config rejects alias mutation, pattern writes, and patched freeze', `const O = (() => Object)()\nO.freeze = value => { value.name = '运行时'; return value }`, `name: '初始'`, ), + publicConfigSource( + `const holder = {}\nholder.g = true ? globalThis : {}\nholder.g.Object = { freeze(value) { value.name = '运行时'; return value } }`, + `name: '初始'`, + ), + publicConfigSource( + `const holder = {}\nholder.nested = { g: globalThis }\nholder.nested.g.Object = { freeze(value) { value.name = '运行时'; return value } }`, + `name: '初始'`, + ), + publicConfigSource( + `const holder = {}\nfunction getGlobal() { return globalThis }\nholder.g = getGlobal()\nholder.g.Object = { freeze(value) { value.name = '运行时'; return value } }`, + `name: '初始'`, + ), + publicConfigSource( + `const holder = {}\nconst source = { get value() { return globalThis } }\nholder.g = source.value\nholder.g.Object = { freeze(value) { value.name = '运行时'; return value } }`, + `name: '初始'`, + ), ]) { assert.throws( () => semanticSourceForDigest('site/src/config/site.ts', mutableConfig), @@ -1211,6 +1241,20 @@ test('static dependency discovery follows literal top-level dynamic imports and ), ['./runtime'], ) + assert.deepEqual( + staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `const load = () => import('./runtime')\nawait Promise.resolve().then(load)`, + ), + ['./runtime'], + ) + assert.deepEqual( + staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `function load() { return import('./runtime') }\nconst alias = load\nawait alias()`, + ), + ['./runtime'], + ) assert.throws( () => staticImportSpecifiersForSource( 'site/src/app/AppContent.tsx', @@ -1218,6 +1262,13 @@ test('static dependency discovery follows literal top-level dynamic imports and ), /Unsupported dynamic module-evaluation import/, ) + assert.throws( + () => staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `const target = './runtime'\nasync function load() { return import(target) }\nawait load()`, + ), + /Unsupported dynamic module-evaluation import/, + ) assert.throws( () => semanticSourceForDigest( 'site/src/app/AppContent.tsx', @@ -1242,6 +1293,42 @@ test('static dependency discovery follows literal top-level dynamic imports and `if (false) import('opaque-two')\nfunction AppContent() { return 正文 }`, ), ) + for (const deadBranch of ['0', 'null', "''"]) { + assert.equal( + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + `if (${deadBranch}) import('opaque-one')\nwhile (false) import('opaque-three')\nfunction AppContent() { return 正文 }`, + ), + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + `if (${deadBranch}) import('opaque-two')\nwhile (false) import('opaque-four')\nfunction AppContent() { return 正文 }`, + ), + deadBranch, + ) + } +}) + +test('semantic package evidence rejects unknown packages and follows selected lazy imports', () => { + for (const source of [ + `import { label } from 'opaque-patcher'\nfunction AppContent() { return {label} }`, + `import 'opaque-patcher'\nfunction AppContent() { return 正文 }`, + ]) { + assert.throws( + () => semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + source, + ), + /Unsupported semantic runtime package opaque-patcher/, + ) + } + + const runtimePackageSpecifiers = new Set() + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + `import { lazy } from 'react'\nconst Icon = lazy(() => import('lucide-react'))\nfunction AppContent() { return }`, + { runtimePackageSpecifiers }, + ) + assert.deepEqual([...runtimePackageSpecifiers].sort(), ['lucide-react', 'react']) }) test('static dependency discovery strips Vite query and hash suffixes', () => { @@ -1316,11 +1403,11 @@ importers: version: 4.1.11 packages: loose-envify@1.4.0: - resolution: {integrity: sha512-runtime-one} + resolution: {integrity: sha512-cnVudGltZS1vbmU=} react@19.2.8: - resolution: {integrity: sha512-react-one} + resolution: {integrity: sha512-cmVhY3Qtb25l} vitest@4.1.11: - resolution: {integrity: sha512-test-one} + resolution: {integrity: sha512-dGVzdC1vbmU=} snapshots: loose-envify@1.4.0: {} react@19.2.8: @@ -1332,14 +1419,14 @@ snapshots: lockProjection, semanticSourceForDigest( 'site/pnpm-lock.yaml', - lockSource.replace('sha512-runtime-one', 'sha512-runtime-two'), + lockSource.replace('sha512-cnVudGltZS1vbmU=', 'sha512-cnVudGltZS10d28='), ), ) assert.equal( lockProjection, semanticSourceForDigest( 'site/pnpm-lock.yaml', - lockSource.replace('sha512-test-one', 'sha512-test-two'), + lockSource.replace('sha512-dGVzdC1vbmU=', 'sha512-dGVzdC10d28='), ), ) assert.throws( @@ -1353,7 +1440,7 @@ snapshots: () => semanticSourceForDigest( 'site/pnpm-lock.yaml', lockSource.replace( - ' loose-envify@1.4.0:\n resolution: {integrity: sha512-runtime-one}\n', + ' loose-envify@1.4.0:\n resolution: {integrity: sha512-cnVudGltZS1vbmU=}\n', '', ), ), @@ -1362,10 +1449,29 @@ snapshots: assert.throws( () => semanticSourceForDigest( 'site/pnpm-lock.yaml', - lockSource.replace('resolution: {integrity: sha512-react-one}', 'resolution: {}'), + lockSource.replace('resolution: {integrity: sha512-cmVhY3Qtb25l}', 'resolution: {}'), ), /Missing registry integrity/, ) + for (const invalidResolution of [ + 'resolution: {integrity: ""}', + 'resolution: {integrity: nope}', + 'resolution: {integrity: sha512-a}', + 'resolution: {integrity: md5-cmVhY3Qtb25l}', + 'resolution: {}\n metadata: {integrity: sha512-cmVhY3Qtb25l}', + ]) { + assert.throws( + () => semanticSourceForDigest( + 'site/pnpm-lock.yaml', + lockSource.replace( + 'resolution: {integrity: sha512-cmVhY3Qtb25l}', + invalidResolution, + ), + ), + /Missing registry integrity/, + invalidResolution, + ) + } assert.throws( () => semanticSourceForDigest( 'site/pnpm-lock.yaml', diff --git a/site/scripts/seo-contract.test.mjs b/site/scripts/seo-contract.test.mjs index e92a667..3887f82 100644 --- a/site/scripts/seo-contract.test.mjs +++ b/site/scripts/seo-contract.test.mjs @@ -232,7 +232,7 @@ test('discovery files expose the 47 approved URLs and real summaries', async () assert.match(lastModified, /--format=%cI/) assert.match(lastModified, /normalizeContentForDigest/) assert.match(lastModified, /semanticRouteFiles/) - assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 16) + assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 17) assert.equal(Object.keys(ROUTE_CONTENT_DIGESTS).length, PUBLIC_PATHS.length) assert.ok(Object.values(ROUTE_CONTENT_DIGESTS).every((digest) => /^[0-9a-f]{64}$/.test(digest))) assert.match(publicRoutes, /\.\.\/data\/catalog\.ts/) diff --git a/site/scripts/worker-routing.test.mjs b/site/scripts/worker-routing.test.mjs index d346e47..57bba4e 100644 --- a/site/scripts/worker-routing.test.mjs +++ b/site/scripts/worker-routing.test.mjs @@ -74,6 +74,11 @@ test('public route HTTP responses apply the approved Accept selection matrix', a ['text/markdown;q=0, */*;q=1', '/part/a', 200], ['not-a-media-range, text/html', '/part/a', 200], ['not-a-media-range', null, 406], + ['text/markdown;q=1;charset=iso-8859-1, text/html;q=0.5', '/part/a', 200], + ['text/markdown;q=1;charset=utf-8, text/html;q=0.5', '/_representations/markdown/part/a.md', 200], + ['text/markdown;charset =utf-8, text/html;q=0.5', '/part/a', 200], + ['text/markdown;;q=1, text/html;q=0.5', '/_representations/markdown/part/a.md', 200], + ['text/markdown;q=1;foo, text/html;q=0.5', '/part/a', 200], ] for (const [accept, expectedPath, expectedStatus] of vectors) { diff --git a/site/src/data/routeLastModified.ts b/site/src/data/routeLastModified.ts index 8c7af3b..97fa315 100644 --- a/site/src/data/routeLastModified.ts +++ b/site/src/data/routeLastModified.ts @@ -1,5 +1,5 @@ // 由 scripts/generate-seo.mjs 从源码时间证据生成,请勿手改。 -export const ROUTE_CONTENT_DIGEST_VERSION = 16 +export const ROUTE_CONTENT_DIGEST_VERSION = 17 export const ROUTE_LAST_MODIFIED: Readonly> = Object.freeze({ "/": "2026-08-23T21:18:04+08:00", "/part/a": "2026-08-23T21:18:04+08:00", @@ -58,43 +58,43 @@ export const ROUTE_CONTENT_DIGESTS: Readonly> = Object.fr "/part/e": "91fd27fe4c911cc354896fc38ad5e2b46b7f74940dbb1ab9e6898f16627f034d", "/part/f": "4621b866bcb4edee102a5950975da5b2c17e38b13726a14f177e9788db525568", "/part/g": "fe56dd93b6eab109e1afd480ae3813e899537c14d30ca886d1a5591a8a20b9b7", - "/part/a/01": "b8496f8c8b1d9cec3382c315ca126bf805ce5a06d107b30900842b2b916dcd1a", - "/part/a/complete": "83d566a9f8cde651812bd2f32f294fc1d01cd4a61312ee1f7fa4acb23698a390", - "/part/a/multiple": "3ccdd7583bf1dd44a5b9d669e1f6d5177a8d7df0846808791a585b1db57fcbc7", - "/part/a/group": "49e476b8d8ec720f11ccf15110b4a76c8e0fa8c5d5142e973ecb5223cbba62f3", - "/part/a/mixed": "d2f5e4571aa9c5a77bde069b4af4bd98a01c4cc2a3446a353131e561f329ff0b", - "/part/a/cost2d": "711448bafc22149bf4da25d8153818ff2cd6e2e46f9ff3ecb35e2e482d8f2d1e", - "/part/a/dep": "cdfde259ee224873dbe183fee4aeaad9fa7d04caeb7f86b58058ddf86ad2ec7b", - "/part/a/variant": "dad5d0e8cfe609667b7adf0d2826738251f837c2113d9b010c9c3a2098326f37", - "/part/a/fractional": "47593481cb6fb3473e25389ff0431e3304bfdce635d1f7af5a898cc2932a86bd", - "/part/b/path": "9dc35dc7c32e6923adb250328f2e729d428940f10ce706fbb53766756873e87e", - "/part/b/maxseg": "d790495204ed878091a86585d8d17934fbc481bd07e14e4978b8c081cee55625", - "/part/b/lis": "3123ed2974e91b3d072dae4efce2ea76deea5083ade8c5acaf66cc23a6469b6c", - "/part/b/lcs": "7fbe92c3652f8217eee2d450e79fa8b5a214061cc96ead2a64e500204dff6256", - "/part/b/edit": "69acb8a225fcec35a28a1be7ded79f618a93189d66ab01271ce3aad5fde9f191", - "/part/b/fsm": "4f5ef5cf988e8f936b8b589c34e3270edfd3861c61f6139e121660b097b93cce", - "/part/b/count": "376d37279feefc80b8a98c28ea116417ac5c87ab7ddf684aa32182689bd75f36", - "/part/c/stone": "8b8dc1f476a49b7786aab2d5e7a6c26a3af46330f6f039b2520ba6c92e6c75a5", - "/part/c/ring": "074c46c6ee38769693c61784e5a5474f67c3ff48227150c36b19b6471390dc0d", - "/part/c/palindrome": "bd3494df1f82c5c205237be664c62d378bc5895dc7fc95d61ca249fd68d7a187", - "/part/c/tree": "57261999473bfaa41f216ba165fb272a9b66cd4c85ee30b4301018b09e8e1278", - "/part/c/merge": "77c2f431bab90ae72aa768e23506a256e1b67037ee9561c9d29eef0fb197614a", - "/part/d/grid": "ad743c975a9dc29a0229c01284b999b1be764924e0f71ef75260d56081205777", - "/part/d/matpow": "18ba5812015e71a9e80fc6561b054e8c6633d6015e11666135071358988863db", - "/part/e/basic": "2355ad8d5172d0d83f4add44da597e8e1d335d771bd58f3fd42032a377b2c2db", - "/part/e/distsum": "4fb7a28b39fce8d1959ed80f99fc5a434b7dc2c0e68e1f65fec08987922444d7", - "/part/e/inout": "78320ba4e85fb3a6ca50b0aad8ba90f91c2bac3ab1f13fb30f5c4929dc887cd5", - "/part/e/center": "a972d922db64fa2838f052f10039ed317ca1f6d4c662f6bd81cb62042969e161", - "/part/f/select": "33f5cd5c82b1a72819447a2a85cfa1333d63cd66077247dfea9505d95dc4de82", - "/part/f/knapsack": "918040420e0932198f5da649863a2de17cbd2a0f426e98a819f6f7e1c3d4ecab", - "/part/f/diameter": "94211f53aaa166b07cbaa500fe595420c4155e2fe1b30c41399d174fd3ff1880", - "/part/f/cover": "1ea33061cff0d26593a97dedbdd3777a441f20d52548da87925802f24a115718", - "/part/f/count": "0001c713ef65adacbe29fb1a07bd4137ec6cf6f38d7f3c619da58b13b25d62ff", - "/part/g/board": "bd3679b5ea20dd48e55b9036567d77bf3b56b1e0b1d63b59e6f6ce4071d9a6b9", - "/part/g/tsp": "acff8b9b784531e48f682f400988d3f733ee7a5f22e6aa0157176110f526009c", - "/part/g/cover": "bdae99d8c98b279a169c5f33fef66ae51e5af7b69bb5903d8b0c64ce730549bf", - "/part/g/subset": "4c7ec6066f60a78e13c1da45e4aeebe9731c4badf63499acc1bc8763793f17d8", - "/part/g/plug": "86631b296d9c3656d1c0e4576677a87a4e7b8a1d9bb5e1cf76cfd878c1385d09", - "/method": "5d4e67a2d6211d4ada30e011dedabf1df917b9c2199fd63108bb87ee875e4180", + "/part/a/01": "17007d7250210c95320f9ed52b40124004cbe815f841d963aad360f8866b5964", + "/part/a/complete": "72a48644c2b390362480cc49dcf32064b3542a0414d39561322336b2e7390339", + "/part/a/multiple": "45115a4d8cdd35e9a9ce80a4462478ce0b0bb7e409e05597213a60cf9fa1f4db", + "/part/a/group": "8d71a32d426904e29e32bbb8984706c28d20f7a144312f44d301546701392a2f", + "/part/a/mixed": "ff1392901c10c8697eae65ba203a2999f5ef30fe9ea2cd06892946327153226b", + "/part/a/cost2d": "e6c8bca352c51b72a7f4d70d374a56a3a5ed84bf3c990fd6574b9196adb951bf", + "/part/a/dep": "c5e87e9539b90c99f54c97af658d7c0a94ab4baa8419c44bcd1f2a878d3e46a1", + "/part/a/variant": "f89d418f92be43c112b62d7026376ac7f6598ae81e3dac2f0a066a286611ae1d", + "/part/a/fractional": "37d89f588f69959308b8aa2f173ca0172b24a13f3e6f9fa221ac627cf799f79e", + "/part/b/path": "a29a4db9d83a0737339c250bf6248c4aee592a90f2f05a43638f9396855292c9", + "/part/b/maxseg": "da4a2e76becb37d8fdeabe0b3d12b272b434bf0807d114c6ec0f8e68b1885a90", + "/part/b/lis": "c6359d5a6f8165f9befce427ead46bd89e3f1c2cc99487fd5dd5b5584a4590a9", + "/part/b/lcs": "d2cb15f573c71303b4612afc9cf4e8da51b9b653c2870cbe64d70f6fb2e15989", + "/part/b/edit": "6f2b56e43ae8e06fd940553e0df6d64293138355895316e83eeae0a63a82e7c0", + "/part/b/fsm": "56e3e2c58a43c8cc538dcfd47158ef673b4440b271e4cf481ad3e9399666b15e", + "/part/b/count": "4595771173ccac7e7f398fb0e285a702019593a7000810885883a73d8d6608a9", + "/part/c/stone": "b4977cdf115fcaba7a0ae580dd6ad41ef81cf7bf61571015eb3951305098ef2e", + "/part/c/ring": "e12226c388747f0dbe0fe2ff1964a03b15dc26fb542f96b14aa91d363a601713", + "/part/c/palindrome": "52a7af455822718902fedf8f103318480f72699a427264ff7c96d17ca22c4f12", + "/part/c/tree": "dacb5db8bae0ab92e637481033c31732784dc7a95950e21313995c554a99abfc", + "/part/c/merge": "601dd07238d2f46fa9888c455404fe6acf714931beee17dbf8c5b1e3c1ccbbec", + "/part/d/grid": "a78d79b3bb075600f426877095574b961e45b357692e1c223234e0e778c35be4", + "/part/d/matpow": "262a8a3cade549f7fd26ca1adccac53147a184bd613ceddf21006922e45a1440", + "/part/e/basic": "8f613e44b164318c274267d9458ed18c573887663f39479e393b790f7abe6abe", + "/part/e/distsum": "a8fd017ce4c5ed536f5b6e3d71a1c231f164afc2f2aa17821f0600e596c1009e", + "/part/e/inout": "162061cc155affe2562307c63439b399d2d19d62c8cffd3cf726ca310349e9f0", + "/part/e/center": "7352dc82635f87a47805bd7dc4f640c208a1236e4bf0d4153cb65c73dfe0e2f5", + "/part/f/select": "302995185c4f508e2d4bf931bf16a76cd8e806c2b6296a8b8acd74d7343900b3", + "/part/f/knapsack": "9dff85361d77114bd4c4a3fbbd8b17fcf30846bd480d564f835fe7eb7fa45544", + "/part/f/diameter": "b30285c1b6e0c47b71ca795df334d273e15260ff74718698e8a3868d64cb009c", + "/part/f/cover": "ed44662aab46cf467396274256141bb27b83107c1fcf59a1607d9745aff375e6", + "/part/f/count": "76d35b10d2335bbdd73bba782592a3b1a5d818f91a063f4d98485cd0c53cc659", + "/part/g/board": "16c8fa2b7cbedab414152b59ccf0636154e379c428e691d2e01c407fbf488dea", + "/part/g/tsp": "8348d29319e0a951f3817111fa540040ee643dc2dd1fe2ced2c8267fde4f5999", + "/part/g/cover": "7745468b0d64ec742f4c7c4abcbbe9d00acd539504b842ed830427cafae4b80b", + "/part/g/subset": "e4b4a95278d12a41fa5b2620d7853be1b5b9ddb63311d38154ba7c26592f294c", + "/part/g/plug": "1a7e28cb2b48b9d6129e57dcc17842be019f9011c616a56c1f1619612715b6a3", + "/method": "4a48c27b5cc5d7d03d42f0a8a44618c6f400a632451b2ca379483601718acf9e", "/problems": "03671fba244a78f18399baada2f18c7fa6c68da60a714bf61e07c44e336b52d0" }) diff --git a/site/worker/content-negotiation.js b/site/worker/content-negotiation.js index ca4bbb0..3fe57bb 100644 --- a/site/worker/content-negotiation.js +++ b/site/worker/content-negotiation.js @@ -106,7 +106,8 @@ function parseQuality(value) { function parseRange(member) { const split = splitDelimited(member, ';') if (!split) return null - const [mediaType, ...parameters] = split.map((part) => part.trim()) + const [rawMediaType, ...parameters] = split + const mediaType = rawMediaType.trim() const match = MEDIA_RANGE.exec(mediaType) if (!match) return null const type = match[1].toLowerCase() @@ -116,12 +117,16 @@ function parseRange(member) { let quality let qualitySeen = false const mediaParameters = new Map() - for (const parameter of parameters) { - if (!parameter) return null + for (const rawParameter of parameters) { + const parameter = rawParameter.trim() + if (!parameter) continue const separator = parameter.indexOf('=') if (separator < 1) return null - const name = parameter.slice(0, separator).trim().toLowerCase() - const value = parameter.slice(separator + 1).trim() + const rawName = parameter.slice(0, separator) + const rawValue = parameter.slice(separator + 1) + if (rawName !== rawName.trim() || rawValue !== rawValue.trim()) return null + const name = rawName.toLowerCase() + const value = rawValue if (!TOKEN_VALUE.test(name) || !validParameterValue(value)) return null if (name === 'q') { if (qualitySeen) return null @@ -130,7 +135,6 @@ function parseRange(member) { if (quality === null) return null continue } - if (qualitySeen) continue if (mediaParameters.has(name)) return null mediaParameters.set(name, parameterValue(value)) } From ab1b2417fa6bd5fae33684eaad262908e19319d8 Mon Sep 17 00:00:00 2001 From: Shanire Date: Thu, 27 Aug 2026 11:03:16 +0800 Subject: [PATCH 20/23] fix(web): close exact-sha evidence gaps Signed-off-by: Shanire --- docs/engineering/architecture.md | 2 +- docs/operations/verification.md | 4 +- site/scripts/last-modified.mjs | 2 +- site/scripts/semantic-source-graph.mjs | 428 ++++++++++++++++++-- site/scripts/semantic-source-graph.test.mjs | 102 ++++- site/scripts/seo-contract.test.mjs | 2 +- site/src/data/routeLastModified.ts | 78 ++-- 7 files changed, 518 insertions(+), 100 deletions(-) diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index 4763ff3..904292f 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -114,7 +114,7 @@ Every parsed source contribution is reduced to a runtime AST projection, so Type Effect-only callbacks are excluded only when the callee has one exact, stable React-effect origin through named, default, namespace, member, or destructured aliases. Mixed aliases, reassigned hooks, patched React namespaces, and same-named local helpers are never pruned as effects. Runtime import edges remain while declaration- and specifier-level type-only edges, unselected import siblings, object-spread copies, and JSX attribute names do not leak in. The graph deliberately prunes surrounding navigation/feedback, unrelated route pages, client-only analytics/theme/motion controllers, and the generated evidence module. Public representation identity is structurally isolated in `config/site.ts`; client endpoints and sidebar-only copy are in modules reachable only through pruned branches. -Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. An unchanged digest preserves the previous value; changed content uses the precise Git commit or working-tree timestamp of a semantically changed projection and must advance. During a digest-schema migration, the current projector is applied to historical sources at the previous `lastmod`: a clean committed semantic edit advances, while an edit confined to a pruned client branch does not. Version 15 first added byte-exact binary assets, semantic CSS queries, nested global-container provenance, top-level dynamic imports, mixed-target validation, and the audited runtime package graph. Version 16 narrowed that package evidence to the trusted package roots actually reachable from each route, then required every selected root and recursively reachable registry package to have an exact importer entry, snapshot, package record, and integrity in `pnpm-lock.yaml`; missing evidence failed closed. Version 17 follows named and aliased module-evaluation callbacks, prunes statically false literal branches and loops, includes selected React `lazy` package imports, and rejects every semantically reachable package outside the audited allowlist. Registry evidence must now place a canonical SHA-256/384/512 SRI value specifically under `resolution.integrity`; empty, malformed, sibling, or unsupported-algorithm values fail closed. Potential global containers stored through conditional, nested-object, factory, or getter values fail closed in both runtime and public-config projections, as do class getter-derived dynamic call targets. This schema refinement preserves all 47 route times at the dependency-maintenance commit `2026-08-23T21:18:04+08:00`, because it changes evidence precision without adding a later semantic source change. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, type-only noise, incomplete execution/scope/mutation context, lossy UTF-8 decoding of binary assets, or an unpinned or route-unrelated trusted runtime package graph. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. +Each route records a versioned, line-ending-neutral SHA-256 digest of that graph. An unchanged digest preserves the previous value; changed content uses the precise Git commit or working-tree timestamp of a semantically changed projection and must advance. During a digest-schema migration, the current projector is applied to historical sources at the previous `lastmod`: a clean committed semantic edit advances, while an edit confined to a pruned client branch does not. Version 15 first added byte-exact binary assets, semantic CSS queries, nested global-container provenance, top-level dynamic imports, mixed-target validation, and the audited runtime package graph. Version 16 narrowed that package evidence to the trusted package roots actually reachable from each route, then required every selected root and recursively reachable registry package to have an exact importer entry, snapshot, package record, and integrity in `pnpm-lock.yaml`; missing evidence failed closed. Version 17 follows named and aliased module-evaluation callbacks, prunes statically false literal branches and loops, includes selected React `lazy` package imports, requires canonical SHA-256/384/512 SRI specifically under `resolution.integrity`, rejects conditional/nested/factory/getter global-container stores, and rejects every semantically reachable package outside the audited allowlist. Version 18 resolves module-evaluation callbacks by lexical binding identity, including nested declarations, shadowing, member-held callbacks, and the latest unconditional reassignment; it executes callbacks only at proven awaited Promise continuation points, prunes unmatched literal `switch` cases, and follows selected React `lazy` relative child modules without expanding unrelated deferred catalog entries. Registry evidence additionally requires the corresponding 32/48/64-byte digest; empty, malformed, short, sibling, or unsupported-algorithm values fail closed. Potential global containers stored through `Object`/`Reflect` or collection mutators fail closed in both runtime and public-config projections, as do class getter-derived dynamic call targets. This schema refinement preserves all 47 route times at the dependency-maintenance commit `2026-08-23T21:18:04+08:00`, because it changes evidence precision without adding a later semantic source change. Earlier rejected schemas admitted shared client effects, sidebar-only copyright copy, imported sibling noise, type-only noise, incomplete execution/scope/mutation context, lossy UTF-8 decoding of binary assets, or an unpinned or route-unrelated trusted runtime package graph. `llms.txt` is grouped by family and declares the same-URL Markdown interface and approved public content signal. `robots.txt` explicitly allows both retrieval and training crawlers; a release is incomplete until Cloudflare AI Crawl Control is confirmed not to inject a conflicting managed no-train block. `pnpm check:seo` rejects generated-artifact drift, including a hand-edited `index.html` head. # Analytics diff --git a/docs/operations/verification.md b/docs/operations/verification.md index cce046b..5f68db8 100644 --- a/docs/operations/verification.md +++ b/docs/operations/verification.md @@ -44,9 +44,9 @@ Pure TypeScript Modules imported by Node tests must use explicit `.ts` extension # SEO And Accessibility Checks -`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. Version 17 follows each route's real SSR injection chain with a line-ending-neutral runtime-AST digest: type-only syntax is erased without dropping runtime-bearing TypeScript parameter properties or optional-chain behavior; intrinsic event/ref/key props and statically proven spreads erase only deferred callback values while retaining synchronous factories; public `config/site.ts` evidence follows immutable field dependencies and validates global provenance; and mixed shell modules contribute route-rendering JSX plus the lexical closure of all runtime module-evaluation edges, root/program synchronous calls, proven synchronous callbacks and selected React `lazy` loaders, constructors, tagged templates, aliases, and render-phase mutations. Known asynchronous callbacks, including stable aliases and locally private callback-factory results, are erased even when nested inside an otherwise selected synchronous slice, while callback acquisition remains synchronous evidence and exported callbacks remain conservative. Regression probes additionally cover all-candidate mixed call targets, object and class getter-derived targets, conditional/nested/factory/getter global containers stored by later member assignment, named and aliased literal dynamic imports reached through module evaluation, statically false literal branches and loops, semantic CSS query imports, and byte-distinct binary assets. Runtime packages are trusted only through the audited dependency set used by this product; each route projects only its semantically reachable trusted roots from `package.json`, rejects reachable packages outside that allowlist, and requires every selected root and transitive registry node to have its exact importer, snapshot, package record, and canonical SHA-256/384/512 `resolution.integrity` value in `pnpm-lock.yaml`. Unsupported mutable, escaped, aliased, globally patched, unresolved, or dynamic config, unproved callback timing, dynamic targets or intrinsic spreads, imported member receivers, arbitrary external mutations, ambiguous nested writes, and incomplete lock evidence outside proven client callbacks fail closed. +`pnpm check:seo` verifies the 47-path catalog, route metadata, checked-in single-origin sitemap/robots baseline, `llms.txt`, 47-entry route summaries, and source-backed `lastmod` data. Version 18 follows each route's real SSR injection chain with a line-ending-neutral runtime-AST digest: type-only syntax is erased without dropping runtime-bearing TypeScript parameter properties or optional-chain behavior; intrinsic event/ref/key props and statically proven spreads erase only deferred callback values while retaining synchronous factories; public `config/site.ts` evidence follows immutable field dependencies and validates global provenance; and mixed shell modules contribute route-rendering JSX plus the lexical closure of all runtime module-evaluation edges, root/program synchronous calls, proven synchronous callbacks and selected React `lazy` loaders, constructors, tagged templates, aliases, and render-phase mutations. Known asynchronous callbacks, including stable aliases and locally private callback-factory results, are erased even when nested inside an otherwise selected synchronous slice, while callback acquisition remains synchronous evidence and exported callbacks remain conservative. Regression probes additionally cover all-candidate mixed call targets, object and class getter-derived targets, conditional/nested/factory/getter and built-in-mutator global containers, nested/shadowed/reassigned/member-held literal dynamic-import callbacks reached through module evaluation, awaited continuation callbacks without treating callback acquisition as execution, statically false literal branches and loops, unmatched literal `switch` cases, selected relative `lazy` children, semantic CSS query imports, and byte-distinct binary assets. Runtime packages are trusted only through the audited dependency set used by this product; each route projects only its semantically reachable trusted roots from `package.json`, rejects reachable packages outside that allowlist, and requires every selected root and transitive registry node to have its exact importer, snapshot, package record, and canonical SHA-256/384/512 `resolution.integrity` value with a 32/48/64-byte digest in `pnpm-lock.yaml`. Unsupported mutable, escaped, aliased, globally patched, unresolved, or dynamic config, unproved callback timing, dynamic targets or intrinsic spreads, imported member receivers, arbitrary external mutations, ambiguous nested writes, and incomplete lock evidence outside proven client callbacks fail closed. -Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. An unchanged digest preserves its prior time; changed content uses the exact Git commit or working-tree timestamp of a semantically changed projection and must advance. A schema migration reapplies the current projector to source history at the previous `lastmod`, so clean committed semantic edits advance while dirty changes confined to pruned client branches do not. Version 15 made the runtime dependency graph visible and advanced all 47 routes to the dependency-maintenance commit at `2026-08-23T21:18:04+08:00`; Version 16 made that evidence route-specific and fail-closed; Version 17 closes the remaining callback, package-root, integrity, global-container, and getter provenance gaps without inventing a later content time, so the 47 timestamps remain there. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, Worker-first static-asset routing, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. +Client endpoints and sidebar-only copy are structurally isolated from the public representation configuration and are unreachable from all 47 semantic graphs. An unchanged digest preserves its prior time; changed content uses the exact Git commit or working-tree timestamp of a semantically changed projection and must advance. A schema migration reapplies the current projector to source history at the previous `lastmod`, so clean committed semantic edits advance while dirty changes confined to pruned client branches do not. Version 15 made the runtime dependency graph visible and advanced all 47 routes to the dependency-maintenance commit at `2026-08-23T21:18:04+08:00`; Version 16 made that evidence route-specific and fail-closed; Version 17 closes the initial callback, package-root, integrity, global-container, and getter provenance gaps; Version 18 closes the exact-SHA review gaps in lexical module evaluation, selected relative lazy children, built-in mutators, literal switches, and SRI byte lengths without inventing a later content time, so the 47 timestamps remain there. This keeps same-day edits precise while unchanged or cross-platform regeneration remains deterministic. The gate also checks the source contracts for the one Cloudflare output, Worker-first static-asset routing, React prerender/hydration, route CSS injection, canonical metadata, and real 404 behavior. `pnpm check:html` then requires the sitemap/public-route/Markdown sets to agree; rejects missing or extra Markdown assets, browser-only markup, internal-path leakage, illegal heading hierarchies, and unresolved same-origin links; regenerates every Markdown document twice from its matching semantic HTML to prove exact same-source parity and determinism; and requires each representation to remain less than half the size of its HTML. When routes, lesson readiness, or route-owned source files change, run `pnpm seo:generate` and review the generated files before committing them. The browser gate runs Chromium against built `dist/` through a strict, non-reused custom preview server. Route tests directly open `/`, `/part/a`, `/part/a/01`, `/method`, and `/part/g/plug`, then exercise live client navigation and keyboard focus. They check HTTP status, prerendered HTML, absence of pending streamed-Suspense placeholders, route CSS at first paint, hydration without errors, CLS below `0.05`, title/description/abstract/canonical/zero-hreflang/Open Graph/JSON-LD metadata, one visible `h1`, route announcements, current-page semantics, initial-load focus, changed-route focus, and skip-link focus. Navigation-positioning tests click a representative lesson outline in each of the seven families, restore a direct lesson fragment, verify the mobile outline and an ordinary family-page fragment, and require a changed route to reset old-page scroll immediately. An unknown path must return 404 with `noindex,nofollow`, no canonical, and the themed not-found heading. Worker-level Node tests separately cover the RFC 9110 Accept matrix, UTF-8 media-parameter matching on either side of `q`, empty parameter slots, invalid whitespace and bare parameters, recovery after a broken quoted member, 406 behavior, GET/HEAD/conditional parity, representation-specific ETags, hidden internal assets, and unaffected API/static/unknown paths. diff --git a/site/scripts/last-modified.mjs b/site/scripts/last-modified.mjs index bc71fab..fd58c85 100644 --- a/site/scripts/last-modified.mjs +++ b/site/scripts/last-modified.mjs @@ -11,7 +11,7 @@ import { } from './semantic-source-graph.mjs' const projectRoot = fileURLToPath(new URL('../../', import.meta.url)) -export const ROUTE_CONTENT_DIGEST_VERSION = 17 +export const ROUTE_CONTENT_DIGEST_VERSION = 18 const historicalSourceCache = new Map() const headSourceCache = new Map() diff --git a/site/scripts/semantic-source-graph.mjs b/site/scripts/semantic-source-graph.mjs index 128f9f4..108ec3c 100644 --- a/site/scripts/semantic-source-graph.mjs +++ b/site/scripts/semantic-source-graph.mjs @@ -1061,6 +1061,28 @@ function publicSiteConfigProjection(program) { } else if (node.type === 'UnaryExpression' && node.operator === 'delete') { rejectUsedIdentity(node.argument, 'mutation of') } else if (node.type === 'CallExpression' || node.type === 'NewExpression') { + if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') { + const owner = unwrap(node.callee.object) + const method = memberNameFromNode(node.callee) + const builtinOwner = owner?.type === 'Identifier' + && !bindings.has(owner.name) + ? owner.name + : null + const storedValues = builtinOwner === 'Object' && method === 'assign' + ? node.arguments?.slice(1) ?? [] + : builtinOwner === 'Object' && method === 'defineProperties' + ? node.arguments?.slice(1, 2) ?? [] + : builtinOwner === 'Object' && method === 'defineProperty' + ? node.arguments?.slice(2, 3) ?? [] + : builtinOwner === 'Reflect' && method === 'set' + ? node.arguments?.slice(2, 3) ?? [] + : builtinOwner === 'Reflect' && method === 'defineProperty' + ? node.arguments?.slice(2, 3) ?? [] + : [] + if (storedValues.some((argument) => mayContainGlobalContainer(argument))) { + unsupported('member-stored global container') + } + } const safeObjectFreeze = node.type === 'CallExpression' && node.callee.type === 'MemberExpression' && node.callee.object.type === 'Identifier' @@ -1177,50 +1199,248 @@ function staticNullishValue(node) { return null } +function staticLiteralValue(node) { + if (node && EXPRESSION_WRAPPERS.has(node.type)) return staticLiteralValue(node.expression) + if (node?.type === 'Literal' || node?.type === 'BooleanLiteral') { + return { known: true, value: node.value } + } + if (node?.type === 'TemplateLiteral' && node.expressions.length === 0) { + return { + known: true, + value: node.quasis[0]?.value?.cooked ?? node.quasis[0]?.value?.raw ?? '', + } + } + if (node?.type === 'UnaryExpression' && node.operator === 'void') { + return { known: true, value: undefined } + } + if (node?.type === 'SequenceExpression') return staticLiteralValue(node.expressions.at(-1)) + return { known: false, value: undefined } +} + function moduleEvaluationDynamicImportSources(program, path) { + const pendingNodes = [program] + let hasDynamicImport = false + while (pendingNodes.length > 0 && !hasDynamicImport) { + const candidate = pendingNodes.pop() + if (candidate?.type === 'ImportExpression') { + hasDynamicImport = true + break + } + for (const [child] of childNodes(candidate)) pendingNodes.push(child) + } + if (!hasDynamicImport) return [] + const sources = [] const functionTypes = new Set([ 'ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression', ]) - const callableDefinitions = new Map() - const callableAliases = new Map() + const nodeScopes = new WeakMap() const activeFunctions = new Set() + const createEvaluationScope = (parent, kind) => ({ parent, kind, bindings: new Map() }) + const rootScope = createEvaluationScope(null, 'program') + + function nearestEvaluationVarScope(scope) { + let current = scope + while (current.parent && current.kind !== 'function' && current.kind !== 'program') { + current = current.parent + } + return current + } - const register = (map, name, value) => { - if (!name) return - const values = map.get(name) ?? new Set() - values.add(value) - map.set(name, values) + function evaluationBinding(scope, name) { + const existing = scope.bindings.get(name) + if (existing) return existing + const binding = { name, writes: [] } + scope.bindings.set(name, binding) + return binding } - for (const statement of program.body ?? []) { - const declaration = statement.type === 'ExportNamedDeclaration' - ? statement.declaration - : statement - if (declaration?.type === 'FunctionDeclaration') { - register(callableDefinitions, declaration.id?.name, declaration) + + function declareEvaluationPattern(pattern, scope, initializer = null, start = null) { + for (const identifier of bindingIdentifiers(pattern)) { + const binding = evaluationBinding(scope, identifier.name) + if (initializer) binding.writes.push({ expression: initializer, start: start ?? initializer.start }) } - if (declaration?.type !== 'VariableDeclaration') continue - for (const declarator of declaration.declarations ?? []) { - if (declarator.id?.type !== 'Identifier' || !declarator.init) continue - if (functionTypes.has(declarator.init.type)) { - register(callableDefinitions, declarator.id.name, declarator.init) - } else if (declarator.init.type === 'Identifier') { - register(callableAliases, declarator.id.name, declarator.init.name) + } + + function buildEvaluationScopes(node, scope, { functionBody = false } = {}) { + if (!node || typeof node !== 'object') return + nodeScopes.set(node, scope) + if (node.type === 'Program') { + for (const [child] of childNodes(node)) buildEvaluationScopes(child, scope) + return + } + if (functionTypes.has(node.type)) { + if (node.type === 'FunctionDeclaration' && node.id) { + const binding = evaluationBinding(scope, node.id.name) + binding.writes.push({ expression: node, start: -1 }) + } + const functionScope = createEvaluationScope(scope, 'function') + if (node.type === 'FunctionExpression' && node.id) { + const binding = evaluationBinding(functionScope, node.id.name) + binding.writes.push({ expression: node, start: -1 }) + } + for (const parameter of node.params ?? []) { + declareEvaluationPattern(parameter, functionScope) + buildEvaluationScopes(parameter, functionScope) + } + if (node.body) buildEvaluationScopes(node.body, functionScope, { functionBody: true }) + return + } + if (node.type === 'BlockStatement') { + const blockScope = functionBody ? scope : createEvaluationScope(scope, 'block') + for (const [child] of childNodes(node)) buildEvaluationScopes(child, blockScope) + return + } + if (node.type === 'VariableDeclaration') { + const declarationScope = node.kind === 'var' ? nearestEvaluationVarScope(scope) : scope + for (const declarator of node.declarations ?? []) { + declareEvaluationPattern(declarator.id, declarationScope, declarator.init, declarator.start) + } + for (const [child] of childNodes(node)) buildEvaluationScopes(child, scope) + return + } + if (node.type === 'ImportDeclaration') { + for (const specifier of node.specifiers ?? []) { + if (specifier.local) declareEvaluationPattern(specifier.local, scope) } } + if (node.type === 'ClassDeclaration' && node.id) { + declareEvaluationPattern(node.id, scope) + } + if (node.type === 'CatchClause') { + const catchScope = createEvaluationScope(scope, 'block') + if (node.param) declareEvaluationPattern(node.param, catchScope) + for (const [child] of childNodes(node)) buildEvaluationScopes(child, catchScope) + return + } + if ( + node.type === 'ForStatement' + || node.type === 'ForInStatement' + || node.type === 'ForOfStatement' + || node.type === 'SwitchStatement' + ) { + const lexicalScope = createEvaluationScope(scope, 'block') + for (const [child] of childNodes(node)) buildEvaluationScopes(child, lexicalScope) + return + } + for (const [child] of childNodes(node)) buildEvaluationScopes(child, scope) } - function resolvedCallables(name, seen = new Set()) { - if (!name || seen.has(name)) return [] - const nextSeen = new Set([...seen, name]) - return [ - ...(callableDefinitions.get(name) ?? []), - ...[...(callableAliases.get(name) ?? [])].flatMap((alias) => ( - resolvedCallables(alias, nextSeen) - )), - ] + buildEvaluationScopes(program, rootScope) + + function lookupEvaluationBinding(identifier) { + let scope = nodeScopes.get(identifier) + while (scope) { + const binding = scope.bindings.get(identifier.name) + if (binding) return binding + scope = scope.parent + } + return null + } + + function indexEvaluationWrites(node) { + if ( + node.type === 'AssignmentExpression' + && node.operator === '=' + && node.left.type === 'Identifier' + ) { + const binding = lookupEvaluationBinding(node.left) + if (binding) binding.writes.push({ expression: node.right, start: node.start }) + } + for (const [child] of childNodes(node)) indexEvaluationWrites(child) + } + indexEvaluationWrites(program) + + function latestBindingExpressions(identifier, seen = new Set()) { + const binding = lookupEvaluationBinding(identifier) + if (!binding || seen.has(binding)) return [] + const eligible = binding.writes.filter((write) => write.start <= identifier.start) + if (eligible.length === 0) return [] + const latestStart = Math.max(...eligible.map((write) => write.start)) + return eligible + .filter((write) => write.start === latestStart) + .map((write) => write.expression) + } + + function staticEvaluationMemberValues(node, key, seen = new Set()) { + const value = node && EXPRESSION_WRAPPERS.has(node.type) ? node.expression : node + if (!value || typeof value !== 'object') return [] + if (value.type === 'Identifier') { + const binding = lookupEvaluationBinding(value) + if (!binding || seen.has(binding)) return [] + const nextSeen = new Set([...seen, binding]) + return latestBindingExpressions(value, seen).flatMap((expression) => ( + staticEvaluationMemberValues(expression, key, nextSeen) + )) + } + if (value.type === 'ConditionalExpression') { + return [value.consequent, value.alternate].flatMap((branch) => ( + staticEvaluationMemberValues(branch, key, seen) + )) + } + if (value.type === 'LogicalExpression') { + return [value.left, value.right].flatMap((branch) => ( + staticEvaluationMemberValues(branch, key, seen) + )) + } + if (value.type === 'SequenceExpression') { + return staticEvaluationMemberValues(value.expressions.at(-1), key, seen) + } + if (value.type === 'MemberExpression') { + const nestedKey = memberNameFromNode(value) + if (nestedKey === null) return [] + return staticEvaluationMemberValues(value.object, nestedKey, seen).flatMap((member) => ( + staticEvaluationMemberValues(member, key, seen) + )) + } + if (value.type === 'ArrayExpression' && /^\d+$/u.test(String(key))) { + const element = value.elements[Number(key)] + return element && element.type !== 'SpreadElement' ? [element] : [] + } + if (value.type !== 'ObjectExpression') return [] + return value.properties.flatMap((property) => { + if (property.type === 'SpreadElement') { + return staticEvaluationMemberValues(property.argument, key, seen) + } + if (property.type !== 'Property' || staticPropertyNameFromNode(property) !== String(key)) { + return [] + } + if (property.kind === 'get') { + throw new Error(`Unsupported dynamic module-evaluation callback in ${path}`) + } + return [property.value] + }) + } + + function resolvedCallables(node, seen = new Set()) { + const value = node && EXPRESSION_WRAPPERS.has(node.type) ? node.expression : node + if (!value || typeof value !== 'object') return [] + if (functionTypes.has(value.type)) return [value] + if (value.type === 'Identifier') { + const binding = lookupEvaluationBinding(value) + if (!binding || seen.has(binding)) return [] + const nextSeen = new Set([...seen, binding]) + return latestBindingExpressions(value, seen).flatMap((expression) => ( + resolvedCallables(expression, nextSeen) + )) + } + if (value.type === 'MemberExpression') { + const key = memberNameFromNode(value) + if (key === null) return [] + return staticEvaluationMemberValues(value.object, key, seen).flatMap((member) => ( + resolvedCallables(member, seen) + )) + } + if (value.type === 'ConditionalExpression') { + return [value.consequent, value.alternate].flatMap((branch) => resolvedCallables(branch, seen)) + } + if (value.type === 'LogicalExpression') { + return [value.left, value.right].flatMap((branch) => resolvedCallables(branch, seen)) + } + if (value.type === 'SequenceExpression') return resolvedCallables(value.expressions.at(-1), seen) + return [] } function executeCallable(callable, options = {}) { @@ -1273,6 +1493,45 @@ function moduleEvaluationDynamicImportSources(program, path) { ) visit(node.right) return } + if (node.type === 'SwitchStatement') { + visit(node.discriminant) + const discriminant = staticLiteralValue(node.discriminant) + if (!discriminant.known) { + for (const switchCase of node.cases ?? []) visit(switchCase) + return + } + let selectedIndex = -1 + let defaultIndex = -1 + let hasUnknownCase = false + for (const [index, switchCase] of (node.cases ?? []).entries()) { + if (!switchCase.test) { + defaultIndex = index + continue + } + const caseValue = staticLiteralValue(switchCase.test) + if (!caseValue.known) { + hasUnknownCase = true + break + } + if (caseValue.known && Object.is(caseValue.value, discriminant.value)) { + selectedIndex = index + break + } + } + if (hasUnknownCase) { + for (const switchCase of node.cases ?? []) visit(switchCase) + return + } + const startIndex = selectedIndex >= 0 ? selectedIndex : defaultIndex + if (startIndex < 0) return + for (const switchCase of node.cases.slice(startIndex)) { + for (const consequent of switchCase.consequent ?? []) { + if (consequent.type === 'BreakStatement') return + visit(consequent) + } + } + return + } if (node.type === 'WhileStatement') { visit(node.test) if (staticBooleanValue(node.test) !== false) visit(node.body) @@ -1292,20 +1551,23 @@ function moduleEvaluationDynamicImportSources(program, path) { return } if (node.type === 'CallExpression' || node.type === 'NewExpression') { - const immediateFunction = functionTypes.has(node.callee?.type) - visit(node.callee, { executeFunction: immediateFunction, awaitedCall }) - if (node.callee?.type === 'Identifier') { - for (const callable of resolvedCallables(node.callee.name)) { - executeCallable(callable, { awaitedCall }) - } + visit(node.callee) + const directCallables = resolvedCallables(node.callee) + for (const callable of directCallables) { + executeCallable(callable, { awaitedCall }) } + const awaitedCallbackMethod = awaitedCall + && node.callee?.type === 'MemberExpression' + && ['catch', 'finally', 'then'].includes(memberNameFromNode(node.callee)) for (const argument of node.arguments ?? []) { if (functionTypes.has(argument?.type)) { - if (awaitedCall) visit(argument, { executeFunction: true, awaitedCall: true }) - } else if (awaitedCall && argument?.type === 'Identifier') { - for (const callable of resolvedCallables(argument.name)) { + if (awaitedCallbackMethod) visit(argument, { executeFunction: true, awaitedCall: true }) + } else if (awaitedCallbackMethod) { + const callbacks = resolvedCallables(argument) + for (const callable of callbacks) { executeCallable(callable, { awaitedCall: true }) } + visit(argument) } else { visit(argument) } @@ -2191,7 +2453,7 @@ function semanticBindingGraph(program, path, analysisContext = {}) { )) } - function isKnownSynchronousCallbackCall(call) { + function isReactLazyCall(call) { const callee = unwrapExpression(call.callee) if (callee?.type === 'Identifier') { const binding = lookup(callee) @@ -2215,6 +2477,12 @@ function semanticBindingGraph(program, path, analysisContext = {}) { && ![...binding.aliases].some((alias) => patchedReactBindings.has(alias)) ) return true } + return false + } + + function isKnownSynchronousCallbackCall(call) { + const callee = unwrapExpression(call.callee) + if (isReactLazyCall(call)) return true if (callee?.type !== 'MemberExpression') return false if ( isUnboundGlobalIdentifier(callee.object, new Set(['Array'])) @@ -3239,6 +3507,42 @@ function semanticBindingGraph(program, path, analysisContext = {}) { } if (node.type === 'CallExpression' && !isReactEffectCall(node)) { const callee = node.callee + if (callee.type === 'MemberExpression') { + const method = memberName(callee) + const staticOwner = callee.object.type === 'Identifier' + && !lookup(callee.object) + ? callee.object.name + : null + const storedValues = staticOwner === 'Object' && method === 'assign' + ? node.arguments?.slice(1) ?? [] + : staticOwner === 'Object' && method === 'defineProperties' + ? node.arguments?.slice(1, 2) ?? [] + : staticOwner === 'Object' && method === 'defineProperty' + ? node.arguments?.slice(2, 3) ?? [] + : staticOwner === 'Reflect' && method === 'set' + ? node.arguments?.slice(2, 3) ?? [] + : staticOwner === 'Reflect' && method === 'defineProperty' + ? node.arguments?.slice(2, 3) ?? [] + : ['push', 'unshift'].includes(method) + ? node.arguments ?? [] + : method === 'splice' + ? node.arguments?.slice(2) ?? [] + : method === 'fill' + ? node.arguments?.slice(0, 1) ?? [] + : method === 'set' + ? node.arguments?.slice(1, 2) ?? [] + : method === 'add' + ? node.arguments?.slice(0, 1) ?? [] + : [] + if (storedValues.some((argument) => mayContainUnboundGlobalContainer(argument))) { + unsupportedRootCalls.push({ + executionScope, + message: 'Unsupported member-stored global container', + mutationNode: node, + semanticNode, + }) + } + } const provenAsyncCallbackIndexes = new Set( isKnownAsyncCallbackCall(node) ? clientAsyncCallbackIndexes(node) : [], ) @@ -3755,6 +4059,7 @@ function semanticBindingGraph(program, path, analysisContext = {}) { isClientEffectCall: isReactEffectCall, isClientOnlyIntrinsicAttribute, isPureDeferredValue, + isReactLazyCall, isReference, lookup, moduleEvaluationEdges, @@ -3829,7 +4134,8 @@ function nestedYamlValue(entry, name, sectionIndent) { } function registryResolutionIntegrity(entry) { - const supportedIntegrity = /^(?:sha256|sha384|sha512)-([A-Za-z0-9+/]+={0,2})$/u + const supportedIntegrity = /^(sha256|sha384|sha512)-([A-Za-z0-9+/]+={0,2})$/u + const digestLengths = { sha256: 32, sha384: 48, sha512: 64 } for (let index = 1; index < entry.lines.length; index += 1) { const resolution = yamlMapEntry(entry.lines[index], 4) if (resolution?.key !== 'resolution') continue @@ -3853,7 +4159,9 @@ function registryResolutionIntegrity(entry) { const match = integrity?.match(supportedIntegrity) if (!match) return null try { - return Buffer.from(match[1], 'base64').toString('base64') === match[1] + const digest = Buffer.from(match[2], 'base64') + return digest.length === digestLengths[match[1]] + && digest.toString('base64') === match[2] ? integrity : null } catch { @@ -3981,6 +4289,7 @@ export function semanticSourceForDigest(path, source, options = {}) { } const bindingGraph = semanticBindingGraph(program, path) const runtimePackageSpecifiers = options.runtimePackageSpecifiers ?? new Set() + const semanticImportSpecifiers = options.semanticImportSpecifiers const recordRuntimePackageSpecifier = (specifier) => { if (typeof specifier !== 'string' || specifier.startsWith('.')) { return @@ -3998,19 +4307,39 @@ export function semanticSourceForDigest(path, source, options = {}) { } return projection } - const collectRuntimePackageSpecifiers = (node) => { + const collectRuntimePackageSpecifiers = (node, { selectedLazy = false } = {}) => { if (!runtimePackageSpecifiers || !node || typeof node !== 'object') return if (bindingGraph.isClientOnlyDeferredValue(node)) return if (bindingGraph.isClientOnlyIntrinsicAttribute(node)) return + if ( + node.type === 'VariableDeclarator' + && node.init?.type === 'CallExpression' + && bindingGraph.isReactLazyCall(node.init) + ) { + collectRuntimePackageSpecifiers(node.init, { selectedLazy: true }) + return + } + if ( + node.type === 'AssignmentExpression' + && node.operator === '=' + && node.right?.type === 'CallExpression' + && bindingGraph.isReactLazyCall(node.right) + ) { + collectRuntimePackageSpecifiers(node.right, { selectedLazy: true }) + return + } if (node.type === 'ImportExpression') { if (typeof node.source?.value !== 'string') { throw new Error(`Unsupported dynamic semantic runtime import in ${path}`) } - recordRuntimePackageSpecifier(node.source.value) + if (selectedLazy) { + semanticImportSpecifiers?.add(node.source.value) + recordRuntimePackageSpecifier(node.source.value) + } return } for (const [child] of bindingGraph.childNodes(node)) { - collectRuntimePackageSpecifiers(child) + collectRuntimePackageSpecifiers(child, { selectedLazy }) } } const projectionOptions = { @@ -4242,7 +4571,11 @@ function semanticRouteEvidence(pathname) { if (PARSED_EXTENSIONS.has(extname(relativePath))) { const source = readFileSync(path, 'utf8') const runtimePackageSpecifiers = new Set() - semanticSourceForDigest(relativePath, source, { runtimePackageSpecifiers }) + const semanticImportSpecifiers = new Set() + semanticSourceForDigest(relativePath, source, { + runtimePackageSpecifiers, + semanticImportSpecifiers, + }) for (const specifier of runtimePackageSpecifiers) { const runtimePackage = trustedRuntimePackageName(specifier) if (!runtimePackage) { @@ -4250,6 +4583,11 @@ function semanticRouteEvidence(pathname) { } runtimePackages.add(runtimePackage) } + for (const specifier of semanticImportSpecifiers) { + if (!specifier.startsWith('.')) continue + const dependency = resolveStaticImport(path, specifier) + if (dependency) pending.push(dependency) + } for (const specifier of staticImportSpecifiersForSource(relativePath, source)) { if (!specifier.startsWith('.')) continue diff --git a/site/scripts/semantic-source-graph.test.mjs b/site/scripts/semantic-source-graph.test.mjs index d13c950..3740e53 100644 --- a/site/scripts/semantic-source-graph.test.mjs +++ b/site/scripts/semantic-source-graph.test.mjs @@ -608,6 +608,10 @@ test('mixed-module closure tracks every supported mutation of selected bindings' `let label = '正文'\nconst holder = {}\nholder.nested = { g: globalThis }\nholder.nested.g.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, `let label = '正文'\nconst holder = {}\nfunction getGlobal() { return globalThis }\nholder.g = getGlobal()\nholder.g.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, `let label = '正文'\nconst holder = {}\nconst source = { get value() { return globalThis } }\nholder.g = source.value\nholder.g.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, + `let label = '正文'\nconst holder = { nested: {} }\nObject.assign(holder.nested, { g: globalThis })\nholder.nested.g.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, + `let label = '正文'\nconst holder = { nested: {} }\nObject.defineProperty(holder.nested, 'g', { value: globalThis })\nholder.nested.g.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, + `let label = '正文'\nconst holder = { nested: {} }\nReflect.set(holder.nested, 'g', globalThis)\nholder.nested.g.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, + `let label = '正文'\nconst holder = []\nholder.push(globalThis)\nholder[0].setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, ]) { assert.throws( () => semanticSourceForDigest('site/src/app/AppContent.tsx', indirectMemberStoredGlobal), @@ -1214,6 +1218,18 @@ test('public config rejects alias mutation, pattern writes, and patched freeze', `const holder = {}\nconst source = { get value() { return globalThis } }\nholder.g = source.value\nholder.g.Object = { freeze(value) { value.name = '运行时'; return value } }`, `name: '初始'`, ), + publicConfigSource( + `const holder = { nested: {} }\nObject.assign(holder.nested, { g: globalThis })\nholder.nested.g.Object = { freeze(value) { value.name = '运行时'; return value } }`, + `name: '初始'`, + ), + publicConfigSource( + `const holder = { nested: {} }\nObject.defineProperty(holder.nested, 'g', { value: globalThis })\nholder.nested.g.Object = { freeze(value) { value.name = '运行时'; return value } }`, + `name: '初始'`, + ), + publicConfigSource( + `const holder = { nested: {} }\nReflect.set(holder.nested, 'g', globalThis)\nholder.nested.g.Object = { freeze(value) { value.name = '运行时'; return value } }`, + `name: '初始'`, + ), ]) { assert.throws( () => semanticSourceForDigest('site/src/config/site.ts', mutableConfig), @@ -1241,6 +1257,41 @@ test('static dependency discovery follows literal top-level dynamic imports and ), ['./runtime'], ) + assert.deepEqual( + staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `async function outer() { const load = () => import('./Child'); await load() }\nouter()`, + ), + ['./Child'], + ) + assert.deepEqual( + staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `const load = () => import('katex')\nasync function outer() { function load() {} await load() }\nouter()`, + ), + [], + ) + assert.deepEqual( + staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `await Promise.resolve(() => import('katex'))`, + ), + [], + ) + assert.deepEqual( + staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `const loaders = { run: () => import('./Child') }\nawait Promise.resolve().then(loaders.run)`, + ), + ['./Child'], + ) + assert.deepEqual( + staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `let load = () => import('./Home')\nload = () => import('./Method')\nawait load()`, + ), + ['./Method'], + ) assert.deepEqual( staticImportSpecifiersForSource( 'site/src/app/AppContent.tsx', @@ -1306,6 +1357,13 @@ test('static dependency discovery follows literal top-level dynamic imports and deadBranch, ) } + assert.deepEqual( + staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `switch (1) { case 2: import('katex'); break }`, + ), + [], + ) }) test('semantic package evidence rejects unknown packages and follows selected lazy imports', () => { @@ -1323,12 +1381,22 @@ test('semantic package evidence rejects unknown packages and follows selected la } const runtimePackageSpecifiers = new Set() + const semanticImportSpecifiers = new Set() semanticSourceForDigest( 'site/src/app/AppContent.tsx', `import { lazy } from 'react'\nconst Icon = lazy(() => import('lucide-react'))\nfunction AppContent() { return }`, - { runtimePackageSpecifiers }, + { runtimePackageSpecifiers, semanticImportSpecifiers }, ) assert.deepEqual([...runtimePackageSpecifiers].sort(), ['lucide-react', 'react']) + assert.deepEqual([...semanticImportSpecifiers], ['lucide-react']) + + const relativeSemanticImports = new Set() + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + `import { lazy } from 'react'\nconst View = lazy(() => import('./Child'))\nfunction AppContent() { return }`, + { semanticImportSpecifiers: relativeSemanticImports }, + ) + assert.deepEqual([...relativeSemanticImports], ['./Child']) }) test('static dependency discovery strips Vite query and hash suffixes', () => { @@ -1403,11 +1471,11 @@ importers: version: 4.1.11 packages: loose-envify@1.4.0: - resolution: {integrity: sha512-cnVudGltZS1vbmU=} + resolution: {integrity: sha512-JRXu9u5IaP6L7ojL5HGIPUTtKOy+3eOYlwW08+edE5tRNFk7BQLjXJ2f4k/W3gCuzl2SXzeW5kR4h3RQ9WYTmw==} react@19.2.8: - resolution: {integrity: sha512-cmVhY3Qtb25l} + resolution: {integrity: sha512-pdBTuBIiIq6ICUAgspm42tMvNa5kaWM9QGO2iizUvhfugjjIdNLJrQI7HRku/RswNK9NkxL4sTiZOQnHzY38Vg==} vitest@4.1.11: - resolution: {integrity: sha512-dGVzdC1vbmU=} + resolution: {integrity: sha512-6wXHBFSd0w9CGUZNY9y8kwsZgmcG2DsY+ku3fIRabFn/AlA9y1dorDEJdHw+HrvpbgTLDb9bAXTAqlk5XhjRug==} snapshots: loose-envify@1.4.0: {} react@19.2.8: @@ -1419,14 +1487,20 @@ snapshots: lockProjection, semanticSourceForDigest( 'site/pnpm-lock.yaml', - lockSource.replace('sha512-cnVudGltZS1vbmU=', 'sha512-cnVudGltZS10d28='), + lockSource.replace( + 'sha512-JRXu9u5IaP6L7ojL5HGIPUTtKOy+3eOYlwW08+edE5tRNFk7BQLjXJ2f4k/W3gCuzl2SXzeW5kR4h3RQ9WYTmw==', + 'sha512-hAKZnwK0TlZOH7R1+W6I7GL+LdvsEcgBASecV4nRlDgLp5g+C8G7sHWLmI4+bO2kzdTJjTmzR+BEjZ72taEplA==', + ), ), ) assert.equal( lockProjection, semanticSourceForDigest( 'site/pnpm-lock.yaml', - lockSource.replace('sha512-dGVzdC1vbmU=', 'sha512-dGVzdC10d28='), + lockSource.replace( + 'sha512-6wXHBFSd0w9CGUZNY9y8kwsZgmcG2DsY+ku3fIRabFn/AlA9y1dorDEJdHw+HrvpbgTLDb9bAXTAqlk5XhjRug==', + 'sha512-G0HZh9nVyIeffTOZmtwLJidbk2FIw9UCsHCwIJFHtdHzjMrsZUJrxlwUHqQqlGY/9KmU0otwCkc3hhDmXfg13A==', + ), ), ) assert.throws( @@ -1440,7 +1514,7 @@ snapshots: () => semanticSourceForDigest( 'site/pnpm-lock.yaml', lockSource.replace( - ' loose-envify@1.4.0:\n resolution: {integrity: sha512-cnVudGltZS1vbmU=}\n', + ' loose-envify@1.4.0:\n resolution: {integrity: sha512-JRXu9u5IaP6L7ojL5HGIPUTtKOy+3eOYlwW08+edE5tRNFk7BQLjXJ2f4k/W3gCuzl2SXzeW5kR4h3RQ9WYTmw==}\n', '', ), ), @@ -1449,7 +1523,10 @@ snapshots: assert.throws( () => semanticSourceForDigest( 'site/pnpm-lock.yaml', - lockSource.replace('resolution: {integrity: sha512-cmVhY3Qtb25l}', 'resolution: {}'), + lockSource.replace( + 'resolution: {integrity: sha512-pdBTuBIiIq6ICUAgspm42tMvNa5kaWM9QGO2iizUvhfugjjIdNLJrQI7HRku/RswNK9NkxL4sTiZOQnHzY38Vg==}', + 'resolution: {}', + ), ), /Missing registry integrity/, ) @@ -1457,14 +1534,17 @@ snapshots: 'resolution: {integrity: ""}', 'resolution: {integrity: nope}', 'resolution: {integrity: sha512-a}', - 'resolution: {integrity: md5-cmVhY3Qtb25l}', - 'resolution: {}\n metadata: {integrity: sha512-cmVhY3Qtb25l}', + 'resolution: {integrity: sha256-YQ==}', + 'resolution: {integrity: sha384-YQ==}', + 'resolution: {integrity: sha512-YQ==}', + 'resolution: {integrity: md5-pdBTuBIiIq6ICUAgspm42tMvNa5kaWM9QGO2iizUvhfugjjIdNLJrQI7HRku/RswNK9NkxL4sTiZOQnHzY38Vg==}', + 'resolution: {}\n metadata: {integrity: sha512-pdBTuBIiIq6ICUAgspm42tMvNa5kaWM9QGO2iizUvhfugjjIdNLJrQI7HRku/RswNK9NkxL4sTiZOQnHzY38Vg==}', ]) { assert.throws( () => semanticSourceForDigest( 'site/pnpm-lock.yaml', lockSource.replace( - 'resolution: {integrity: sha512-cmVhY3Qtb25l}', + 'resolution: {integrity: sha512-pdBTuBIiIq6ICUAgspm42tMvNa5kaWM9QGO2iizUvhfugjjIdNLJrQI7HRku/RswNK9NkxL4sTiZOQnHzY38Vg==}', invalidResolution, ), ), diff --git a/site/scripts/seo-contract.test.mjs b/site/scripts/seo-contract.test.mjs index 3887f82..06ef60b 100644 --- a/site/scripts/seo-contract.test.mjs +++ b/site/scripts/seo-contract.test.mjs @@ -232,7 +232,7 @@ test('discovery files expose the 47 approved URLs and real summaries', async () assert.match(lastModified, /--format=%cI/) assert.match(lastModified, /normalizeContentForDigest/) assert.match(lastModified, /semanticRouteFiles/) - assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 17) + assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 18) assert.equal(Object.keys(ROUTE_CONTENT_DIGESTS).length, PUBLIC_PATHS.length) assert.ok(Object.values(ROUTE_CONTENT_DIGESTS).every((digest) => /^[0-9a-f]{64}$/.test(digest))) assert.match(publicRoutes, /\.\.\/data\/catalog\.ts/) diff --git a/site/src/data/routeLastModified.ts b/site/src/data/routeLastModified.ts index 97fa315..ead236a 100644 --- a/site/src/data/routeLastModified.ts +++ b/site/src/data/routeLastModified.ts @@ -1,5 +1,5 @@ // 由 scripts/generate-seo.mjs 从源码时间证据生成,请勿手改。 -export const ROUTE_CONTENT_DIGEST_VERSION = 17 +export const ROUTE_CONTENT_DIGEST_VERSION = 18 export const ROUTE_LAST_MODIFIED: Readonly> = Object.freeze({ "/": "2026-08-23T21:18:04+08:00", "/part/a": "2026-08-23T21:18:04+08:00", @@ -58,43 +58,43 @@ export const ROUTE_CONTENT_DIGESTS: Readonly> = Object.fr "/part/e": "91fd27fe4c911cc354896fc38ad5e2b46b7f74940dbb1ab9e6898f16627f034d", "/part/f": "4621b866bcb4edee102a5950975da5b2c17e38b13726a14f177e9788db525568", "/part/g": "fe56dd93b6eab109e1afd480ae3813e899537c14d30ca886d1a5591a8a20b9b7", - "/part/a/01": "17007d7250210c95320f9ed52b40124004cbe815f841d963aad360f8866b5964", - "/part/a/complete": "72a48644c2b390362480cc49dcf32064b3542a0414d39561322336b2e7390339", - "/part/a/multiple": "45115a4d8cdd35e9a9ce80a4462478ce0b0bb7e409e05597213a60cf9fa1f4db", - "/part/a/group": "8d71a32d426904e29e32bbb8984706c28d20f7a144312f44d301546701392a2f", - "/part/a/mixed": "ff1392901c10c8697eae65ba203a2999f5ef30fe9ea2cd06892946327153226b", - "/part/a/cost2d": "e6c8bca352c51b72a7f4d70d374a56a3a5ed84bf3c990fd6574b9196adb951bf", - "/part/a/dep": "c5e87e9539b90c99f54c97af658d7c0a94ab4baa8419c44bcd1f2a878d3e46a1", - "/part/a/variant": "f89d418f92be43c112b62d7026376ac7f6598ae81e3dac2f0a066a286611ae1d", - "/part/a/fractional": "37d89f588f69959308b8aa2f173ca0172b24a13f3e6f9fa221ac627cf799f79e", - "/part/b/path": "a29a4db9d83a0737339c250bf6248c4aee592a90f2f05a43638f9396855292c9", - "/part/b/maxseg": "da4a2e76becb37d8fdeabe0b3d12b272b434bf0807d114c6ec0f8e68b1885a90", - "/part/b/lis": "c6359d5a6f8165f9befce427ead46bd89e3f1c2cc99487fd5dd5b5584a4590a9", - "/part/b/lcs": "d2cb15f573c71303b4612afc9cf4e8da51b9b653c2870cbe64d70f6fb2e15989", - "/part/b/edit": "6f2b56e43ae8e06fd940553e0df6d64293138355895316e83eeae0a63a82e7c0", - "/part/b/fsm": "56e3e2c58a43c8cc538dcfd47158ef673b4440b271e4cf481ad3e9399666b15e", - "/part/b/count": "4595771173ccac7e7f398fb0e285a702019593a7000810885883a73d8d6608a9", - "/part/c/stone": "b4977cdf115fcaba7a0ae580dd6ad41ef81cf7bf61571015eb3951305098ef2e", - "/part/c/ring": "e12226c388747f0dbe0fe2ff1964a03b15dc26fb542f96b14aa91d363a601713", - "/part/c/palindrome": "52a7af455822718902fedf8f103318480f72699a427264ff7c96d17ca22c4f12", - "/part/c/tree": "dacb5db8bae0ab92e637481033c31732784dc7a95950e21313995c554a99abfc", - "/part/c/merge": "601dd07238d2f46fa9888c455404fe6acf714931beee17dbf8c5b1e3c1ccbbec", - "/part/d/grid": "a78d79b3bb075600f426877095574b961e45b357692e1c223234e0e778c35be4", - "/part/d/matpow": "262a8a3cade549f7fd26ca1adccac53147a184bd613ceddf21006922e45a1440", - "/part/e/basic": "8f613e44b164318c274267d9458ed18c573887663f39479e393b790f7abe6abe", - "/part/e/distsum": "a8fd017ce4c5ed536f5b6e3d71a1c231f164afc2f2aa17821f0600e596c1009e", - "/part/e/inout": "162061cc155affe2562307c63439b399d2d19d62c8cffd3cf726ca310349e9f0", - "/part/e/center": "7352dc82635f87a47805bd7dc4f640c208a1236e4bf0d4153cb65c73dfe0e2f5", - "/part/f/select": "302995185c4f508e2d4bf931bf16a76cd8e806c2b6296a8b8acd74d7343900b3", - "/part/f/knapsack": "9dff85361d77114bd4c4a3fbbd8b17fcf30846bd480d564f835fe7eb7fa45544", - "/part/f/diameter": "b30285c1b6e0c47b71ca795df334d273e15260ff74718698e8a3868d64cb009c", - "/part/f/cover": "ed44662aab46cf467396274256141bb27b83107c1fcf59a1607d9745aff375e6", - "/part/f/count": "76d35b10d2335bbdd73bba782592a3b1a5d818f91a063f4d98485cd0c53cc659", - "/part/g/board": "16c8fa2b7cbedab414152b59ccf0636154e379c428e691d2e01c407fbf488dea", - "/part/g/tsp": "8348d29319e0a951f3817111fa540040ee643dc2dd1fe2ced2c8267fde4f5999", - "/part/g/cover": "7745468b0d64ec742f4c7c4abcbbe9d00acd539504b842ed830427cafae4b80b", - "/part/g/subset": "e4b4a95278d12a41fa5b2620d7853be1b5b9ddb63311d38154ba7c26592f294c", - "/part/g/plug": "1a7e28cb2b48b9d6129e57dcc17842be019f9011c616a56c1f1619612715b6a3", - "/method": "4a48c27b5cc5d7d03d42f0a8a44618c6f400a632451b2ca379483601718acf9e", + "/part/a/01": "b8496f8c8b1d9cec3382c315ca126bf805ce5a06d107b30900842b2b916dcd1a", + "/part/a/complete": "83d566a9f8cde651812bd2f32f294fc1d01cd4a61312ee1f7fa4acb23698a390", + "/part/a/multiple": "3ccdd7583bf1dd44a5b9d669e1f6d5177a8d7df0846808791a585b1db57fcbc7", + "/part/a/group": "49e476b8d8ec720f11ccf15110b4a76c8e0fa8c5d5142e973ecb5223cbba62f3", + "/part/a/mixed": "d2f5e4571aa9c5a77bde069b4af4bd98a01c4cc2a3446a353131e561f329ff0b", + "/part/a/cost2d": "711448bafc22149bf4da25d8153818ff2cd6e2e46f9ff3ecb35e2e482d8f2d1e", + "/part/a/dep": "cdfde259ee224873dbe183fee4aeaad9fa7d04caeb7f86b58058ddf86ad2ec7b", + "/part/a/variant": "dad5d0e8cfe609667b7adf0d2826738251f837c2113d9b010c9c3a2098326f37", + "/part/a/fractional": "47593481cb6fb3473e25389ff0431e3304bfdce635d1f7af5a898cc2932a86bd", + "/part/b/path": "9dc35dc7c32e6923adb250328f2e729d428940f10ce706fbb53766756873e87e", + "/part/b/maxseg": "d790495204ed878091a86585d8d17934fbc481bd07e14e4978b8c081cee55625", + "/part/b/lis": "3123ed2974e91b3d072dae4efce2ea76deea5083ade8c5acaf66cc23a6469b6c", + "/part/b/lcs": "7fbe92c3652f8217eee2d450e79fa8b5a214061cc96ead2a64e500204dff6256", + "/part/b/edit": "69acb8a225fcec35a28a1be7ded79f618a93189d66ab01271ce3aad5fde9f191", + "/part/b/fsm": "4f5ef5cf988e8f936b8b589c34e3270edfd3861c61f6139e121660b097b93cce", + "/part/b/count": "376d37279feefc80b8a98c28ea116417ac5c87ab7ddf684aa32182689bd75f36", + "/part/c/stone": "8b8dc1f476a49b7786aab2d5e7a6c26a3af46330f6f039b2520ba6c92e6c75a5", + "/part/c/ring": "074c46c6ee38769693c61784e5a5474f67c3ff48227150c36b19b6471390dc0d", + "/part/c/palindrome": "bd3494df1f82c5c205237be664c62d378bc5895dc7fc95d61ca249fd68d7a187", + "/part/c/tree": "57261999473bfaa41f216ba165fb272a9b66cd4c85ee30b4301018b09e8e1278", + "/part/c/merge": "77c2f431bab90ae72aa768e23506a256e1b67037ee9561c9d29eef0fb197614a", + "/part/d/grid": "ad743c975a9dc29a0229c01284b999b1be764924e0f71ef75260d56081205777", + "/part/d/matpow": "18ba5812015e71a9e80fc6561b054e8c6633d6015e11666135071358988863db", + "/part/e/basic": "2355ad8d5172d0d83f4add44da597e8e1d335d771bd58f3fd42032a377b2c2db", + "/part/e/distsum": "4fb7a28b39fce8d1959ed80f99fc5a434b7dc2c0e68e1f65fec08987922444d7", + "/part/e/inout": "78320ba4e85fb3a6ca50b0aad8ba90f91c2bac3ab1f13fb30f5c4929dc887cd5", + "/part/e/center": "a972d922db64fa2838f052f10039ed317ca1f6d4c662f6bd81cb62042969e161", + "/part/f/select": "33f5cd5c82b1a72819447a2a85cfa1333d63cd66077247dfea9505d95dc4de82", + "/part/f/knapsack": "918040420e0932198f5da649863a2de17cbd2a0f426e98a819f6f7e1c3d4ecab", + "/part/f/diameter": "94211f53aaa166b07cbaa500fe595420c4155e2fe1b30c41399d174fd3ff1880", + "/part/f/cover": "1ea33061cff0d26593a97dedbdd3777a441f20d52548da87925802f24a115718", + "/part/f/count": "0001c713ef65adacbe29fb1a07bd4137ec6cf6f38d7f3c619da58b13b25d62ff", + "/part/g/board": "bd3679b5ea20dd48e55b9036567d77bf3b56b1e0b1d63b59e6f6ce4071d9a6b9", + "/part/g/tsp": "acff8b9b784531e48f682f400988d3f733ee7a5f22e6aa0157176110f526009c", + "/part/g/cover": "bdae99d8c98b279a169c5f33fef66ae51e5af7b69bb5903d8b0c64ce730549bf", + "/part/g/subset": "4c7ec6066f60a78e13c1da45e4aeebe9731c4badf63499acc1bc8763793f17d8", + "/part/g/plug": "86631b296d9c3656d1c0e4576677a87a4e7b8a1d9bb5e1cf76cfd878c1385d09", + "/method": "5d4e67a2d6211d4ada30e011dedabf1df917b9c2199fd63108bb87ee875e4180", "/problems": "03671fba244a78f18399baada2f18c7fa6c68da60a714bf61e07c44e336b52d0" }) From 9ad2713199b72838894ff805f4af0bccd820600f Mon Sep 17 00:00:00 2001 From: Shanire Date: Thu, 27 Aug 2026 11:24:17 +0800 Subject: [PATCH 21/23] wip(web): capture remaining evidence review gaps Signed-off-by: Shanire --- site/scripts/semantic-source-graph.mjs | 640 ++++++++++++++++---- site/scripts/semantic-source-graph.test.mjs | 118 ++++ 2 files changed, 630 insertions(+), 128 deletions(-) diff --git a/site/scripts/semantic-source-graph.mjs b/site/scripts/semantic-source-graph.mjs index 108ec3c..cb668b2 100644 --- a/site/scripts/semantic-source-graph.mjs +++ b/site/scripts/semantic-source-graph.mjs @@ -534,6 +534,9 @@ function publicSiteConfigProjection(program) { const mayContainGlobalContainer = (node, seen = new Set()) => { const value = unwrap(node) if (!value || typeof value !== 'object') return false + if (value.type === 'SpreadElement') { + return mayContainGlobalContainer(value.argument, seen) + } if (isGlobalContainer(value, seen)) return true if (value.type === 'CallExpression' || value.type === 'NewExpression') return true if (value.type === 'Identifier' && bindings.has(value.name)) { @@ -1062,23 +1065,45 @@ function publicSiteConfigProjection(program) { rejectUsedIdentity(node.argument, 'mutation of') } else if (node.type === 'CallExpression' || node.type === 'NewExpression') { if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') { - const owner = unwrap(node.callee.object) - const method = memberNameFromNode(node.callee) + const wrapperMethod = memberNameFromNode(node.callee) + const borrowed = ['apply', 'call'].includes(wrapperMethod) + const target = borrowed ? unwrap(node.callee.object) : node.callee + const owner = target?.type === 'MemberExpression' ? unwrap(target.object) : null + const method = target?.type === 'MemberExpression' + ? memberNameFromNode(target) + : null const builtinOwner = owner?.type === 'Identifier' && !bindings.has(owner.name) ? owner.name : null - const storedValues = builtinOwner === 'Object' && method === 'assign' + const invocationArguments = wrapperMethod === 'call' ? node.arguments?.slice(1) ?? [] + : wrapperMethod === 'apply' + ? null + : node.arguments ?? [] + const storedValues = wrapperMethod === 'apply' + ? node.arguments?.slice(1, 2) ?? [] + : builtinOwner === 'Object' && method === 'assign' + ? invocationArguments?.slice(1) ?? [] : builtinOwner === 'Object' && method === 'defineProperties' - ? node.arguments?.slice(1, 2) ?? [] + ? invocationArguments?.slice(1, 2) ?? [] : builtinOwner === 'Object' && method === 'defineProperty' - ? node.arguments?.slice(2, 3) ?? [] + ? invocationArguments?.slice(2, 3) ?? [] : builtinOwner === 'Reflect' && method === 'set' - ? node.arguments?.slice(2, 3) ?? [] + ? invocationArguments?.slice(2, 3) ?? [] : builtinOwner === 'Reflect' && method === 'defineProperty' - ? node.arguments?.slice(2, 3) ?? [] - : [] + ? invocationArguments?.slice(2, 3) ?? [] + : ['push', 'unshift'].includes(method) + ? invocationArguments ?? [] + : method === 'splice' + ? invocationArguments?.slice(2) ?? [] + : method === 'fill' + ? invocationArguments?.slice(0, 1) ?? [] + : method === 'set' + ? invocationArguments?.slice(1, 2) ?? [] + : method === 'add' + ? invocationArguments?.slice(0, 1) ?? [] + : [] if (storedValues.some((argument) => mayContainGlobalContainer(argument))) { unsupported('member-stored global container') } @@ -1252,15 +1277,73 @@ function moduleEvaluationDynamicImportSources(program, path) { function evaluationBinding(scope, name) { const existing = scope.bindings.get(name) if (existing) return existing - const binding = { name, writes: [] } + const binding = { name, writes: [], memberWrites: new Map() } scope.bindings.set(name, binding) return binding } - function declareEvaluationPattern(pattern, scope, initializer = null, start = null) { - for (const identifier of bindingIdentifiers(pattern)) { - const binding = evaluationBinding(scope, identifier.name) - if (initializer) binding.writes.push({ expression: initializer, start: start ?? initializer.start }) + function declareEvaluationPattern( + pattern, + scope, + initializer = null, + start = null, + memberSource = null, + ) { + if (!pattern || typeof pattern !== 'object') return + if (pattern.type === 'Identifier') { + const binding = evaluationBinding(scope, pattern.name) + if (initializer) { + binding.writes.push({ + expression: memberSource ? null : initializer, + memberSource, + start: start ?? initializer.start, + conditional: false, + }) + } + return + } + if (pattern.type === 'AssignmentPattern') { + declareEvaluationPattern( + pattern.left, + scope, + initializer ?? pattern.right, + start, + memberSource, + ) + return + } + if (pattern.type === 'RestElement') { + declareEvaluationPattern(pattern.argument, scope, initializer, start, memberSource) + return + } + if (pattern.type === 'ObjectPattern') { + for (const property of pattern.properties ?? []) { + if (property.type === 'RestElement') { + declareEvaluationPattern(property.argument, scope, initializer, start) + continue + } + const key = staticPropertyNameFromNode(property) + declareEvaluationPattern( + property.value, + scope, + initializer, + start, + key === null ? null : { container: initializer, key }, + ) + } + return + } + if (pattern.type === 'ArrayPattern') { + for (const [index, element] of (pattern.elements ?? []).entries()) { + if (!element) continue + declareEvaluationPattern( + element, + scope, + initializer, + start, + { container: initializer, key: String(index) }, + ) + } } } @@ -1340,28 +1423,34 @@ function moduleEvaluationDynamicImportSources(program, path) { return null } - function indexEvaluationWrites(node) { - if ( - node.type === 'AssignmentExpression' - && node.operator === '=' - && node.left.type === 'Identifier' - ) { - const binding = lookupEvaluationBinding(node.left) - if (binding) binding.writes.push({ expression: node.right, start: node.start }) + function effectiveEvaluationWrites(writes, referenceStart = Number.POSITIVE_INFINITY) { + const eligible = writes + .filter((write) => write.start <= referenceStart) + .sort((left, right) => left.start - right.start) + let candidates = [] + for (const write of eligible) { + if (write.conditional) candidates.push(write) + else candidates = [write] } - for (const [child] of childNodes(node)) indexEvaluationWrites(child) + return candidates + } + + function expressionsForEvaluationWrite(write, seen) { + if (write.expression) return [write.expression] + if (!write.memberSource?.container) return [] + return staticEvaluationMemberValues( + write.memberSource.container, + write.memberSource.key, + seen, + ) } - indexEvaluationWrites(program) function latestBindingExpressions(identifier, seen = new Set()) { const binding = lookupEvaluationBinding(identifier) if (!binding || seen.has(binding)) return [] - const eligible = binding.writes.filter((write) => write.start <= identifier.start) - if (eligible.length === 0) return [] - const latestStart = Math.max(...eligible.map((write) => write.start)) - return eligible - .filter((write) => write.start === latestStart) - .map((write) => write.expression) + return effectiveEvaluationWrites(binding.writes, identifier.start).flatMap((write) => ( + expressionsForEvaluationWrite(write, seen) + )) } function staticEvaluationMemberValues(node, key, seen = new Set()) { @@ -1371,9 +1460,17 @@ function moduleEvaluationDynamicImportSources(program, path) { const binding = lookupEvaluationBinding(value) if (!binding || seen.has(binding)) return [] const nextSeen = new Set([...seen, binding]) - return latestBindingExpressions(value, seen).flatMap((expression) => ( + const baseValues = latestBindingExpressions(value, seen).flatMap((expression) => ( staticEvaluationMemberValues(expression, key, nextSeen) )) + const writes = binding.memberWrites.get(String(key)) ?? [] + if (writes.length === 0) return baseValues + let values = baseValues + for (const write of writes.sort((left, right) => left.start - right.start)) { + const writtenValues = expressionsForEvaluationWrite(write, nextSeen) + values = write.conditional ? [...values, ...writtenValues] : writtenValues + } + return values } if (value.type === 'ConditionalExpression') { return [value.consequent, value.alternate].flatMap((branch) => ( @@ -1443,138 +1540,422 @@ function moduleEvaluationDynamicImportSources(program, path) { return [] } + function recordEvaluationPatternWrite( + pattern, + expression, + { conditional = false, start = pattern?.start ?? expression?.start ?? -1 } = {}, + memberSource = null, + ) { + if (!pattern || typeof pattern !== 'object') return [] + if (pattern.type === 'Identifier') { + const binding = lookupEvaluationBinding(pattern) + if (!binding) return [] + binding.writes.push({ + expression: memberSource ? null : expression, + memberSource, + start, + conditional, + }) + return [binding] + } + if (pattern.type === 'AssignmentPattern') { + return recordEvaluationPatternWrite( + pattern.left, + expression ?? pattern.right, + { conditional, start }, + memberSource, + ) + } + if (pattern.type === 'RestElement') { + return recordEvaluationPatternWrite( + pattern.argument, + expression, + { conditional, start }, + memberSource, + ) + } + if (pattern.type === 'ObjectPattern') { + return pattern.properties.flatMap((property) => { + if (property.type === 'RestElement') { + return recordEvaluationPatternWrite( + property.argument, + expression, + { conditional, start }, + ) + } + const key = staticPropertyNameFromNode(property) + return recordEvaluationPatternWrite( + property.value, + expression, + { conditional, start }, + key === null ? null : { container: expression, key }, + ) + }) + } + if (pattern.type === 'ArrayPattern') { + return pattern.elements.flatMap((element, index) => ( + element + ? recordEvaluationPatternWrite( + element, + expression, + { conditional, start }, + { container: expression, key: String(index) }, + ) + : [] + )) + } + return [] + } + + function recordEvaluationMemberWrite(target, expression, { conditional = false } = {}) { + const value = target && EXPRESSION_WRAPPERS.has(target.type) + ? target.expression + : target + if (value?.type !== 'MemberExpression') return false + const key = memberNameFromNode(value) + if (key === null) return false + const object = value.object && EXPRESSION_WRAPPERS.has(value.object.type) + ? value.object.expression + : value.object + if (object?.type !== 'Identifier') return false + const binding = lookupEvaluationBinding(object) + if (!binding) return false + const writes = binding.memberWrites.get(String(key)) ?? [] + writes.push({ + conditional, + expression, + memberSource: null, + start: value.start, + }) + binding.memberWrites.set(String(key), writes) + return true + } + function executeCallable(callable, options = {}) { if (activeFunctions.has(callable)) return activeFunctions.add(callable) + const restoredWrites = new Map() try { - visit(callable, { ...options, executeFunction: true }) + for (const [index, parameter] of (callable.params ?? []).entries()) { + const argument = options.argumentValues?.[index] + if (!argument) continue + for (const binding of bindingIdentifiers(parameter) + .map((identifier) => lookupEvaluationBinding(identifier)) + .filter(Boolean)) { + if (!restoredWrites.has(binding)) restoredWrites.set(binding, binding.writes.length) + } + recordEvaluationPatternWrite(parameter, argument, { + start: callable.start, + }) + } + const completion = visit(callable, { ...options, executeFunction: true }) + return { completion, promiseReturns: options.promiseReturns ?? [] } } finally { + for (const [binding, length] of restoredWrites) binding.writes.length = length activeFunctions.delete(callable) } } - function visit(node, { executeFunction = false, awaitedCall = false } = {}) { - if (!node || typeof node !== 'object') return + const fulfilledPromise = (values = []) => ({ status: 'fulfilled', values }) + const rejectedPromise = (values = []) => ({ status: 'rejected', values }) + const unknownPromise = (values = []) => ({ status: 'unknown', values }) + + function mergePromiseStates(states) { + const available = states.filter(Boolean) + if (available.length === 0) return fulfilledPromise() + const status = available.every((state) => state.status === available[0].status) + ? available[0].status + : 'unknown' + return { status, values: available.flatMap((state) => state.values ?? []) } + } + + function executeAwaitedCallback(callbackNode, argumentValues = []) { + const callables = resolvedCallables(callbackNode) + if (callables.length === 0) return unknownPromise() + return mergePromiseStates(callables.flatMap((callable) => { + const promiseReturns = [] + executeCallable(callable, { argumentValues, promiseReturns }) + return promiseReturns.length > 0 ? promiseReturns : [fulfilledPromise()] + })) + } + + function evaluateAwaitedPromise(node, options = {}) { + const value = node && EXPRESSION_WRAPPERS.has(node.type) ? node.expression : node + if (!value || typeof value !== 'object') return fulfilledPromise() + if (value.type === 'AwaitExpression') return evaluateAwaitedPromise(value.argument, options) + if (value.type === 'ImportExpression') { + visit(value, options) + return fulfilledPromise([value]) + } + if (value.type === 'SequenceExpression') { + for (const expression of value.expressions.slice(0, -1)) visit(expression, options) + return evaluateAwaitedPromise(value.expressions.at(-1), options) + } + if (value.type === 'ConditionalExpression') { + visit(value.test, options) + const condition = staticBooleanValue(value.test) + if (condition === true) return evaluateAwaitedPromise(value.consequent, options) + if (condition === false) return evaluateAwaitedPromise(value.alternate, options) + return mergePromiseStates([ + evaluateAwaitedPromise(value.consequent, { ...options, conditional: true }), + evaluateAwaitedPromise(value.alternate, { ...options, conditional: true }), + ]) + } + if (value.type !== 'CallExpression' && value.type !== 'NewExpression') { + visit(value, options) + return fulfilledPromise([value]) + } + + const callee = value.callee + const method = callee?.type === 'MemberExpression' ? memberNameFromNode(callee) : null + const isUnboundPromise = callee?.type === 'MemberExpression' + && callee.object.type === 'Identifier' + && callee.object.name === 'Promise' + && !lookupEvaluationBinding(callee.object) + if (isUnboundPromise && method === 'resolve') { + for (const argument of value.arguments ?? []) visit(argument, options) + return fulfilledPromise(value.arguments?.slice(0, 1) ?? []) + } + if (isUnboundPromise && method === 'reject') { + for (const argument of value.arguments ?? []) visit(argument, options) + return rejectedPromise(value.arguments?.slice(0, 1) ?? []) + } + if ( + isUnboundPromise + && ['all', 'allSettled', 'any', 'race'].includes(method) + ) { + const aggregate = value.arguments?.[0] + const elements = aggregate?.type === 'ArrayExpression' + ? aggregate.elements.filter((element) => element && element.type !== 'SpreadElement') + : [] + const states = elements.map((element) => evaluateAwaitedPromise(element, options)) + if (aggregate?.type !== 'ArrayExpression') visit(aggregate, options) + if (method === 'allSettled') return fulfilledPromise() + if (method === 'all' && states.every((state) => state.status === 'fulfilled')) { + return fulfilledPromise() + } + return mergePromiseStates(states) + } + if (callee?.type === 'MemberExpression' && ['catch', 'finally', 'then'].includes(method)) { + const previous = evaluateAwaitedPromise(callee.object, options) + if (method === 'finally') { + if (value.arguments?.[0]) executeAwaitedCallback(value.arguments[0]) + return previous + } + if (method === 'catch') { + if (previous.status === 'fulfilled') return previous + if (!value.arguments?.[0]) return previous + if (previous.status === 'rejected') { + return executeAwaitedCallback(value.arguments[0], previous.values) + } + return mergePromiseStates([ + previous, + executeAwaitedCallback(value.arguments[0], previous.values), + ]) + } + const onFulfilled = value.arguments?.[0] + const onRejected = value.arguments?.[1] + if (previous.status === 'fulfilled') { + return onFulfilled + ? executeAwaitedCallback(onFulfilled, previous.values) + : previous + } + if (previous.status === 'rejected') { + return onRejected + ? executeAwaitedCallback(onRejected, previous.values) + : previous + } + return mergePromiseStates([ + onFulfilled ? executeAwaitedCallback(onFulfilled, previous.values) : previous, + onRejected ? executeAwaitedCallback(onRejected, previous.values) : previous, + ]) + } + + const directCallables = resolvedCallables(callee) + if (directCallables.length > 0) { + return mergePromiseStates(directCallables.flatMap((callable) => { + const promiseReturns = [] + executeCallable(callable, { + argumentValues: value.arguments ?? [], + promiseReturns, + }) + return promiseReturns.length > 0 ? promiseReturns : [fulfilledPromise()] + })) + } + visit(value, options) + return unknownPromise() + } + + function visit(node, options = {}) { + const { + conditional = false, + executeFunction = false, + promiseReturns = null, + } = options + if (!node || typeof node !== 'object') return 'normal' + if (node.type === 'Program' || node.type === 'BlockStatement') { + for (const [child] of childNodes(node)) { + const completion = visit(child, options) + if (completion !== 'normal') return completion + } + return 'normal' + } if (node.type === 'ImportExpression') { if (typeof node.source?.value !== 'string') { throw new Error(`Unsupported dynamic module-evaluation import in ${path}`) } sources.push(node.source) - return + return 'normal' } if (functionTypes.has(node.type)) { - if (!executeFunction) return - for (const parameter of node.params ?? []) visit(parameter) - visit(node.body) - return + if (!executeFunction) return 'normal' + if (node.type === 'ArrowFunctionExpression' && node.expression && promiseReturns) { + promiseReturns.push(evaluateAwaitedPromise(node.body, options)) + return 'return' + } + return visit(node.body, { ...options, executeFunction: false }) + } + if (node.type === 'ReturnStatement') { + if (node.argument) { + if (promiseReturns) promiseReturns.push(evaluateAwaitedPromise(node.argument, options)) + else visit(node.argument, options) + } else if (promiseReturns) { + promiseReturns.push(fulfilledPromise()) + } + return 'return' + } + if (node.type === 'ThrowStatement') { + visit(node.argument, options) + if (promiseReturns) promiseReturns.push(rejectedPromise([node.argument])) + return 'throw' + } + if (node.type === 'BreakStatement') return 'break' + if (node.type === 'AssignmentExpression') { + visit(node.right, options) + if (node.operator === '=') { + if (node.left.type === 'Identifier' || node.left.type.endsWith('Pattern')) { + recordEvaluationPatternWrite(node.left, node.right, { conditional, start: node.start }) + } else { + recordEvaluationMemberWrite(node.left, node.right, { conditional }) + } + } + return 'normal' } if (node.type === 'IfStatement') { - visit(node.test) + visit(node.test, options) const condition = staticBooleanValue(node.test) - if (condition !== false) visit(node.consequent) - if (condition !== true) visit(node.alternate) - return + if (condition === true) return visit(node.consequent, options) + if (condition === false) return visit(node.alternate, options) + const consequent = visit(node.consequent, { ...options, conditional: true }) + const alternate = visit(node.alternate, { ...options, conditional: true }) + return consequent === alternate ? consequent : 'normal' } if (node.type === 'ConditionalExpression') { - visit(node.test) + visit(node.test, options) const condition = staticBooleanValue(node.test) - if (condition !== false) visit(node.consequent) - if (condition !== true) visit(node.alternate) - return + if (condition === true) return visit(node.consequent, options) + if (condition === false) return visit(node.alternate, options) + visit(node.consequent, { ...options, conditional: true }) + visit(node.alternate, { ...options, conditional: true }) + return 'normal' } if (node.type === 'LogicalExpression') { - visit(node.left) + visit(node.left, options) const condition = staticBooleanValue(node.left) const nullish = staticNullishValue(node.left) - if ( - (node.operator === '&&' && condition !== false) + const definitelyRuns = (node.operator === '&&' && condition === true) + || (node.operator === '||' && condition === false) + || (node.operator === '??' && nullish === true) + const mayRun = definitelyRuns + || (node.operator === '&&' && condition !== false) || (node.operator === '||' && condition !== true) || (node.operator === '??' && nullish !== false) - ) visit(node.right) - return + if (mayRun) visit(node.right, { ...options, conditional: conditional || !definitelyRuns }) + return 'normal' } if (node.type === 'SwitchStatement') { - visit(node.discriminant) + visit(node.discriminant, options) const discriminant = staticLiteralValue(node.discriminant) if (!discriminant.known) { - for (const switchCase of node.cases ?? []) visit(switchCase) - return + for (const switchCase of node.cases ?? []) { + visit(switchCase.test, { ...options, conditional: true }) + for (const consequent of switchCase.consequent ?? []) { + visit(consequent, { ...options, conditional: true }) + } + } + return 'normal' } let selectedIndex = -1 let defaultIndex = -1 - let hasUnknownCase = false for (const [index, switchCase] of (node.cases ?? []).entries()) { if (!switchCase.test) { defaultIndex = index continue } + visit(switchCase.test, options) const caseValue = staticLiteralValue(switchCase.test) if (!caseValue.known) { - hasUnknownCase = true - break + for (const candidate of node.cases ?? []) { + for (const consequent of candidate.consequent ?? []) { + visit(consequent, { ...options, conditional: true }) + } + } + return 'normal' } - if (caseValue.known && Object.is(caseValue.value, discriminant.value)) { + if (Object.is(caseValue.value, discriminant.value)) { selectedIndex = index break } } - if (hasUnknownCase) { - for (const switchCase of node.cases ?? []) visit(switchCase) - return - } const startIndex = selectedIndex >= 0 ? selectedIndex : defaultIndex - if (startIndex < 0) return + if (startIndex < 0) return 'normal' for (const switchCase of node.cases.slice(startIndex)) { for (const consequent of switchCase.consequent ?? []) { - if (consequent.type === 'BreakStatement') return - visit(consequent) + const completion = visit(consequent, options) + if (completion === 'break') return 'normal' + if (completion !== 'normal') return completion } } - return + return 'normal' } if (node.type === 'WhileStatement') { - visit(node.test) - if (staticBooleanValue(node.test) !== false) visit(node.body) - return + visit(node.test, options) + const condition = staticBooleanValue(node.test) + if (condition !== false) { + const completion = visit(node.body, { + ...options, + conditional: conditional || condition !== true, + }) + if (completion === 'return' || completion === 'throw') return completion + } + return 'normal' } if (node.type === 'ForStatement') { - visit(node.init) - visit(node.test) - if (!node.test || staticBooleanValue(node.test) !== false) { - visit(node.body) - visit(node.update) + visit(node.init, options) + visit(node.test, options) + const condition = node.test ? staticBooleanValue(node.test) : true + if (condition !== false) { + const loopOptions = { ...options, conditional: conditional || condition !== true } + const completion = visit(node.body, loopOptions) + if (completion === 'return' || completion === 'throw') return completion + visit(node.update, loopOptions) } - return + return 'normal' } if (node.type === 'AwaitExpression') { - visit(node.argument, { awaitedCall: true }) - return + evaluateAwaitedPromise(node.argument, options) + return 'normal' } if (node.type === 'CallExpression' || node.type === 'NewExpression') { - visit(node.callee) - const directCallables = resolvedCallables(node.callee) - for (const callable of directCallables) { - executeCallable(callable, { awaitedCall }) - } - const awaitedCallbackMethod = awaitedCall - && node.callee?.type === 'MemberExpression' - && ['catch', 'finally', 'then'].includes(memberNameFromNode(node.callee)) - for (const argument of node.arguments ?? []) { - if (functionTypes.has(argument?.type)) { - if (awaitedCallbackMethod) visit(argument, { executeFunction: true, awaitedCall: true }) - } else if (awaitedCallbackMethod) { - const callbacks = resolvedCallables(argument) - for (const callable of callbacks) { - executeCallable(callable, { awaitedCall: true }) - } - visit(argument) - } else { - visit(argument) - } + visit(node.callee, options) + for (const callable of resolvedCallables(node.callee)) { + executeCallable(callable, { argumentValues: node.arguments ?? [] }) } - return + for (const argument of node.arguments ?? []) visit(argument, options) + return 'normal' } - for (const [child] of childNodes(node)) visit(child) + for (const [child] of childNodes(node)) visit(child, options) + return 'normal' } visit(program) @@ -2738,6 +3119,9 @@ function semanticBindingGraph(program, path, analysisContext = {}) { function mayContainUnboundGlobalContainer(node, seen = new Set()) { const value = unwrapExpression(node) if (!value || typeof value !== 'object') return false + if (value.type === 'SpreadElement') { + return mayContainUnboundGlobalContainer(value.argument, seen) + } if (unboundGlobalContainer(value, seen)) return true if (value.type === 'CallExpression' || value.type === 'NewExpression') return true if (value.type === 'Identifier') { @@ -3508,31 +3892,42 @@ function semanticBindingGraph(program, path, analysisContext = {}) { if (node.type === 'CallExpression' && !isReactEffectCall(node)) { const callee = node.callee if (callee.type === 'MemberExpression') { - const method = memberName(callee) - const staticOwner = callee.object.type === 'Identifier' - && !lookup(callee.object) - ? callee.object.name + const wrapperMethod = memberName(callee) + const borrowed = ['apply', 'call'].includes(wrapperMethod) + const target = borrowed ? unwrapExpression(callee.object) : callee + const method = target?.type === 'MemberExpression' ? memberName(target) : null + const staticOwner = target?.type === 'MemberExpression' + && target.object.type === 'Identifier' + && !lookup(target.object) + ? target.object.name : null - const storedValues = staticOwner === 'Object' && method === 'assign' + const invocationArguments = wrapperMethod === 'call' ? node.arguments?.slice(1) ?? [] + : wrapperMethod === 'apply' + ? null + : node.arguments ?? [] + const storedValues = wrapperMethod === 'apply' + ? node.arguments?.slice(1, 2) ?? [] + : staticOwner === 'Object' && method === 'assign' + ? invocationArguments?.slice(1) ?? [] : staticOwner === 'Object' && method === 'defineProperties' - ? node.arguments?.slice(1, 2) ?? [] + ? invocationArguments?.slice(1, 2) ?? [] : staticOwner === 'Object' && method === 'defineProperty' - ? node.arguments?.slice(2, 3) ?? [] + ? invocationArguments?.slice(2, 3) ?? [] : staticOwner === 'Reflect' && method === 'set' - ? node.arguments?.slice(2, 3) ?? [] + ? invocationArguments?.slice(2, 3) ?? [] : staticOwner === 'Reflect' && method === 'defineProperty' - ? node.arguments?.slice(2, 3) ?? [] + ? invocationArguments?.slice(2, 3) ?? [] : ['push', 'unshift'].includes(method) - ? node.arguments ?? [] + ? invocationArguments ?? [] : method === 'splice' - ? node.arguments?.slice(2) ?? [] + ? invocationArguments?.slice(2) ?? [] : method === 'fill' - ? node.arguments?.slice(0, 1) ?? [] + ? invocationArguments?.slice(0, 1) ?? [] : method === 'set' - ? node.arguments?.slice(1, 2) ?? [] + ? invocationArguments?.slice(1, 2) ?? [] : method === 'add' - ? node.arguments?.slice(0, 1) ?? [] + ? invocationArguments?.slice(0, 1) ?? [] : [] if (storedValues.some((argument) => mayContainUnboundGlobalContainer(argument))) { unsupportedRootCalls.push({ @@ -4311,21 +4706,10 @@ export function semanticSourceForDigest(path, source, options = {}) { if (!runtimePackageSpecifiers || !node || typeof node !== 'object') return if (bindingGraph.isClientOnlyDeferredValue(node)) return if (bindingGraph.isClientOnlyIntrinsicAttribute(node)) return - if ( - node.type === 'VariableDeclarator' - && node.init?.type === 'CallExpression' - && bindingGraph.isReactLazyCall(node.init) - ) { - collectRuntimePackageSpecifiers(node.init, { selectedLazy: true }) - return - } - if ( - node.type === 'AssignmentExpression' - && node.operator === '=' - && node.right?.type === 'CallExpression' - && bindingGraph.isReactLazyCall(node.right) - ) { - collectRuntimePackageSpecifiers(node.right, { selectedLazy: true }) + if (node.type === 'CallExpression' && bindingGraph.isReactLazyCall(node)) { + for (const argument of node.arguments ?? []) { + collectRuntimePackageSpecifiers(argument, { selectedLazy: true }) + } return } if (node.type === 'ImportExpression') { diff --git a/site/scripts/semantic-source-graph.test.mjs b/site/scripts/semantic-source-graph.test.mjs index 3740e53..f960e51 100644 --- a/site/scripts/semantic-source-graph.test.mjs +++ b/site/scripts/semantic-source-graph.test.mjs @@ -612,6 +612,11 @@ test('mixed-module closure tracks every supported mutation of selected bindings' `let label = '正文'\nconst holder = { nested: {} }\nObject.defineProperty(holder.nested, 'g', { value: globalThis })\nholder.nested.g.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, `let label = '正文'\nconst holder = { nested: {} }\nReflect.set(holder.nested, 'g', globalThis)\nholder.nested.g.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, `let label = '正文'\nconst holder = []\nholder.push(globalThis)\nholder[0].setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, + `let label = '正文'\nconst holder = { nested: {} }\nObject.assign(holder.nested, ...[{ g: globalThis }])\nholder.nested.g.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, + `let label = '正文'\nconst holder = []\nholder.push(...[globalThis])\nholder[0].setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, + `let label = '正文'\nconst holder = []\nArray.prototype.push.call(holder, globalThis)\nholder[0].setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, + `let label = '正文'\nconst holder = {}\nconst { assign } = Object\nassign(holder, { g: globalThis })\nholder.g.setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, + `let label = '正文'\nconst holder = []\nconst push = Array.prototype.push\npush.call(holder, globalThis)\nholder[0].setTimeout = callback => callback()\nfunction AppContent() { setTimeout(() => { label = '变化' }, 0); return {label} }`, ]) { assert.throws( () => semanticSourceForDigest('site/src/app/AppContent.tsx', indirectMemberStoredGlobal), @@ -1230,6 +1235,26 @@ test('public config rejects alias mutation, pattern writes, and patched freeze', `const holder = { nested: {} }\nReflect.set(holder.nested, 'g', globalThis)\nholder.nested.g.Object = { freeze(value) { value.name = '运行时'; return value } }`, `name: '初始'`, ), + publicConfigSource( + `const holder = []\nholder.push(globalThis)\nholder[0].Object = { freeze(value) { value.name = '运行时'; return value } }`, + `name: '初始'`, + ), + publicConfigSource( + `const holder = { nested: {} }\nObject.assign(holder.nested, ...[{ g: globalThis }])\nholder.nested.g.Object = { freeze(value) { value.name = '运行时'; return value } }`, + `name: '初始'`, + ), + publicConfigSource( + `const holder = []\nArray.prototype.push.call(holder, globalThis)\nholder[0].Object = { freeze(value) { value.name = '运行时'; return value } }`, + `name: '初始'`, + ), + publicConfigSource( + `const holder = {}\nconst { assign } = Object\nassign(holder, { g: globalThis })\nholder.g.Object = { freeze(value) { value.name = '运行时'; return value } }`, + `name: '初始'`, + ), + publicConfigSource( + `const holder = []\nconst push = Array.prototype.push\npush.call(holder, globalThis)\nholder[0].Object = { freeze(value) { value.name = '运行时'; return value } }`, + `name: '初始'`, + ), ]) { assert.throws( () => semanticSourceForDigest('site/src/config/site.ts', mutableConfig), @@ -1292,6 +1317,69 @@ test('static dependency discovery follows literal top-level dynamic imports and ), ['./Method'], ) + assert.deepEqual( + staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `let load = () => import('./Child')\nif (false) load = () => Promise.resolve()\nawait load()`, + ), + ['./Child'], + ) + assert.deepEqual( + staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `let load = () => import('./Child')\nfunction never() { load = () => Promise.resolve() }\nawait load()`, + ), + ['./Child'], + ) + assert.deepEqual( + staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `const loaders = { run: () => Promise.resolve() }\nloaders.run = () => import('./Child')\nawait loaders.run()`, + ), + ['./Child'], + ) + assert.deepEqual( + staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `const loaders = { run: () => import('./Child') }\nconst { run } = loaders\nawait run()`, + ), + ['./Child'], + ) + assert.deepEqual( + staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `const load = () => import('./Child')\nawait Promise.resolve(load).then(callback => callback())`, + ), + ['./Child'], + ) + assert.deepEqual( + staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `const load = () => import('./Child')\nawait Promise.resolve().catch(load)`, + ), + [], + ) + assert.deepEqual( + staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `const first = () => import('./First')\nconst second = () => import('./Second')\nawait Promise.resolve().then(first).then(second)`, + ), + ['./First', './Second'], + ) + assert.deepEqual( + staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `const load = () => import('./Child')\nawait Promise.all([Promise.resolve().then(load)])`, + ), + ['./Child'], + ) + assert.deepEqual( + staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `const load = () => import('./Child')\nfunction boot() { return Promise.resolve().then(load) }\nawait boot()`, + ), + ['./Child'], + ) assert.deepEqual( staticImportSpecifiersForSource( 'site/src/app/AppContent.tsx', @@ -1364,6 +1452,20 @@ test('static dependency discovery follows literal top-level dynamic imports and ), [], ) + assert.deepEqual( + staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `switch (1) { case (import('./Child'), 1): break }`, + ), + ['./Child'], + ) + assert.deepEqual( + staticImportSpecifiersForSource( + 'site/src/app/AppContent.tsx', + `function boot() { switch ('a') { case 'a': return; case 'b': return import('./Child') } }\nawait boot()`, + ), + [], + ) }) test('semantic package evidence rejects unknown packages and follows selected lazy imports', () => { @@ -1397,6 +1499,22 @@ test('semantic package evidence rejects unknown packages and follows selected la { semanticImportSpecifiers: relativeSemanticImports }, ) assert.deepEqual([...relativeSemanticImports], ['./Child']) + + const conditionalSemanticImports = new Set() + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + `import { lazy } from 'react'\nconst View = true ? lazy(() => import('./Child')) : null\nfunction AppContent() { return }`, + { semanticImportSpecifiers: conditionalSemanticImports }, + ) + assert.deepEqual([...conditionalSemanticImports], ['./Child']) + + const memberSemanticImports = new Set() + semanticSourceForDigest( + 'site/src/app/AppContent.tsx', + `import { lazy } from 'react'\nconst views = { Child: lazy(() => import('./Child')) }\nconst View = views.Child\nfunction AppContent() { return }`, + { semanticImportSpecifiers: memberSemanticImports }, + ) + assert.deepEqual([...memberSemanticImports], ['./Child']) }) test('static dependency discovery strips Vite query and hash suffixes', () => { From 693475959c258d181931cd8c9ef8d6c5965f8882 Mon Sep 17 00:00:00 2001 From: Shanire Date: Thu, 27 Aug 2026 11:41:54 +0800 Subject: [PATCH 22/23] =?UTF-8?q?fix(web):=20=E5=88=A0=E9=99=A4=E8=BF=87?= =?UTF-8?q?=E6=97=B6=E7=9A=84=E8=A7=86=E8=A7=89=E8=B5=84=E4=BA=A7=E5=92=8C?= =?UTF-8?q?=E4=BA=A4=E6=8E=A5=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-08-01-body-demo-session-handoff.md | 95 ------------------ handoff/body-demo-composition-dark.png | Bin 78301 -> 0 bytes handoff/body-demo-live-dark.png | Bin 73614 -> 0 bytes handoff/body-demo-live-light.png | Bin 65053 -> 0 bytes handoff/body-demo-live-mobile.png | Bin 24122 -> 0 bytes handoff/body-demo-review-dialog-mobile.png | Bin 32392 -> 0 bytes handoff/body-demo-specimen-dark.png | Bin 214461 -> 0 bytes handoff/body-demo-standardization-handoff.md | 45 --------- handoff/body-demo-standardization-plan.md | 73 -------------- handoff/representative-a-01-light.png | Bin 48181 -> 0 bytes handoff/representative-b-lcs-dark.png | Bin 56452 -> 0 bytes handoff/representative-d-matpow-dark.png | Bin 58226 -> 0 bytes handoff/representative-g-plug-dark.png | Bin 44009 -> 0 bytes handoff/representative-g-plug-light.png | Bin 54689 -> 0 bytes handoff/representative-g-plug-mobile.png | Bin 34500 -> 0 bytes handoff/representative-review-dialog.png | Bin 69365 -> 0 bytes 16 files changed, 213 deletions(-) delete mode 100644 handoff/2026-08-01-body-demo-session-handoff.md delete mode 100644 handoff/body-demo-composition-dark.png delete mode 100644 handoff/body-demo-live-dark.png delete mode 100644 handoff/body-demo-live-light.png delete mode 100644 handoff/body-demo-live-mobile.png delete mode 100644 handoff/body-demo-review-dialog-mobile.png delete mode 100644 handoff/body-demo-specimen-dark.png delete mode 100644 handoff/body-demo-standardization-handoff.md delete mode 100644 handoff/body-demo-standardization-plan.md delete mode 100644 handoff/representative-a-01-light.png delete mode 100644 handoff/representative-b-lcs-dark.png delete mode 100644 handoff/representative-d-matpow-dark.png delete mode 100644 handoff/representative-g-plug-dark.png delete mode 100644 handoff/representative-g-plug-light.png delete mode 100644 handoff/representative-g-plug-mobile.png delete mode 100644 handoff/representative-review-dialog.png diff --git a/handoff/2026-08-01-body-demo-session-handoff.md b/handoff/2026-08-01-body-demo-session-handoff.md deleted file mode 100644 index 62e63fc..0000000 --- a/handoff/2026-08-01-body-demo-session-handoff.md +++ /dev/null @@ -1,95 +0,0 @@ -# DP大师正文 Demo 改造会话交接(2026-08-01) - -> 交接目的:2026-08-02 继续 Task B。 -> 当前分支:`main`。 -> 远端停点:`origin/main = beeb684`。 -> 本地未推送提交:`a7beef6 fix: keep route metadata current before commit`。 -> 禁止事项:不 push、不部署、不执行 `pnpm release`。 - -## 明日先做 - -1. 先确认工作区仍然干净:`git status -sb`。 -2. 由用户 push 当前本地提交 `a7beef6`,等待新一轮 GitHub Actions 完成。 -3. 检查新的 `DP大师 CI` 运行;只有 CI 通过后才继续视觉工作。若失败,读取该轮实际失败步骤与日志,不沿用旧轮猜测。 -4. 打开 `http://127.0.0.1:4173/part/b/lcs`,定位“看它一格一格长出来,再回溯出答案”,请用户明确判断 B/LCS 是否保持设计稿方向与独立线性 DP 家族语言。 -5. B/LCS 未通过时只改该样例;明确通过后,才开始下一个家族的独立高保真静态雕塑并重建第二轮评审材料。 - -## Git 与 CI 停点 - -- `beeb684 perf: reclaim family art asset budget` 已由用户 push,是当前远端 `main`。 -- 对应 [CI #78](https://github.com/ShanireZ/DpMaster/actions/runs/30704281060) 失败:checkout、pnpm、Node、依赖和 Chromium/Firefox/WebKit 安装均成功,`pnpm verify` 在最前段 `check:seo` 失败。 -- 根因已本地复现:`beeb684` 修改 C–G 路由视觉源文件后,以下 Git 日期派生产物仍停留在提交前状态: - - `site/public/sitemap.xml` - - `site/public/llms.txt` - - `site/public/route-summaries.json` - - `site/src/data/routeLastModified.ts` -- `a7beef6` 已刷新这四份产物,并修复 `site/scripts/last-modified.mjs`:提交前生成 SEO 时,尚未提交的路由依赖会使用本地日期,不再要求“先提交路由修改、再补一轮日期提交”。 -- `site/scripts/seo-contract.test.mjs` 已增加对应工具链合同。 -- 用户自己的 PowerShell 中 `gh auth status` 正常。Codex 进程无法读取 Windows Keyring,因此 `gh run view --log-failed` 可能返回 403;这不是用户凭据失效。公开运行状态仍可用 `gh run list` 或 Actions 页面检查。 - -## 当前产品与评审状态 - -- 第一轮只确认了方向:纵向仪表脊、公共交互整体确认、增强演绎档;不是代表课程成品验收。 -- 旧第二轮代表课程评审已明确未通过,原因是材质、空间关系与主构图密度离设计稿太远;旧评审页不能作为通过证据。 -- A/01 的“静态高保真 Hero 与数据完全解耦”方向已由用户确认。 -- 完全背包已有独立静态 Hero,表达同类物品可重复取用,不复用 01 背包资产。 -- B/LCS 已接入独立 `960×450` 透明 AVIF:Hero 只负责装饰;字符、匹配、DP 表和播放状态全部留在数据区。 -- B/LCS 当前仍是第二个家族样例的待评审对象。未取得用户明确判断前,不推广至其余家族或全部 37 课。 -- 本次会话已在实页启动 B/LCS 播放,桌面 Hero、编辑器、DP 表和控制轨正常;本地预览标签停在 Hero/编辑器区域。4173 端口在交接时已有预览服务,但明日恢复时应重新确认,不假设进程仍存在。 - -## 当前验证证据 - -- `pnpm check:seo`:在提交 `a7beef6` 后再次运行,通过 47 条公开路由一致性检查。 -- SEO/工具链专项:12/12 通过。 -- `pnpm test`:233/233 Node 合同测试通过。 -- `pnpm test:unit`:11 个文件、45/45 通过。 -- `pnpm lint`:零 warning。 -- `pnpm build`:Cloudflare/EdgeOne 双区构建及 96 个区域 HTML 通过。 -- `pnpm check:analytics`:96 个 EdgeOne 页面各一份静态 beacon,96 个 Cloudflare 页面不含静态 beacon。 -- `pnpm check:assets`:243 个文件;Cloudflare `5,691,431` bytes,EdgeOne `5,691,429` bytes,最大单文件 `732,959` bytes;6.30 MB 门禁余量 `608,569` bytes。 -- 正文标准 Chromium 专项:8/8 通过,覆盖 14 门代表课、A/01、完全背包、B/LCS 窄宽、共享 DP 表、插头 DP、主题与 reduced-motion。 -- B–G 家族插画专项:9/9 通过,覆盖类别 Hero、28 张课程图版、主题、中间宽度和真实 Demo 状态变化。 -- 最近一次 Chromium 全量:42/42 通过。 -- 完整 `pnpm verify` 本轮运行到浏览器阶段前的所有门禁均通过;浏览器启动因已有 4173 严格预览占用而停止,不是断言失败。随后复用现有预览运行正文标准专项,8/8 通过。 - -## 关键文件 - -- 总计划:[`body-demo-standardization-plan.md`](body-demo-standardization-plan.md) -- 长期任务交接:[`body-demo-standardization-handoff.md`](body-demo-standardization-handoff.md) -- 逐课审计:[`../docs/concepts/body-demo-audit.md`](../docs/concepts/body-demo-audit.md) -- B/LCS Hero 资产:`site/src/assets/demo-art/lcs-instrument-v1.avif` -- B/LCS Hero:`site/src/components/demos/grid/LCSHero.tsx` -- B/LCS 响应式样式:`site/src/components/demos/grid/lcs-demo.css` -- 正文专项浏览器合同:`site/tests/browser/body-demo-standardization.spec.ts` -- 路由日期生成器:`site/scripts/last-modified.mjs` -- CI 工作流:`.github/workflows/ci.yml` - -## 明日恢复命令 - -所有包管理命令从 `site/` 执行: - -```powershell -cd D:\Workspace\DpMaster -git status -sb -git log -3 --oneline -gh run list --repo ShanireZ/DpMaster --workflow ci.yml --limit 3 - -cd site -pnpm check:seo -pnpm build -node scripts/preview.mjs --host 127.0.0.1 --port 4173 --strictPort -``` - -若 4173 已占用,先确认占用者;不要停止用户已有服务。浏览器回归可复用现有生产预览并使用不管理 `webServer` 的临时 Playwright 配置,完成后删除临时配置。 - -## 不要做 - -- 不要执行 `pnpm release`、`pnpm deploy:cf` 或 `pnpm deploy:eo`。 -- 不要把 GitHub Actions 改成部署通道。 -- 不要因为 Codex 内的 `gh auth status` 读不到 Keyring 而要求用户再次登录。 -- 不要把旧第二轮截图或旧评审弹窗当作已经通过。 -- 不要在 B/LCS 明确通过前推广到全部 37 门课程。 -- 不要把动态字符、数字、匹配状态或播放帧重新烘焙进 Hero。 -- 不要用手写 SVG/CSS 假装高保真材质;Hero 延续“独立静态透明资产,数据留在仪器区”的策略。 -- 不要通过提高 6.30 MB 阈值掩盖资源增长。 -- 不要清理、覆盖或停止不属于当前任务的工作区改动与本地服务。 diff --git a/handoff/body-demo-composition-dark.png b/handoff/body-demo-composition-dark.png deleted file mode 100644 index b44f796813874d0b8a9d3db81456bf1841bf2915..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 78301 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�*yTw*ul0(#kfln>{!PzkDF{HG{ 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 diff --git a/handoff/body-demo-live-dark.png b/handoff/body-demo-live-dark.png deleted file mode 100644 index 730e12d8b2c09f66a3074ad9b773bf144d84c35b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 73614 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 SzKSTKJ!C)hm$UiT;r|7_Th_|} diff --git a/handoff/body-demo-live-light.png b/handoff/body-demo-live-light.png deleted file mode 100644 index cfdbbc716f0d1498cd9ae2960931846cd52de6e3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65053 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 wy0{MAgKWq`m_?#8Q$2=XoxlC% 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 Fe*xb=zg7SM diff --git a/handoff/body-demo-live-mobile.png b/handoff/body-demo-live-mobile.png deleted file mode 100644 index e736cf6e36779159a419ecf749b5cab6a191a803..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24122 zcmd3NWmH_twr=ADcXtUvgKGmJI0Scx0Kuhk8i(NCSdifE?he7--Jy}-mXHu0+2`(a z&V6^EGw!`V-gv9WsFqs2)?8IJ?VDdcEj?`ku;ipn)@HSqa`tT|Wgrf49$_{uYBzfVQ$n6$2DDB)jwz>&bx{cSqCWSo_B| zDL$wh$7ZI$wU~=)SeWFlb?v1MJ>(r+1}#gNzd>`rq!m!O$HT?Gxf3ceG7?d(e=kis z@WygQ3G2|GVIG<|(x$F&ciRwCRl(ih)Q5*7-o`bY&>jPT^XK0NKY;26d3s4o$iADz zmorp&t%w=BqxbLD2dzrV?ql`P#q1ExfOZm3ovDoDQbZ|g7`i&JUG(j%!V?TW!`bsi z=%(l$lo%J39Phx=On>R5YxhMRKAgW^XK~>Xm9gdkrLB|2rnOYM3dE1u>vwO#HD|;+ zF&`m!vU*p};VsI=zs#8^&WWG9dmvD$$*oB+E%W92(piQ3vrWJ0LCbDq@qM+Dn(rqq z-l{TJ3GA>m)U@3;OK0bvf_8)9{_9@M3Yu@2(kX)9Ti-URDfpC?n_H#H4qT0Yi9$$) z)q=w0GsWFVz-G#qzjAI55Dp5BLYir>7rd!!3aXu!j`GM(ufV~I#z2h z=s>h^5i^H(kFNRE$JS}-NndX9;s(*2G`|RogO}ukVwNf6=?YFsVH~iBF2wHfj1a1= zg(^hiW~0g?YTbwNlm|?zZWsW&4FzMjO-)aLv7rf2K=9)6$wYR+>|Ki;Pa=nc5w1x{ zgeFPJOHcTe7vky1A>c2oUU5FGghQ=VEyvcQ2Ke|211r-Cl6W<3Xax`kSKusJm1{O^QgLL_WhLJYoojOQ1 zd_T>aa?jVFTaSP-V#K(zksaI;Q zmwk1at9MZwJ>;=NE8A4gN5(mW?Ebq0}!%IkDW5Yaa*0aVkzA-ddPe~CG%_(de zSR?E?V>IqU20HC=YD&0v=|5)veYt#D2` z+&f~#8;ka=>qAh;H6!9~tb#Fbe+NyPAnvQL71EtSW) zAG+P{+A>zO~2`iLZXZrCS+1eSI5FOjk|m6fIg%2_=FfZJTG9x^>p#y!mmntHy2{Q&V04 zcIY}YU;Gx6E5&}2e0{W8LVBX|Gih3f{kk~1D+|JmBuhEo$DwlYjDw+c_^VG ztOFl|WqLlJlmCz=0{QAY11I|^z5IG0 zUwa#UK{p}hK{qj+!09wnbOFnij$Ld>VL^k2vl9KgYfh1hDz7!!J|{;c0Dq?Z(z8uh z4f78RL&3|1g%;+u=$^T1Xcljm=9dba)mQmx9t=kja;!D`b7FcNwBkuRXNEhsYpISp z7ez#bq=|7E+kQ5K3~jKo(fR`}yC<>uE2E#a02`~|w=QVN6*D;nD!)>VNc<1&_(Mhh z`wb#{fjTohPQe=q0!bLT|A`;`yVLB&4=mP1X2E`=8u&Kpe|e2={NC*6Yv2`2x0IV^ zUWe41WdQ869SG`oV&?tS^(eSfcc?n^!2MaR zsiP8MeTx9X&!Ux_JiyR+kQ@b70%3b53&poeYjByO2P9#JFu-NjRBRc1gI?&ShGlTY z>VPfVf1FMT3AfBU4IB~C#RykGQ-Ivw^R5Wv3tsB;y~%RP{d~gl&9I~!9^V-G!X{j5 zbW-vo2M9Y77lum@_ls_hz!g=_vSHi(_C14Go5%H~Jf_6fjSD?uz+aj7ys!FJX8m6b zvr##ra*b-AX);>53{_8$9Y<&vcq;}^T7shcaW>tg5%xA4;8=N-XB#6*YprNqCd~Qa zYkA{Po8vR-98opfHGu^*bzEUyVJ+)orAM|6Dt)Xx@A^vcaXQ&K(DZ`b$>KaS#Y{V` zALNB+bcwfZgcW=D4Aj4 zY;;8zbgo)}J$z(G_k%2n6Qy}9wg_*!mB`H)R>rGV(I8>J2TWo=%u`#~SXi1%Y*Dh> zEQYDRscs4)7O6RBUPsXqyqIb3cUimAU7lFc%F#Sg(6h@ateH;!(y|28?So%61j;lUZ8VSB9>NYF8{ z?9yyC67k~1N>Y8RW|kMO$>eOcEvt)pLoyw-Fv}%$GiPWz`?+nYv@%VnaruPS+|(_3 zPMR1%kO|KxLMBkIRl#wTe4@b0Tq4f8XOg7sTJC|yy0i9pQY6U`lCVsc9P{bbdlx*% zFAN$TQRu>IynLblXB7K;Ml82U?tbS7^gYm9ntXs1y>>66|3;tk&PQ16&qlWEtPp`=uTZCch*<0w%HZmrZ= ztVnjOiGzsG);(JRLB$t6chV8bQ*r^fBMWrto5{ki8DY91ok0t&I{P(?EMLv>(s3P! zHg%biyL6X2k?fv%@Ru`yaT_p%lbx_LLK>aMneuC>HCN*{PJq=UfzYXTmV6>TnjU%d6vl%5k8q(^NR5WB2bi5nIRESr;?Uk77 z&u)nU2?a{QTC)VWZ-@<)9B*1aGE6f>KWeSGQ^0MsC}wWeS&fj$*9*FRQ8O~qrbz>@ zDF57})Q%r)@t!#`Z(3s`ct~EW$7i;p+b}6A_#Bm$@&a*!Hk3~5Nc6Td`3X?38T)h1 z@1aUJ87ijr9L!sfSXoi_)N#Ji_WnHVYg$y%C|OqjSSimN(jo>b#+ErIAq*lKB6APu zhlYyCpBS(oE}uMbx<*Q`n%bnRknR@xH@*xB#rbL$un}~KYhalhB8#Ao$!g`cFtXN0 z-WSU=en=5l{xGtxruJ@E>cMl2Q%v=)VV5jcMEZt#cX1p$zkZ{zQSZF<{n@I4jcItw zD;p!Kn^0#f8^t%1n3Nz?k%nni)jCbJZw0D|OA+tuGlJeNP zFge%5ky-I(II6w?ZWhLVR)MFW>;s=VFNB$RmLgn4{|fvouT<#AWgBVDJ=H@DhYLC=NE;s=3Zwi8r$ibV>~k;@z4XF7~H*PJ>@Grk}?QJL`YPRVLQ=_EK7KBnGf=7Z#>2DdCU@Xg_0y{Y#8(mlFlgz% zB~pqntn{wu9UdODMQ4g>DX&=O1*C64g?c)^-Lpn3mD;Z8LP*LUonK|A{vd+ZOpMlK zfU~PUF5H9{p8LES_vzFZw_O4A@h-~OOt`-L=}bkmJ!G8W_qyZ5k+kys`VB#N=P?+$ zIGujEt`%|r2~bTYMipb;4jO#eMw)p7d>hR6J0+w1#`y&JspW+io%~Xkcx}-CAPYyFYJeU}7qBCRiRMnfYov2JpXNl+D%WUK zV5^>>wml|51*_-@?t^@jAGH)jXjrjJFX}o$8S7%;lyi@Qcdf zbsf=TFj7(-vNMo6jrL%HD<9hb+%=DMJg1Kl5}B`QsPCF_C(HU-zA=L8^znm~KtLa9 zSMGT{yu@>w@z;%*KQn@IIhHn~GG6w#uAj>GPXI-$U*$4JH|e}#mu>vOx;|!tQ?)06 z+7ap#fFj%jyf9o)+92G!YB5dtWBtC;d7rH$jl@lO&~4}HE8^wQ9*|HW4@}Z9YHr$K zS1sUs(mJ;}Ipcb-e_aU@P!2+_0sXpRK zYa2J-4}nHmfEdCBu8{faH{X#+t8A21XtuVdEU4owtvtAM;~;uw(T^Oj%gzX|%!BKm zm*=XcI1kn525}+7MJ^BG3xVE4Zr;4PvAOu8b<;u}dU6k zcg1~4`^g=6@70T$H9c!^))11Vc%;AuMt2(=m$vg$@7prGnCg;tQ7~^^svj=w&^zVE zZB?TRSwpsj$C%o03w6^PYQ;RvQaV;3=NVL6v5po$p|i#Cu9a-XTG;YO9-25~KDsju zbumajm3L~=GmoiE-I2+(&UeJ1*!hY_Ni#>tdtBkT#0sWpEmByu@cJw-Ek%7Z;_vuw zasbXb6NzCk?eM{@U2C+E+jZo}2cJ@pDps%JRI1c*Q%_@mP8qTkY1Y^BDXrl|533Ye z`5WTzuv5)gCq?MjyuJr_-^7DjXM7Nh%L+R;ZERC`=jZelh|xc{tcREa>tb6Fp$a~9 zE6#k$P;PVEwriE#NyhT4#1YC>ob&6!4)9%R&EH= zYlARhbOF~RRhv^h_f_&tRMULeT$%jQm5D21=Py38*jWF{xIJ4vBG_sTlpch@=QI$Y zsa)`!p#B-sYmAu=vDxTLvqY8sJ#l1esQ@92!^cR($PGQ`$H>2v!9&Ok7G#qzb9BLcoSXyNcD?h&oHa=F3X-s1)R&?21 z;~lR)cWO!e?fs9R@Lk0IKj}h;$%7O197+6}C;W5pMXS&0kbm{H$Lj|Cj>T(N-p1v~ zh*8V=7pTkiIoy#reB6xFNhbBHp7nnDGmykKmo8*b$6N z@dHue{Y*Awu%9Q2rUABD9c7d?|F_ePD|n-dFHz3>1TZQ5rtlph)uibMI$y8D(93b& zw-;YmB1rQI8Q%w=;yHMEWNpA?@O#Ce+?iW>zqT=Gn6QG`K>FfAVD}omAfYkM;xzM< z_$v_G+GUne_;yAnT;OsR5@6|Dq`~&S$yRcq!@x_sW(&6;iFpe?%8Lo_sOryZXrlVI! zr|yC}rSf}KFjevKA&93$R_K@KBFq9^MeiI3oP{25CV1EhB7^l4m13+JN%7){k#J6% z`&vvfP+3U)C(SD=Bcca%`2=dk7x*Zua6>VW0=vgK4J@njOUq6QE0StQ88iX6Zd-`o zOv<>D@oxi;iPw|+)5CY|ei+w}UQd95ieD!@<G z>kl-Du^>xadyUWC)T-yjdZ408rt?}K&a6l@E0$vxE~Y2f8;mAb{K!lnl_tTS3CCeuU4rZM%y%z#i%!IQ zZZ|lE4L$D5vySAkL*OQ;l+kgxEU!UJeQm z|4O4&dhjdgDn5B+E?yG#s{7#XVcMd!_v;Bz*cJNlc(<6p;4&{5dKfY*!+?*|4Yl9m z_2b(N`o?;qal1$wEh%L6X=ocFGdgz4MjuMNJc(dhIIALNK(3rfOsdDKBgEe<>Ry%w z)1o_Vnzrs#lA+%F?Pap88~8c&hw^V`Y|;B3j6P;4#SWe~GERyg3*V+}bi2 z8qgIjc4YoA=uzCFu^`R^Q;0Wlys^vfSLF}#2V(M+na@n?p2KzujAoj+DA}(e;XJOk zUk2PG9P6?6h7@+HynF)4XH^bxG|j_NPV0VuxUztFbbJ$rmoT6J{lj7X$EUx6P6WSg zRvhgQj4Mg&Kg`Rhl`0+rHGDAj8`V_idE34ETFl|5IR5GcyM;w-H4 zVN+Q@)dsbRwwqSib!$EiJ( zQw%tb-fp3TeiH7d*dfe1T<3WLEY_dzJpmrFf6%;+IQ*q zTg*8V6JrRjEq;()*(aLtz<_=xrj1M9H7zi$anrq+WA*5`bQw-}>nnj|hQMQC@SPoH zaRa^v>qwD~c5Y3N+(bT3>!lqfTm5ti1hHj0mFdGzQvN?g&$c@h)T;`>3Jq*te8F=)7C4fF1BLF!S!)sx)WNE z$qo>%*N@%?pGazU?iyJYw#?VLA{gMlt7(X_r0&Pfr(c~^q!c`dsvXPfI`7ohF0lW#uHl6Th5D~W{9U8!oR z4lf9Pz6rIHc_`?2yxLQt%#QcAkaPK7SX$0qd(1eFKh=aQiKpb{gvk!toFrfFy%SVE zqM5aveyeNqaqhOs8dJ8hBfgDJAiEy=T?u;}X*S;1$wL0rLTr+Ky(mRPidc!R3dzfe zc0)V>crif9hPpUQE^0NRAnFi!Ej&@VH>At-^2Y7qd=R($qt<%7C-}pyeDPOqEiaEa z)%m*e*m5E_PHF7>*DC*rDF4ile{0&C*snsv_{I21Z>x3esAZJ#KH}gNqiFo;yzTzU zc~ch-86h6luG+U9cP3jps7SJQ>ZSKrByKn3$~k9#bsqUKWE5XnSDT=G6JXc2Ty?Fc zZ+|6Ews3KPMarFV#l9upb|v%^-yCXwQCT3D8kFluH>Qv34nWD_c&LmE)i*})hfy^sq`hj(3^Y|2h1|h;~ zQ)*?jOi8Z}WWNme-Te-9Um0>T^=0SWDrVVf(VZmS+smzo4p47L{?!9pOG@zmb?L*)WY6&FIV6CxX-QZp;ZXSU zPdH(DKVv7zugqrgTu82%3+P#;zkVhcT3YohpItD%zTCdmS~|37Gy-$YEu3u1RJKa= z4j)v;@NrB@$NvBdkv3sT30*a#I~3RZT$=z_FpC(?s|Px5x$wnswJ5FHJ_6g3Uq7E+JD8*7RpeqsbOf=CavaC908Ge z#b{$lO6D^DON}P{f=k)A2JSei!@u0{0ge4 zmktfE%_~>6kB~hd&IQ(=03u&8sBb62>kFFXb71lg&Ng&&lBvnegvq4Si*9sUtRxOCHvCrI#%rE#SK*E>5+G=CF;`FQn} zvAeO5Bkcl1DD@!!PV*^g{%vOayj^?Rk>!!4_O^mkS<@hK0+5{s4oScrH^Vb~&(-vB z9FkK#C-q%%jazeOND2FAd2ppD2=(T3m3)(?i$LM&M6>2_x55tI&X#szOB-=^==##gEaUhy=Jf4OVP(xjG8AiF zj;6r&{mU(w z%S`}yAuUqNYSbVN+#02-;reCVAG@jMBq&SSJ<}%JCrjC~GL#`RFru(Y?bym$k4x8p zj@3)H8(<;iyr=IoI#ASPLy{S7b<>dWR^PIC^yLKEKa5k zxFijwU_RnCOscH+pKBElynPk^u=1+~-D zb1odJYG6A@J04nig)t63gfH#F$^f{~Fhi+~Bk;CmwO+;KRMW75JRJ3Ffd9@EBB%ij z?o3Y5w;K?VR=U?4k{^3@1DDtc~32@(2VC+ ze6m$*Ro&{|{yAwH;4AG&d}gE|SU)-~RXppS7Eu~4=1@<}WS$YUK&&@}J!yXs*g=k- zeJZ$_vrew$i%%mcQmfg>@Re()$wguL0I3nj_9r#jEK?t@JV+Fbd#P46o zlV&caKxEnI(7TG@DV+}Ai_iW_w$J`uT5A1BnJ3i$x5VDFdIyq?8+pZ=!(fXe#d4D|%P2f>0 z6!qt6kWqdHWmx_?qyIUYf7U2Sn%ot#Z}cIrjkJF<2LdJ3UYfXv-QsuxbfVr&>Gum$ z9`PTS9{c)dL%=2PW<@L?W}pcWAKTq%6}K`qiJfTjMlKhm|I#_bc}M(VlrSJxQ( zGvDulzDwe$&Gk&EqjOSL-Z46acNESIFq`p^H-8R|$`zFOZ_+pr0naP3TFm%&kOcc1 zRng4WLJ81jX^hK%h4S_V%1Dk}aZgsCAgWB_~X>fpc=kRknMZf zzN_wS|AnAT5%STA-1&KDqOsmg#G!**^SRmWG3P$*pSuA5_*m`{p+rNp`FpFg+xo`C zLwNOGV5_^Rb7XqvK6i@XS?<9~^O5k&_-?*3=OD`DPz;7xFzvczk64*~RlCM)nw%+_ zWB*-~=a#-vLI&t-Y>&&#nl)`1dUV`8Uoi%I_vJ#v_ua9m`k=G@%|xxr$h29l zch@&y&0J)fxhkwPs9ap4`W+i}vroU-$VbWttj5<%Q&Yv95gV*N>xL6h1t${ST%B9J zc5Yr~DLcGi(yIkP$UiP;^nqtAIjpPvg!G*mtT>{lTbsvjECsS)qP}mzd}U64L2-^5 zMnN%lr(oy^qSFQ0c=IIN&x^?xd$rADrk70|b7Mu`*MJMU6PW1n#QLCsF{#=6ZTuOU zgD-d0i;G{-Sy`&Z_|yjl^CdjEEvSMv-&b%UbNh&xdT4+bY;-``}_R7@FO) z?^K+4I}C73Ge@K1Y!lDJZ_Z9gQ9aOWP2(r8-8{DKG&W01Zka8K?MpHVXFFA@xfpEH z_+SnC!QXbDiG?zeS+nk?k-^Ej$E{3ZqwYfsJUL&w2~8>hkR1SA8w2F7+UY(lq7ZrF=Dg{*C)xw|7pEq->a8}1ilNM4MNa8_B zzBxCO5nKix643{=hWl0yPx4x#t zGd6uRy3Wmg*}8_jDvP_eIKX@BTyw^c6xOHI;RB4DhPM-(E})~zQ~`NmoNcFeoUZ;IZZH0aR4;#~;%zwp4gj@TgU!;z#BojP z%_NTKowCzCSiWK)B>qZ8{K)0U+8rU!m{$l#J#Gw8W_=V@F>ul7NWHm1kjUyds%IV} zOw@FeAc!>9405*h1`w#IWs}4h(66q2tmAppY&VeoNOiwGi#R~dy>3L~VczdVe5-L> zzb-qQOLfWGKV+4JP_n8udQDohB`-%x~gAzMHA9=Qe`~0F+8G$$q3bl~VMQh^L9SV0mWm z^CTsE<sh(Q3(LJHmkr!fuu{owd# zR;j2js?Scss|*~{jEgtP$OmQo!r0n?gL`l?V9s2yTiaS6gkvBUK{lK^sBcCRikrj< z`aIisuT#I8anMOpI7sd2>5>hepq+<@w)yX9)mTDG^$b{25P6d9qlk5<{mJ*&_nPp< zSKPM8g_<#d(-e}|N3vUG1$lWz5XSc|cJa3eMfA=4_20gg>5{udC*Is9s$cdG^}UNK zehowlnZ7ox%OIJ7Vv!)q0s}E<17G~TBK5EF1G(?ds9(zS10tZK*Gfxj7*IRt&IT?- zIU+Sfwg=bRttTS)^Bd$16x_>P4_=L>2Xs68PfU3ke%*IYp8#QJ2+fAPtg%@oKDx~= zc;-W|Ggqd)=UX>TN{SmgG(6JPHS$Mx!)y*XKsip4n=obLE1#Lw`HPB1o5j2H$JOS5 z?n_|ccf4Fa-reWYyDYk}m&I7U3`N_^6#}i$Rh~zkXq>pE2)yV zp*cnZIfCXx_DxD=*_F7}B1RU}`{ME1`l1wQ5h~!Z_#G#>&fn3d&0pR^>XDYzt6 zzdH~yJ}f|h`0tSQk~mHPF{w)IVA8}@BfuZjxSN9Bqg!Gu@f-7Gc)GjC(7S4^=!Ud9 z^GGCgzQ1@pXZ2$_CDliD z#KRHH#QL^xTVzLS7QIyuz|;xJWP=99_r)8;!^h4BUFazTMg49NS$GK5=Rt$9JhoOFeKw`!G?^ zrn~5|7tn?jA_eM$G*b|y+u15F=zi8d0*{Dkp#7j&CNh8B-Iwpqf(D{2y+Y@TTs>0L z-)^l3s`6Z^%eX~3j0XI2&=PV&!k9TNImMdQ7!~HOlU|V0)@kO)5P-*dLA2Y)zdLLr zZ^F4Crv9U%e;iJKL5Sm-cJG(4h6~qSm6jVgD$lC!B+JeHyqh5jN8rrn(^x~n+6(&^ zWT5y-)bWrsWP6u@dPi|?S8fQe%$7G2rdN+W*}WOK{qdwVc^dD4Qa)KXp3F&N`<2xR z$ps8XH(Z?V)zJzq57*I(RXW~S>UbFD3IXWZH95WjRtGJb)pSbN#F}P~Iams?4h;>R zX`fi7YbhRP(M<>aoEp+T?+0nWI-OS2XD-tl0N1YXabutp!EN@nmp9Bsmw%M{&JYEc z(MN6a^DS*g{mR2h!vQfHp|rFD&^8Ken*kFZBmC2&xr!klac17oipI-|3C&T6^;4*w zQ0TH7=2oj^{WzWKIGrmWVy6X?%(mvwW`6)ftUuhDrdwK*yc17@a$~N(NkvjLs{@)v zO1aP32s31+g0<$DM9;?5PRH%gq##bpxu%&Grg?WVaJGps(tzs~Gc)5}c5!MBq@xyU z=Fro_w%QFML&9I3wX)F=mXwHBW0zE(S*BvKirRw$rKIEd?_cH5;&FEvM>@ZErZ+XU zw9zAq+wTVm>;gPs4hqrQJ9G>bkG+JfPmF_m#!KQuY7(y~l|z!xXhU`%@s55(;jCMw z!p|qMX2`%j3MXMlXXAd(zuXbUpPtgo$;*w%NSQ+(fKS+-06Gu)Q~GOLRiFiS%-fd~ zCo5J=vjf?Ad7A^>Lh3=cdi_vH9%K$-=iH91tS!~F%o#p9kJxhQN*vh%OI{j-D){R< z)Kw(I-0WIE3!ZM6JK9}^S!a;09wrV51ZvJbcudk^CSSU+1wWcuxcHVeCPQnscIrAu zhPNX~R$5z|Q(x=raixdGM^BA-J)P{7xeecnjJ_JTNiReG1YHx+@+lQ-h*0 zFZd_X2mPsS4C=p-4^5asEXU5q+T7x{o9x-e;6nX4*3`RaYS*kSs?{e|xNF!I=HEPc zj7^{uku}14gkp?9Uwu{|@uD@*@-PYUB)hz^=`us51N=IVaYEW?o6My#WlERjk$jzR z+}{67!kHBw>&3ZfX?S^}ld_~-4;>9|DuN^2@)WKkpriR109jY2C&#kD>jpL%UY_S1yjc{% z|21JmaGOY{mMej;_m^R4cYn=%*n0j8yCNb^RZ~NW z?t}7*8}o~N5MvF+I*a1@{9mGI*WN|Uy6vp0HLUkyo%h)%F3eJ z>`Y6oZL;nllFTe39K6TsWGwJUa&n1xt?CL*N;%%#&1t#~o$YIe(gBjQni!Fvo**^a z+S+3|(l6!;!bHWwiZ9}Ct~iQlfooGR_Z0M+2J({R9ZA96l6328aG47!(W~MEN@mv- zJ1Dy@Uir@`8=1*jURlvIb*e6$Y=$^WpjWG%FKlcQtdpQJL#SHXURzrMi!SkL|8V8d|BUC}^BUY-#Xu&e%DZ zS(7eD9LNBlk%qzqbZ@(kWdB5{mKI@# zu#^6Plh7?K``*B1A-wt*tf4C|{;Fc3#1PwilH4nRbh3|0I|43#reKNlDFs$jM5MykT(4E4bJdd=wIuvv_U zneWnm^cet$WJ?jK(O;D4{k-?~SlE8% ztcyBc9@H5fnUyvPR7)%~k=zNS4x#~Z`+o!&O5&62*`xvkX4Hl`03w8DWKd80GqUOz zyQ54q4QU5D`(1Q~G#$byCqMCAYt`h@3dVZq3)-vo6}d71C~JAmJsOLy@87()(cjV= z#2_YmUI#mdhYWUO3$~nJn1l2Oq>hkb`7l_CCY)ou32s9&b@`7?lZk~Hmwcfa4dZa= z_S=2_M6~C78Vy#vpH9=9oP35WTAu)4d1>1u9)`2Al^S{LQ^E`tcQfJbw)>FI^*9&$ z&}ei77Y4)xj(i_Szi#5UJ#BOfuRF8Zx4}SP@wug0m$Yx84*Ye z;Vewc+_isw1?@unA`;{dsBK35NENpm>{}Wu(_T=bU5Y4UY?SPwJytXic9@rD=3>+K zyF7{`%5QSmBl7RriSiV{eTad|4Q-c2q?dVk#Q#W-Z*rgkZElqmA89`jB5x;M58auX z>YpjWL&=cPuGI!QRVZ0KHyF8^pH1|>ucVvov*w<@$eQ$QVIEi^b|^C z4~#=8D*VJ|%TgbxE_?^LaH_#Yn_Xs$CX|I60z;P&6g>H686I!8l{j5ZaVTv_fu+r$ z-uPU|^OFB!5IoPU=hX*H>3B&i=z@Pc5P|%e&X)7J7$+ML@TDIIumuAI4ckQS3qT0C zc^++}!-)1xFBCx;6#6KGUIF)*;2y1cPV>&k-oxUaJ~Dd<0=h;gV{N$Z+V=q5I!42xJbo~U&faM zm`=A^!sI+-$51ViWW?z-KPe2{{UuB`jDgLgq|^BkH^gV%)~9GJ zKS;e%5>1>bM7*3KVZBXXyj$JY!vEu}$I5HTI$5l(3Si>>%8d-?}?PQCay z)Z3%}U+vd3=$pYht8`u5-MuYvvDiz3fV zV}C@k5R0RcChpHl6*LsQd(1XQVgE>QTlURc#Hf^+>Nny>!}6P)_cs>h+%sJL48bKa z!N&cuh47z{YgQXizwB9X-o4%V{WEyIrCJmC1Sl277x0^>j3}?#$Y*-yhO7HBiT(bL z7FLq-DE2+!^+V7#4O>L2-yCJ?^QMDhQBKs{LZe%xgLhrWm}7y5|2T`P_4ogkea%LH zW&a%dfAtqq2!9kM&nx7xpn*AqPr&sD))TFZZ{A%RdBtt)o^3aJ3su_}-+hbA{~$?* zRsU;8S{nYR`$XmZlRv4D_9tx;-OmCX5vQp9Y{zTE)%y+WoMp!l-Zi>QP1K4|*~7KNmfomy&^bH6sda zA`V_K%wN|$xR+6$VclA01jAe7K$U+cFiUE+*~lK$+-_Ptcm}#sPy`sls{M$bCKc2&5C9)1Fy-Tvg`WGc;%5l9b z;Rj4U-$?NMPZ7%3^Z0SU10U?appT)v_0OmJU90(5gck9k-86Vws$dPZBW?Kj3Gen^RN!Ow#WjM%?5b7CwhKqY0iWz5)_48u zF6!lBx@%%p*80qkR`676Dd#L0zLLui8-wq(N}5N=TVcU6MbyIHKEnbfQ1B1@bNmfg(XI_omMb%-_X?WXjTpUkg{z zN%ivae?!}1S=dlX{?54VK$LO&SVyy8-*MHt4Q^GLpBWj-c66qAOpWD#OmBp|IKR9! z%q(r`oPJ+Y7Mu})7I%&QEO=E_Hd z7eaI66odCuCLq_d*3e!rM<3?jPUufE*O9B%&_s#SaOF^$QBYGpjqB~BUEiAGl>3&M zI5&6y+0%ZWBshuHZpMoB!8ggD;0vjj>JTTpK_*hHUyDy|Wg63nY)Pp$CE6HAM^Q(< zaqxAxAVrPJu?@ne;urkt)l#%scAm+bfIjqL_p%rf;54NYs!qdHYo(l{K_^Fw;}*Zg ztsb>aYYc<>?b#aC|IP3gC%a<<(JJrNZLmpIh$bQ6E~*+lKHyHq{hf(4gQ5^VW{u28 z(*mY)MfS&hszY#%AiG)5YJc;doq#n*V_^ z#KQURAv*xVnH$ndve4cwyCNkxm8hH`jy`hGs^J$~Qb-|zR&vxmR7&*!t2UfcRh}>Z z8!EZ~Kl3pFPe6GDAnfn33RFdZZzWrfKDg)6F2;qZSme)f9+HN^iDtNCfqVTM}3RcV*h6W3~1CX&N8+@UAIV01f@mmct$1e2D{ zJ6UcOW_p2~+TUHc^l%5A_rb3y`Ss0ZR|+S#Hjw!6dF5K5@`UbYqTs9+0*zIK7fOLz zf#ZZ$fBN*4_GEvko8-OpA=Sn-OssM?ne;`q{gORgenBdxb;x7{oTRfy_nhw z<{hx%ZuYY(^5$>JC#VoY_-pck6r7j|FaYDKq8z>dUJO63)G8c@+P3I+gmAn47du8u z%mRxNi_hMhgX(NNZr#y~3b}VK_zNPWYu38ww%@I5{UJHrUIW@$z?vSuwr8_n#?&Ay zbYJOWf{wma59SNp=fH$MV@<mToeMS@A3YOIW4TpF0<)%!=J&?~sYuOm{w@$?H1F^=9-rFHU2>(cyTU)P#kB`D za#00VL>vo{5`2NJSR4evV1x(=FL4>+)tM?M8t%F|k|L+%pMV$YwfJ$z_RPdFZ}jCTTL zka1mzi)>3G0e{J=FeGag*WqB)bJp>y_~?7BN8Teq69d!xq+s9P^8S$Hoq-D06ZxF1 zxOWP9&T67Mxt@#K2mL(evM>AQ?A6`HaXT zny8qD6d9pl#>xyB+>&k~glxH-o#)XL>MGg@Pz|pM58`KVyD}Jc2BaLNZJ$fZyHm4V zRR}i7LZQ1TfN0d20fH0W1`RP}L&XaS0P8ETjD)$OSjN8-tj~fOlFmNv^I5s~T|faisvHBJ=63ur6&7^ z$sWk7KKCf0hGZ&TZ>kf+31Y-ax4zn&AhVK1r@py>NMHvsZWRx!UtQn)Djwsoa+ip4 z=)k-Z?qFn0HEKJbk9zvCWA|&!am+`nsylC3&R)PjTGotJ=S!mcj8v+)G7ZDIlJ>_T zg2xEh$krZ=^xokMa2$Qj*PM~No81@c0V&WAyo!O(qkrzRNChQB5JP}x@DHDbLbws% zX8~Xcz*C@jAVM><88BlSUiG4t2l4hYFm%P>%|GzgME&Ab`Q^lgD=iicXHE_=Bi*ho zyE@@W7caS*I03iJuo7tigaI~BG(?5$-`}Li^QFjZ+n*D_oVadY;7MRg+|8}A+mdA{0T|QFhtC$f#SNf{HkK7s;xoJjMm#1`C#*lK5B;YP5ph+Ujonn^R?Lyz*Ww{|l zNhPguCuD5BXR9fN$7OtCZjDy&SJ>^DD4eJ?IKqC&IWoT$bKLubURFcUfh{#|zz>G` zvh`4C5fe90AKIZ~@~8aG&IM^G1)mhnE9&$(cAZnsOhxH(5DPab*fQK|akb>Yz?Y{} zT-s-j!)`*#Rcrjt0~^!Gp<$#$V)nAP-rgBnkDPX0gYpa8DX2S3 zGC!}3MnPP#_xDvivQ9UG_u4qp(wbzr4<2f zTsy$21u!(^6ZGTzuMY>nI4scYt>~_Ogm&;e`Wh`{E{0>^fo_MO4Jr=el!+EGHzO)C zjbAX9y^}v{-q?5Nz!cm#SlL~zI3{VEoFX?Qe^I~t5)f0M!Q~R5Zvd-FU$ZTtnwca5 zj)Ja{=FfSm{B;BwE@?pofboZM_TRJ@@E8VVZ}X!;X>1noVpA*$MmRhik3?CS;5pFm z0^XGXi@Eat#h>pu<<^b+o^0|NluLYWs!ooEH?=D0(E%QWb8KPh(XVNe@?Xg3=Izc} zme|mfvYsf;+eDDcv&oVHxs9bs!Iizo`khoSEtoWReo)?&d0NqIYIe5eQwn=uac6Za zaLHoT4);KewybX^#3?_K4WK&FH81Sms&?~9$BorphQC&<%y{U2iyL)NawuPOlU;Vw zyMDm#FqvA`0 zY%u=S!j03-YDu~}FOw%L1+&@}BF6^d4Fp^LOqkq2O+wA;o4{;M;7xwa>f61 z;Lxp5)P&B}(y-HRBd-H9xB}AtK!0QpseqLodUv{AtnPHROVrZ^#|YedFS#aJbuGP? znQ=C2*rHXF&JR~3aBO)k6nw3TS$vnW=~flP@m&5OBEKmGC`Uil_W1Y$WO^tQf`9^9 zLd=qo0w>_n2q;SSN>IhXV=zxtmlgh=CtUA)@r$57upYX|j5IS*fkW#IiDI!hJtTPY zY=9MSMzjO~4U2>EWwh+JaW;_8vVTB}vqc138V-cfR^%+-x(%LHe%-^jZvSxt2NwP* zLh8SS2^yyW(Nu^uAsCljf>_xef)PlB#{<7nlO?Mf(YJ0%&^gL-m%eb%MbJJdz%!qYL~1nn@3isP~P z`T>Q}UzqyfVs#tgVyN{PK#wb<4cBUb!SiUr%aZmf(UA#3HGmyoFe*mU;-IMH=2sRm zHN{1bP1lrB)zSs>6YEBYF6o3^6UWQ+k`_KRw|iq^f9u7*V=5D!O8ywh|f4 zSxnbSKbk>)_p9bc`gw;OVQ4F@=+jNts%NiR;%_IYPu`nKH3nIkHIpfC{2jRxF}ueR z0|}|=NjHw!^A6*v?hY{7*Dx3ao{0s$1s29J;DrL+(9mv(ifdT`U(x)F-|esE3E+1F zo0eB=EaDfD5M!p8igC$Bgb4~}$-pC3JctR)DVss6Hw;ohpn3w>f_Li1Nl*!M0~u;^ zw;ZuPf}I_qE(Xt5Qx}Us#X{@IsYE03iR*1Ho)DmACLnwr&FK27{w{Q8R^MSAzTP9< z?zmdfgQRxTom{0eIgb>(GjS}Xuxn3OkM!UMDXtX*d74@Dnb4aJY~QKn9R=_AP1yyM zw-m%kxnOs%$UF(_oxGbqev1OwP_IticuMW{@p(CKQW|X4NTxb|z4~&bbnyLTE{$b# zQo_^oS51ZG@evsJl{mEv|5fHw!s?m6w~v;*r`6`#L+xwcPxK~erPp+Y9a9Wttcsef z4pa8mE1a#zy$@(#2jT9w0@%bk5baf*&ZW_6 zQb}W>BJ*kODIu|EZ{YWQath;SZ)o>fqbto{Eqd!sk^;ZQ<(|yT1BMx{Jl>zvbWr-kBEz-u9(a3sKA+B@) zc>rBo+zn+-fOncC;DVY0~ z^@f$()${s{s&AN|wt!-DJMd$xPlf83WM z_hM^n`q&dl*>uR*>D8UnHx06RLkUN&>Nf3JqE-%8jqXk={@{{8QQEl_6~Qw=L^g&^ zFJQ5Ul$vv&FD;)HyPI2P$9YNYA|4f=NH*M?!g=y_VS zz^H7{QKaIQ)4?C-%woqYRHg8jTX`Alt@qtD9FU(gIu{UiK$5BwD86nh2A@*YWnHE% zmnPQ~Ke9&XdF@f|NvKKqX8EOsvS;+TBS3{@NB{w%}lXotf_Bd`C7ztF0-Jw&e(|e-P=;)Whpl4wbqbtlM2d%qa{oYY8y%Il&Oz?s> zn1z`;*6%Qt>YShZ%ntP%pRC<>N_EFUdYoc1S9V{s-1CLO5p6cNd`6NSbtAOynUTwn z2WUp;80M;i(W(mYcNoI_kCI>2v&%&tOMp1h=|8SfHC7eFVy(^)KL3bE*x`UZW+G0P z6NGZex~rVASuDZ0DIMG#y)rgkneYH6Ob*ZgA_FY*<8)eJ<#c@T_)Ucoy{TkXmGWBr zxdm%r4Hu)U=~V9&;BFU_e|cH3VUQkKUac#sG~WLU%RmHskP@ChH-48ACaU3wHB=OZ j@HIG6MG})5T^lK?(vW7GeWmzN&rWBKoFGi}d*6QmosAh1 diff --git a/handoff/body-demo-review-dialog-mobile.png b/handoff/body-demo-review-dialog-mobile.png deleted file mode 100644 index 4719f28bbbb0cdf21a681a5f50618f835f290042..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32392 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 diff --git a/handoff/body-demo-specimen-dark.png b/handoff/body-demo-specimen-dark.png deleted file mode 100644 index d1079f05b3ccc1b65072f8c7414280f1813e903a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 214461 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

h&O> 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 更新时间:2026-08-02 -> 当前阶段:Task B 完成;P1 37 门独立 Demo Hero、P2 文档刷新与最终验收均已通过 -> Git:本轮只创建一个聚焦本地提交,不 push、不部署 - -## 完成结果 - -- 用户已批准 14 门代表课程的高保真方向;该方向现已从代表样例扩展到全部 37 门课程。 -- catalog 中全部 37 门有效课程现在统一使用 `data-demo-standard="instrument"` 与 `data-demo-intensity="enhanced"`。 -- 历史 `.demo` 统一获得纵向仪表脊、连续编号、工程网格、焦点外观与 44×44px 触控基线;新式 Demo 继续使用 `DemoWorkbench`、`InstrumentRail`、`DemoTableViewport`、`DemoDetailSwitch` 和 `VizStateRole`。 -- 旧的 14 门课程白名单 `lessonStandard.ts` 已删除,正文标准不再维护会与 catalog 漂移的第二份注册表。 -- 当前库存为 37 门课程、111 个正文图版、56 个真实 Demo;G/plug 已包含正式六帧交互 Demo。 -- 全部 37 门课程的首个真实 Demo 均有独立透明高保真静态 Hero;37 个构建后资源 URL 互不重复,14 门代表课程继续作为视觉评审归档。 -- 所有 Hero 均为空替代文本、`aria-hidden` 的纯装饰,与输入、求解和播放状态解耦;区域 6.30 MB 资源门禁保持不变。 - -长期事实见: - -- [`../docs/concepts/body-demo-audit.md`](../docs/concepts/body-demo-audit.md) -- [`../docs/concepts/family-visual-standard.md`](../docs/concepts/family-visual-standard.md) -- [`body-demo-standardization-plan.md`](body-demo-standardization-plan.md) - -## 本轮实现范围 - -- `site/src/assets/demo-art/`:新增此前缺失的 22 张课程 Hero,统一经透明去底与 AVIF 预算优化;删除无引用、可由 Git 恢复的旧 A/01 Hero。 -- `site/src/components/demos/`:把新增资产接到 A/B/C/E/F/G 共 22 门课程的首个真实 Demo,统一使用 `DemoSculptureHero`。 -- `site/tests/browser/body-demo-standardization.spec.ts`:逐路由覆盖 37 课壳层、Demo 存在性、页面溢出和 37 张 Hero 的课程专属资源合同,同时保留代表交互回归。 -- `site/tests/browser/ab-lesson-hero.spec.ts`:在 37 课桌面与 390px 巡检中断言统一正文合同。 - -## 验证证据 - -- Node 内容与算法测试:236 项全部通过;Oxlint 零 warning;两区生产构建与预渲染通过。 -- 定向 Chromium:正文标准化测试 10 项全部通过,其中逐路由断言 37 门 Hero 独立、纯装饰且课程专属。 -- in-app browser 桌面抽查 A/multiple、B/edit、C/tree、E/inout、F/select、G/tsp:Hero 均为 600×600 独立 AVIF、展示高度 320px,页面与首 Demo 横向溢出为 0,控制台无 warning/error。 -- A/multiple 的 `m` 从 13 变为 14;G/tsp 在 390×844 下点位编辑产生状态变化,两处 Hero 资源均保持不变。 -- G/tsp 移动端 Hero 高 220px,27 个按钮均不小于 44×44px;深浅主题 Hero 尺寸与左侧几何一致,页面和 Demo 无横向溢出,控制台干净。 -- 完整 `pnpm verify` 已通过:236 项 Node 测试、组件测试、零 warning lint、两区生产构建、46 项浏览器测试(Chromium 全量、Firefox/WebKit 冒烟)全部通过。 -- 两区资产门禁:Cloudflare 278 个文件、6,283,671 bytes;EdgeOne 278 个文件、6,283,669 bytes;最大单文件 732,959 bytes。 - -## 边界 - -- 不执行 `pnpm release`、`pnpm deploy:cf`、`pnpm deploy:eo`,不把 GitHub Actions 改成部署通道。 -- 不恢复“插头 DP(选修)”或任何选修定位。 -- 不用卡片堆叠、桌面图缩小或右侧裁切替代移动端语义重排。 -- 后续新增课程或 Demo 只从 catalog 和共享原语扩展,不恢复课程白名单。 diff --git a/handoff/body-demo-standardization-plan.md b/handoff/body-demo-standardization-plan.md deleted file mode 100644 index 467b206..0000000 --- a/handoff/body-demo-standardization-plan.md +++ /dev/null @@ -1,73 +0,0 @@ -# DP大师 A–G 正文图版与 Demo 统一改造计划 - -> Task:B -> 状态:Task A 与 Task B 均已完成;P0、P1 37 门独立 Demo Hero 与 P2 最终验收均已通过 -> 范围:A–G 共 37 门课程的正文图版、Demo 表达系统与 B 类课程路径箭头 -> Git:本轮只创建一个聚焦本地提交,不 push、不部署 - -## 工程前置 - -- [x] Task A 工程基线 commit 已完成。 -- 所有命令使用 pnpm 11 与 Node 24;完整门禁是 `pnpm verify`。 -- 新代码使用 TypeScript 7、ESM、类型感知 Oxlint与现代浏览器标准。 -- 禁止废弃 API、旧兼容层、旧 Demo 选择器、页面横向滚动和桌面图直接裁切。 -- 方案拍板必须提供可比较示意图并使用无倒计时弹窗;不得静默跨过评审门。 -- `docs/` 只记录通过评审后的长期标准;进度与评审清单只写在本文件。 - -## 目标 - -- 补全此前仅覆盖 C–G 的审计,将 A/B 纳入同一套逐课台账。 -- 建立“仪器骨架+雕塑核心”的正文图版与 Demo 统一标准。 -- 先用交互式标本场验证共享原语,再实装代表课程,最后推广到全部课程。 -- 修复 B 类桌面课程列表中箭头脱离课程卡片的问题。 - -## 已拍板的视觉规则 - -- **统一深度**:统一状态、工具、滚动、动效、主题和移动端语法;各家族保留自己的材质与空间方言。 -- **主构图**:每课围绕一个算法主对象形成连续画面,禁止同尺寸卡片堆叠。 -- **Polygon**:核心切面必须对应状态、依赖、合并或冲突;语义切面是规则,材质装饰是上限。 -- **背景**:常态使用 Warm Ink 算法负空间;完整求解或关键里程碑允许一次克制的矿材展场显现。 -- **动效**:常态为路径描摹、吸附、差分亮边和短回弹;完成态允许一次雕塑演绎。 -- **工具条**:双层仪器轨道。主轨固定重置、前一步、播放/暂停、后一步、进度和速度;参数与模式沿同一基线展开。 -- **状态色**:全局语义色叠加家族材质,并以轮廓、符号、纹理或线型提供第二编码。 -- **大表格**:局部仪器视窗为基线;超大表使用活动邻域与全局缩略图;禁止页面横向滚动和交互表格整体压缩。 -- **移动端**:每课都进行语义重排;算法主脊柱常驻,表格、轨迹和解释层原位切换。 -- **主题**:深浅主题保持相同 DOM、节点、状态和几何,仅翻译材质、光照、阴影与墨线。 - -## 实施清单 - -- [x] 将 C–G 审计升级为 A–G 审计,逐课记录图版、Demo、工具条、状态、滚动、移动端和主题问题。 -- [x] 删除 B 类独立绝对定位箭头层,恢复 7 个卡内 CTA 箭头与一致的 hover/focus/选中动效。 -- [x] 建立 `DemoWorkbench`、`InstrumentRail`、`DemoTableViewport`、`DemoDetailSwitch` 与 `VizStateRole`。 -- [x] 生成标本场的深色、浅色、移动端和关键状态对比示意图。 -- [x] 建立不进入导航、排除 sitemap 且 `noindex` 的交互式标准标本场。 -- [x] 通过无倒计时弹窗评审标本场:纵向仪表脊、整体确认、增强演绎档。 -- [x] 实装代表课程:A `01/dep`、B `lcs/fsm`、C `stone/ring`、D `grid/matpow`、E `basic/distsum`、F `knapsack/cover`、G `board/plug`。 -- [x] 生成代表课程实现对比图,并建立第二次无倒计时评审弹窗。 -- [x] 用户通过代表课程第二轮真实手感评审,并明确进入 37 课推广。 -- [x] 记录第二轮未通过:代表课虽然符合共享壳层合同,但图像材质、空间关系和主构图密度离设计稿过远。 -- [x] 参照标题旁插图策略,为 A/01 接入透明高保真 AVIF,并将 Hero 缩小为纯静态装饰。 -- [x] 根据复核将 Hero 与数据彻底解耦:删除手工对象、SVG 路径、容量轨道和状态动画;数据只在编辑区与 DP 表表达。 -- [x] 数据交互独立保留:物品和容量支持直接输入,状态表自动跟随当前格,位置缩略条反映真实滚动状态。 -- [x] 由用户确认 A/01 高保真样机的正文方向,再重做代表课程评审材料。 -- [x] 为 B/LCS 接入独立静态高保真 Hero,并完成 320–768px 字符编辑器、表格和播放轨专项回归。 -- [x] 用户确认 P0 通过:B/LCS 证明静态装饰 Hero 可以形成独立家族语言,解除其余代表样例制作门禁。 -- [x] 为 A/dep、B/fsm 与 C–G 十门代表课接入 12 张独立透明 AVIF;Hero 保持纯装饰,不承载输入、状态或播放帧。 -- [x] 将第二轮复核弹窗更新为 14 门真实课程入口;不得把 P1 工程完成等同于代表课程视觉拍板通过。 -- [x] 在继续添加家族资产前回收区域资源预算;保持 6.30 MB 门禁,不以调高阈值替代优化。 -- [x] 为插头 DP 补齐正式可操作 Demo 和独立移动端语义重排。 -- [x] 将已确认的视觉壳层推广到全部 37 门课程。 -- [x] 为此前未覆盖的 22 门课程分别生成、去底、压缩并接入独立 AVIF Hero,使全 37 门首个真实 Demo 均具备一课一图。 -- [x] 将 Hero 浏览器门禁从 14 门扩到 37 门,并断言装饰合同、课程资源名与构建后 URL 唯一性。 -- [x] 更新 A–G 审计、正文 Demo 标准、任务计划和 handoff 的过期口径。 -- [x] 完成 Chromium 全量、Firefox/WebKit 冒烟、多主题多视口和资源预算验证。 -- [x] 运行完整 `pnpm verify`、视觉对照检查和 `git diff --check`。 -- [x] 总结 Task B 并创建唯一聚焦 commit。 - -## 验收 - -- B 类在 1440、820、390 宽度下均有 7 个卡内箭头,且不存在独立 transition 层。 -- 共享原语具备键盘、读屏、色觉、触控与 reduced-motion 合同。 -- light/dark、1600×1000、1440×900、1024、820、390×844 均无页面横向溢出或内容裁切。 -- 代表课程的真实 Demo 操作产生可断言的状态变化且 Hero 保持不变;全 37 课分别加载唯一 Hero 并通过轻量视觉巡检。 -- 保持单文件 760 KB、CSS 80 KB、课程懒加载和现有 solver/输入合同。 diff --git a/handoff/representative-a-01-light.png b/handoff/representative-a-01-light.png deleted file mode 100644 index fb69525d3f25d39ece3d5dce6f50513aa94ec1bc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 48181 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 F{%-@U3hDp= From 4cb7113f51ff93d7cab094b2c0e2fad27a114fc3 Mon Sep 17 00:00:00 2001 From: Shanire Date: Thu, 27 Aug 2026 12:14:31 +0800 Subject: [PATCH 23/23] fix(web): close destructured and aliased mutator provenance gaps Signed-off-by: Shanire --- site/scripts/last-modified.mjs | 2 +- site/scripts/semantic-source-graph.mjs | 629 +++++++++++++++++++------ site/scripts/seo-contract.test.mjs | 2 +- site/src/data/routeLastModified.ts | 96 ++-- 4 files changed, 526 insertions(+), 203 deletions(-) diff --git a/site/scripts/last-modified.mjs b/site/scripts/last-modified.mjs index fd58c85..e7ab76e 100644 --- a/site/scripts/last-modified.mjs +++ b/site/scripts/last-modified.mjs @@ -11,7 +11,7 @@ import { } from './semantic-source-graph.mjs' const projectRoot = fileURLToPath(new URL('../../', import.meta.url)) -export const ROUTE_CONTENT_DIGEST_VERSION = 18 +export const ROUTE_CONTENT_DIGEST_VERSION = 19 const historicalSourceCache = new Map() const headSourceCache = new Map() diff --git a/site/scripts/semantic-source-graph.mjs b/site/scripts/semantic-source-graph.mjs index cb668b2..985e7fe 100644 --- a/site/scripts/semantic-source-graph.mjs +++ b/site/scripts/semantic-source-graph.mjs @@ -433,10 +433,40 @@ function publicSiteConfigProjection(program) { ? statement.declaration : statement if (declaration?.type !== 'VariableDeclaration') continue + const registerDeclaratorBindings = (pattern, init) => { + if (!pattern || !init) return + if (pattern.type === 'Identifier') { + bindings.set(pattern.name, init) + bindingKinds.set(pattern.name, declaration.kind) + } else if (pattern.type === 'ObjectPattern') { + for (const property of pattern.properties) { + if (property.type === 'Property') { + const member = { + type: 'MemberExpression', + object: init, + property: property.key, + computed: property.computed, + } + registerDeclaratorBindings(property.value, member) + } + } + } else if (pattern.type === 'ArrayPattern') { + for (const [index, element] of pattern.elements.entries()) { + if (element) { + const member = { + type: 'MemberExpression', + object: init, + property: { type: 'Literal', value: index }, + computed: true, + } + registerDeclaratorBindings(element, member) + } + } + } + } for (const declarator of declaration.declarations) { - if (declarator.id.type === 'Identifier' && declarator.init) { - bindings.set(declarator.id.name, declarator.init) - bindingKinds.set(declarator.id.name, declaration.kind) + if (declarator.init) { + registerDeclaratorBindings(declarator.id, declarator.init) } } } @@ -837,6 +867,159 @@ function publicSiteConfigProjection(program) { } return false } + const resolvePublicCallableDetails = (node, seen = new Set()) => { + const value = unwrap(node) + if (!value || typeof value !== 'object') return null + if (value.type === 'MemberExpression') { + const method = memberNameFromNode(value) + const obj = unwrap(value.object) + if (obj?.type === 'Identifier' && !bindings.has(obj.name)) { + if (method === 'prototype') { + return { owner: obj.name, isPrototype: true, method: null } + } + if ( + STATIC_TARGET_MUTATORS.has(obj.name) + || ['Array', 'Set', 'Map', 'String', 'Object', 'Reflect'].includes(obj.name) + ) { + return { owner: obj.name, isPrototype: false, method } + } + } + if (obj?.type === 'ArrayExpression') { + return { owner: 'Array', isPrototype: true, method } + } + if (obj?.type === 'NewExpression' && obj.callee?.type === 'Identifier') { + if (['Set', 'Map'].includes(obj.callee.name) && !bindings.has(obj.callee.name)) { + return { owner: obj.callee.name, isPrototype: true, method } + } + } + const resolvedObj = resolvePublicCallableDetails(obj, seen) + if (resolvedObj) { + if (resolvedObj.isPrototype || method === 'prototype') { + return { + owner: resolvedObj.owner, + isPrototype: true, + method: method === 'prototype' ? resolvedObj.method : method, + } + } + return { + owner: resolvedObj.owner, + isPrototype: false, + method: method ?? resolvedObj.method, + } + } + return null + } + if (value.type === 'Identifier') { + if (!bindings.has(value.name)) { + if ( + STATIC_TARGET_MUTATORS.has(value.name) + || ['Array', 'Set', 'Map', 'String', 'Object', 'Reflect'].includes(value.name) + ) { + return { owner: value.name, isPrototype: false, method: null } + } + return null + } + if (seen.has(value.name)) return null + const init = constBindingExpression(value.name, seen) + if (init) { + return resolvePublicCallableDetails(init, new Set([...seen, value.name])) + } + } + return null + } + + const resolvePublicInvocation = (callNode) => { + const callee = unwrap(callNode.callee) + const wrapper = callee?.type === 'MemberExpression' ? memberNameFromNode(callee) : null + const isCall = wrapper === 'call' + const isApply = wrapper === 'apply' + const isBorrowed = isCall || isApply + const underlying = isBorrowed ? unwrap(callee.object) : callee + const thisArg = isBorrowed ? (callNode.arguments?.[0] ?? null) : null + const rawArgs = isBorrowed + ? (callNode.arguments?.slice(1) ?? []) + : (callNode.arguments ?? []) + const isApplyDynamic = isApply && callNode.arguments?.[1]?.type !== 'ArrayExpression' + const invocationArgs = isApply && callNode.arguments?.[1]?.type === 'ArrayExpression' + ? callNode.arguments[1].elements.map(unwrap) + : rawArgs + + const details = resolvePublicCallableDetails(underlying) + if (details) { + if (details.owner && STATIC_TARGET_MUTATORS.get(details.owner)?.has(details.method)) { + const mutatedTarget = isBorrowed ? invocationArgs[0] : (callNode.arguments?.[0] ?? null) + const storedValues = isApplyDynamic + ? (callNode.arguments?.slice(1, 2) ?? []) + : details.owner === 'Object' && details.method === 'assign' + ? invocationArgs.slice(1) + : details.owner === 'Object' && details.method === 'defineProperties' + ? invocationArgs.slice(1, 2) + : details.owner === 'Object' && details.method === 'defineProperty' + ? invocationArgs.slice(2, 3) + : details.owner === 'Reflect' && (details.method === 'set' || details.method === 'defineProperty') + ? invocationArgs.slice(2, 3) + : (details.owner === 'Object' || details.owner === 'Reflect') && details.method === 'setPrototypeOf' + ? invocationArgs.slice(1, 2) + : [] + return { + kind: 'static', + owner: details.owner, + method: details.method, + mutatedTarget, + storedValues, + } + } + if (details.isPrototype && MUTATING_MEMBER_CALLEES.has(details.method)) { + const mutatedTarget = isBorrowed ? thisArg : (callee.type === 'MemberExpression' ? callee.object : null) + const storedValues = isApplyDynamic + ? (callNode.arguments?.slice(1, 2) ?? []) + : ['push', 'unshift'].includes(details.method) + ? invocationArgs + : details.method === 'splice' + ? invocationArgs.slice(2) + : details.method === 'fill' + ? invocationArgs.slice(0, 1) + : details.method === 'set' + ? invocationArgs.slice(1, 2) + : details.method === 'add' + ? invocationArgs.slice(0, 1) + : [] + return { + kind: 'collection', + owner: details.owner, + method: details.method, + mutatedTarget, + storedValues, + } + } + } + if (callee?.type === 'MemberExpression') { + const method = memberNameFromNode(callee) + if (method && MUTATING_MEMBER_CALLEES.has(method)) { + const mutatedTarget = callee.object + const storedValues = ['push', 'unshift'].includes(method) + ? (callNode.arguments ?? []) + : method === 'splice' + ? (callNode.arguments?.slice(2) ?? []) + : method === 'fill' + ? (callNode.arguments?.slice(0, 1) ?? []) + : method === 'set' + ? (callNode.arguments?.slice(1, 2) ?? []) + : method === 'add' + ? (callNode.arguments?.slice(0, 1) ?? []) + : [] + return { + kind: 'member', + owner: null, + method, + mutatedTarget, + storedValues, + } + } + } + return null + } + const inspectObjectPatches = (node) => { const target = node.type === 'AssignmentExpression' ? node.left @@ -870,11 +1053,11 @@ function publicSiteConfigProjection(program) { !safeFreezeCall && (node.arguments ?? []).some((argument) => isGlobalObject(argument)) ) trustedObjectFreeze = false + const invocation = resolvePublicInvocation(node) if ( - callee?.type === 'MemberExpression' - && isGlobalObject(callee.object) + invocation?.kind === 'static' && ['assign', 'defineProperties', 'defineProperty', 'setPrototypeOf'].includes( - localMemberName(callee), + invocation.method, ) && (node.arguments ?? []).some((argument) => isGlobalObject(argument)) ) trustedObjectFreeze = false @@ -1064,48 +1247,15 @@ function publicSiteConfigProjection(program) { } else if (node.type === 'UnaryExpression' && node.operator === 'delete') { rejectUsedIdentity(node.argument, 'mutation of') } else if (node.type === 'CallExpression' || node.type === 'NewExpression') { - if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') { - const wrapperMethod = memberNameFromNode(node.callee) - const borrowed = ['apply', 'call'].includes(wrapperMethod) - const target = borrowed ? unwrap(node.callee.object) : node.callee - const owner = target?.type === 'MemberExpression' ? unwrap(target.object) : null - const method = target?.type === 'MemberExpression' - ? memberNameFromNode(target) - : null - const builtinOwner = owner?.type === 'Identifier' - && !bindings.has(owner.name) - ? owner.name - : null - const invocationArguments = wrapperMethod === 'call' - ? node.arguments?.slice(1) ?? [] - : wrapperMethod === 'apply' - ? null - : node.arguments ?? [] - const storedValues = wrapperMethod === 'apply' - ? node.arguments?.slice(1, 2) ?? [] - : builtinOwner === 'Object' && method === 'assign' - ? invocationArguments?.slice(1) ?? [] - : builtinOwner === 'Object' && method === 'defineProperties' - ? invocationArguments?.slice(1, 2) ?? [] - : builtinOwner === 'Object' && method === 'defineProperty' - ? invocationArguments?.slice(2, 3) ?? [] - : builtinOwner === 'Reflect' && method === 'set' - ? invocationArguments?.slice(2, 3) ?? [] - : builtinOwner === 'Reflect' && method === 'defineProperty' - ? invocationArguments?.slice(2, 3) ?? [] - : ['push', 'unshift'].includes(method) - ? invocationArguments ?? [] - : method === 'splice' - ? invocationArguments?.slice(2) ?? [] - : method === 'fill' - ? invocationArguments?.slice(0, 1) ?? [] - : method === 'set' - ? invocationArguments?.slice(1, 2) ?? [] - : method === 'add' - ? invocationArguments?.slice(0, 1) ?? [] - : [] - if (storedValues.some((argument) => mayContainGlobalContainer(argument))) { - unsupported('member-stored global container') + if (node.type === 'CallExpression') { + const invocation = resolvePublicInvocation(node) + if (invocation) { + if (invocation.storedValues.some((argument) => mayContainGlobalContainer(argument))) { + unsupported('member-stored global container') + } + if (invocation.mutatedTarget) { + rejectUsedIdentity(invocation.mutatedTarget, 'mutation of') + } } } const safeObjectFreeze = node.type === 'CallExpression' @@ -3075,12 +3225,56 @@ function semanticBindingGraph(program, path, analysisContext = {}) { } function directConstInitializer(binding) { - const relation = nodeParents.get(binding?.identifier) - if (relation?.parent.type !== 'VariableDeclarator' || relation.key !== 'id') return null - const declaration = nodeParents.get(relation.parent)?.parent - return declaration?.type === 'VariableDeclaration' && declaration.kind === 'const' - ? relation.parent.init - : null + if (!binding?.identifier) return null + let current = binding.identifier + const steps = [] + while (nodeParents.has(current)) { + const { parent, key } = nodeParents.get(current) + if (parent.type === 'Property' && key === 'value') { + steps.unshift({ type: 'property', key: parent.key, computed: parent.computed }) + current = parent + continue + } + if (parent.type === 'ObjectPattern' && key === 'properties') { + current = parent + continue + } + if (parent.type === 'ArrayPattern' && key === 'elements') { + const index = parent.elements.indexOf(current) + if (index === -1) return null + steps.unshift({ type: 'element', index }) + current = parent + continue + } + if (parent.type === 'VariableDeclarator' && key === 'id') { + const declaration = nodeParents.get(parent)?.parent + if (declaration?.type !== 'VariableDeclaration' || declaration.kind !== 'const') { + return null + } + if (!parent.init) return null + let result = parent.init + for (const step of steps) { + if (step.type === 'property') { + result = { + type: 'MemberExpression', + object: result, + property: step.key, + computed: step.computed, + } + } else if (step.type === 'element') { + result = { + type: 'MemberExpression', + object: result, + property: { type: 'Literal', value: step.index }, + computed: true, + } + } + } + return result + } + break + } + return null } function unboundGlobalContainer(node, seen = new Set()) { @@ -3423,6 +3617,163 @@ function semanticBindingGraph(program, path, analysisContext = {}) { return false } + function resolveCallableDetails(node, seen = new Set()) { + const value = unwrapExpression(node) + if (!value || typeof value !== 'object') return null + if (value.type === 'MemberExpression') { + const method = memberName(value) + const obj = unwrapExpression(value.object) + if (obj?.type === 'Identifier' && !lookup(obj)) { + if (method === 'prototype') { + return { owner: obj.name, isPrototype: true, method: null } + } + if ( + STATIC_TARGET_MUTATORS.has(obj.name) + || ['Array', 'Set', 'Map', 'String', 'Object', 'Reflect'].includes(obj.name) + ) { + return { owner: obj.name, isPrototype: false, method } + } + } + if (obj?.type === 'ArrayExpression') { + return { owner: 'Array', isPrototype: true, method } + } + if (obj?.type === 'NewExpression' && obj.callee?.type === 'Identifier') { + if (['Set', 'Map'].includes(obj.callee.name) && !lookup(obj.callee)) { + return { owner: obj.callee.name, isPrototype: true, method } + } + } + const resolvedObj = resolveCallableDetails(obj, seen) + if (resolvedObj) { + if (resolvedObj.isPrototype || method === 'prototype') { + return { + owner: resolvedObj.owner, + isPrototype: true, + method: method === 'prototype' ? resolvedObj.method : method, + } + } + return { + owner: resolvedObj.owner, + isPrototype: false, + method: method ?? resolvedObj.method, + } + } + return null + } + if (value.type === 'Identifier') { + const binding = lookup(value) + if (!binding) { + if ( + STATIC_TARGET_MUTATORS.has(value.name) + || ['Array', 'Set', 'Map', 'String', 'Object', 'Reflect'].includes(value.name) + ) { + return { owner: value.name, isPrototype: false, method: null } + } + return null + } + if (seen.has(binding)) return null + const init = directConstInitializer(binding) + if (init) { + return resolveCallableDetails(init, new Set([...seen, binding])) + } + } + return null + } + + function resolveInvocation(callNode) { + const callee = unwrapExpression(callNode.callee) + const wrapper = callee?.type === 'MemberExpression' ? memberName(callee) : null + const isCall = wrapper === 'call' + const isApply = wrapper === 'apply' + const isBorrowed = isCall || isApply + const underlying = isBorrowed ? unwrapExpression(callee.object) : callee + const thisArg = isBorrowed ? (callNode.arguments?.[0] ?? null) : null + const rawArgs = isBorrowed + ? (callNode.arguments?.slice(1) ?? []) + : (callNode.arguments ?? []) + const isApplyDynamic = isApply && callNode.arguments?.[1]?.type !== 'ArrayExpression' + const invocationArgs = isApply && callNode.arguments?.[1]?.type === 'ArrayExpression' + ? callNode.arguments[1].elements.map(unwrapExpression) + : rawArgs + + const details = resolveCallableDetails(underlying) + if (details) { + if (details.owner && STATIC_TARGET_MUTATORS.get(details.owner)?.has(details.method)) { + const mutatedTarget = isBorrowed ? invocationArgs[0] : (callNode.arguments?.[0] ?? null) + const storedValues = isApplyDynamic + ? (callNode.arguments?.slice(1, 2) ?? []) + : details.owner === 'Object' && details.method === 'assign' + ? invocationArgs.slice(1) + : details.owner === 'Object' && details.method === 'defineProperties' + ? invocationArgs.slice(1, 2) + : details.owner === 'Object' && details.method === 'defineProperty' + ? invocationArgs.slice(2, 3) + : details.owner === 'Reflect' && (details.method === 'set' || details.method === 'defineProperty') + ? invocationArgs.slice(2, 3) + : (details.owner === 'Object' || details.owner === 'Reflect') && details.method === 'setPrototypeOf' + ? invocationArgs.slice(1, 2) + : [] + return { + kind: 'static', + owner: details.owner, + method: details.method, + mutatedTarget, + storedValues, + rawArgs: invocationArgs, + } + } + if (details.isPrototype && MUTATING_MEMBER_CALLEES.has(details.method)) { + const mutatedTarget = isBorrowed ? thisArg : (callee.type === 'MemberExpression' ? callee.object : null) + const storedValues = isApplyDynamic + ? (callNode.arguments?.slice(1, 2) ?? []) + : ['push', 'unshift'].includes(details.method) + ? invocationArgs + : details.method === 'splice' + ? invocationArgs.slice(2) + : details.method === 'fill' + ? invocationArgs.slice(0, 1) + : details.method === 'set' + ? invocationArgs.slice(1, 2) + : details.method === 'add' + ? invocationArgs.slice(0, 1) + : [] + return { + kind: 'collection', + owner: details.owner, + method: details.method, + mutatedTarget, + storedValues, + rawArgs: invocationArgs, + } + } + } + if (callee?.type === 'MemberExpression') { + const method = memberName(callee) + if (method && MUTATING_MEMBER_CALLEES.has(method)) { + const mutatedTarget = callee.object + const storedValues = ['push', 'unshift'].includes(method) + ? (callNode.arguments ?? []) + : method === 'splice' + ? (callNode.arguments?.slice(2) ?? []) + : method === 'fill' + ? (callNode.arguments?.slice(0, 1) ?? []) + : method === 'set' + ? (callNode.arguments?.slice(1, 2) ?? []) + : method === 'add' + ? (callNode.arguments?.slice(0, 1) ?? []) + : [] + return { + kind: 'member', + owner: null, + method, + mutatedTarget, + storedValues, + rawArgs: callNode.arguments ?? [], + } + } + } + return null + } + function indexPatchedStaticOwners(node) { if (node.type === 'AssignmentExpression' || node.type === 'UpdateExpression') { const target = node.type === 'AssignmentExpression' ? node.left : node.argument @@ -3447,51 +3798,55 @@ function semanticBindingGraph(program, path, analysisContext = {}) { const owner = mutationMemberOwner(node.argument) ?? globalStaticOwner(node.argument) markStaticOwnerPatched(owner) } - if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') { - const owner = globalStaticOwner(node.callee.object) - const method = memberName(node.callee) - if ( - owner - && STATIC_TARGET_MUTATORS.get(owner)?.has(method) - && globalOwnerRoot(node.arguments?.[0]) - ) { - markStaticOwnerPatched(globalOwnerRoot(node.arguments[0])) - } - if (unboundGlobalContainer(node.arguments?.[0])) { - const patchOwnerKey = (key) => { - const name = ( - key?.type === 'StringLiteral' - || key?.type === 'Literal' - ) && typeof key.value === 'string' - ? key.value - : null - if (name && TRACKED_GLOBAL_OWNERS.has(name)) patchedStaticOwners.add(name) - else if (!name) markAllStaticOwnersPatched() + if (node.type === 'CallExpression') { + const invocation = resolveInvocation(node) + if (invocation?.kind === 'static') { + const owner = invocation.owner + const method = invocation.method + const target = invocation.mutatedTarget + if ( + owner + && STATIC_TARGET_MUTATORS.get(owner)?.has(method) + && globalOwnerRoot(target) + ) { + markStaticOwnerPatched(globalOwnerRoot(target)) } - const patchObjectKeys = (value) => { - if (value?.type !== 'ObjectExpression') { - markAllStaticOwnersPatched() - return + if (unboundGlobalContainer(target)) { + const patchOwnerKey = (key) => { + const name = ( + key?.type === 'StringLiteral' + || key?.type === 'Literal' + ) && typeof key.value === 'string' + ? key.value + : null + if (name && TRACKED_GLOBAL_OWNERS.has(name)) patchedStaticOwners.add(name) + else if (!name) markAllStaticOwnersPatched() } - for (const property of value.properties) { - if (property.type !== 'Property' || property.computed) { + const patchObjectKeys = (value) => { + if (value?.type !== 'ObjectExpression') { markAllStaticOwnersPatched() - continue + return + } + for (const property of value.properties) { + if (property.type !== 'Property' || property.computed) { + markAllStaticOwnersPatched() + continue + } + patchOwnerKey(property.key) } - patchOwnerKey(property.key) } - } - if (owner === 'Object' && method === 'assign') { - for (const source of node.arguments?.slice(1) ?? []) patchObjectKeys(source) - } else if (owner === 'Object' && method === 'defineProperties') { - patchObjectKeys(node.arguments?.[1]) - } else if ( - (owner === 'Object' && method === 'defineProperty') - || (owner === 'Reflect' && (method === 'defineProperty' || method === 'set')) - ) { - patchOwnerKey(node.arguments?.[1]) - } else if (owner && STATIC_TARGET_MUTATORS.get(owner)?.has(method)) { - markAllStaticOwnersPatched() + if (owner === 'Object' && method === 'assign') { + for (const source of invocation.storedValues) patchObjectKeys(source) + } else if (owner === 'Object' && method === 'defineProperties') { + if (invocation.storedValues[0]) patchObjectKeys(invocation.storedValues[0]) + } else if ( + (owner === 'Object' && method === 'defineProperty') + || (owner === 'Reflect' && (method === 'defineProperty' || method === 'set')) + ) { + patchOwnerKey(invocation.rawArgs?.[1]) + } else if (owner && STATIC_TARGET_MUTATORS.get(owner)?.has(method)) { + markAllStaticOwnersPatched() + } } } } @@ -3890,54 +4245,16 @@ function semanticBindingGraph(program, path, analysisContext = {}) { record(node.left, 'write', { propagateAliases: false }) } if (node.type === 'CallExpression' && !isReactEffectCall(node)) { - const callee = node.callee - if (callee.type === 'MemberExpression') { - const wrapperMethod = memberName(callee) - const borrowed = ['apply', 'call'].includes(wrapperMethod) - const target = borrowed ? unwrapExpression(callee.object) : callee - const method = target?.type === 'MemberExpression' ? memberName(target) : null - const staticOwner = target?.type === 'MemberExpression' - && target.object.type === 'Identifier' - && !lookup(target.object) - ? target.object.name - : null - const invocationArguments = wrapperMethod === 'call' - ? node.arguments?.slice(1) ?? [] - : wrapperMethod === 'apply' - ? null - : node.arguments ?? [] - const storedValues = wrapperMethod === 'apply' - ? node.arguments?.slice(1, 2) ?? [] - : staticOwner === 'Object' && method === 'assign' - ? invocationArguments?.slice(1) ?? [] - : staticOwner === 'Object' && method === 'defineProperties' - ? invocationArguments?.slice(1, 2) ?? [] - : staticOwner === 'Object' && method === 'defineProperty' - ? invocationArguments?.slice(2, 3) ?? [] - : staticOwner === 'Reflect' && method === 'set' - ? invocationArguments?.slice(2, 3) ?? [] - : staticOwner === 'Reflect' && method === 'defineProperty' - ? invocationArguments?.slice(2, 3) ?? [] - : ['push', 'unshift'].includes(method) - ? invocationArguments ?? [] - : method === 'splice' - ? invocationArguments?.slice(2) ?? [] - : method === 'fill' - ? invocationArguments?.slice(0, 1) ?? [] - : method === 'set' - ? invocationArguments?.slice(1, 2) ?? [] - : method === 'add' - ? invocationArguments?.slice(0, 1) ?? [] - : [] - if (storedValues.some((argument) => mayContainUnboundGlobalContainer(argument))) { - unsupportedRootCalls.push({ - executionScope, - message: 'Unsupported member-stored global container', - mutationNode: node, - semanticNode, - }) - } + const invocation = resolveInvocation(node) + if (invocation?.storedValues?.some((argument) => mayContainUnboundGlobalContainer(argument))) { + unsupportedRootCalls.push({ + executionScope, + message: 'Unsupported member-stored global container', + mutationNode: node, + semanticNode, + }) } + const callee = node.callee const provenAsyncCallbackIndexes = new Set( isKnownAsyncCallbackCall(node) ? clientAsyncCallbackIndexes(node) : [], ) @@ -4046,18 +4363,24 @@ function semanticBindingGraph(program, path, analysisContext = {}) { }) } const method = callee.type === 'MemberExpression' ? memberName(callee) : null - const staticOwner = callee.type === 'MemberExpression' - && callee.object.type === 'Identifier' - ? callee.object.name - : null - const isStaticTargetMutator = method - && callee.object.type === 'Identifier' - && !lookup(callee.object) - && !patchedStaticOwners.has(staticOwner) - && STATIC_TARGET_MUTATORS.get(staticOwner)?.has(method) - if (isStaticTargetMutator) { - record(node.arguments?.[0]) - for (const argument of node.arguments?.slice(1) ?? []) { + if ( + invocation?.kind === 'static' + && !patchedStaticOwners.has(invocation.owner) + && STATIC_TARGET_MUTATORS.get(invocation.owner)?.has(invocation.method) + ) { + if (invocation.mutatedTarget) record(invocation.mutatedTarget) + for (const argument of invocation.storedValues) { + record(argument, 'ambiguous-mutation', { trackDynamic: false }) + } + } else if (invocation?.kind === 'collection') { + if (invocation.mutatedTarget) { + record( + invocation.mutatedTarget, + MUTATING_MEMBER_CALLEES.has(invocation.method) ? 'write' : 'ambiguous-mutation', + { trackDynamic: false }, + ) + } + for (const argument of invocation.storedValues) { record(argument, 'ambiguous-mutation', { trackDynamic: false }) } } else if (callee.type === 'MemberExpression') { diff --git a/site/scripts/seo-contract.test.mjs b/site/scripts/seo-contract.test.mjs index 06ef60b..2edbb45 100644 --- a/site/scripts/seo-contract.test.mjs +++ b/site/scripts/seo-contract.test.mjs @@ -232,7 +232,7 @@ test('discovery files expose the 47 approved URLs and real summaries', async () assert.match(lastModified, /--format=%cI/) assert.match(lastModified, /normalizeContentForDigest/) assert.match(lastModified, /semanticRouteFiles/) - assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 18) + assert.equal(ROUTE_CONTENT_DIGEST_VERSION, 19) assert.equal(Object.keys(ROUTE_CONTENT_DIGESTS).length, PUBLIC_PATHS.length) assert.ok(Object.values(ROUTE_CONTENT_DIGESTS).every((digest) => /^[0-9a-f]{64}$/.test(digest))) assert.match(publicRoutes, /\.\.\/data\/catalog\.ts/) diff --git a/site/src/data/routeLastModified.ts b/site/src/data/routeLastModified.ts index ead236a..c99c0ee 100644 --- a/site/src/data/routeLastModified.ts +++ b/site/src/data/routeLastModified.ts @@ -1,5 +1,5 @@ // 由 scripts/generate-seo.mjs 从源码时间证据生成,请勿手改。 -export const ROUTE_CONTENT_DIGEST_VERSION = 18 +export const ROUTE_CONTENT_DIGEST_VERSION = 19 export const ROUTE_LAST_MODIFIED: Readonly> = Object.freeze({ "/": "2026-08-23T21:18:04+08:00", "/part/a": "2026-08-23T21:18:04+08:00", @@ -50,51 +50,51 @@ export const ROUTE_LAST_MODIFIED: Readonly> = Object.free "/problems": "2026-08-23T21:18:04+08:00" }) export const ROUTE_CONTENT_DIGESTS: Readonly> = Object.freeze({ - "/": "682f5d25703320b6c97aca5e9e1d4f292e67325b84d8fcc8b8888b2b6d0fb34c", - "/part/a": "8979f4fc256fcb9bbbc12525e40f8ff311606864251643facdae2f9d6b225de9", - "/part/b": "54dc9df1e934a21f50603d3c4ef130b40c2597f7f5bbb55dfc976e817528d0dd", - "/part/c": "cbe18d232491d3d458b1b3450e1900045792e981311df5e3e7b18c9eb79e7b61", - "/part/d": "d8c193a454c907271599c0dabf40c57a0e55b9a111e2a7feaad0f70b228a4da0", - "/part/e": "91fd27fe4c911cc354896fc38ad5e2b46b7f74940dbb1ab9e6898f16627f034d", - "/part/f": "4621b866bcb4edee102a5950975da5b2c17e38b13726a14f177e9788db525568", - "/part/g": "fe56dd93b6eab109e1afd480ae3813e899537c14d30ca886d1a5591a8a20b9b7", - "/part/a/01": "b8496f8c8b1d9cec3382c315ca126bf805ce5a06d107b30900842b2b916dcd1a", - "/part/a/complete": "83d566a9f8cde651812bd2f32f294fc1d01cd4a61312ee1f7fa4acb23698a390", - "/part/a/multiple": "3ccdd7583bf1dd44a5b9d669e1f6d5177a8d7df0846808791a585b1db57fcbc7", - "/part/a/group": "49e476b8d8ec720f11ccf15110b4a76c8e0fa8c5d5142e973ecb5223cbba62f3", - "/part/a/mixed": "d2f5e4571aa9c5a77bde069b4af4bd98a01c4cc2a3446a353131e561f329ff0b", - "/part/a/cost2d": "711448bafc22149bf4da25d8153818ff2cd6e2e46f9ff3ecb35e2e482d8f2d1e", - "/part/a/dep": "cdfde259ee224873dbe183fee4aeaad9fa7d04caeb7f86b58058ddf86ad2ec7b", - "/part/a/variant": "dad5d0e8cfe609667b7adf0d2826738251f837c2113d9b010c9c3a2098326f37", - "/part/a/fractional": "47593481cb6fb3473e25389ff0431e3304bfdce635d1f7af5a898cc2932a86bd", - "/part/b/path": "9dc35dc7c32e6923adb250328f2e729d428940f10ce706fbb53766756873e87e", - "/part/b/maxseg": "d790495204ed878091a86585d8d17934fbc481bd07e14e4978b8c081cee55625", - "/part/b/lis": "3123ed2974e91b3d072dae4efce2ea76deea5083ade8c5acaf66cc23a6469b6c", - "/part/b/lcs": "7fbe92c3652f8217eee2d450e79fa8b5a214061cc96ead2a64e500204dff6256", - "/part/b/edit": "69acb8a225fcec35a28a1be7ded79f618a93189d66ab01271ce3aad5fde9f191", - "/part/b/fsm": "4f5ef5cf988e8f936b8b589c34e3270edfd3861c61f6139e121660b097b93cce", - "/part/b/count": "376d37279feefc80b8a98c28ea116417ac5c87ab7ddf684aa32182689bd75f36", - "/part/c/stone": "8b8dc1f476a49b7786aab2d5e7a6c26a3af46330f6f039b2520ba6c92e6c75a5", - "/part/c/ring": "074c46c6ee38769693c61784e5a5474f67c3ff48227150c36b19b6471390dc0d", - "/part/c/palindrome": "bd3494df1f82c5c205237be664c62d378bc5895dc7fc95d61ca249fd68d7a187", - "/part/c/tree": "57261999473bfaa41f216ba165fb272a9b66cd4c85ee30b4301018b09e8e1278", - "/part/c/merge": "77c2f431bab90ae72aa768e23506a256e1b67037ee9561c9d29eef0fb197614a", - "/part/d/grid": "ad743c975a9dc29a0229c01284b999b1be764924e0f71ef75260d56081205777", - "/part/d/matpow": "18ba5812015e71a9e80fc6561b054e8c6633d6015e11666135071358988863db", - "/part/e/basic": "2355ad8d5172d0d83f4add44da597e8e1d335d771bd58f3fd42032a377b2c2db", - "/part/e/distsum": "4fb7a28b39fce8d1959ed80f99fc5a434b7dc2c0e68e1f65fec08987922444d7", - "/part/e/inout": "78320ba4e85fb3a6ca50b0aad8ba90f91c2bac3ab1f13fb30f5c4929dc887cd5", - "/part/e/center": "a972d922db64fa2838f052f10039ed317ca1f6d4c662f6bd81cb62042969e161", - "/part/f/select": "33f5cd5c82b1a72819447a2a85cfa1333d63cd66077247dfea9505d95dc4de82", - "/part/f/knapsack": "918040420e0932198f5da649863a2de17cbd2a0f426e98a819f6f7e1c3d4ecab", - "/part/f/diameter": "94211f53aaa166b07cbaa500fe595420c4155e2fe1b30c41399d174fd3ff1880", - "/part/f/cover": "1ea33061cff0d26593a97dedbdd3777a441f20d52548da87925802f24a115718", - "/part/f/count": "0001c713ef65adacbe29fb1a07bd4137ec6cf6f38d7f3c619da58b13b25d62ff", - "/part/g/board": "bd3679b5ea20dd48e55b9036567d77bf3b56b1e0b1d63b59e6f6ce4071d9a6b9", - "/part/g/tsp": "acff8b9b784531e48f682f400988d3f733ee7a5f22e6aa0157176110f526009c", - "/part/g/cover": "bdae99d8c98b279a169c5f33fef66ae51e5af7b69bb5903d8b0c64ce730549bf", - "/part/g/subset": "4c7ec6066f60a78e13c1da45e4aeebe9731c4badf63499acc1bc8763793f17d8", - "/part/g/plug": "86631b296d9c3656d1c0e4576677a87a4e7b8a1d9bb5e1cf76cfd878c1385d09", - "/method": "5d4e67a2d6211d4ada30e011dedabf1df917b9c2199fd63108bb87ee875e4180", - "/problems": "03671fba244a78f18399baada2f18c7fa6c68da60a714bf61e07c44e336b52d0" + "/": "2f9dfb3659357c803fa448496d521d8743cf2012f269fa855e7a86c937ba35b2", + "/part/a": "502213527b7845226dde88866903d4453e7c92c87723b9e41eaec80b3023d8a7", + "/part/b": "a1adecfbeef6e8c2d1909edb894c8c555d86dabf7c22ffe9d7f63efd3f75cf55", + "/part/c": "097d6f200fef0060bb19f609431767353e93fc9c53f3a38ad17ae7a167371251", + "/part/d": "c604bc05b0769634ef3abec99537e0166edac7fa625f701f0ab3cf84e90da9a4", + "/part/e": "f1fae191144e92b3a5633528143b9f33c720857f9518547acc886e7b426666e1", + "/part/f": "1a143e46815e7709f9c00f79d9ba70caaed3a85e575e292f133394e75371e6d9", + "/part/g": "b19ac0f408d22b73a81a20d85a094bd8b3c3ae5350928c4307ba110f6f2b5858", + "/part/a/01": "9e8be59c886aa39be1353341da2e309db98c870a58a3bb6ab4d0f9b6f3348b63", + "/part/a/complete": "8c681dc3eca3a0f15fa95f48c3f8612fa21c8441bb6f5ba424d8c3abfd4653e8", + "/part/a/multiple": "e0953ccd1b26f5f0209cf9819737ec17d669cb740181af7c786e257365a2483e", + "/part/a/group": "40b1a3df7b7abcf5b0db4bc832c3a48173b657ea807d8d68d5bec9135ef9a074", + "/part/a/mixed": "00db01daaed45901775e8a9691489412d1b769b8450ca45fb7ef967b449e85f3", + "/part/a/cost2d": "61405556bc3c548d23bede38f6781a2e5e4833619ac916468a93c2802e3e2f93", + "/part/a/dep": "f4c4242de21dba579bc2bb189659ebb6fc01f770bfdc3508bdc25c1cc5a07e59", + "/part/a/variant": "c388740e4a77f7cdf3dba73212f56cd241a4d6d80e5554a7f1c399a0524d3747", + "/part/a/fractional": "3282649ca7f9cdc2e23cc3c99618872dbd4822431c8b0d3351cd5466d4bab3ea", + "/part/b/path": "025ac658f8d40ec2fb69ea76b74b6c4d9fbd8f32464675795edfaecb288c9439", + "/part/b/maxseg": "0f808fb9f5459716712e13ecf6e9125f4d08546007bca268bdceaa196b51fc0e", + "/part/b/lis": "8e356e350ea3370525f7a31d196ba9589420d05819794abfcdacaa64aa339347", + "/part/b/lcs": "492895df92ef65249f2d84e60cfb880dbbcda30835ba4e51d9fe35c245fb5527", + "/part/b/edit": "2130aeb741f341ef11f6ecdfaa3b6e2cdb217c9a2090edfbadab5508b6cb6292", + "/part/b/fsm": "5ee34fec66794f2105f39ea137c465975a4cc6a22bc2016cb300485dd7a50f0f", + "/part/b/count": "de1fcaa83ea827e2c3207ea43a91b13d31c2b954dd90c8defa7067268e57310a", + "/part/c/stone": "387740392af68fd8732088a83e1c2e196dc977bec4d0b5e0ec25b12a5e3d9786", + "/part/c/ring": "b265c321269c859d5fc95fe358fc67638a79d19dd7b73a719e5124958d6ceae6", + "/part/c/palindrome": "8f4672682886ca800ee572955a92e13aa269cd0ca4e63739407664ced9d786e6", + "/part/c/tree": "1c350ff7bdf4690af87348a93d412f375631af21478d92faded0d8d5a368b3c7", + "/part/c/merge": "b2bd2ffeeb427b3c7b8f99287ccbae3653dce8869c7c067885f642dffce9530b", + "/part/d/grid": "e2aaedfdbc6817e5364d8007723f9df80b62de31ff0d5bbcbb84e30f788a8359", + "/part/d/matpow": "a5434571f8dd309a23cb12cb02624f0debd0aa52b1eaa48d01deeeff72b0c617", + "/part/e/basic": "6f4f057d8ee06628ae9db6f7b797268f34eb27be032f2ab3b9ad1585128601c5", + "/part/e/distsum": "d79e2539bd4ffbe7d9b166bad8ae1f8883eaee34c7d6336c7e529ea46fbed31d", + "/part/e/inout": "d469f4907c35f5aa2a11ff03583959956918730352f24f58130290e4748eed22", + "/part/e/center": "bacf35b59b0f63acb575c42da5f39e29429e89b31b6a27d12fb294f22e50b896", + "/part/f/select": "588a1f556c3a64f99530ce54deb80ca2254df55647d50170eff57958bb3de2c3", + "/part/f/knapsack": "32ca3baa20888f4c4c49b6668f7dd6258edf735ad4f0612365f117489962d2ca", + "/part/f/diameter": "ae6a5608d2e13efb2cf2052d31876d1b8e385f116a0d7bc400312329bbbbb5db", + "/part/f/cover": "052a5780a9845a9dcd42edf5a35551d4642fff41b0c3fe8f5b44a2fc97e63fd0", + "/part/f/count": "dedc5fcad88eb370b00ac8af3289df99188316a589b8ec585c89e81a2b60e59d", + "/part/g/board": "f65e12a40f116346fd789598fc1deeb6e909538a4f7fd11c428f6e7082565eb3", + "/part/g/tsp": "234652f63ec666ea31702873a93859e27827266b5aa1d9ebe67f29f77a311f01", + "/part/g/cover": "0b6b244e17e3ef553c53d0af76514963148f9e7a6939ecd1b74c1ce4a7558863", + "/part/g/subset": "18d2cde4a65edc43d9e5d18a3110dd5d6ddff3d573f7f4e9686f3b8a3dd8df21", + "/part/g/plug": "e56f55e3e80328d0308c68ab8405d51e7e00b3818e456909ac90ff867588df34", + "/method": "2e6abc5e41bf722c9ab7c3a76d7578e97cba4bff5dcc9cc212fb1d2c67cb160e", + "/problems": "4c57b8977d0194e6fcfe37ae833a31ee41495ff2ce2ec7ea5b746df0827c4536" })

~g8uZwk4NJE-A%oY?5*&U8GbF_ZNA=~ZY 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 z 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⪼)_KQZ**@?9 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 af-Ek(C;dT25G6$Q`i|a{k08JA!~YMp6p^t2 diff --git a/handoff/representative-d-matpow-dark.png b/handoff/representative-d-matpow-dark.png deleted file mode 100644 index 41a10ecc19c57ddefc748b96b78552c4416bf37f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 58226 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≻DfeQjq;lr`LGG?21yP^ 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 diff --git a/handoff/representative-g-plug-dark.png b/handoff/representative-g-plug-dark.png deleted file mode 100644 index 74d2e0d5a68df7e4c4b186791291dd946561eda2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 44009 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 diff --git a/handoff/representative-g-plug-light.png b/handoff/representative-g-plug-light.png deleted file mode 100644 index dca50d6a3715506bd9cd5dff8f8502bd8a6ed478..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 54689 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 diff --git a/handoff/representative-g-plug-mobile.png b/handoff/representative-g-plug-mobile.png deleted file mode 100644 index 5ae6b398cd83b7c565d9f003be5cf4e167ef2217..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34500 zcmc$_cQl;ew?96jjA$V`qa=tRql@06_vl?BI?;Pg5S6sw%1|0+Fjg5_ki;a&5#0C;!-!neI#=#}S zd;EkCNd8n-o5Iqal3&0xGznUR&+L=+3|#nNK^Tl-&A-ao8=#NKzYkp0sIz`}X(&s_g%SY!{d@28W+u_Wi$ekyQ> z0^G0T{vTkI0i*%fF%*^*dIg_kpXZaRKK=AKmJFxhQ`bWUg4blPn7_P^W}|reP=QAN z-{|*DykXk^C7o3D`8^YFv}D5`pWcq(T&+kX5F5b0eA4rI!{f~kwn&H}7 zzD{>+sTcv+ZjKS}@QLJLESVknb=NI%=UJ_Cw~x@2P;1nf12!$_4v=rrIxP5gOzENc zrYFacCq4)-GEwu`SDjIBimnS>ft;LPGNyOV>)pmLq!{i$Jp}wRpu&sIJQVaMju`P8 zw)&(&JfN~Ij&Y-X?Gm~E&yTcx-Q@$QH zqx8`m*4IJgiY?JqGk^tb0JfE!ro>q8sh)|#d@Ds=&zr)_a6~0^gyL%-ektXPn4+r$ zalEZWhV3zn9H)TUSvB^H2iv&FByD{;N?n9iaU_&o^_QAQUus_(p2+GUOW8(QiIkPX zg<|v+rnC^*0doRX6%-;;YsR4IV`N8+OBq(lB=OCwVVKRHBB?IM?Ct+ zVq)FIIg3K01Fl82YHwo^PIZW^g$T8RQM$?BB576$9S$WT1WzO63;v}zPamYu@3K14 z5az4_u!j)_3-9xsECacpVc~YLnAjEsG6{rwn$R{qmU0K?L<-}-w_DNzy=1BZFV=SU2p3)SOYSR`JHSrb%B`OCA7u4FU7N2D zh&H)iWMh4Z^UM8M#vy(Cyz=8=TP?!OMxW(E2E&e$UkjxOW{OeQfN^y1y92+_oe)C& z*tY}xTg=4}PWw1(C$+!{wGaD@wif%`P9~MGapmuP%Q~PL6OOHN>*AwM=IFxDmQl78ti= zc2u-cDo+oIo;h*I)R@hh7}3@>*^&7WK-bF$7258GnE8ZeRj$0o%u#MF1mt_eN~jFF zwLu$(uj6A2)zz#ruCF_@X?yvBv=>J5vfq|(bz0E$9)aLgN4mv$FdR`? z9I@ulLQK5aJmv<7{OAT$sBqMf)h}go4Sw&&J3gTx$K*B1-``88sd5^yj=l7CCmlb! z^xrJk(+9&QRodgV>Dp1UM!TSeJqzEjKMsgn#mJ_!E`M51;00WH#3CGjab_PvV*a2R z)Mey*YpNt^L{O`qeWFGb|0vTx8YTNYU7h*4PF~*s6nk$K|A&VEfBIEXs!yqk|0+7{ z-Z0*4>VNxU#z|0$P$f|1;3SomfBrE#8bA8Kd*Mz|(NB?;#eD_M$Dw%je<1)rI_y-B z(%A6%+WX5y`7=4|q)JxrP7&E{PO2G{R8G;9SZF=PV=dNactaUkgtaP+GVWb7+EvYM zOZl%`r7fp#m|ml*#GG^{l(c+Gor)q?uX)u>1R|5W)!ek&z~!~=qHu1(!+K(}vy)<& z(Z;v5E3fm$Hg~5`_AJ5rw{``v^y_Boncz|#AJ;tg#!mhG_6C!2HSx$MVF+JS7J2_F z7>Hz$B<{qBzsx+qAbNbNJ_M34W7d2QSo@l?fFwna@w*%#tZ{p=gokBJH!pQ|Qx_Rs z<4bwGc>)<_Fj3Otjn3Caj0i~Q1^xt;S_6F6Yb{6V$IcHeyMT8eLCrmtKv8C|G3@1% z^lKaHiJPd}(Xk^j{>zFNjYBn|m8m`wHSwPYpgzdRXCUiC04X*6!gG@DrM7EFrxy&a z-HE+XTHqD$;`ReRv6SPVRUG$F}N=r%#5@v`>EH^~+9$3iysj!+#IhecQ zk$F?UIb>><;a>1Q$@Tq5UlMna@Ou|mQ4wsGoQ_Muz5(Sr6FGHrZ|}|2RtD)0dRu8( zO+uCSdn%F-;06w@C12Xr96NOB0-C=22&6W{3t>ThXDkB_G+qidm_W66l`~)TZmJwC z>-*KLgRG-~R^6i{qFu!9)C*_xUf`B6#$m@dMyySt+7vuEgNkbhG1zW^7rt)-rojv; zKK}YuE~zk|IlYEC)dkLipraFI^F)5YSk!pMO&SwV!BIe5{RmLbs>xVzG&16#)FI|< z`e!cC+GZOFq-CN^Jm5^0*$Ysmr}W7Twz*8|P=%!l9uSj=uto)b->LeXOBO68E-_`8 ztsh(wu4t&gfW3eVO1){|gFyK)!#)}GpGmQq(Ci~#=1v6_Y5V#R$R^xr|7{6YQf-b; zuR|}Lgq}S^O z@ITDZzwK`S+T#9YmHf&v-}c3{s_d@Az8V(H{jpQI9KHkaD&&F}8<~Dz*!_7dJu;NT z9kuxRuYcX&7^bbwF|w(VJ3z5-Rrdwlj;>=$&83y!|59vK^;3{dD3Jv|HtkJX1Mqdpky7=vm8@V>Sh;pjVEf>l+7c12acEtK04~ z^=be2&JOGEKP-24o^MYP*6a<{d12zFlhynxzb$j0iaoc2JP`kCVnR1tZ&~TG=HV}O z*6foqcXbDFojk#W%jyaKLA>2I(9@M*Jjap0YJ&Jcr6e`o5`zJ^Y@;EgWndv{)S&o! z+5C#@`)?J;-_Zl-IAQqbv?kSdGYol#=ByR7y^+aUlT%VqTh*mNQ<(e8>IINN6AKvH z&tf*$QdM<(BiX9m`65w^t$zp`<m~ zZZW}O>(j`dH^?=zh?m{$#DMKwbbxXSR*1xIzW7p$mmzbV4;Ob671!hsA?%#T%L?l& z;Le#arR=x^xFH0mYKsno!13#?C@me(dhYQfnL3n!=`uGuYj3CsBT^xh`im`Coc5u; zI~g_5vO9~k+1IwkS7xcSk{Yd6wuhHjia6E~D8cHrWCuC3Zmh3=QBpXJm_1~#aK7m% zNQLfPO*Tv~s-ebJJeN;-MDGBPw2-cJIe`%!J+AQDoq3m2bzu#~fhVydk4ScYW3RF2 zoegfiDN5;9e!Z0M?JfD{Vrb!AFs@l(yw?G_2y)cru*h0Ep-SR6)plO#QOeot<&5g> z4ir6}EgXX1dtI|P(L~b0*iL4mSUERG%%SZn)SlKUrz?^9_Q|P;sxr%4FK3DKDa3){ zHMdvG>oJ$QiP@#6@ydHZ6(oe&X2cTZzaJl(>v28sCx7xypl?z2%!G!5U4xjSPN$UV zl4xFP`{CaS7WRYvh;ihxy&-2vzcXFSq&%e%rN0#;KQ@Tfo`lhlMw&F88>8?ty|6{Z zVK5Utd1^MnX2#!)p21dOxB5loGKr6!7T~7mbH1>Qn4|Op*UTxLd1F-y-nr?}N z5vjsbu5J`nO_Ni$3ftPwd|_fujS__%aYDF={TMq`Q!B$*YOvnwR(_VGaeA&Li)KYv zV)V5CuYYd~Rpw(nTPS$bTgx2^lNsLMh^vQD3Ou0L%$|PhM9Gt-CBAKj2ghC~y z&;_F+1wgsilEr!7y8KVw`}E=9^RG81dmWR9WQ$HcIGF=b=Mi9QUrgngjuK;AvC^#1 zUbE9-vZLw`a{e`oN^>S@J` z#gxj~-dG2a$+iqO<2G-i=mv7>e60`Kt>FP78FH!@>NBWQ5!kp9&8DdIVrk>XZDw}q zJA5P*f8cmo{C;{2HfPrwgQT2PwN)MTU1z{(Tc1|)Y%#hFj6+yV53c>Zu| zjk48-#OiwsGk{H9n_i1vP*7@=79spjV(>-nuOMw7983~o>1}G~O1UwaLYX?#6WT4( zGm9=f*6zxf3=r>b=D^Y;TGNsO7ZjIiPstx6E37Ut&Z@)HRT8#m21mE(t>#Kh0j%4% zWyaB5vU0@6+;l^TN;LylB59i}Xdtuyt-Yu{*Soovf=(6dS`|Yr#9m=iMnY=F#`}q1 zN>fz*lUhG=JPMk$cKJM;&Acl)stT*~ik-RCDP~+O!K{hffIbnTRsU=5SFHx+xk`@a zR&aB3t#WW`_GoNv$;mD&_b4IVoRrd`>*%PM-BI(RuhqovpzAIklq9EXmEpycUOCN2 zN_ z03r-P>}3N<)la6r%Z>QA^3!DYEJcOop5Wq*BZntj0<$mf09F#~2RpSbS`E%NCA;s6 z!&Xs)%HN%yR@nL1KVm*=x;1dBgY@lq=VTQ$NgL>l&rG);P$*J$$RdA)oRB99OjtBw zo^@7p3Zh$e$1|9VY)sKQlYN5ZVZ8hTF} zU#8g%zDAn!KK-moNu;?~PFT2>@eZ?%Jdfw1Da+N#b{ZLaozZU(mRonLm~&t|5z;iE zO;uE#DE2(Ox38v;Srnc_BapOZ@TLy(qr()^j zq9R*0%_|LI_wKtn;VQYnrQ);ZV)#eV{gi=9{m2!s!b&G>w1&VlVs@;aDj-v*uf^0Y zF}zX3yLsNl=Z4D$%$HrSt9mu;Q^#t(oeZG{7`$C-)tl4BgqJ!TAqDv+r05JLqu2fY zm?e)!m6;a*687W^Rc_0pGtF(vJn7t?!QF>Fg(vQh9v1md%F**eTZoF;02t!@4ai-M z538nE#0og#5_BRlIPcP4tnp@{vRpaty?et77n`$TU{hOt)9TkoCgfi8*`>+hrLW1a z=g!k-(=)@X{c8!WS!?0t%hJlk`ua|hv2&CR75vX?Cc8$)+_7=uPnIsfgSb^p4Rzlb zg7v-5ru7DWrHn1iwwZLa*n-K0cdi+DY-h+-9UZ+rY3!`^&H!ERKa3=p%uZBxI=o85 zOdK}CO4xe@UBE%E4o)zo*Rnf0c9Yd87w_5FU**o;o|F1NJO(WNouW_@?StHDbVJCX znMSb;1Y%1R>X3Cmk3Z)jlv9QKOD<<^>7sfiC0-`Os;xhml`J=QPqNj=$$2uq9_>}P z!dB40JJ~1Ezxvx+f{FL%71^=8`{fJI^ph>_6jKxntl{^B8bMA@XaUcCvspS#PFWTDU6_|FY3v)^K1eR8}jheN6Y z^g=?PV(kj~YDLNI4A>df*=p*gB54CK+M1K>&dR(n6dPkq`~+q-V_B-^Zt`l&m*d% zuc`NiqEsgzIKfnim)1BH;~hNu^VI@YFqoAm5u5(k(zYFF^+b|jpl<;r)Lz-|T^VHP z?p;z^N%XPfao1cgka-&*j=hSk`hEa&GI0oO$ZDFDBi`d!Fr<8I&!2v_6q!6luPo9> zLRj{@9p_t-Lw?{Q4%I$=0s6^2;qtE~H!U11S1q}nnh^aeA10@Q$RU(>(a=HamVutm zRh8(`JK{j!#{oav&Ufk^Eyivo@~2aoz1t-xQ%x%L^#HF&>vAwRtAQziYO{-x1z4HNZIAp}dw(B5ff-OFS?daN?dTtuSV&P<9wHR$R|!-i5~DhKQl&Gc<_! zQs1^PA0&tF02R_ECwCLHm7MDrg;*M)Gi5WqwR%49z<=){M7DCx; zpAB|R1)QGSXBbMpuYKt}B8tM>63oJDWgo(x33Bkv;Ve%vR}dtIhZ*Oxx-}L#bF&Ht ze*771iKGbSje)5&tLH##;00Sh1Z7#Z0EA|3eH(Inj71-p)daE3+(K| zp<>UMPu}R!t#98-SkdljC2ks}3~8H}m%tzh6UWkAVtLIMP~mvdDh64C$w~ej#TD0< zJx)6Mp6`pLd#q$FMFM{DIan+#@z>WpUj-?@)s0ZxWO_r^U9NSg9@!&GGpfEMXFWIq z{xSF&^tvPE028OQa0@J+5lhpUG~0j}({tTMN*Qi>bPl!iOWfeXY8tf{vNb9;rav4? z<*3GxEO0Eb9GNp6VUAY=`=tDJQD7b$R6~oZ_-1b;>|j0+Gdnd%vo?|SE7Ih^YAY*O zVN4q3YW?jz@D4C2d%y46h}+dVIEYghOq&F1O)OehKy#Fr`soJf`oesm6m(Kihc3j# z&ZTvxFTR@FN2qPYA}X#tMBxCNJdoF=`4sZ+_#Gf`)``u_$3%78(3(p1uz;8D5r|2@ z|MuCT6rCgCvo9qR^^Iaz(oNRKDV03j?xND=tbj)nIS=;!)MGMS$B_*!beWVw%PMPL ztxQ~|OZB8c+^YRC_=wt>Cb`t-@{ggyAgJJsLRQWR2;S zt^WK78EPL=Hjxbm=3UGn;9m{RaY%J6SSv8@p7uX#_@;zD9bKT-F+91{lc>k2+rFJ1 zU6RlC6=)sO1_U2BniINA z(}l!b`uc&Z8@jpHonYg3PYOe!(sjps$jCc@qBvfin3yq3>ptE1taa9V;B-hg(9GMH z$u8=mDX4GrOl6y=50N3-pVQBHk_uuyF~X z%B}muqG9MVB}{MT{S`2WhVRu z`CGxiWnH(b<{i}O&&>9DHBC+|bL1V5=(C1a2fF8oHE3pw{iaIzI~B-w2gqFctMlk~ zXzE6OUT~Az&Lrdze(=R-Zg#qNZO77ysb)fSQH9BFDf)V=^cANqMO9!~_n6NvfCM~- z6FUCWRNhqfKZSWMJc{}4VK+5qxkq$O@On!cE`}hcb<)kH z2O9datKoU#j9dPYlZ_3Cz;CK4x5GE;JAc^Dlx(Tyx=r=z)i3KBduUm*l82V~mGaDp z$%%bjM#YUo8_Md(?33uLa-saREQ=k=(V#~Dg*7h#4|g0^{uH`sh@ODXvNSQ@7G?(KDjYg357loL)&^ihD5>F$m6 z#M)-b9#=OHf5Y3sU}R3R>02_HCA#Z!N*dfR&IfeeWY7WpfLQMSNY^X`wpLR zwA_Mx1L0*ea;`6lrYzAiw6LhnbD1P3Czzj_T3#J@0W3Dzr*$JSx0~VE-!{aRH80-i zL6jir4heOym+kPeM41oCnsZYoeqW4g=%wdkPwD0cK3UjE9=M(D`#XNvUa*JqL}G;iW8$jw?f|GX8|m|am0JesbBmDW zTiGePx>pPKf*TuZS~>#^8fYqCbu!VWi`7ICGcs~wGBUmYl;*(Q=2R^kgodRM=rL0g zsDApd{Ko$iP1b)9its4PD}I%NkNfK2PxelnV&%bYQ&@?jVavwoUw;l54zr?K<~JW7 ztE`(aO zfkeGF+N&H(mFWh*aLy6KMD>MXj!#a`K*z5{j7cU1OR^hw{GGt#vwa4O?Rpyi1w{Qg zBnR1jM6}}O+dJgiy%V-~fC7`aJHTu7hXd#bRu@>)lo~}Ef-s^5-ChYps45-ClgD4s z{^I0~?ubp6btsoYOhM;ndRRZ_hlvFSO;^Pms2fK%_u*Jcd<$=kzci^w$!XmIkio+6 zT*p$l*Thlt1gwq)C!)&DiV+|I;Fs<7V<54u5;^1UF7vYaIPYvIdPtoh%(c6fQkk~i#o!xl@y2X&%{o`)A>P#!z7dTes#5Lr>rW_HM<3=Jhw zdDC1QgB$V4B2O1-))u@UGLTJXC&o1yth@n6^Aq3s(kZfFxqwMJ52&rq#H53X|Jqw; z*_4{Lt8d82j>0aWI_P505L~in0>nx7H((I?IHZC7A2uRbpt|9R8#M>v`5Ofamq^3*1(db4 z_*)doaummWN}7_MSjn!*BGSeg0da&4`cC$-z2r9;U-mGQ#lenG3?|p_(e?_gjC;OAtTew{f23scTQxm{A=(tF>kDTK8 zSB;Kx@^fNJuREzH+p8T2ov9K^Uz!k0D*%dPOEmXlJzwuW{1YP(S$~1mErsY!m^7ea zC|Hy%S@8(Ed{WV*(|+g)ncQwDcnC~WU-kDocU<45FmAV$1HUY4O;TeO>}s(6dSz3I z?f;$W-T8#WZBMWO=krSa8H!`JO6YUYRud$Tlo!DljerSvbFF1SAc`W$VYf~P5UEm< zsY^_34y32F`jS*h8%*S`tE;j4qCC5b;c&K^Cp2WT2i&^BedRvd6He=vH<}t>tj8i*PaVK-j)q zv>3FTfW7U{XdiQ-SzS}c6WAbEdZ}z5#Qsm$rnU#4FIrCsYOCRe-IsXTu&;Pzm;j2F!d%+ zD6Y0?XrL)CEA$^KgctxL$^R^^9~cqX$6xZjTFGYV2L8z6jI(d$E}x)ky2Lem?Z9BY z^Aj8uOavyCsL3xr7`FcTgFNdzWf*DGB5sbJo_KBkoVKQlbP0yDbe$#WzS{-k-D!y;qw6GrQu3M#UMd8+$-J#7D=ZDPac8FPkP^sMyuM8v zX*r78KwJ70p*7bA2Bb#l3**TLh@ILr09kKuSX+3NqY?L++w&AQy-gKa)d)^f9}R)h zJ^NeHTAi^sD#~-qWvKq$jfBt8YA;0 z5FB^C*0+JaBqsRmV8-wXU60u>4XBQ>J?@!Vnw^FCO6b*^hG2JY~YhO5k2 zLrLrPyrW6)<9QV0#4|U(F;TNlaw;z{%;^zj@_yHY28{TF~ z&~ z0HZAfv-0vyFNT%Z^R%lzYrHCwqF1L~+I(-+d_GI%wV?81(J01Tiud&mgc0K9{$=Sv z--tX#&n8kJ%PN(fx)Xl3MnNg}T)CRXey|zxeV8@~yc*p|7B=VXE7Cc-c^1YI*-NUA-sBKvcH}w)yj{LlvE5 zy2Hpo1h@<=S`3rfkq+JKa8+=dGd)z(7SMJua&m6zs%nT-jl61b3O8X7{UyR&Z#e=w zKQ=s=>`_}N$%5Vi{2&mRX(-}|_b7t9>yPytdfwtVy1=ERsr3-4agTew5ti)c+$iv%e4YG2jLGXiUTIV6UmqP(|0&P+LpiVxWpo z^@_@c-iU=Yt3~Ot=Y)r~I)mXGN5w`tW9Ry##)%o)KL{~3vnp_Llr0@=i01A?)-0$D zmXf2dpO9t^m5F1ZaZ+tJNZ8^8xk%)^#OiZc)ye)tR>IPwUY(xD-j{&$P zS3ypTZ=dVw+qWMU!$M|aYVQO0a}5D|cynFjA9Qwesil-W@g;a3aEh51?5cf$6mWS% zAu!vI!g_DuaV`hHdaphn_(Qas1B7silZ~BBevOtjq7E^GCXSqrv6Z8g4@9~jI_&v7 zh)T-hdi>A@2F_4{!2uI6odG6$Dv_evhWzT3&L3*aqMzRSTe+TU4()T)gB2?xlXVxI zz%`5J0-S|3Ni(=`E2}YfnvHPaimrlRH! zAZPk&XtN~l^-NSC>&2Ge-dai@R;_b?;s;-v-*eTC3>@z(+NU&3%fZw76fft?0%Ho2 zy{h6xpd@?)BD%pd=X>BNHHWd39an13vJuIK8A6*J#gE&~W8y}=VM?swR}^zpG=iHb z0x|!s%(mj#V)Vr;VMS|~)h>R2K>Y0)g4e75Tz|SpOM|{R$1qZf9#1Vx6M#Kq(Ym;e zG~2W19JVdU-VzmWjH(y$rUik(tu?E&jsd8KuPTNLj+KZayNZ#BMK_5tT@09El*ipO zhMS!=mp^Bz5bG!F$wLTIyyimTP0>;hM8suu!kwc#I1Jf)! zI~Zyuh#ue>1sg`yMHlBEoYC#)evs98pQD!Psf2S>5r1WM>HHxk&v|nl`-f=1h9Q5) z4p_~yam3ua;fmsGdrkkTgzNm88y2xUOTJMcx}bK*2<7z2@^jGp42V;BCJTsvPN+NV zQR{fsVl}(>VqIL%;N-%7S+#EMsu2fs=!H7OSfGvfIH@hEZNpDfnYGII!-$p*-8)2O zdRb%x0t!q{W)mU%cJ#>e`>n1bq_$hjEV}+3(Qky$WI{KO_;Ao?9#WQ+HDvtKPUnX2 zKfsbVs``8Bl{NcxEki@xHK?P+9xZJ9A>xV_V;HZRl7D0PNB@OmHKh?>PP#a6Zw)rn zml|4J`H9#`%|+qi<7ZU1nF@|{t@k-2aP74I%N((^K62Pv|1y??uY@&O=XPQXx+mp| zfY9byUXV#jp$^|yF?JS&LgaElI_)LU>)bJ4E%R~mW(?R>|d+*EPwjsMTmJm%e?WsC?n(!rPYnzsUR&on6bK zB1TED?<45wV?eH%SO1v)w77gpW2eu!SEIOsoI{8P+3#Eiqa~1y&uoU9hd_K%c=(Kq zz7Q3Pe%M(++tm2goKDLGV_u=Nfd7y9AeVTk3h0P+!~3UT&~%*p-0Xbu?9}3mSz3E8 z)H%hp;2A8^xA01aXdSf9K9*+^KcPZY9cwElN~31hM?{r0O^T2tHFdIU)SK&86a1n# z>hvt5f~_Xa&P6NL{W>7x!-nb|ATh+~NbBpZR;|mJP2Evl)z41`8O`>~xFgt*?0o z8TE+_rj~DF%Q#@2frESG}3#inHp>}+yF|x;2jF0(Agxz>w^%>f?%gOrWHRzkN+Iai^ z7Boevx2=bfh*jrTXcG*$q%g5|b=|UiXlTTSTyEAVPR@N}R_0%U0a&dj9LW=x$8W{p z&Sb?DXOf@zSJ*)E8K7fqrZFX=^XZcG?Ue(#>4!bqZ&&xb&awr^t;CGpm8z-NtbeAl zF8yXCKgMrfr2z zu$7WRI6N#s`LJ^G3=oNtRT+r9YiN50NW zyqYSa0B;c^Be2l9`A=l5amE|LgqW|&U2`rzy!eu05tj@U+C4(f6oMH0dNiRz`(b{S zXE`fzU@pjJjKvDJwhhXWWl0(Iw!TUbKsnF;)amD;9z=NxbvPQP5xoA5C;gUo$hcef ziZP(5P_}B4E;6<@z_?-#o0W&JsZVpeKGDr>l~<|lg`|eWMO_O)x^<*2NKBN@1JuT1KUm zRr5DG^Cj0*=c>5iZT;*<`W{(7&t2Nwf@uFHJ06z2pB|rN$tdr!%Kt7xCOpDby*laR z_ChQqlIs>`a$A3Nk6>lTjrS!Ikuuj8WGFE=e^*(z_uTT^xMttgyVf2y@liL7^fnQr zdu}9EtR6?W)4|_c+?Fa+@9D{)s5h)Um6Uw_r_otvlh~r{$$fG;eg@*ot#Uzg!kO7G zPRBHKMT58#s_c($1TDXl$^aO}E=*9I{9+!b+HX6w(a%p;bfw*#S|q$YwVJ|YuPDNkZeuYZhzSHI1<)gkL&V?vkF3F1mpcA|sZr6nYpYAA3) z>V*0G$Zj_bU7zS#m&=*uVojp5y*m(_Imh1w$q1*Qb8K0Zm7m`$@AK3)W{o(axDEfL z4%`Zp8Q2yfG?nj6Z?izVH5%`l(nf9EDCwjDx7+ZPzDXVD#s{XLwLe_jH0(JBe6Vgb z-Tgr^ecNco&f_ma>zNJJ`tJYYF(f5qd+mGt5j{w)Jn=W)|_2Lm2=%(_%cL4)3B|sp-srM}>{s?1U8W#EJhW^0HnP_5I@=0f0{i-uz>Qq+VBT2_LmzUcGn)hn_TEXwzKtIb_A*0LbhbKX+sXMR zYi0(28Rs9Kpr>$DzyD~* z-j8)es370jq*9T|D?UuH%@AE2)z3eLeCRTjcWlhBesb(j9i}ThTOqNIs*! zn?pZN4LWJFFC8;N(jWT7E?SmYe@Qv}vDYZn163h^C^_z%$c}TmaqmkW;x&k)g*3pF ztOkexPX&gu?;Vfqd`GS7hT>Y%7*#sYOmvu+!D3f8lEk`#?X{dhz3HoDT5F*P%Ghqq zPr6e*4z9C4%d|IU`@%1A)x8Zzm_RAoKlGy-#@des(Mgevw%v(0b|8>z?N3 z2Z~tFpVgxf-q#p~{&)(F%+r*uO4U381WEDBoNWYv!Q^=8KWtO*JHW~8*sZtS9l+a` z<&aiwV}C6c|JM*h1}x?Z?DFt~TlDJ*3x5*GF{!{YCrNQ1TDw$u|E(8l^0 zVuFmI*%djV@`T<;c)EIi9^TRaE9?_1uu8jvPS=X_V_PUE;iq zd!KSS(s@j`_SBt`T_A@LqVnscUQeBY0FBBH?r2APr4kF^G>flLB14ZHuDksQHXc9f zu}?Yg(>2$X;N{QZu=ZRadnC{#MIR;$&q|vsVntLh{48O zj^3ddS~fvz6`w5Z-48kf*p=b%ym6!sY@?356jYxb4&3;n_){nNOMFt|_*ll#m;z+U7Qc}U-6&U<7TW|3)`$AaWOVJh5>%aI{SZN{`-!F`gCe};ZWbMnUuBD7v zs;z~RCxvx(DMRXO$IU7XZtjETXctY_x>wtH zistyAnMwF3eiNK8ZJO_TOSCHv1^(vOyjth+6x2wzTum4b%vuT@BS`T113zd9 zxdYfD%@BxOUMu2_A6skLN1_W%K(10K7P!(cm~KQyues3-%?@$nc~lRzFy zy^WamCcf3zHhn)^qQPKMmT^?pl)m#MW%|&c7{b2BFYIO()alDbUj4Re*1T%OGO}nu zL524dKn9YMU?o3F^D~dCDL26Wa%ceqp{uRaQ1I%YuawXV*;ZikQhRqScGQK<~s46MQcfhk7xrd55vok*UOBiJqm?k@w<|hPn^;NxLqM)>x9Srggl;?Khm0Tr} z-0v)^@FfNZMm}`lvTm9)&CuzcGFi}-eBuRhRlz3`vGqL07dSppsIT`DN~4dE@Y#B} zqOXT45ma9K3wQ91Xbq)h7cnV;9{jv=-F=vYfGXSPfx`kEQ~UcgT39|RkeOw3*UvaAdP`y>|2U^a+`0ZQ9uXg|?^*g{M!sR}>*AS@rtF^sVppM~4 z*e<8J4?1~Z@^a*jV~c^l#Xj@Pn^fKY{vA_hx1fl4H_6rg_*|`k8;!IU2cg{lnh{rj z>jbNxl%gqx*yTPO-j`+q%8spn8fEnKRJ6$`)9jhy(1IKeCU)+iK5hqY=6jpH?n`r~ zrbzd*;02~Np;z=+z@PzU?7&=D5>rrH;$scsr9Ox!%O2eb{eEteEtTCgy^C19&=y>D zc;+lo&4bl>wDa;(^3{QF?EaU=Kl;Y*5a@&xq_!Z(X*H&zEcIDic6VU`+;RgiPwCs*d?ypuIC z>tMte*~7fnrOIJX@D;(Jfym}am>npu-@wf2%F*Bp`f`pgx*2R;ioC zI^qbTn1vh>VZM#M{9WmOY?r*acRLt)n1Md8W4>@7V_9oc4lCV+a>ysO4<~}61XsgP zEn=$lr^Y0vX7vVysiH~ru8 zo_nCd=gjrL(ctF)1gfIV$^rjXJ25gk-PNU>biVWCv2{6;LljEH0*zpB1la!{?g6A`p%Vfm6u$IX=yjtuMvaohO<27 zt{dS)OVYQw@gD?_-u!D6G_QbRsNMLUTN^Dt^G^pS!NVi;-ait3T`uj`3Bsbe{&i2@ z>RT>NqxY2HwlSdY^wYm+l=xvChszbeEye$#MZ99J{uh(xzi6-je;@eJt+TUl-Xot3 z?~T^)-M_^5_`9{14}Bx2cL0UMsXKt!hwu+GepR`r=RqGPa2x+OVgaM{&*N798}kk4 zd8r=a3v*Ecg(`D7GWgw1!(Y`2L&QzbrStDZ|JX1DCp0>&jI$sw@1Es9)NK7%x3X#f zao)gtH5<)cUAeU2g8vR$VY-lY{@<_%$)rEzA^1Ad1+JY0oRpl!Dzdnw1=_e4<@?k# zjb{d`|MRU5>rt|~ChhLoVJvQ@h~90sqENpR{)~ro@y#)YWRv-?T{exY7FB-MG5z#% zkxfB}!-+NjQL5tC)z-jBmCIhL54y%24j18eB?X0VBRTD^ejbxIPYP07TRDZYuIKP- zSFhjKAHgM_w@_6hU+bU%@J1y~2ZX~r>uw+0_leZ4?h;I-7CBRg2CdF)67_;#hD(La z7Zt?K=)dwlUXW=r z2tPVONJx|{0-oLhD3?~+Jg3d+>juRLm(m=Zo>z{nNDx_j@E*SPIUKPSC1G|ywg?um z&#y?o@=`Q14T=OQ4>!G%&v0sABFdLA*t`Rn5O9|*{DEuDm^zFs=dkOkxIslchOg3H zhoz1@F)y6MQp2RiM&8$d@z8JaPiGL~+VcE5E+^|cvORnf_gk24PEuh;5E(XQod2W( zmP&0QqH=0hr%^_YuYPewXRt))A)4v4Q(<7EowPU@KdmRj*s!C>@iBxhdjC&#ZynZF)2@%Eg%Vtf6bixJ zin|tfcWJRA0SW~7(xQbRMG8fO2Pr?_B54b6tcC zleJb@Yo2*#=9&AxpA@|mrU&dKC9>b*cRn0H{cY1a+NHmE(yZYRqu4K=8Mjw-B-I$~ zwO0q3g)mZEw?*t)gi2OAV+MVoj2{oNSv$0RS|dbBI;pLXifh9%8houhkDx?p829gq zoNYpZ5_xb?V2jusS8sdn*sI2-Wp5kNUQ+nmiXesd#}xZ4Nl@bVYmfnO@I61{KIO~m zI7s$J=op{MdH0}`i=f{msr%%!gUPXB2A_|rB5u10N)HGO{MZj?v0F_2o#2S*+~OP7 z6*{^cK#FicxSAy*W?&P%MJyt^xwZOSd;`k&DK17SwmRPJ0*!+UJaIKt@xaBaebz{l ziP&dKbp})p^1sf6yc(pm1Xmm)y`5c}XY~z)_|(v=xDi_B9QaKJEtQXp^#`4-7MA)g z)?ciAczxJu*p~frN#(mLiU#qBvKCGW&MPesl6YZahHkf2j(%U6Y9k>6QP`-uEd^|- zeEwb7M_y#>Lo|0^k&3i`wV?WB9}aWMku4WjtjMN-Z6-n8yUVHADEOYbC=o<`}iNl>JmWJ3+TcpQ4TE%FGI+8_eTF%8yqY8SU z2XaI7C4siQhvfahy#2obu^dx|IQ2oW%3TFlcuC`gxbxW;3W)WF6sIRS$e*AduLIkr z-GZUHWdjtX2F?^}ai?2e3+2&z-$~<94eG98@0+fHTj}2|6fOIROM3X9rED1t;Z|c% z*aV<0G=~o}xLr`^;g!|ZPwU4**x*djl>xSN&|dS&)SM_u(i`ybV5(_4TiKp?&%IOga^bh;T&YN>CY{e4byBeOle!!k^ZS(6Uj#=Q zQa(0h!yOiIBaC~@6=xIUdRD@Bayu+4&Y^{QP5ND$>nc!DOvh16(reH9JuZH-yreM2 z`N9zI!HQvoMG|i;vP4RyxUT+4$0z`lfr^J{`7B4^3Uh8GHbF&c{2$JpOfDUg91D?i z*h87CPa*_20kfG+fdm<6*y-U6rSesmJ`&0{qi>q--|MTyx}6pz9MFY2&6p&$t28cX*_g{PUzxQh?jp_fLC3VEoeFVIojyRYiz z3BUO^RsEOZDJs@~k9Yp((DdqtYOn9IN0kTnj9OCaJ+iW=EKl#k zy9z@2#*yQJaHGkP{K6iCnqh~=zT#472EQ6dr6#S!W^3Xoqln80|JG*lGxPwi$JR*~ zRV>r9YJyy;!^ODCOJC6foa`f%UNguzFy)?aFuv;UvxK|cwe6bDXe%N>TK1_*lU>3X z%33m79RM07f~SqQl8X894Q!>{>q*(;f*|#}$rp)=&rGjNX%;0g&gy_+QWL)B0X5l|OLK4S z(w7F{Q*JH7Ei5WMG(_Bp39LTvAJ9*vWA%&%mE*Iw^`JraS006_)CW`3Z$uKzqBcA$ zaYfH?RrzISSwyFL4~pi^5xmAbS>TW`{sgKJvq{?cY6puHhq8)y@-s^=>M^kEl1)6@ zZxAyO6Mtri$-wASi#l7R!zqVti(q5iGtY@u3!F-XDd&u0pgLY)#RByGNzM~--$ zVV~VX)M1inW5I+fP4#=V>7}w<&03u}LkNYWM4H+`SbODo;f4I{he=15dhIK+8z#ge(2_v`Q&s)s0I340`l)~rz!Gx=UwP5&)!jM) zPP5*SnTGS^<@fV8PF;%uVKO-^fvNmfmcCa5EMUK@N`-vx3;+D{peZ?hix-}F&65)? zMRM=ZL0eMF-|wp~)p0FPGs7AtbS`s@eBGF08{1hs-(yf?;Q~YXX9rj(#2s6fxRTQr zw8sQ=OpoU%d}H3%w{tadiMwZJdYOj*1u$0%{PxjQ&@e0O3JyvfH)^2XEw?X&V(?(_ zjv)s;gBM+emJe^yTMpfgG`BTC(c>RKDi2qJA&oxT1kVIlTb+iWb$3)R)6|9>?@H^R z(k&`|bisciGH_vtUsdS9!MbZ=J(ZC--B^VG7oaW7M~NeU^n)12wdYTc9Pa~J$h6@j z{e`M2kKIAj61F8J^@&}^lnLJSfU^P0v+ya~_VLC#=|qhYD^rJ{;<=zq=lNF3+pqT0 zOM~OB+Mf*cye0jbbqx*FbhOO082P4k8Xiu}uQT9v;=k+wa`2i^7Tc4#&wKiF&Z^Iu z@rQFxG@-2|AnU}@5|eyEh90w8&kLu$%M7>%I@nEIB2tpMHls2bPDFl?uL$s@t)2JC{mItex|>+f3V_2_ok&hx_u+IuLh z^q}}x0@{+TrCi7C5r;BZDQwz+RBzZ5%#r7?O>Vxi+yoe@INd+yD6r(p`p{aP6%F@pEtQ z60tSJ7)|c2pUByD*^7gR7UCu6LT_4EO0B9F4oRPQnI{v5?I0L}0X-@buQvTF6o(e3 zXC`0dHY!-v4uC)so(BPlgArT@2bSzlTvv6JTRFN8Qlp1A6KGmaWu=K1yyKhBcV;Jz z6e9gJ{shm!1mk3b77Wk4>OLM+L@$`Cjf`NTi{Oib$VN-O@N;51P&86R(c-r5(ndiy!%*UAYu4yo>`?z%5e%Fn~R#}l-o00ayN z7WhsvG1PF?(DD@LTb5&WJ}Qw%`S}^t`3bH)J0Z0?s z_$$XjRk=&|&ODc0fnZ&?H8s`ZjVl#CpLsP+A(J`Hq_|g6dIi{JiqobcR6-Luw20aS zZb=#{b^I1oYA~t)c#smj_}tn4i7znIZ=(-|$BX12ShG#O&wp+;it*bF7Rt`ntoNashxI*VE+u2tE^JrkO{r>-yf z{6i`G<{DZwXFSPrgxL?+3SCKNV@k5SIIA2Sxsfcrhy0`W+~`Z$ty}S=!C|xc=PB?` zb_XYyv;LcFWpM0=v*uw&qEX+o{?#?RR<(l$rw<13NCk^Smn41$_}-qi4P%-JVi|*n z?=aMp`l4%)8ULv2)~m`(UfUrfpOknfKgNC-gVFi5n>b`LY&mG?f&D>7kZ3U*tH77A zSp_%32m|wH2z_457k zs*9_bmr3dE^9iFRNzxwu`8Z2>QGRMl;+5=}>NDghz!q&!wEKFC7|$m?zzddJRA=gp zaEiYXLCAuF&?|0~M68A#H=p|__}Fd*l$&nU-~ZtZz-P$g zK)z8IT&aiFe}f$^Wck(_&MfcQpf+gm+%Pnn9KER|@ApkzPQNU0!OI>m+|f*Q5jW9u zP0vlYrF{gNlXyLrII6b=rH_tik4~%C9If;jP2JnMkR+Y|=mRvX?|6glmes3ePyXcf$_!+Szto605VQ&d`>n)*r0 zKz+ulMyc(v(_2Zz&E$X|(f0yj-8S98MdO|ihv2?Ylq^Wi&QHrxI72Yw*H#FIytoQb zuyMXot$`j$lqcx-pS-&1ZNBaqH9Q1KOpi=@Wha$?TOKbscNQEDOKn`B+DkkOwDI?j-6)HZxfTbFMf03Nxm=Wwf<(AY!#5K-(}Fp@|XrI`F0fv9&N0Z|5>y zhp_Z7DeE!QvW}O~i1OYh9c5_uY zf@J0l*Fp|hYRjg#r_KBu3>Oz%8>7~(TxYI2k&cccv^VddVjd`NQ5WLbjqD!tUP)PL zX=z?fuXr^Wt=s0Rm160LDj>BH@v~q;EENuIR5i(YS)NRR(gx@>4}7hJe!;XH$n6;& zMgLz6;D6`+*8Gn0DEtoeJuTGFd%*O+hkA@EGiEIemTo0EYp@=hchCG-#mASL-s13| zh;#PNU@zhw{#?_S&UlO^L1{iF+XGcT4m%XljeEN0&}24eVWuL0hmY`Sq`F}bPukpt zuiH^oCA|Zk84Huyu6pjz4!kr?Rb}wYz4TkpEqNHdZzjnv7Y$MI3`H>_q|mmNbbaSJ z4m~ZG%p|6hk0)b_*>zCQqrryWiqd`$MTbiCDlNBY=D;IH?--x~uPSZ5bPnxn0k#3z80K1u5?-ew z2Kx)GS>V#Rv))UwN^db;hwmh9tr>C@vnsqh6;?|lCl-s$*>sF17|Xy|3EFD zgcc0hb@vIXKxM{vn9z9Xtt>Xh(zIQLR|+CZzU`k{HZ$L zzZo&D%_dfdlzdBLnTSbLVlC!cX7oi&tY~W) zY>{|jkTVfcANsuC@VU(7O^`ads(f9t)i@=m!=->(nhjl0qYaFQP^k?mQq5$dMIyuH z*x7O?4V_+T5$8wesxS%^`PBD`n~L|Q-dADFM2Z01G6c-xS}K>G_?Y3qf|0^Wm`ugM z#J&f9kDeX~R#mjns&ap7X=M#GX)Vu?+G8R=@snFw;Jc@N_ZQQsTE zyed#YP+E8ezD$Cqn!N9RjO1+wXurU$Y@0j0F21>SVW;4* zC@YaXo#Fjm98)Z>VNVQNQ#`Pj3Y5@e-0Zv9DFq@Fd7Bb&OjB+%P@ao|W%P zjr1YH-MAHv(G1BLG2L^tt=Asno?kTH1u@vfypYVj#{l8T*yvr%&bCx_gsO5jJ^@Fh zPeJ(ns&>WA0srLXDVwI9HBjWT1F3O z0tY_F##9=TeQKo}aot&}13Z>Ety<);ejKTJ!XD00**A#&U_*ufSRESXvRJ7rhduiO(y!)Ih01I2`F%v8@>abHe($7+>e#ypdp8`_VxKgi`iEZ+3jzgX31{V z84zr6Zhnj}AW4btYVRvXKw7vc zXujKF>vG@AZZLQ|EW>2;+y)XK4{LFrW$s}_aYbx(kdW-|G2Rmk=9&Jz%H+QQtb;80 z4U=CaYK}tuY3LZTdfu%uh>B2eA*b9YMC*xW#+Ag=wIm_b)R*_oFQFJ4Yoh(PJQ+4= zF#3O~O5%+CL-^8wz3}gl!=K%Nl%y(yi?@3V*R*G(2}SMZyC#%u`!8(WKUP;q`g$Wp z7RF9hj87ZDni(-`_QhqS5BoTiTKHxggm_*})sOfBFMU@}-N#_Oq_bt=E}~&Yx$O_- z-pZL`D0OmFJE_%iXN}FYMfjoD5CWq+Z6qbho|!r_&(V<*>R)|Kj}^E4J{0+e>bp5) zb}U#;hn-`2e;AUkJt_P{9_FYyp4LPan`uvh1U)LMGI-`R&%n-I@1b~>#?$Ye4Vf$= z^#%)&Ui$AfGr?_>7v#*S#1W_K09Q!ng{0Vn;0>#yqPm#(4+j0ruRhC__MmGz0wPbf za+-nRElJ0?=~d}>8(*Ohtv1~E{6?g{;7>ggR(}yB<5|Rj=^ob%#`geH*)zKDHGmCY z^DVQLy7Fmg5ye5@M=Day5=R24R^7zxfeD7SrCbCxix2pR`aP``(+&oJ4439>Q#1mX z)Q+xRNg1`Ahkj-EYKd=U2xV?DX*?<^hX*O>lf?EyzuO$b#0L%+Vy!ynSN)#cUpx@w z6B|@>(c8D;)u7xian}3TR_@C47+=CX6f%~q*v56bCgC|grM1GBwf60yfH|;%#qzb$#MA^CzKl!ud6!miqP5|YGV@AFpkOg{TgBNm=9Bm88G2-_?o&<-*bvH z)lds>tFVd|v($`rA)9aYoBfm@HIZ>yaDMAT=?5)2w`@$aB=Vk6{%IpU_)9M?uoJhz zL%~XLr~4?EDDWGdHQeUV$u1Tc7thtRP~QXm>PO=N6B1LZPpoS`6v0Dd%#l}Wue^ek zWsAZqNp%M(U(Y>b6UW?6t znbi{@7l|Nv;J?*6qvjS?ePopZKz~-|I&*IfV}tyb^!tH(ds5~wXR_DBrnPCE)b8!r zQz|}4VysobVTNNeA`B-8q;HV*E%rH-<>yD+`;aCKXfb9yh`Xds_o;PZlLm=vKmpww z+~z*UGQO2VkBsiU#@B}HyNhFwZLc1R_O-&kPB_Mx?7*GWD%%#({2&g!RNdbMVw>#4 zu+F!Py|W9T87Bhu2I4N$mLuMs_{Zj}ptGjyVuoend1G=gJ6%ymHe2=_bDtl%)T8H{ zwj5^`hs4u-F#mzVl=$UIhDJazFg&?!ZkMZJP&H4|(aSvO8@=lDnZ!&FAFn>?`;vw& zaLL{ybMUZLiBDYu#t(d+rF=AUaw=R;{kK`qt?Yx>CxNxAKaWHgu1?!pOovXFd|$VN z(bUxDTL~aoT_}x%SSXoni)Q|237k2if#TW9@pDK94w{;5Xa9)BXj8z{;~K7F_1z4$ zp-jAG3?lsXr6gh7$OxcFqpL6>f%Joj)`j)Pfu))j_UMfD+1|MVD=X^O3__c6BJJP0 zmj8xe{f~$)tn{BYI}QH=6jcfcwD>1saj;y6rr`XNy$x7 z(E$;dFS=Z@t)=?WrzY0kU(?X-Q#Dn@r;Ptv@o-n`fuLu#u6_d?5csf;sEJw$n0H}m zom<4=F)nzXOa?!4Du-q`S}aYbNzM(pQd!O*@%jK8rQfSG^OB&I8A^R7XhKtJIuSy`~A=6;56I1Zv zsZafAj98R#mp1YUH&`n|Hn#CrgrX0aQ17XZ*2N%dfxqBW5Q7q3tg@@G<17xTlkd6d z3sXe%ZuP!;j%2(ywGlo_L^RGJD8av6K8ww)-Gf!a)b+VV8D|kU9be>vuID?aK^RK}-8vr=>oPFKSS+awc zw}gf^#pw%+uT{zFn;e8KcVDliu5xJ*6E)46)4``xjgjz&limGndoTFX1UG__TRkO| zQKQWPwxxwZT%8(5Hf}Bw&^LNw?##y$B1?S+xiD)K8OlIO!bH;Is|$PEF%2sKb})+H1~W#+yjJQorLc zbIMPi9MZP2b?-m5yaLvxwa7)(H=*C_m0B#AMLZT-S$mRNF>Qo@Bl6*#sUl!B3Yl=Q z1UTKRt5a883!36v%*&0afeCjR;7AtZje4wcG&_JW9eXA5$%wV}wDq)bTuvjmDxcsb zC* z?EveP%$g#^f_-YG=V?O;%7BTcy3F`*?V5FkVxX-1j;8c+aoJg;w!HmReXia(ZdU{Z zBcr0mcosUiQ{LY{Sm3#+-y0uhFrGlVVTyMSW!zgBkf^&REr(f0{o<~n^rrvk!t^&J zLg8sa`Eo*yxb_~qxXrVr27BLSv4~emN*(t+_ostjO{AgOC-ofEJOyM}_kMbP;=cKV z2by_O>(ako_(EAOc zh9{zCGjGBiX=G1hJ#|c1p7_uNsQ0O8uLpxwC>SIy#zG#zQV#0OK`&c$rW5Q)MhU<; zP5YSv$@!uI(4q}3+%d5kmoqi!4LJoM{QF2>cEy$L>PWkS#yh_;^96-z5ISaeqMjWV_lvb#di4+q2o(0plE9pgCh{Sah{d zudr6j(<{kg-(W~52(VLxH#J{c%CTP{@R1h@&gY6pLs!S57DkF(dG37jcdTwz*Bj7M z6Mza5HZ|8>4@9qx(3Dw81wNuStfel(fQ#DY<2nQ-QDi%7ae-za&VWYd7YwXF%|IzhwrVQtc5pm zb2Ad9aW!AIV6@0BA(mHZTIh)R0Kl<#eLVvnw?(o@gt8z1D_Kvgy`)T!S}wuEuf2^#2o8R#$1-W;ye zFCPm>tf64b_t$;*Odk~+eF4K_-Zwv2`n2}%;1pX!YX+durS z%3kN2=G70lR}^tIwi_y+lG8)^$Z1EFc#uFAhVL6VAHebU!NqWa`}{y3eDO?|{^$?w z4roYw#(RSUW?oAcoV~CjQTtMed7o(Ql{#%*o$upCTePkVOAIGH&|qbYNb!+W7EiFdBce!OMME8Jqxda7J<&%1A2U}FMZtNZvIKWgYTa>rD$>CD#Y-1nM6JycJrl zOKk*Z6+rr=;P4>^`bZ5WO16Tc?7XQFJ_QDwfF{CY@P%_=H!k|R+@7Ng4VI8|qBnwP|u32G8)QtYNoz=_`T5S57atMJ{6 zRN;CObPyl+;p<8jWx=Ac_v^-p*OZ!~l75EtQx^#>M@x^CKq0=~A2+NZJ<%h4zUV>l z=ObY3$Nm9Y5H69Tm}Et+i9^tq9Mu@|=%p?nq{$77A%_bgZh-bYv3gucq%b}4Q`CGI z%0OT8X7fjS-5POMc1#*>s~k87scpotF?xOun{lBgipQdB8VMY{YP2xILTeBP!xwZI zyle3t+})lZBS%XakKaqQ+$E1&THAr7-Dzs87hMqR9x9Fw6x1dc4=ktkXO`-8#Tu0N zergobV@z%pTyfr2EQdVUK(XZ?Sm+hDn3lpbW-!;{ssc5lTi<>ZBFao79))+fO&KDP zypy{K>&vJ8#%l&Vh#3LFj`~f2zO&GGuBl5n@iV+f)e}k}2i(M7d)3pgS8+>mkpRzC zPt!wq5!g{d{X9X>sJ+2qlTdW4>Rxq|h%2P!NK)eCrxgDP)3*?Z1r;^QfYhhga$3H} zi#FV&O@16kAIdm(9d>hzJqh)IsgbI-khf6-ENJ}zU^52WAE56)?Tu8dREGf_Q1i%R zwZ8z99{tO#&R%WBd*l~_w#lBIhj9lY+&+=e$RQ5-8Vh#LStoEf#eH9z=H{Is6p?B2 z@bRqNinsX_Z_yM9t()w``h-IFFpI;8TU!fnB3 zb#)0co#w-ynqyvDWW|%oBm(CUJuUPPi#+4x075i3$nNGNaYc#t9HSq(Wz&BH4gUq& zguWCtVFxo=XmFAey%*AA@gi2whh!o9bf984Dw*GVy~+0}k&nT^H&yzDcn=P9 z_}%uE5@oimwUKBB-L7j>ebcS$GBtBio_4S+lM<^l47_w31uz2`{6Z4eY*Wa z?yz1o#>@X;VS(VaU<@5jg0V>tufA7xI+YX3b|`oHAo z_;>nB0K*OQ55j^F`4v>CkKI6(P*(?=iSQ5Pjmh>;n=4CvQAyn9rEs6aa&z?^tvbdu zU?S45Y1f@(SX`fph|OHZf2Gg9M@h-xd>X;;29_J4Y^{`%avImx_GfIGX?N$}+XL}< zAD_`Jf#XY{PvZl0WZW0@RJkH zVj=sT`Z#f5b1Fnj=o*bzA{{lsw~O6FOa1>1$oe03|A@@NF4Qk&rTq<`{bSVw{|28e zC}E9a$4>fv#D84zAO3ulS;}6_u8OVCru}-JW?XnR{P%N(<>|upbHk!^Si}B=;W(K7 zpk!sYIc%%S5^H1_fFCi!mY4!^|eQ1(9!)~VlMCoR(i=0Rr5E39s zP)XKj5YjMEgeOjZclbsIf!6_#-pKwTUa1)@3F)4$fX)jbKN0-}Xqm!Ibse$t?UlUa z-$K!-=hLQ5PSH@~$qGw_zKjg&& zZYxakY1=SYGc}#rMAAatN3%tZT?9-Qw%EMnlQ&o{5}SwJ#5H+jSXhb|2GCLvrIloG z@h|F%JGL=JMX6*;db)PkuOY|gT9Nph?_xLoIW{)3i(91=w~zW}JT}PMq%C~-KNyF3 z%Ufh!5Xs-=Up6#%ZlFUZSGq6vK$M=<3#I#Tblr&oN~)_b9H6Da^F>O25(>0s4jMZeMDS;jss~A0Ab2NeuTj&!gHEb;^EX)@G#Ei^BBV6 zu0u*oQ;%6(BpO3;rLOyEzUHU4QO@;1QQc-Wv^{%U-uH;BOf<&0xDIfp42`1+*-F{t zv2nawfXxRNQRxuxD$_FCiYXF)efo#`pLgg6pSx)i~dH4_Ns~Sv3nB#(SXR|&=;D4Ss|nP zFt*}?*cO!rAb~?{0uV?G$*gRLdaK;}RizDxHvG)!&7G{7r!De4^QqAyW&tf~&XIpR z5RaldUwt+0FEEmvakE3t4l79YlcTp?ea)flZ8>@XFqQ374{!EPf2LrPUn-eVl!*w(osZVRS( z?v2kU`Q=63P!JS~^z~c#Htxf7lU^{SD22Bi-1!l+N{$s7gj*jMe}@o`T0E6Gp$^3_ zE>1eD_?cZfb;qWq!C$c*Ax2?-N&NGa14~>j@cI}s3D|PLzE98oKRon5_+|QBxN7O& zmCxM_pcv&>N94zH>6E56YfT&IXp?B~QbO6WYc?Kb z+0|;q-a6ALbx_(~ugqO{h<^{5g+cvI{h0lls+$Ym;~&H`HlH-Zq|Bfm_au3IMUC&5 zvfm_~bU3hvlbQWH6$y=neDr_(gq8??lce>}xh5zN1Zl0hk{A zeVGJP)-ukQM-*2yn;$4|dxQF}Y4b<}{w^{1{yfQX|EbKXVEK1RrMlyj{bEQGHj&Yv zlJnoEhP^TP_Wz%Y$h&oQ(%yEX7(f5G4OD8tDyt&Tnk;MN^qUlt0{efqfgb3i2JZ*D z1`4vj4*6Hx{bQs4{@@>5_WykxO@gyyW2@4ka+v+Y*!k}t{NsR4$`1SQ&;!fwYIs?z z?LtI33$ErKB3u&4v2cSi3JT?!A;7EQSi#m0O`uQjwlRYA$#rup;wE*3j_~9_#4y6a z*}71S?dq;i0lkaK#VeF*w2efh!4$& zShRs#(*0wpe_C9gPcR93!sxq$gVnH+n2)H>R~h>Mc46N zlQBHXLW>P5N8xq`Rs0lmz)0JGl30&9uN#fY#>$C%ci-&wC=kfX3GzrP_gNHZ{j#G_)r6U_U#T1Fx z?BG}#3S9O6m!Hp#7v8*#dC|w9X4xCjL_;r@drJC1MO%juMeTV3_sqKapjC6N;{=bH zI7M@^Tol+`4Y-yKV-5esm9wp+nHH)#MkPF);&@zRM6mbP=sV+I08zuB+F+Z<1qA%# zfHO%F;-q}p&C=Zyl=yK8+AIt}<3XGghWbI>y3BP4&43Jr4Bwk;{3!Hm{1|@^|6h)>I`3B~^_4;2 zkNl3WhQqL1t&W}j=Xte4q4LzbXxqgE!CKGA{5iRsro38;PC&iegWgl*e8^D{1%Tq4Ak`KcmEjs|?`U zFCev2pH%08&xgvA7N?`}D;@JE1mGf; zK`V*PbnW$7Cf0z@$?j(LiSkYXLF`(WZP{ht%fcXWbn6n)2?75|8^DcwCY^EZ04i5$ zTfKJMwvu4if+9df4hcq~&(#c!2S_I==wkbY7Z?8YKwtppAmx>w{On2QI zcz+DTe0ULZ=}}#PTx}B+teujd+j8S{<&6l{#K6$XLBkn63zkj-YQ=Bn;^tC34UCkh zSfVWA7VAbW_p?W49;eG?rGx4#94Hu5kW({aOM-!AbxY(khI-=Q9*ZW=e%hPoRg$n> z3%e?4%dCMQlda!8rzT>1^Z9ATB8O9@*QBA*D3gNz$P#0XIJf%+6YxWl{<}>vIw@I{ zDFNIEbOumeN~N>&c8q}P0oI9T3CIB9K%?`-hfMhSyJIva)16Aa!S9Sm3?d>7MIM-4 zn3B8;FCd$aoDbgI$NI%3DRV6dabx0sv4|FXdXLfm2%Ex0+15R%5@OpPc;iTC@EuZYoFd15_^MN4JXD}qMr z37q!Z^b2^K>7g1yg9v3S76WZ^~futm6eS=795peL&D^{X`o3Z&?UE56|uIfsl3gVPi)f$r?W z#Nf8JDSIiRuYtG;%C2ACWZ4~|&-F=bpXFD#-PC}|`v@vYF*D+UE>dD;f@q>7oD z?Qb4_;PPU9%k8C4PdJP0Ucrq-dhTcYyXh%@G+=UFS}II?EYzNgF(H)dh|`0j>;@_n z2xMD8)1_Ym%AwV@I%8d6=VW(nrR9izGqem)7Yl*-;Mb}tjGmS=rE`7K=`R3!4ie>k3{d*yjELGv z+Z-hO=MHrT>Ndj6JW;)~V0N}&wif!!!J@ng>P2$Cd7+t^4HsoXE7jFX*!ksqFfddB zHfK$(Fp zXZ)@4$b`ug(4*=rwQ%uuFG(+R&4pIZ^x>^eR|)E3H0cL%|Dvg3R+N`kerT&G$$*)= ztZ)pCNb(uwu%O$&VaG<%{2Hc1cKPpUAm@?WAvQmx4rAVfopN(J!ipTW7cG|go`RAv zSr{@`b3T4HWX`E`;X!9j}kvm*ZOANu!F z49|8}x`{veT!moj=)ctowgw1m?nH3ShLTzE^_E82&$=6_eY~()y4vt+Nzs zLqTF}eSP{A5n0S)tT%))I_UJt-Pk$?;pDQIg*;(IJpU~pYS^xRijICVp+22Fb<@wd zjt5NgA&M(u$@(ZV@PDGG|8#6%W(m7$SX5YUDSOf{d^KuTWg;GVp;7kZVMu1V)4(DI zmtlFV@9*&!*E)qYH=JDu-HN1CR+TGWW>F#;rlaYMYO=Co|d330SCu$UD`qt7`Be;pSORLRDXmWRscuM zF#hWno}q$FNipHrs)fI-T%Pm~?%U+QA}@mlr6_Afo%ZW##%PnQk>p;PSQllqm6y5U zA=DmkM6`WBuJ7w7Mxs=E?3@%d4Dj@hVh<%VQ}vn}!LPsH>6vY>tgbWy%}oM6olco? z;b$u;Z$JHxFo#4$W)g&&rp-f}8}^CA-`8A84L|W`5cPTM>v()1_33We*En3n#3iB1 z>JnS{{hAe^5x3X9mmeDBU+zt^rb(!XYQ8m~!yT!dbW!8GzX04^d_KV)D%2t4?lwst zejN7SkVXa$Sq2)u1@`{rxR9rs;{BtB$Zx>)`$OKfMg`ny*k(m>F7kYa6igMx$vuRNovT-B0KaQ8?Ec8wvHA&4@x776IHn5s z0W=mg+OtDbr|?pDXmeYjxl0J6Y0fio3I_2Kxb;$cUj%*kv)9N-_k`+qePFwm=&bq6 z==A2Z2J>%{yX0Kw2r1F-9#$>=zW@*2gZJIMiY5ecN;hrj3&6~Wi-*4S*hlw^qviaQTuWs zoBeZV$EVW%$Z0|~4gU3#xbeZBNZt)}639{CEDcJ(i=Ky`9y^&%{sE>Au;xkr%Ptk2 z-?t3Qg&U~;?sNYerva{t5;LG9fnmz)hLXI|4SPB;EAYXhs+jZL=Vw+sORu@oCL2pJICKe>Zpz9y$m8~i%EWm8U6zPG|!qnBypZDKv5hqWxhVO=AJ2mM>GRL%%LpddugPOU#wp$ zI##CMh`>>KG7VL)TIkWBOHCBCe*qlI{h$QNL6KVsRC%ElbROK)H@ttSXSqLV?QUx1 zkYUJi&~_m({t6P_Y!iuMcH7?Erh3f0h}beyVN$Or>YZDjRSR2d6{P_9bH0t~V4LM1 z7%>`>CsbxY_oJT^yr6XOa_MF5B}ZO`5^(iM@713N1{W92(bMxKD_~+q{eLh%Y*l(} zRBPx)5i08vvdQ2uJ-agk|2<2fsuBDPFzwxOtn$+%rjJdgPzM{;DbaP`%cA-|iVA9A zrAYWR95taTL9MP)UuyepIj^o|d77agk54pRdKsJ{EqE>$b8U+C41Iiv_^qwY>kOqt zoSim?61piN2Y5cMCdPdJD5p=(od27E4ua;ilq|Vtc~cf2abH_yz+)u;b`qabm+pFzq)(3bYJ4E25|?j)EJ z@ZRIG>1dt6gK~~~y#YNv&NGn~6DrH~z=6#&LU;B|68sm8FeUAiZVQeV zjt`$EGSH)izlnG2^8w2&%|0}y-#L3aQkU?Ff*|8GE(8Wveo57Yn`w_q>&wc?W*NP+ z9mcG}AOo%*??jP&6U5xgX{}V*y4g|M?~^U=BoP>`RtW+J_~)bQWyTg9)fEo=q~i_j zd#X-Dpk;3b1CsKA2g`)3!Q)*&aflIz8O0MBH(Smyblv;&R*rY6O}4^PzB{=S$1!g2 z^>y=5qc)qifx=U4efF59J;wzGa;pT%AN7Ti+}{ZV6;cvEE|)nQbi-HJY)&EtPG{qv zK6=}n#?-cO1$1F>+}}OX-mxRXQr99P4n3U=3uY%SZw18+BkXG6%neAYWXN@b@W&qn z&sJ;kXEJ;nrP+Fpdup$_aUF|~C}|)pz9*@TRs+MRw&(E&2Mi;6r zTUF{O<=0$oQ{G*>*S}uZl6EsksoD8gv>OH}1b&EGi=`5LJ@x>qpP%z?c;(%e>|T^V zOeCrd%EmbAL%9NNHB~MA1_9L{aQWnsv(o*_7WBuh$tyg4BTtenum7*eKq3jXr@TCB zMXxL`OQ%ZDG4E7HVi2^E-UbEdyHSbU=TXy2Jm+{~A+sc^sCcHs)JwS2h%1;#JQXke z7vSE}DgENl`o4}MmYdnIezScuHR6ozrmTes-cb-XeNRnBdY%_2uMDH>vN^|*rJjS5Z%A7+=MAxg2YANgr(M3gm3`mg!_18Qnq{{R30 diff --git a/handoff/representative-review-dialog.png b/handoff/representative-review-dialog.png deleted file mode 100644 index ac28b5feee228c3f1b685408f1f10dcb3576803d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 69365 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