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
This commit is contained in:
unanmed 2026-09-11 15:10:25 +08:00
parent 5c962520ef
commit 07fd6c9786
3 changed files with 198 additions and 43 deletions

View File

@ -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

View File

@ -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);
});
});

View File

@ -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 <legacy-only|client-only|legacy-data|legacy-common>]'
);
}
const fixture = args[1];
if (!Object.hasOwn(fixtureCycles, fixture)) {
throw new Error(`Unknown circular fixture: ${fixture}`);
}
return fixture as FixtureName;
}
async function loadRepositoryCycles(): Promise<readonly CircularCycle[]> {
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<void> {
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);