Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified archify.zip
Binary file not shown.
1 change: 1 addition & 0 deletions archify/bin/archify.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2765,6 +2765,7 @@ function checkerDiagnostics(checker) {

function compositionFixes(issue) {
if (issue.code === 'composition/viewport-height') return [String(issue.detail || '').replace(/^\[[^\]]+\]\s*/, '')];
if (issue.code === 'composition/crossing-analysis-budget') return issue.supportedFixes || [];
if (issue.code !== 'composition/desktop-readability') return COMPOSITION_FIXES[issue.code] || [];
const sourceFontPx = Number(issue.sourceFontPx);
const actualBudgetPx = Number(issue.availableDiagramWidth);
Expand Down
80 changes: 71 additions & 9 deletions archify/scripts/check-render-output.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ import {
} from '../renderers/shared/desktop-readability.mjs';

const input = process.argv[2];
const DEFAULT_MAX_CROSSING_PAIR_CHECKS = 50_000;
const DEFAULT_MAX_CROSSING_SEGMENT_PAIR_CHECKS = 3_000_000;
const maxCrossingPairChecks = positiveIntegerEnvironment(
'ARCHIFY_CHECK_MAX_CROSSING_PAIR_CHECKS',
DEFAULT_MAX_CROSSING_PAIR_CHECKS,
);
const maxCrossingSegmentPairChecks = positiveIntegerEnvironment(
'ARCHIFY_CHECK_MAX_CROSSING_SEGMENT_PAIR_CHECKS',
DEFAULT_MAX_CROSSING_SEGMENT_PAIR_CHECKS,
);

if (!input || input === '-h' || input === '--help') {
console.error('Usage: node scripts/check-render-output.mjs <diagram.html>');
Expand Down Expand Up @@ -142,7 +152,13 @@ if (svgMatches.length === 1) {
diagonal.length === 0,
diagonal.map(({ arrow, segmentIndex }) => `${arrow.kind} ${arrow.index} segment ${segmentIndex + 1}: expected an orthogonal segment or an explicitly authored direct straight route; ${arrow.raw}`),
);
const measuredRelationshipCrossings = collectRelationshipCrossings(arrows, workflowV2);
const crossingAnalysis = collectRelationshipCrossings(
arrows,
workflowV2,
maxCrossingPairChecks,
maxCrossingSegmentPairChecks,
);
const measuredRelationshipCrossings = crossingAnalysis.crossings;
const resolvedCrossovers = measuredRelationshipCrossings.filter((hit) => (
hit.left.crossoverHalo && hit.right.crossoverHalo
));
Expand Down Expand Up @@ -213,7 +229,8 @@ if (svgMatches.length === 1) {
const desktopReadabilityIsError = qualityProfile === 'showcase';
// Certain geometry, but new: surface it as evidence first (CONTRIBUTING.md#product-and-compatibility-contracts).
const viewportHeightIsError = false;
const compositionErrors = (qualityGatesEnforced ? containerBorderRuns.length : 0)
const compositionErrors = (crossingAnalysis.exhausted ? 1 : 0)
+ (qualityGatesEnforced ? containerBorderRuns.length : 0)
+ (crossingIsError ? relationshipCrossings.length : 0)
+ (corridorIsError ? ambiguousCorridors.length + arrowheadCollisions.length : 0)
+ (labelClearanceIsError ? labelRouteClearance.length : 0)
Expand Down Expand Up @@ -328,6 +345,17 @@ if (svgMatches.length === 1) {
otherRelationship: relationshipRecord(hit.right),
point: hit.point.map((value) => Math.round(value * 10) / 10),
})),
...(crossingAnalysis.exhausted ? [{
severity: 'error',
code: 'composition/crossing-analysis-budget',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Expose the budget recovery in public diagnostics

When this budget is hit through the documented archify deliver <type> <input.json> ... [--json] or finalize flows, checkerDiagnostics() calls compositionFixes(), but archify/bin/archify.mjs:2675-2683,2727-2729 has no mapping for this new code. With ARCHIFY_CHECK_MAX_CROSSING_PAIR_CHECKS=1 on event-stream.dataflow.json, the emitted diagnostic had supportedFixes: [], leaving the rerun instruction only in evidence.detail; add this code to the mapping so agent-facing failures expose the executable recovery in supportedFixes.

AGENTS.md reference: AGENTS.md:L3-L3

Useful? React with 👍 / 👎.

attemptedPairs: crossingAnalysis.attemptedPairs,
attemptedSegmentPairs: crossingAnalysis.attemptedSegmentPairs,
pairLimit: maxCrossingPairChecks,
segmentPairLimit: maxCrossingSegmentPairChecks,
exhaustedBudget: crossingAnalysis.exhaustedBudget,
detail: crossingBudgetDetail(crossingAnalysis),
supportedFixes: crossingBudgetFixes(crossingAnalysis),
}] : []),
...ambiguousCorridors.map((hit) => ({
severity: corridorIsError ? 'error' : 'warning',
code: 'composition/ambiguous-corridor',
Expand Down Expand Up @@ -399,10 +427,10 @@ if (svgMatches.length === 1) {
);
addCheck(
'relationship_crossings',
!crossingIsError || relationshipCrossings.length === 0,
relationshipCrossings.map((hit) => (
!crossingAnalysis.exhausted && (!crossingIsError || relationshipCrossings.length === 0),
[...relationshipCrossings.map((hit) => (
`[composition/proper-crossing] ${qualityProfile} ${relationshipName(hit.left)} crosses ${relationshipName(hit.right)} at [${formatPoint(hit.point)}]`
)),
)), ...(crossingAnalysis.exhausted ? [`[composition/crossing-analysis-budget] ${crossingBudgetDetail(crossingAnalysis)}`] : [])],
);
addCheck(
'relationship_corridors',
Expand Down Expand Up @@ -734,14 +762,21 @@ function parseRoutePoints(value) {
return points.length >= 2 && points.every(isPoint) ? points : null;
}

function collectRelationshipCrossings(arrows, includeSharedEndpoints = false) {
const relationships = arrows.filter((arrow) => arrow.from && arrow.to && arrow.segments.length).map(arrow => ({
function collectRelationshipCrossings(
arrows,
includeSharedEndpoints = false,
pairLimit = DEFAULT_MAX_CROSSING_PAIR_CHECKS,
segmentPairLimit = DEFAULT_MAX_CROSSING_SEGMENT_PAIR_CHECKS,
) {
const relationships = arrows.filter((arrow) => arrow.from && arrow.to).map(arrow => ({
...arrow,
// Readable-v2 routePoints come from the visible path, never its metadata.
// A straight-through via is not a visual endpoint; preserve real bends.
segments: includeSharedEndpoints ? arrow.routeSubpaths.flatMap(forwardCollinearAnalysisSegments) : arrow.segments,
}));
})).filter((arrow) => arrow.segments.length);
const crossings = [];
let attemptedPairs = 0;
let attemptedSegmentPairs = 0;
for (let leftIndex = 0; leftIndex < relationships.length; leftIndex += 1) {
const left = relationships[leftIndex];
for (let rightIndex = leftIndex + 1; rightIndex < relationships.length; rightIndex += 1) {
Expand All @@ -752,9 +787,17 @@ function collectRelationshipCrossings(arrows, includeSharedEndpoints = false) {
&& !includeSharedEndpoints
&& !(left.independentPorts && right.independentPorts)
&& !(left.automaticWorkflowRoute && right.automaticWorkflowRoute)) continue;
if (attemptedPairs >= pairLimit) {
return { crossings, attemptedPairs, attemptedSegmentPairs, exhausted: true, exhaustedBudget: 'relationship-pairs' };
}
attemptedPairs += 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Charge the budget for segment-pair checks

When either eligible relationship contains many path segments (for example, two imported SVG routes with thousands of supported Q commands), collectRelationshipCrossings() charges just one unit here and then executes the full Cartesian product at lines 764-766, so ARCHIFY_CHECK_MAX_CROSSING_PAIR_CHECKS=1 provides no time bound. On the candidate, an 88 KB two-route/2,500-Q fixture exceeded a 5 s timeout without producing a receipt; account for segment-pair tests (or otherwise cap them) so a single relationship pair cannot bypass the new fail-closed budget.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip pairs with no analyzable segments

In a readable-v2 artifact, a supported path consisting only of a non-collinear Q has arrow.segments, so it survives the filter, but the replacement at line 749 gives it an empty crossing-analysis segment list. This increment still charges every pair of such no-op relationships: with ARCHIFY_CHECK_MAX_CROSSING_PAIR_CHECKS=1, three disjoint Q-only routes now return composition/crossing-analysis-budget although the inner loop performs zero segment tests (the parent commit passes the same fixture), and the default similarly rejects 317 such routes. Filter after the replacement or skip pairs whose transformed segment list is empty.

AGENTS.md reference: AGENTS.md:L3-L3

Useful? React with 👍 / 👎.

let point = null;
for (const leftSegment of left.segments) {
for (const rightSegment of right.segments) {
if (attemptedSegmentPairs >= segmentPairLimit) {
return { crossings, attemptedPairs, attemptedSegmentPairs, exhausted: true, exhaustedBudget: 'segment-pairs' };
}
attemptedSegmentPairs += 1;
point = properSegmentIntersection(leftSegment.start, leftSegment.end, rightSegment.start, rightSegment.end);
if (point) break;
}
Expand All @@ -763,7 +806,26 @@ function collectRelationshipCrossings(arrows, includeSharedEndpoints = false) {
if (point) crossings.push({ left, right, point });
}
}
return crossings;
return { crossings, attemptedPairs, attemptedSegmentPairs, exhausted: false, exhaustedBudget: null };
}

function crossingBudgetDetail(analysis) {
const attempted = analysis.exhaustedBudget === 'segment-pairs'
? `${analysis.attemptedSegmentPairs} segment pairs (limit ${maxCrossingSegmentPairChecks}) after ${analysis.attemptedPairs} relationship pairs`
: `${analysis.attemptedPairs} relationship pairs (limit ${maxCrossingPairChecks}) after ${analysis.attemptedSegmentPairs} segment pairs`;
return `Crossing analysis stopped after ${attempted}; inspection is incomplete.`;
}

function crossingBudgetFixes(analysis) {
return analysis.exhaustedBudget === 'segment-pairs'
? ['increase ARCHIFY_CHECK_MAX_CROSSING_SEGMENT_PAIR_CHECKS for this known-large artifact and rerun the check']
: ['increase ARCHIFY_CHECK_MAX_CROSSING_PAIR_CHECKS for this known-large artifact and rerun the check'];
}

function positiveIntegerEnvironment(name, fallback) {
const value = process.env[name];
if (value === undefined || value === '') return fallback;
return /^[1-9]\d*$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : fallback;
}

function relationshipName(arrow) {
Expand Down
2 changes: 1 addition & 1 deletion test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -900,7 +900,7 @@ test('cli: deliver preserves the previous artifact when the final check fails',

test('cli: deliver accepts a dense artifact whose checker receipt exceeds the 1 MiB default buffer', { timeout: 60000 }, () => {
const input = path.join(tmp, 'dense-receipt.architecture.json');
fs.writeFileSync(input, JSON.stringify(bipartiteArchitectureSpec(8)));
fs.writeFileSync(input, JSON.stringify(bipartiteArchitectureSpec(10)));
const out = path.join(tmp, 'dense-receipt.html');
// Scrub the knob so this test exercises the default limit even when a
// developer or CI environment overrides it.
Expand Down
18 changes: 18 additions & 0 deletions test/helpers/crossing-budget-fixture.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import fs from 'node:fs';
import path from 'node:path';

const output = process.argv[2];
if (!output) throw new Error('Usage: node test/helpers/crossing-budget-fixture.mjs <output.html>');

const arrows = Array.from({ length: 120 }, (_, index) => {
const y = 10 + index * 3;
const points = Array.from({ length: 101 }, (_, step) => [
20 + Math.ceil(step / 2) * 20,
y + Math.floor(step / 2) % 2,
]);
const d = `M${points.map(([x, pointY], step) => step ? `L${x} ${pointY}` : `${x} ${pointY}`).join('')}`;
return `<path id="r${index}" data-edge-id="r${index}" data-edge-from="from-${index}" data-edge-to="to-${index}" d="${d}" class="a-default" marker-end="url(#arrowhead)"/>`;
}).join('');

fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
fs.writeFileSync(output, `<!doctype html><svg viewBox="0 0 2100 400" data-quality-profile="standard">${arrows}</svg>`);
125 changes: 121 additions & 4 deletions test/svg-path-checker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,18 @@ function killTree(child) {
}
}

async function check(t, body, { publicCli = false, rootAttrs = '', profile = 'showcase' } = {}) {
async function check(t, body, { publicCli = false, rootAttrs = '', profile = 'showcase', env = {} } = {}) {
const file = path.join(scratch, `${sequence++}.html`);
fs.writeFileSync(file, `<!doctype html><html><body><svg viewBox="0 0 400 250" data-quality-profile="${profile}" ${rootAttrs}>${body}</svg></body></html>`);
const args = publicCli ? [cli, 'check', file, '--json'] : [checker, file];
return execute(t, args, body);
return execute(t, args, body, deadlineMs, env);
}

async function execute(t, args, description, timeoutMs = deadlineMs) {
async function execute(t, args, description, timeoutMs = deadlineMs, env = {}) {
const child = spawn(process.execPath, args, {
detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ARCHIFY_UPDATE_CHECK_DISABLED: '1' },
env: { ...process.env, ARCHIFY_UPDATE_CHECK_DISABLED: '1', ...env },
});
let stdout = '', stderr = '', timedOut = false, exited = false, spawnError, cleanupError;
child.stdout.on('data', chunk => { stdout += chunk; });
Expand Down Expand Up @@ -143,6 +143,20 @@ test('正常二次曲线继续受检,不能为拒绝其他曲线而禁止已
assertPass(await check(t, arrow('m20 20l60 0q20 0 20 20v80')));
});

test('readable-v2 的纯 Q 路线不消耗交叉关系对预算', async t => {
const body = [
arrow('M20 20Q40 40 60 20', { id: 'one', from: 'a', to: 'b' }),
arrow('M20 60Q40 80 60 60', { id: 'two', from: 'c', to: 'd' }),
arrow('M20 100Q40 120 60 100', { id: 'three', from: 'e', to: 'f' }),
].join('');
const result = await check(t, body, {
rootAttrs: 'data-layout-contract="readable-v2"',
env: { ARCHIFY_CHECK_MAX_CROSSING_PAIR_CHECKS: '1' },
});
assertPass(result);
assert.equal(result.receipt.composition.metrics.properCrossings, 0);
});

test('readable-v2 的圆角前后直线不生成虚假跨越,采样点不成为微线段', async t => {
const rounded = arrow('M20 20L80 20Q100 20 100 40L100 120');
const other = arrow('M50 70H96', { id: 'other', from: 'c', to: 'd', extra: 'data-edge-key="1"' });
Expand Down Expand Up @@ -196,6 +210,109 @@ for (const rootAttrs of ['', 'data-layout-contract="readable-v2"']) {
});
}

test('交叉分析预算耗尽时以失败回执结束,不把未完成检查当作通过', async t => {
const body = [
arrow('M20 20H80', { id: 'one', from: 'a', to: 'b' }),
arrow('M20 60H80', { id: 'two', from: 'c', to: 'd' }),
arrow('M20 100H80', { id: 'three', from: 'e', to: 'f' }),
].join('');
const result = await check(t, body, {
env: { ARCHIFY_CHECK_MAX_CROSSING_PAIR_CHECKS: '1' },
});
assert.equal(result.code, 1);
const crossingCheck = result.receipt.checks.find(item => item.name === 'relationship_crossings');
assert.equal(crossingCheck.ok, false);
assert.match(crossingCheck.details.join('\n'), /crossing-analysis-budget.*limit 1/);
assert.equal(
result.receipt.composition.issues.find(item => item.code === 'composition/crossing-analysis-budget')?.attemptedPairs,
1,
);
assert.match(
result.receipt.composition.issues.find(item => item.code === 'composition/crossing-analysis-budget')?.supportedFixes?.join('\n') || '',
/ARCHIFY_CHECK_MAX_CROSSING_PAIR_CHECKS/,
);
});

test('交叉分析关系对预算只接受安全正整数环境变量', async t => {
// 318 relationships produce 50,403 eligible pairs, so invalid overrides
// must fall back to the production 50,000-pair limit rather than merely
// differing from an explicitly tiny valid override.
const body = Array.from({ length: 318 }, (_, index) => arrow('M20 20H80', {
id: `fallback-${index}`,
from: `source-${index}`,
to: `target-${index}`,
})).join('');
for (const [value, expectedLimit] of [
['', 50_000],
['not-a-number', 50_000],
['0', 50_000],
['1.5', 50_000],
['9007199254740992', 50_000],
['1', 1],
]) {
const result = await check(t, body, {
env: { ARCHIFY_CHECK_MAX_CROSSING_PAIR_CHECKS: value },
});
assert.equal(result.code, 1);
const issue = result.receipt.composition.issues.find(item => item.code === 'composition/crossing-analysis-budget');
assert.equal(issue?.exhaustedBudget, 'relationship-pairs');
assert.equal(issue?.attemptedPairs, expectedLimit);
assert.match(issue?.detail || '', new RegExp(`relationship pairs \\(limit ${expectedLimit}\\)`));
}
});

test('交叉分析限制每个关系对内的线段乘积,并给出可执行的恢复指引', async t => {
const result = await check(t, [
arrow('M20 20H100V40H20V60H100', { id: 'many-left', from: 'a', to: 'b' }),
arrow('M200 120H280V140H200V160H280', { id: 'many-right', from: 'c', to: 'd' }),
].join(''), {
env: { ARCHIFY_CHECK_MAX_CROSSING_SEGMENT_PAIR_CHECKS: '1' },
publicCli: true,
});
assert.equal(result.code, 1);
const issue = result.receipt.composition.issues.find(item => item.code === 'composition/crossing-analysis-budget');
assert.equal(issue?.exhaustedBudget, 'segment-pairs');
assert.equal(issue?.attemptedPairs, 1);
assert.equal(issue?.attemptedSegmentPairs, 1);
assert.match(issue?.detail || '', /segment pairs \(limit 1\)/);
assert.match(issue?.supportedFixes?.join('\n') || '', /ARCHIFY_CHECK_MAX_CROSSING_SEGMENT_PAIR_CHECKS/);
});

test('公共 deliver CLI 将交叉预算恢复指引写入诊断', async t => {
const output = path.join(scratch, `${sequence++}-event-stream.html`);
const result = await execute(t, [
cli,
'deliver',
'dataflow',
path.join(skillRoot, 'examples', 'event-stream.dataflow.json'),
output,
'--json',
], 'event-stream dataflow with an intentionally exhausted crossing budget', deadlineMs, {
ARCHIFY_CHECK_MAX_CROSSING_PAIR_CHECKS: '1',
});
assert.equal(result.code, 1);
const diagnostic = result.receipt.diagnostics.find(item => item.code === 'composition/crossing-analysis-budget');
assert.match(diagnostic?.supportedFixes?.join('\n') || '', /ARCHIFY_CHECK_MAX_CROSSING_PAIR_CHECKS/);
});

test('公共 deliver CLI 保留线段对预算的特定恢复指引', async t => {
const output = path.join(scratch, `${sequence++}-event-stream-segment-budget.html`);
const result = await execute(t, [
cli,
'deliver',
'dataflow',
path.join(skillRoot, 'examples', 'event-stream.dataflow.json'),
output,
'--json',
], 'event-stream dataflow with an intentionally exhausted segment-pair budget', deadlineMs, {
ARCHIFY_CHECK_MAX_CROSSING_SEGMENT_PAIR_CHECKS: '1',
});
assert.equal(result.code, 1);
const diagnostic = result.receipt.diagnostics.find(item => item.code === 'composition/crossing-analysis-budget');
assert.match(diagnostic?.supportedFixes?.join('\n') || '', /ARCHIFY_CHECK_MAX_CROSSING_SEGMENT_PAIR_CHECKS/);
assert.doesNotMatch(diagnostic?.supportedFixes?.join('\n') || '', /ARCHIFY_CHECK_MAX_CROSSING_PAIR_CHECKS/);
});

test('Z 回到当前子路径起点,后续相对 m 不沿旧终点移动', async t => {
const d = 'M20 20H100V100H20Z m0 120h60';
const other = arrow('M10 180H30', { id: 'other', from: 'c', to: 'd' });
Expand Down
Loading