@polymorphism-tech/morph-spec 8.37.0 → 8.38.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 +19 -6
- package/README.md +2 -2
- package/bin/morph-spec.js +16 -7
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +3 -3
- package/framework/MORPH.md +25 -22
- package/framework/agents.json +76 -27
- package/framework/docs/CLI.md +55 -18
- package/framework/evals/rubrics/code-quality.md +5 -0
- package/framework/evals/rubrics/condensed.md +6 -2
- package/framework/evals/rubrics/contracts.md +20 -0
- package/framework/evals/rubrics/test-coverage.md +17 -6
- package/framework/hooks/README.md +9 -3
- package/framework/hooks/claude-code/core/graph-refresh-kick.js +52 -0
- package/framework/hooks/claude-code/core/telemetry-log.js +172 -87
- package/framework/hooks/claude-code/core/validate-completion.js +16 -90
- package/framework/hooks/claude-code/core/worktree-context.js +10 -0
- package/framework/hooks/shared/command-tokens.js +96 -0
- package/framework/hooks/shared/git-exclude.js +44 -0
- package/framework/hooks/shared/git-paths.js +52 -0
- package/framework/hooks/shared/graph-freshness.js +380 -0
- package/framework/hooks/shared/graph-refresh.js +416 -0
- package/framework/hooks/shared/morph-cli.js +17 -5
- package/framework/hooks/shared/redact-secrets.js +55 -0
- package/framework/hooks/shared/telemetry-logger.js +25 -2
- package/framework/hooks/shared/transcript-usage.js +80 -0
- package/framework/rubrics/backend.json +2 -2
- package/framework/rubrics/default.json +2 -2
- package/framework/rubrics/frontend.json +2 -2
- package/framework/rules/csharp-standards.md +1 -0
- package/framework/rules/morph-workflow.md +1 -0
- package/framework/rules/nextjs-standards.md +1 -0
- package/framework/schemas/config.schema.json +41 -1
- package/framework/schemas/feature.schema.json +5 -1
- 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 +36 -11
- package/framework/skills/core/morph-brainstorming/SKILL.md +16 -7
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +39 -10
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +72 -15
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +51 -13
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +34 -9
- package/framework/standards/STANDARDS.json +187 -12
- package/framework/standards/ai-agents/rag-custom-pgvector.md +7 -3
- package/framework/standards/ai-agents/testing-ai.md +2 -1
- package/framework/standards/architecture/code-comments.md +64 -0
- package/framework/standards/backend/dotnet/testing.md +22 -0
- package/framework/standards/frontend/admin-app/data-table.md +562 -0
- package/framework/standards/frontend/admin-app/feedback.md +236 -0
- package/framework/standards/frontend/admin-app/forms.md +232 -0
- package/framework/standards/frontend/admin-app/page-structure.md +203 -0
- package/framework/standards/frontend/nextjs/components.md +37 -58
- package/framework/standards/frontend/nextjs/data-fetching.md +1 -0
- package/framework/standards/frontend/nextjs/forms.md +31 -30
- package/framework/standards/frontend/nextjs/state-management.md +2 -1
- package/framework/standards/frontend/nextjs/testing.md +18 -0
- package/framework/templates/context/design-system.md +3 -3
- package/framework/templates/docs/proposal.md +11 -0
- package/framework/templates/docs/spec.md +15 -0
- package/framework/templates/docs/ui-mockups.md +115 -98
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaPlan.cs +12 -6
- package/framework/templates/feature/decisions.md +124 -122
- package/package.json +1 -1
- package/src/commands/graph/affected.js +4 -3
- package/src/commands/graph/build.js +109 -18
- package/src/commands/graph/explain.js +8 -5
- package/src/commands/graph/status.js +14 -8
- package/src/commands/observability/cost.js +148 -18
- package/src/commands/project/doctor.js +82 -10
- package/src/commands/project/update.js +25 -0
- package/src/commands/project/worktree.js +73 -90
- package/src/commands/state/advance.js +18 -0
- package/src/commands/state/approve.js +15 -0
- package/src/commands/state/archive.js +62 -9
- package/src/commands/state/finish.js +62 -14
- package/src/commands/state/gate-check.js +8 -1
- package/src/commands/state/gate-decision.js +8 -4
- package/src/commands/state/report-check.js +90 -9
- package/src/commands/state/usecase.js +66 -0
- package/src/commands/tasks/dag.js +17 -2
- package/src/commands/validation/validate-feature.js +6 -1
- package/src/commands/validation/verify.js +20 -1
- package/src/dashboard/scanner/agents-scanner.js +5 -1
- package/src/dashboard/scanner/hooks-scanner.js +8 -1
- package/src/dashboard/scanner/standards-scanner.js +2 -1
- package/src/lib/ai/comment-stripper.js +101 -4
- package/src/lib/evaluator-report.js +25 -0
- package/src/lib/generators/handoff-generator.js +4 -18
- package/src/lib/git/default-branch.js +52 -0
- package/src/lib/git/tree-fingerprint.js +58 -0
- package/src/lib/git/tree-state.js +167 -0
- package/src/lib/graph/god-nodes.js +12 -3
- package/src/lib/graph/graph-health.js +48 -2
- package/src/lib/graph/graph-loader.js +99 -25
- package/src/lib/graph/graphifyignore.js +145 -28
- package/src/lib/graph/sanitize.js +117 -11
- package/src/lib/risk-detector.js +90 -0
- package/src/lib/specs/acervo-format.js +70 -0
- package/src/lib/specs/acervo-lifecycle.js +264 -0
- package/src/lib/specs/adr-index.js +169 -0
- package/src/lib/specs/revocations.js +156 -0
- package/src/lib/specs/screen-checklist.js +357 -0
- package/src/lib/specs/usecase-check.js +20 -16
- package/src/lib/specs/usecase-promoter.js +103 -38
- package/src/lib/specs/usecase-registry.js +7 -7
- package/src/lib/tasks/tasks-json-parser.js +2 -3
- package/src/lib/telemetry/metrics.js +386 -0
- package/src/lib/telemetry/telemetry-reader.js +161 -51
- package/src/lib/telemetry/verify-event.js +79 -0
- package/src/lib/validators/code-budget/code-budget-validator.js +172 -0
- package/src/lib/validators/code-budget/comment-scanner.js +75 -0
- package/src/lib/validators/code-budget/index.js +9 -0
- package/src/lib/validators/reuse/reuse-validator.js +4 -2
- package/src/lib/validators/reuse/symbol-scanner.js +27 -22
- package/src/lib/validators/validation-runner.js +21 -5
- package/src/lib/validators/verify-runner.js +11 -0
- package/src/lib/worktree/finish-backup.js +739 -0
- package/src/lib/worktree/ignored-triage.js +15 -8
- package/src/lib/worktree/worktree-audit.js +5 -0
- package/src/scripts/setup-infra.js +29 -18
- package/src/utils/agents-installer.js +281 -12
- package/src/utils/hooks-installer.js +75 -15
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan's declaration of what a feature supersedes in the project's acervo:
|
|
3
|
+
* the "UCs e ADRs que esta feature altera ou revoga" section of `spec.md`.
|
|
4
|
+
*
|
|
5
|
+
* ## UCs e ADRs que esta feature altera ou revoga
|
|
6
|
+
*
|
|
7
|
+
* | Id | Mudança | Substituto | Escopo em que o antigo ainda vale |
|
|
8
|
+
* |---|---|---|---|
|
|
9
|
+
* | UC-{nn} | parcial | UC-{nn} | {onde o antigo ainda vale} |
|
|
10
|
+
* | {feature}/ADR-{nnn} | total | — | — |
|
|
11
|
+
*
|
|
12
|
+
* Columns are positional, so the header wording is free. Parsing is pure: the
|
|
13
|
+
* finish applies the entries (`acervo-lifecycle.js`), and `dag`/`usecase
|
|
14
|
+
* check` report the problems while `spec.md` can still be edited — it freezes
|
|
15
|
+
* at Gate 2, so a bad row found at close time can no longer be fixed there.
|
|
16
|
+
*
|
|
17
|
+
* Nothing ambiguous is applied: a row with any problem, and every row of an id
|
|
18
|
+
* declared twice, stays out of `entries`. A table inside a code fence is an
|
|
19
|
+
* example, never a declaration.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { SEPARATOR_ROW_RE, UC_ID_RE, isTableLine, tableCells } from './acervo-format.js';
|
|
23
|
+
|
|
24
|
+
export const REVOCATIONS_TITLE = 'UCs e ADRs que esta feature altera ou revoga';
|
|
25
|
+
|
|
26
|
+
/** ADR numbers restart in every feature, so only `{feature}/ADR-{nnn}` is unambiguous. */
|
|
27
|
+
export const ADR_REF_RE = /^[a-z0-9][a-z0-9-]*\/ADR-\d+$/;
|
|
28
|
+
|
|
29
|
+
const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
|
|
30
|
+
const FENCE_RE = /^\s*(```|~~~)/;
|
|
31
|
+
const ID_TOKEN_RE = /\b(?:UC|ADR)-\d+\b/;
|
|
32
|
+
/** A list item: the shape a revocation takes when the author skips the table. */
|
|
33
|
+
const LIST_ITEM_RE = /^\s*(?:[-*+]|\d+[.)])\s/;
|
|
34
|
+
|
|
35
|
+
const CHANGES = new Map([['total', 'total'], ['parcial', 'parcial'], ['partial', 'parcial']]);
|
|
36
|
+
|
|
37
|
+
/** `—`, `–`, `-` and an empty cell all mean "none". */
|
|
38
|
+
const isNone = (cell) => /^(?:—|–|-)?$/.test(cell);
|
|
39
|
+
|
|
40
|
+
/** Id-like cells tolerate the typography an author reaches for: `code` and **bold**. */
|
|
41
|
+
const bare = (cell) => cell.replace(/^[`*]+|[`*]+$/g, '').trim();
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The problems of one data row; `[]` = the row is applicable.
|
|
45
|
+
* @returns {{ entry: object|null, problems: Array<{ id: string|null, reason: string, message: string }> }}
|
|
46
|
+
*/
|
|
47
|
+
function readRow(line) {
|
|
48
|
+
const problems = [];
|
|
49
|
+
const problem = (id, reason, message) => problems.push({ id, reason, message });
|
|
50
|
+
|
|
51
|
+
const row = tableCells(line);
|
|
52
|
+
if (row.length < 4) {
|
|
53
|
+
problem(null, 'malformed-row', `linha com ${row.length} coluna(s), esperado 4 (Id | Mudança | Substituto | Escopo): ${line.trim()}`);
|
|
54
|
+
return { entry: null, problems };
|
|
55
|
+
}
|
|
56
|
+
const id = bare(row[0]);
|
|
57
|
+
const changeCell = bare(row[1]).toLowerCase();
|
|
58
|
+
const substitute = isNone(bare(row[2])) ? null : bare(row[2]);
|
|
59
|
+
const scope = isNone(row[3]) ? null : row[3];
|
|
60
|
+
|
|
61
|
+
const kind = UC_ID_RE.test(id) ? 'uc' : ADR_REF_RE.test(id) ? 'adr' : null;
|
|
62
|
+
if (!kind) {
|
|
63
|
+
problem(id || null, 'malformed-id', `"${id}" não é um UC-{nn} nem um {feature}/ADR-{nnn}`);
|
|
64
|
+
return { entry: null, problems };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const change = CHANGES.get(changeCell);
|
|
68
|
+
if (!change) problem(id, 'malformed-change', `${id}: mudança "${bare(row[1])}" — use total ou parcial`);
|
|
69
|
+
|
|
70
|
+
if (kind === 'uc' && !substitute) {
|
|
71
|
+
problem(id, 'missing-substitute', `${id}: UC revogado precisa do substituto (UC-{nn}) — é o superseded_by do acervo`);
|
|
72
|
+
} else if (substitute && !(kind === 'uc' ? UC_ID_RE : ADR_REF_RE).test(substitute)) {
|
|
73
|
+
problem(id, 'malformed-substitute',
|
|
74
|
+
`${id}: substituto "${substitute}" — esperado ${kind === 'uc' ? 'UC-{nn}' : '{feature}/ADR-{nnn}'}`);
|
|
75
|
+
} else if (substitute === id) {
|
|
76
|
+
problem(id, 'self-substitute', `${id}: o substituto é o próprio ${id}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (change === 'parcial' && !scope) {
|
|
80
|
+
problem(id, 'missing-scope', `${id}: revogação parcial precisa dizer onde o antigo ainda vale (coluna Escopo)`);
|
|
81
|
+
} else if (change === 'total' && scope) {
|
|
82
|
+
problem(id, 'scope-on-total', `${id}: revogação total com escopo "${scope}" — se o antigo ainda vale em algum lugar, a mudança é parcial`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { entry: problems.length ? { id } : { kind, id, change, substitute, scope }, problems };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {string} content - the whole `spec.md`
|
|
90
|
+
* @returns {{
|
|
91
|
+
* declared: boolean,
|
|
92
|
+
* entries: Array<{ kind: 'uc'|'adr', id: string, change: 'total'|'parcial', substitute: string|null, scope: string|null }>,
|
|
93
|
+
* problems: Array<{ id: string|null, reason: string, message: string }>
|
|
94
|
+
* }}
|
|
95
|
+
* `declared: false` — no section at all (a plan written before it existed).
|
|
96
|
+
*/
|
|
97
|
+
export function parseRevocations(content) {
|
|
98
|
+
const lines = String(content ?? '').split(/\r?\n/);
|
|
99
|
+
const title = REVOCATIONS_TITLE.toLowerCase();
|
|
100
|
+
|
|
101
|
+
let start = -1;
|
|
102
|
+
let level = 0;
|
|
103
|
+
for (let i = 0; i < lines.length; i++) {
|
|
104
|
+
const h = HEADING_RE.exec(lines[i]);
|
|
105
|
+
if (h && h[2].toLowerCase().startsWith(title)) { start = i + 1; level = h[1].length; break; }
|
|
106
|
+
}
|
|
107
|
+
if (start === -1) return { declared: false, entries: [], problems: [] };
|
|
108
|
+
|
|
109
|
+
let end = lines.length;
|
|
110
|
+
for (let i = start; i < lines.length; i++) {
|
|
111
|
+
const h = HEADING_RE.exec(lines[i]);
|
|
112
|
+
if (h && h[1].length <= level) { end = i; break; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const problems = [];
|
|
116
|
+
const rows = []; // { id, entry } in order, applicable or not
|
|
117
|
+
let fenced = false;
|
|
118
|
+
let separatorSeen = false;
|
|
119
|
+
let tableLines = 0;
|
|
120
|
+
|
|
121
|
+
for (const line of lines.slice(start, end)) {
|
|
122
|
+
if (FENCE_RE.test(line)) { fenced = !fenced; continue; }
|
|
123
|
+
if (fenced) continue;
|
|
124
|
+
|
|
125
|
+
if (!isTableLine(line)) {
|
|
126
|
+
const stray = LIST_ITEM_RE.test(line) ? ID_TOKEN_RE.exec(line) : null;
|
|
127
|
+
if (stray) {
|
|
128
|
+
problems.push({ id: stray[0], reason: 'outside-table',
|
|
129
|
+
message: `${stray[0]} aparece fora da tabela — só as linhas da tabela são aplicadas no finish` });
|
|
130
|
+
}
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
tableLines++;
|
|
134
|
+
if (SEPARATOR_ROW_RE.test(line)) { separatorSeen = true; continue; }
|
|
135
|
+
if (!separatorSeen) continue; // the header row: its wording is free
|
|
136
|
+
|
|
137
|
+
const { entry, problems: rowProblems } = readRow(line);
|
|
138
|
+
problems.push(...rowProblems);
|
|
139
|
+
if (entry) rows.push(entry);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (tableLines > 0 && !separatorSeen) {
|
|
143
|
+
problems.push({ id: null, reason: 'unreadable-table',
|
|
144
|
+
message: 'tabela ilegível — falta a linha separadora (|---|---|---|---) depois do cabeçalho; nenhuma linha foi lida' });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// An id declared twice is ambiguous as a whole: none of its rows applies.
|
|
148
|
+
const count = new Map();
|
|
149
|
+
for (const r of rows) count.set(r.id, (count.get(r.id) ?? 0) + 1);
|
|
150
|
+
for (const [id, n] of count) {
|
|
151
|
+
if (n > 1) problems.push({ id, reason: 'duplicate-id', message: `${id} aparece em ${n} linhas — nenhuma foi aplicada; deixe uma só` });
|
|
152
|
+
}
|
|
153
|
+
const entries = rows.filter((r) => r.kind && count.get(r.id) === 1);
|
|
154
|
+
|
|
155
|
+
return { declared: true, entries, problems };
|
|
156
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O checklist de tela do `1-design/mockups.md` — as decisões que uma tela de
|
|
3
|
+
* dados precisa (volume, paginação, ordenação, filtro, os três estados,
|
|
4
|
+
* mobile, abas) escritas por tela, na fase uiux.
|
|
5
|
+
*
|
|
6
|
+
* Existe porque o que o design não pergunta não vira `doneCriteria`, e o que
|
|
7
|
+
* não é critério ninguém entrega: telas de admin saíam listando tudo, sem
|
|
8
|
+
* paginar nem ordenar, com o design aprovado. Os leitores do MESMO parser:
|
|
9
|
+
*
|
|
10
|
+
* - `gate-check uiux` pausa com os problemas nomeados;
|
|
11
|
+
* - `approve`/`advance` com assinatura `auto` recusam a mesma pausa sem uma
|
|
12
|
+
* decisão gravada (`uiuxAutoApprovalBlock`) — a regra é a do risk-detector;
|
|
13
|
+
* - `gate-check review` exige que o avaliador registre a conferência de cada
|
|
14
|
+
* item decidido contra a tela entregue (`checklistCoverage`).
|
|
15
|
+
*
|
|
16
|
+
* Mockup no formato ANTERIOR ao checklist (telas `## S001: …`, sem nenhuma
|
|
17
|
+
* `## Tela:`) é legado e isento, com a isenção gravada no sinal.
|
|
18
|
+
*
|
|
19
|
+
* O formato é o que a `morph-uiux` ensina: cada tela é um `## Tela: <nome>`, e
|
|
20
|
+
* dentro dela um `### Checklist de tela` com uma linha por item — tabela
|
|
21
|
+
* (`| Paginação | servidor, 25/página |`) ou lista (`- **Paginação:** …`).
|
|
22
|
+
* "Não se aplica" é resposta válida; vazio, "?" ou "a definir" não são.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { createHash } from 'crypto';
|
|
26
|
+
import { existsSync, readFileSync } from 'fs';
|
|
27
|
+
import { join } from 'path';
|
|
28
|
+
import { fileURLToPath } from 'url';
|
|
29
|
+
import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
|
|
30
|
+
import { evaluatorScreenChecklist } from '../evaluator-report.js';
|
|
31
|
+
import { LEGACY_MOCKUP_EXEMPTION, uiuxChecklistPause } from '../risk-detector.js';
|
|
32
|
+
|
|
33
|
+
const PACKAGE_AGENTS = fileURLToPath(new URL('../../../framework/agents.json', import.meta.url));
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Os itens, na ordem do template. `match` roda sobre o rótulo NORMALIZADO
|
|
37
|
+
* (minúsculas, sem acento): "Estado de erro", "Erro" e "**Erro:**" são o mesmo
|
|
38
|
+
* item. Um rótulo que casa com vários ("Estados (vazio/carregando/erro)")
|
|
39
|
+
* responde a todos eles.
|
|
40
|
+
*/
|
|
41
|
+
export const SCREEN_CHECKLIST_ITEMS = [
|
|
42
|
+
{ id: 'superficie', label: 'Superfície', match: /\bsuperficie\b/ },
|
|
43
|
+
{ id: 'volume', label: 'Volume esperado', match: /\bvolume\b/ },
|
|
44
|
+
{ id: 'paginacao', label: 'Paginação', match: /\bpaginacao\b/ },
|
|
45
|
+
{ id: 'ordenacao', label: 'Ordenação', match: /\bordenacao\b/ },
|
|
46
|
+
{ id: 'filtro', label: 'Filtro', match: /\bfiltros?\b/ },
|
|
47
|
+
{ id: 'vazio', label: 'Estado vazio', match: /\bvazio\b/ },
|
|
48
|
+
{ id: 'carregando', label: 'Estado carregando', match: /\bcarregando\b|\bcarregamento\b/ },
|
|
49
|
+
{ id: 'erro', label: 'Estado de erro', match: /\berro\b/ },
|
|
50
|
+
{ id: 'mobile', label: 'Mobile', match: /\bmobile\b/ },
|
|
51
|
+
{ id: 'abas', label: 'Abas', match: /\babas\b/ },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Itens que o avaliador não confere contra a tela: a superfície decide QUAIS
|
|
56
|
+
* standards a tela lê, não um comportamento que a tela entregue exiba.
|
|
57
|
+
*/
|
|
58
|
+
const NOT_VERIFIED_AT_REVIEW = new Set(['superficie']);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Respostas que ocupam a célula sem decidir nada: as exatas, e as que COMEÇAM
|
|
62
|
+
* adiando a decisão ("a definir depois", "TBD.", "? talvez").
|
|
63
|
+
*/
|
|
64
|
+
const PLACEHOLDERS = new Set(['', '-', '--', '—', '–', '...', 'x', 'todo', 'definir', 'pendente']);
|
|
65
|
+
const PLACEHOLDER_PREFIX_RE = /^(a definir|tbd|\?|…)/;
|
|
66
|
+
|
|
67
|
+
/** O formato de mockup anterior ao checklist: telas `## S001: <nome>`. */
|
|
68
|
+
const LEGACY_SCREEN_HEADING_RE = /^##\s+S\d+\s*:/i;
|
|
69
|
+
|
|
70
|
+
const NOT_APPLICABLE_RE = /^(nao se aplica|n\/a)\b/;
|
|
71
|
+
|
|
72
|
+
const SCREEN_HEADING_RE = /^##\s+tela\s*[:—–-]\s*(.+?)\s*#*\s*$/i;
|
|
73
|
+
const CHECKLIST_HEADING_RE = /^(#{3,6})\s+checklist\b/i;
|
|
74
|
+
|
|
75
|
+
/** Minúsculas, sem acento, sem marcação inline, espaços colapsados. */
|
|
76
|
+
export function normalizeLabel(text) {
|
|
77
|
+
return String(text ?? '')
|
|
78
|
+
.normalize('NFD')
|
|
79
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
80
|
+
.replace(/[*_`"“”]/g, '')
|
|
81
|
+
.toLowerCase()
|
|
82
|
+
.replace(/\s+/g, ' ')
|
|
83
|
+
.trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Nome de tela comparável entre o mockup e o relatório do avaliador. */
|
|
87
|
+
function screenKey(name) {
|
|
88
|
+
return normalizeLabel(name).replace(/^tela\s*[:—–-]\s*/, '').replace(/[.:;,]+$/, '');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Os itens que um rótulo responde. */
|
|
92
|
+
function itemsOf(label) {
|
|
93
|
+
const norm = normalizeLabel(label);
|
|
94
|
+
return SCREEN_CHECKLIST_ITEMS.filter((item) => item.match.test(norm)).map((item) => item.id);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Uma linha de checklist como `[rótulo, resposta]`, ou null: linha de tabela
|
|
99
|
+
* (separador `|---|` fora) ou item de lista `- Rótulo: resposta`.
|
|
100
|
+
*/
|
|
101
|
+
function checklistRow(line) {
|
|
102
|
+
const trimmed = line.trim();
|
|
103
|
+
if (trimmed.startsWith('|')) {
|
|
104
|
+
const cells = tableCells(trimmed);
|
|
105
|
+
if (cells.length < 2 || cells.every((c) => /^:?-{2,}:?$/.test(c) || c === '')) return null;
|
|
106
|
+
return [cells[0], cells.slice(1).join(' ').trim()];
|
|
107
|
+
}
|
|
108
|
+
const bullet = /^[-*+]\s+(.+)$/.exec(trimmed.replace(/\*\*/g, ''));
|
|
109
|
+
if (!bullet) return null;
|
|
110
|
+
const colon = bullet[1].indexOf(':');
|
|
111
|
+
if (colon === -1) return null;
|
|
112
|
+
return [bullet[1].slice(0, colon), bullet[1].slice(colon + 1).trim()];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** As células de uma linha de tabela markdown, sem as bordas. */
|
|
116
|
+
export function tableCells(line) {
|
|
117
|
+
const inner = line.trim().replace(/^\|/, '').replace(/\|$/, '');
|
|
118
|
+
return inner.split(/(?<!\\)\|/).map((cell) => cell.trim());
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Linhas fora de blocos cercados: um exemplo dentro de ``` não é uma tela. */
|
|
122
|
+
function proseLines(markdown) {
|
|
123
|
+
const out = [];
|
|
124
|
+
let fenced = false;
|
|
125
|
+
for (const line of String(markdown ?? '').replace(/\r\n/g, '\n').split('\n')) {
|
|
126
|
+
if (/^\s*(```|~~~)/.test(line)) { fenced = !fenced; continue; }
|
|
127
|
+
if (!fenced) out.push(line);
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* As telas do mockup e o que o checklist de cada uma responde.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} markdown - conteúdo do `1-design/mockups.md`
|
|
136
|
+
* @returns {Array<{ name: string, hasChecklist: boolean, answers: Map<string, string> }>}
|
|
137
|
+
*/
|
|
138
|
+
export function parseScreens(markdown) {
|
|
139
|
+
const screens = [];
|
|
140
|
+
let screen = null;
|
|
141
|
+
let checklistLevel = 0;
|
|
142
|
+
|
|
143
|
+
for (const line of proseLines(markdown)) {
|
|
144
|
+
const heading = /^(#{1,6})\s/.exec(line);
|
|
145
|
+
if (heading) {
|
|
146
|
+
const level = heading[1].length;
|
|
147
|
+
const named = SCREEN_HEADING_RE.exec(line);
|
|
148
|
+
if (level <= 2) {
|
|
149
|
+
screen = named && named[1].trim() ? { name: named[1].trim(), hasChecklist: false, answers: new Map() } : null;
|
|
150
|
+
if (screen) screens.push(screen);
|
|
151
|
+
checklistLevel = 0;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const checklist = CHECKLIST_HEADING_RE.exec(line);
|
|
155
|
+
if (screen && checklist) {
|
|
156
|
+
screen.hasChecklist = true;
|
|
157
|
+
checklistLevel = checklist[1].length;
|
|
158
|
+
} else if (checklistLevel && level <= checklistLevel) {
|
|
159
|
+
checklistLevel = 0;
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (!screen || !checklistLevel) continue;
|
|
164
|
+
const row = checklistRow(line);
|
|
165
|
+
if (!row) continue;
|
|
166
|
+
for (const id of itemsOf(row[0])) {
|
|
167
|
+
if (!screen.answers.has(id) || !screen.answers.get(id)) screen.answers.set(id, row[1]);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return screens;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isPlaceholder(answer) {
|
|
174
|
+
const norm = normalizeLabel(answer).replace(/[.!;:,]+$/, '').trim();
|
|
175
|
+
return PLACEHOLDERS.has(norm) || PLACEHOLDER_PREFIX_RE.test(norm);
|
|
176
|
+
}
|
|
177
|
+
const isNotApplicable = (answer) => NOT_APPLICABLE_RE.test(normalizeLabel(answer));
|
|
178
|
+
|
|
179
|
+
const labelOf = (id) => SCREEN_CHECKLIST_ITEMS.find((item) => item.id === id)?.label ?? id;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* As superfícies que a persona `ui-designer` declara no `agents.json` — o
|
|
183
|
+
* vocabulário válido do item `Superfície`, que o `morph-plan` §5a usa para
|
|
184
|
+
* somar os standards da tela em `tasks[].standards`. Lê a cópia instalada do
|
|
185
|
+
* projeto; sem ela (este repositório), a do pacote. Persona sem `surfaces`
|
|
186
|
+
* (instalação anterior ao campo) devolve null: o valor não é validado.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} cwd
|
|
189
|
+
* @returns {string[]|null}
|
|
190
|
+
*/
|
|
191
|
+
export function knownSurfaces(cwd) {
|
|
192
|
+
for (const path of [join(cwd, '.morph', 'framework', 'agents.json'), PACKAGE_AGENTS]) {
|
|
193
|
+
if (!existsSync(path)) continue;
|
|
194
|
+
try {
|
|
195
|
+
const surfaces = JSON.parse(readFileSync(path, 'utf8'))?.agents?.['ui-designer']?.surfaces;
|
|
196
|
+
return surfaces && typeof surfaces === 'object' && !Array.isArray(surfaces) ? Object.keys(surfaces) : null;
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** A resposta de `Superfície` nomeia uma das superfícies conhecidas? */
|
|
205
|
+
function namesSurface(answer, surfaces) {
|
|
206
|
+
const norm = normalizeLabel(answer);
|
|
207
|
+
return surfaces.some((key) => new RegExp(`(^|[^a-z0-9-])${key}([^a-z0-9-]|$)`).test(norm));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Audita o checklist do mockup: toda tela tem o bloco, e todo item tem uma
|
|
212
|
+
* resposta escrita — e, quando `surfaces` é dado, a `Superfície` é uma delas.
|
|
213
|
+
*
|
|
214
|
+
* Sem nenhuma `## Tela:`, o mockup é `legacy` (isento) só quando declara telas
|
|
215
|
+
* no formato antigo; qualquer outro formato é o problema "nenhuma tela
|
|
216
|
+
* declarada" — senão trocar o heading seria o atalho para pular o checklist.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} markdown
|
|
219
|
+
* @returns {{ legacy: boolean, screens: Array<{ name: string, hasChecklist: boolean, missing: string[], unanswered: string[] }>,
|
|
220
|
+
* problems: string[] }} `problems` vazio ⇔ checklist completo (ou isento)
|
|
221
|
+
*/
|
|
222
|
+
export function auditScreenChecklist(markdown, { surfaces = null } = {}) {
|
|
223
|
+
const parsed = parseScreens(markdown);
|
|
224
|
+
const problems = [];
|
|
225
|
+
if (parsed.length === 0) {
|
|
226
|
+
if (proseLines(markdown).some((line) => LEGACY_SCREEN_HEADING_RE.test(line))) {
|
|
227
|
+
return { legacy: true, screens: [], problems };
|
|
228
|
+
}
|
|
229
|
+
problems.push('nenhuma tela declarada no formato `## Tela: <nome>`');
|
|
230
|
+
}
|
|
231
|
+
const screens = parsed.map((screen) => {
|
|
232
|
+
const missing = [];
|
|
233
|
+
const unanswered = [];
|
|
234
|
+
for (const { id } of SCREEN_CHECKLIST_ITEMS) {
|
|
235
|
+
if (!screen.answers.has(id)) missing.push(id);
|
|
236
|
+
else if (isPlaceholder(screen.answers.get(id))) unanswered.push(id);
|
|
237
|
+
}
|
|
238
|
+
if (!screen.hasChecklist) {
|
|
239
|
+
problems.push(`tela "${screen.name}" sem \`### Checklist de tela\``);
|
|
240
|
+
} else {
|
|
241
|
+
if (missing.length) problems.push(`tela "${screen.name}": falta ${missing.map(labelOf).join(', ')}`);
|
|
242
|
+
if (unanswered.length) problems.push(`tela "${screen.name}": sem resposta em ${unanswered.map(labelOf).join(', ')}`);
|
|
243
|
+
const surface = screen.answers.get('superficie');
|
|
244
|
+
if (surfaces?.length && surface !== undefined && !isPlaceholder(surface) && !isNotApplicable(surface)
|
|
245
|
+
&& !namesSurface(surface, surfaces)) {
|
|
246
|
+
problems.push(`tela "${screen.name}": Superfície "${surface}" não é uma das superfícies da ui-designer (${surfaces.join(', ')})`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return { name: screen.name, hasChecklist: screen.hasChecklist, missing, unanswered };
|
|
250
|
+
});
|
|
251
|
+
return { legacy: false, screens, problems };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* O sinal `screenChecklist` de um gate, lido do disco. COLETA, nunca decide:
|
|
256
|
+
* quem pausa é o risk-detector.
|
|
257
|
+
*
|
|
258
|
+
* `mockups: false` é "a feature não tem mockup" — nada a cobrar. No review, o
|
|
259
|
+
* que se cobra é a conferência (`uncovered`: itens decididos sem linha na
|
|
260
|
+
* seção do avaliador), não o design, que já passou pelo Gate uiux.
|
|
261
|
+
*
|
|
262
|
+
* @param {string} cwd
|
|
263
|
+
* @param {string} featureName
|
|
264
|
+
* @param {string} gateName
|
|
265
|
+
* @returns {{ mockups: boolean, legacy: boolean, screens: string[], problems: string[],
|
|
266
|
+
* exempt?: string, section?: boolean, uncovered?: string[] }}
|
|
267
|
+
*/
|
|
268
|
+
export function collectScreenChecklist(cwd, featureName, gateName) {
|
|
269
|
+
const mockups = readMockups(cwd, featureName);
|
|
270
|
+
if (mockups === null) return { mockups: false, legacy: false, screens: [], problems: [] };
|
|
271
|
+
const audit = auditScreenChecklist(mockups, { surfaces: knownSurfaces(cwd) });
|
|
272
|
+
const signal = { mockups: true, legacy: audit.legacy, screens: audit.screens.map((s) => s.name), problems: audit.problems };
|
|
273
|
+
if (audit.legacy) signal.exempt = LEGACY_MOCKUP_EXEMPTION;
|
|
274
|
+
if (gateName === 'review') {
|
|
275
|
+
const report = evaluatorScreenChecklist(cwd, featureName);
|
|
276
|
+
signal.section = report.section;
|
|
277
|
+
signal.uncovered = checklistCoverage(mockups, report.section ? report.body : '');
|
|
278
|
+
}
|
|
279
|
+
return signal;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Por que o Gate uiux NÃO pode ser aprovado com assinatura `auto` — a pausa
|
|
284
|
+
* que o `gate-check uiux` daria pelo checklist —, ou null. Lido por `approve`
|
|
285
|
+
* e `advance`, para que uma aprovação automática nunca passe onde o gate-check
|
|
286
|
+
* pausaria. Uma decisão gravada com `pause: false` (o `gate-decision` manual,
|
|
287
|
+
* ou um gate-check que não pausou) é a resposta a essa pausa e destrava — só
|
|
288
|
+
* para o mockup que ela viu: a decisão guarda `mockupsHash`, e um mockup
|
|
289
|
+
* escrito ou mudado depois dela volta a ser conferido.
|
|
290
|
+
*
|
|
291
|
+
* @param {string} cwd
|
|
292
|
+
* @param {string} featureName
|
|
293
|
+
* @param {object} feature - o feature.json (lê `gateDecisions.uiux`)
|
|
294
|
+
* @returns {string|null}
|
|
295
|
+
*/
|
|
296
|
+
export function uiuxAutoApprovalBlock(cwd, featureName, feature) {
|
|
297
|
+
const decision = feature?.gateDecisions?.uiux;
|
|
298
|
+
if (decision?.pause === false && decision.mockupsHash === mockupsHash(cwd, featureName)) return null;
|
|
299
|
+
return uiuxChecklistPause(collectScreenChecklist(cwd, featureName, 'uiux'));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* O que o avaliador deixou de conferir: por tela do mockup, os itens com
|
|
304
|
+
* decisão (nem "não se aplica", nem a superfície) sem linha na seção
|
|
305
|
+
* `## Checklist de tela` do relatório. Uma linha que o avaliador marca como
|
|
306
|
+
* não verificável conta como conferida — o que pausa é a AUSÊNCIA da linha.
|
|
307
|
+
*
|
|
308
|
+
* @param {string} designMarkdown - o `1-design/mockups.md`
|
|
309
|
+
* @param {string} reportSection - o corpo da seção do `evaluator-report.md`
|
|
310
|
+
* @returns {string[]} `"<tela>: <item>, <item>"` por tela com lacuna
|
|
311
|
+
*/
|
|
312
|
+
export function checklistCoverage(designMarkdown, reportSection) {
|
|
313
|
+
const covered = new Map();
|
|
314
|
+
for (const line of proseLines(reportSection)) {
|
|
315
|
+
if (!line.trim().startsWith('|')) continue;
|
|
316
|
+
const cells = tableCells(line);
|
|
317
|
+
if (cells.length < 2) continue;
|
|
318
|
+
const key = screenKey(cells[0]);
|
|
319
|
+
if (!covered.has(key)) covered.set(key, new Set());
|
|
320
|
+
for (const id of itemsOf(cells[1])) covered.get(key).add(id);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const gaps = [];
|
|
324
|
+
for (const screen of parseScreens(designMarkdown)) {
|
|
325
|
+
const done = covered.get(screenKey(screen.name)) ?? new Set();
|
|
326
|
+
const owed = [...screen.answers]
|
|
327
|
+
.filter(([id, answer]) => !NOT_VERIFIED_AT_REVIEW.has(id) && !isPlaceholder(answer) && !isNotApplicable(answer))
|
|
328
|
+
.map(([id]) => id)
|
|
329
|
+
.filter((id) => !done.has(id));
|
|
330
|
+
if (owed.length) gaps.push(`${screen.name}: ${owed.map(labelOf).join(', ')}`);
|
|
331
|
+
}
|
|
332
|
+
return gaps;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* O `1-design/mockups.md` por conteúdo: BOM e CRLF não contam, para que o
|
|
337
|
+
* checkout de outra máquina seja o mesmo mockup.
|
|
338
|
+
*
|
|
339
|
+
* @param {string} cwd
|
|
340
|
+
* @param {string} featureName
|
|
341
|
+
* @returns {string|null} null sem mockup
|
|
342
|
+
*/
|
|
343
|
+
export function mockupsHash(cwd, featureName) {
|
|
344
|
+
const text = readMockups(cwd, featureName);
|
|
345
|
+
if (text === null) return null;
|
|
346
|
+
return createHash('sha256').update(text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n')).digest('hex').slice(0, 16);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** O `1-design/mockups.md` da feature, ou null quando ausente/ilegível. Nunca lança. */
|
|
350
|
+
export function readMockups(cwd, featureName) {
|
|
351
|
+
try {
|
|
352
|
+
const path = getAbsoluteOutputPath(cwd, featureName, 'uiMockups');
|
|
353
|
+
return existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
354
|
+
} catch {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
* Fail-open like the promoter: no `usecases/` folder means nothing to check.
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
-
import {
|
|
36
|
+
import { readFileSync } from 'fs';
|
|
37
37
|
import { join } from 'path';
|
|
38
|
-
import {
|
|
38
|
+
import { isCanonicalUseCaseName, useCaseFilesById } from './acervo-format.js';
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* A post-condition line: `- **P2** — Cada veterinário traz suas especialidades`.
|
|
@@ -180,23 +180,18 @@ export function parsePostConditions(content) {
|
|
|
180
180
|
* continuation); `postConditions[].text` stays the first line, which is what
|
|
181
181
|
* the literal-carry check compares against.
|
|
182
182
|
*
|
|
183
|
+
* Any `UC-{nn}-….md` counts, canonical or not — installed plans rely on it.
|
|
184
|
+
* `nonCanonical` lists the files of that id the promotion will not carry, so
|
|
185
|
+
* `checkUseCases` can say so instead of the finish dropping them in silence.
|
|
186
|
+
*
|
|
183
187
|
* @param {string} usecasesDir - absolute path to `2-plan/usecases`
|
|
184
|
-
* @returns {Map<string, { id: string, file: string, postConditions: Array<{label: string, text: string}>, artifactJustified: Map<string, string>, fullText: Map<string, string> }>}
|
|
188
|
+
* @returns {Map<string, { id: string, file: string, nonCanonical: string[], postConditions: Array<{label: string, text: string}>, artifactJustified: Map<string, string>, fullText: Map<string, string> }>}
|
|
185
189
|
*/
|
|
186
190
|
export function loadUseCases(usecasesDir) {
|
|
187
191
|
const byId = new Map();
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
try {
|
|
192
|
-
names = readdirSync(usecasesDir).sort();
|
|
193
|
-
} catch {
|
|
194
|
-
return byId; // unreadable folder is "no use cases", never a crash
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
for (const file of names) {
|
|
198
|
-
const id = useCaseId(file);
|
|
199
|
-
if (!id || !file.endsWith('.md')) continue;
|
|
192
|
+
for (const [id, files] of useCaseFilesById(usecasesDir, { canonicalOnly: false })) {
|
|
193
|
+
const file = files[files.length - 1];
|
|
194
|
+
const nonCanonical = files.filter((f) => !isCanonicalUseCaseName(f));
|
|
200
195
|
try {
|
|
201
196
|
const content = readFileSync(join(usecasesDir, file), 'utf8');
|
|
202
197
|
const blocks = parsePostConditionBlocks(content);
|
|
@@ -206,7 +201,7 @@ export function loadUseCases(usecasesDir) {
|
|
|
206
201
|
fullText.set(label, block.text);
|
|
207
202
|
if (block.justified) artifactJustified.set(label, block.text);
|
|
208
203
|
}
|
|
209
|
-
byId.set(id, { id, file, postConditions: parsePostConditions(content), artifactJustified, fullText });
|
|
204
|
+
byId.set(id, { id, file, nonCanonical, postConditions: parsePostConditions(content), artifactJustified, fullText });
|
|
210
205
|
} catch { /* one unreadable use case must not sink the rest */ }
|
|
211
206
|
}
|
|
212
207
|
return byId;
|
|
@@ -241,6 +236,15 @@ export function checkUseCases(tasksData, usecasesDir) {
|
|
|
241
236
|
|
|
242
237
|
if (byId.size === 0) return { errors, warnings, checked: false };
|
|
243
238
|
|
|
239
|
+
for (const uc of byId.values()) {
|
|
240
|
+
for (const file of uc.nonCanonical) {
|
|
241
|
+
warnings.push(
|
|
242
|
+
`${uc.id} está em 2-plan/usecases/${file}, nome fora do padrão UC-{nn}-{slug}.md: ` +
|
|
243
|
+
'o finish não o promove ao acervo — renomeie'
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
244
248
|
// Pre-normalize every doneCriteria once: this is O(tasks × postConditions)
|
|
245
249
|
// and both are small, but the normalization itself is not free.
|
|
246
250
|
const criteria = tasks.map((t) => ({ id: t?.id, text: normalize(t?.doneCriteria || ''), usecase: t?.usecase }));
|