@polymorphism-tech/morph-spec 10.6.0 → 10.7.1

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.
@@ -0,0 +1,104 @@
1
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { FEATURE_VIEW_FILE } from '../../core/paths/output-schema.js';
4
+ import { parsePlan } from '../plan/plan-parser.js';
5
+ import { planHash } from '../plan/checks.js';
6
+ import { reviewReportsFromDir } from '../review-reports.js';
7
+ import { implementProgressFrom } from '../../../framework/hooks/shared/report-progress.js';
8
+ import { changeSummary } from '../recap/recap.js';
9
+ import { renderFeatureHtml } from './feature-html.js';
10
+
11
+ const readText = (path) => { try { return readFileSync(path, 'utf8'); } catch { return null; } };
12
+ const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
13
+ const listDir = (path) => { try { return readdirSync(path); } catch { return []; } };
14
+
15
+ const GATE_ASK = { plan: 'Plano', uiux: 'Design', review: 'Revisão' };
16
+
17
+ function collectDesign(featureDir) {
18
+ const screens = (sub) => listDir(join(featureDir, '1-design', sub))
19
+ .filter((f) => f.endsWith('.html'))
20
+ .map((f) => {
21
+ const name = f.replace(/\.html$/, '');
22
+ const shot = `${name}-1280.png`;
23
+ return { name, kind: sub, href: `1-design/${sub}/${f}`, shot: existsSync(join(featureDir, '1-design', sub, shot)) ? `1-design/${sub}/${shot}` : null };
24
+ });
25
+ return [...screens('prototipos'), ...screens('opcoes')];
26
+ }
27
+
28
+ function stations({ gates, design, progress, reviews, archived }) {
29
+ const approved = (g) => gates[g]?.approved === true || gates[g] === true;
30
+ const hasReview = Object.values(reviews).some((r) => r.status !== 'missing');
31
+ const built = hasReview || (progress?.total > 0 && progress.done === progress.total);
32
+ const list = [
33
+ { id: 'plan', label: 'Plano', done: approved('plan') },
34
+ ...(design ? [{ id: 'uiux', label: 'Design', done: approved('uiux') }] : []),
35
+ { id: 'implement', label: 'Implementação', done: built, note: progress?.total ? `${progress.done} de ${progress.total}` : null },
36
+ { id: 'review', label: 'Revisão', done: approved('review') },
37
+ { id: 'ship', label: 'Entrega', done: archived },
38
+ ];
39
+ const now = list.findIndex((s) => !s.done);
40
+ return list.map((s, i) => ({ ...s, state: s.done ? 'done' : i === now ? 'now' : 'next' }));
41
+ }
42
+
43
+ function currentGate(fj) {
44
+ const pending = ['plan', 'uiux', 'review'].find((g) => fj.gateDecisions?.[g] && !(fj.approvalGates?.[g]?.approved === true));
45
+ if (!pending) return null;
46
+ const d = fj.gateDecisions[pending];
47
+ return { gate: pending, label: GATE_ASK[pending], pause: d.pause === true, resolver: d.resolver ?? null, reason: d.reason ?? null };
48
+ }
49
+
50
+ /** Everything the page shows, read from the feature folder; a missing file only drops its part. */
51
+ export function collectFeatureView(featureDir, feature, { changes = null, archived = false } = {}) {
52
+ const fj = readJson(join(featureDir, 'feature.json')) ?? {};
53
+ const planText = readText(join(featureDir, '2-plan', 'plan.md'));
54
+ const plan = parsePlan(planText ?? '');
55
+ const recap = readJson(join(featureDir, 'recap.json'));
56
+ const reviews = reviewReportsFromDir(featureDir);
57
+ const progress = implementProgressFrom(readText(join(featureDir, '2-plan', 'checks.json')), readText(join(featureDir, '3-implement', 'report.md')));
58
+ const design = collectDesign(featureDir);
59
+ const hasDesign = design.length > 0 || fj.approvalGates?.uiux !== undefined || existsSync(join(featureDir, '1-design'));
60
+ const proved = new Map([
61
+ ...(recap?.scenarios ?? []).filter((c) => c.status).map((c) => [c.id, c]),
62
+ ...(reviews.verifier?.checks ?? []).map((c) => [c.id, c]),
63
+ ]);
64
+ const scenarios = plan.scenarios.map((c) => {
65
+ const row = progress?.checks.find((p) => p.id === c.id);
66
+ return { ...c, status: row?.status ?? 'pendente', evidence: row?.evidence ?? '', proved: proved.get(c.id)?.status ?? null, proof_note: proved.get(c.id)?.evidence ?? '' };
67
+ });
68
+ return {
69
+ feature,
70
+ description: fj.description ?? null,
71
+ workType: fj.workType ?? null,
72
+ plan: { ...plan, scenarios },
73
+ planHash: planText ? planHash(planText) : null,
74
+ gate: currentGate(fj),
75
+ gates: fj.approvalGates ?? {},
76
+ stations: stations({ gates: fj.approvalGates ?? {}, design: hasDesign, progress, reviews, archived: archived || fj.status === 'completed' }),
77
+ progress,
78
+ verification: fj.verification ?? null,
79
+ reviews,
80
+ critique: readText(join(featureDir, '2-plan', 'critica.md')),
81
+ design: archived ? [] : design,
82
+ changes,
83
+ recap,
84
+ };
85
+ }
86
+
87
+ /** Rewrites `feature.html` in place. Never throws: the page must not fail the command that refreshed it. */
88
+ export function writeFeatureView(featureDir, feature, { cwd = null, archived = false, changes } = {}) {
89
+ try {
90
+ const stat = changes !== undefined ? changes : (cwd && changeSummary(cwd, { statOnly: true }));
91
+ const path = join(featureDir, FEATURE_VIEW_FILE);
92
+ writeFileSync(path, renderFeatureHtml(collectFeatureView(featureDir, feature, { changes: stat ?? null, archived })));
93
+ return { ok: true, path };
94
+ } catch (err) {
95
+ return { ok: false, reason: err.message };
96
+ }
97
+ }
98
+
99
+ /** The page exists once the plan passed `plan-check`; before that there is nothing to decide on. */
100
+ export function refreshFeatureView(cwd, feature) {
101
+ const dir = join(cwd, '.morph', 'features', feature);
102
+ if (!existsSync(join(dir, '2-plan', 'checks.json'))) return null;
103
+ return writeFeatureView(dir, feature, { cwd });
104
+ }
@@ -0,0 +1,63 @@
1
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
2
+
3
+ const MIN_NODE_W = 132;
4
+ const CHAR_W = 7.4;
5
+ const NODE_H = 40;
6
+ const GAP = 84;
7
+ const LANE = 38;
8
+
9
+ /** States in the order a reader meets them: breadth-first from the first transition's origin. */
10
+ export function stateOrder(transitions) {
11
+ const order = [];
12
+ const seen = new Set();
13
+ const queue = transitions.length ? [transitions[0].from] : [];
14
+ const visit = (s) => { if (!seen.has(s)) { seen.add(s); queue.push(s); } };
15
+ if (queue.length) seen.add(queue[0]);
16
+ while (queue.length) {
17
+ const s = queue.shift();
18
+ order.push(s);
19
+ for (const t of transitions) if (t.from === s) visit(t.to);
20
+ }
21
+ for (const t of transitions) for (const s of [t.from, t.to]) if (!seen.has(s)) { seen.add(s); order.push(s); }
22
+ return order;
23
+ }
24
+
25
+ // One row of states; a forward transition arcs above, a backward one below, each in its own lane so
26
+ // labels never stack; a self-transition loops over its node.
27
+ export function stateMachineSvg(transitions, label = 'Máquina de estados', id = 'arrow') {
28
+ if (!transitions.length) return '';
29
+ const order = stateOrder(transitions);
30
+ const NODE_W = Math.max(MIN_NODE_W, Math.ceil(Math.max(...order.map((s) => s.length)) * CHAR_W) + 24);
31
+ const x = (s) => 24 + order.indexOf(s) * (NODE_W + GAP);
32
+ const span = (t) => Math.abs(order.indexOf(t.to) - order.indexOf(t.from));
33
+ const forward = transitions.filter((t) => order.indexOf(t.to) > order.indexOf(t.from)).sort((a, b) => span(a) - span(b));
34
+ const backward = transitions.filter((t) => order.indexOf(t.to) < order.indexOf(t.from)).sort((a, b) => span(a) - span(b));
35
+ const loops = transitions.filter((t) => t.to === t.from);
36
+ const top = 24 + (forward.length + (loops.length ? 1 : 0)) * LANE;
37
+ const height = top + NODE_H + 24 + backward.length * LANE;
38
+ const width = 48 + order.length * NODE_W + (order.length - 1) * GAP;
39
+ const mid = (s) => x(s) + NODE_W / 2;
40
+
41
+ const edge = (t, i, above) => {
42
+ const x1 = mid(t.from) + (above ? 18 : -18);
43
+ const x2 = mid(t.to) + (above ? -18 : 18);
44
+ const y0 = above ? top : top + NODE_H;
45
+ const y = above ? top - (i + 1) * LANE : top + NODE_H + (i + 1) * LANE;
46
+ const lx = (x1 + x2) / 2;
47
+ return `<path class="edge" d="M${x1} ${y0} C${x1} ${y} ${x2} ${y} ${x2} ${y0}" marker-end="url(#${id})"/>`
48
+ + `<text class="edge-label" x="${lx}" y="${y + (above ? 0 : 14)}" text-anchor="middle"><title>${esc(t.trigger)}</title>${esc(t.trigger)}</text>`;
49
+ };
50
+ const loop = (t) => {
51
+ const cx = mid(t.from);
52
+ return `<path class="edge" d="M${cx - 14} ${top} C${cx - 30} ${top - LANE} ${cx + 30} ${top - LANE} ${cx + 14} ${top}" marker-end="url(#${id})"/>`
53
+ + `<text class="edge-label" x="${cx}" y="${top - LANE + 4}" text-anchor="middle">${esc(t.trigger)}</text>`;
54
+ };
55
+
56
+ return `<svg class="machine" viewBox="0 0 ${width} ${height}" style="width:100%;min-width:${Math.round(width * 0.85)}px" role="img" aria-label="${esc(label)}: ${esc(order.join(', '))}">
57
+ <defs><marker id="${id}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" class="arrow-head"/></marker></defs>
58
+ ${forward.map((t, i) => edge(t, i, true)).join('\n')}
59
+ ${backward.map((t, i) => edge(t, i, false)).join('\n')}
60
+ ${loops.map(loop).join('\n')}
61
+ ${order.map((s) => `<g class="state"><rect x="${x(s)}" y="${top}" width="${NODE_W}" height="${NODE_H}" rx="10"/><text x="${mid(s)}" y="${top + NODE_H / 2 + 5}" text-anchor="middle">${esc(s)}</text></g>`).join('\n')}
62
+ </svg>`;
63
+ }
@@ -0,0 +1,18 @@
1
+ :root {
2
+ color-scheme: light dark;
3
+ --paper:#eef2f0; --panel:#fbfcfb; --ink:#12282b; --dim:#3f5558; --faint:#5a6e70; --rule:#cfdad7;
4
+ --route:#0b6e75; --sign:#ffc629; --on-sign:#1c1a0a;
5
+ --ok:#1f7a4d; --warn:#935500; --bad:#b4232f;
6
+ --s1:#2a78d6; --s2:#eb6834; --s3:#1baf7a; --s4:#eda100; --s5:#e87ba4;
7
+ --display:"Bahnschrift","DIN Alternate","DIN Next","Avenir Next",system-ui,sans-serif;
8
+ --text:"Segoe UI Variable Text","Segoe UI",-apple-system,"Helvetica Neue",system-ui,sans-serif;
9
+ --code:"Cascadia Code",ui-monospace,Menlo,Consolas,monospace;
10
+ }
11
+ @media (prefers-color-scheme: dark) {
12
+ :root {
13
+ --paper:#0e1c1e; --panel:#152729; --ink:#e6efee; --dim:#adc1c0; --faint:#8ba1a2; --rule:#2a4144;
14
+ --route:#45c4c9;
15
+ --ok:#5cc28a; --warn:#e2a54b; --bad:#f07178;
16
+ --s1:#3987e5; --s2:#d95926; --s3:#199e70; --s4:#c98500; --s5:#d55181;
17
+ }
18
+ }
@@ -1,125 +0,0 @@
1
- const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
2
-
3
- const NODE_W = 132;
4
- const NODE_H = 40;
5
- const GAP = 84;
6
- const LANE = 38;
7
-
8
- /** States in the order a reader meets them: breadth-first from the first transition's origin. */
9
- export function stateOrder(transitions) {
10
- const order = [];
11
- const seen = new Set();
12
- const queue = transitions.length ? [transitions[0].from] : [];
13
- const visit = (s) => { if (!seen.has(s)) { seen.add(s); queue.push(s); } };
14
- if (queue.length) seen.add(queue[0]);
15
- while (queue.length) {
16
- const s = queue.shift();
17
- order.push(s);
18
- for (const t of transitions) if (t.from === s) visit(t.to);
19
- }
20
- for (const t of transitions) for (const s of [t.from, t.to]) if (!seen.has(s)) { seen.add(s); order.push(s); }
21
- return order;
22
- }
23
-
24
- // One row of states; a forward transition arcs above, a backward one below, each in its own lane so
25
- // labels never stack; a self-transition loops over its node.
26
- export function stateMachineSvg(transitions) {
27
- if (!transitions.length) return '';
28
- const order = stateOrder(transitions);
29
- const x = (s) => 24 + order.indexOf(s) * (NODE_W + GAP);
30
- const span = (t) => Math.abs(order.indexOf(t.to) - order.indexOf(t.from));
31
- const forward = transitions.filter((t) => order.indexOf(t.to) > order.indexOf(t.from)).sort((a, b) => span(a) - span(b));
32
- const backward = transitions.filter((t) => order.indexOf(t.to) < order.indexOf(t.from)).sort((a, b) => span(a) - span(b));
33
- const loops = transitions.filter((t) => t.to === t.from);
34
- const top = 24 + (forward.length + (loops.length ? 1 : 0)) * LANE;
35
- const height = top + NODE_H + 24 + backward.length * LANE;
36
- const width = 48 + order.length * NODE_W + (order.length - 1) * GAP;
37
- const mid = (s) => x(s) + NODE_W / 2;
38
-
39
- const edge = (t, i, above) => {
40
- const x1 = mid(t.from) + (above ? 18 : -18);
41
- const x2 = mid(t.to) + (above ? -18 : 18);
42
- const y0 = above ? top : top + NODE_H;
43
- const y = above ? top - (i + 1) * LANE : top + NODE_H + (i + 1) * LANE;
44
- const lx = (x1 + x2) / 2;
45
- return `<path class="edge" d="M${x1} ${y0} C${x1} ${y} ${x2} ${y} ${x2} ${y0}" marker-end="url(#arrow)"/>`
46
- + `<text class="edge-label" x="${lx}" y="${y + (above ? 0 : 14)}" text-anchor="middle"><title>${esc(t.trigger)}</title>${esc(t.trigger)}</text>`;
47
- };
48
- const loop = (t) => {
49
- const cx = mid(t.from);
50
- return `<path class="edge" d="M${cx - 14} ${top} C${cx - 30} ${top - LANE} ${cx + 30} ${top - LANE} ${cx + 14} ${top}" marker-end="url(#arrow)"/>`
51
- + `<text class="edge-label" x="${cx}" y="${top - LANE + 4}" text-anchor="middle">${esc(t.trigger)}</text>`;
52
- };
53
-
54
- return `<svg class="machine" viewBox="0 0 ${width} ${height}" style="width:100%;min-width:${Math.round(width * 0.6)}px" role="img" aria-label="Máquina de estados: ${esc(order.join(', '))}">
55
- <defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" class="arrow-head"/></marker></defs>
56
- ${forward.map((t, i) => edge(t, i, true)).join('\n')}
57
- ${backward.map((t, i) => edge(t, i, false)).join('\n')}
58
- ${loops.map(loop).join('\n')}
59
- ${order.map((s) => `<g class="state"><rect x="${x(s)}" y="${top}" width="${NODE_W}" height="${NODE_H}" rx="10"/><text x="${mid(s)}" y="${top + NODE_H / 2 + 5}" text-anchor="middle">${esc(s)}</text></g>`).join('\n')}
60
- </svg>`;
61
- }
62
-
63
- const RUNG_TONE = { 'migrar': 'warn', 'irreversível': 'bad', 'irreversivel': 'bad' };
64
-
65
- function scenarioCard(c) {
66
- const chips = [
67
- c.proof && `<span class="chip">prova: ${esc(c.proof)}</span>`,
68
- c.risk && `<span class="chip">risco: ${esc(c.risk)}</span>`,
69
- c.rung && `<span class="chip ${RUNG_TONE[c.rung.toLowerCase()] ?? ''}">degrau: ${esc(c.rung)}</span>`,
70
- ].filter(Boolean).join('');
71
- return `<li class="card"><div class="cid">${esc(c.id)}</div><div><p>${esc(c.text)}</p>${chips ? `<div class="chips">${chips}</div>` : ''}</div></li>`;
72
- }
73
-
74
- export function renderPlanHtml(feature, plan, { planHash = null } = {}) {
75
- const defaults = plan.defaults?.items ?? [];
76
- const transitions = plan.states?.transitions ?? [];
77
- const section = (title, body) => `<section><h2>${esc(title)}</h2>${body}</section>`;
78
- return `<!doctype html>
79
- <html lang="pt-BR">
80
- <head>
81
- <meta charset="utf-8">
82
- <meta name="viewport" content="width=device-width, initial-scale=1">
83
- <title>Plano — ${esc(feature)}</title>
84
- <style>
85
- :root { --bg:#f6f7f9; --card:#fff; --fg:#14171c; --dim:#4b5260; --faint:#7a8291; --hair:#dfe3ea; --ok:#2f7d4f; --warn:#a35f00; --bad:#b4232f; --accent:#2d5bd7; }
86
- @media (prefers-color-scheme: dark) { :root { --bg:#0f1115; --card:#171a21; --fg:#e8ebf0; --dim:#b3bac6; --faint:#7f8796; --hair:#2a2f3a; --ok:#5cc28a; --warn:#e2a54b; --bad:#f07178; --accent:#8fb0ff; } }
87
- * { box-sizing:border-box; }
88
- body { margin:0; background:var(--bg); color:var(--fg); font:15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; }
89
- main { max-width:920px; margin:0 auto; padding:40px 16px 64px; }
90
- h1 { font-size:28px; margin:0 0 4px; letter-spacing:-.01em; }
91
- .meta { color:var(--faint); font-size:13px; margin:0 0 32px; }
92
- h2 { font-size:15px; margin:32px 0 12px; }
93
- ul.cards { list-style:none; padding:0; margin:0; display:grid; gap:10px; }
94
- .card { display:grid; grid-template-columns:3rem minmax(0,1fr); gap:12px; background:var(--card); border:1px solid var(--hair); border-radius:12px; padding:14px 16px; }
95
- .card p { margin:0; }
96
- .cid { font:600 13px/1.6 ui-monospace, monospace; color:var(--accent); }
97
- .chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
98
- .chip { font-size:12px; color:var(--dim); border:1px solid var(--hair); border-radius:999px; padding:1px 9px; }
99
- .chip.warn { color:var(--warn); border-color:currentColor; }
100
- .chip.bad { color:var(--bad); border-color:currentColor; }
101
- ol.defaults { margin:0; padding-left:22px; color:var(--dim); }
102
- ol.defaults li { margin:4px 0; }
103
- .scroll { overflow-x:auto; background:var(--card); border:1px solid var(--hair); border-radius:12px; padding:12px; }
104
- .machine .state rect { fill:var(--bg); stroke:var(--accent); stroke-width:1.5; }
105
- .machine .state text { fill:var(--fg); font:600 13px system-ui, sans-serif; }
106
- .machine .edge { fill:none; stroke:var(--faint); stroke-width:1.5; }
107
- .machine .arrow-head { fill:var(--faint); }
108
- .machine .edge-label { fill:var(--dim); font:12px system-ui, sans-serif; paint-order:stroke; stroke:var(--card); stroke-width:4px; }
109
- .none { color:var(--faint); }
110
- </style>
111
- </head>
112
- <body>
113
- <main>
114
- <h1>${esc(feature)}</h1>
115
- <p class="meta">Visão do plano, só leitura — gerada pelo <code>morph-spec plan-check</code> a partir de 2-plan/plan.md${planHash ? ` (${esc(planHash.slice(0, 12))})` : ''}. Para mudar, edite o plan.md.</p>
116
- ${section(`Cenários (${plan.scenarios.length})`, `<ul class="cards">${plan.scenarios.map(scenarioCard).join('')}</ul>`)}
117
- ${transitions.length ? section('Estados', `<div class="scroll">${stateMachineSvg(transitions)}</div>`) : ''}
118
- ${section('Padrões assumidos', defaults.length
119
- ? `<ol class="defaults">${defaults.map((d) => `<li value="${d.n}">${esc(d.text)}</li>`).join('')}</ol><p class="meta">Para vetar um, responda "veta N" no gate.</p>`
120
- : '<p class="none">Nenhum.</p>')}
121
- </main>
122
- </body>
123
- </html>
124
- `;
125
- }