fix(web): scan plain src for a dual-face browser package without src/client

This commit is contained in:
mektpoy
2026-09-18 20:06:44 +08:00
parent 0e23ee6789
commit 771ebf4ace
5 changed files with 35 additions and 17 deletions
@@ -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
@@ -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.
@@ -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/` 都计入。
+17 -6
View File
@@ -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 })
}
+14 -7
View File
@@ -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)