@polymorphism-tech/morph-spec 8.35.1 → 8.36.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 +9 -7
- package/README.md +2 -2
- package/bin/morph-spec.js +2 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/MORPH.md +24 -23
- package/framework/docs/CLI.md +184 -10
- package/framework/evals/proof-rubrics/eval-de-modelo.md +16 -0
- package/framework/evals/rubrics/condensed.md +3 -1
- package/framework/evals/rubrics/test-coverage.md +8 -0
- package/framework/hooks/shared/phase-utils.js +1 -0
- package/framework/phases.json +1 -1
- package/framework/rules/morph-workflow.md +1 -1
- package/framework/schemas/config.schema.json +7 -0
- 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 +21 -10
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- 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 +20 -3
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +41 -3
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +11 -2
- 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 +1 -1
- package/framework/standards/STANDARDS.json +2 -2
- package/framework/standards/ai-agents/prompt-sources.md +4 -3
- package/framework/standards/ai-agents/testing-ai.md +14 -13
- package/framework/templates/dotnet/ai-kit/README.md +14 -8
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +11 -3
- package/package.json +1 -1
- package/src/commands/project/doctor.js +51 -2
- package/src/commands/tasks/dag.js +13 -1
- package/src/commands/validation/verify.js +47 -18
- package/src/core/paths/output-schema.js +7 -0
- package/src/lib/detectors/index.js +225 -217
- package/src/lib/risk-detector.js +38 -18
- package/src/lib/specs/done-criteria-check.js +200 -0
- package/src/lib/specs/usecase-check.js +129 -5
- package/src/lib/tasks/dotnet-test-introspection.js +517 -0
- package/src/lib/tasks/test-nodes.js +190 -20
- package/src/lib/tasks/test-runner.js +122 -2
- package/src/lib/tasks/test-scope.js +567 -0
- package/src/lib/validators/traceability-check.js +9 -2
- package/src/lib/validators/verify-runner.js +150 -18
- package/src/lib/worktree/docker-audit.js +5 -1
package/src/lib/risk-detector.js
CHANGED
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
* - designSystemDivergence: boolean — conflicting design sources detected
|
|
10
10
|
* - standardsViolations: string[] — violations of mandate.md proibições
|
|
11
11
|
* - verificationStatus: string — último `morph-spec verify` ('fail' pausa, qualquer gate)
|
|
12
|
+
* - verificationScope + gate: string — presente e != 'feature' pausa SÓ no ship: o
|
|
13
|
+
* último `verify` foi um recorte de task, não da feature inteira (`null`/ausente
|
|
14
|
+
* não conta — narrow, nunca widen)
|
|
12
15
|
* - e2eStatus + e2eReason + gate — nó e2e do último verify. Um `skip`
|
|
13
|
-
* cuja razão NÃO seja dispensa legítima pausa SÓ no ship (gate 'review')
|
|
14
|
-
*
|
|
15
|
-
* `skip`. Dispensa legítima só conta vinda de 'feature' (ver abaixo)
|
|
16
|
+
* cuja razão NÃO seja dispensa legítima pausa SÓ no ship (gate 'review').
|
|
17
|
+
* Sem escopo (stamp sem o campo) cai do mesmo lado seguro que `unknown`
|
|
16
18
|
* - verificationStale + gate: bool/str — stamp com sha != HEAD pausa SÓ no
|
|
17
19
|
* ship (gate 'review') — código mudou depois do último verify
|
|
18
20
|
* - evalScoreSource + gate: str — no ship, score que NÃO veio do avaliador
|
|
@@ -172,6 +174,30 @@ export function shouldPauseGate(config, signals) {
|
|
|
172
174
|
};
|
|
173
175
|
}
|
|
174
176
|
|
|
177
|
+
// No gate de SHIP: um `verification` PRESENTE cujo escopo não é o da feature
|
|
178
|
+
// é um recorte parcial — de UMA task, nunca da app inteira. O stamp é
|
|
179
|
+
// SINGULAR, sobrescrito por qualquer verify de qualquer escopo, e vale
|
|
180
|
+
// INDEPENDENTE do que os nós individuais dizem: mesmo com e2e 'pass' dentro
|
|
181
|
+
// daquele recorte, build/tests/validators também rodaram só para a task, não
|
|
182
|
+
// para a feature. Antes desta regra, um `verify {f} {task}` totalmente verde
|
|
183
|
+
// comprava o Gate 3 da feature que ele nem tentou verificar por inteiro — já
|
|
184
|
+
// acontecia manualmente (`--filter`), e o recorte automático de PR B faz
|
|
185
|
+
// acontecer em TODA task. Por isso corre ANTES do bloco de e2e abaixo: ele
|
|
186
|
+
// pausa por si só, sem depender de e2eStatus/e2eReason.
|
|
187
|
+
//
|
|
188
|
+
// `typeof === 'string'` exclui `null`/ausente (features legadas sem stamp) —
|
|
189
|
+
// narrow, nunca widen. O caminho de volta é sempre o mesmo, barato e nomeado:
|
|
190
|
+
// `morph-spec verify {feature}` (suíte inteira) antes do ship.
|
|
191
|
+
if (signals.gate === 'review'
|
|
192
|
+
&& typeof signals.verificationScope === 'string'
|
|
193
|
+
&& signals.verificationScope !== FEATURE_SCOPE) {
|
|
194
|
+
return {
|
|
195
|
+
pause: true,
|
|
196
|
+
reason: `último morph-spec verify foi de escopo "${signals.verificationScope}" (recorte parcial de task)`
|
|
197
|
+
+ ' — rode `morph-spec verify {feature}` (suíte inteira) antes do ship',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
175
201
|
// No gate de SHIP apenas: o nó e2e do último verify PULOU. Um skip não
|
|
176
202
|
// reprova o rollup, então `verificationStatus` sai 'pass' e o gate ficava
|
|
177
203
|
// cego — auto-passando um Gate 3 sobre uma app que ninguém subiu. A razão
|
|
@@ -187,21 +213,15 @@ export function shouldPauseGate(config, signals) {
|
|
|
187
213
|
if (E2E_SKIP_CLASSES_THAT_PAUSE.includes(klass)) {
|
|
188
214
|
return { pause: true, reason: e2ePauseReason(klass, signals.e2eReason) };
|
|
189
215
|
}
|
|
190
|
-
// Segunda pergunta, que a razão sozinha não responde: QUEM carimbou.
|
|
191
|
-
// `
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
// mesmo um "project does not containerize" carimbado por task é a resposta
|
|
200
|
-
// a uma pergunta que o gate não fez. O caminho de volta é barato e nomeado
|
|
201
|
-
// (rodar `verify {feature}`), e é o que morph-review já manda fazer antes
|
|
202
|
-
// do Gate 3. Narrow, nunca widen: nenhuma razão nova passa a valer aqui —
|
|
203
|
-
// uma que já valia deixa de valer fora do escopo que responde pela app.
|
|
204
|
-
if (signals.verificationScope !== FEATURE_SCOPE) {
|
|
216
|
+
// Segunda pergunta, que a razão sozinha não responde: QUEM carimbou. Um
|
|
217
|
+
// escopo de TASK (`task:{id}`) já pausou acima, antes deste bloco — o que
|
|
218
|
+
// sobra a checar aqui é só a ausência de escopo (`null`/indefinido:
|
|
219
|
+
// feature legada, ou stamp anterior à introdução do campo). Cai do mesmo
|
|
220
|
+
// lado seguro que `unknown` pelo mesmo motivo de sempre: recusar é barato,
|
|
221
|
+
// aprovar sem prova de QUEM respondeu pela app não é. O caminho de volta é
|
|
222
|
+
// o mesmo (rodar `verify {feature}`), e é o que morph-review já manda
|
|
223
|
+
// fazer antes do Gate 3.
|
|
224
|
+
if (signals.verificationScope == null) {
|
|
205
225
|
return {
|
|
206
226
|
pause: true,
|
|
207
227
|
reason: e2eScopeRefusal(klass, signals.verificationScope, signals.e2eReason),
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lexical heuristic: does a task's `doneCriteria` describe an ARTIFACT (a
|
|
3
|
+
* file, a grep, a binary) instead of behaviour observable at runtime?
|
|
4
|
+
*
|
|
5
|
+
* A literal `doneCriteria` travels verbatim into the test that verifies it
|
|
6
|
+
* (`morph-implement` copies it, never paraphrases). A use-case post-condition
|
|
7
|
+
* shaped like "nenhum arquivo em X contém Y" — even a literal `grep` command —
|
|
8
|
+
* becomes a test that reads file text instead of exercising behaviour. This
|
|
9
|
+
* is a WARNING, never an error: `dag`'s exit code never changes because of
|
|
10
|
+
* it, the same way `checkUseCases`'s coverage/literalness findings do not.
|
|
11
|
+
*
|
|
12
|
+
* Runs over EVERY task, unlike `usecase-check.js#checkUseCases` (which
|
|
13
|
+
* short-circuits when the feature has no use cases): a task's own
|
|
14
|
+
* `doneCriteria` can describe an artifact with no use case in sight, and
|
|
15
|
+
* that must still be caught. This is why it lives in its own file — it is a
|
|
16
|
+
* standalone lexical pass, not wiring between use cases and tasks.
|
|
17
|
+
*
|
|
18
|
+
* Two ways a match is silenced:
|
|
19
|
+
* - a task WITHOUT a use case can justify itself inline, with
|
|
20
|
+
* `Por que artefato: …` anywhere in its own `doneCriteria`. A task WITH a
|
|
21
|
+
* use case never can — it justifies in the use case, otherwise the marker
|
|
22
|
+
* would silence every post-condition it carries at once;
|
|
23
|
+
* - a task carrying a use-case post-condition literally is silenced only
|
|
24
|
+
* if THAT post-condition was justified in the use case document (the
|
|
25
|
+
* `> [!note] Por que artefato:` callout `usecase-check.js` parses).
|
|
26
|
+
* Justified text is removed from the task's `doneCriteria` before scanning —
|
|
27
|
+
* not skipped as a whole task — so a justified P1 never silences an
|
|
28
|
+
* unjustified P2 carried in the same `doneCriteria`. The scan runs on the
|
|
29
|
+
* whitespace-collapsed text with its CASE PRESERVED: an uppercase format
|
|
30
|
+
* acronym (`arquivo em CSV`) is the only thing that tells an export from a
|
|
31
|
+
* directory (`arquivo em src`), and lowercasing first would erase it.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { ARTIFACT_MARKER_RE, loadUseCases, normalize } from './usecase-check.js';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Case-insensitive spelling of `word` inside a regex that must stay
|
|
38
|
+
* case-SENSITIVE elsewhere — Node 18 has no inline `(?i:…)` modifier.
|
|
39
|
+
*/
|
|
40
|
+
const anyCase = (word) => [...word].map((c) => {
|
|
41
|
+
const lo = c.toLowerCase();
|
|
42
|
+
const up = c.toUpperCase();
|
|
43
|
+
return lo === up ? c : `[${lo}${up}]`;
|
|
44
|
+
}).join('');
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What follows "arquivo em" in a behavioural export criterion ("exporta o
|
|
48
|
+
* arquivo em CSV", "salva o arquivo em formato aberto"): an uppercase file
|
|
49
|
+
* format acronym or the word `formato`. Case-sensitive on purpose — `src`,
|
|
50
|
+
* `docs` or `lib` after "arquivo em" are places, and places are artifacts.
|
|
51
|
+
*/
|
|
52
|
+
const FORMAT_TOKEN = String.raw`\s+(?:[A-Z]{2,5}(?![\p{L}\d_])|${anyCase('formato')}(?![\p{L}]))`;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Vocabulary of artifact-shaped phrasing. `term` is the label surfaced in
|
|
56
|
+
* warnings and cited by the `morph-plan` skill prose — keep it stable, a
|
|
57
|
+
* later parity test imports it verbatim.
|
|
58
|
+
*
|
|
59
|
+
* Unicode lookarounds, never `\b`: in JS, `\b` does not treat accented
|
|
60
|
+
* letters (é, á, ó…) as word characters, so it would false-boundary inside
|
|
61
|
+
* "não" or "binário".
|
|
62
|
+
*/
|
|
63
|
+
export const ARTIFACT_VOCABULARY = [
|
|
64
|
+
{ term: 'grep', re: /(?<![\p{L}\d_])grep(?![\p{L}\d_])/iu },
|
|
65
|
+
{ term: 'contém a string', re: /cont[ée]m\s+as?\s+strings?/iu },
|
|
66
|
+
{ term: 'o binário', re: /(?<![\p{L}])o\s+bin[áa]rio(?![\p{L}])/iu },
|
|
67
|
+
{ term: 'nenhum arquivo', re: /(?<![\p{L}])nenhum\s+arquivo(?![\p{L}])/iu },
|
|
68
|
+
{
|
|
69
|
+
term: 'arquivo/pasta/diretório em',
|
|
70
|
+
re: new RegExp(
|
|
71
|
+
String.raw`(?<![\p{L}])(?:${anyCase('arquivo')}|${anyCase('pasta')}|${anyCase('diret')}[óoÓO]${anyCase('rio')})` +
|
|
72
|
+
String.raw`[sS]?\s+${anyCase('em')}(?![\p{L}])(?!${FORMAT_TOKEN})`,
|
|
73
|
+
'u',
|
|
74
|
+
),
|
|
75
|
+
},
|
|
76
|
+
{ term: 'glob **/', re: /\*\*\/|\/\*\*/ },
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Vocabulary terms matched in `text`, in vocabulary order, each at most once.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} text
|
|
83
|
+
* @returns {string[]}
|
|
84
|
+
*/
|
|
85
|
+
export function findArtifactTerms(text) {
|
|
86
|
+
const s = String(text ?? '');
|
|
87
|
+
const found = [];
|
|
88
|
+
for (const { term, re } of ARTIFACT_VOCABULARY) {
|
|
89
|
+
if (re.test(s)) found.push(term);
|
|
90
|
+
}
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Whitespace-collapsed, case PRESERVED — `normalize` without the lowercasing. */
|
|
95
|
+
function collapse(text) {
|
|
96
|
+
return String(text).replace(/\s+/g, ' ').trim();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Remove every case-insensitive occurrence of `needle` (already normalized)
|
|
101
|
+
* from `text`, keeping the case of what remains. Relies on `text` and its
|
|
102
|
+
* lowercase form being index-aligned — the caller guarantees it.
|
|
103
|
+
*/
|
|
104
|
+
function cut(text, needle) {
|
|
105
|
+
if (!needle) return text;
|
|
106
|
+
const lower = text.toLowerCase();
|
|
107
|
+
let out = '';
|
|
108
|
+
let from = 0;
|
|
109
|
+
let at;
|
|
110
|
+
while ((at = lower.indexOf(needle, from)) !== -1) {
|
|
111
|
+
out += `${text.slice(from, at)} `;
|
|
112
|
+
from = at + needle.length;
|
|
113
|
+
}
|
|
114
|
+
return out + text.slice(from);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Flag every task whose `doneCriteria` describes an artifact rather than
|
|
119
|
+
* behaviour, after removing use-case text the author already justified.
|
|
120
|
+
*
|
|
121
|
+
* Fail-open: a missing `usecasesDir` still checks every task (it just has no
|
|
122
|
+
* justifications or post-conditions to consult); a malformed `tasksData`
|
|
123
|
+
* (no `tasks` array) or a non-string `doneCriteria` never throws.
|
|
124
|
+
*
|
|
125
|
+
* @param {Object} tasksData - parsed tasks.json
|
|
126
|
+
* @param {string} usecasesDir - absolute path to `2-plan/usecases`
|
|
127
|
+
* @returns {{ warnings: string[], flagged: Array<{ task: string, usecase: string|null, labels: string[], terms: string[] }>, checked: true }}
|
|
128
|
+
*/
|
|
129
|
+
export function checkArtifactCriteria(tasksData, usecasesDir) {
|
|
130
|
+
const warnings = [];
|
|
131
|
+
const flagged = [];
|
|
132
|
+
const tasks = Array.isArray(tasksData?.tasks) ? tasksData.tasks : [];
|
|
133
|
+
|
|
134
|
+
let byId = new Map();
|
|
135
|
+
if (typeof usecasesDir === 'string') {
|
|
136
|
+
try { byId = loadUseCases(usecasesDir); } catch { byId = new Map(); }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const task of tasks) {
|
|
140
|
+
if (typeof task?.doneCriteria !== 'string') continue;
|
|
141
|
+
|
|
142
|
+
let D = collapse(task.doneCriteria);
|
|
143
|
+
if (!D) continue;
|
|
144
|
+
// `cut` needs `D` index-aligned with its lowercase form. The rare letter
|
|
145
|
+
// whose lowercase is longer (`İ`) breaks that; fall back to the lowercase
|
|
146
|
+
// text, which only costs the format-acronym exception for that task.
|
|
147
|
+
if (D.toLowerCase().length !== D.length) D = D.toLowerCase();
|
|
148
|
+
|
|
149
|
+
// Inline justification is for tasks WITHOUT a use case (morph-plan: "Task
|
|
150
|
+
// sem `usecase` justifica no próprio doneCriteria"). On a use-case task it
|
|
151
|
+
// would silence every post-condition carried, justified or not.
|
|
152
|
+
if (!task.usecase && ARTIFACT_MARKER_RE.test(D)) continue;
|
|
153
|
+
|
|
154
|
+
const uc = task.usecase ? byId.get(task.usecase) : null;
|
|
155
|
+
const unjustifiedLabels = [];
|
|
156
|
+
|
|
157
|
+
if (uc) {
|
|
158
|
+
// Remove justified post-condition text first, so its own vocabulary
|
|
159
|
+
// hits (if any) never surface — and so it cannot mask what remains.
|
|
160
|
+
// A wrapped post-condition is removed in both forms a plan legitimately
|
|
161
|
+
// carries: the whole block, or only its first line (what `checkUseCases`
|
|
162
|
+
// treats as THE post-condition). Longest first.
|
|
163
|
+
for (const pc of uc.postConditions) {
|
|
164
|
+
if (!uc.artifactJustified.has(pc.label)) continue;
|
|
165
|
+
for (const text of [uc.artifactJustified.get(pc.label), pc.text]) {
|
|
166
|
+
D = cut(D, normalize(text));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Among what is left, name the unjustified post-conditions actually
|
|
170
|
+
// carried into this doneCriteria AND that themselves read as artifact —
|
|
171
|
+
// judged on the whole block, since a wrapped post-condition can carry
|
|
172
|
+
// its vocabulary on the continuation line.
|
|
173
|
+
const carried = D.toLowerCase();
|
|
174
|
+
for (const pc of uc.postConditions) {
|
|
175
|
+
if (uc.artifactJustified.has(pc.label)) continue;
|
|
176
|
+
const needle = normalize(pc.text);
|
|
177
|
+
const block = uc.fullText.get(pc.label) ?? pc.text;
|
|
178
|
+
if (needle && carried.includes(needle) && findArtifactTerms(block).length > 0) {
|
|
179
|
+
unjustifiedLabels.push(pc.label);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const terms = findArtifactTerms(D);
|
|
185
|
+
if (terms.length === 0) continue;
|
|
186
|
+
|
|
187
|
+
const quoted = terms.join(', ');
|
|
188
|
+
const message = unjustifiedLabels.length > 0
|
|
189
|
+
? `${task.id}.doneCriteria descreve artefato, não comportamento ("${quoted}") — ` +
|
|
190
|
+
`reescreva ${task.usecase} ${unjustifiedLabels.join('/')} como efeito observável em runtime, ` +
|
|
191
|
+
`ou justifique com "> [!note] Por que artefato:" na linha abaixo dela`
|
|
192
|
+
: `${task.id}.doneCriteria descreve artefato, não comportamento ("${quoted}") — ` +
|
|
193
|
+
`reescreva como efeito observável em runtime, ou acrescente "Por que artefato: …" ao próprio critério`;
|
|
194
|
+
|
|
195
|
+
warnings.push(message);
|
|
196
|
+
flagged.push({ task: task.id, usecase: task.usecase || null, labels: unjustifiedLabels, terms });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { warnings, flagged, checked: true };
|
|
200
|
+
}
|
|
@@ -3,8 +3,15 @@
|
|
|
3
3
|
*
|
|
4
4
|
* This is the mechanical half of the use-case layer. Whether a post-condition is
|
|
5
5
|
* *observable in a test* is semantic and stays with Gate 2 and the evaluator —
|
|
6
|
-
* no checker can decide it.
|
|
7
|
-
*
|
|
6
|
+
* no checker can decide it. A narrower question IS decidable, though: a
|
|
7
|
+
* post-condition phrased as "nenhum arquivo/grep/binário…" describes an
|
|
8
|
+
* ARTIFACT, not behaviour, and that is a lexical warning `done-criteria-check.js`
|
|
9
|
+
* raises against `dag`. A `> [!note] Por que artefato:` callout right below the
|
|
10
|
+
* post-condition is how a use case justifies keeping it literal — this module
|
|
11
|
+
* parses that callout (`parseArtifactJustifications`) so the lexical checker
|
|
12
|
+
* can skip the labels an author already defended. What IS decidable beyond
|
|
13
|
+
* that is the plumbing, and the plumbing is where the requirement actually
|
|
14
|
+
* leaked before:
|
|
8
15
|
*
|
|
9
16
|
* - a task pointing at a use case that does not exist → ERROR (a broken plan)
|
|
10
17
|
* - a post-condition no task ever mentions → warning (orphan)
|
|
@@ -37,11 +44,117 @@ import { useCaseId } from './usecase-promoter.js';
|
|
|
37
44
|
*/
|
|
38
45
|
const POSTCONDITION_RE = /^[ \t]*[-*][ \t]*\*\*(P\d+)\*\*[ \t]*[—–-][ \t]*(.+?)[ \t]*$/gm;
|
|
39
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Single-line form of `POSTCONDITION_RE`, used to walk a document line by line.
|
|
49
|
+
* DERIVED, never retyped: two hand copies drift, and a bullet shape only one
|
|
50
|
+
* of them accepts makes its justification stop working in silence.
|
|
51
|
+
*/
|
|
52
|
+
const POSTCONDITION_LINE_RE = new RegExp(POSTCONDITION_RE.source);
|
|
53
|
+
|
|
54
|
+
/** Any bullet (not just a labelled post-condition) — closes an open block. */
|
|
55
|
+
const BULLET_LINE_RE = /^\s*[-*]\s/;
|
|
56
|
+
|
|
57
|
+
/** A markdown heading — closes an open block. */
|
|
58
|
+
const HEADING_LINE_RE = /^\s*#/;
|
|
59
|
+
|
|
60
|
+
/** A blockquote line — it belongs to a callout, never to the post-condition text. */
|
|
61
|
+
const BLOCKQUOTE_LINE_RE = /^\s*>/;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The justification marker, ONE spelling for both forms: the callout below a
|
|
65
|
+
* use-case post-condition (here) and the inline form in a task's own
|
|
66
|
+
* `doneCriteria` (`done-criteria-check.js`). Tolerant of spacing — including
|
|
67
|
+
* before the colon — because the spacing is a typing accident, not a decision.
|
|
68
|
+
*/
|
|
69
|
+
export const ARTIFACT_MARKER_RE = /por\s+que\s+artefato\s*:/i;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The artifact-justification callout. It must live on its OWN line right
|
|
73
|
+
* below the post-condition — never inline, because `POSTCONDITION_RE`
|
|
74
|
+
* captures to end of line and that text travels literally into `doneCriteria`.
|
|
75
|
+
* The foldable forms (`[!note]-`, `[!note]+`) are the same callout.
|
|
76
|
+
*/
|
|
77
|
+
const ARTIFACT_NOTE_RE = new RegExp(String.raw`^\s*>\s*\[!note\][-+]?\s*` + ARTIFACT_MARKER_RE.source, 'i');
|
|
78
|
+
|
|
40
79
|
/** Case-folded, whitespace-collapsed form used for containment tests. */
|
|
41
|
-
function normalize(text) {
|
|
80
|
+
export function normalize(text) {
|
|
42
81
|
return String(text).toLowerCase().replace(/\s+/g, ' ').trim();
|
|
43
82
|
}
|
|
44
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Parse every post-condition of a use-case document as a BLOCK: its full text
|
|
86
|
+
* (first line + wrapped continuation) and whether a `> [!note] Por que
|
|
87
|
+
* artefato:` callout justifies it.
|
|
88
|
+
*
|
|
89
|
+
* A post-condition bullet opens a block for its label; continuation lines
|
|
90
|
+
* (wrapped post-condition text) are appended to the block's text; the block
|
|
91
|
+
* closes at a blank line, a new bullet, or a heading — same shape as a
|
|
92
|
+
* post-condition itself, so a callout separated by a blank line or living
|
|
93
|
+
* under the NEXT bullet never counts.
|
|
94
|
+
*
|
|
95
|
+
* Blockquote lines are never post-condition text: they belong to the callout,
|
|
96
|
+
* and a justification wrapped over two `>` lines is ordinary Obsidian style.
|
|
97
|
+
* Once the blockquote opens, a following line without `>` is the quote's lazy
|
|
98
|
+
* continuation (CommonMark), so it stays out of the text too. Appending either
|
|
99
|
+
* to the post-condition produced a text no `doneCriteria` ever carries.
|
|
100
|
+
*
|
|
101
|
+
* A repeated label counts once — first wins, as in `parsePostConditions`.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} content
|
|
104
|
+
* @returns {Map<string, { text: string, justified: boolean }>}
|
|
105
|
+
*/
|
|
106
|
+
export function parsePostConditionBlocks(content) {
|
|
107
|
+
const blocks = new Map();
|
|
108
|
+
let current = null; // { label, textParts: string[], justified: boolean, quoted: boolean }
|
|
109
|
+
|
|
110
|
+
const closeBlock = () => {
|
|
111
|
+
if (current && !blocks.has(current.label)) {
|
|
112
|
+
blocks.set(current.label, { text: current.textParts.join(' ').trim(), justified: current.justified });
|
|
113
|
+
}
|
|
114
|
+
current = null;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
for (const line of String(content).split(/\r?\n/)) {
|
|
118
|
+
const bulletMatch = line.match(POSTCONDITION_LINE_RE);
|
|
119
|
+
if (bulletMatch) {
|
|
120
|
+
closeBlock();
|
|
121
|
+
current = { label: bulletMatch[1], textParts: [bulletMatch[2].trim()], justified: false, quoted: false };
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (!current) continue; // prose outside any post-condition block
|
|
125
|
+
|
|
126
|
+
if (line.trim() === '' || HEADING_LINE_RE.test(line) || BULLET_LINE_RE.test(line)) {
|
|
127
|
+
closeBlock();
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (BLOCKQUOTE_LINE_RE.test(line)) {
|
|
131
|
+
if (ARTIFACT_NOTE_RE.test(line)) current.justified = true;
|
|
132
|
+
current.quoted = true;
|
|
133
|
+
continue; // callout lines are metadata, never part of the post-condition text
|
|
134
|
+
}
|
|
135
|
+
if (current.quoted) continue; // lazy continuation of the blockquote
|
|
136
|
+
current.textParts.push(line.trim());
|
|
137
|
+
}
|
|
138
|
+
closeBlock();
|
|
139
|
+
|
|
140
|
+
return blocks;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The justified post-conditions of a use-case document — the labels whose
|
|
145
|
+
* block (see `parsePostConditionBlocks`) carried the callout.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} content
|
|
148
|
+
* @returns {Map<string, string>} label → full post-condition text (first line + continuation)
|
|
149
|
+
*/
|
|
150
|
+
export function parseArtifactJustifications(content) {
|
|
151
|
+
const result = new Map();
|
|
152
|
+
for (const [label, block] of parsePostConditionBlocks(content)) {
|
|
153
|
+
if (block.justified) result.set(label, block.text);
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
45
158
|
/**
|
|
46
159
|
* Parse one use-case document into its post-conditions.
|
|
47
160
|
*
|
|
@@ -63,8 +176,12 @@ export function parsePostConditions(content) {
|
|
|
63
176
|
/**
|
|
64
177
|
* Read every use case of a feature, keyed by ID.
|
|
65
178
|
*
|
|
179
|
+
* `fullText` maps EVERY label to its whole block (first line + wrapped
|
|
180
|
+
* continuation); `postConditions[].text` stays the first line, which is what
|
|
181
|
+
* the literal-carry check compares against.
|
|
182
|
+
*
|
|
66
183
|
* @param {string} usecasesDir - absolute path to `2-plan/usecases`
|
|
67
|
-
* @returns {Map<string, { id: string, file: string, postConditions: Array<{label: string, text: string}> }>}
|
|
184
|
+
* @returns {Map<string, { id: string, file: string, postConditions: Array<{label: string, text: string}>, artifactJustified: Map<string, string>, fullText: Map<string, string> }>}
|
|
68
185
|
*/
|
|
69
186
|
export function loadUseCases(usecasesDir) {
|
|
70
187
|
const byId = new Map();
|
|
@@ -82,7 +199,14 @@ export function loadUseCases(usecasesDir) {
|
|
|
82
199
|
if (!id || !file.endsWith('.md')) continue;
|
|
83
200
|
try {
|
|
84
201
|
const content = readFileSync(join(usecasesDir, file), 'utf8');
|
|
85
|
-
|
|
202
|
+
const blocks = parsePostConditionBlocks(content);
|
|
203
|
+
const artifactJustified = new Map();
|
|
204
|
+
const fullText = new Map();
|
|
205
|
+
for (const [label, block] of blocks) {
|
|
206
|
+
fullText.set(label, block.text);
|
|
207
|
+
if (block.justified) artifactJustified.set(label, block.text);
|
|
208
|
+
}
|
|
209
|
+
byId.set(id, { id, file, postConditions: parsePostConditions(content), artifactJustified, fullText });
|
|
86
210
|
} catch { /* one unreadable use case must not sink the rest */ }
|
|
87
211
|
}
|
|
88
212
|
return byId;
|