@orchestrator-claude/definitions 3.38.0 → 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.
Files changed (51) hide show
  1. package/agents/architecture-mediator.md +1 -0
  2. package/manifest.json +12 -4
  3. package/package.json +1 -1
  4. package/skills/archify/LICENSE +22 -0
  5. package/skills/archify/SKILL.md +278 -0
  6. package/skills/archify/VENDOR.txt +70 -0
  7. package/skills/archify/assets/template.html +1261 -0
  8. package/skills/archify/bin/archify.mjs +271 -0
  9. package/skills/archify/examples/agent-run.lifecycle.json +64 -0
  10. package/skills/archify/examples/agent-tool-call.workflow.json +88 -0
  11. package/skills/archify/examples/cache-miss-request.sequence.json +75 -0
  12. package/skills/archify/examples/product-analytics.dataflow.json +70 -0
  13. package/skills/archify/examples/web-app.architecture.json +41 -0
  14. package/skills/archify/package-lock.json +80 -0
  15. package/skills/archify/package.json +23 -0
  16. package/skills/archify/renderers/architecture/grid.mjs +62 -0
  17. package/skills/archify/renderers/architecture/render-architecture.mjs +371 -0
  18. package/skills/archify/renderers/dataflow/README.md +82 -0
  19. package/skills/archify/renderers/dataflow/render-dataflow.mjs +295 -0
  20. package/skills/archify/renderers/lifecycle/README.md +91 -0
  21. package/skills/archify/renderers/lifecycle/render-lifecycle.mjs +384 -0
  22. package/skills/archify/renderers/sequence/README.md +84 -0
  23. package/skills/archify/renderers/sequence/render-sequence.mjs +257 -0
  24. package/skills/archify/renderers/shared/cli.mjs +44 -0
  25. package/skills/archify/renderers/shared/generated-validators.mjs +13 -0
  26. package/skills/archify/renderers/shared/geometry.mjs +230 -0
  27. package/skills/archify/renderers/shared/layout-report.mjs +40 -0
  28. package/skills/archify/renderers/shared/utils.mjs +88 -0
  29. package/skills/archify/renderers/shared/validator.mjs +40 -0
  30. package/skills/archify/renderers/workflow/README.md +103 -0
  31. package/skills/archify/renderers/workflow/render-workflow.mjs +481 -0
  32. package/skills/archify/schemas/README.md +72 -0
  33. package/skills/archify/schemas/architecture.schema.json +121 -0
  34. package/skills/archify/schemas/common.schema.json +43 -0
  35. package/skills/archify/schemas/dataflow.schema.json +221 -0
  36. package/skills/archify/schemas/lifecycle.schema.json +241 -0
  37. package/skills/archify/schemas/sequence.schema.json +186 -0
  38. package/skills/archify/schemas/workflow.schema.json +332 -0
  39. package/skills/archify/scripts/check-render-output.mjs +297 -0
  40. package/skills/archify/scripts/generate-validators.mjs +66 -0
  41. package/skills/archify/scripts/render-examples.mjs +26 -0
  42. package/skills/archify/test/animation.test.mjs +53 -0
  43. package/skills/archify/test/cli.test.mjs +206 -0
  44. package/skills/archify/test/degraded.test.mjs +157 -0
  45. package/skills/archify/test/generate-validators.test.mjs +40 -0
  46. package/skills/archify/test/geometry.test.mjs +186 -0
  47. package/skills/archify/test/golden.mjs +167 -0
  48. package/skills/archify/test/grid.test.mjs +47 -0
  49. package/skills/archify/test/layout-rules.test.mjs +193 -0
  50. package/skills/archify/test/render-output-checks.test.mjs +75 -0
  51. package/skills/arquitetura-mediada-arquiteto/SKILL.md +20 -3
