@orchestrator-claude/definitions 3.37.3 → 3.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/agentic-architect.md +174 -0
- package/agents/architecture-mediator.md +215 -0
- package/agents/azure-devops-engineer.md +2 -2
- package/agents/backend-architect.md +198 -0
- package/agents/cicd-platform-engineer.md +3 -2
- package/agents/contract-architect.md +210 -0
- package/agents/datamodel-architect.md +202 -0
- package/agents/frontend-architect.md +166 -0
- package/agents/frontend-engineer.md +10 -7
- package/agents/harness-architect.md +51 -32
- package/agents/harness-engineer.md +175 -0
- package/agents/implementer.md +10 -1
- package/agents/infra-architect.md +160 -0
- package/agents/operational-artifact-builder.md +1 -1
- package/agents/python-data-engineer.md +5 -4
- package/agents/software-architect.md +29 -147
- package/hooks/hooks.json +2 -2
- package/hooks/pre-agent-dispatch.mjs +3 -1
- package/hooks/pre-agent-dispatch.ts +2 -0
- package/hooks/subagent-start.ts +3 -1
- package/manifest.json +101 -13
- package/package.json +1 -1
- package/skills/archify/LICENSE +22 -0
- package/skills/archify/SKILL.md +278 -0
- package/skills/archify/VENDOR.txt +70 -0
- package/skills/archify/assets/template.html +1261 -0
- package/skills/archify/bin/archify.mjs +271 -0
- package/skills/archify/examples/agent-run.lifecycle.json +64 -0
- package/skills/archify/examples/agent-tool-call.workflow.json +88 -0
- package/skills/archify/examples/cache-miss-request.sequence.json +75 -0
- package/skills/archify/examples/product-analytics.dataflow.json +70 -0
- package/skills/archify/examples/web-app.architecture.json +41 -0
- package/skills/archify/package-lock.json +80 -0
- package/skills/archify/package.json +23 -0
- package/skills/archify/renderers/architecture/grid.mjs +62 -0
- package/skills/archify/renderers/architecture/render-architecture.mjs +371 -0
- package/skills/archify/renderers/dataflow/README.md +82 -0
- package/skills/archify/renderers/dataflow/render-dataflow.mjs +295 -0
- package/skills/archify/renderers/lifecycle/README.md +91 -0
- package/skills/archify/renderers/lifecycle/render-lifecycle.mjs +384 -0
- package/skills/archify/renderers/sequence/README.md +84 -0
- package/skills/archify/renderers/sequence/render-sequence.mjs +257 -0
- package/skills/archify/renderers/shared/cli.mjs +44 -0
- package/skills/archify/renderers/shared/generated-validators.mjs +13 -0
- package/skills/archify/renderers/shared/geometry.mjs +230 -0
- package/skills/archify/renderers/shared/layout-report.mjs +40 -0
- package/skills/archify/renderers/shared/utils.mjs +88 -0
- package/skills/archify/renderers/shared/validator.mjs +40 -0
- package/skills/archify/renderers/workflow/README.md +103 -0
- package/skills/archify/renderers/workflow/render-workflow.mjs +481 -0
- package/skills/archify/schemas/README.md +72 -0
- package/skills/archify/schemas/architecture.schema.json +121 -0
- package/skills/archify/schemas/common.schema.json +43 -0
- package/skills/archify/schemas/dataflow.schema.json +221 -0
- package/skills/archify/schemas/lifecycle.schema.json +241 -0
- package/skills/archify/schemas/sequence.schema.json +186 -0
- package/skills/archify/schemas/workflow.schema.json +332 -0
- package/skills/archify/scripts/check-render-output.mjs +297 -0
- package/skills/archify/scripts/generate-validators.mjs +66 -0
- package/skills/archify/scripts/render-examples.mjs +26 -0
- package/skills/archify/test/animation.test.mjs +53 -0
- package/skills/archify/test/cli.test.mjs +206 -0
- package/skills/archify/test/degraded.test.mjs +157 -0
- package/skills/archify/test/generate-validators.test.mjs +40 -0
- package/skills/archify/test/geometry.test.mjs +186 -0
- package/skills/archify/test/golden.mjs +167 -0
- package/skills/archify/test/grid.test.mjs +47 -0
- package/skills/archify/test/layout-rules.test.mjs +193 -0
- package/skills/archify/test/render-output-checks.test.mjs +75 -0
- package/skills/arquitetura-lane-dispatch/SKILL.md +205 -0
- package/skills/arquitetura-mediada-arquiteto/SKILL.md +261 -0
- package/skills/checkpoint-protocol/SKILL.md +1 -1
- package/skills/orchestrator/SKILL.md +1 -1
- package/skills/release/release.sh +63 -6
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Golden-file harness for the archify renderers. No test framework needed:
|
|
2
|
+
// renderers are deterministic, so fresh renders must match the checked-in
|
|
3
|
+
// example HTML aside from platform checkout line endings. Also covers schema enforcement (negative cases),
|
|
4
|
+
// template freshness of the architecture-mode example, and version sync.
|
|
5
|
+
//
|
|
6
|
+
// Run from the skill folder: npm test
|
|
7
|
+
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const skillRoot = path.resolve(__dirname, '..');
|
|
16
|
+
const repoRoot = path.resolve(skillRoot, '..');
|
|
17
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-test-'));
|
|
18
|
+
|
|
19
|
+
let failures = 0;
|
|
20
|
+
|
|
21
|
+
function check(name, ok, detail) {
|
|
22
|
+
if (ok) {
|
|
23
|
+
console.log(` ok ${name}`);
|
|
24
|
+
} else {
|
|
25
|
+
failures += 1;
|
|
26
|
+
console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function render(mode, inputPath, outPath) {
|
|
31
|
+
execFileSync('node', [
|
|
32
|
+
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
|
33
|
+
inputPath,
|
|
34
|
+
outPath,
|
|
35
|
+
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeNewlines(text) {
|
|
39
|
+
return text.replace(/\r\n?/g, '\n');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
console.log('golden renders (renderer output must match checked-in examples)');
|
|
44
|
+
|
|
45
|
+
const GOLDEN = [
|
|
46
|
+
['workflow', 'agent-tool-call.workflow.json', 'workflow-agent-tool-call-rendered.html'],
|
|
47
|
+
['sequence', 'cache-miss-request.sequence.json', 'sequence-cache-miss-request.html'],
|
|
48
|
+
['dataflow', 'product-analytics.dataflow.json', 'dataflow-product-analytics.html'],
|
|
49
|
+
['lifecycle', 'agent-run.lifecycle.json', 'lifecycle-agent-run.html'],
|
|
50
|
+
['architecture', 'web-app.architecture.json', 'web-app-rendered.html'],
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
for (const [mode, input, golden] of GOLDEN) {
|
|
54
|
+
const out = path.join(tmp, golden);
|
|
55
|
+
try {
|
|
56
|
+
render(mode, path.join(skillRoot, 'examples', input), out);
|
|
57
|
+
const fresh = fs.readFileSync(out, 'utf8');
|
|
58
|
+
const checked = fs.readFileSync(path.join(repoRoot, 'examples', golden), 'utf8');
|
|
59
|
+
check(`${mode}: ${golden}`, normalizeNewlines(fresh) === normalizeNewlines(checked),
|
|
60
|
+
`fresh render differs from examples/${golden}; if the change is intentional, re-render the examples and commit them`);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
check(`${mode}: ${golden}`, false, String(err.stderr || err.message).slice(0, 300));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
console.log('schema enforcement (invalid JSON must fail with a path-prefixed message)');
|
|
68
|
+
|
|
69
|
+
function expectFailure(name, mode, mutate, expectInMessage) {
|
|
70
|
+
const base = JSON.parse(fs.readFileSync(
|
|
71
|
+
path.join(skillRoot, 'examples', GOLDEN.find(([m]) => m === mode)[1]), 'utf8'));
|
|
72
|
+
mutate(base);
|
|
73
|
+
const input = path.join(tmp, `neg-${name.replace(/[^a-z0-9]+/gi, '-')}.json`);
|
|
74
|
+
fs.writeFileSync(input, JSON.stringify(base));
|
|
75
|
+
try {
|
|
76
|
+
render(mode, input, path.join(tmp, 'neg-out.html'));
|
|
77
|
+
check(name, false, 'renderer exited 0 on invalid input');
|
|
78
|
+
} catch (err) {
|
|
79
|
+
const message = String(err.stderr || err.message);
|
|
80
|
+
check(name, message.includes(expectInMessage),
|
|
81
|
+
`expected "${expectInMessage}" in:\n${message.slice(0, 300)}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
expectFailure('card dot outside enum', 'workflow',
|
|
86
|
+
(d) => { d.cards[0].dot = 'pink'; }, '/cards/0/dot');
|
|
87
|
+
expectFailure('node id starting with a digit', 'workflow',
|
|
88
|
+
(d) => { d.nodes[0].id = '1user'; }, 'pattern');
|
|
89
|
+
expectFailure('extra property rejected', 'workflow',
|
|
90
|
+
(d) => { d.nodes[0].colour = 'red'; }, 'additional properties');
|
|
91
|
+
expectFailure('column beyond layout maximum', 'workflow',
|
|
92
|
+
(d) => { d.nodes[0].col = 7; }, '<= 5');
|
|
93
|
+
expectFailure('missing schema_version', 'sequence',
|
|
94
|
+
(d) => { delete d.schema_version; }, 'schema_version');
|
|
95
|
+
expectFailure('cross-lane state overlap', 'lifecycle',
|
|
96
|
+
(d) => {
|
|
97
|
+
const approval = d.states.find((s) => s.id === 'approval');
|
|
98
|
+
const failed = d.states.find((s) => s.id === 'failed');
|
|
99
|
+
delete failed.yOffset;
|
|
100
|
+
failed.col = approval.col;
|
|
101
|
+
}, 'less than 10px apart');
|
|
102
|
+
expectFailure('zero component width rejected by schema', 'architecture',
|
|
103
|
+
(d) => { d.components[0].size = [0, 60]; }, '/components/0/size/0');
|
|
104
|
+
expectFailure('zero component height rejected by schema', 'architecture',
|
|
105
|
+
(d) => { d.components[0].size = [120, 0]; }, '/components/0/size/1');
|
|
106
|
+
expectFailure('negative component width rejected by schema', 'architecture',
|
|
107
|
+
(d) => { d.components[0].size = [-1, 60]; }, '/components/0/size/0');
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
console.log('template freshness (architecture example must carry the current template)');
|
|
111
|
+
|
|
112
|
+
function blocks(html, tag) {
|
|
113
|
+
const re = new RegExp(`<${tag}[^>]*>[\\s\\S]*?<\\/${tag}>`, 'g');
|
|
114
|
+
return html.match(re) || [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const template = fs.readFileSync(path.join(skillRoot, 'assets/template.html'), 'utf8');
|
|
118
|
+
const webApp = fs.readFileSync(path.join(repoRoot, 'examples/web-app.html'), 'utf8');
|
|
119
|
+
// <style> and <script> blocks pass through applyTemplate untouched, so the
|
|
120
|
+
// architecture-mode example must contain them verbatim or it has drifted.
|
|
121
|
+
for (const tag of ['style', 'script']) {
|
|
122
|
+
const t = blocks(template, tag).filter((b) => !b.includes('[PROJECT NAME]'));
|
|
123
|
+
const w = blocks(webApp, tag).filter((b) => !b.includes('Sample Web App'));
|
|
124
|
+
check(`web-app.html ${tag} blocks match template`,
|
|
125
|
+
JSON.stringify(t) === JSON.stringify(w),
|
|
126
|
+
'examples/web-app.html was generated from a stale template — re-derive it');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
console.log('version sync');
|
|
131
|
+
|
|
132
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(skillRoot, 'package.json'), 'utf8'));
|
|
133
|
+
check('template generator meta matches package.json version',
|
|
134
|
+
template.includes(`<meta name="generator" content="archify ${pkg.version}">`),
|
|
135
|
+
`package.json says ${pkg.version}`);
|
|
136
|
+
|
|
137
|
+
const lock = JSON.parse(fs.readFileSync(path.join(skillRoot, 'package-lock.json'), 'utf8'));
|
|
138
|
+
check('package-lock.json version matches package.json',
|
|
139
|
+
lock.version === pkg.version && lock.packages?.['']?.version === pkg.version,
|
|
140
|
+
`lockfile says ${lock.version} — run npm install and rebuild the zip`);
|
|
141
|
+
|
|
142
|
+
const skillMd = fs.readFileSync(path.join(skillRoot, 'SKILL.md'), 'utf8');
|
|
143
|
+
const skillVersion = (skillMd.match(/^\s*version:\s*"([^"]+)"/m) || [])[1];
|
|
144
|
+
check('SKILL.md metadata version matches package.json major.minor',
|
|
145
|
+
!!skillVersion && pkg.version.startsWith(skillVersion),
|
|
146
|
+
`SKILL.md says ${skillVersion}, package.json says ${pkg.version}`);
|
|
147
|
+
|
|
148
|
+
for (const readmeName of ['README.md', 'README_EN.md', 'README_ZH.md']) {
|
|
149
|
+
const readme = fs.readFileSync(path.join(repoRoot, readmeName), 'utf8');
|
|
150
|
+
check(`${readmeName} badge matches package.json version`,
|
|
151
|
+
readme.includes(`/badge/version-${pkg.version}-`),
|
|
152
|
+
`${readmeName} does not advertise ${pkg.version}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const landingPage = fs.readFileSync(path.join(repoRoot, 'docs/index.html'), 'utf8');
|
|
156
|
+
const landingVersions = [...landingPage.matchAll(/\bv\d+\.\d+\.\d+\b/g)].map((m) => m[0]);
|
|
157
|
+
check('GitHub Pages version labels match package.json',
|
|
158
|
+
landingVersions.length > 0 && landingVersions.every((v) => v === `v${pkg.version}`),
|
|
159
|
+
`landing page says ${[...new Set(landingVersions)].join(', ') || '(no version)'}`);
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
163
|
+
if (failures) {
|
|
164
|
+
console.error(`\n${failures} check(s) failed`);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
console.log('\nall checks passed');
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { gridLayout, resolveComponentPos, validateGridPlacement } from '../renderers/architecture/grid.mjs';
|
|
4
|
+
|
|
5
|
+
test('gridLayout returns null for free placement', () => {
|
|
6
|
+
assert.equal(gridLayout({}), null);
|
|
7
|
+
assert.equal(gridLayout({ layout: undefined }), null);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('resolveComponentPos prefers explicit pos over row/col', () => {
|
|
11
|
+
const grid = gridLayout({ layout: { mode: 'grid' } });
|
|
12
|
+
assert.deepEqual(resolveComponentPos({ pos: [9, 8], row: 0, col: 0 }, grid), [9, 8]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('resolveComponentPos maps row/col to pixel origin', () => {
|
|
16
|
+
const grid = gridLayout({
|
|
17
|
+
layout: { mode: 'grid', origin: [40, 80], gapX: 30, gapY: 40, cellW: 130, cellH: 64 },
|
|
18
|
+
});
|
|
19
|
+
assert.deepEqual(resolveComponentPos({ row: 0, col: 0 }, grid), [40, 80]);
|
|
20
|
+
assert.deepEqual(resolveComponentPos({ row: 1, col: 2 }, grid), [40 + 2 * 160, 80 + 104]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('validateGridPlacement rejects duplicate cells and missing row/col', () => {
|
|
24
|
+
const grid = gridLayout({ layout: { mode: 'grid', cols: 4 } });
|
|
25
|
+
const problems = [];
|
|
26
|
+
validateGridPlacement({
|
|
27
|
+
components: [
|
|
28
|
+
{ id: 'a', row: 0, col: 0 },
|
|
29
|
+
{ id: 'b', row: 0, col: 0 },
|
|
30
|
+
{ id: 'c' },
|
|
31
|
+
],
|
|
32
|
+
}, grid, problems);
|
|
33
|
+
assert.ok(problems.some((p) => p.includes('share grid cell')));
|
|
34
|
+
assert.ok(problems.some((p) => p.includes('"c" needs pos')));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('validateGridPlacement ignores explicit pos overrides without row/col', () => {
|
|
38
|
+
const grid = gridLayout({ layout: { mode: 'grid', cols: 4 } });
|
|
39
|
+
const problems = [];
|
|
40
|
+
validateGridPlacement({
|
|
41
|
+
components: [
|
|
42
|
+
{ id: 'a', pos: [40, 80] },
|
|
43
|
+
{ id: 'b', pos: [200, 80] },
|
|
44
|
+
],
|
|
45
|
+
}, grid, problems);
|
|
46
|
+
assert.deepEqual(problems, []);
|
|
47
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Per-rule coverage for the renderers' layout validators. The golden suite's
|
|
2
|
+
// negative cases mostly trip ajv SCHEMA rules; this file targets the hand-
|
|
3
|
+
// written LAYOUT rules (the `problems.push(...)` checks) — the layer that has
|
|
4
|
+
// regressed before — by mutating a valid example into exactly one violation
|
|
5
|
+
// and asserting the renderer exits non-zero with the expected message.
|
|
6
|
+
//
|
|
7
|
+
// It also locks the error-message CONTRACT: representative messages must carry
|
|
8
|
+
// both the numeric threshold and a remediation hint, since the consumer is an
|
|
9
|
+
// LLM that fixes the JSON from the message alone.
|
|
10
|
+
//
|
|
11
|
+
// node --test test/*.test.mjs
|
|
12
|
+
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const skillRoot = path.resolve(__dirname, '..');
|
|
23
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-rules-'));
|
|
24
|
+
|
|
25
|
+
const EXAMPLES = {
|
|
26
|
+
workflow: 'agent-tool-call.workflow.json',
|
|
27
|
+
sequence: 'cache-miss-request.sequence.json',
|
|
28
|
+
dataflow: 'product-analytics.dataflow.json',
|
|
29
|
+
lifecycle: 'agent-run.lifecycle.json',
|
|
30
|
+
architecture: 'web-app.architecture.json',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function load(mode) {
|
|
34
|
+
return JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES[mode]), 'utf8'));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Returns { code, stderr }. Never throws on non-zero exit.
|
|
38
|
+
function render(mode, doc) {
|
|
39
|
+
const input = path.join(tmp, `${mode}-${Math.abs(hash(JSON.stringify(doc)))}.json`);
|
|
40
|
+
const outPath = path.join(tmp, `${mode}-${Math.abs(hash(JSON.stringify(doc)))}.html`);
|
|
41
|
+
fs.writeFileSync(input, JSON.stringify(doc));
|
|
42
|
+
try {
|
|
43
|
+
execFileSync('node', [
|
|
44
|
+
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
|
45
|
+
input,
|
|
46
|
+
outPath,
|
|
47
|
+
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
48
|
+
return { code: 0, stderr: '', outPath };
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return { code: err.status ?? 1, stderr: String(err.stderr || ''), outPath };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function hash(s) {
|
|
55
|
+
let h = 0;
|
|
56
|
+
for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) | 0;
|
|
57
|
+
return h;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// [name, mode, mutate(doc), expectedSubstrings[]] — every mutation introduces
|
|
61
|
+
// exactly one layout violation. Each expected substring must appear in stderr.
|
|
62
|
+
const CASES = [
|
|
63
|
+
// ---- workflow layout rules ----
|
|
64
|
+
['workflow: unknown lane', 'workflow', (d) => { d.nodes[0].lane = 'ghost'; }, ['unknown lane "ghost"']],
|
|
65
|
+
['workflow: node label wider than box', 'workflow',
|
|
66
|
+
(d) => { d.nodes[0].label = 'An Extremely Long Node Label That Overflows'; }, ['wider than node', 'shorten the label']],
|
|
67
|
+
['workflow: viewBox width below schema min', 'workflow',
|
|
68
|
+
(d) => { d.meta.viewBox = [699, 900]; }, ['700']],
|
|
69
|
+
['workflow: nodes too close in a lane', 'workflow',
|
|
70
|
+
(d) => { d.nodes.push({ ...d.nodes[0], id: 'dupe', col: d.nodes[0].col }); }, ['less than 8px apart']],
|
|
71
|
+
['workflow: empty group', 'workflow',
|
|
72
|
+
(d) => { d.groups = [{ id: 'empty', label: 'Empty group', lane: 'ui', fromCol: 3, toCol: 4 }]; }, ['does not contain any nodes']],
|
|
73
|
+
['workflow: mainPath missing edge', 'workflow',
|
|
74
|
+
(d) => { d.mainPath = ['user', 'planner']; }, ['mainPath step "user" -> "planner" has no matching edge']],
|
|
75
|
+
['workflow: mainPath moves backward', 'workflow',
|
|
76
|
+
(d) => { d.mainPath = ['external', 'trace']; }, ['moves backward from col']],
|
|
77
|
+
|
|
78
|
+
// ---- sequence layout rules ----
|
|
79
|
+
['sequence: message references unknown participant', 'sequence',
|
|
80
|
+
(d) => { d.messages[0].from = 'ghost'; }, ['unknown source "ghost"']],
|
|
81
|
+
['sequence: message y outside timeline', 'sequence',
|
|
82
|
+
(d) => { d.messages[0].y = 9000; }, ['outside the readable timeline', 'keep y between']],
|
|
83
|
+
['sequence: segment to <= from', 'sequence',
|
|
84
|
+
(d) => { d.segments = [{ from: 400, to: 300, label: 'bad' }]; }, ['invalid y range', 'greater than']],
|
|
85
|
+
|
|
86
|
+
// ---- dataflow layout rules ----
|
|
87
|
+
['dataflow: flow missing label', 'dataflow',
|
|
88
|
+
(d) => { delete d.flows[0].label; }, ['label']],
|
|
89
|
+
['dataflow: flow references unknown node', 'dataflow',
|
|
90
|
+
(d) => { d.flows[0].to = 'ghost'; }, ['unknown target "ghost"']],
|
|
91
|
+
|
|
92
|
+
// ---- lifecycle layout rules ----
|
|
93
|
+
['lifecycle: missing reserved main lane', 'lifecycle',
|
|
94
|
+
(d) => {
|
|
95
|
+
d.lanes = d.lanes.map((l) => (l.id === 'main' ? { ...l, id: 'primary' } : l));
|
|
96
|
+
d.states = d.states.map((s) => (s.lane === 'main' ? { ...s, lane: 'primary' } : s));
|
|
97
|
+
}, ['"main"', 'reserved']],
|
|
98
|
+
['lifecycle: cross-lane state overlap', 'lifecycle',
|
|
99
|
+
(d) => {
|
|
100
|
+
const approval = d.states.find((s) => s.id === 'approval');
|
|
101
|
+
const failed = d.states.find((s) => s.id === 'failed');
|
|
102
|
+
delete failed.yOffset;
|
|
103
|
+
failed.col = approval.col;
|
|
104
|
+
}, ['less than 10px apart']],
|
|
105
|
+
['lifecycle: viewBox height below schema min', 'lifecycle',
|
|
106
|
+
(d) => { d.meta.viewBox = [980, 565]; }, ['566']],
|
|
107
|
+
|
|
108
|
+
// ---- architecture layout rules ----
|
|
109
|
+
['architecture: components overlap', 'architecture',
|
|
110
|
+
(d) => { d.components[1].pos = [...d.components[0].pos]; }, ['less than 8px apart']],
|
|
111
|
+
['architecture: connection references unknown component', 'architecture',
|
|
112
|
+
(d) => { d.connections[0].to = 'ghost'; }, ['unknown target "ghost"']],
|
|
113
|
+
['architecture: boundary wraps unknown component', 'architecture',
|
|
114
|
+
(d) => { d.boundaries[0].wraps.push('ghost'); }, ['wraps unknown component "ghost"']],
|
|
115
|
+
['architecture: label wider than component', 'architecture',
|
|
116
|
+
(d) => { d.components[0].label = 'An Extremely Long Component Label Overflow'; }, ['wider than component', 'shorten the label']],
|
|
117
|
+
['architecture: component overlap suggests fix', 'architecture',
|
|
118
|
+
(d) => { d.components[1].pos = [...d.components[0].pos]; }, ['Suggested fix', 'move "']],
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
for (const [name, mode, mutate, expected] of CASES) {
|
|
122
|
+
test(name, () => {
|
|
123
|
+
const doc = load(mode);
|
|
124
|
+
mutate(doc);
|
|
125
|
+
const { code, stderr } = render(mode, doc);
|
|
126
|
+
assert.notEqual(code, 0, `expected non-zero exit; stderr:\n${stderr}`);
|
|
127
|
+
assert.doesNotMatch(stderr, /TypeError|is not a function|Cannot read/, `crashed instead of reporting:\n${stderr}`);
|
|
128
|
+
for (const sub of expected) {
|
|
129
|
+
assert.ok(stderr.includes(sub), `expected "${sub}" in stderr:\n${stderr}`);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- error-message contract: threshold + remediation, not just a path ----
|
|
135
|
+
test('contract: short-edge message carries both the px minimum and a fix verb', () => {
|
|
136
|
+
const d = load('workflow');
|
|
137
|
+
// Force a too-short labeled edge between adjacent same-lane columns.
|
|
138
|
+
d.nodes.push({ id: 'a1', lane: d.nodes[0].lane, col: 0, type: 'backend', label: 'A' });
|
|
139
|
+
d.nodes.push({ id: 'a2', lane: d.nodes[0].lane, col: 0, type: 'backend', label: 'B', yOffset: 30 });
|
|
140
|
+
d.edges.push({ from: 'a1', to: 'a2', label: 'x', route: 'straight' });
|
|
141
|
+
const { stderr } = render('workflow', d);
|
|
142
|
+
// Whatever rule fires, the messages must remain actionable (threshold + verb).
|
|
143
|
+
assert.match(stderr, /\d+px|at least \d+|0\.\.\d+|less than/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('contract: ajv path errors are annotated with the element id', () => {
|
|
147
|
+
const d = load('workflow');
|
|
148
|
+
d.nodes[3].colour = 'red'; // unknown property → ajv additionalProperties
|
|
149
|
+
const { stderr } = render('workflow', d);
|
|
150
|
+
// Only meaningful when ajv is installed; skip the assertion in degraded mode.
|
|
151
|
+
if (!/schema validation failed/.test(stderr)) return;
|
|
152
|
+
assert.match(stderr, /id\/label:/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('workflow: same-lane offset auto edge stays orthogonal', () => {
|
|
156
|
+
const d = {
|
|
157
|
+
schema_version: 1,
|
|
158
|
+
diagram_type: 'workflow',
|
|
159
|
+
meta: { title: 'Same-lane offset route' },
|
|
160
|
+
lanes: [{ id: 'main', label: 'Main lane' }],
|
|
161
|
+
nodes: [
|
|
162
|
+
{ id: 'left', lane: 'main', col: 1, type: 'backend', label: 'A', width: 32, height: 38, yOffset: -14 },
|
|
163
|
+
{ id: 'right', lane: 'main', col: 2, type: 'backend', label: 'B', width: 32, height: 38, yOffset: 14 },
|
|
164
|
+
],
|
|
165
|
+
edges: [{ from: 'left', to: 'right' }],
|
|
166
|
+
};
|
|
167
|
+
const { code, stderr, outPath } = render('workflow', d);
|
|
168
|
+
assert.equal(code, 0, stderr);
|
|
169
|
+
const html = fs.readFileSync(outPath, 'utf8');
|
|
170
|
+
assert.doesNotMatch(html, /M 236 105 L 284 133/);
|
|
171
|
+
assert.match(html, /M 236 105 L 260 105 L 260 133 L 284 133/);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('workflow: edge crossing a non-endpoint node is rejected', () => {
|
|
175
|
+
const d = {
|
|
176
|
+
schema_version: 1,
|
|
177
|
+
diagram_type: 'workflow',
|
|
178
|
+
meta: { title: 'Crossing edge route' },
|
|
179
|
+
lanes: [{ id: 'main', label: 'Main lane' }],
|
|
180
|
+
nodes: [
|
|
181
|
+
{ id: 'left', lane: 'main', col: 0, type: 'backend', label: 'Left', width: 60 },
|
|
182
|
+
{ id: 'middle', lane: 'main', col: 2, type: 'database', label: 'Middle', width: 70 },
|
|
183
|
+
{ id: 'right', lane: 'main', col: 4, type: 'backend', label: 'Right', width: 60 },
|
|
184
|
+
],
|
|
185
|
+
edges: [{ from: 'left', to: 'right', route: 'straight' }],
|
|
186
|
+
};
|
|
187
|
+
const { code, stderr } = render('workflow', d);
|
|
188
|
+
assert.notEqual(code, 0, `expected non-zero exit; stderr:\n${stderr}`);
|
|
189
|
+
assert.match(stderr, /crosses node "middle"/);
|
|
190
|
+
assert.match(stderr, /fromSide\/toSide|channel|lane\/column/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const skillRoot = path.resolve(__dirname, '..');
|
|
11
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-checks-'));
|
|
12
|
+
const checker = path.join(skillRoot, 'scripts/check-render-output.mjs');
|
|
13
|
+
|
|
14
|
+
function checkHtml(name, svgBody) {
|
|
15
|
+
const htmlPath = path.join(tmp, `${name}.html`);
|
|
16
|
+
fs.writeFileSync(htmlPath, `<!doctype html><html><body><svg viewBox="0 0 240 160">${svgBody}</svg></body></html>`);
|
|
17
|
+
try {
|
|
18
|
+
const stdout = execFileSync('node', [checker, htmlPath], { encoding: 'utf8' });
|
|
19
|
+
return { code: 0, result: JSON.parse(stdout) };
|
|
20
|
+
} catch (err) {
|
|
21
|
+
return { code: err.status ?? 1, result: JSON.parse(String(err.stdout || '{}')) };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('render output check: accepts orthogonal arrows away from legend', () => {
|
|
26
|
+
const { code, result } = checkHtml('clean', `
|
|
27
|
+
<path d="M 20 20 L 120 20 L 120 60" class="a-default" stroke-width="1.4" marker-end="url(#arrowhead)"/>
|
|
28
|
+
<!-- Legend -->
|
|
29
|
+
<text x="40" y="120" class="t-primary" font-size="10">Legend</text>
|
|
30
|
+
<rect x="40" y="132" width="14" height="9" class="c-backend"/>
|
|
31
|
+
<text x="60" y="140" class="t-muted" font-size="7">Backend</text>
|
|
32
|
+
`);
|
|
33
|
+
assert.equal(code, 0);
|
|
34
|
+
assert.equal(result.ok, true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('render output check: rejects two-point diagonal arrows', () => {
|
|
38
|
+
const { code, result } = checkHtml('diagonal', `
|
|
39
|
+
<path d="M 20 20 L 120 80" class="a-default" stroke-width="1.4" marker-end="url(#arrowhead)"/>
|
|
40
|
+
<!-- Legend -->
|
|
41
|
+
<text x="40" y="120" class="t-primary" font-size="10">Legend</text>
|
|
42
|
+
`);
|
|
43
|
+
assert.notEqual(code, 0);
|
|
44
|
+
const check = result.checks.find((item) => item.name === 'orthogonal_arrows');
|
|
45
|
+
assert.equal(check.ok, false);
|
|
46
|
+
assert.match(check.details[0], /path 1/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('render output check: rejects arrows crossing legend text', () => {
|
|
50
|
+
const { code, result } = checkHtml('legend-crossing', `
|
|
51
|
+
<path d="M 20 112 L 180 112" class="a-dashed" stroke-width="1.4" marker-end="url(#arrowhead-dashed)"/>
|
|
52
|
+
<!-- Legend -->
|
|
53
|
+
<text x="40" y="120" class="t-primary" font-size="10">Legend</text>
|
|
54
|
+
<rect x="40" y="132" width="14" height="9" class="c-backend"/>
|
|
55
|
+
<text x="60" y="140" class="t-muted" font-size="7">Backend</text>
|
|
56
|
+
`);
|
|
57
|
+
assert.notEqual(code, 0);
|
|
58
|
+
const check = result.checks.find((item) => item.name === 'legend_clearance');
|
|
59
|
+
assert.equal(check.ok, false);
|
|
60
|
+
assert.match(check.details[0], /Legend/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('render output check: ignores unmarked sequence lifelines near legend', () => {
|
|
64
|
+
const { code, result } = checkHtml('lifeline-near-legend', `
|
|
65
|
+
<path d="M 60 20 L 60 126" class="a-default" stroke-width="0.8" stroke-dasharray="3,7"/>
|
|
66
|
+
<!-- Legend -->
|
|
67
|
+
<text x="40" y="120" class="t-primary" font-size="10">Legend</text>
|
|
68
|
+
<path d="M 120 136 L 154 136" class="a-default" stroke-width="1.4" stroke-dasharray="3,5" marker-end="url(#arrowhead)"/>
|
|
69
|
+
<text x="163" y="139" class="t-muted" font-size="8">return</text>
|
|
70
|
+
`);
|
|
71
|
+
assert.equal(code, 0);
|
|
72
|
+
assert.equal(result.ok, true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|