mirror of
https://github.com/motajs/template.git
synced 2026-09-14 10:58:51 +08:00
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:
parent
5c962520ef
commit
07fd6c9786
@ -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
|
||||
|
||||
|
||||
15
script/check-data-circular.test.ts
Normal file
15
script/check-data-circular.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user