@@ -0,0 +1,26 @@
1
+ // Re-render every bundled example from its JSON IR. Installed skills keep HTML
2
+ // beside the JSON examples; the development script passes the golden directory.
3
+
4
+ import { execFileSync } from 'node:child_process';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const skillRoot = path.resolve(__dirname, '..');
10
+ const outputRoot = path.resolve(process.argv[2] || path.join(skillRoot, 'examples'));
11
+
12
+ const TARGETS = [
13
+ ['workflow', 'agent-tool-call.workflow.json', 'workflow-agent-tool-call-rendered.html'],
14
+ ['sequence', 'cache-miss-request.sequence.json', 'sequence-cache-miss-request.html'],
15
+ ['dataflow', 'product-analytics.dataflow.json', 'dataflow-product-analytics.html'],
16
+ ['lifecycle', 'agent-run.lifecycle.json', 'lifecycle-agent-run.html'],
17
+ ['architecture', 'web-app.architecture.json', 'web-app-rendered.html'],
18
+ ];
19
+
20
+ for (const [mode, input, output] of TARGETS) {
21
+ execFileSync(process.execPath, [
22
+ path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
23
+ path.join(skillRoot, 'examples', input),
24
+ path.join(outputRoot, output),
25
+ ], { stdio: 'inherit' });
26
+ }
@@ -0,0 +1,53 @@
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-animation-'));
12
+
13
+ const CASES = {
14
+ architecture: 'web-app.architecture.json',
15
+ workflow: 'agent-tool-call.workflow.json',
16
+ sequence: 'cache-miss-request.sequence.json',
17
+ dataflow: 'product-analytics.dataflow.json',
18
+ lifecycle: 'agent-run.lifecycle.json',
19
+ };
20
+
21
+ function render(mode, example, animation = 'trace') {
22
+ const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', example), 'utf8'));
23
+ if (animation) doc.meta = { ...doc.meta, animation };
24
+ const suffix = animation || 'static';
25
+ const input = path.join(tmp, `${mode}-${suffix}.json`);
26
+ const output = path.join(tmp, `${mode}-${suffix}.html`);
27
+ fs.writeFileSync(input, JSON.stringify(doc));
28
+ execFileSync('node', [path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`), input, output], {
29
+ stdio: ['ignore', 'ignore', 'pipe'],
30
+ });
31
+ return fs.readFileSync(output, 'utf8');
32
+ }
33
+
34
+ function svgBlock(html) {
35
+ return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
36
+ }
37
+
38
+ test('static output omits animation attributes', () => {
39
+ const svg = svgBlock(render('workflow', CASES.workflow, null));
40
+ assert.doesNotMatch(svg, /data-animation=/);
41
+ assert.doesNotMatch(svg, /data-animate=/);
42
+ });
43
+
44
+ for (const [mode, example] of Object.entries(CASES)) {
45
+ test(`${mode}: trace animation annotates svg, edges, and nodes`, () => {
46
+ const svg = svgBlock(render(mode, example));
47
+ assert.match(svg, /<svg[^>]+data-animation="trace"/);
48
+ assert.match(svg, /data-animate="edge" style="--step:0"/);
49
+ assert.match(svg, /data-animate="node" style="--step:0"/);
50
+ });
51
+ }
52
+
53
+ process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
@@ -0,0 +1,206 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawnSync } 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-cli-'));
12
+ const cli = path.join(skillRoot, 'bin/archify.mjs');
13
+
14
+ function run(args, options = {}) {
15
+ return spawnSync(process.execPath, [cli, ...args], {
16
+ cwd: options.cwd || skillRoot,
17
+ encoding: 'utf8',
18
+ env: options.env || process.env,
19
+ });
20
+ }
21
+
22
+ function copyInstalledSkill(target) {
23
+ fs.cpSync(skillRoot, target, {
24
+ recursive: true,
25
+ filter(source) {
26
+ const rel = path.relative(skillRoot, source);
27
+ return rel !== 'node_modules' && !rel.startsWith(`node_modules${path.sep}`)
28
+ && rel !== 'test' && !rel.startsWith(`test${path.sep}`);
29
+ },
30
+ });
31
+ }
32
+
33
+ test('cli: help lists commands and diagram types', () => {
34
+ const result = run(['--help']);
35
+ assert.equal(result.status, 0, result.stderr);
36
+ assert.match(result.stdout, /archify render <type>/);
37
+ assert.match(result.stdout, /archify doctor/);
38
+ assert.match(result.stdout, /archify demo \[output-directory\]/);
39
+ assert.match(result.stdout, /architecture, workflow, sequence, dataflow, lifecycle/);
40
+ });
41
+
42
+ test('cli: doctor reports a complete installation is ready', () => {
43
+ const result = run(['doctor']);
44
+ assert.equal(result.status, 0, result.stderr);
45
+ assert.match(result.stdout, /\[ok\] Node\.js v\d+/);
46
+ assert.match(result.stdout, /\[ok\] Core template/);
47
+ assert.match(result.stdout, /\[ok\] Example renderer/);
48
+ assert.match(result.stdout, /\[ok\] Standalone schema validators/);
49
+ assert.match(result.stdout, /\[ok\] architecture renderer, schema, and example/);
50
+ assert.match(result.stdout, /\[ok\] lifecycle renderer, schema, and example/);
51
+ assert.match(result.stdout, /Archify is ready\./);
52
+ });
53
+
54
+ test('cli: doctor identifies an incomplete installation', () => {
55
+ const incompleteRoot = path.join(tmp, 'incomplete-skill');
56
+ const incompleteBin = path.join(incompleteRoot, 'bin');
57
+ fs.mkdirSync(incompleteBin, { recursive: true });
58
+ fs.copyFileSync(cli, path.join(incompleteBin, 'archify.mjs'));
59
+
60
+ const result = spawnSync(process.execPath, [path.join(incompleteBin, 'archify.mjs'), 'doctor'], {
61
+ cwd: incompleteRoot,
62
+ encoding: 'utf8',
63
+ });
64
+
65
+ assert.equal(result.status, 1);
66
+ assert.match(result.stdout, /\[missing\] Core template/);
67
+ assert.match(result.stdout, /\[missing\] workflow renderer, schema, and example/);
68
+ assert.match(result.stderr, /Archify is not ready: \d+ required files? missing\./);
69
+ });
70
+
71
+ test('cli: doctor rejects a corrupt standalone validator', () => {
72
+ const corruptRoot = path.join(tmp, 'corrupt-skill');
73
+ copyInstalledSkill(corruptRoot);
74
+ fs.writeFileSync(path.join(corruptRoot, 'renderers/shared/generated-validators.mjs'), 'export const workflow = ;\n');
75
+
76
+ const result = spawnSync(process.execPath, [path.join(corruptRoot, 'bin/archify.mjs'), 'doctor'], {
77
+ cwd: corruptRoot,
78
+ encoding: 'utf8',
79
+ });
80
+
81
+ assert.equal(result.status, 1);
82
+ assert.match(result.stdout, /\[invalid\] Standalone schema validators/);
83
+ assert.match(result.stderr, /Archify is not ready: 1 runtime check failed\./);
84
+ });
85
+
86
+ test('cli: examples renders from an installed skill', () => {
87
+ const installedRoot = path.join(tmp, 'installed-skill');
88
+ copyInstalledSkill(installedRoot);
89
+
90
+ const result = spawnSync(process.execPath, [path.join(installedRoot, 'bin/archify.mjs'), 'examples'], {
91
+ cwd: installedRoot,
92
+ encoding: 'utf8',
93
+ });
94
+
95
+ assert.equal(result.status, 0, result.stderr);
96
+ for (const output of [
97
+ 'workflow-agent-tool-call-rendered.html',
98
+ 'sequence-cache-miss-request.html',
99
+ 'dataflow-product-analytics.html',
100
+ 'lifecycle-agent-run.html',
101
+ 'web-app-rendered.html',
102
+ ]) {
103
+ assert.equal(fs.existsSync(path.join(installedRoot, 'examples', output)), true, output);
104
+ }
105
+ });
106
+
107
+ test('cli: demo creates a ready-to-open diagram in a chosen directory', () => {
108
+ const outputDirectory = path.join(tmp, 'my-demo');
109
+ const output = path.join(outputDirectory, 'archify-demo.html');
110
+ const result = run(['demo', outputDirectory]);
111
+
112
+ assert.equal(result.status, 0, result.stderr);
113
+ assert.equal(fs.existsSync(output), true);
114
+ assert.match(fs.readFileSync(output, 'utf8'), /Sample Web App Diagram/);
115
+ assert.match(result.stdout, new RegExp(`Demo ready: ${output.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
116
+ assert.match(result.stdout, /Next: open the HTML in your browser/);
117
+ assert.match(result.stdout, /archify render architecture/);
118
+ });
119
+
120
+ test('cli: demo defaults to the current directory', () => {
121
+ const workingDirectory = path.join(tmp, 'default-demo');
122
+ fs.mkdirSync(workingDirectory);
123
+ const result = run(['demo'], { cwd: workingDirectory });
124
+
125
+ assert.equal(result.status, 0, result.stderr);
126
+ assert.equal(fs.existsSync(path.join(workingDirectory, 'archify-demo.html')), true);
127
+ });
128
+
129
+ test('cli: render writes a diagram html file', () => {
130
+ const out = path.join(tmp, 'workflow.html');
131
+ const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
132
+ const result = run(['render', 'workflow', input, out]);
133
+ assert.equal(result.status, 0, result.stderr);
134
+ assert.equal(fs.existsSync(out), true);
135
+ assert.match(fs.readFileSync(out, 'utf8'), /Agent Tool Call Workflow/);
136
+ });
137
+
138
+ test('cli: check validates rendered html', () => {
139
+ const out = path.join(tmp, 'workflow-check.html');
140
+ const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
141
+ assert.equal(run(['render', 'workflow', input, out]).status, 0);
142
+
143
+ const result = run(['check', out]);
144
+ assert.equal(result.status, 0, result.stderr);
145
+ assert.match(result.stdout, /"ok": true/);
146
+ });
147
+
148
+ test('cli: validate emits structured json without keeping html output', () => {
149
+ const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
150
+ const before = new Set(fs.readdirSync(tmp));
151
+ const result = run(['validate', 'workflow', input, '--json']);
152
+ assert.equal(result.status, 0, result.stderr);
153
+ const parsed = JSON.parse(result.stdout);
154
+ assert.equal(parsed.ok, true);
155
+ assert.equal(parsed.type, 'workflow');
156
+ assert.equal(parsed.checks.length, 4);
157
+ assert.deepEqual(new Set(fs.readdirSync(tmp)), before);
158
+ });
159
+
160
+ test('cli: inspect emits architecture layout json', () => {
161
+ const input = path.resolve(skillRoot, '../examples/archify-repo-grid.architecture.json');
162
+ const result = run(['inspect', 'architecture', input]);
163
+ assert.equal(result.status, 0, result.stderr);
164
+ const parsed = JSON.parse(result.stdout);
165
+ assert.equal(parsed.ok, true);
166
+ assert.equal(parsed.diagram_type, 'architecture');
167
+ assert.equal(parsed.layout.mode, 'grid');
168
+ assert.ok(parsed.components.length >= 5);
169
+ assert.ok(parsed.connections.length >= 1);
170
+ });
171
+
172
+ test('cli: validate returns renderer errors for bad input', () => {
173
+ const input = path.join(tmp, 'bad.workflow.json');
174
+ const validateTmp = path.join(tmp, 'validate-failure-tmp');
175
+ const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples/agent-tool-call.workflow.json'), 'utf8'));
176
+ doc.edges[0].to = 'ghost';
177
+ fs.writeFileSync(input, JSON.stringify(doc));
178
+ fs.mkdirSync(validateTmp);
179
+
180
+ const result = run(['validate', 'workflow', input], {
181
+ env: { ...process.env, TMPDIR: validateTmp },
182
+ });
183
+ assert.notEqual(result.status, 0);
184
+ assert.match(result.stderr, /unknown target "ghost"/);
185
+ assert.deepEqual(fs.readdirSync(validateTmp), []);
186
+ });
187
+
188
+ test('cli: validate rejects an unknown type without leaking a temp directory', () => {
189
+ const validateTmp = path.join(tmp, 'validate-unknown-type-tmp');
190
+ fs.mkdirSync(validateTmp);
191
+
192
+ const result = run(['validate', 'unknown', 'ignored.json'], {
193
+ env: {
194
+ ...process.env,
195
+ TMPDIR: validateTmp,
196
+ TMP: validateTmp,
197
+ TEMP: validateTmp,
198
+ },
199
+ });
200
+
201
+ assert.equal(result.status, 2);
202
+ assert.match(result.stderr, /Unknown diagram type "unknown"/);
203
+ assert.deepEqual(fs.readdirSync(validateTmp), []);
204
+ });
205
+
206
+ process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
@@ -0,0 +1,157 @@
1
+ // Installation contract: the shipped skill performs full JSON Schema
2
+ // validation without node_modules. AJV is a build-time dependency only; its
3
+ // standalone validators are committed and included in the distribution.
4
+ //
5
+ // A malformed-but-JSON-legal document must EXIT
6
+ // NON-ZERO with a friendly message — never crash (TypeError / is not a
7
+ // function) and never write NaN/undefined into the HTML. A random VALID
8
+ // perturbation of an example must still render (exit 0, no NaN).
9
+ //
10
+ // node --test test/*.test.mjs
11
+
12
+ import { test } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import { execFileSync } from 'node:child_process';
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+ const skillRoot = path.resolve(__dirname, '..');
22
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-degraded-'));
23
+
24
+ const EXAMPLES = {
25
+ workflow: 'agent-tool-call.workflow.json',
26
+ sequence: 'cache-miss-request.sequence.json',
27
+ dataflow: 'product-analytics.dataflow.json',
28
+ lifecycle: 'agent-run.lifecycle.json',
29
+ architecture: 'web-app.architecture.json',
30
+ };
31
+
32
+ const installedRoot = path.join(tmp, 'installed-skill');
33
+ fs.cpSync(skillRoot, installedRoot, {
34
+ recursive: true,
35
+ filter(source) {
36
+ const rel = path.relative(skillRoot, source);
37
+ return rel !== 'node_modules' && !rel.startsWith(`node_modules${path.sep}`)
38
+ && rel !== 'test' && !rel.startsWith(`test${path.sep}`);
39
+ },
40
+ });
41
+
42
+ function render(mode, doc) {
43
+ const input = path.join(tmp, `in-${Math.random().toString(36).slice(2)}.json`);
44
+ const out = path.join(tmp, 'out.html');
45
+ fs.writeFileSync(input, JSON.stringify(doc));
46
+ if (fs.existsSync(out)) fs.rmSync(out);
47
+ let code = 0;
48
+ let stderr = '';
49
+ try {
50
+ execFileSync('node', [path.join(installedRoot, `renderers/${mode}/render-${mode}.mjs`), input, out],
51
+ { stdio: ['ignore', 'ignore', 'pipe'] });
52
+ } catch (err) {
53
+ code = err.status ?? 1;
54
+ stderr = String(err.stderr || '');
55
+ }
56
+ const html = fs.existsSync(out) ? fs.readFileSync(out, 'utf8') : '';
57
+ return { code, stderr, html };
58
+ }
59
+
60
+ function assertFriendlyFailure(mode, doc, label) {
61
+ const { code, stderr, html } = render(mode, doc);
62
+ assert.notEqual(code, 0, `${label}: expected non-zero exit`);
63
+ assert.doesNotMatch(stderr, /TypeError|RangeError|is not a function|Cannot read/,
64
+ `${label}: crashed instead of reporting friendly error:\n${stderr}`);
65
+ assert.doesNotMatch(html, /NaN|undefined/, `${label}: wrote NaN/undefined into HTML`);
66
+ }
67
+
68
+ // ---- type-wrong-but-JSON-legal documents per mode ----
69
+ const ARRAY_FIELDS = {
70
+ workflow: ['lanes', 'phases', 'groups', 'mainPath', 'nodes', 'edges', 'cards'],
71
+ sequence: ['participants', 'messages', 'segments', 'activations', 'cards'],
72
+ dataflow: ['stages', 'nodes', 'flows', 'cards'],
73
+ lifecycle: ['lanes', 'states', 'transitions', 'cards'],
74
+ architecture: ['components', 'boundaries', 'connections', 'cards'],
75
+ };
76
+
77
+ for (const [mode, fields] of Object.entries(ARRAY_FIELDS)) {
78
+ for (const field of fields) {
79
+ test(`${mode}: ${field} as a string fails friendly`, () => {
80
+ const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES[mode]), 'utf8'));
81
+ if (!(field in doc)) return; // optional field absent in this example
82
+ doc[field] = 'oops';
83
+ assertFriendlyFailure(mode, doc, `${mode}.${field}`);
84
+ });
85
+ }
86
+ test(`${mode}: scalar meta fails friendly`, () => {
87
+ const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES[mode]), 'utf8'));
88
+ doc.meta = 42;
89
+ assertFriendlyFailure(mode, doc, `${mode}.meta`);
90
+ });
91
+ }
92
+
93
+ // ---- missing-coordinate fields must not yield NaN coordinates ----
94
+ test('workflow: node missing col never writes NaN', () => {
95
+ const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES.workflow), 'utf8'));
96
+ delete doc.nodes[0].col;
97
+ assertFriendlyFailure('workflow', doc, 'workflow node no col');
98
+ });
99
+ test('lifecycle: state missing col never writes NaN', () => {
100
+ const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES.lifecycle), 'utf8'));
101
+ delete doc.states[0].col;
102
+ assertFriendlyFailure('lifecycle', doc, 'lifecycle state no col');
103
+ });
104
+
105
+ // ---- property test: deterministic VALID perturbations always render ----
106
+ // Seeded PRNG (no Math.random — keeps the test reproducible across runs).
107
+ function mulberry32(seed) {
108
+ return function next() {
109
+ seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
110
+ let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
111
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
112
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
113
+ };
114
+ }
115
+
116
+ test('property: shuffling node/state order still renders (order-independence)', () => {
117
+ for (const mode of ['workflow', 'dataflow', 'lifecycle']) {
118
+ const arrKey = mode === 'lifecycle' ? 'states' : 'nodes';
119
+ for (let seed = 1; seed <= 8; seed += 1) {
120
+ const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES[mode]), 'utf8'));
121
+ const rng = mulberry32(seed);
122
+ // Fisher–Yates with the seeded PRNG.
123
+ const a = doc[arrKey];
124
+ for (let i = a.length - 1; i > 0; i -= 1) {
125
+ const j = Math.floor(rng() * (i + 1));
126
+ [a[i], a[j]] = [a[j], a[i]];
127
+ }
128
+ const { code, html } = render(mode, doc);
129
+ assert.equal(code, 0, `${mode} seed ${seed}: valid shuffle should render (exit 0)`);
130
+ assert.doesNotMatch(html, /NaN|undefined>/, `${mode} seed ${seed}: NaN in output`);
131
+ }
132
+ }
133
+ });
134
+
135
+ test('installed skill rejects unknown fields without node_modules', () => {
136
+ const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES.workflow), 'utf8'));
137
+ doc.nodes[0].colour = 'cyan';
138
+ const { code, stderr } = render('workflow', doc);
139
+ assert.notEqual(code, 0);
140
+ assert.match(stderr, /workflow schema validation failed/);
141
+ assert.match(stderr, /\/nodes\/0 \(id\/label: "user"\) must NOT have additional properties/);
142
+ assert.match(stderr, /"additionalProperty":"colour"/);
143
+ assert.doesNotMatch(stderr, /ajv is not installed|skipping JSON-schema validation/);
144
+ });
145
+
146
+ for (const mode of Object.keys(EXAMPLES)) {
147
+ test(`installed skill retains full ${mode} schema without node_modules`, () => {
148
+ const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES[mode]), 'utf8'));
149
+ doc.unknownField = true;
150
+ const { code, stderr } = render(mode, doc);
151
+ assert.notEqual(code, 0);
152
+ assert.match(stderr, new RegExp(`${mode} schema validation failed`));
153
+ assert.match(stderr, /"additionalProperty":"unknownField"/);
154
+ });
155
+ }
156
+
157
+ process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
@@ -0,0 +1,40 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawnSync } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const skillRoot = path.resolve(__dirname, '..');
10
+
11
+ test('validator freshness check accepts CRLF checkouts', () => {
12
+ const scratch = fs.mkdtempSync(path.join(skillRoot, '.validator-check-'));
13
+ try {
14
+ fs.mkdirSync(path.join(scratch, 'scripts'));
15
+ fs.mkdirSync(path.join(scratch, 'renderers', 'shared'), { recursive: true });
16
+ fs.cpSync(path.join(skillRoot, 'schemas'), path.join(scratch, 'schemas'), { recursive: true });
17
+ fs.copyFileSync(
18
+ path.join(skillRoot, 'scripts', 'generate-validators.mjs'),
19
+ path.join(scratch, 'scripts', 'generate-validators.mjs'),
20
+ );
21
+
22
+ const validator = fs.readFileSync(
23
+ path.join(skillRoot, 'renderers', 'shared', 'generated-validators.mjs'),
24
+ 'utf8',
25
+ );
26
+ fs.writeFileSync(
27
+ path.join(scratch, 'renderers', 'shared', 'generated-validators.mjs'),
28
+ validator.replace(/\r\n?|\n/g, '\r\n'),
29
+ );
30
+
31
+ const result = spawnSync(process.execPath, [
32
+ path.join(scratch, 'scripts', 'generate-validators.mjs'),
33
+ '--check',
34
+ ], { encoding: 'utf8' });
35
+
36
+ assert.equal(result.status, 0, result.stderr);
37
+ } finally {
38
+ fs.rmSync(scratch, { recursive: true, force: true });
39
+ }
40
+ });
@@ -0,0 +1,186 @@
1
+ // Unit tests for the pure geometry/text helpers that every renderer leans on.
2
+ // These are exercised only transitively by the golden byte-compares, which
3
+ // can't distinguish a geometry regression from an intentional layout change —
4
+ // so they get a direct oracle here. Zero deps: node:test + node:assert.
5
+ //
6
+ // node --test test/*.test.mjs (or: npm test)
7
+
8
+ import { test } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import {
11
+ rectsOverlap,
12
+ segmentIntersectsRect,
13
+ asArray,
14
+ isFinitePoint,
15
+ anchor,
16
+ defaultFromSide,
17
+ defaultToSide,
18
+ chosenSide,
19
+ polylinePath,
20
+ roundedPath,
21
+ labelPoint,
22
+ suggestLabelObstacleFix,
23
+ suggestComponentSeparation,
24
+ } from '../renderers/shared/geometry.mjs';
25
+ import { textUnits, applyTemplate } from '../renderers/shared/utils.mjs';
26
+
27
+ const rect = (x, y, w, h) => ({ x, y, width: w, height: h, cx: x + w / 2, cy: y + h / 2 });
28
+
29
+ test('rectsOverlap: separated rects do not overlap', () => {
30
+ assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(20, 0, 10, 10)), false);
31
+ });
32
+
33
+ test('rectsOverlap: clearly overlapping rects overlap', () => {
34
+ assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(5, 5, 10, 10)), true);
35
+ });
36
+
37
+ test('rectsOverlap: edge-touching is NOT overlap at gap 0 (<= boundary)', () => {
38
+ // a ends at x=10, b starts at x=10 — exactly touching.
39
+ assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(10, 0, 10, 10), 0), false);
40
+ });
41
+
42
+ test('rectsOverlap: positive gap flags rects within that gap as too close', () => {
43
+ // 8px apart, required gap 8 → touching the threshold counts as too close.
44
+ assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(18, 0, 10, 10), 8), false);
45
+ assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(17, 0, 10, 10), 8), true);
46
+ });
47
+
48
+ test('rectsOverlap: negative gap shrinks the hit box (label-collision convention)', () => {
49
+ // gap -2 means rects must overlap by MORE than 2px to count — a 1px sliver
50
+ // does not. This is the sign convention the label checks rely on.
51
+ assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(9, 0, 10, 10), -2), false);
52
+ assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(7, 0, 10, 10), -2), true);
53
+ });
54
+
55
+ test('segmentIntersectsRect: detects an edge crossing a node box', () => {
56
+ assert.equal(segmentIntersectsRect({ start: [0, 5], end: [20, 5] }, rect(8, 0, 4, 10)), true);
57
+ assert.equal(segmentIntersectsRect({ start: [0, 20], end: [20, 20] }, rect(8, 0, 4, 10)), false);
58
+ });
59
+
60
+ test('asArray coerces non-arrays to [] (degraded-mode guard)', () => {
61
+ assert.deepEqual(asArray([1, 2]), [1, 2]);
62
+ assert.deepEqual(asArray('oops'), []);
63
+ assert.deepEqual(asArray(undefined), []);
64
+ assert.deepEqual(asArray(null), []);
65
+ assert.deepEqual(asArray({ length: 3 }), []);
66
+ });
67
+
68
+ test('isFinitePoint rejects NaN/undefined/Infinity', () => {
69
+ assert.equal(isFinitePoint(1, 2, 3, 4), true);
70
+ assert.equal(isFinitePoint(1, NaN), false);
71
+ assert.equal(isFinitePoint(1, undefined), false);
72
+ assert.equal(isFinitePoint(1, Infinity), false);
73
+ });
74
+
75
+ test('anchor returns the correct edge midpoint for each side', () => {
76
+ const r = rect(100, 100, 40, 20); // cx=120 cy=110
77
+ assert.deepEqual(anchor(r, 'left'), [100, 110]);
78
+ assert.deepEqual(anchor(r, 'right'), [140, 110]);
79
+ assert.deepEqual(anchor(r, 'top'), [120, 100]);
80
+ assert.deepEqual(anchor(r, 'bottom'), [120, 120]);
81
+ });
82
+
83
+ test('anchor falls back to the right edge for unknown/auto sides', () => {
84
+ const r = rect(100, 100, 40, 20);
85
+ assert.deepEqual(anchor(r, 'auto'), [140, 110]);
86
+ assert.deepEqual(anchor(r, undefined), [140, 110]);
87
+ });
88
+
89
+ test('defaultFromSide / defaultToSide are mirror pairs', () => {
90
+ const a = { cx: 0, cy: 0 };
91
+ const right = { cx: 100, cy: 0 };
92
+ assert.equal(defaultFromSide(a, right), 'right');
93
+ assert.equal(defaultToSide(a, right), 'left');
94
+ const below = { cx: 0, cy: 100 };
95
+ assert.equal(defaultFromSide(a, below), 'bottom');
96
+ assert.equal(defaultToSide(a, below), 'top');
97
+ });
98
+
99
+ test('chosenSide treats explicit "auto" as "use the geometric fallback"', () => {
100
+ assert.equal(chosenSide('left', 'right'), 'left');
101
+ assert.equal(chosenSide('auto', 'right'), 'right');
102
+ assert.equal(chosenSide(undefined, 'right'), 'right');
103
+ });
104
+
105
+ test('polylinePath emits M then L commands', () => {
106
+ assert.equal(polylinePath([[0, 0], [10, 0], [10, 10]]), 'M 0 0 L 10 0 L 10 10');
107
+ });
108
+
109
+ test('roundedPath degrades to a polyline for <3 points or radius<=0', () => {
110
+ assert.equal(roundedPath([[0, 0], [10, 0]], 10), 'M 0 0 L 10 0');
111
+ assert.equal(roundedPath([[0, 0], [10, 0], [10, 10]], 0), 'M 0 0 L 10 0 L 10 10');
112
+ });
113
+
114
+ test('roundedPath inserts a quadratic corner and never emits NaN', () => {
115
+ const d = roundedPath([[0, 0], [100, 0], [100, 100]], 10);
116
+ assert.match(d, /Q 100 0/); // corner pivots on the bend point
117
+ assert.doesNotMatch(d, /NaN/);
118
+ });
119
+
120
+ test('roundedPath clamps radius to half the shorter adjacent segment', () => {
121
+ // 6px segments with radius 10 → r clamps to 3; no overshoot / NaN.
122
+ const d = roundedPath([[0, 0], [6, 0], [6, 6]], 10);
123
+ assert.doesNotMatch(d, /NaN/);
124
+ assert.match(d, /^M 0 0/);
125
+ });
126
+
127
+ test('labelPoint: 2-point path is the midpoint lifted 10px, plus offsets', () => {
128
+ assert.deepEqual(labelPoint({}, [[0, 100], [100, 100]]), [50, 90]);
129
+ assert.deepEqual(labelPoint({ labelDx: 5, labelDy: -4 }, [[0, 100], [100, 100]]), [55, 86]);
130
+ });
131
+
132
+ test('labelPoint: labelSegment selects a segment and clamps to range', () => {
133
+ const pts = [[0, 0], [100, 0], [100, 100], [200, 100]];
134
+ // segment 0 → midpoint of pts[0],pts[1] = (50,0) lifted 10
135
+ assert.deepEqual(labelPoint({ labelSegment: 0 }, pts), [50, -10]);
136
+ // segment 99 clamps to the last segment
137
+ assert.deepEqual(labelPoint({ labelSegment: 99 }, pts), [150, 90]);
138
+ });
139
+
140
+ test('labelPoint: explicit labelAt wins outright', () => {
141
+ assert.deepEqual(labelPoint({ labelAt: [7, 8] }, [[0, 0], [100, 0]]), [7, 8]);
142
+ });
143
+
144
+ test('textUnits: ASCII=1, CJK=2, mixed sums, fullwidth supplementary=2', () => {
145
+ assert.equal(textUnits('abc'), 3);
146
+ assert.equal(textUnits('中文'), 4);
147
+ assert.equal(textUnits('a中'), 3);
148
+ assert.equal(textUnits(''), 0);
149
+ assert.equal(textUnits(null), 0);
150
+ assert.equal(textUnits('𠀀'), 2); // CJK Ext-B (supplementary plane)
151
+ assert.equal(textUnits('🚀'), 2); // emoji
152
+ });
153
+
154
+ test('suggestLabelObstacleFix includes rects and labelAt/labelDy hints', () => {
155
+ const labelRect = { x: 100, y: 180, width: 48, height: 14, label: '写入' };
156
+ const obstacle = { id: 'memtool', x: 30, y: 130, width: 230, height: 58 };
157
+ const hint = suggestLabelObstacleFix(labelRect, 124, 188, obstacle);
158
+ assert.match(hint, /label rect: \[100, 180, 48, 14\]/);
159
+ assert.match(hint, /component "memtool"/);
160
+ assert.match(hint, /Suggested fix: labelAt/);
161
+ assert.match(hint, /labelDy \+\d+/);
162
+ });
163
+
164
+ test('suggestComponentSeparation proposes nudged pos', () => {
165
+ const a = { id: 'api', x: 100, y: 200, width: 120, height: 60 };
166
+ const b = { id: 'db', x: 150, y: 200, width: 120, height: 60 };
167
+ const hint = suggestComponentSeparation(a, b, 8);
168
+ assert.match(hint, /move "db" pos to \[228, 200\]/);
169
+ });
170
+
171
+ test('applyTemplate preserves dollar sequences in titles', () => {
172
+ const template = `<title>[PROJECT NAME] Architecture Diagram</title>
173
+ <h1>[PROJECT NAME] Architecture</h1>
174
+ <p class="subtitle">[Subtitle description]</p>
175
+ <!-- ARCHIFY:SVG_SLOT_START --><svg></svg> <!-- ARCHIFY:SVG_SLOT_END -->
176
+ <!-- ARCHIFY:CARDS_SLOT_START --><div></div> <!-- ARCHIFY:CARDS_SLOT_END -->
177
+ [Project Name] &bull; [Additional metadata]`;
178
+ const html = applyTemplate(template, {
179
+ title: 'Plan $$50 tier',
180
+ subtitle: 'test',
181
+ footer: 'f',
182
+ svg: '<svg/>',
183
+ cards: '',
184
+ });
185
+ assert.match(html, /Plan \$\$50 tier/);
186
+ });