@polymorphism-tech/morph-spec 9.1.0 → 9.3.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 +14 -78
- package/README.md +2 -2
- package/bin/morph-spec.js +14 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +4 -3
- package/framework/MORPH.md +7 -10
- package/framework/agents.json +15 -12
- package/framework/commands/morph-status.md +2 -2
- package/framework/docs/CLI.md +24 -16
- package/framework/evals/review-checklist.md +15 -2
- package/framework/hooks/README.md +8 -44
- package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
- package/framework/hooks/claude-code/core/state-sync.js +1 -1
- package/framework/hooks/claude-code/core/worktree-context.js +23 -0
- package/framework/hooks/claude-code/statusline.py +3 -2
- package/framework/hooks/claude-code/statusline.sh +11 -11
- package/framework/hooks/shared/gate-event.js +18 -0
- package/framework/hooks/shared/next-step.js +107 -0
- package/framework/hooks/shared/slice-progress.js +18 -5
- package/framework/hooks/shared/state-writer.js +6 -1
- package/framework/mcp/registry.json +31 -1
- package/framework/rules/nextjs-standards.md +1 -1
- package/framework/schemas/feature.schema.json +1 -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 +6 -4
- package/framework/skills/core/morph-hotfix/SKILL.md +3 -3
- package/framework/skills/core/morph-machine/SKILL.md +1 -1
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +8 -6
- package/framework/skills/core/morph-review/SKILL.md +9 -6
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +2 -1
- package/framework/standards/STANDARDS.json +2 -2
- package/framework/standards/STANDARDS.meta.json +1 -1
- package/framework/standards/ai-agents/setup.md +2 -0
- package/framework/standards/backend/dotnet/testing.md +10 -4
- package/framework/standards/frontend/nextjs/testing.md +10 -6
- package/framework/standards/integration/mcp/mcp-tools.md +3 -1
- package/framework/templates/.idea/morph-templates.xml +92 -0
- package/framework/templates/.vscode/morph-templates.code-snippets +186 -0
- package/framework/templates/IDE-SNIPPETS.md +262 -262
- package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
- package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
- package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
- package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
- package/framework/templates/docs/plan.md +3 -1
- package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
- package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +56 -56
- package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
- package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
- package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
- package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
- package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
- package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
- package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
- package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
- package/framework/templates/meta-prompts/fusion/fusion-agent.md +76 -76
- package/framework/templates/meta-prompts/fusion/fusion-aggregator.md +100 -100
- package/framework/templates/state.template.json +17 -17
- package/framework/templates/ui/design-system.css +226 -226
- package/package.json +1 -1
- package/src/commands/observability/cost.js +12 -0
- package/src/commands/project/status.js +6 -68
- package/src/commands/state/advance.js +1 -1
- package/src/commands/state/approve.js +2 -2
- package/src/commands/state/gate-check.js +2 -2
- package/src/commands/state/gate-decision.js +4 -1
- package/src/commands/state/next.js +24 -0
- package/src/commands/state/report-check.js +3 -1
- package/src/commands/validation/mutate.js +9 -6
- package/src/commands/validation/plan-check.js +4 -2
- package/src/commands/validation/tests-audit.js +7 -0
- package/src/commands/validation/validate-feature.js +4 -2
- package/src/commands/validation/verify.js +21 -16
- package/src/core/state/state-manager.js +4 -0
- package/src/dashboard/app/main.jsx +13 -13
- package/src/dashboard/app/pages/FeatureDetail.jsx +6 -2
- package/src/dashboard/scanner/features-scanner.js +5 -2
- package/src/dashboard/vite.config.js +24 -24
- package/src/lib/ci/workflow-scaffold.js +12 -0
- package/src/lib/e2e/applicability.js +37 -0
- package/src/lib/generators/handoff-generator.js +11 -25
- package/src/lib/mutation/adapters.js +6 -17
- package/src/lib/mutation/runner.js +23 -75
- package/src/lib/mutation/trx.js +0 -1
- package/src/lib/next-step.js +10 -0
- package/src/lib/plan/checks.js +29 -2
- package/src/lib/plan/plan-parser.js +6 -2
- package/src/lib/review-reports.js +6 -3
- package/src/lib/risk-detector.js +20 -7
- package/src/lib/stack/stack-scan.js +13 -1
- package/src/lib/telemetry/metrics.js +46 -0
- package/src/lib/tests-audit/audit.js +96 -0
- package/src/lib/validators/code-budget/code-budget-validator.js +1 -1
- package/src/lib/validators/validation-runner.js +6 -38
- package/src/lib/validators/verify-runner.js +37 -22
- package/src/utils/agents-installer.js +24 -1
- package/framework/hooks/git/commit-msg/conventional-commits.sh +0 -33
- package/framework/hooks/git/pre-commit/agents.sh +0 -25
- package/framework/hooks/git/pre-commit/orchestrator.sh +0 -64
- package/framework/hooks/git/pre-push/run-tests.sh +0 -44
|
@@ -4,12 +4,13 @@ import { applyRegions, diffOf } from './manifest.js';
|
|
|
4
4
|
import { blame, parseCompilerErrors, patchFile, switchClassName, switchSource, SWITCH_ENV } from './schemata.js';
|
|
5
5
|
import { md5Of, md5OfFile, openSnapshot, restoreSnapshot, trackGenerated } from './snapshot.js';
|
|
6
6
|
import { parseBuiltDlls } from './commands.js';
|
|
7
|
-
import { firstLines
|
|
7
|
+
import { firstLines } from './trx.js';
|
|
8
8
|
import { nearestProjectOf } from './dotnet-introspection.js';
|
|
9
9
|
|
|
10
10
|
const posix = (p) => p.replace(/\\/g, '/');
|
|
11
11
|
const MAX_SCHEMATA_ATTEMPTS = 3;
|
|
12
|
-
|
|
12
|
+
|
|
13
|
+
export const noSubsetReason = (why) => `sem subconjunto utilizável (${why ?? 'nenhum teste da feature nem que cite os tipos mutados'}): a mutação não roda a suíte inteira — prove o cenário por leitura`;
|
|
13
14
|
|
|
14
15
|
const envWithout = (env) => { const { [SWITCH_ENV]: _drop, ...rest } = env; return rest; };
|
|
15
16
|
const failedNames = (result) => new Set(result.failedTests.map((t) => t.name));
|
|
@@ -123,29 +124,15 @@ export async function runMutation(input) {
|
|
|
123
124
|
.map((name) => [name, md5OfFile(dlls.get(name))]),
|
|
124
125
|
);
|
|
125
126
|
|
|
126
|
-
const run = (
|
|
127
|
-
|
|
128
|
-
const confirmKill = async (id, verdict, env) => {
|
|
129
|
-
if (!adapter.reproduce) return verdict;
|
|
130
|
-
const names = [...new Set(verdict.failures.map((f) => f.name))].slice(0, MAX_REPRODUCE);
|
|
131
|
-
const again = await timed(`${id}-reproduce`, () => adapter.reproduce({ names, label: `${id}-reproduce`, env }));
|
|
132
|
-
if (again.outcome === 'crash' || !again.executed) {
|
|
133
|
-
return { state: 'inconclusive', reason: `a falha de ${names[0]} não pôde ser confirmada na repetição (${again.note ?? 'nenhum teste executado'})` };
|
|
134
|
-
}
|
|
135
|
-
const wanted = new Set(names.map(methodOf));
|
|
136
|
-
const held = again.failedTests.filter((t) => wanted.has(methodOf(t.name)));
|
|
137
|
-
if (held.length > 0) return { ...verdict, reason: reasonOf(held) };
|
|
138
|
-
const whole = await timed(`${id}-reproduce-full`, () => adapter.runTests({ scope: 'full', label: `${id}-reproduce-full`, env }));
|
|
139
|
-
if (whole.outcome === 'crash' || !whole.executed) {
|
|
140
|
-
return { state: 'inconclusive', reason: `a falha de ${names[0]} não pôde ser confirmada na repetição (${whole.note ?? 'nenhum teste executado'})` };
|
|
141
|
-
}
|
|
142
|
-
const heldWhole = whole.failedTests.filter((t) => wanted.has(methodOf(t.name)));
|
|
143
|
-
if (heldWhole.length > 0) return { ...verdict, reason: reasonOf(heldWhole) };
|
|
144
|
-
const which = names.length === 1 ? names[0] : `${names.length} testes`;
|
|
145
|
-
return { state: 'survived', reason: `nenhum teste falhou de forma reproduzível: ${which} falhou uma vez e passou isolado e na suíte inteira repetida (instável, não prova o mutante)` };
|
|
146
|
-
};
|
|
127
|
+
const run = (label, env) => timed(label, () => adapter.runTests({ label, env }));
|
|
147
128
|
|
|
148
129
|
const record = (id, patch) => Object.assign(results.get(id), patch);
|
|
130
|
+
const recordSubset = (id, result) => {
|
|
131
|
+
const verdict = judge(result, baselineSubset);
|
|
132
|
+
const base = { scope: 'subset', executed: result.executed, durationMs: result.durationMs };
|
|
133
|
+
if (verdict.state === 'killed') record(id, { ...base, verdict: 'killed', killers: verdict.failures.length, reason: verdict.reason });
|
|
134
|
+
else record(id, { ...base, verdict: verdict.state, reason: verdict.reason ?? '' });
|
|
135
|
+
};
|
|
149
136
|
const applyOnDisk = (id) => {
|
|
150
137
|
const region = byId.get(id);
|
|
151
138
|
writeFileSync(join(root, region.file), applyRegions(originals.get(region.file), [region], (x) => x.replaceText).text);
|
|
@@ -154,10 +141,8 @@ export async function runMutation(input) {
|
|
|
154
141
|
let pristine = new Map();
|
|
155
142
|
let binary = { proof: 'unavailable', projects: [] };
|
|
156
143
|
let baselineSubset = null;
|
|
157
|
-
let baselineFull = null;
|
|
158
144
|
let subsetLive = adapter.hasSubset;
|
|
159
145
|
let subsetNote = null;
|
|
160
|
-
const needFullBaseline = [];
|
|
161
146
|
let restoreReport = [];
|
|
162
147
|
|
|
163
148
|
try {
|
|
@@ -238,40 +223,26 @@ export async function runMutation(input) {
|
|
|
238
223
|
if (subsetLive && (schemataIds.length > 0 || hasRebuild)) {
|
|
239
224
|
const copies = schemataIds.length > 0 ? jobs : 1;
|
|
240
225
|
baselineSubset = mergeBaselines(await Promise.all(Array.from({ length: copies }, (_, i) => (
|
|
241
|
-
run(
|
|
226
|
+
run(i === 0 ? 'baseline-subset' : `baseline-subset-${i + 1}`, envWithout(process.env))))));
|
|
242
227
|
if (baselineSubset.outcome === 'crash' || baselineSubset.executed === 0) {
|
|
243
228
|
subsetLive = false;
|
|
244
229
|
subsetNote = baselineSubset.executed === 0 ? 'o filtro do subconjunto não selecionou teste algum' : (baselineSubset.note ?? 'a rodada-base do subconjunto não terminou');
|
|
245
230
|
}
|
|
246
231
|
}
|
|
247
|
-
if (!subsetLive && schemataIds.length > 0) baselineFull = await run('full', 'baseline-full', envWithout(process.env));
|
|
248
|
-
|
|
249
|
-
const scopeOf = subsetLive ? 'subset' : 'full';
|
|
250
|
-
const scopeBaseline = () => (subsetLive ? baselineSubset : baselineFull);
|
|
251
232
|
const routed = [];
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
const base = { scope: scopeOf, executed: result.executed, durationMs: result.durationMs };
|
|
263
|
-
if (verdict.state === 'killed') {
|
|
264
|
-
record(id, { ...base, verdict: 'killed', killers: verdict.failures.length, reason: verdict.reason });
|
|
265
|
-
} else if (scopeOf === 'subset' && verdict.state === 'survived') {
|
|
266
|
-
record(id, { ...base, verdict: 'survived', reason: '' });
|
|
267
|
-
} else {
|
|
268
|
-
record(id, { ...base, verdict: null, reason: verdict.reason ?? '' });
|
|
269
|
-
routed.push({ id, needs: 'full' });
|
|
270
|
-
}
|
|
233
|
+
workerCount = subsetLive && schemataIds.length > 0 ? jobs : 1;
|
|
234
|
+
if (subsetLive) {
|
|
235
|
+
const outcomes = new Map();
|
|
236
|
+
await pool(schemataIds, jobs, async (id) => {
|
|
237
|
+
outcomes.set(id, await run(`${id}-subset`, { ...process.env, [SWITCH_ENV]: id }));
|
|
238
|
+
});
|
|
239
|
+
for (const id of schemataIds) recordSubset(id, outcomes.get(id));
|
|
240
|
+
for (const r of results.values()) if (r.mode === 'rebuild') routed.push(r.id);
|
|
241
|
+
} else {
|
|
242
|
+
for (const r of results.values()) record(r.id, { verdict: 'inconclusive', scope: null, reason: noSubsetReason(subsetNote) });
|
|
271
243
|
}
|
|
272
|
-
for (const r of results.values()) if (r.mode === 'rebuild') routed.push({ id: r.id, needs: subsetLive ? 'subset' : 'full' });
|
|
273
244
|
|
|
274
|
-
for (const
|
|
245
|
+
for (const id of routed) {
|
|
275
246
|
applyOnDisk(id);
|
|
276
247
|
try {
|
|
277
248
|
if (adapter.buildCommands.length > 0) {
|
|
@@ -282,21 +253,7 @@ export async function runMutation(input) {
|
|
|
282
253
|
continue;
|
|
283
254
|
}
|
|
284
255
|
}
|
|
285
|
-
|
|
286
|
-
const sub = await run('subset', `${id}-subset`, envWithout(process.env));
|
|
287
|
-
const verdict = judge(sub, baselineSubset);
|
|
288
|
-
const base = { scope: 'subset', executed: sub.executed, durationMs: sub.durationMs };
|
|
289
|
-
if (verdict.state === 'killed') record(id, { ...base, verdict: 'killed', killers: verdict.failures.length, reason: verdict.reason });
|
|
290
|
-
else record(id, { ...base, verdict: verdict.state, reason: verdict.reason ?? '' });
|
|
291
|
-
continue;
|
|
292
|
-
}
|
|
293
|
-
const full = await run('full', `${id}-full`, envWithout(process.env));
|
|
294
|
-
const first = judge(full, baselineFull);
|
|
295
|
-
const verdict = first.state === 'killed' ? await confirmKill(id, first, envWithout(process.env)) : first;
|
|
296
|
-
const base = { scope: 'full', executed: full.executed, durationMs: full.durationMs };
|
|
297
|
-
if (verdict.state === 'inconclusive') record(id, { ...base, verdict: 'inconclusive', reason: verdict.reason });
|
|
298
|
-
else if (verdict.state === 'survived') record(id, { ...base, verdict: 'survived', reason: verdict.reason ?? '' });
|
|
299
|
-
else { record(id, { ...base, verdict: 'killed', killers: verdict.failures.length, reason: verdict.reason }); if (!baselineFull) needFullBaseline.push({ id, full }); }
|
|
256
|
+
recordSubset(id, await run(`${id}-subset`, envWithout(process.env)));
|
|
300
257
|
} finally {
|
|
301
258
|
restoreSnapshot(snapshot);
|
|
302
259
|
}
|
|
@@ -310,14 +267,6 @@ export async function runMutation(input) {
|
|
|
310
267
|
binary = proveBinary({ pristine, dlls: last.dlls, restoredAt, hashes: dllHashes(last.dlls) });
|
|
311
268
|
}
|
|
312
269
|
|
|
313
|
-
if (needFullBaseline.length > 0) {
|
|
314
|
-
baselineFull = await run('full', 'baseline-full', envWithout(process.env));
|
|
315
|
-
for (const { id, full } of needFullBaseline) {
|
|
316
|
-
const verdict = judge(full, baselineFull);
|
|
317
|
-
if (verdict.state === 'killed') record(id, { killers: verdict.failures.length, reason: verdict.reason });
|
|
318
|
-
else record(id, { verdict: 'survived', killers: 0, reason: '' });
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
270
|
} finally {
|
|
322
271
|
restoreReport = restoreSnapshot(snapshot, { rewrite: false });
|
|
323
272
|
gate?.release();
|
|
@@ -338,7 +287,6 @@ export async function runMutation(input) {
|
|
|
338
287
|
mutants,
|
|
339
288
|
baseline: {
|
|
340
289
|
subset: baselineSubset ? { executed: baselineSubset.executed, failed: baselineSubset.failed } : null,
|
|
341
|
-
full: baselineFull ? { executed: baselineFull.executed, failed: baselineFull.failed } : null,
|
|
342
290
|
},
|
|
343
291
|
binary,
|
|
344
292
|
restore: {
|
package/src/lib/mutation/trx.js
CHANGED
|
@@ -43,7 +43,6 @@ export function parseTrx(xml) {
|
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
export const methodOf = (testName) => String(testName).replace(/\s*\(.*$/s, '').trim();
|
|
47
46
|
|
|
48
47
|
export function firstLines(message, max = 2, width = 240) {
|
|
49
48
|
const lines = String(message).split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, max).join(' ⏎ ');
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { collectNextSignals, decideNext } from '../../framework/hooks/shared/next-step.js';
|
|
2
|
+
import { treeMovedSince } from './git/evidence-staleness.js';
|
|
3
|
+
|
|
4
|
+
/** The CLI collector: the hook-readable facts plus verify staleness (git). Null without feature.json. */
|
|
5
|
+
export function nextStepFor(featureName, tree) {
|
|
6
|
+
const signals = collectNextSignals(featureName, tree);
|
|
7
|
+
if (!signals) return null;
|
|
8
|
+
signals.verificationStale = treeMovedSince(tree, signals.verification?.sha);
|
|
9
|
+
return { ...decideNext(signals), phase: signals.phase, failStreak: signals.failStreak };
|
|
10
|
+
}
|
package/src/lib/plan/checks.js
CHANGED
|
@@ -3,8 +3,9 @@ import { existsSync, readFileSync } from 'fs';
|
|
|
3
3
|
import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
|
|
4
4
|
import { frozenBody } from '../../../framework/hooks/shared/errata.js';
|
|
5
5
|
import { readAgentsJson } from '../specs/screen-checklist.js';
|
|
6
|
+
import { getFeature } from '../../../framework/hooks/shared/state-reader.js';
|
|
6
7
|
import { findArtifactTerms } from './artifact-vocabulary.js';
|
|
7
|
-
import { PROOF_KINDS, REUSE_VERDICTS, ADR_SCOPES, SECTIONS, CONFLICTS_HEADING } from './plan-parser.js';
|
|
8
|
+
import { PROOF_KINDS, RISK_KINDS, REUSE_VERDICTS, ADR_SCOPES, SECTIONS, CONFLICTS_HEADING } from './plan-parser.js';
|
|
8
9
|
|
|
9
10
|
export const CHECKS_VERSION = 1;
|
|
10
11
|
export const MAX_SLICE_FILES = 8;
|
|
@@ -83,7 +84,19 @@ export function parallelGroups(slices) {
|
|
|
83
84
|
return groups;
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
|
|
87
|
+
// A frozen body already extracted under older rules cannot gain a `risco:` line, so it is not demanded.
|
|
88
|
+
export function planRuleOptions(cwd, featureName) {
|
|
89
|
+
return {
|
|
90
|
+
workType: getFeature(featureName, cwd)?.workType ?? null,
|
|
91
|
+
riskRules: readPlanChecks(cwd, featureName).status !== 'fresh',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const foldRisk = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').replace(/`/g, '').toLowerCase().trim();
|
|
96
|
+
const RISK_BY_FOLD = new Map(RISK_KINDS.map((r) => [foldRisk(r), r]));
|
|
97
|
+
const REGRESSION_WORK_TYPES = ['bug', 'hotfix'];
|
|
98
|
+
|
|
99
|
+
export function validatePlan(plan, { personas = null, surfaces = null, workType = null, riskRules = true } = {}) {
|
|
87
100
|
const errors = [];
|
|
88
101
|
const warnings = [];
|
|
89
102
|
const at = (line) => (line ? ` (linha ${line})` : '');
|
|
@@ -114,12 +127,26 @@ export function validatePlan(plan, { personas = null, surfaces = null } = {}) {
|
|
|
114
127
|
} else if (!PROOF_KINDS.includes(c.proof.toLowerCase())) {
|
|
115
128
|
errors.push(`${c.id}: prova "${c.proof}" fora de ${PROOF_KINDS.join(' | ')}${at(c.line)}`);
|
|
116
129
|
}
|
|
130
|
+
const proof = c.proof?.toLowerCase();
|
|
131
|
+
const risk = c.risk === null ? null : RISK_BY_FOLD.get(foldRisk(c.risk)) ?? false;
|
|
132
|
+
if (risk === false) {
|
|
133
|
+
errors.push(`${c.id}: risco "${c.risk}" fora de ${RISK_KINDS.join(' | ')}${at(c.line)}`);
|
|
134
|
+
} else if (riskRules && proof === 'teste' && risk === null) {
|
|
135
|
+
errors.push(`${c.id}: "prova: teste" sem "risco:" (${RISK_KINDS.join(' | ')}) — sem risco, a prova é "leitura" ou "e2e"${at(c.line)}`);
|
|
136
|
+
} else if (risk && proof === 'leitura') {
|
|
137
|
+
warnings.push(`${c.id}: risco "${risk}" provado só por leitura${at(c.line)}`);
|
|
138
|
+
}
|
|
117
139
|
const terms = findArtifactTerms(c.text);
|
|
118
140
|
if (terms.length > 0 && !c.artifactWhy) {
|
|
119
141
|
errors.push(`${c.id} descreve artefato ("${terms.join(', ')}") sem "> Por que artefato:" — reescreva como efeito observável ou justifique${at(c.line)}`);
|
|
120
142
|
}
|
|
121
143
|
}
|
|
122
144
|
|
|
145
|
+
const isRegression = (c) => ['teste', 'e2e'].includes(c.proof?.toLowerCase()) && c.risk !== null && RISK_BY_FOLD.get(foldRisk(c.risk)) === 'regressão';
|
|
146
|
+
if (riskRules && REGRESSION_WORK_TYPES.includes(workType) && plan.scenarios.length > 0 && !plan.scenarios.some(isRegression)) {
|
|
147
|
+
errors.push(`${workType} sem cenário de regressão — o sintoma do bug real vira um Cn com "prova: teste" e "risco: regressão"`);
|
|
148
|
+
}
|
|
149
|
+
|
|
123
150
|
const scenarioIds = new Set(plan.scenarios.map((c) => c.id));
|
|
124
151
|
const sliceIds = new Set(plan.slices.map((s) => s.id));
|
|
125
152
|
const covered = new Set();
|
|
@@ -2,7 +2,8 @@ import { tableCells, isTableLine, SEPARATOR_ROW_RE } from '../specs/gfm-table.js
|
|
|
2
2
|
import { ARTIFACT_MARKER_RE } from './artifact-vocabulary.js';
|
|
3
3
|
import { markdownLines } from '../../../framework/hooks/shared/errata.js';
|
|
4
4
|
|
|
5
|
-
export const PROOF_KINDS = ['teste', 'e2e', 'eval', 'manual'];
|
|
5
|
+
export const PROOF_KINDS = ['teste', 'e2e', 'eval', 'manual', 'leitura'];
|
|
6
|
+
export const RISK_KINDS = ['regra', 'dinheiro', 'contrato', 'concorrência', 'dado', 'regressão'];
|
|
6
7
|
export const REUSE_VERDICTS = ['REUSAR', 'ESTENDER', 'CRIAR'];
|
|
7
8
|
export const ADR_SCOPES = ['feature', 'projeto'];
|
|
8
9
|
|
|
@@ -36,6 +37,7 @@ const SCENARIO_RE = new RegExp(String.raw`^[-*]\s+\*\*(C\d+)\*\*${DASH}(.+)$`);
|
|
|
36
37
|
const ADR_RE = new RegExp(String.raw`^###\s+(ADR-\d+)${DASH}(.+?)\s*$`);
|
|
37
38
|
const SLICE_RE = new RegExp(String.raw`^###\s+(F\d+)${DASH}(.+?)\s*$`);
|
|
38
39
|
const PROOF_RE = /^\s*prova\s*:\s*(.*?)\s*$/i;
|
|
40
|
+
const RISK_RE = /^\s*risco\s*:\s*(.*?)\s*$/i;
|
|
39
41
|
const WHY_RE = new RegExp(String.raw`^\s*>\s*` + ARTIFACT_MARKER_RE.source + String.raw`\s*(.*?)\s*$`, 'i');
|
|
40
42
|
const SCOPE_RE = /^\s*\*\*Alcance:\*\*\s*(.*?)\s*$/i;
|
|
41
43
|
const SLICE_LINE_RE = /^\s*[-*]\s+([\p{L}]+)\s*:\s*(.*?)\s*$/u;
|
|
@@ -69,7 +71,7 @@ function parseScenarios(lines) {
|
|
|
69
71
|
if (line.fenced) { current = null; continue; }
|
|
70
72
|
const m = SCENARIO_RE.exec(line.raw);
|
|
71
73
|
if (m) {
|
|
72
|
-
current = { id: m[1], text: m[2].trim(), proof: null, artifactWhy: null, line: line.n };
|
|
74
|
+
current = { id: m[1], text: m[2].trim(), proof: null, risk: null, artifactWhy: null, line: line.n };
|
|
73
75
|
scenarios.push(current);
|
|
74
76
|
continue;
|
|
75
77
|
}
|
|
@@ -79,6 +81,8 @@ function parseScenarios(lines) {
|
|
|
79
81
|
if (why) { current.artifactWhy = why[1] || ''; continue; }
|
|
80
82
|
const proof = PROOF_RE.exec(line.raw);
|
|
81
83
|
if (proof && current.proof === null) { current.proof = proof[1]; continue; }
|
|
84
|
+
const risk = RISK_RE.exec(line.raw);
|
|
85
|
+
if (risk && current.risk === null) { current.risk = risk[1]; continue; }
|
|
82
86
|
if (/^\s*>/.test(line.raw)) continue;
|
|
83
87
|
current.text = `${current.text} ${line.raw.trim()}`;
|
|
84
88
|
}
|
|
@@ -27,10 +27,11 @@ const CHECKS_SECTION_RE = sectionRe('Checks');
|
|
|
27
27
|
const MEDIA_SECTION_RE = /^##\s+M[íi]dia\s+gerada\s*$/m;
|
|
28
28
|
const SCREEN_CHECKLIST_SECTION_RE = sectionRe('Checklist\\s+de\\s+tela');
|
|
29
29
|
|
|
30
|
-
/** Hotfix: só o verificador, escrito pelo orquestrador sem dispatch. */
|
|
30
|
+
/** Hotfix: só o verificador, escrito pelo orquestrador sem dispatch. Chore: só o revisor (review leve). */
|
|
31
31
|
export function requiredReviewRoles({ workType, securityRequired = false } = {}) {
|
|
32
32
|
if (workType === 'hotfix') return ['verifier'];
|
|
33
|
-
|
|
33
|
+
const base = workType === 'chore' ? ['reviewer'] : ['verifier', 'reviewer'];
|
|
34
|
+
return securityRequired ? [...base, 'security'] : base;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
function sectionBodies(text, re) {
|
|
@@ -172,7 +173,9 @@ export function reviewSignal(reports, { required, checkIds }) {
|
|
|
172
173
|
superseded: r.superseded,
|
|
173
174
|
};
|
|
174
175
|
}
|
|
175
|
-
out.checks =
|
|
176
|
+
out.checks = !required.includes('verifier')
|
|
177
|
+
? { status: 'not-required', missing: [], notProven: [] }
|
|
178
|
+
: checkIds === null
|
|
176
179
|
? { status: 'no-checks', missing: [], notProven: [] }
|
|
177
180
|
: { status: 'ok', total: checkIds.length, ...checksCoverage(checkIds, reports.verifier.checks) };
|
|
178
181
|
return out;
|
package/src/lib/risk-detector.js
CHANGED
|
@@ -11,6 +11,7 @@ const E2E_SKIP_PATTERNS = [
|
|
|
11
11
|
[/docker is not available/i, 'environment'],
|
|
12
12
|
[/^requested$/i, 'requested'],
|
|
13
13
|
[/does not containerize/i, 'project'],
|
|
14
|
+
[/^not applicable:/i, 'not-applicable'],
|
|
14
15
|
];
|
|
15
16
|
|
|
16
17
|
// `unknown` (razão vazia) pausa de propósito: dispensa sem motivo é o no-op silencioso que a regra elimina.
|
|
@@ -99,6 +100,7 @@ export function reviewPause(signal) {
|
|
|
99
100
|
if (r.verdict !== 'SHIP') return `veredito do ${role} é ${r.verdict}`;
|
|
100
101
|
}
|
|
101
102
|
const checks = signal?.checks;
|
|
103
|
+
if (checks?.status === 'not-required') return null;
|
|
102
104
|
if (!checks || checks.status !== 'ok') {
|
|
103
105
|
return 'sem `2-plan/checks.json` legível — a cobertura dos cenários pelo verificador não é conferível';
|
|
104
106
|
}
|
|
@@ -156,23 +158,25 @@ export function shouldPauseGate(config, signals) {
|
|
|
156
158
|
const trust = config?.trust ?? 'auto';
|
|
157
159
|
|
|
158
160
|
if (trust === 'manual') {
|
|
159
|
-
return { pause: true, reason: 'trust mode is manual' };
|
|
161
|
+
return { pause: true, signal: 'trust-manual', reason: 'trust mode is manual' };
|
|
160
162
|
}
|
|
161
163
|
|
|
162
164
|
if (FORCED_PAUSE_WORK_TYPES.includes(signals.workType)) {
|
|
163
165
|
return {
|
|
164
166
|
pause: true,
|
|
167
|
+
signal: 'hotfix',
|
|
165
168
|
reason: 'hotfix: aprovação humana obrigatória no ship (política de tipo — ignora trust:auto)',
|
|
166
169
|
};
|
|
167
170
|
}
|
|
168
171
|
|
|
169
172
|
if (signals.designSystemDivergence) {
|
|
170
|
-
return { pause: true, reason: 'design-system divergence detected' };
|
|
173
|
+
return { pause: true, signal: 'design-system', reason: 'design-system divergence detected' };
|
|
171
174
|
}
|
|
172
175
|
|
|
173
176
|
if (Array.isArray(signals.standardsViolations) && signals.standardsViolations.length > 0) {
|
|
174
177
|
return {
|
|
175
178
|
pause: true,
|
|
179
|
+
signal: 'standards',
|
|
176
180
|
reason: `standards violations detected: ${signals.standardsViolations.join(', ')}`,
|
|
177
181
|
};
|
|
178
182
|
}
|
|
@@ -181,6 +185,7 @@ export function shouldPauseGate(config, signals) {
|
|
|
181
185
|
if (signals.verificationStatus === 'fail') {
|
|
182
186
|
return {
|
|
183
187
|
pause: true,
|
|
188
|
+
signal: 'verify-red',
|
|
184
189
|
reason: 'último morph-spec verify está VERMELHO — rode verify e corrija antes de aprovar o gate',
|
|
185
190
|
};
|
|
186
191
|
}
|
|
@@ -189,6 +194,7 @@ export function shouldPauseGate(config, signals) {
|
|
|
189
194
|
if (signals.gate === 'review' && !signals.verificationStatus) {
|
|
190
195
|
return {
|
|
191
196
|
pause: true,
|
|
197
|
+
signal: 'verify-missing',
|
|
192
198
|
reason: 'nenhum `morph-spec verify` carimbado nesta feature — rode `morph-spec verify {feature}` antes do ship',
|
|
193
199
|
};
|
|
194
200
|
}
|
|
@@ -198,7 +204,7 @@ export function shouldPauseGate(config, signals) {
|
|
|
198
204
|
if (signals.gate === 'review' && signals.e2eStatus === 'skip') {
|
|
199
205
|
const klass = classifyE2eSkip(signals.e2eReason);
|
|
200
206
|
if (E2E_SKIP_CLASSES_THAT_PAUSE.includes(klass)) {
|
|
201
|
-
return { pause: true, reason: e2ePauseReason(klass, signals.e2eReason) };
|
|
207
|
+
return { pause: true, signal: 'e2e-skip', reason: e2ePauseReason(klass, signals.e2eReason) };
|
|
202
208
|
}
|
|
203
209
|
}
|
|
204
210
|
|
|
@@ -206,13 +212,14 @@ export function shouldPauseGate(config, signals) {
|
|
|
206
212
|
if (signals.gate === 'review' && signals.verificationStale === true) {
|
|
207
213
|
return {
|
|
208
214
|
pause: true,
|
|
215
|
+
signal: 'verify-stale',
|
|
209
216
|
reason: 'verification stamp desatualizado (o código mudou depois do commit do stamp) — rode `morph-spec verify` de novo antes do ship',
|
|
210
217
|
};
|
|
211
218
|
}
|
|
212
219
|
|
|
213
220
|
if (signals.gate === 'review') {
|
|
214
221
|
const reason = reviewPause(signals.reviewReports);
|
|
215
|
-
if (reason) return { pause: true, reason };
|
|
222
|
+
if (reason) return { pause: true, signal: 'review-reports', reason };
|
|
216
223
|
}
|
|
217
224
|
|
|
218
225
|
// Prompt e modelo não são lidos por build nem por teste. Depois dos relatórios de review de propósito:
|
|
@@ -227,6 +234,7 @@ export function shouldPauseGate(config, signals) {
|
|
|
227
234
|
if (signals.evalsStatus === null || signals.evalsStatus === undefined) {
|
|
228
235
|
return {
|
|
229
236
|
pause: true,
|
|
237
|
+
signal: 'prompt-model-no-eval',
|
|
230
238
|
reason: `mudou prompt/modelo (${where}) e o projeto não declara nó de eval`
|
|
231
239
|
+ ' — nenhum build ou teste unitário fica vermelho por um prompt trocado;'
|
|
232
240
|
+ ' declare um `runner: "evals"` em project.tests[] ou registre a dispensa com `morph-spec gate-decision`',
|
|
@@ -235,6 +243,7 @@ export function shouldPauseGate(config, signals) {
|
|
|
235
243
|
if (signals.evalsStatus === 'skip') {
|
|
236
244
|
return {
|
|
237
245
|
pause: true,
|
|
246
|
+
signal: 'prompt-model-eval-skip',
|
|
238
247
|
reason: `mudou prompt/modelo (${where}) e o eval não foi executado neste verify`
|
|
239
248
|
+ ' — rode `morph-spec verify {feature}` (escopo de feature) antes do ship',
|
|
240
249
|
};
|
|
@@ -248,6 +257,7 @@ export function shouldPauseGate(config, signals) {
|
|
|
248
257
|
&& signals.mediaClaims.section !== true) {
|
|
249
258
|
return {
|
|
250
259
|
pause: true,
|
|
260
|
+
signal: 'media-claims',
|
|
251
261
|
reason: '`4-review/verifier.md` sem a seção `## Mídia gerada` — as quatro afirmações de mídia'
|
|
252
262
|
+ ' (custo, revisão humana, marcação, hot path) não foram provadas'
|
|
253
263
|
+ ' (evals/review-checklist.md, "Verificador")',
|
|
@@ -257,7 +267,7 @@ export function shouldPauseGate(config, signals) {
|
|
|
257
267
|
// Sem o checklist de tela completo, paginação, ordenação, filtro e os três estados não viram cenário.
|
|
258
268
|
if (signals.gate === 'uiux') {
|
|
259
269
|
const reason = uiuxChecklistPause(signals.screenChecklist);
|
|
260
|
-
if (reason) return { pause: true, reason };
|
|
270
|
+
if (reason) return { pause: true, signal: 'uiux-checklist', reason };
|
|
261
271
|
}
|
|
262
272
|
|
|
263
273
|
// Cobra-se só o item devido (`uncovered`): tela toda "não se aplica", design legado ou ausente não devem nada.
|
|
@@ -266,6 +276,7 @@ export function shouldPauseGate(config, signals) {
|
|
|
266
276
|
if (section !== true) {
|
|
267
277
|
return {
|
|
268
278
|
pause: true,
|
|
279
|
+
signal: 'screen-checklist',
|
|
269
280
|
reason: `\`4-review/verifier.md\` sem a seção \`## Checklist de tela\` — o design decidiu ${firstOf(uncovered)}`
|
|
270
281
|
+ ' e o verificador não registrou a conferência contra a tela entregue'
|
|
271
282
|
+ ' (evals/review-checklist.md, "Contrato de tela")',
|
|
@@ -273,6 +284,7 @@ export function shouldPauseGate(config, signals) {
|
|
|
273
284
|
}
|
|
274
285
|
return {
|
|
275
286
|
pause: true,
|
|
287
|
+
signal: 'screen-checklist',
|
|
276
288
|
reason: `a seção \`## Checklist de tela\` não confere ${firstOf(uncovered)}`
|
|
277
289
|
+ ' — uma linha por item decidido de cada tela, mesmo quando não verificável',
|
|
278
290
|
};
|
|
@@ -280,17 +292,18 @@ export function shouldPauseGate(config, signals) {
|
|
|
280
292
|
|
|
281
293
|
if (signals.gate === 'plan') {
|
|
282
294
|
const reason = planChecksPause(signals.planChecks);
|
|
283
|
-
if (reason) return { pause: true, reason };
|
|
295
|
+
if (reason) return { pause: true, signal: 'plan-checks', reason };
|
|
284
296
|
}
|
|
285
297
|
|
|
286
298
|
if (typeof signals.sliceCount === 'number' && signals.sliceCount > MAX_SLICES) {
|
|
287
299
|
return {
|
|
288
300
|
pause: true,
|
|
301
|
+
signal: 'slice-count',
|
|
289
302
|
reason: `${signals.sliceCount} fatias, acima de ${MAX_SLICES} — o plano é grande demais para uma feature`,
|
|
290
303
|
};
|
|
291
304
|
}
|
|
292
305
|
|
|
293
|
-
return { pause: false, reason: '' };
|
|
306
|
+
return { pause: false, signal: null, reason: '' };
|
|
294
307
|
}
|
|
295
308
|
|
|
296
309
|
export function getRiskSignals(signals) {
|
|
@@ -99,7 +99,19 @@ function auditDotnet(files, read) {
|
|
|
99
99
|
const otherDb = has(/^(Microsoft\.EntityFrameworkCore\.SqlServer|Pomelo\.EntityFrameworkCore\.MySql|MySql\.|MySqlConnector|Microsoft\.Data\.SqlClient|System\.Data\.SqlClient)/);
|
|
100
100
|
rows.push({ layer: 'ORM', detected: ef.length > 0 ? 'EF Core' : (packages.has('Dapper') ? 'Dapper' : 'ausente'), expected: 'EF Core', ok: ef.length > 0 });
|
|
101
101
|
rows.push({ layer: 'Banco', detected: npgsql.length > 0 ? 'Postgres (Npgsql)' : (otherDb.join(', ') || 'ausente'), expected: 'Neon (Postgres)', ok: npgsql.length > 0 && otherDb.length === 0 });
|
|
102
|
-
|
|
102
|
+
const semanticKernel = has(/^Microsoft\.SemanticKernel/);
|
|
103
|
+
const kernelFunction = grepFiles(source, read, /\[KernelFunction\b/);
|
|
104
|
+
const hostedAgents = has(/^Azure\.AI\.(Projects|Agents\.Persistent)\b/);
|
|
105
|
+
const floatingAi = has(/^(Microsoft\.Agents\.AI|Microsoft\.Extensions\.AI|OpenAI$|Google\.GenAI$|ModelContextProtocol)/)
|
|
106
|
+
.filter((p) => /\*|^[[(]/.test(packages.get(p) ?? ''));
|
|
107
|
+
rows.push({ layer: 'IA', detected: has(/^Microsoft\.(Agents\.AI|Extensions\.AI|SemanticKernel)/).join(', ') || 'ausente', expected: 'MAF quando houver agentes', ok: semanticKernel.length === 0 && kernelFunction.length === 0 && hostedAgents.length === 0 });
|
|
108
|
+
const antiPatterns = '`morph-spec standards --show ai-agents-setup#7-what-not-to-do-anti-patterns`';
|
|
109
|
+
if (semanticKernel.length > 0 || kernelFunction.length > 0) {
|
|
110
|
+
add('Alto', `Semantic Kernel em vez de MAF (${semanticKernel.join(', ') || '`[KernelFunction]`'})`, [...(semanticKernel.length > 0 ? projects : []), ...kernelFunction],
|
|
111
|
+
`\`Microsoft.Agents.AI\` (\`agent.RunAsync()\`, tool por \`AIFunctionFactory.Create()\`); ${antiPatterns}`);
|
|
112
|
+
}
|
|
113
|
+
if (hostedAgents.length > 0) add('Alto', `Serviço de agente hospedado (${hostedAgents.join(', ')})`, projects, `modelo direto por \`IChatClient\`; ${antiPatterns}`);
|
|
114
|
+
for (const p of floatingAi) add('Médio', `Versão flutuante de SDK de IA (${p} ${packages.get(p)})`, [], 'versão exata, a do pin (`morph-spec doctor --ai`)');
|
|
103
115
|
rows.push({ layer: 'Jobs', detected: has(/^Hangfire/).length > 0 ? 'Hangfire' : 'ausente', expected: 'Hangfire só com razão real', ok: true });
|
|
104
116
|
if (packages.has('Dapper') && ef.length === 0) add('Bloqueante', 'ORM diferente (Dapper puro sem EF Core)', projects, 'migrar o acesso a dados para EF Core');
|
|
105
117
|
if (otherDb.length > 0) add('Bloqueante', `Banco diferente (${otherDb.join(', ')})`, projects, 'migrar para Neon (Postgres, Npgsql)');
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { ingestedBytes } from './telemetry-reader.js';
|
|
5
5
|
import { producerOf } from '../../../framework/hooks/shared/command-tokens.js';
|
|
6
6
|
import { isFailedToolCall } from '../../../framework/hooks/shared/telemetry-logger.js';
|
|
7
|
+
import { GATE_EVENT, GATE_STEPS } from '../../../framework/hooks/shared/gate-event.js';
|
|
7
8
|
|
|
8
9
|
export const RERUN_WINDOW_MS = 10 * 60 * 1000;
|
|
9
10
|
|
|
@@ -122,6 +123,43 @@ function reuseStateOf(e) {
|
|
|
122
123
|
return { state: 'absent', reason: null };
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
const newGateSlot = () => ({ decisions: 0, paused: 0, autoPassed: 0, approvedAfterPause: 0, reworkedAfterPause: 0, unapproved: 0, bySignal: {} });
|
|
127
|
+
|
|
128
|
+
// A pause stays open until the next approve or a decision that no longer pauses on the same signal:
|
|
129
|
+
// re-running gate-check over the same unchanged pause is not rework.
|
|
130
|
+
function trackGate(gates, open, e) {
|
|
131
|
+
if (!e.gate || !GATE_STEPS.includes(e.step)) return;
|
|
132
|
+
const slot = gates[e.gate] ||= newGateSlot();
|
|
133
|
+
const key = `${e.feature || ''}\u0000${e.gate}`;
|
|
134
|
+
const pending = open.get(key);
|
|
135
|
+
const bySignal = (signal) => slot.bySignal[signal || 'desconhecido'] ||= { paused: 0, approvedAfterPause: 0, reworkedAfterPause: 0 };
|
|
136
|
+
const credit = (field) => {
|
|
137
|
+
slot[field] += 1;
|
|
138
|
+
bySignal(pending.signal)[field] += 1;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
if (e.step === 'unapprove') {
|
|
142
|
+
slot.unapproved += 1;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (e.step === 'approve') {
|
|
146
|
+
if (pending) credit('approvedAfterPause');
|
|
147
|
+
open.delete(key);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
slot.decisions += 1;
|
|
151
|
+
if (pending && e.pause && e.signal === pending.signal) return;
|
|
152
|
+
if (pending) credit(e.source === 'manual' && !e.pause ? 'approvedAfterPause' : 'reworkedAfterPause');
|
|
153
|
+
if (e.pause) {
|
|
154
|
+
slot.paused += 1;
|
|
155
|
+
bySignal(e.signal).paused += 1;
|
|
156
|
+
open.set(key, { signal: e.signal });
|
|
157
|
+
} else {
|
|
158
|
+
slot.autoPassed += 1;
|
|
159
|
+
open.delete(key);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
125
163
|
/** The returned shape is documented in framework/docs/CLI.md, "cost --metrics". */
|
|
126
164
|
export function computeMetrics(events, opts = {}) {
|
|
127
165
|
const top = Number.isInteger(opts.top) && opts.top > 0 ? opts.top : 10;
|
|
@@ -140,6 +178,8 @@ export function computeMetrics(events, opts = {}) {
|
|
|
140
178
|
const lastFingerprint = new Map();
|
|
141
179
|
const unchangedRepeats = {};
|
|
142
180
|
const reuse = { runs: 0, byTree: {}, skipReasons: {} };
|
|
181
|
+
const gates = {};
|
|
182
|
+
const openPauses = new Map();
|
|
143
183
|
let v2 = 0;
|
|
144
184
|
let legacy = 0;
|
|
145
185
|
let turnsObserved = false;
|
|
@@ -166,6 +206,11 @@ export function computeMetrics(events, opts = {}) {
|
|
|
166
206
|
continue;
|
|
167
207
|
}
|
|
168
208
|
|
|
209
|
+
if (e.event === GATE_EVENT) {
|
|
210
|
+
trackGate(gates, openPauses, e);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
169
214
|
if (e.event === 'Verify') {
|
|
170
215
|
if (e.fingerprint && e.persisted !== false) {
|
|
171
216
|
if (lastFingerprint.get(fkey) === e.fingerprint) {
|
|
@@ -291,6 +336,7 @@ export function computeMetrics(events, opts = {}) {
|
|
|
291
336
|
},
|
|
292
337
|
dotnetFullSuite: fullSuite,
|
|
293
338
|
reuse,
|
|
339
|
+
gates,
|
|
294
340
|
features: featureRows,
|
|
295
341
|
};
|
|
296
342
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { basename, join } from 'path';
|
|
4
|
+
import { isTestPath } from '../graph/sanitize.js';
|
|
5
|
+
|
|
6
|
+
export const PROSE_MIN_HITS = 3;
|
|
7
|
+
export const FAMILY_MIN_SIZE = 4;
|
|
8
|
+
|
|
9
|
+
const TEST_FILE = /\.(cs|[cm]?[jt]sx?)$/i;
|
|
10
|
+
const CS_CASE = /\[\s*\w*(Fact|Theory)\b/g;
|
|
11
|
+
const JS_CASE = /^\s*(?:it|test)(?:\.each\s*\([\s\S]*?\))?\s*\(\s*['"`]/gm;
|
|
12
|
+
const READS_DOC = /(ReadAll\w*|readFileSync)\s*\([^)]*\.(md|txt)\b/;
|
|
13
|
+
const PROSE_ASSERT = /(?:Assert\.(?:Contains|DoesNotContain)\(\s*"|\.(?:toContain|toMatch)\(\s*['"`])([^"'`]{20,})/g;
|
|
14
|
+
const CONSTANT_ASSERT = /Assert\.Equal\(\s*"[^"]*"\s*,\s*[A-Z]\w*(?:\.[A-Z]\w*)+\s*\)|expect\(\s*[A-Z][A-Z0-9_]+(?:\.[A-Z0-9_]+)*\s*\)\.toBe\(\s*['"`]/g;
|
|
15
|
+
const CS_METHOD = /\[\s*\w*Fact\b[^\]]*\][\s\S]*?(?:void|Task)\s+(\w+)\s*\(/g;
|
|
16
|
+
const LAYER = /^(\w+?)(Handler|Endpoints?|EndpointIntegration|Integration)Tests$/;
|
|
17
|
+
|
|
18
|
+
function trackedTestFiles(root) {
|
|
19
|
+
try {
|
|
20
|
+
return execFileSync('git', ['ls-files', '-z'], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 256 * 1024 * 1024 })
|
|
21
|
+
.toString().split('\0').filter((f) => f && TEST_FILE.test(f) && isTestPath(f));
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const lineOf = (text, index) => text.slice(0, index).split('\n').length;
|
|
28
|
+
const countOf = (text, re) => (text.match(re) ?? []).length;
|
|
29
|
+
|
|
30
|
+
function factFamilies(text) {
|
|
31
|
+
const byStem = new Map();
|
|
32
|
+
for (const m of text.matchAll(CS_METHOD)) {
|
|
33
|
+
const name = m[1];
|
|
34
|
+
const cut = name.lastIndexOf('_');
|
|
35
|
+
if (cut <= 0) continue;
|
|
36
|
+
const stem = name.slice(0, cut);
|
|
37
|
+
byStem.set(stem, [...(byStem.get(stem) ?? []), name]);
|
|
38
|
+
}
|
|
39
|
+
return [...byStem].filter(([, names]) => names.length >= FAMILY_MIN_SIZE).map(([stem, names]) => ({ stem, size: names.length }));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Read-only heuristics over the tracked test files: candidates to delete or merge, never verdicts. */
|
|
43
|
+
export function auditTests(root) {
|
|
44
|
+
const files = trackedTestFiles(root);
|
|
45
|
+
if (files === null) return { ok: false, reason: 'git ls-files não respondeu — a auditoria lê só arquivos rastreados' };
|
|
46
|
+
|
|
47
|
+
const perFile = [];
|
|
48
|
+
const prose = [];
|
|
49
|
+
const constants = [];
|
|
50
|
+
const families = [];
|
|
51
|
+
const layers = new Map();
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
let text;
|
|
54
|
+
try { text = readFileSync(join(root, file), 'utf8'); } catch { continue; }
|
|
55
|
+
const cases = /\.cs$/i.test(file) ? countOf(text, CS_CASE) : countOf(text, JS_CASE);
|
|
56
|
+
if (cases === 0) continue;
|
|
57
|
+
perFile.push({ file, cases });
|
|
58
|
+
|
|
59
|
+
const proseHits = [...text.matchAll(PROSE_ASSERT)].filter((m) => /\s/.test(m[1]));
|
|
60
|
+
if (READS_DOC.test(text) || proseHits.length >= PROSE_MIN_HITS) {
|
|
61
|
+
prose.push({ file, readsDoc: READS_DOC.test(text), literalAsserts: proseHits.length });
|
|
62
|
+
}
|
|
63
|
+
for (const m of text.matchAll(CONSTANT_ASSERT)) constants.push({ file, line: lineOf(text, m.index) });
|
|
64
|
+
for (const family of factFamilies(text)) families.push({ file, ...family });
|
|
65
|
+
|
|
66
|
+
const layer = LAYER.exec(basename(file).replace(/\.\w+$/, ''));
|
|
67
|
+
if (layer) layers.set(layer[1], [...(layers.get(layer[1]) ?? []), file]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
perFile.sort((a, b) => b.cases - a.cases);
|
|
71
|
+
return {
|
|
72
|
+
ok: true,
|
|
73
|
+
files: perFile.length,
|
|
74
|
+
cases: perFile.reduce((n, f) => n + f.cases, 0),
|
|
75
|
+
largest: perFile.slice(0, 10),
|
|
76
|
+
prose,
|
|
77
|
+
constants,
|
|
78
|
+
families,
|
|
79
|
+
layers: [...layers].filter(([, f]) => f.length > 1).map(([subject, f]) => ({ subject, files: f })),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function renderTestsAudit(result) {
|
|
84
|
+
if (!result.ok) return `tests audit: ${result.reason}`;
|
|
85
|
+
const out = [`${result.cases} casos em ${result.files} arquivos de teste`, ''];
|
|
86
|
+
const section = (title, rows) => {
|
|
87
|
+
if (rows.length === 0) return;
|
|
88
|
+
out.push(`${title} (${rows.length})`, ...rows.map((r) => ` ${r}`), '');
|
|
89
|
+
};
|
|
90
|
+
section('Maiores arquivos', result.largest.map((f) => `${f.cases} ${f.file}`));
|
|
91
|
+
section('Fixam texto ou leem doc — candidatos a apagar', result.prose.map((p) => `${p.file}${p.readsDoc ? ' (lê .md/.txt)' : ''}${p.literalAsserts ? ` — ${p.literalAsserts} asserts de frase` : ''}`));
|
|
92
|
+
section('Constante contra o próprio literal — candidatos a apagar', result.constants.map((c) => `${c.file}:${c.line}`));
|
|
93
|
+
section(`Famílias de [Fact] (≥ ${FAMILY_MIN_SIZE} com o mesmo prefixo) — candidatas a [Theory]`, result.families.map((f) => `${f.file} — ${f.stem}_* ×${f.size}`));
|
|
94
|
+
section('Mesmo assunto em mais de uma camada — mantenha a mais barata que morde', result.layers.map((l) => `${l.subject}: ${l.files.join(', ')}`));
|
|
95
|
+
return out.join('\n').trimEnd();
|
|
96
|
+
}
|