@polymorphism-tech/morph-spec 8.22.0 → 8.23.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/README.md +2 -2
- package/bin/morph-spec.js +14 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/MORPH.md +1 -1
- package/framework/agents.json +1 -0
- package/framework/docs/CLI.md +3 -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 +1 -1
- 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 +14 -2
- package/framework/skills/core/morph-plan/SKILL.md +6 -4
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +29 -3
- 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 +28 -0
- package/framework/standards/backend/dotnet/fluent-validation-vsa.md +5 -2
- package/framework/standards/backend/dotnet/testing.md +127 -0
- package/package.json +1 -1
- package/src/commands/state/approve.js +11 -0
- package/src/commands/state/archive.js +12 -3
- package/src/commands/state/finish.js +9 -4
- package/src/commands/state/handoff.js +3 -2
- package/src/commands/state/usecase.js +77 -0
- package/src/commands/validation/verify.js +6 -0
- package/src/lib/generators/handoff-generator.js +2 -2
- package/src/lib/specs/usecase-promoter.js +135 -19
- package/src/lib/specs/usecase-registry.js +237 -0
- package/src/lib/validators/traceability-check.js +198 -0
- package/src/lib/validators/verify-runner.js +14 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* traceability-check.js — advisory, feature-scope-only check: do the files
|
|
3
|
+
* cited in recap.md's "**Files:**" bullet lists have an owning task in
|
|
4
|
+
* tasks.json's `outputs`?
|
|
5
|
+
*
|
|
6
|
+
* This is the DETERMINISTIC half of a two-layer fix. The NLH half lives in
|
|
7
|
+
* `framework/skills/core/morph-implement/SKILL.md` §7 ("outputs DEVE listar
|
|
8
|
+
* TODO arquivo criado ou modificado pela task") and targets the root cause: a
|
|
9
|
+
* file shipped without EVER being declared anywhere (not in outputs, not in
|
|
10
|
+
* recap.md — the real-world case that motivated this module, see the finding
|
|
11
|
+
* below). This module cannot catch THAT class of drift — a file absent from
|
|
12
|
+
* both documents is invisible to a check that only compares the two documents
|
|
13
|
+
* against each other. What it DOES catch is a narrower, complementary class:
|
|
14
|
+
* recap.md and tasks.json DISAGREEING — an author cited a file in the recap's
|
|
15
|
+
* prose but forgot (or a later task re-touched it without) declaring it in any
|
|
16
|
+
* task's `outputs`. That disagreement is real signal even though it is not the
|
|
17
|
+
* same defect the SKILL.md rule targets.
|
|
18
|
+
*
|
|
19
|
+
* Motivating evidence: run 2026-08-12T133732Z-vet-visit-followup-reminder —
|
|
20
|
+
* `ApplicationDbContextFactory.cs` shipped in production with no owning task
|
|
21
|
+
* in `outputs` AND no mention in recap.md (out of this module's reach by
|
|
22
|
+
* construction). Separately, `src/VetClinic.Api/appsettings.json` and
|
|
23
|
+
* `Properties/launchSettings.json` WERE cited in T1's recap `**Files:**` list
|
|
24
|
+
* but never appear in any task's `outputs` — exactly the disagreement this
|
|
25
|
+
* module flags.
|
|
26
|
+
*
|
|
27
|
+
* Advisory, never blocking: the returned `status` is only ever 'pass' | 'warn'
|
|
28
|
+
* | 'skip' — never 'fail'. `runVerify()` keeps this result OUTSIDE `nodes` on
|
|
29
|
+
* purpose so it can never flip the overall build/tests/validators/e2e status
|
|
30
|
+
* computed from `nodes` alone (see verify-runner.js).
|
|
31
|
+
*
|
|
32
|
+
* Fail-open throughout: no recap.md, no tasks.json, or a parse error all
|
|
33
|
+
* degrade to `{ status: 'skip' }` rather than throwing or warning about the
|
|
34
|
+
* absence itself — this is rastreabilidade tooling, not a completeness gate.
|
|
35
|
+
*
|
|
36
|
+
* @module traceability-check
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { existsSync, readFileSync } from 'fs';
|
|
40
|
+
import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
|
|
41
|
+
|
|
42
|
+
/** Matches a `**Files:**` marker followed by one or more `- ` bullet lines. */
|
|
43
|
+
const FILES_BLOCK_RE = /\*\*Files:\*\*[ \t]*\r?\n((?:^[ \t]*-[ \t].*\r?\n?)+)/gm;
|
|
44
|
+
|
|
45
|
+
/** Backtick-quoted spans within a bullet line. */
|
|
46
|
+
const BACKTICK_RE = /`([^`]+)`/g;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Extract the set of file paths cited under every "**Files:**" block in a
|
|
50
|
+
* recap.md's text, applying a conservative decomposition of the natural
|
|
51
|
+
* language shorthand real recaps use:
|
|
52
|
+
*
|
|
53
|
+
* - A line with exactly one backtick span: that span is a candidate,
|
|
54
|
+
* whether or not it contains a `/` (a legitimate top-level file, e.g.
|
|
55
|
+
* `` `VetClinic.sln` ``, has no slash).
|
|
56
|
+
* - A line with multiple comma-separated backtick spans (e.g.
|
|
57
|
+
* `` `a/b.json`, `c.json`, `d/e.json` ``): only the FIRST span and any
|
|
58
|
+
* LATER span that itself contains `/` are candidates. A later span with
|
|
59
|
+
* no `/` is ambiguous shorthand (a bare filename implicitly relative to
|
|
60
|
+
* the first span's directory, or a suffix like `` `.Designer.cs` ``) and
|
|
61
|
+
* is skipped rather than guessed — false negatives are the acceptable
|
|
62
|
+
* failure mode for advisory tooling, false positives are not.
|
|
63
|
+
* - Any span containing `{`/`}` (brace-expansion shorthand, e.g.
|
|
64
|
+
* `` `Foo/{A,B,C}.cs` ``) is skipped entirely — decomposing it correctly
|
|
65
|
+
* would require guessing at delimiters this format doesn't standardize.
|
|
66
|
+
* - Any span ending in `/` is a directory reference, not a file — skipped.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} recapText
|
|
69
|
+
* @returns {string[]} de-duplicated, normalized (forward-slash) candidate paths
|
|
70
|
+
*/
|
|
71
|
+
export function extractCitedFiles(recapText) {
|
|
72
|
+
const cited = new Set();
|
|
73
|
+
if (!recapText) return [];
|
|
74
|
+
|
|
75
|
+
FILES_BLOCK_RE.lastIndex = 0;
|
|
76
|
+
let blockMatch;
|
|
77
|
+
while ((blockMatch = FILES_BLOCK_RE.exec(recapText)) !== null) {
|
|
78
|
+
const block = blockMatch[1];
|
|
79
|
+
const lines = block.split(/\r?\n/).filter((l) => /^[ \t]*-[ \t]/.test(l));
|
|
80
|
+
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
const spans = [...line.matchAll(BACKTICK_RE)].map((m) => m[1].trim()).filter(Boolean);
|
|
83
|
+
spans.forEach((span, idx) => {
|
|
84
|
+
if (idx > 0 && !span.includes('/')) return; // ambiguous shorthand continuation
|
|
85
|
+
if (span.includes('{') || span.includes('}')) return; // brace-expansion, too ambiguous to decompose
|
|
86
|
+
if (span.endsWith('/')) return; // directory reference, not a file
|
|
87
|
+
cited.add(normalizePath(span));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return [...cited];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Backslashes to forward slashes, strip a leading `./`. */
|
|
96
|
+
function normalizePath(p) {
|
|
97
|
+
return String(p).replace(/\\/g, '/').replace(/^\.\//, '');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Union of `outputs` across every `done` task in tasks.json, normalized.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} projectPath
|
|
104
|
+
* @param {string} featureName
|
|
105
|
+
* @returns {string[]|null} null when tasks.json is missing/unreadable/invalid
|
|
106
|
+
* (the caller treats null as "cannot judge coverage" and skips, not warns)
|
|
107
|
+
*/
|
|
108
|
+
export function collectDoneOutputs(projectPath, featureName) {
|
|
109
|
+
let tasksPath;
|
|
110
|
+
try {
|
|
111
|
+
tasksPath = getAbsoluteOutputPath(projectPath, featureName, 'tasks');
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
if (!existsSync(tasksPath)) return null;
|
|
116
|
+
|
|
117
|
+
let data;
|
|
118
|
+
try {
|
|
119
|
+
data = JSON.parse(readFileSync(tasksPath, 'utf8'));
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
|
|
125
|
+
const outputs = [];
|
|
126
|
+
for (const task of tasks) {
|
|
127
|
+
if (task?.status !== 'done') continue;
|
|
128
|
+
for (const output of task.outputs || []) {
|
|
129
|
+
if (typeof output === 'string' && output.trim()) outputs.push(normalizePath(output));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return outputs;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A cited file is covered if some done task's `outputs` names it exactly, or
|
|
137
|
+
* names a directory (an entry ending in `/`) that the cited file falls under.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} file
|
|
140
|
+
* @param {string[]} outputs
|
|
141
|
+
* @returns {boolean}
|
|
142
|
+
*/
|
|
143
|
+
function isCovered(file, outputs) {
|
|
144
|
+
for (const output of outputs) {
|
|
145
|
+
if (output === file) return true;
|
|
146
|
+
if (output.endsWith('/') && file.startsWith(output)) return true;
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Run the recap-vs-outputs traceability check for a feature.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} projectPath
|
|
155
|
+
* @param {string} featureName
|
|
156
|
+
* @returns {{ status: 'pass'|'warn'|'skip', warnings: string[], reason: string|null, citedCount: number }}
|
|
157
|
+
*/
|
|
158
|
+
export function checkOutputsTraceability(projectPath, featureName) {
|
|
159
|
+
let recapPath;
|
|
160
|
+
try {
|
|
161
|
+
recapPath = getAbsoluteOutputPath(projectPath, featureName, 'recap');
|
|
162
|
+
} catch {
|
|
163
|
+
return { status: 'skip', warnings: [], reason: 'unknown feature', citedCount: 0 };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!existsSync(recapPath)) {
|
|
167
|
+
return { status: 'skip', warnings: [], reason: 'no recap.md', citedCount: 0 };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let recapText;
|
|
171
|
+
try {
|
|
172
|
+
recapText = readFileSync(recapPath, 'utf8');
|
|
173
|
+
} catch {
|
|
174
|
+
return { status: 'skip', warnings: [], reason: 'recap.md unreadable', citedCount: 0 };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const cited = extractCitedFiles(recapText);
|
|
178
|
+
if (cited.length === 0) {
|
|
179
|
+
return { status: 'skip', warnings: [], reason: 'no Files: entries found in recap.md', citedCount: 0 };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const outputs = collectDoneOutputs(projectPath, featureName);
|
|
183
|
+
if (outputs === null) {
|
|
184
|
+
return { status: 'skip', warnings: [], reason: 'no tasks.json', citedCount: cited.length };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const warnings = cited
|
|
188
|
+
.filter((file) => !isCovered(file, outputs))
|
|
189
|
+
.sort()
|
|
190
|
+
.map((file) => `${file} — cited in recap.md but not listed in any done task's outputs`);
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
status: warnings.length > 0 ? 'warn' : 'pass',
|
|
194
|
+
warnings,
|
|
195
|
+
reason: null,
|
|
196
|
+
citedCount: cited.length,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -31,6 +31,7 @@ import { buildComposeEnv } from '../e2e/compose-env.js';
|
|
|
31
31
|
import { runTestSuite, resolveTestCommand } from '../tasks/test-runner.js';
|
|
32
32
|
import { runValidation } from './validation-runner.js';
|
|
33
33
|
import { loadRubric, aggregateScores } from './score-aggregator.js';
|
|
34
|
+
import { checkOutputsTraceability } from './traceability-check.js';
|
|
34
35
|
import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
|
|
35
36
|
|
|
36
37
|
/** Default build timeout — dotnet cold build is slow; 30s (the Stop-hook default) is too short. */
|
|
@@ -56,6 +57,7 @@ const OUTPUT_TAIL_LINES = 20;
|
|
|
56
57
|
* @param {Function} [options._runTestSuite] - Injectable override for runTestSuite (testing)
|
|
57
58
|
* @param {Function} [options._runValidation] - Injectable override for runValidation (testing)
|
|
58
59
|
* @param {Function} [options._runE2eCheck] - Injectable override for runE2eCheck (testing)
|
|
60
|
+
* @param {Function} [options._checkOutputsTraceability] - Injectable override for checkOutputsTraceability (testing)
|
|
59
61
|
* @returns {Promise<Object>} Aggregated verify result — see module docs for shape
|
|
60
62
|
*/
|
|
61
63
|
export async function runVerify(projectPath, featureName, taskId, options = {}) {
|
|
@@ -70,6 +72,7 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
|
|
|
70
72
|
_runTestSuite = runTestSuite,
|
|
71
73
|
_runValidation = runValidation,
|
|
72
74
|
_runE2eCheck = runE2eCheck,
|
|
75
|
+
_checkOutputsTraceability = checkOutputsTraceability,
|
|
73
76
|
} = options;
|
|
74
77
|
|
|
75
78
|
const stack = readStack(projectPath);
|
|
@@ -105,6 +108,16 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
|
|
|
105
108
|
});
|
|
106
109
|
appendE2eFeedback(feedback, nodes.e2e);
|
|
107
110
|
|
|
111
|
+
// 5. traceability — advisory-only, feature-scope only (see traceability-check.js
|
|
112
|
+
// for the recap-vs-outputs disagreement it flags, and what it deliberately
|
|
113
|
+
// cannot catch). Lives OUTSIDE `nodes` on purpose: `status` below is computed
|
|
114
|
+
// from `nodes` alone, so this can never flip build/tests/validators/e2e green
|
|
115
|
+
// to red — recap/outputs disagreement is a rastreabilidade signal, not a
|
|
116
|
+
// correctness gate.
|
|
117
|
+
const traceability = taskId
|
|
118
|
+
? { status: 'skip', warnings: [], reason: 'task-scoped verify does not run traceability', citedCount: 0 }
|
|
119
|
+
: _checkOutputsTraceability(projectPath, featureName);
|
|
120
|
+
|
|
108
121
|
const status = Object.values(nodes).some((n) => n.status === 'fail') ? 'fail' : 'pass';
|
|
109
122
|
|
|
110
123
|
return {
|
|
@@ -117,6 +130,7 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
|
|
|
117
130
|
status,
|
|
118
131
|
nodes,
|
|
119
132
|
feedback,
|
|
133
|
+
traceability,
|
|
120
134
|
};
|
|
121
135
|
}
|
|
122
136
|
|