From 07fd6c978667a37a47b511cdd9e2577266b90e6a Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Fri, 11 Sep 2026 15:10:25 +0800 Subject: [PATCH] fix(03-16): scope circular compatibility cycles explicitly - classify only all-compatibility cycles outside the approved gate - preserve fail-closed data and common boundary reporting --- .../03-COMMON-CYCLE-CONTRACT.md | 17 +- script/check-data-circular.test.ts | 15 ++ script/check-data-circular.ts | 209 ++++++++++++++---- 3 files changed, 198 insertions(+), 43 deletions(-) create mode 100644 script/check-data-circular.test.ts diff --git a/.planning/phases/03-data-completion/03-COMMON-CYCLE-CONTRACT.md b/.planning/phases/03-data-completion/03-COMMON-CYCLE-CONTRACT.md index 837fca9..7cf260a 100644 --- a/.planning/phases/03-data-completion/03-COMMON-CYCLE-CONTRACT.md +++ b/.planning/phases/03-data-completion/03-COMMON-CYCLE-CONTRACT.md @@ -47,9 +47,20 @@ consume. Any `common/data-common` cycle and any cycle within that transitive common graph is a gate failure. `@motajs/common` itself must first be proven acyclic; a common-only cycle is not an allowed exception. -Only render-only or explicitly legacy-only cycles outside the four-package and -transitive-common graph remain outside this phase's D-20 scope. This exclusion -does not permit the current common/data-common cycles to remain. +CORR-03-07 makes the compatibility boundary explicit. A cycle is +**compatibility-only** only when every normalized member is under +`packages-user/data-state/src/legacy/` or `packages-user/client-modules/`. +Legacy-only and client-only cycles therefore remain outside scope, but a mixed +legacy+data or legacy+common cycle is in-scope because it contains an approved +data or common member and must fail non-zero. A data-only cycle and a cycle +between `packages/common/` and `data-common` are also in-scope. + +The supported circular-gate fixtures exercise this exact classifier in a +separate process: `legacy-only` and `client-only` exit 0, while `legacy-data` +and `legacy-common` exit non-zero. These fixtures use synthetic cycle members +and do not depend on the current repository graph. This scope correction does +not modify legacy implementation, save architecture, replay/event code, or +user-owned replay-safety decorator placement under S-01 and S-04. ## Approval record diff --git a/script/check-data-circular.test.ts b/script/check-data-circular.test.ts new file mode 100644 index 0000000..dff6150 --- /dev/null +++ b/script/check-data-circular.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { classifyCycle, isInScope } from './check-data-circular'; + +describe('circular gate scope classifier', () => { + // 仅兼容路径组成的 legacy cycle 应被排除在门禁范围之外 + it('classifies a legacy-only cycle as outside scope', () => { + const cycle = [ + 'packages-user\\data-state\\src\\legacy\\move.ts', + 'packages-user/data-state/src/legacy/map.ts' + ]; + + expect(classifyCycle(cycle).compatibilityOnly).toBe(true); + expect(isInScope(cycle)).toBe(false); + }); +}); diff --git a/script/check-data-circular.ts b/script/check-data-circular.ts index 7563f6d..f53709b 100644 --- a/script/check-data-circular.ts +++ b/script/check-data-circular.ts @@ -1,4 +1,5 @@ import madge from 'madge'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { relative, resolve } from 'node:path'; const root = process.cwd(); @@ -9,57 +10,185 @@ const entries = [ 'packages-user/data-state/src/index.ts', 'packages/common/src/index.ts' ]; -const dataPrefixes = [ +const compatibilityPrefixes = [ + 'packages-user/data-state/src/legacy/', + 'packages-user/client-modules/' +]; +const approvedPrefixes = [ 'packages-user/data-common/', 'packages-user/data-base/', 'packages-user/data-system/', - 'packages-user/data-state/' + 'packages/common/' ]; +export type CircularCycle = readonly string[]; + +export interface CycleClassification { + normalizedCycle: CircularCycle; + compatibilityOnly: boolean; + approvedScope: boolean; + inScope: boolean; +} + +export type FixtureName = + | 'legacy-only' + | 'client-only' + | 'legacy-data' + | 'legacy-common'; + +interface FixtureCycles { + readonly [name: string]: readonly CircularCycle[]; +} + +const fixtureCycles: FixtureCycles = { + 'legacy-only': [ + [ + 'packages-user/data-state/src/legacy/map.ts', + 'packages-user/data-state/src/legacy/move.ts' + ] + ], + 'client-only': [ + [ + 'packages-user/client-modules/map.ts', + 'packages-user/client-modules/render.ts' + ] + ], + 'legacy-data': [ + [ + 'packages-user/data-state/src/legacy/move.ts', + 'packages-user/data-base/src/hero/state.ts' + ] + ], + 'legacy-common': [ + [ + 'packages-user/data-state/src/legacy/move.ts', + 'packages/common/src/utils/types.ts' + ] + ] +}; + +function normalizePath(file: string): string { + return file.replaceAll('\\', '/').replace(/^\.\//, ''); +} + +function isCompatibilityPath(file: string): boolean { + return compatibilityPrefixes.some(prefix => file.startsWith(prefix)); +} + +function isApprovedPath(file: string): boolean { + if (file.startsWith('packages-user/data-state/')) { + return !file.startsWith('packages-user/data-state/src/legacy/'); + } + return approvedPrefixes.some(prefix => file.startsWith(prefix)); +} + +export function classifyCycle(cycle: CircularCycle): CycleClassification { + const normalizedCycle = cycle.map(normalizePath); + const compatibilityOnly = normalizedCycle.every(isCompatibilityPath); + const approvedScope = normalizedCycle.some(isApprovedPath); + return { + normalizedCycle, + compatibilityOnly, + approvedScope, + inScope: approvedScope + }; +} + +export function isInScope(cycle: CircularCycle): boolean { + return classifyCycle(cycle).inScope; +} + function toRelativePath(file: string): string { const absolute = resolve(root, file); return relative(root, absolute).replaceAll('\\', '/'); } -function isInScope(cycle: readonly string[]): boolean { - return cycle.some(file => { - const normalized = file.replaceAll('\\', '/'); - return ( - normalized.includes('packages/common/') || - dataPrefixes.some(prefix => normalized.includes(prefix)) +function reportCycles(cycles: readonly CircularCycle[]): number { + const classifications = cycles.map(classifyCycle); + const inScope = classifications.filter( + classification => classification.inScope + ); + const outsideScope = classifications.filter( + classification => !classification.inScope + ); + + console.log( + `Circular diagnostics: ${cycles.length} total, ${inScope.length} in-scope, ${outsideScope.length} outside scope` + ); + for (const [index, classification] of inScope.entries()) { + console.log(`IN-SCOPE CYCLE ${index + 1}:`); + console.log( + classification.normalizedCycle.map(toRelativePath).join(' -> ') ); + } + for (const [index, classification] of outsideScope.entries()) { + console.log(`OUTSIDE-SCOPE CYCLE ${index + 1}:`); + console.log( + classification.normalizedCycle.map(toRelativePath).join(' -> ') + ); + } + + if (inScope.length > 0) { + console.error( + 'Circular gate failed: four-package, common/data-common, or transitive common cycles remain' + ); + return 1; + } + + console.log( + 'Circular gate passed: four data packages and the transitive common boundary are acyclic' + ); + return 0; +} + +function parseFixture(args: readonly string[]): FixtureName | undefined { + if (args.length === 0) { + return undefined; + } + if (args.length !== 2 || args[0] !== '--fixture') { + throw new Error( + 'Usage: pnpm exec tsx script/check-data-circular.ts [--fixture ]' + ); + } + const fixture = args[1]; + if (!Object.hasOwn(fixtureCycles, fixture)) { + throw new Error(`Unknown circular fixture: ${fixture}`); + } + return fixture as FixtureName; +} + +async function loadRepositoryCycles(): Promise { + const graph = await madge(entries, { + baseDir: root, + tsConfig: resolve(root, 'tsconfig.json'), + fileExtensions: ['ts', 'tsx'], + detectiveOptions: { ts: { skipTypeImports: false } } + }); + return graph.circular() as readonly CircularCycle[]; +} + +function isMainModule(): boolean { + return process.argv[1] + ? pathToFileURL(resolve(process.argv[1])).href === import.meta.url + : false; +} + +async function main(): Promise { + const fixture = parseFixture(process.argv.slice(2)); + const cycles = fixture + ? fixtureCycles[fixture] + : await loadRepositoryCycles(); + const exitCode = reportCycles(cycles); + if (exitCode !== 0) { + process.exit(1); + } +} + +if (isMainModule()) { + main().catch(error => { + console.error(error instanceof Error ? error.message : error); + process.exit(2); }); } -const graph = await madge(entries, { - baseDir: root, - tsConfig: resolve(root, 'tsconfig.json'), - fileExtensions: ['ts', 'tsx'], - detectiveOptions: { ts: { skipTypeImports: false } } -}); -const cycles = graph.circular() as readonly (readonly string[])[]; -const inScope = cycles.filter(isInScope); -const outsideScope = cycles.filter(cycle => !isInScope(cycle)); - -console.log( - `Circular diagnostics: ${cycles.length} total, ${inScope.length} in-scope, ${outsideScope.length} outside scope` -); -for (const [index, cycle] of inScope.entries()) { - console.log(`IN-SCOPE CYCLE ${index + 1}:`); - console.log(cycle.map(toRelativePath).join(' -> ')); -} -for (const [index, cycle] of outsideScope.entries()) { - console.log(`OUTSIDE-SCOPE CYCLE ${index + 1}:`); - console.log(cycle.map(toRelativePath).join(' -> ')); -} - -if (inScope.length > 0) { - console.error( - 'Circular gate failed: four-package, common/data-common, or transitive common cycles remain' - ); - process.exit(1); -} - -console.log( - 'Circular gate passed: four data packages and the transitive common boundary are acyclic' -); +export const scriptPath = fileURLToPath(import.meta.url);