@polymorphism-tech/morph-spec 10.5.4 → 10.7.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/CLAUDE.md +1 -0
- package/README.md +2 -2
- package/bin/morph-spec.js +9 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/docs/CLI.md +7 -2
- package/framework/hooks/shared/phase-utils.js +0 -2
- package/framework/rules/plan-grammar.md +6 -2
- package/framework/schemas/config.schema.json +10 -0
- package/framework/schemas/feature.schema.json +12 -2
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +2 -2
- package/framework/skills/core/morph-apply/references/review.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +5 -4
- package/framework/skills/core/morph-uiux/SKILL.md +2 -2
- package/framework/templates/docs/plan.md +3 -3
- package/package.json +1 -1
- package/src/commands/state/finish.js +99 -11
- package/src/commands/state/recap.js +6 -5
- package/src/commands/validation/plan-check.js +4 -7
- package/src/commands/validation/verify.js +13 -4
- package/src/core/paths/output-schema.js +3 -1
- package/src/lib/ci/ci-verify.js +16 -0
- package/src/lib/ci/pr-checks.js +76 -0
- package/src/lib/plan/checks.js +6 -6
- package/src/lib/plan/plan-parser.js +3 -0
- package/src/lib/recap/recap.js +6 -5
- package/src/lib/validators/verify-runner.js +6 -3
- package/src/lib/view/feature-html.js +307 -0
- package/src/lib/view/feature-page.client.js +116 -0
- package/src/lib/view/feature-page.css +169 -0
- package/src/lib/view/feature-view.js +104 -0
- package/src/lib/view/flow-svg.js +63 -0
- package/src/lib/view/tokens.css +18 -0
- package/src/lib/plan/plan-html.js +0 -125
|
@@ -13,7 +13,7 @@ import { HeavyBusyError } from '../../lib/heavy/gate.js';
|
|
|
13
13
|
import { afterHeavyRun } from '../../lib/heavy/cleanup.js';
|
|
14
14
|
import { memoryBusyHint } from '../../lib/heavy/live-stacks.js';
|
|
15
15
|
import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
16
|
-
import { runCiVerify, CiUsageError, reusableLocalStamp } from '../../lib/ci/ci-verify.js';
|
|
16
|
+
import { runCiVerify, CiUsageError, reusableLocalStamp, resolvePrProof } from '../../lib/ci/ci-verify.js';
|
|
17
17
|
import { syncStackRules } from '../../lib/installers/stack-sync.js';
|
|
18
18
|
import {
|
|
19
19
|
DETACH_RUN_ENV, DETACH_STAGE_ENV, DETACH_WORKER_ENV, launchWorker, markWorkerExit, runFilesOf, superviseVerify,
|
|
@@ -137,6 +137,7 @@ async function runLocalVerify(featureName, options, fingerprint) {
|
|
|
137
137
|
fingerprint,
|
|
138
138
|
fresh: options.fresh,
|
|
139
139
|
affected: options.affected,
|
|
140
|
+
excludeNodes: options.excludeNodes,
|
|
140
141
|
timeout: options.timeout,
|
|
141
142
|
skipTests: options.skipTests,
|
|
142
143
|
skipBuild: options.skipBuild,
|
|
@@ -231,14 +232,19 @@ export async function verifyCommand(featureName, options = {}) {
|
|
|
231
232
|
}
|
|
232
233
|
|
|
233
234
|
let ci = null;
|
|
235
|
+
let prProof = null;
|
|
234
236
|
if (options.ci) {
|
|
235
237
|
const fresh = reusableLocalStamp(process.cwd(), feature.verification);
|
|
236
238
|
if (fresh) exitWithReusedStamp(featureName, fresh, 'stamp verde e fresco — o CI não roda de novo', options);
|
|
239
|
+
prProof = resolvePrProof(process.cwd(), featureName, readMorphConfig(process.cwd()));
|
|
240
|
+
if (prProof?.local) console.error(chalk.yellow(`\n↪ completa local: ${prProof.local}\n`));
|
|
237
241
|
try {
|
|
238
|
-
const outcome = await runCiVerify(process.cwd(), featureName, readMorphConfig(process.cwd()), {
|
|
242
|
+
const outcome = prProof ? null : await runCiVerify(process.cwd(), featureName, readMorphConfig(process.cwd()), {
|
|
239
243
|
log: (line) => console.error(chalk.gray(` ${line}`)),
|
|
240
244
|
});
|
|
241
|
-
if (outcome
|
|
245
|
+
if (!outcome) {
|
|
246
|
+
// The PR proof has no run to wait for here: `finish --pr` waits for it.
|
|
247
|
+
} else if (outcome.mode === 'ci') {
|
|
242
248
|
ci = outcome;
|
|
243
249
|
} else if (outcome.mode === 'pending') {
|
|
244
250
|
if (options.json) console.log(JSON.stringify({ status: 'pending', persisted: false, reason: outcome.reason }, null, 2));
|
|
@@ -259,7 +265,8 @@ export async function verifyCommand(featureName, options = {}) {
|
|
|
259
265
|
// Taken before the run: it is the tree the run is about to read.
|
|
260
266
|
const fingerprint = treeFingerprint(process.cwd());
|
|
261
267
|
|
|
262
|
-
const
|
|
268
|
+
const pendingPr = prProof?.covers ? prProof : null;
|
|
269
|
+
const result = ci ? ci.result : await runLocalVerify(featureName, { ...options, excludeNodes: pendingPr?.covers }, fingerprint);
|
|
263
270
|
|
|
264
271
|
const cleanup = ci ? { status: 'skipped', reason: 'ci' } : afterHeavyRun({
|
|
265
272
|
projectPath: process.cwd(),
|
|
@@ -305,6 +312,7 @@ export async function verifyCommand(featureName, options = {}) {
|
|
|
305
312
|
ts: now,
|
|
306
313
|
...(result.flaky?.length ? { flaky: result.flaky } : {}),
|
|
307
314
|
...(ci ? { source: 'ci', ciRun: ci.run.url } : {}),
|
|
315
|
+
...(pendingPr && result.status === 'pass' ? { pendingPr } : {}),
|
|
308
316
|
};
|
|
309
317
|
if (!scopedRun) await updateFeature(featureName, 'verification', stamp);
|
|
310
318
|
}
|
|
@@ -334,6 +342,7 @@ export async function verifyCommand(featureName, options = {}) {
|
|
|
334
342
|
Array.isArray(node.runs) ? { ...withoutOutput(node), runs: node.runs.map(withoutOutput) } : withoutOutput(node)]));
|
|
335
343
|
const output = {
|
|
336
344
|
...result, nodes, persisted: persist, ...(persist ? {} : { reason }), repeatOf, cleanup,
|
|
345
|
+
...(pendingPr ? { pendingPr } : {}),
|
|
337
346
|
...(ci ? { source: 'ci', ciRun: { id: ci.run.databaseId, url: ci.run.url, sha: ci.run.headSha }, workflowCreated: ci.created } : {}),
|
|
338
347
|
};
|
|
339
348
|
console.log(JSON.stringify(output, null, 2));
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { join } from 'path';
|
|
2
2
|
|
|
3
|
+
/** The one page of a feature, at its root: it belongs to no phase, and only the CLI writes it. */
|
|
4
|
+
export const FEATURE_VIEW_FILE = 'feature.html';
|
|
5
|
+
|
|
3
6
|
export const OUTPUT_SCHEMA = {
|
|
4
7
|
plan: { filename: 'plan.md', phaseDir: '2-plan', phase: 'plan', protected: true, approvalGate: 'plan' },
|
|
5
8
|
checks: { filename: 'checks.json', phaseDir: '2-plan', phase: 'plan', protected: true, approvalGate: 'plan' },
|
|
6
9
|
planCritique: { filename: 'critica.md', phaseDir: '2-plan', phase: 'plan', protected: false },
|
|
7
|
-
planView: { filename: 'plan.html', phaseDir: '2-plan', phase: 'plan', protected: false },
|
|
8
10
|
|
|
9
11
|
uiMockups: { filename: 'mockups.md', phaseDir: '1-design', phase: 'uiux', protected: true, approvalGate: 'uiux' },
|
|
10
12
|
|
package/src/lib/ci/ci-verify.js
CHANGED
|
@@ -8,7 +8,10 @@ import {
|
|
|
8
8
|
CI_WORKFLOW_DIR, CI_WORKFLOW_FILE, ciAvailability, downloadResult, findCiWorkflow, pushBranch,
|
|
9
9
|
uncommittedCode, waitForRun, workflowPushProblem,
|
|
10
10
|
} from './github-ci.js';
|
|
11
|
+
import { prProofConfig } from './pr-checks.js';
|
|
11
12
|
import { renderCiWorkflow } from './workflow-scaffold.js';
|
|
13
|
+
import { resolveTestNodes } from '../tasks/test-nodes.js';
|
|
14
|
+
import { selectAffected } from '../verify/affected.js';
|
|
12
15
|
|
|
13
16
|
export const CI_WAIT_MS = 50 * 60 * 1000;
|
|
14
17
|
const APPEAR_MS = Number(process.env.MORPH_CI_APPEAR_MS ?? 90 * 1000);
|
|
@@ -58,6 +61,19 @@ export function reusableLocalStamp(root, stamp) {
|
|
|
58
61
|
return treeMovedSince(root, stamp.sha, head) ? null : stamp;
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
/**
|
|
65
|
+
* `covers` = suites the PR's own checks prove, left out of the local run. `local` = run everything here,
|
|
66
|
+
* with the reason. Null = the project declared no PR proof. What the affected run would escalate stays local.
|
|
67
|
+
*/
|
|
68
|
+
export function resolvePrProof(root, featureName, config, { select = selectAffected } = {}) {
|
|
69
|
+
const proof = prProofConfig(config);
|
|
70
|
+
if (!proof) return null;
|
|
71
|
+
const avail = ciAvailability(root, featureName, config);
|
|
72
|
+
if (!avail.ok) return { local: avail.reason };
|
|
73
|
+
const selection = select({ projectPath: root, nodes: resolveTestNodes(root).nodes });
|
|
74
|
+
return selection.scope === 'full' ? { local: selection.reason } : proof;
|
|
75
|
+
}
|
|
76
|
+
|
|
61
77
|
// Every way CI cannot answer is `mode: 'local'` with the reason: no CI is never a pause. Only the
|
|
62
78
|
// uploaded `verify --json` becomes a result, judged by the same stamp and rules as a local run.
|
|
63
79
|
export async function runCiVerify(root, featureName, config, { log = () => {}, waitMs = CI_WAIT_MS, wait = waitForRun } = {}) {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { resolveGh } from '../git/gh.js';
|
|
3
|
+
|
|
4
|
+
const FAILED = new Set(['FAILURE', 'TIMED_OUT']);
|
|
5
|
+
const PASSED = new Set(['SUCCESS', 'SKIPPED', 'NEUTRAL']);
|
|
6
|
+
|
|
7
|
+
// A job killed by exhausted Actions quota fails within seconds without running a step.
|
|
8
|
+
const MIN_JUDGED_MS = 30 * 1000;
|
|
9
|
+
|
|
10
|
+
export const PR_WAIT_MS = 50 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
/** The PR proof a project declared; null when it closes by merge, opted out of CI or declared nothing. */
|
|
13
|
+
export function prProofConfig(config) {
|
|
14
|
+
if (config?.project?.ci === false || config?.finish?.mode === 'merge') return null;
|
|
15
|
+
const { workflows, covers } = config?.project?.prChecks ?? {};
|
|
16
|
+
const filled = (list) => Array.isArray(list) && list.length > 0;
|
|
17
|
+
return filled(workflows) && filled(covers) ? { workflows, covers } : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function neverRan(check) {
|
|
21
|
+
const elapsed = Date.parse(check.completedAt) - Date.parse(check.startedAt);
|
|
22
|
+
return !Number.isFinite(elapsed) || elapsed < MIN_JUDGED_MS;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Only a declared workflow counts: a green lint or deploy run says nothing about the suite. */
|
|
26
|
+
export function judgeChecks(rollup, workflows) {
|
|
27
|
+
const checks = (rollup ?? []).filter((check) => workflows.includes(check.workflowName));
|
|
28
|
+
if (checks.length === 0) return { state: 'absent' };
|
|
29
|
+
if (checks.some((check) => check.status !== 'COMPLETED')) return { state: 'running' };
|
|
30
|
+
|
|
31
|
+
const failed = checks.filter((check) => FAILED.has(check.conclusion));
|
|
32
|
+
const real = failed.filter((check) => !neverRan(check));
|
|
33
|
+
if (real.length > 0) return { state: 'fail', failed: real };
|
|
34
|
+
if (failed.length > 0) return { state: 'no-verdict', reason: `${failed[0].workflowName} morreu sem rodar (cota do Actions?)` };
|
|
35
|
+
|
|
36
|
+
const undecided = checks.find((check) => !PASSED.has(check.conclusion));
|
|
37
|
+
if (undecided) return { state: 'no-verdict', reason: `${undecided.workflowName}: ${undecided.conclusion}` };
|
|
38
|
+
if (!checks.some((check) => check.conclusion === 'SUCCESS')) return { state: 'no-verdict', reason: 'nenhum check julgou o código' };
|
|
39
|
+
return { state: 'pass' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readPr(root, branch) {
|
|
43
|
+
try {
|
|
44
|
+
const { bin, prefix } = resolveGh();
|
|
45
|
+
return JSON.parse(execFileSync(bin, [...prefix, 'pr', 'view', branch, '--json', 'headRefOid,statusCheckRollup'], { cwd: root, stdio: 'pipe', timeout: 15000 }).toString());
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Waits `appearMs` even when the first workflow already settled: the others may not have been created yet.
|
|
53
|
+
* @returns {Promise<{ state: 'pass'|'fail'|'no-verdict'|'pending', failed?: object[], reason?: string }>}
|
|
54
|
+
*/
|
|
55
|
+
export async function waitForPrChecks(root, { branch, sha, workflows, timeoutMs = PR_WAIT_MS,
|
|
56
|
+
appearMs = Number(process.env.MORPH_CI_APPEAR_MS ?? 90 * 1000), pollMs = 15000,
|
|
57
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now, read = readPr }) {
|
|
58
|
+
const start = now();
|
|
59
|
+
let misses = 0;
|
|
60
|
+
for (;;) {
|
|
61
|
+
const elapsed = now() - start;
|
|
62
|
+
const pr = read(root, branch);
|
|
63
|
+
if (pr === null) {
|
|
64
|
+
if (++misses >= 3) return { state: 'no-verdict', reason: 'gh pr view não respondeu' };
|
|
65
|
+
} else {
|
|
66
|
+
misses = 0;
|
|
67
|
+
const verdict = pr.headRefOid === sha ? judgeChecks(pr.statusCheckRollup, workflows) : { state: 'absent' };
|
|
68
|
+
if (elapsed >= appearMs) {
|
|
69
|
+
if (verdict.state === 'absent') return { state: 'no-verdict', reason: `nenhum check de ${workflows.join(', ')} neste commit` };
|
|
70
|
+
if (verdict.state !== 'running') return verdict;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (elapsed >= timeoutMs) return { state: 'pending', reason: `checks não terminaram em ${Math.round(timeoutMs / 60000)} min` };
|
|
74
|
+
await sleep(pollMs);
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/lib/plan/checks.js
CHANGED
|
@@ -40,11 +40,11 @@ function defaultsWarnings(plan, at) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
// Optional: absent is fine; present, every line is a transition the plan view can draw.
|
|
43
|
-
function
|
|
44
|
-
if (!plan.present
|
|
45
|
-
const { transitions, invalid } = plan
|
|
46
|
-
const errors = invalid.map((l) => `## ${SECTIONS
|
|
47
|
-
if (transitions.length === 0 && invalid.length === 0) errors.push(`## ${SECTIONS
|
|
43
|
+
function transitionErrors(plan, key, at) {
|
|
44
|
+
if (!plan.present[key]) return [];
|
|
45
|
+
const { transitions, invalid } = plan[key === 'flow' ? 'flow' : 'states'];
|
|
46
|
+
const errors = invalid.map((l) => `## ${SECTIONS[key]}: "${l.raw}" não é transição — use "- origem → destino: gatilho"${at(l.line)}`);
|
|
47
|
+
if (transitions.length === 0 && invalid.length === 0) errors.push(`## ${SECTIONS[key]} vazia — liste as transições ou apague a seção`);
|
|
48
48
|
return errors;
|
|
49
49
|
}
|
|
50
50
|
|
|
@@ -120,7 +120,7 @@ export function validatePlan(plan, { workType = null, riskRules = true, text = n
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
errors.push(...defaultsErrors(plan, { workType, riskRules }, at));
|
|
123
|
-
errors.push(...
|
|
123
|
+
errors.push(...transitionErrors(plan, 'states', at), ...transitionErrors(plan, 'flow', at));
|
|
124
124
|
warnings.push(...defaultsWarnings(plan, at));
|
|
125
125
|
|
|
126
126
|
const isSlices = (s) => /^fatias$/i.test(s.name.trim());
|
|
@@ -14,6 +14,7 @@ export const SECTIONS = {
|
|
|
14
14
|
assumptions: 'Suposições não confirmadas',
|
|
15
15
|
defaults: 'Padrões assumidos',
|
|
16
16
|
states: 'Estados',
|
|
17
|
+
flow: 'Fluxo',
|
|
17
18
|
scenarios: 'Cenários',
|
|
18
19
|
decisions: 'Decisões',
|
|
19
20
|
architecture: 'Arquitetura',
|
|
@@ -191,6 +192,8 @@ export function parsePlan(text) {
|
|
|
191
192
|
reuseHeader: reuse.header,
|
|
192
193
|
defaults: parseDefaults(body('defaults')),
|
|
193
194
|
states: parseStates(body('states')),
|
|
195
|
+
flow: parseStates(body('flow')),
|
|
196
|
+
text: Object.fromEntries(Object.keys(SECTIONS).map((k) => [k, body(k).map((l) => l.raw).join('\n').trim()])),
|
|
194
197
|
errata: errata ? errata.lines.map((l) => l.raw).join('\n').trim() : null,
|
|
195
198
|
sectionsAfterErrata: errata ? sections.filter((s) => s.line > errata.line).map((s) => s.name) : [],
|
|
196
199
|
};
|
package/src/lib/recap/recap.js
CHANGED
|
@@ -2,7 +2,7 @@ import { execFileSync } from 'child_process';
|
|
|
2
2
|
import { existsSync, readFileSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { parsePlan } from '../plan/plan-parser.js';
|
|
5
|
-
import { stateMachineSvg } from '../
|
|
5
|
+
import { stateMachineSvg } from '../view/flow-svg.js';
|
|
6
6
|
import { reviewReportsFromDir } from '../review-reports.js';
|
|
7
7
|
import { loadGraph, getFreshness } from '../graph/graph-loader.js';
|
|
8
8
|
import { parseDiff, buildDiffTree, declarationDrift } from '../graph/diff-tree.js';
|
|
@@ -19,7 +19,7 @@ const git = (cwd, ...args) => execFileSync('git', args, { cwd, stdio: 'pipe', ma
|
|
|
19
19
|
* What changed on the branch: the call tree when the graph is of HEAD and its lines hold, else the
|
|
20
20
|
* plain file and line counts. Never throws; outside git it is null.
|
|
21
21
|
*/
|
|
22
|
-
export function changeSummary(cwd, { base = null } = {}) {
|
|
22
|
+
export function changeSummary(cwd, { base = null, statOnly = false } = {}) {
|
|
23
23
|
let from = base;
|
|
24
24
|
try {
|
|
25
25
|
from ??= git(cwd, 'merge-base', detectDefaultBranch(cwd) || 'main', 'HEAD').trim();
|
|
@@ -33,6 +33,7 @@ export function changeSummary(cwd, { base = null } = {}) {
|
|
|
33
33
|
} catch {
|
|
34
34
|
return null;
|
|
35
35
|
}
|
|
36
|
+
if (statOnly) return { stat, tree: null };
|
|
36
37
|
const loaded = loadGraph(cwd);
|
|
37
38
|
if (!loaded.ok || getFreshness(cwd, loaded.graph).state !== 'fresh') return { stat, tree: null };
|
|
38
39
|
try {
|
|
@@ -88,7 +89,7 @@ export function recapScenes(r) {
|
|
|
88
89
|
id: 'abertura',
|
|
89
90
|
title: r.feature,
|
|
90
91
|
narration: `${r.feature}. ${r.description ?? ''} ${r.days !== null ? `Do início ao fim, ${r.days === 0 ? 'menos de um dia' : `${r.days} dia${r.days > 1 ? 's' : ''}`}.` : ''}`.replace(/\s+/g, ' ').trim(),
|
|
91
|
-
body: `<p class="lead">${esc(r.description ?? '')}</p>${r.days !== null ? `<p class="big">${r.days === 0 ? '< 1' : r.days} <span>dia${r.days
|
|
92
|
+
body: `<p class="lead">${esc(r.description ?? '')}</p>${r.days !== null ? `<p class="big">${r.days === 0 ? '< 1' : r.days} <span>dia${r.days <= 1 ? '' : 's'}</span></p>` : ''}`,
|
|
92
93
|
});
|
|
93
94
|
if (r.scenarios.length) {
|
|
94
95
|
const ok = r.scenarios.filter((c) => c.status === 'PROVADO').length;
|
|
@@ -135,6 +136,7 @@ export function recapScenes(r) {
|
|
|
135
136
|
}
|
|
136
137
|
|
|
137
138
|
const SCENE_MS = 9000;
|
|
139
|
+
const TOKENS = readFileSync(new URL('../view/tokens.css', import.meta.url), 'utf8');
|
|
138
140
|
|
|
139
141
|
/** `audio`: optional `{ [sceneId]: dataUri }`; without it the page narrates with the system voice. */
|
|
140
142
|
export function renderRecapHtml(r, { audio = null } = {}) {
|
|
@@ -147,8 +149,7 @@ export function renderRecapHtml(r, { audio = null } = {}) {
|
|
|
147
149
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
148
150
|
<title>Recap — ${esc(r.feature)}</title>
|
|
149
151
|
<style>
|
|
150
|
-
:root { --bg
|
|
151
|
-
@media (prefers-color-scheme: light) { :root { --bg:#f6f7f9; --card:#fff; --fg:#14171c; --dim:#4b5260; --faint:#7a8291; --hair:#dfe3ea; --ok:#2f7d4f; --warn:#a35f00; --bad:#b4232f; --accent:#2d5bd7; } }
|
|
152
|
+
${TOKENS}:root { --bg:var(--paper); --card:var(--panel); --fg:var(--ink); --hair:var(--rule); --accent:var(--route); }
|
|
152
153
|
* { box-sizing:border-box; }
|
|
153
154
|
body { margin:0; min-height:100vh; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; display:flex; flex-direction:column; }
|
|
154
155
|
.stage { flex:1; position:relative; max-width:960px; width:100%; margin:0 auto; padding:40px 16px 16px; }
|
|
@@ -105,7 +105,9 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
105
105
|
// Usage errors are judged before the build: paying for a build to discover a typo
|
|
106
106
|
// is the worst ordering, and a refusal after the cache was touched leaves a half-rewritten tree.
|
|
107
107
|
const resolvedPlan = resolveTestNodes(projectPath);
|
|
108
|
-
const
|
|
108
|
+
const excluded = new Set(options.excludeNodes ?? []);
|
|
109
|
+
const scopedNodes = options.selection ? options.selection.nodes : resolvedPlan.nodes;
|
|
110
|
+
const testPlan = { ...resolvedPlan, nodes: scopedNodes.filter((node) => !excluded.has(node.id)) };
|
|
109
111
|
const filter = parseTestFilter(options.filter);
|
|
110
112
|
if (filter && !testPlan.nodes.some((node) => nodeMatchesFilter(node, filter))) {
|
|
111
113
|
const declared = testPlan.nodes.map((node) => node.id).join(', ') || '(none)';
|
|
@@ -150,10 +152,11 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
150
152
|
const kept = options.selection.nodes.map((n) => n.id).join(', ') || 'nenhuma';
|
|
151
153
|
feedback.push(`[verify] rodada afetada, suítes: ${kept}; a completa roda uma vez antes da aprovação`);
|
|
152
154
|
}
|
|
155
|
+
if (excluded.size > 0) feedback.push(`[verify] provadas pelo CI da PR no finish: ${[...excluded].join(', ')}`);
|
|
153
156
|
|
|
154
157
|
// Only a complete run feeds or reads the cache: a partial one proves nothing about the nodes it left out.
|
|
155
158
|
const cacheDir = fingerprint && !filter && !skipTests && !skipBuild && !skipE2e && !recordBaseline
|
|
156
|
-
&& !refreshCache && !linux && !freshE2e
|
|
159
|
+
&& !refreshCache && !linux && !freshE2e && excluded.size === 0
|
|
157
160
|
? resolveGitCommonDir(projectPath)
|
|
158
161
|
: null;
|
|
159
162
|
const reuse = planReuse(cacheDir && !fresh ? readVerifyCache(cacheDir, featureName) : null, fingerprint);
|
|
@@ -172,7 +175,7 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
172
175
|
projectPath,
|
|
173
176
|
testPlan,
|
|
174
177
|
filter,
|
|
175
|
-
skipTests,
|
|
178
|
+
skipTests: skipTests || (excluded.size > 0 && testPlan.nodes.length === 0),
|
|
176
179
|
buildNode: nodes.build,
|
|
177
180
|
timeout: explicitTimeout,
|
|
178
181
|
refreshCache,
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { PROOF_KINDS, RISK_KINDS, REUSE_VERDICTS } from '../plan/plan-parser.js';
|
|
3
|
+
import { renderRecapHtml } from '../recap/recap.js';
|
|
4
|
+
import { stateMachineSvg } from './flow-svg.js';
|
|
5
|
+
|
|
6
|
+
export const TOKENS_CSS = readFileSync(new URL('./tokens.css', import.meta.url), 'utf8');
|
|
7
|
+
const PAGE_CSS = readFileSync(new URL('./feature-page.css', import.meta.url), 'utf8');
|
|
8
|
+
const CLIENT_JS = readFileSync(new URL('./feature-page.client.js', import.meta.url), 'utf8');
|
|
9
|
+
|
|
10
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
11
|
+
const WORK_LABEL = { feature: 'Feature', bug: 'Correção de bug', chore: 'Manutenção', hotfix: 'Hotfix' };
|
|
12
|
+
const STATUS_LABEL = { pendente: 'Pendente', feito: 'Feito', bloqueado: 'Bloqueado' };
|
|
13
|
+
const RUNG_TONE = { migrar: 'warn', 'irreversível': 'bad', irreversivel: 'bad' };
|
|
14
|
+
const NONE_RE = /^(nenhum|nenhuma)\.?$/i;
|
|
15
|
+
|
|
16
|
+
function linkifier(plan) {
|
|
17
|
+
const known = new Set([...plan.scenarios.map((c) => c.id), ...plan.adrs.map((a) => a.id)]);
|
|
18
|
+
const defaults = new Set(plan.defaults.items.map((d) => d.n));
|
|
19
|
+
const ref = (id, label) => `<a href="#${id}" data-ref="${id}">${label}</a>`;
|
|
20
|
+
return (escaped) => escaped
|
|
21
|
+
.replace(/\b(C\d+|ADR-\d+)\b/g, (m, id) => (known.has(id) ? ref(id, m) : m))
|
|
22
|
+
.replace(/\b([Pp]adrão) (\d+)\b/g, (m, word, n) => (defaults.has(Number(n)) ? ref(`P${n}`, `${word} ${n}`) : m));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function inline(text, link) {
|
|
26
|
+
return String(text).split(/(`[^`]+`)/).map((part) => {
|
|
27
|
+
if (/^`[^`]+`$/.test(part)) return `<code>${esc(part.slice(1, -1))}</code>`;
|
|
28
|
+
return link(esc(part)).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
29
|
+
}).join('');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function blocks(markdown, link) {
|
|
33
|
+
const text = String(markdown ?? '').replace(/<!--[\s\S]*?-->/g, '');
|
|
34
|
+
const out = [];
|
|
35
|
+
let para = [];
|
|
36
|
+
let list = null;
|
|
37
|
+
let table = null;
|
|
38
|
+
let fence = null;
|
|
39
|
+
const closePara = () => { if (para.length) out.push(`<p>${inline(para.join(' '), link)}</p>`); para = []; };
|
|
40
|
+
const closeList = () => { if (list) out.push(`<ul>${list.map((i) => `<li>${inline(i, link)}</li>`).join('')}</ul>`); list = null; };
|
|
41
|
+
const closeTable = () => {
|
|
42
|
+
if (!table) return;
|
|
43
|
+
const [head, ...rows] = table;
|
|
44
|
+
const cells = (row, tag) => row.map((c) => `<${tag}>${inline(c, link)}</${tag}>`).join('');
|
|
45
|
+
out.push(`<div class="scroll-x"><table><thead><tr>${cells(head, 'th')}</tr></thead><tbody>${rows.map((r) => `<tr>${cells(r, 'td')}</tr>`).join('')}</tbody></table></div>`);
|
|
46
|
+
table = null;
|
|
47
|
+
};
|
|
48
|
+
const closeAll = () => { closePara(); closeList(); closeTable(); };
|
|
49
|
+
for (const line of text.split(/\r?\n/)) {
|
|
50
|
+
if (fence) {
|
|
51
|
+
if (/^\s*```/.test(line)) { out.push(`<pre><code>${esc(fence.join('\n'))}</code></pre>`); fence = null; } else fence.push(line);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (/^\s*```/.test(line)) { closeAll(); fence = []; continue; }
|
|
55
|
+
if (!line.trim()) { closeAll(); continue; }
|
|
56
|
+
if (/^\s*\|/.test(line)) {
|
|
57
|
+
closePara(); closeList();
|
|
58
|
+
if (/^\s*\|[\s:|-]+\|?\s*$/.test(line)) continue;
|
|
59
|
+
(table ??= []).push(line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim()));
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
closeTable();
|
|
63
|
+
const bullet = /^\s*[-*]\s+(.*)$/.exec(line);
|
|
64
|
+
if (bullet) { closePara(); (list ??= []).push(bullet[1]); continue; }
|
|
65
|
+
if (list && /^\s+\S/.test(line)) { list[list.length - 1] += ` ${line.trim()}`; continue; }
|
|
66
|
+
closeList();
|
|
67
|
+
const heading = /^#{1,6}\s+(.*)$/.exec(line);
|
|
68
|
+
if (heading) { closePara(); out.push(`<h3>${inline(heading[1], link)}</h3>`); continue; }
|
|
69
|
+
para.push(line.trim());
|
|
70
|
+
}
|
|
71
|
+
if (fence) out.push(`<pre><code>${esc(fence.join('\n'))}</code></pre>`);
|
|
72
|
+
closeAll();
|
|
73
|
+
return out.join('');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function splitProblem(text) {
|
|
77
|
+
const m = /^\s*\**\s*Resultado buscado\s*:\s*\**\s*(.+)$/im.exec(text ?? '');
|
|
78
|
+
const outcome = m ? m[1].replace(/\*\*/g, '').trim() : null;
|
|
79
|
+
return { outcome, problem: m ? text.replace(m[0], '').trim() : text ?? '' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const bulletsOf = (text) => String(text ?? '').replace(/<!--[\s\S]*?-->/g, '').split(/\r?\n/)
|
|
83
|
+
.map((l) => /^\s*[-*]\s+(.+)$/.exec(l)?.[1]).filter((l) => l && !NONE_RE.test(l.trim()));
|
|
84
|
+
|
|
85
|
+
const conflictsOf = (plan) => {
|
|
86
|
+
const text = plan.conflicts?.text?.replace(/<!--[\s\S]*?-->/g, '').trim();
|
|
87
|
+
return text && !NONE_RE.test(text) ? text : null;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export function pendingAsks(view) {
|
|
91
|
+
const { plan } = view;
|
|
92
|
+
const asks = [];
|
|
93
|
+
for (const c of plan.scenarios) {
|
|
94
|
+
if (c.rung && c.rung.toLowerCase() !== 'refazer') asks.push({ id: c.id, verb: 'Revisar', action: 'Pedir ajuste', anchor: `ask-${c.id}`, text: `Não se desfaz com revert (degrau: ${c.rung}). ${c.text}`, tag: 'Degrau' });
|
|
95
|
+
if (c.status === 'bloqueado') asks.push({ id: c.id, verb: 'Destravar', action: 'Decidir', anchor: `ask-${c.id}-b`, text: `Bloqueado na implementação. ${c.evidence || c.text}`, tag: 'Bloqueio' });
|
|
96
|
+
}
|
|
97
|
+
bulletsOf(plan.text.assumptions).forEach((text, i) => asks.push({ id: `suposição ${i + 1}`, verb: 'Corrigir', action: 'Corrigir', anchor: `S${i + 1}`, text, tag: 'Suposição sem prova' }));
|
|
98
|
+
for (const d of plan.defaults.items) asks.push({ id: `padrão ${d.n}`, verb: 'Vetar', action: 'Vetar', anchor: `P${d.n}`, text: d.text, tag: `Padrão ${d.n}` });
|
|
99
|
+
return asks;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function askRow(a, link) {
|
|
103
|
+
return `<li id="${esc(a.anchor)}" data-ask="${esc(a.id)}" data-verb="${esc(a.verb)}"><div><p>${inline(a.text, link)}</p><span class="tag">${esc(a.tag)}</span></div><button type="button" class="toggle" aria-pressed="false">${esc(a.action)}</button></li>`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function segmented(title, entries, filterable = true) {
|
|
107
|
+
const used = entries.filter((e) => e.n > 0);
|
|
108
|
+
if (!used.length) return '';
|
|
109
|
+
const total = used.reduce((sum, e) => sum + e.n, 0);
|
|
110
|
+
const attrs = (e) => (filterable ? `data-filter="${esc(e.key)}" data-jump` : 'disabled');
|
|
111
|
+
return `<div class="chart"><h3>${esc(title)}</h3><div class="seg" role="img" aria-label="${esc(used.map((e) => `${e.label}: ${e.n}`).join(', '))}">${used.map((e) => `<button type="button" ${attrs(e)} style="--n:${e.n};--c:var(--s${e.slot})" title="${esc(e.label)}: ${e.n} de ${total}" aria-label="${esc(e.label)}: ${e.n}"></button>`).join('')}</div><ul class="legend">${used.map((e) => `<li><i style="--c:var(--s${e.slot})"></i>${esc(e.label)} ${e.n}</li>`).join('')}</ul></div>`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function riskBars(scenarios) {
|
|
115
|
+
const rows = RISK_KINDS.map((r) => ({ r, n: scenarios.filter((c) => c.risk?.toLowerCase() === r).length })).filter((x) => x.n);
|
|
116
|
+
if (!rows.length) return '';
|
|
117
|
+
const max = Math.max(...rows.map((x) => x.n));
|
|
118
|
+
return `<div class="chart"><h3>Risco que os cenários protegem</h3><ul class="bars">${rows.map((x) => `<li><button type="button" data-filter="${esc(x.r)}" data-jump title="Ver cenários de ${esc(x.r)}"><span>${esc(x.r)}</span><span class="track"><span class="fill" style="--share:${x.n / max}"></span></span><span>${x.n}</span></button></li>`).join('')}</ul></div>`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function panorama(view, asks, link) {
|
|
122
|
+
const { plan } = view;
|
|
123
|
+
const { outcome, problem } = splitProblem(plan.text.problem);
|
|
124
|
+
const rungs = plan.scenarios.filter((c) => c.rung && c.rung.toLowerCase() !== 'refazer').length;
|
|
125
|
+
const proofs = PROOF_KINDS.map((k, i) => ({ key: k, label: k, slot: i + 1, n: plan.scenarios.filter((c) => c.proof?.toLowerCase() === k).length }));
|
|
126
|
+
const reuse = REUSE_VERDICTS.map((v, i) => ({ key: v, label: v.toLowerCase(), slot: i + 1, n: plan.reuse.filter((r) => r.verdict === v).length }));
|
|
127
|
+
const num = (href, n, label, alert = false) => `<li${alert ? ' class="alert"' : ''}><a href="${href}"><b>${n}</b>${esc(label)}</a></li>`;
|
|
128
|
+
return `<section id="panorama">
|
|
129
|
+
${outcome ? `<p class="outcome">${inline(outcome, link)}</p>` : ''}
|
|
130
|
+
<ul class="numbers">
|
|
131
|
+
${num('#cenarios', plan.scenarios.length, plan.scenarios.length === 1 ? 'cenário define o pronto' : 'cenários definem o pronto')}
|
|
132
|
+
${num('#decisoes', plan.adrs.length, plan.adrs.length === 1 ? 'decisão registrada' : 'decisões registradas')}
|
|
133
|
+
${num('#decidir', asks.length, asks.length === 1 ? 'ponto para você olhar' : 'pontos para você olhar')}
|
|
134
|
+
${rungs ? num('#decidir', rungs, 'sem volta por revert', true) : ''}
|
|
135
|
+
</ul>
|
|
136
|
+
${problem ? `<details class="fold" open><summary>O problema hoje</summary><div class="prose">${blocks(problem, link)}</div></details>` : ''}
|
|
137
|
+
${segmented('Como cada cenário será provado', proofs)}
|
|
138
|
+
${riskBars(plan.scenarios)}
|
|
139
|
+
${segmented('Reaproveitar ou criar', reuse, false)}
|
|
140
|
+
</section>`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function gateSign(view) {
|
|
144
|
+
const g = view.gate;
|
|
145
|
+
if (g?.pause && g.resolver !== 'session') {
|
|
146
|
+
return `<div class="sign"><h3>O ${esc(g.label.toLowerCase())} espera sua decisão</h3><p>${inline(g.reason ?? 'Leia os pontos abaixo e responda no terminal.', (s) => s)}</p></div>`;
|
|
147
|
+
}
|
|
148
|
+
const approved = Object.values(view.gates).filter((x) => x === true || x?.approved === true).length;
|
|
149
|
+
return `<div class="sign clear"><h3>${approved ? 'Nenhuma decisão parada' : 'Gate ainda não chamado'}</h3><p>Você pode vetar qualquer padrão abaixo a qualquer momento.</p></div>`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function decide(view, asks, link) {
|
|
153
|
+
const conflicts = conflictsOf(view.plan);
|
|
154
|
+
return `<section id="decidir"><h2>O que precisa de você</h2><p class="sub">Marque o que quer mudar. O que ficar sem marca está aceito. A resposta pronta aparece no rodapé para colar no terminal.</p>
|
|
155
|
+
${gateSign(view)}
|
|
156
|
+
${asks.length ? `<ul class="asks">${asks.map((a) => askRow(a, link)).join('')}</ul>` : '<p class="empty">Nenhum ponto em aberto neste plano.</p>'}
|
|
157
|
+
${conflicts ? `<h3>Conflitos com decisões anteriores</h3><div class="prose">${blocks(conflicts, link)}</div>` : ''}
|
|
158
|
+
${view.critique ? `<details class="fold"><summary>Ler a crítica independente do plano</summary><div class="prose">${blocks(view.critique, link)}</div></details>` : ''}
|
|
159
|
+
</section>`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function scenarioItem(c, cites, link) {
|
|
163
|
+
const state = c.proved === 'PROVADO' ? 'provado' : c.proved === 'FALHA' ? 'falha' : c.status;
|
|
164
|
+
const label = c.proved ?? STATUS_LABEL[c.status];
|
|
165
|
+
const tone = c.proved === 'PROVADO' || c.status === 'feito' ? 'ok' : c.proved === 'FALHA' || c.status === 'bloqueado' ? 'bad' : '';
|
|
166
|
+
const tags = [c.proof, c.risk, c.status, c.rung].filter(Boolean).map((t) => String(t).toLowerCase());
|
|
167
|
+
const rung = c.rung && c.rung.toLowerCase() !== 'refazer' ? `<span class="${RUNG_TONE[c.rung.toLowerCase()] ?? ''}">Degrau: ${esc(c.rung)}</span>` : '';
|
|
168
|
+
const adrs = cites.length ? `<span>${cites.map((id) => `<a href="#${id}" data-ref="${id}">${id}</a>`).join(', ')}</span>` : '';
|
|
169
|
+
const evidence = c.proof_note || c.evidence;
|
|
170
|
+
return `<li id="${esc(c.id)}" data-state="${state}" data-tags="${esc(tags.join(' '))}"><p><span class="id">${esc(c.id)}</span>${inline(c.text, link)}</p><div class="tags"><span class="${tone}">${esc(label)}</span>${c.proof ? `<span>Prova: ${esc(c.proof)}</span>` : ''}${c.risk ? `<span>Risco: ${esc(c.risk)}</span>` : ''}${rung}${adrs}</div>${evidence ? `<p class="evidence">${inline(evidence, link)}</p>` : ''}</li>`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function scenariosSection(view, citesOf, link) {
|
|
174
|
+
const { scenarios } = view.plan;
|
|
175
|
+
const keys = (list) => [...new Set(list.filter(Boolean).map((k) => k.toLowerCase()))];
|
|
176
|
+
const filters = ['todos', ...keys(scenarios.map((c) => c.proof)), ...keys(scenarios.map((c) => c.risk)), ...keys(scenarios.map((c) => c.status))];
|
|
177
|
+
return `<section id="cenarios"><h2>O que vai estar pronto</h2><p class="sub">Cada ponto da linha é um comportamento que o verificador executa antes da entrega.</p>
|
|
178
|
+
<div class="filters" role="group" aria-label="Filtrar cenários">${filters.map((f, i) => `<button type="button" data-filter="${esc(f)}" aria-pressed="${i === 0}">${esc(f)}</button>`).join('')}</div>
|
|
179
|
+
<ol class="line">${scenarios.map((c) => scenarioItem(c, citesOf(c.id), link)).join('')}</ol></section>`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const adrBody = (a) => a.body.replace(/^\s*\*\*Alcance:\*\*.*$/im, '').trim();
|
|
183
|
+
|
|
184
|
+
function matrix(view, cited) {
|
|
185
|
+
const adrs = view.plan.adrs.filter((a) => cited.get(a.id).length);
|
|
186
|
+
if (!adrs.length) return '';
|
|
187
|
+
const ids = view.plan.scenarios.map((c) => c.id);
|
|
188
|
+
return `<h3>Qual decisão sustenta qual cenário</h3><div class="scroll-x"><table class="matrix"><thead><tr><th></th>${ids.map((id) => `<th><a href="#${id}" data-ref="${id}">${id}</a></th>`).join('')}</tr></thead><tbody>${adrs.map((a) => `<tr><th><a href="#${a.id}" data-ref="${a.id}">${a.id}</a></th>${ids.map((id) => `<td>${cited.get(a.id).includes(id) ? `<a href="#${id}" aria-label="${a.id} sustenta ${id}"></a>` : ''}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function decisions(view, cited, link) {
|
|
192
|
+
const { adrs } = view.plan;
|
|
193
|
+
return `<section id="decisoes"><h2>Decisões</h2><p class="sub">Abra uma decisão para ver o motivo. Os links levam aos cenários que dependem dela.</p>
|
|
194
|
+
${adrs.length ? adrs.map((a) => `<details class="adr" id="${esc(a.id)}"><summary>${esc(a.id)}: ${esc(a.title)}${a.scope ? `<small>vale para ${a.scope === 'projeto' ? 'o projeto' : 'esta feature'}</small>` : ''}</summary><div class="prose">${blocks(adrBody(a), link)}</div></details>`).join('') : '<p class="empty">Este plano não registrou decisões de arquitetura.</p>'}
|
|
195
|
+
${matrix(view, cited)}</section>`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function howItWorks(view, link) {
|
|
199
|
+
const { plan } = view;
|
|
200
|
+
const flow = plan.flow?.transitions ?? [];
|
|
201
|
+
const states = plan.states?.transitions ?? [];
|
|
202
|
+
const reuse = plan.reuse.length ? `<h3>O que aproveita e o que nasce</h3><div class="scroll-x"><table><thead><tr><th>Conceito</th><th>Veredito</th><th>Onde</th></tr></thead><tbody>${plan.reuse.map((r) => `<tr><td>${inline(r.concept, link)}</td><td>${esc(r.verdict.toLowerCase())}</td><td>${inline(r.target, link)}${r.why ? `<br><small>${inline(r.why, link)}</small>` : ''}</td></tr>`).join('')}</tbody></table></div>` : '';
|
|
203
|
+
const out = plan.text.outOfScope;
|
|
204
|
+
return `<section id="como"><h2>Como funciona</h2>
|
|
205
|
+
${flow.length ? `<h3>Fluxo</h3><div class="diagram">${stateMachineSvg(flow, 'Fluxo', 'flow')}</div>` : ''}
|
|
206
|
+
${states.length ? `<h3>Estados</h3><div class="diagram">${stateMachineSvg(states, 'Máquina de estados', 'states')}</div>` : ''}
|
|
207
|
+
${plan.text.architecture ? `<h3>Arquitetura</h3><div class="prose">${blocks(plan.text.architecture, link)}</div>` : ''}
|
|
208
|
+
${reuse}
|
|
209
|
+
${out ? `<h3>Fora de escopo</h3><div class="prose">${blocks(out, link)}</div>` : ''}
|
|
210
|
+
${plan.text.contract ? `<details class="fold"><summary>Contrato de implementação</summary><div class="prose">${blocks(plan.text.contract, link)}</div></details>` : ''}
|
|
211
|
+
${plan.errata ? `<h3>Errata</h3><div class="prose">${blocks(plan.errata, link)}</div>` : ''}
|
|
212
|
+
</section>`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const REVIEW_LABEL = { verifier: 'Verificador', reviewer: 'Revisor', security: 'Segurança' };
|
|
216
|
+
|
|
217
|
+
function execution(view, link) {
|
|
218
|
+
const { progress, verification, reviews, design, changes } = view;
|
|
219
|
+
const started = progress?.done > 0 || progress?.blocked > 0 || verification || Object.values(reviews).some((r) => r.status !== 'missing');
|
|
220
|
+
if (!started && !design.length) return '';
|
|
221
|
+
const share = progress?.total ? progress.done / progress.total : 0;
|
|
222
|
+
const ring = progress?.total ? `<div class="gauge"><svg class="ring" viewBox="0 0 112 112" role="img" aria-label="${progress.done} de ${progress.total} cenários feitos"><circle class="base" cx="56" cy="56" r="45"/><circle class="part" cx="56" cy="56" r="45" style="--share:${share}"/></svg><div><b>${progress.done} de ${progress.total}</b>cenários feitos${progress.blocked ? `, ${progress.blocked} bloqueado${progress.blocked > 1 ? 's' : ''}` : ''}</div></div>` : '';
|
|
223
|
+
const stat = changes?.stat ? `<div class="gauge"><div><b>${changes.stat.files}</b>arquivos alterados, +${changes.stat.added} e −${changes.stat.removed} linhas</div></div>` : '';
|
|
224
|
+
const rows = [];
|
|
225
|
+
if (verification) {
|
|
226
|
+
const ok = verification.status === 'pass';
|
|
227
|
+
rows.push(`<li>Verify: <span class="${ok ? 'ok' : 'bad'}">${ok ? 'passou' : 'falhou'}</span>${verification.sha ? ` em <code>${esc(String(verification.sha).slice(0, 8))}</code>` : ''}</li>`);
|
|
228
|
+
}
|
|
229
|
+
for (const [role, r] of Object.entries(reviews)) {
|
|
230
|
+
if (r.status === 'missing') continue;
|
|
231
|
+
const tone = r.verdict === 'SHIP' ? 'ok' : r.verdict === 'FIX' ? 'bad' : 'wait';
|
|
232
|
+
rows.push(`<li>${REVIEW_LABEL[role]}: <span class="${tone}">${esc(r.verdict ?? 'em andamento')}</span>${r.blockers?.length ? `, ${r.blockers.length} bloqueador${r.blockers.length > 1 ? 'es' : ''}` : ''}</li>`);
|
|
233
|
+
}
|
|
234
|
+
const blockers = Object.values(reviews).flatMap((r) => r.blockers ?? []);
|
|
235
|
+
return `<section id="execucao"><h2>Execução e provas</h2>
|
|
236
|
+
<div class="gauges">${ring}${stat}${rows.length ? `<ul class="status-list">${rows.join('')}</ul>` : ''}</div>
|
|
237
|
+
${blockers.length ? `<h3>Bloqueadores da revisão</h3><div class="prose"><ul>${blockers.map((b) => `<li>${inline(`${b.id}: ${b.evidence || b.where}`, link)}</li>`).join('')}</ul></div>` : ''}
|
|
238
|
+
${design.length ? `<h3>Telas</h3><ul class="shots">${design.map((d) => `<li><a href="${esc(d.href)}">${d.shot ? `<img src="${esc(d.shot)}" alt="" loading="lazy">` : ''}${esc(d.name)}${d.kind === 'opcoes' ? ' (opções)' : ''}</a></li>`).join('')}</ul>` : ''}
|
|
239
|
+
</section>`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function recapSection(view) {
|
|
243
|
+
if (!view.recap) return '';
|
|
244
|
+
return `<section id="recap"><h2>A história da feature</h2><p class="sub">Toque para ouvir o que foi feito, cena por cena.</p><iframe class="recap" title="Recap narrado" loading="lazy" srcdoc="${esc(renderRecapHtml(view.recap))}"></iframe></section>`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function refsOf(plan) {
|
|
248
|
+
const refs = {};
|
|
249
|
+
for (const c of plan.scenarios) refs[c.id] = c.text.replace(/`/g, '');
|
|
250
|
+
for (const a of plan.adrs) refs[a.id] = a.title;
|
|
251
|
+
for (const d of plan.defaults.items) refs[`P${d.n}`] = d.text.replace(/`/g, '');
|
|
252
|
+
return JSON.stringify(refs).replace(/</g, '\\u003c');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function renderFeatureHtml(view) {
|
|
256
|
+
const { plan } = view;
|
|
257
|
+
const link = linkifier(plan);
|
|
258
|
+
const asks = pendingAsks(view);
|
|
259
|
+
const ids = plan.scenarios.map((c) => c.id);
|
|
260
|
+
const cited = new Map(plan.adrs.map((a) => [a.id, ids.filter((id) => new RegExp(`\\b${id}\\b`).test(a.body))]));
|
|
261
|
+
const citesOf = (id) => plan.adrs.filter((a) => cited.get(a.id).includes(id)).map((a) => a.id);
|
|
262
|
+
const done = view.stations.filter((s) => s.done).length;
|
|
263
|
+
const exec = execution(view, link);
|
|
264
|
+
const recap = recapSection(view);
|
|
265
|
+
const nav = [
|
|
266
|
+
['panorama', 'Panorama'], ['decidir', 'Decidir', asks.length], ['cenarios', 'Cenários'], ['decisoes', 'Decisões'], ['como', 'Como funciona'],
|
|
267
|
+
...(exec ? [['execucao', 'Execução']] : []), ...(recap ? [['recap', 'Recap']] : []),
|
|
268
|
+
];
|
|
269
|
+
const key = `${view.feature}:${(view.planHash ?? '').slice(0, 12)}`;
|
|
270
|
+
return `<!doctype html>
|
|
271
|
+
<html lang="pt-BR">
|
|
272
|
+
<head>
|
|
273
|
+
<meta charset="utf-8">
|
|
274
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
275
|
+
<title>${esc(view.feature)}</title>
|
|
276
|
+
<style>
|
|
277
|
+
${TOKENS_CSS}${PAGE_CSS}</style>
|
|
278
|
+
</head>
|
|
279
|
+
<body data-key="${esc(key)}" data-feature="${esc(view.feature)}">
|
|
280
|
+
<header class="top">
|
|
281
|
+
<p class="kind">${esc(WORK_LABEL[view.workType] ?? 'Feature')}</p>
|
|
282
|
+
<h1>${esc(view.feature)}</h1>
|
|
283
|
+
${view.description ? `<p class="ask">${esc(view.description)}</p>` : ''}
|
|
284
|
+
<ol class="route" style="--done:${Math.min(1, done / Math.max(1, view.stations.length - 1)).toFixed(3)}">${view.stations.map((s) => `<li class="stop ${s.state}"${s.state === 'now' ? ' aria-current="step"' : ''}>${esc(s.label)}${s.note ? `<small>${esc(s.note)}</small>` : ''}</li>`).join('')}</ol>
|
|
285
|
+
</header>
|
|
286
|
+
<div class="shell">
|
|
287
|
+
<nav class="side" aria-label="Seções">${nav.map(([id, label, badge]) => `<a href="#${id}">${esc(label)}${badge ? `<b>${badge}</b>` : ''}</a>`).join('')}</nav>
|
|
288
|
+
<main>
|
|
289
|
+
${panorama(view, asks, link)}
|
|
290
|
+
${decide(view, asks, link)}
|
|
291
|
+
${scenariosSection(view, citesOf, link)}
|
|
292
|
+
${decisions(view, cited, link)}
|
|
293
|
+
${howItWorks(view, link)}
|
|
294
|
+
${exec}
|
|
295
|
+
${recap}
|
|
296
|
+
<p class="source">Gerada pelo morph-spec a partir de plan.md${view.planHash ? ` (${esc(view.planHash.slice(0, 12))})` : ''} e dos relatórios da feature. Atualize a aba para ver o estado mais recente. Para mudar o plano, edite o plan.md.</p>
|
|
297
|
+
</main>
|
|
298
|
+
</div>
|
|
299
|
+
<div id="peek" role="tooltip"></div>
|
|
300
|
+
<div id="tray" role="region" aria-label="Sua resposta"><span class="count"></span><button type="button" class="copy">Copiar resposta</button><button type="button" class="clear">Limpar</button></div>
|
|
301
|
+
<script type="application/json" id="refs">${refsOf(plan)}</script>
|
|
302
|
+
<script>
|
|
303
|
+
${CLIENT_JS}</script>
|
|
304
|
+
</body>
|
|
305
|
+
</html>
|
|
306
|
+
`;
|
|
307
|
+
}
|