diff --git a/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.i18n.yaml index d1515fb973..4531b00628 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md -2026-09-17-web-feature-routes-and-route-gate.md: 5c18e23e827c4556c0012147f857f214ecc5641a -2026-09-17-web-feature-routes-and-route-gate.zh.md: 2ab045e288e61eb9ba8dc6df222bf3048a11363a +2026-09-17-web-feature-routes-and-route-gate.md: 3939bdcd0c53e766827e8fcf05f64fb4d284c59b +2026-09-17-web-feature-routes-and-route-gate.zh.md: fb329c8fc1f93b9f979612b7163e838cc521428b diff --git a/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md index 5c18e23e82..3939bdcd0c 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md +++ b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md @@ -28,4 +28,4 @@ The gate reads inline literal fragments only, so a composed reference (`comboRef ## Testing -The gate's own spec pins a negative control per rule: interpolated targets (`${origin}/api/file`), protocol-relative and absolute forms, an unstripped shared key, a relative route resolved against a `location` read, and root-absolute producer fields. Each feature package's specs assert the relative route its browser half sends; `markdown-images.e2e.ts` keeps the local-image path covered end to end. +The gate's own spec pins a negative control per rule: interpolated targets (`${origin}/api/file`), protocol-relative and absolute forms, an unstripped shared key, a relative route resolved against a `location` read, and root-absolute producer fields. Each feature package's specs assert the relative route its browser half sends; `markdown-images.e2e.ts` keeps the local-image path covered end to end. The discovery spec pins which client projects contribute browser sources: a DOM project the Host aggregate also compiles contributes its plain `src/` unless it has a `src/client` half. diff --git a/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.zh.md b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.zh.md index 2ab045e288..fb329c8fc1 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.zh.md @@ -28,4 +28,4 @@ Status: implemented ## 测试 -门禁自身的 spec 为每条规则固定了负向对照:插值目标(`${origin}/api/file`)、协议相对与绝对形式、未剥前导斜杠的共享键、相对路由相对 `location` 读取解析,以及生产者中根绝对的引用字段。各功能包的 spec 断言其浏览器半侧发出的相对路由;`markdown-images.e2e.ts` 端到端维持本地图片路径的覆盖。 +门禁自身的 spec 为每条规则固定了负向对照:插值目标(`${origin}/api/file`)、协议相对与绝对形式、未剥前导斜杠的共享键、相对路由相对 `location` 读取解析,以及生产者中根绝对的引用字段。各功能包的 spec 断言其浏览器半侧发出的相对路由;`markdown-images.e2e.ts` 端到端维持本地图片路径的覆盖。发现逻辑的 spec 固定了哪些 client 项目贡献浏览器源文件:同时被 Host 聚合编译的 DOM 项目,若没有 `src/client` 半边,则其整个 `src/` 都计入。 diff --git a/scripts/verify-client-route-resolution.spec.ts b/scripts/verify-client-route-resolution.spec.ts index 5b14d6201c..5695501363 100644 --- a/scripts/verify-client-route-resolution.spec.ts +++ b/scripts/verify-client-route-resolution.spec.ts @@ -157,15 +157,26 @@ function writeFaceAggregates(root: string, client: readonly string[], host: read } describe('browser face discovery', () => { - it('takes sources from the Client aggregate alone', () => { + it('takes every DOM client project, and only its src/client half when the Host aggregate also compiles it', () => { const root = mkdtempSync(join(tmpdir(), 'dsh-route-face-')) try { - writeProject(root, 'browser-package', { lib: ['ES2024', 'DOM'], sources: ['src/view.ts', 'src/globals.d.ts'] }) - writeProject(root, 'shared-package', { lib: ['ES2024', 'DOM'], sources: ['src/both-faces.ts'] }) - writeProject(root, 'host-package', { lib: ['ES2024'], sources: ['src/host.ts'] }) - writeFaceAggregates(root, ['browser-package', 'shared-package', 'host-package'], ['shared-package', 'host-package']) + writeProject(root, 'packages/client/browser-package', { lib: ['ES2024', 'DOM'], sources: ['src/view.ts', 'src/globals.d.ts'] }) + // Compiles in both aggregates and keeps its browser code in plain `src`. + writeProject(root, 'packages/client/shared-package', { lib: ['ES2024', 'DOM'], sources: ['src/both-faces.ts'] }) + // Compiles in both aggregates but has a `src/client` browser half, so its + // plain `src` (the node half) is out of scope. + writeProject(root, 'packages/client/dual-package', { lib: ['ES2024', 'DOM'], sources: ['src/client/View.tsx', 'src/node.ts'] }) + writeProject(root, 'packages/client/host-package', { lib: ['ES2024'], sources: ['src/host.ts'] }) + writeFaceAggregates(root, [ + 'packages/client/browser-package', 'packages/client/shared-package', + 'packages/client/dual-package', 'packages/client/host-package', + ], ['packages/client/shared-package', 'packages/client/dual-package', 'packages/client/host-package']) - expect(browserFaceSources(root)).toEqual(['browser-package/src/view.ts']) + expect(browserFaceSources(root)).toEqual([ + 'packages/client/browser-package/src/view.ts', + 'packages/client/dual-package/src/client/View.tsx', + 'packages/client/shared-package/src/both-faces.ts', + ]) } finally { rmSync(root, { recursive: true, force: true }) } diff --git a/scripts/verify-client-route-resolution.ts b/scripts/verify-client-route-resolution.ts index 89ac2cf829..db3b91de05 100644 --- a/scripts/verify-client-route-resolution.ts +++ b/scripts/verify-client-route-resolution.ts @@ -256,11 +256,12 @@ export function findRouteResolutionViolations( * * A browser half belongs to `src/client`; a package that keeps its whole UI in * plain `src` is found through the project itself, which carries the Client - * compiler shape (DOM libraries). A project the Host aggregate also compiles - * has a host half rather than a browser-only surface, so only its `src/client` - * tree is browser face. Every such face must contribute at least one source - * file: a face that contributes none means the aggregate's layout moved out - * from under this discovery, which must fail loud rather than scan less. + * compiler shape (DOM libraries). A config the Host aggregate also compiles has + * a host half rather than a browser-only surface, so its plain `src/` is + * scanned only when it has no `src/client` half; that half arrives below. Every + * such face must contribute at least one source file: a face that contributes + * none means the aggregate's layout moved out from under this discovery, which + * must fail loud rather than scan less. * @param projectRoot - repository root to discover from. * @returns sorted repository-relative source paths. */ @@ -274,12 +275,18 @@ export function browserFaceSources(projectRoot: string = root): string[] { const hostConfigs = faceConfigs(projectRoot, 'host').byPath for (const [configPath, parsed] of faceConfigs(projectRoot, 'client').byPath) { - if (parsed.options.lib?.includes('lib.dom.d.ts') !== true || hostConfigs.has(configPath)) continue - const sourceRoot = `${dirname(configPath).replaceAll('\\', '/')}/src/` + if (parsed.options.lib?.includes('lib.dom.d.ts') !== true) continue + const configRoot = dirname(configPath).replaceAll('\\', '/') + const sourceRoot = `${configRoot}/src/` // An aggregate or app shell (the root Client program) carries the compiler // shape but no `src/` tree of its own; its browser sources arrive through // the references and `src/client` discovery below. if (!existsSync(sourceRoot)) continue + // A package the Host aggregate also compiles has a host half; when its + // browser half is `src/client`, plain `src/` may hold the node half, and + // `src/client` alone is scanned below. Without that half, all of `src/` is + // the browser surface. + if (hostConfigs.has(configPath) && existsSync(`${configRoot}/src/client`)) continue const faceSources = parsed.fileNames.filter((file) => { const normalized = file.replaceAll('\\', '/') return normalized.startsWith(sourceRoot) && /\.tsx?$/.test(normalized)