@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.
Files changed (50) hide show
  1. package/CLAUDE.md +9 -7
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +2 -1
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/MORPH.md +24 -23
  8. package/framework/docs/CLI.md +184 -10
  9. package/framework/evals/proof-rubrics/eval-de-modelo.md +16 -0
  10. package/framework/evals/rubrics/condensed.md +3 -1
  11. package/framework/evals/rubrics/test-coverage.md +8 -0
  12. package/framework/hooks/shared/phase-utils.js +1 -0
  13. package/framework/phases.json +1 -1
  14. package/framework/rules/morph-workflow.md +1 -1
  15. package/framework/schemas/config.schema.json +7 -0
  16. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  17. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  18. package/framework/skills/core/morph-apply/SKILL.md +21 -10
  19. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  20. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  21. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  22. package/framework/skills/core/morph-implement/SKILL.md +20 -3
  23. package/framework/skills/core/morph-media/SKILL.md +1 -1
  24. package/framework/skills/core/morph-plan/SKILL.md +41 -3
  25. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  26. package/framework/skills/core/morph-review/SKILL.md +11 -2
  27. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  28. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  29. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  30. package/framework/standards/STANDARDS.json +2 -2
  31. package/framework/standards/ai-agents/prompt-sources.md +4 -3
  32. package/framework/standards/ai-agents/testing-ai.md +14 -13
  33. package/framework/templates/dotnet/ai-kit/README.md +14 -8
  34. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +11 -3
  35. package/package.json +1 -1
  36. package/src/commands/project/doctor.js +51 -2
  37. package/src/commands/tasks/dag.js +13 -1
  38. package/src/commands/validation/verify.js +47 -18
  39. package/src/core/paths/output-schema.js +7 -0
  40. package/src/lib/detectors/index.js +225 -217
  41. package/src/lib/risk-detector.js +38 -18
  42. package/src/lib/specs/done-criteria-check.js +200 -0
  43. package/src/lib/specs/usecase-check.js +129 -5
  44. package/src/lib/tasks/dotnet-test-introspection.js +517 -0
  45. package/src/lib/tasks/test-nodes.js +190 -20
  46. package/src/lib/tasks/test-runner.js +122 -2
  47. package/src/lib/tasks/test-scope.js +567 -0
  48. package/src/lib/validators/traceability-check.js +9 -2
  49. package/src/lib/validators/verify-runner.js +150 -18
  50. package/src/lib/worktree/docker-audit.js +5 -1
@@ -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
- * - verificationScope: string — escopo do stamp que carimbou esse
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. O
191
- // `verification` é um stamp SINGULAR sobrescrito por qualquer verify, de
192
- // qualquer escopo — então um stamp de escopo `task:{id}` no momento do
193
- // ship significa literalmente que o último verify NÃO foi o da feature. A
194
- // razão que ele carrega descreve outra corrida: 'task group "docs" does
195
- // not touch runtime' é dispensa legítima do que AQUELA task verificou, e a
196
- // última task de uma feature tipicamente é `group: docs` — ela compraria o
197
- // Gate 3 da feature que acabou de escrever, numa máquina onde a app nunca
198
- // subiu. Vale para as duas classes de dispensa, e não só para `scope`:
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. What IS decidable is the plumbing, and the plumbing
7
- * is where the requirement actually leaked before:
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
- byId.set(id, { id, file, postConditions: parsePostConditions(content) });
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;