@polymorphism-tech/morph-spec 9.8.0 → 10.0.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 (185) hide show
  1. package/CLAUDE.md +10 -10
  2. package/README.md +4 -4
  3. package/bin/morph-spec.js +40 -43
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +24 -30
  8. package/framework/MORPH.md +47 -123
  9. package/framework/agents.json +6 -374
  10. package/framework/commands/morph-apply.md +1 -1
  11. package/framework/commands/morph-archive.md +4 -3
  12. package/framework/commands/morph-hotfix.md +8 -30
  13. package/framework/commands/morph-infra.md +2 -2
  14. package/framework/commands/morph-media.md +10 -9
  15. package/framework/commands/morph-review.md +15 -0
  16. package/framework/commands/morph-status.md +1 -1
  17. package/framework/docs/CLI.md +83 -94
  18. package/framework/evals/review-checklist.md +45 -53
  19. package/framework/hooks/README.md +4 -4
  20. package/framework/hooks/claude-code/core/README.md +1 -1
  21. package/framework/hooks/claude-code/core/denylist.json +4 -4
  22. package/framework/hooks/claude-code/core/gate-guard.js +9 -0
  23. package/framework/hooks/claude-code/core/masked-exit.js +49 -0
  24. package/framework/hooks/claude-code/core/state-sync.js +4 -1
  25. package/framework/hooks/claude-code/core/telemetry-log.js +1 -2
  26. package/framework/hooks/claude-code/core/test-budget.js +10 -70
  27. package/framework/hooks/claude-code/core/validate-completion.js +11 -24
  28. package/framework/hooks/claude-code/core/worktree-context.js +5 -22
  29. package/framework/hooks/claude-code/core/worktree-create.js +6 -45
  30. package/framework/hooks/claude-code/core/worktree-env-inject.js +2 -10
  31. package/framework/hooks/shared/e2e-check.js +0 -0
  32. package/framework/hooks/shared/gate-event.js +2 -2
  33. package/framework/hooks/shared/graph-refresh.js +20 -3
  34. package/framework/hooks/shared/heavy-command.js +32 -1
  35. package/framework/hooks/shared/heavy-config.js +1 -1
  36. package/framework/hooks/shared/next-step.js +63 -24
  37. package/framework/hooks/shared/phase-utils.js +6 -9
  38. package/framework/hooks/shared/report-progress.js +86 -0
  39. package/framework/hooks/shared/state-reader.js +3 -2
  40. package/framework/hooks/statusline.js +332 -0
  41. package/framework/phases.json +32 -10
  42. package/framework/rules/ai-agents-standards.md +9 -1
  43. package/framework/rules/csharp-standards.md +4 -0
  44. package/framework/rules/ef-standards.md +20 -0
  45. package/framework/rules/frontend-standards.md +1 -1
  46. package/framework/rules/implement-report.md +25 -0
  47. package/framework/rules/infrastructure-standards.md +2 -0
  48. package/framework/rules/nextjs-standards.md +7 -0
  49. package/framework/rules/plan-grammar.md +53 -0
  50. package/framework/rules/ui-design.md +27 -0
  51. package/framework/schemas/config.schema.json +11 -18
  52. package/framework/schemas/feature.schema.json +7 -1
  53. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  54. package/framework/skills/core/create-umbraco-lp/references/umbraco-lp-patterns.md +1 -1
  55. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +2 -2
  56. package/framework/skills/core/morph-apply/SKILL.md +38 -166
  57. package/framework/skills/core/morph-apply/references/debugging.md +8 -0
  58. package/framework/skills/core/morph-apply/references/hotfix.md +42 -0
  59. package/framework/skills/core/morph-apply/references/machine.md +29 -0
  60. package/framework/skills/core/{morph-media/SKILL.md → morph-apply/references/media.md} +11 -74
  61. package/framework/skills/core/morph-apply/references/review.md +65 -0
  62. package/framework/skills/core/morph-plan/SKILL.md +42 -108
  63. package/framework/skills/core/morph-plan/references/critique.md +40 -0
  64. package/framework/skills/core/morph-plan/references/interview.md +25 -0
  65. package/framework/skills/core/morph-uiux/SKILL.md +47 -64
  66. package/framework/skills/core/morph-uiux/surfaces.json +32 -0
  67. package/framework/standards/STANDARDS.json +32 -5
  68. package/framework/standards/STANDARDS.meta.json +5 -1
  69. package/framework/standards/ai-agents/agent-session.md +2 -3
  70. package/framework/standards/ai-agents/context-providers.md +2 -2
  71. package/framework/standards/ai-agents/durable-workflows-hitl.md +2 -2
  72. package/framework/standards/ai-agents/providers/model-registry.md +2 -2
  73. package/framework/standards/ai-agents/rag-custom-pgvector.md +6 -6
  74. package/framework/standards/ai-agents/setup.md +1 -1
  75. package/framework/standards/ai-agents/tool-adapters.md +2 -2
  76. package/framework/standards/architecture/stack-baseline.md +47 -0
  77. package/framework/standards/backend/integrations/neon-auth/neon-auth.md +2 -4
  78. package/framework/standards/frontend/design-system/ai-image-generation.md +51 -12
  79. package/framework/standards/frontend/design-system/ai-video-generation.md +50 -4
  80. package/framework/standards/frontend/design-system/art-direction.md +2 -2
  81. package/framework/standards/frontend/scroll-driven/frame-scrub.md +12 -2
  82. package/framework/standards/frontend/scroll-driven/scroll-components.md +76 -4
  83. package/framework/templates/REGISTRY.json +1 -2
  84. package/framework/templates/context/ARCHITECTURE.md +1 -1
  85. package/framework/templates/docs/plan.md +12 -12
  86. package/framework/templates/docs/ui-mockups.md +17 -105
  87. package/package.json +1 -1
  88. package/src/commands/e2e/e2e.js +88 -23
  89. package/src/commands/graph/diff.js +74 -0
  90. package/src/commands/graph/status.js +1 -1
  91. package/src/commands/observability/cost.js +2 -4
  92. package/src/commands/observability/telemetry.js +1 -1
  93. package/src/commands/project/doctor.js +23 -39
  94. package/src/commands/project/fleet.js +5 -6
  95. package/src/commands/project/init.js +63 -36
  96. package/src/commands/project/stack-scan.js +3 -42
  97. package/src/commands/project/stack-set.js +26 -0
  98. package/src/commands/project/status.js +12 -13
  99. package/src/commands/project/update.js +39 -3
  100. package/src/commands/project/worktree.js +3 -14
  101. package/src/commands/state/advance.js +17 -17
  102. package/src/commands/state/approve.js +0 -6
  103. package/src/commands/state/archive.js +27 -1
  104. package/src/commands/state/auto-approval.js +1 -1
  105. package/src/commands/state/create.js +0 -5
  106. package/src/commands/state/gate-check.js +37 -30
  107. package/src/commands/state/gate-decision.js +3 -2
  108. package/src/commands/state/next.js +1 -0
  109. package/src/commands/state/recap.js +67 -0
  110. package/src/commands/state/report-check.js +36 -178
  111. package/src/commands/validation/plan-check.js +16 -11
  112. package/src/commands/validation/validate-feature.js +3 -3
  113. package/src/commands/validation/verify.js +3 -6
  114. package/src/core/paths/output-schema.js +3 -3
  115. package/src/core/state/state-manager.js +2 -4
  116. package/src/dashboard/app/components/CommandPalette.jsx +2 -5
  117. package/src/dashboard/app/components/GatePauses.jsx +116 -0
  118. package/src/dashboard/app/components/SessionMap.jsx +77 -0
  119. package/src/dashboard/app/pages/Activity.jsx +4 -0
  120. package/src/dashboard/app/pages/AgentMap.jsx +1 -2
  121. package/src/dashboard/app/pages/FeatureDetail.jsx +129 -186
  122. package/src/dashboard/app/pages/Features.jsx +7 -7
  123. package/src/dashboard/app/pages/PhasePipeline.jsx +1 -1
  124. package/src/dashboard/app/pages/Portfolio.jsx +40 -5
  125. package/src/dashboard/scanner/activity-scanner.js +29 -0
  126. package/src/dashboard/scanner/agents-scanner.js +11 -14
  127. package/src/dashboard/scanner/features-scanner.js +45 -24
  128. package/src/dashboard/scanner/project-api.js +11 -2
  129. package/src/dashboard/scanner/skills-scanner.js +11 -1
  130. package/src/lib/e2e/applicability.js +3 -1
  131. package/src/lib/git/tree-state.js +1 -16
  132. package/src/lib/graph/diff-tree.js +127 -0
  133. package/src/lib/graph/graphify-runtime.js +12 -5
  134. package/src/lib/installers/memory-installer.js +2 -2
  135. package/src/lib/installers/rules-installer.js +57 -0
  136. package/src/lib/installers/stack-assets.js +17 -0
  137. package/src/lib/installers/stack-sync.js +219 -0
  138. package/src/lib/plan/checks.js +55 -110
  139. package/src/lib/plan/critique.js +28 -0
  140. package/src/lib/plan/media.js +42 -0
  141. package/src/lib/plan/plan-html.js +125 -0
  142. package/src/lib/plan/plan-parser.js +34 -45
  143. package/src/lib/recap/piper.js +36 -0
  144. package/src/lib/recap/recap.js +232 -0
  145. package/src/lib/review-reports.js +28 -4
  146. package/src/lib/risk-detector.js +90 -11
  147. package/src/lib/specs/screen-checklist.js +33 -48
  148. package/src/lib/stack/dotnet-build-ignores.js +8 -9
  149. package/src/lib/telemetry/metrics.js +27 -29
  150. package/src/lib/validators/reuse/reuse-validator.js +1 -9
  151. package/src/lib/validators/verify-runner.js +0 -7
  152. package/src/lib/worktree/fleet-scanner.js +7 -20
  153. package/src/lib/worktree/worktree-audit.js +0 -37
  154. package/src/scripts/setup-infra.js +15 -50
  155. package/src/utils/agents-installer.js +27 -201
  156. package/src/utils/hooks-installer.js +11 -95
  157. package/framework/hooks/claude-code/core/warn-bash-source-write.js +0 -23
  158. package/framework/hooks/claude-code/statusline.py +0 -699
  159. package/framework/hooks/claude-code/statusline.sh +0 -11
  160. package/framework/hooks/shared/agent-reap.js +0 -74
  161. package/framework/hooks/shared/slice-progress.js +0 -110
  162. package/framework/skills/core/morph-hotfix/SKILL.md +0 -133
  163. package/framework/skills/core/morph-machine/SKILL.md +0 -46
  164. package/framework/skills/core/morph-review/SKILL.md +0 -129
  165. package/framework/skills/core/morph-standards/SKILL.md +0 -52
  166. package/framework/standards/PROVENANCE.json +0 -119
  167. package/src/commands/project/worktree-reap.js +0 -34
  168. package/src/commands/state/handoff.js +0 -65
  169. package/src/commands/validation/mutate.js +0 -263
  170. package/src/commands/validation/scout-check.js +0 -28
  171. package/src/lib/generators/handoff-generator.js +0 -172
  172. package/src/lib/mutation/adapters.js +0 -79
  173. package/src/lib/mutation/commands.js +0 -61
  174. package/src/lib/mutation/dotnet-introspection.js +0 -281
  175. package/src/lib/mutation/manifest.js +0 -144
  176. package/src/lib/mutation/plan.js +0 -44
  177. package/src/lib/mutation/report.js +0 -68
  178. package/src/lib/mutation/runner.js +0 -301
  179. package/src/lib/mutation/schemata.js +0 -60
  180. package/src/lib/mutation/snapshot.js +0 -65
  181. package/src/lib/mutation/subset.js +0 -109
  182. package/src/lib/mutation/trx.js +0 -50
  183. package/src/lib/scout/scout-check.js +0 -293
  184. package/src/lib/validators/traceability-check.js +0 -103
  185. package/src/lib/worktree/agent-reaper.js +0 -247
