Compare commits

..
Author SHA1 Message Date
jarvis-at-skic 0248baa97b fix: [verified] separate Target lifecycle channels and export typography
Build and publish Eldorado documentation / build (pull_request) Successful in 50s
2026-10-05 21:11:29 +00:00
jarvis-at-skic 7e3696d2d7 Merge pull request 'fix: share Target central-agent edge midpoints' (#26) from fix/target-midpoint-connectors into main
Build and publish Eldorado documentation / build (push) Successful in 40s
2026-10-05 13:14:33 -07:00
jarvis-at-skic 15c5dcb31d fix: align Target governance links to shared edge midpoints
Build and publish Eldorado documentation / build (pull_request) Successful in 38s
2026-10-05 20:12:20 +00:00
jarvis-at-skic 64b2d6dae8 Merge pull request 'docs: rename Target contributors to AI Agent' (#25) from docs/target-ai-agent into main
Build and publish Eldorado documentation / build (push) Successful in 45s
2026-10-05 12:54:21 -07:00
10 changed files with 309 additions and 14 deletions

No files matched your search

+8 -8
View File
@@ -23,14 +23,14 @@
],
"connections": [
{"id":"govern-scope","from":"agent","to":"scope","label":"","variant":"security","fromSide":"right","toSide":"bottom","via":[[750,495],[750,292.5],[600,292.5]]},
{"id":"govern-architecture","from":"agent","to":"architecture","label":"","variant":"security","fromSide":"right","toSide":"bottom","via":[[765,495],[765,345],[870,345]]},
{"id":"govern-ui-ux","from":"agent","to":"ui-ux","label":"","variant":"security","fromSide":"right","toSide":"left"},
{"id":"govern-planning","from":"agent","to":"planning","label":"","variant":"security","fromSide":"right","toSide":"top","via":[[765,495],[765,690],[870,690]]},
{"id":"govern-delivery","from":"agent","to":"delivery","label":"","variant":"security","fromSide":"bottom","toSide":"top"},
{"id":"govern-uat","from":"agent","to":"uat","label":"","variant":"security","fromSide":"left","toSide":"top","via":[[435,495],[435,690],[330,690]]},
{"id":"govern-security","from":"agent","to":"security","label":"","variant":"security","fromSide":"left","toSide":"right"},
{"id":"govern-releases","from":"agent","to":"releases","label":"","variant":"security","fromSide":"left","toSide":"bottom","via":[[435,495],[435,345],[330,345]]},
{"id":"govern-scope","from":"agent","to":"scope","label":"","variant":"security","fromSide":"top","toSide":"bottom","route":"straight"},
{"id":"govern-architecture","from":"agent","to":"architecture","label":"","variant":"security","fromSide":"top","toSide":"bottom","via":[[600,345],[870,345]]},
{"id":"govern-ui-ux","from":"agent","to":"ui-ux","label":"","variant":"security","fromSide":"right","toSide":"left","route":"straight"},
{"id":"govern-planning","from":"agent","to":"planning","label":"","variant":"security","fromSide":"bottom","toSide":"top","via":[[600,645],[870,645]]},
{"id":"govern-delivery","from":"agent","to":"delivery","label":"","variant":"security","fromSide":"bottom","toSide":"top","route":"straight"},
{"id":"govern-uat","from":"agent","to":"uat","label":"","variant":"security","fromSide":"bottom","toSide":"top","via":[[600,645],[330,645]]},
{"id":"govern-security","from":"agent","to":"security","label":"","variant":"security","fromSide":"left","toSide":"right","route":"straight"},
{"id":"govern-releases","from":"agent","to":"releases","label":"","variant":"security","fromSide":"top","toSide":"bottom","via":[[600,345],[330,345]]},
{"id":"stage-1","from":"scope","to":"architecture","label":"Then","variant":"emphasis","fromSide":"right","toSide":"left"},
{"id":"stage-2","from":"architecture","to":"ui-ux","label":"Then","variant":"emphasis","fromSide":"bottom","toSide":"top"},
{"id":"stage-3","from":"ui-ux","to":"planning","label":"Then","variant":"emphasis","fromSide":"bottom","toSide":"top"},
+1 -1
View File
@@ -15,7 +15,7 @@
"verify:build": "node scripts/verify.mjs --build",
"diagrams:build": "node scripts/build-diagrams.mjs",
"test": "node --test scripts/*.test.mjs",
"test:browser": "node scripts/browser/strategy-story.cjs && node scripts/browser/inline-controls.cjs && node scripts/browser/sdlc-cycle.cjs && node scripts/browser/skills-measurement.cjs && node scripts/browser/roadmap.cjs",
"test:browser": "node scripts/browser/strategy-story.cjs && node scripts/browser/inline-controls.cjs && node scripts/browser/sdlc-cycle.cjs && node scripts/browser/target-midpoints.cjs && node scripts/browser/skills-measurement.cjs && node scripts/browser/roadmap.cjs",
"kpi:example": "node scripts/ai-kpi.mjs"
},
"dependencies": {
+5 -2
View File
@@ -28,8 +28,11 @@ const fs=require('node:fs');
const close=panel.getByRole('button',{name:'Close full-screen diagram',exact:true});await close.waitFor({state:'visible'});
assert.equal(await close.innerText(),'×');assert(await close.evaluate(n=>n===document.activeElement));
const box=await close.boundingBox();assert.equal(box.width,44);assert.equal(box.height,44);
await close.click();assert(await opener.evaluate(n=>n===document.activeElement));
await opener.click();await close.waitFor({state:'visible'});await page.keyboard.press('Escape');await page.waitForFunction(()=>!document.querySelector('.archify-panel--story dialog').open);assert(await opener.evaluate(n=>n===document.activeElement));
await close.click();await panel.locator('dialog iframe').waitFor({state:'detached'});assert(await opener.evaluate(n=>n===document.activeElement));
await opener.click();await close.waitFor({state:'visible'});await page.keyboard.press('Escape');await page.waitForFunction(()=>!document.querySelector('.archify-panel--story dialog').open);
// Native close clears open before the queued React onClose restores focus.
// Wait for its unmount before focusing the next keyboard interaction target.
await panel.locator('dialog iframe').waitFor({state:'detached'});assert(await opener.evaluate(n=>n===document.activeElement));
if(id !== 'measurement') {
const summary=page.locator('.strategy-content > details > summary').first();
await summary.focus();await page.keyboard.press('Enter');
+185
View File
@@ -0,0 +1,185 @@
const {chromium} = require('playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const {pathToFileURL} = require('node:url');
const {resolve} = require('node:path');
// Inspect the rendered paths, not just the authored JSON. Also runs on downloaded SVGs.
function inspect({mobile}) {
const svg = document.querySelector('svg[role="img"]');
const point = (element, p) => new DOMPoint(p.x, p.y).matrixTransform(element.getCTM());
const boxes = [...svg.querySelectorAll('[data-node-id]')].map(node => {
const rect = node.querySelector('rect'), box = rect.getBBox();
const a = point(rect, box), b = point(rect, {x:box.x + box.width, y:box.y + box.height});
return {id:node.dataset.nodeId, x:a.x, y:a.y, right:b.x, bottom:b.y, cx:(a.x+b.x)/2, cy:(a.y+b.y)/2};
});
const agent = boxes.find(n => n.id === 'agent');
const errors = [], endpoints = {};
const phases = ['scope','architecture','ui-ux','planning','delivery','uat','security','releases'];
const edges = [...svg.querySelectorAll('path[data-edge-id]')];
if (edges.length !== 24) errors.push(`expected 24 graph edges, got ${edges.length}`);
phases.forEach((phase, index) => {
for (const [id, from, to] of [
[`govern-${phase}`,'agent',phase],
[`stage-${index+1}`,phase,phases[(index+1)%phases.length]],
[`contribute-${phase}`,`code-${phase}`,phase],
]) {
const matches = edges.filter(p => p.dataset.edgeId === id);
if (matches.length !== 1 || matches[0].dataset.edgeFrom !== from || matches[0].dataset.edgeTo !== to || !matches[0].getAttribute('marker-end') || matches[0].getAttribute('marker-start')) errors.push(`${id}: graph or direction changed`);
}
});
// CTMs put boxes and paths in the same viewport coordinate system.
// 0.01 covers floating-point error against bounds, NOT source spreading:
// shared endpoints below must be exactly identical.
const paths = [...svg.querySelectorAll('path[data-edge-id^="govern-"]')];
if (paths.length !== 8) errors.push(`expected eight governance paths, got ${paths.length}`);
for (const path of paths) {
const id = path.dataset.edgeTo;
const side = mobile ? 'left' : ['scope','architecture','releases'].includes(id) ? 'top'
: ['planning','delivery','uat'].includes(id) ? 'bottom' : id === 'security' ? 'left' : 'right';
const expected = {top:[agent.cx,agent.y],bottom:[agent.cx,agent.bottom],left:[agent.x,agent.cy],right:[agent.right,agent.cy]}[side];
const start = point(path,path.getPointAtLength(0));
endpoints[id] = [start.x,start.y];
if (Math.hypot(start.x-expected[0],start.y-expected[1]) > 0.01) errors.push(`${id}: wrong ${side} midpoint`);
const length = path.getTotalLength();
const target = boxes.find(n => n.id === id);
const destination = mobile ? [target.x,target.cy]
: side === 'top' ? [target.cx,target.bottom] : side === 'bottom' ? [target.cx,target.y]
: side === 'left' ? [target.right,target.cy] : [target.x,target.cy];
const end = point(path,path.getPointAtLength(length));
if (Math.hypot(end.x-destination[0],end.y-destination[1]) > 0.01) errors.push(`${id}: destination midpoint changed`);
for (let distance = 1; distance < length; distance += 2) {
const p = point(path,path.getPointAtLength(distance));
for (const box of boxes) {
if (box.id === id) continue;
if (p.x > box.x+0.1 && p.x < box.right-0.1 && p.y > box.y+0.1 && p.y < box.bottom-0.1) {
errors.push(`${id}: crosses ${box.id}`); break;
}
}
}
}
for (const group of mobile ? [phases] : [['scope','architecture','releases'],['planning','delivery','uat']]) {
const first = endpoints[group[0]];
if (group.some(id => !endpoints[id] || endpoints[id][0] !== first?.[0] || endpoints[id][1] !== first?.[1])) errors.push(`source-port spreading: ${group.join(',')}`);
}
const lifecycle = [];
if (!mobile) {
for (const [id, x, y1, y2, peer] of [
['stage-2',850,262.5,457.5,'architecture'], ['stage-3',850,532.5,727.5,'planning'],
['stage-6',350,727.5,532.5,'uat'], ['stage-7',350,457.5,262.5,'releases'],
]) {
const path = svg.querySelector(`path[data-edge-id="${id}"]`);
if (!path) {errors.push(`${id}: missing lifecycle path`); continue;}
const length = path.getTotalLength();
const a = path.getPointAtLength(0), b = path.getPointAtLength(length);
// Local SVG coordinates test exact offsets; transformed coordinates test
// node clearance at any inline/fullscreen/export scale.
if (Math.hypot(a.x-x,a.y-y1) > 0.01 || Math.hypot(b.x-x,b.y-y2) > 0.01) errors.push(`${id}: wrong shifted endpoints`);
if (!path.getAttribute('marker-end')) errors.push(`${id}: missing direction`);
const governance = paths.find(p => p.dataset.edgeTo === peer);
if (!governance) {errors.push(`${id}: missing governance peer ${peer}`); continue;}
const end = governance.getPointAtLength(governance.getTotalLength());
if (Math.abs(Math.abs(a.x-end.x)-20) > 0.01) errors.push(`${id}: wrong attachment clearance`);
for (let distance = 1; distance < length; distance += 2) {
const local = path.getPointAtLength(distance), p = point(path,local);
if (Math.abs(local.x-x) > 0.01) errors.push(`${id}: channel not straight`);
for (const box of boxes) {
if (p.x > box.x+0.1 && p.x < box.right-0.1 && p.y > box.y+0.1 && p.y < box.bottom-0.1) errors.push(`${id}: crosses ${box.id}`);
}
}
// No positive-length collinear overlap with ANY governance segment.
// Perpendicular crossings remain legitimate in this fixed radial layout.
for (const govern of paths) {
const total = govern.getTotalLength();
for (let d = 0; d < total - 1; d += 1) {
const p = govern.getPointAtLength(d), q = govern.getPointAtLength(d+1);
if (Math.abs(p.x-x) < 0.01 && Math.abs(q.x-x) < 0.01 && Math.min(Math.max(p.y,q.y),Math.max(y1,y2)) - Math.max(Math.min(p.y,q.y),Math.min(y1,y2)) > 0.01) errors.push(`${id}: overlaps ${govern.dataset.edgeId}`);
}
}
lifecycle.push({id,start:[a.x,a.y],end:[b.x,b.y],attachmentClearance:Math.abs(a.x-end.x)});
}
}
return {errors:[...new Set(errors)],endpoints,lifecycle};
}
function inspectTypography({mobile}) {
const errors = [];
const nodes = [...document.querySelectorAll('[data-node-id]')].map(node => {
const box = node.querySelector('rect').getBoundingClientRect();
const texts = [...node.querySelectorAll(':scope > text')];
const bounds = texts.map(text => text.getBoundingClientRect());
texts.forEach((text, index) => {
const expected = text.hasAttribute('data-node-label') ? (mobile ? 38 : 27) : (mobile ? 28 : 21);
if (parseFloat(getComputedStyle(text).fontSize) !== expected) errors.push(`${node.id}: wrong text size`);
const b = bounds[index];
if (b.width <= 0 || b.height <= 0 || b.x < box.x - 1 || b.right > box.right + 1 || b.y < box.y - 1 || b.bottom > box.bottom + 1) errors.push(`${node.id}: text outside node`);
});
if (bounds.length === 2 && bounds[0].bottom > bounds[1].top) errors.push(`${node.id}: title/subtitle overlap`);
return {id:node.id, text:texts.map(t => t.textContent), sizes:texts.map(t => getComputedStyle(t).fontSize)};
});
if (nodes.length !== 17) errors.push(`expected 17 nodes, got ${nodes.length}`);
return {errors, nodes};
}
(async () => {
const base = process.argv[2] || 'http://127.0.0.1:4173';
const output = `.cache/midpoints-${process.argv[3] || 'local'}`;
fs.mkdirSync(output,{recursive:true});
const browser = await chromium.launch({headless:true, executablePath:process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,args:['--no-sandbox']});
const results = [];
try {
for (const width of [1440,390,320]) {
const page = await browser.newPage({viewport:{width,height:1000}});
await page.goto(`${base}/eldorado-v1/documentation/strategy/target-operating-model/`,{waitUntil:'networkidle'});
const panel = page.locator('[data-diagram="strategy-target"]');
const iframe = panel.locator(`:scope > .story-${width > 700 ? 'desktop' : 'mobile'}`);
await iframe.scrollIntoViewIfNeeded();
const frame = await (await iframe.elementHandle()).contentFrame();
await frame.locator('svg[role="img"]').waitFor();
const inline = await frame.evaluate(inspect,{mobile:width < 700});
assert.deepEqual(inline.errors,[]);
await page.screenshot({path:`${output}/${width}-inline.png`,fullPage:true});
const exported = [];
for (const theme of ['light','dark']) {
if (await frame.locator('html').getAttribute('data-theme') !== theme) await frame.locator('#btn-theme').click();
await frame.waitForFunction(t => document.documentElement.dataset.theme === t, theme);
await frame.locator('#btn-export').click();
const [download] = await Promise.all([page.waitForEvent('download'),frame.locator('[data-format="svg"]').click()]);
assert.equal(await download.failure(),null);
const path = `${output}/${width}-${theme}.svg`;
await download.saveAs(path);
// A separate SVG document has no viewer CSS to hide export regressions.
const standalone = await browser.newPage({viewport:{width:1440,height:1100},colorScheme:theme});
await standalone.goto(pathToFileURL(resolve(path)).href);
assert.equal(await standalone.evaluate(() => document.contentType),'image/svg+xml');
await standalone.evaluate(() => document.fonts.ready);
const geometry = await standalone.evaluate(inspect,{mobile:width < 700});
const typography = await standalone.evaluate(inspectTypography,{mobile:width < 700});
assert.deepEqual(geometry.errors,[]);
assert.deepEqual(typography.errors,[]);
const background = await standalone.locator('rect.c-bg-rect').evaluate(n => getComputedStyle(n).fill);
if (exported.length) assert.notEqual(background,exported[0].background,'standalone theme must change');
// CDP captures SVG documents without Playwright's HTML-body screenshot wait.
const client = await standalone.context().newCDPSession(standalone);
const size = await standalone.evaluate(() => ({width:document.documentElement.width.baseVal.value,height:document.documentElement.height.baseVal.value}));
const image = await client.send('Page.captureScreenshot',{format:'png',captureBeyondViewport:true,clip:{x:0,y:0,...size,scale:1}});
fs.writeFileSync(`${output}/${width}-${theme}-standalone.png`,Buffer.from(image.data,'base64'));
exported.push({theme,background,geometry,typography});
await standalone.close();
}
await panel.getByRole('button',{name:/^Expand /}).click();
const full = await (await panel.locator('dialog iframe').elementHandle()).contentFrame();
await full.locator('svg[role="img"]').waitFor();
// The existing fullscreen viewer always opens the radial desktop source.
const fullscreen = await full.evaluate(inspect,{mobile:false});
assert.deepEqual(fullscreen.errors,[]);
await page.screenshot({path:`${output}/${width}-fullscreen.png`});
await panel.getByRole('button',{name:'Close full-screen diagram'}).click();
results.push({width,inline,exported,fullscreen});
fs.writeFileSync(`${output}/results.json`,JSON.stringify(results,null,2));
await page.close();
}
assert.equal(results.length,3);
console.log('PASS midpoint geometry: 3 viewports × inline/fullscreen; standalone SVG geometry and typography in light/dark');
} finally {await browser.close();}
})().catch(error => {console.error(error);process.exitCode=1;});
+12 -1
View File
@@ -2,6 +2,7 @@ import {readdirSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, renam
import {join} from 'node:path';
import {execFileSync} from 'node:child_process';
import {roadmapViewer} from './roadmap-viewer.mjs';
import {targetRouting} from './target-routing.mjs';
import {createHash} from 'node:crypto';
const cli = 'vendor/archify/bin/archify.mjs';
const types = new Set(['architecture','workflow','sequence','dataflow','lifecycle']);
@@ -39,6 +40,7 @@ for (const file of readdirSync('diagrams').sort()) {
}
// Verify the upstream artifact first, then add the scoped portal integration.
let viewer = readFileSync(output, 'utf8');
if (name === 'strategy-target') viewer = targetRouting(viewer);
// Upstream intentionally disables preset selection in gallery embeds. Enable
// only those two guards; fail closed if a future vendor update changes them.
const start = viewer.indexOf('Archify.preset = (function () {');
@@ -66,7 +68,16 @@ for (const file of readdirSync('diagrams').sort()) {
const checked = JSON.parse(execFileSync(process.execPath, [cli,'check',output,'--json'], {encoding:'utf8', env:{...process.env, ARCHIFY_UPDATE_CHECK_DISABLED:'1'}}));
if (!checked.ok || checked.composition?.status !== 'pass') throw new Error('Roadmap final artifact failed');
writeFileSync(`.cache/archify/${name}.portal.json`, JSON.stringify({source:input, sourceSha256:hash(input), adapterSha256:hash('scripts/roadmap-viewer.mjs'), artifactSha256:hash(output), checker:checked}, null, 2));
} else renameSync(`static/diagrams/${name}/index.delivery.json`, `.cache/archify/${name}.delivery.json`);
} else {
renameSync(`static/diagrams/${name}/index.delivery.json`, `.cache/archify/${name}.delivery.json`);
if (name === 'strategy-target') {
// The native receipt describes the PRE-integration artifact only.
const hash = path => createHash('sha256').update(readFileSync(path)).digest('hex');
const checked = JSON.parse(execFileSync(process.execPath, [cli,'check',output,'--json'], {encoding:'utf8', env:{...process.env, ARCHIFY_UPDATE_CHECK_DISABLED:'1'}}));
if (!checked.ok || checked.composition?.status !== 'pass') throw new Error('Target final artifact failed');
writeFileSync(`.cache/archify/${name}.portal.json`, JSON.stringify({source:input, sourceSha256:hash(input), adapterSha256:hash('scripts/target-routing.mjs'), artifactSha256:hash(output), nativeReceiptScope:'before portal integration', checker:checked}, null, 2));
}
}
}
copyFileSync('vendor/archify/LICENSE','static/diagrams/ARCHIFY-LICENSE.txt');
copyFileSync('vendor/archify/THIRD_PARTY_NOTICES.md','static/diagrams/THIRD-PARTY-NOTICES.md');
+2 -1
View File
@@ -8,7 +8,8 @@
font-size: var(--cycle-label);
transform: translateY(-6px);
}
.diagram-container > svg text[data-detail="context"] {
/* Export strips data-detail; node ownership and the muted class survive. */
.diagram-container > svg [data-node-id] > text.t-muted {
font-size: var(--cycle-detail);
transform: translateY(16px);
}
+1 -1
View File
@@ -36,7 +36,7 @@ test('mobile story diagrams preserve the same nodes and directed relationships',
const desktop = JSON.parse(read(`diagrams/${name}.architecture.json`));
const mobile = JSON.parse(read(`diagrams/${name}-mobile.architecture.json`));
assert(desktop.components.length >= 5 && desktop.components.length <= (name === 'strategy-target' ? 18 : 7), name);
const semantic = d => ({nodes:d.components.map(({pos,size,...n})=>n),edges:d.connections.map(({fromSide,toSide,via,labelAt,...e})=>e)});
const semantic = d => ({nodes:d.components.map(({pos,size,...n})=>n),edges:d.connections.map(({fromSide,toSide,route,via,labelAt,...e})=>e)});
assert.deepEqual(semantic(desktop), semantic(mobile), name);
assert(desktop.connections.every(e => (e.label || e.id.startsWith('govern-')) && e.from !== e.to), name);
assert.equal(desktop.meta.legend.mode, 'hidden');
+31
View File
@@ -0,0 +1,31 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {createRouter} from '../vendor/archify/renderers/architecture/routing.mjs';
import {segmentIntersectsRect} from '../vendor/archify/renderers/shared/geometry.mjs';
for (const suffix of ['', '-mobile']) {
test(`Target${suffix} uses shared native midpoint ports without node crossings`, () => {
const diagram = JSON.parse(readFileSync(`diagrams/strategy-target${suffix}.architecture.json`, 'utf8'));
const boxes = new Map(diagram.components.map(({id, pos: [x, y], size: [width, height]}) =>
[id, {id, x, y, width, height, cx: x + width / 2, cy: y + height / 2}]));
const router = createRouter(boxes, diagram.connections, {distinctAutomaticPorts: true});
const agent = boxes.get('agent');
for (const edge of diagram.connections.filter(edge => edge.from === 'agent')) {
const side = suffix ? 'left' : ['scope', 'architecture', 'releases'].includes(edge.to) ? 'top'
: ['planning', 'delivery', 'uat'].includes(edge.to) ? 'bottom' : edge.to === 'security' ? 'left' : 'right';
assert.equal(edge.fromSide, side, edge.id);
assert(edge.via || (edge.route && edge.route !== 'auto'), `${edge.id} must opt out of automatic port spreading`);
const expected = {top: [agent.cx, agent.y], bottom: [agent.cx, agent.y + agent.height],
left: [agent.x, agent.cy], right: [agent.x + agent.width, agent.cy]}[side];
const points = router.pathFor(edge).points;
assert.deepEqual(points[0], expected, edge.id);
for (let i = 1; i < points.length; i++) {
for (const box of boxes.values()) {
if ([edge.from, edge.to].includes(box.id)) continue;
assert(!segmentIntersectsRect({start: points[i - 1], end: points[i]}, box), `${edge.id} crosses ${box.id}`);
}
}
}
});
}
+32
View File
@@ -0,0 +1,32 @@
// Owned desktop-only integration, applied AFTER native artifact verification.
// Native architecture JSON has no per-edge attachment offset. Keep the central
// governance ports native; shift only these lifecycle channels on the same sides.
export const targetLifecycleOffsets = [
{id:'stage-2', from:'architecture', to:'ui-ux', x:870, shiftedX:850, y1:262.5, y2:457.5},
{id:'stage-3', from:'ui-ux', to:'planning', x:870, shiftedX:850, y1:532.5, y2:727.5},
{id:'stage-6', from:'uat', to:'security', x:330, shiftedX:350, y1:727.5, y2:532.5},
{id:'stage-7', from:'security', to:'releases', x:330, shiftedX:350, y1:457.5, y2:262.5},
];
export function targetRouting(viewer) {
for (const {id, from, to, x, shiftedX, y1, y2} of targetLifecycleOffsets) {
const oldPath = `M ${x} ${y1} L ${x} ${y2}`;
const newPath = `M ${shiftedX} ${y1} L ${shiftedX} ${y2}`;
const oldPoints = `${x},${y1};${x},${y2}`;
const newPoints = `${shiftedX},${y1};${shiftedX},${y2}`;
// Match the whole native crossover group: its mask must move with the edge.
// Trace/export use this actual path; composition checks use its point list.
const groups = [...viewer.matchAll(/<g data-graph-role="automatic-crossover"[^>]*>\s*<path\b[^>]*\/>\s*<path\b[^>]*\/>\s*<\/g>/g)]
.filter(([group]) => group.includes(`data-edge-id="${id}"`));
if (groups.length !== 1) throw new Error(`Target routing drift: ${id} group`);
const group = groups[0][0];
for (const attribute of [`data-edge-from="${from}"`, `data-edge-to="${to}"`, `data-composition-points="${oldPoints}"`, 'data-graph-role="automatic-crossover-underlay"']) {
if (!group.includes(attribute)) throw new Error(`Target routing drift: ${id} ${attribute}`);
}
if (group.split(`d="${oldPath}"`).length !== 3) throw new Error(`Target routing drift: ${id} paths`);
const shifted = group.replaceAll(`d="${oldPath}"`, `d="${newPath}"`)
.replace(`data-composition-points="${oldPoints}"`, `data-composition-points="${newPoints}"`);
viewer = viewer.replace(group, shifted);
}
return viewer;
}
+32
View File
@@ -0,0 +1,32 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {targetRouting, targetLifecycleOffsets} from './target-routing.mjs';
const fixture = targetLifecycleOffsets.map(({id, from, to, x, y1, y2}) => `<g data-graph-role="automatic-crossover" style="--step:9">
<path data-graph-role="automatic-crossover-underlay" d="M ${x} ${y1} L ${x} ${y2}"/>
<path data-edge-from="${from}" data-edge-to="${to}" data-edge-id="${id}" data-composition-points="${x},${y1};${x},${y2}" d="M ${x} ${y1} L ${x} ${y2}" marker-end="url(#arrowhead-emphasis)"/>
</g>`).join('\n');
test('Target adapter shifts precisely four paths, masks and composition points', () => {
const unrelated = '<path data-edge-id="govern-architecture" d="M 600 450 L 870 262.5"/><g data-node-id="agent"/>';
const output = targetRouting(fixture + unrelated);
assert(output.endsWith(unrelated));
let restored = output;
for (const {x, shiftedX, y1, y2} of targetLifecycleOffsets) {
assert.equal(output.split(`d="M ${shiftedX} ${y1} L ${shiftedX} ${y2}"`).length, 3);
assert(output.includes(`data-composition-points="${shiftedX},${y1};${shiftedX},${y2}"`));
restored = restored.replaceAll(`d="M ${shiftedX} ${y1} L ${shiftedX} ${y2}"`, `d="M ${x} ${y1} L ${x} ${y2}"`)
.replace(`data-composition-points="${shiftedX},${y1};${shiftedX},${y2}"`, `data-composition-points="${x},${y1};${x},${y2}"`);
}
assert.equal(restored, fixture + unrelated, 'all other bytes preserved');
});
test('Target adapter fails closed on missing, duplicate, moved or changed native edges', () => {
for (const changed of [fixture.replace('stage-2', 'missing'), fixture + fixture,
fixture.replace('870 262.5', '871 262.5'), fixture.replace('870,262.5', '871,262.5'),
fixture.replace('data-edge-from="architecture"', 'data-edge-from="scope"'),
fixture.replace('automatic-crossover-underlay', 'unknown')]) {
assert.throws(() => targetRouting(changed), /Target routing drift/);
}
assert.throws(() => targetRouting(targetRouting(fixture)), /Target routing drift/);
});