@@ -1,65 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
3
- import { join, dirname } from 'node:path';
4
-
5
- export const md5Of = (bytes) => createHash('md5').update(bytes).digest('hex');
6
- export const md5OfFile = (abs) => md5Of(readFileSync(abs));
7
-
8
- /**
9
- * Copies of the original bytes, kept outside the working tree, plus a journal
10
- * that survives the process: a run killed halfway is undone by the next
11
- * `--restore` from the journal alone.
12
- */
13
- export function openSnapshot({ dir, root, files, runId }) {
14
- mkdirSync(dir, { recursive: true });
15
- const entries = files.map((file, index) => {
16
- const abs = join(root, file);
17
- const bytes = readFileSync(abs);
18
- const backup = join(dir, `${index}.bak`);
19
- writeFileSync(backup, bytes);
20
- return { file, md5: md5Of(bytes), backup, created: false };
21
- });
22
- const snapshot = { runId, root, dir, entries, generated: [], state: 'open' };
23
- writeJournal(snapshot);
24
- return snapshot;
25
- }
26
-
27
- export function trackGenerated(snapshot, file) {
28
- if (!snapshot.generated.includes(file)) snapshot.generated.push(file);
29
- writeJournal(snapshot);
30
- }
31
-
32
- export function writeJournal(snapshot) {
33
- writeFileSync(join(snapshot.dir, 'journal.json'), JSON.stringify(snapshot, null, 2));
34
- }
35
-
36
- export function readJournal(dir) {
37
- const path = join(dir, 'journal.json');
38
- return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null;
39
- }
40
-
41
- /**
42
- * Writes the original bytes back — a fresh write, so the file is NEWER than
43
- * any binary built from a mutant. Restoring the old mtime would let the build
44
- * consider the mutant binary up to date and never recompile the source.
45
- */
46
- export function restoreSnapshot(snapshot, { rewrite = true } = {}) {
47
- const report = [];
48
- for (const entry of snapshot.entries) {
49
- const abs = join(snapshot.root, entry.file);
50
- const before = existsSync(abs) ? md5OfFile(abs) : null;
51
- if (rewrite || before !== entry.md5) {
52
- mkdirSync(dirname(abs), { recursive: true });
53
- writeFileSync(abs, readFileSync(entry.backup));
54
- }
55
- const after = md5OfFile(abs);
56
- report.push({ file: entry.file, md5Original: entry.md5, md5Before: before, md5After: after, ok: after === entry.md5 });
57
- }
58
- for (const file of snapshot.generated) {
59
- const abs = join(snapshot.root, file);
60
- if (existsSync(abs)) unlinkSync(abs);
61
- }
62
- snapshot.state = report.every((r) => r.ok) ? 'restored' : 'restore-failed';
63
- writeJournal(snapshot);
64
- return report;
65
- }
@@ -1,109 +0,0 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
- import { dirname, join, relative, resolve } from 'node:path';
3
- import { csharpClasses } from './dotnet-introspection.js';
4
- import { relatedCommand } from './commands.js';
5
-
6
- const SKIP_DIRS = new Set(['bin', 'obj', 'node_modules', '.git', 'worktrees', 'TestResults']);
7
- const SAFE_CLASS = /^[A-Za-z_][A-Za-z0-9_]*$/;
8
- const MIN_TYPE_LENGTH = 4;
9
-
10
- const posix = (p) => p.replace(/\\/g, '/');
11
-
12
- function walkCs(dir, out = []) {
13
- let entries = [];
14
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
15
- for (const e of entries) {
16
- if (e.isDirectory()) {
17
- if (!SKIP_DIRS.has(e.name)) walkCs(join(dir, e.name), out);
18
- } else if (e.name.endsWith('.cs')) {
19
- out.push(join(dir, e.name));
20
- }
21
- }
22
- return out;
23
- }
24
-
25
- const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
26
-
27
- export function mutatedTypesOf(root, files) {
28
- const types = new Set();
29
- for (const file of files) {
30
- for (const t of csharpClasses(resolve(root, file)).types) if (t.length >= MIN_TYPE_LENGTH) types.add(t);
31
- }
32
- return [...types];
33
- }
34
-
35
- const mentionOf = (types) => (types.length > 0 ? new RegExp(`\\b(?:${types.map(escapeRe).join('|')})\\b`) : null);
36
-
37
- /** Test files (a test attribute in the code) under `testProjectDirs` naming one of `types` as a whole word. */
38
- export function testFilesCiting({ root, testProjectDirs, types, exclude = [] }) {
39
- const mention = mentionOf(types);
40
- if (mention === null) return [];
41
- const skip = new Set(exclude.map(posix));
42
- const found = [];
43
- for (const dir of testProjectDirs) {
44
- for (const abs of walkCs(resolve(root, dir))) {
45
- const rel = posix(relative(root, abs));
46
- if (skip.has(rel)) continue;
47
- const info = csharpClasses(abs);
48
- if (info.scannable && info.hasTestAttribute && mention.test(readFileSync(abs, 'utf8'))) found.push(rel);
49
- }
50
- }
51
- return found.sort();
52
- }
53
-
54
- /** Chosen by the tool, never the verifier: test files the feature changed or that name a mutated type. */
55
- export function dotnetSubset({ root, testProjectDirs, mutatedFiles, changed = [] }) {
56
- const changedSet = new Set(changed.map(posix));
57
- const mention = mentionOf(mutatedTypesOf(root, mutatedFiles));
58
-
59
- const byChange = new Set();
60
- const byMention = new Set();
61
- const classes = new Set();
62
- for (const dir of testProjectDirs) {
63
- for (const abs of walkCs(resolve(root, dir))) {
64
- const info = csharpClasses(abs);
65
- if (!info.scannable || !info.hasTestAttribute || info.names.length === 0) continue;
66
- const rel = posix(relative(root, abs));
67
- const viaChange = changedSet.has(rel);
68
- const viaMention = !viaChange && mention !== null && mention.test(readFileSync(abs, 'utf8'));
69
- if (!viaChange && !viaMention) continue;
70
- (viaChange ? byChange : byMention).add(rel);
71
- for (const name of info.names) classes.add(name);
72
- }
73
- }
74
- const unsafe = [...classes].find((c) => !SAFE_CLASS.test(c));
75
- if (unsafe) return { usable: false, reason: `nome de classe "${unsafe}" não é seguro num filtro` };
76
- if (classes.size === 0) return { usable: false, reason: 'nenhum arquivo de teste alterado pela feature nem citando os tipos mutados' };
77
- return {
78
- usable: true,
79
- classes: [...classes].sort(),
80
- filter: [...classes].sort().map((c) => `FullyQualifiedName~${c}`).join('|'),
81
- files: { changed: [...byChange].sort(), mentions: [...byMention].sort() },
82
- };
83
- }
84
-
85
- export function jsSubset({ runner, command, root, mutatedFiles }) {
86
- const files = mutatedFiles.filter((f) => existsSync(resolve(root, f)) && statSync(resolve(root, f)).isFile());
87
- const cmd = relatedCommand(runner, command, files);
88
- if (cmd === null) return { usable: false, reason: `runner "${runner}" sem seleção por arquivo relacionado neste formato de comando` };
89
- return { usable: true, command: cmd, files: { related: files } };
90
- }
91
-
92
- const SLN_PROJECT = /^Project\("[^"]*"\)\s*=\s*"[^"]*",\s*"([^"]+\.csproj)"/gim;
93
- const SLNX_PROJECT = /<Project\b[^>]*\bPath="([^"]+\.csproj)"/gi;
94
-
95
- function solutionProjects(solutionAbs) {
96
- let text;
97
- try { text = readFileSync(solutionAbs, 'utf8'); } catch { return []; }
98
- const pattern = /\.slnx$/i.test(solutionAbs) ? SLNX_PROJECT : SLN_PROJECT;
99
- return [...text.matchAll(pattern)].map((m) => resolve(dirname(solutionAbs), posix(m[1])));
100
- }
101
-
102
- /** A solution stands for every project it lists; one that lists none yields no directory. */
103
- export function testProjectDirsOf(root, targets, cwd = root) {
104
- const projects = targets.flatMap((t) => {
105
- const abs = resolve(cwd, t);
106
- return /\.slnx?$/i.test(t) ? solutionProjects(abs) : [abs];
107
- });
108
- return [...new Set(projects.map((p) => posix(relative(root, dirname(p)))))];
109
- }
@@ -1,50 +0,0 @@
1
- const ENTITIES = { '&lt;': '<', '&gt;': '>', '&amp;': '&', '&quot;': '"', '&apos;': "'" };
2
-
3
- export function decodeXml(text) {
4
- return text
5
- .replace(/&(?:lt|gt|amp|quot|apos);/g, (m) => ENTITIES[m])
6
- .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
7
- .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec)));
8
- }
9
-
10
- const attr = (attrs, name) => new RegExp(`\\b${name}="([^"]*)"`).exec(attrs)?.[1] ?? null;
11
- const count = (attrs, name) => Number(attr(attrs, name) ?? 0);
12
-
13
- /**
14
- * What a VSTest TRX says about a run: the counters, and for every failed test
15
- * its name and the FIRST message line — the literal reason a mutation report
16
- * quotes. Linear over the text: a full suite writes several MB.
17
- */
18
- export function parseTrx(xml) {
19
- const counters = /<Counters\b([^>]*)\/?>/.exec(xml)?.[1] ?? null;
20
- const summary = /<ResultSummary\b([^>]*)>/.exec(xml)?.[1] ?? '';
21
- const failedTests = [];
22
- const tag = /<UnitTestResult\b([^>]*?)(\/?)>/g;
23
- for (let m = tag.exec(xml); m; m = tag.exec(xml)) {
24
- if (attr(m[1], 'outcome') !== 'Failed') continue;
25
- let message = '';
26
- if (m[2] !== '/') {
27
- const close = xml.indexOf('</UnitTestResult>', tag.lastIndex);
28
- const body = xml.slice(tag.lastIndex, close === -1 ? undefined : close);
29
- const raw = /<Message>([\s\S]*?)<\/Message>/.exec(body)?.[1] ?? '';
30
- message = decodeXml(raw.replace(/^<!\[CDATA\[|\]\]>$/g, '')).trim();
31
- }
32
- failedTests.push({ name: decodeXml(attr(m[1], 'testName') ?? '?'), message });
33
- }
34
- return {
35
- parsed: counters !== null,
36
- total: counters ? count(counters, 'total') : 0,
37
- executed: counters ? count(counters, 'executed') : 0,
38
- passed: counters ? count(counters, 'passed') : 0,
39
- failed: counters ? count(counters, 'failed') : failedTests.length,
40
- aborted: counters ? count(counters, 'aborted') + count(counters, 'timeout') : 0,
41
- runOutcome: attr(summary, 'outcome'),
42
- failedTests,
43
- };
44
- }
45
-
46
-
47
- export function firstLines(message, max = 2, width = 240) {
48
- const lines = String(message).split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, max).join(' ⏎ ');
49
- return lines.length > width ? `${lines.slice(0, width)}…` : lines;
50
- }
@@ -1,293 +0,0 @@
1
- import { execFileSync } from 'child_process';
2
- import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
3
- import { join, relative, resolve } from 'path';
4
- import { workTreeTop } from '../../../framework/hooks/shared/git-paths.js';
5
-
6
- export const LINE_TOLERANCE = 2;
7
-
8
- const SPAN = /`([^`\n]+)`/g;
9
- const SPAN_CITATION = /^(.+\.[A-Za-z0-9]+):(\d+)(?:-(\d+))?$/;
10
- const BARE_CITATION = /(?<![\w.:\\/-])(?:[A-Za-z]:)?[\w.\\/-]*\w\.[A-Za-z0-9]+:(\d+)(?:-(\d+))?\b/g;
11
- const GREP_LINE = /^((?:[A-Za-z]:)?[^\s:]+\.[A-Za-z0-9]+)(?::(\d+):|-(\d+)-)(.*)$/;
12
- const FENCE = /^\s*(```|~~~)/;
13
- const HEADING = /^\s{0,3}#{1,6}\s+(.*)$/;
14
- const INFERENCE_HEADING = /inferid|hip[óo]tese|inferred|hypothes/i;
15
- const INFERENCE_MARKER = /\b(INFERIDO|hip[óo]tese)\b/i;
16
- const COMMAND_SPAN = /^(rg|grep|git\s+grep|morph-spec\s+graph|findstr|Select-String|sls)\b/;
17
- const COUNT = /(?:→|->|=>)\s*\d+|\b\d+\s+(?:ocorr[êe]ncias?|arquivos?|linhas?|hits?|resultados?|matches?|files?|lines?)\b/i;
18
- const PATH_SPAN = /^(?:[\w.-]*[\\/])+[\w.-]+$|^[\w.-]+\.(?:cs|csproj|fs|vb|razor|js|mjs|cjs|ts|tsx|jsx|json|md|py|go|java|sql|ya?ml|xml|props|targets|sln)$/i;
19
-
20
- const VERDICTS = [
21
- /\b(?:VIVO|VIVA|MORTO|MORTA|ALIVE|DEAD)\b/,
22
- /\b(?:c[óo]digo morto|dead code|unused)\b/i,
23
- /\bsem (?:nenhum )?(?:consumidor|chamador|leitor|escritor|setter|uso|refer[êe]ncia|teste)s?\b/i,
24
- /(?<!\b(?:execut|rod)\w*\s)\bnenhum[a]? (?:consumidor|chamador|leitor|escritor|setter|uso|refer[êe]ncia|teste|caller|call site)s?\b/i,
25
- /\bningu[ée]m (?:chama|usa|l[êe]|escreve|grava|consome|referencia)\b/i,
26
- /\bn[ãa]o (?:é|est[áa]) (?:usad|chamad|lid|referenciad|escrit)/i,
27
- /\bnunca (?:é )?(?:usad|chamad|lid|escrit)/i,
28
- /\bn[ãa]o h[áa] (?:consumidor|chamador|leitor|escritor|uso|teste|refer[êe]ncia)/i,
29
- /\bno (?:callers?|consumers?|readers?|writers?|tests?|usages?)\b/i,
30
- ];
31
-
32
- const WALK_SKIP = new Set(['node_modules', '.git', 'bin', 'obj', 'worktrees', '.worktrees']);
33
-
34
- function splitLines(text) {
35
- const lines = text.replace(/^/, '').split(/\r?\n/);
36
- if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
37
- return lines;
38
- }
39
-
40
- const squash = (s) => s.replace(/\s+/g, ' ').trim();
41
-
42
- function lineTokens(line) {
43
- const tokens = [];
44
- const masked = line.replace(SPAN, (whole, inner, index) => {
45
- const cite = inner.trim().match(SPAN_CITATION);
46
- if (cite) tokens.push({ index, cite: { raw: inner.trim(), path: cite[1], start: +cite[2], end: +(cite[3] ?? cite[2]) } });
47
- else tokens.push({ index, quote: inner });
48
- return ' '.repeat(whole.length);
49
- });
50
- for (const m of masked.matchAll(BARE_CITATION)) {
51
- const cite = m[0].match(SPAN_CITATION);
52
- if (cite) tokens.push({ index: m.index, cite: { raw: m[0], path: cite[1], start: +cite[2], end: +(cite[3] ?? cite[2]) } });
53
- }
54
- return tokens.sort((a, b) => a.index - b.index);
55
- }
56
-
57
- function citationsOf(line) {
58
- const tokens = lineTokens(line);
59
- const out = [];
60
- tokens.forEach((t, i) => {
61
- if (!t.cite) return;
62
- let quote = null;
63
- for (const next of tokens.slice(i + 1)) {
64
- if (next.cite) break;
65
- const q = next.quote.trim();
66
- if (COMMAND_SPAN.test(q) || PATH_SPAN.test(q)) continue;
67
- quote = next.quote;
68
- break;
69
- }
70
- out.push({ ...t.cite, quote });
71
- });
72
- return out;
73
- }
74
-
75
- function makeFileLister(root) {
76
- let files = null;
77
- return () => {
78
- if (files) return files;
79
- try {
80
- files = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], {
81
- cwd: root, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, maxBuffer: 64 * 1024 * 1024,
82
- }).toString().split('\0').filter(Boolean);
83
- } catch {
84
- files = [];
85
- const walk = (dir) => {
86
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
87
- if (WALK_SKIP.has(entry.name)) continue;
88
- const full = join(dir, entry.name);
89
- if (entry.isDirectory()) walk(full);
90
- else files.push(relative(root, full).replace(/\\/g, '/'));
91
- }
92
- };
93
- walk(root);
94
- }
95
- return files;
96
- };
97
- }
98
-
99
- const isFile = (p) => existsSync(p) && statSync(p).isFile();
100
-
101
- function resolveCitedPath(rawPath, root, listFiles) {
102
- const p = rawPath.replace(/\\/g, '/').replace(/^\.\//, '');
103
- if (/^[A-Za-z]:\//.test(p) || p.startsWith('/')) {
104
- return isFile(p) ? { path: p } : { problem: 'missing-file' };
105
- }
106
- const direct = join(root, p);
107
- if (isFile(direct)) return { path: direct };
108
- const fold = process.platform === 'win32' ? (s) => s.toLowerCase() : (s) => s;
109
- const suffix = fold(`/${p}`);
110
- const candidates = listFiles().filter((f) => fold(`/${f}`).endsWith(suffix));
111
- if (candidates.length === 1) return { path: join(root, candidates[0]) };
112
- if (candidates.length > 1) return { problem: 'ambiguous-path', candidates };
113
- return { problem: 'missing-file' };
114
- }
115
-
116
- function quoteAppears(quote, lines, start, end) {
117
- const from = Math.max(0, start - 1 - LINE_TOLERANCE);
118
- const to = Math.min(lines.length, end + LINE_TOLERANCE);
119
- const window = squash(lines.slice(from, to).join(' '));
120
- let at = 0;
121
- for (const fragment of quote.split(/\.\.\.|…/).map(squash).filter(Boolean)) {
122
- const found = window.indexOf(fragment, at);
123
- if (found === -1) return false;
124
- at = found + fragment.length;
125
- }
126
- return true;
127
- }
128
-
129
- function auditCitation(cite, root, listFiles, readCache) {
130
- const where = resolveCitedPath(cite.path, root, listFiles);
131
- if (where.problem === 'ambiguous-path') {
132
- return { kind: 'ambiguous-path', detail: `${where.candidates.length} arquivos casam: ${where.candidates.slice(0, 5).join(', ')}` };
133
- }
134
- if (where.problem) return { kind: 'missing-file', detail: `${cite.path} não existe sob ${root}` };
135
- if (!readCache.has(where.path)) readCache.set(where.path, splitLines(readFileSync(where.path, 'utf8')));
136
- const lines = readCache.get(where.path);
137
- cite.resolved = where.path;
138
- if (cite.start < 1 || cite.end < cite.start || cite.end > lines.length) {
139
- return { kind: 'line-out-of-range', detail: `o arquivo tem ${lines.length} linhas` };
140
- }
141
- const quotes = cite.quote != null ? [cite.quote] : cite.fence;
142
- if (!quotes) {
143
- return cite.listing ? null : { kind: 'missing-quote', detail: 'a citação não traz o trecho literal em crase depois dela' };
144
- }
145
- const missing = quotes.find((q) => !quoteAppears(q, lines, cite.start, cite.end));
146
- if (missing != null) {
147
- return {
148
- kind: 'quote-mismatch',
149
- detail: `trecho ausente nas linhas ${Math.max(1, cite.start - LINE_TOLERANCE)}-${Math.min(lines.length, cite.end + LINE_TOLERANCE)}; linha ${cite.start}: ${squash(lines[cite.start - 1])}`,
150
- };
151
- }
152
- cite.verified = true;
153
- return null;
154
- }
155
-
156
- const asMention = (line) => line.replace(SPAN, ' ').replace(/"[^"\n]*"|“[^”\n]*”/g, ' ');
157
-
158
- const isVerdict = (line) =>
159
- VERDICTS[0].test(line.replace(SPAN, ' ')) || VERDICTS.slice(1).some((re) => re.test(asMention(line)));
160
-
161
- const hasSearchCommand = (line) => [...line.matchAll(SPAN)].some((m) => COMMAND_SPAN.test(m[1].trim()));
162
-
163
- function underSearchCommand(lines, i) {
164
- for (let j = i - 1; j >= 0; j--) {
165
- if (hasSearchCommand(lines[j])) return true;
166
- if (HEADING.test(lines[j])) return false;
167
- }
168
- return false;
169
- }
170
-
171
- function auditVerdict(line, evidence, inInference) {
172
- if (!inInference && !INFERENCE_MARKER.test(line)) {
173
- return { kind: 'verdict-outside-inference', detail: 'veredito de ausência/vida fora da seção de hipóteses (Inferido)' };
174
- }
175
- const spans = [...evidence.matchAll(SPAN)].map((m) => m[1].trim());
176
- if (!spans.some((s) => COMMAND_SPAN.test(s))) {
177
- return { kind: 'verdict-without-command', detail: 'hipótese de ausência/vida sem o comando de busca usado, em crase' };
178
- }
179
- if (!COUNT.test(evidence)) return { kind: 'verdict-without-count', detail: 'hipótese de ausência/vida sem a contagem bruta do comando' };
180
- return null;
181
- }
182
-
183
- function fenceAfter(lines, i) {
184
- let j = i + 1;
185
- while (j < lines.length && lines[j].trim() === '') j++;
186
- if (j >= lines.length || !FENCE.test(lines[j])) return null;
187
- const body = [];
188
- for (let k = j + 1; k < lines.length && !FENCE.test(lines[k]); k++) {
189
- const text = lines[k].replace(/^\s*:?\d+[:\s]\s*/, '').trim();
190
- if (text) body.push(text);
191
- }
192
- return body.length ? body : null;
193
- }
194
-
195
- const indentOf = (line) => line.match(/^\s*/)[0].length;
196
- const BULLET = /^\s*([-*+]|\d+[.)])\s/;
197
-
198
- function itemStart(lines, i) {
199
- let start = i;
200
- for (let j = i - 1; j >= 0 && lines[j].trim() !== '' && !HEADING.test(lines[j]); j--) {
201
- if (BULLET.test(lines[j]) && indentOf(lines[j]) < indentOf(lines[start])) start = j;
202
- }
203
- return start;
204
- }
205
-
206
- function continuationEnd(lines, i) {
207
- const indent = indentOf(lines[i]);
208
- let j = i + 1;
209
- while (j < lines.length && lines[j].trim() !== '' && !HEADING.test(lines[j]) && !FENCE.test(lines[j])) {
210
- if (indentOf(lines[j]) <= indent && BULLET.test(lines[j])) break;
211
- j++;
212
- }
213
- return j;
214
- }
215
-
216
- export function resolveScoutRoot(root, cwd = process.cwd()) {
217
- return root ? resolve(cwd, root) : (workTreeTop(cwd) ?? cwd);
218
- }
219
-
220
- export function checkScoutMap(text, { root }) {
221
- const lines = splitLines(text);
222
- const listFiles = makeFileLister(root);
223
- const readCache = new Map();
224
- const failures = [];
225
- const citations = [];
226
- let verdicts = 0;
227
- let inFence = false;
228
- let inferenceLevel = null;
229
- let subsectionStart = null;
230
-
231
- const audit = (cite, lineNo) => {
232
- citations.push(cite);
233
- const problem = auditCitation(cite, root, listFiles, readCache);
234
- if (problem) failures.push({ line: lineNo, citation: cite.raw, cite, ...problem });
235
- };
236
- const subsectionText = (from) => {
237
- let to = from + 1;
238
- while (to < lines.length && !HEADING.test(lines[to])) to++;
239
- return lines.slice(from, to).join('\n');
240
- };
241
-
242
- lines.forEach((line, i) => {
243
- const lineNo = i + 1;
244
- if (FENCE.test(line)) { inFence = !inFence; return; }
245
- if (inFence) {
246
- const grep = line.trim().match(GREP_LINE);
247
- if (grep) {
248
- const n = +(grep[2] ?? grep[3]);
249
- audit({ raw: `${grep[1]}:${n}`, path: grep[1], start: n, end: n, quote: grep[4].trim() || null }, lineNo);
250
- }
251
- return;
252
- }
253
- const heading = line.match(HEADING);
254
- if (heading) {
255
- const level = line.trim().match(/^#+/)[0].length;
256
- if (INFERENCE_HEADING.test(heading[1])) { inferenceLevel = level; subsectionStart = null; }
257
- else if (inferenceLevel != null && level > inferenceLevel) subsectionStart = i;
258
- else { inferenceLevel = null; subsectionStart = null; }
259
- }
260
-
261
- const cites = citationsOf(line);
262
- const residue = cites.reduce((rest, c) => rest.split(c.raw).join(' '), line)
263
- .replace(/[`*_|>()[\]\-+:;,.—–\s]|\d+[.)]/g, '');
264
- for (const cite of cites) {
265
- if (cite.quote == null) {
266
- cite.fence = fenceAfter(lines, i);
267
- cite.listing = residue === '' && underSearchCommand(lines, i);
268
- }
269
- audit(cite, lineNo);
270
- }
271
-
272
- if (!isVerdict(line)) return;
273
- verdicts++;
274
- const evidence = subsectionStart != null
275
- ? subsectionText(subsectionStart)
276
- : lines.slice(itemStart(lines, i), continuationEnd(lines, itemStart(lines, i))).join('\n');
277
- const verdict = auditVerdict(line, evidence, inferenceLevel != null);
278
- if (verdict) failures.push({ line: lineNo, text: squash(line), ...verdict });
279
- });
280
-
281
- const covered = (c) => citations.some((v) => v.verified && v.resolved === c.resolved && v.start <= c.start && c.end <= v.end);
282
- const kept = failures.filter((f) => !(f.kind === 'missing-quote' && covered(f.cite)));
283
- if (citations.length === 0) {
284
- kept.push({ line: null, kind: 'no-citations', detail: 'o mapa não cita nenhum arquivo:linha — nada nele é verificável' });
285
- }
286
- return {
287
- ok: kept.length === 0,
288
- root,
289
- citations: citations.map(({ raw, resolved, quote }) => ({ raw, resolved: resolved ?? null, quote })),
290
- verdicts,
291
- failures: kept.map(({ cite, ...f }) => f),
292
- };
293
- }
@@ -1,103 +0,0 @@
1
- // Advisory: status is only 'pass' | 'warn' | 'skip', and runVerify() keeps it outside
2
- // `nodes` so it never flips the verify rollup.
3
-
4
- import { existsSync, readFileSync } from 'fs';
5
- import { join } from 'path';
6
- import { readPlanChecks } from '../plan/checks.js';
7
- import { sliceProgress, sliceReportPath } from '../../../framework/hooks/shared/slice-progress.js';
8
-
9
- const FILES_BLOCK_RE = /\*\*Arquivos:\*\*([^\r\n]*)\r?\n?((?:^[ \t]*-[ \t].*\r?\n?)*)/gm;
10
-
11
- const BACKTICK_RE = /`([^`]+)`/g;
12
-
13
- // A later span without `/` is ambiguous shorthand and is skipped, not guessed: false
14
- // negatives are acceptable for advisory tooling, false positives are not.
15
- export function extractCitedFiles(reportText) {
16
- const cited = new Set();
17
- if (!reportText) return [];
18
-
19
- FILES_BLOCK_RE.lastIndex = 0;
20
- let blockMatch;
21
- while ((blockMatch = FILES_BLOCK_RE.exec(reportText)) !== null) {
22
- const bullets = blockMatch[2].split(/\r?\n/).filter((l) => /^[ \t]*-[ \t]/.test(l));
23
- for (const line of [blockMatch[1], ...bullets]) {
24
- const spans = [...line.matchAll(BACKTICK_RE)].map((m) => m[1].trim()).filter(Boolean);
25
- spans.forEach((span, idx) => {
26
- if (idx > 0 && !span.includes('/')) return;
27
- if (span.includes('{') || span.includes('}')) return;
28
- if (span.endsWith('/')) return;
29
- cited.add(normalizePath(span));
30
- });
31
- }
32
- }
33
-
34
- return [...cited];
35
- }
36
-
37
- export function normalizePath(p) {
38
- return String(p).replace(/\\/g, '/').replace(/^\.\//, '');
39
- }
40
-
41
- /** null when the plan has no readable extraction. */
42
- export function collectSliceFiles(projectPath, featureName) {
43
- const checks = readPlanChecks(projectPath, featureName).checks;
44
- if (!checks) return null;
45
- return checks.slices.flatMap((slice) => (slice.files ?? [])
46
- .filter((f) => typeof f === 'string' && f.trim())
47
- .map(normalizePath));
48
- }
49
-
50
- function isCovered(file, declared) {
51
- for (const entry of declared) {
52
- if (entry === file) return true;
53
- if (entry.endsWith('/') && file.startsWith(entry)) return true;
54
- }
55
- return false;
56
- }
57
-
58
- function readOrNull(path) {
59
- try {
60
- return existsSync(path) ? readFileSync(path, 'utf8') : null;
61
- } catch {
62
- return null;
63
- }
64
- }
65
-
66
- export function checkOutputsTraceability(projectPath, featureName) {
67
- const featureDir = join(projectPath, '.morph', 'features', featureName);
68
- const progress = sliceProgress(featureDir);
69
- const reports = (progress?.slices ?? [])
70
- .map((slice) => ({ id: slice.id, text: readOrNull(sliceReportPath(featureDir, slice.id)) }))
71
- .filter((r) => r.text !== null);
72
- if (reports.length === 0) {
73
- return { status: 'skip', warnings: [], reason: 'no slice reports', citedCount: 0 };
74
- }
75
-
76
- const citedBy = new Map();
77
- for (const { id, text } of reports) {
78
- for (const file of extractCitedFiles(text)) {
79
- if (!citedBy.has(file)) citedBy.set(file, []);
80
- citedBy.get(file).push(id);
81
- }
82
- }
83
- if (citedBy.size === 0) {
84
- return { status: 'skip', warnings: [], reason: 'no Arquivos: entries found in the slice reports', citedCount: 0 };
85
- }
86
-
87
- const declared = collectSliceFiles(projectPath, featureName);
88
- if (declared === null) {
89
- return { status: 'skip', warnings: [], reason: 'no checks.json', citedCount: citedBy.size };
90
- }
91
-
92
- const warnings = [...citedBy.keys()]
93
- .filter((file) => !isCovered(file, declared))
94
- .sort()
95
- .map((file) => `${file} — cited in reports/${citedBy.get(file).join(', ')} but declared in no slice's arquivos:`);
96
-
97
- return {
98
- status: warnings.length > 0 ? 'warn' : 'pass',
99
- warnings,
100
- reason: null,
101
- citedCount: citedBy.size,
102
- };
103
- }