@polymorphism-tech/morph-spec 8.37.1 → 8.38.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 (128) hide show
  1. package/CLAUDE.md +19 -6
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +16 -7
  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 +3 -3
  8. package/framework/MORPH.md +25 -22
  9. package/framework/agents.json +76 -27
  10. package/framework/docs/CLI.md +54 -17
  11. package/framework/evals/rubrics/code-quality.md +5 -0
  12. package/framework/evals/rubrics/condensed.md +6 -2
  13. package/framework/evals/rubrics/contracts.md +20 -0
  14. package/framework/evals/rubrics/test-coverage.md +17 -6
  15. package/framework/hooks/README.md +9 -3
  16. package/framework/hooks/claude-code/core/graph-refresh-kick.js +52 -0
  17. package/framework/hooks/claude-code/core/telemetry-log.js +172 -87
  18. package/framework/hooks/claude-code/core/validate-completion.js +16 -90
  19. package/framework/hooks/claude-code/core/worktree-context.js +10 -0
  20. package/framework/hooks/shared/command-tokens.js +96 -0
  21. package/framework/hooks/shared/git-exclude.js +44 -0
  22. package/framework/hooks/shared/git-paths.js +52 -0
  23. package/framework/hooks/shared/graph-freshness.js +380 -0
  24. package/framework/hooks/shared/graph-refresh.js +416 -0
  25. package/framework/hooks/shared/morph-cli.js +17 -5
  26. package/framework/hooks/shared/redact-secrets.js +55 -0
  27. package/framework/hooks/shared/telemetry-logger.js +25 -2
  28. package/framework/hooks/shared/transcript-usage.js +80 -0
  29. package/framework/rubrics/backend.json +2 -2
  30. package/framework/rubrics/default.json +2 -2
  31. package/framework/rubrics/frontend.json +2 -2
  32. package/framework/rules/csharp-standards.md +1 -0
  33. package/framework/rules/morph-workflow.md +1 -0
  34. package/framework/rules/nextjs-standards.md +1 -0
  35. package/framework/schemas/config.schema.json +41 -1
  36. package/framework/schemas/feature.schema.json +5 -1
  37. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  38. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  39. package/framework/skills/core/morph-apply/SKILL.md +36 -11
  40. package/framework/skills/core/morph-brainstorming/SKILL.md +16 -7
  41. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  42. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  43. package/framework/skills/core/morph-implement/SKILL.md +39 -10
  44. package/framework/skills/core/morph-media/SKILL.md +1 -1
  45. package/framework/skills/core/morph-plan/SKILL.md +72 -15
  46. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  47. package/framework/skills/core/morph-review/SKILL.md +51 -13
  48. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  49. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  50. package/framework/skills/core/morph-uiux/SKILL.md +34 -9
  51. package/framework/standards/STANDARDS.json +187 -12
  52. package/framework/standards/ai-agents/testing-ai.md +2 -1
  53. package/framework/standards/architecture/code-comments.md +64 -0
  54. package/framework/standards/backend/dotnet/testing.md +22 -0
  55. package/framework/standards/frontend/admin-app/data-table.md +562 -0
  56. package/framework/standards/frontend/admin-app/feedback.md +236 -0
  57. package/framework/standards/frontend/admin-app/forms.md +232 -0
  58. package/framework/standards/frontend/admin-app/page-structure.md +203 -0
  59. package/framework/standards/frontend/nextjs/components.md +37 -58
  60. package/framework/standards/frontend/nextjs/data-fetching.md +1 -0
  61. package/framework/standards/frontend/nextjs/forms.md +31 -30
  62. package/framework/standards/frontend/nextjs/state-management.md +2 -1
  63. package/framework/standards/frontend/nextjs/testing.md +18 -0
  64. package/framework/templates/context/design-system.md +3 -3
  65. package/framework/templates/docs/proposal.md +11 -0
  66. package/framework/templates/docs/spec.md +15 -0
  67. package/framework/templates/docs/ui-mockups.md +115 -98
  68. package/framework/templates/feature/decisions.md +124 -122
  69. package/package.json +1 -1
  70. package/src/commands/graph/affected.js +4 -3
  71. package/src/commands/graph/build.js +109 -18
  72. package/src/commands/graph/explain.js +8 -5
  73. package/src/commands/graph/status.js +14 -8
  74. package/src/commands/observability/cost.js +148 -18
  75. package/src/commands/project/doctor.js +82 -10
  76. package/src/commands/project/update.js +25 -0
  77. package/src/commands/project/worktree.js +73 -90
  78. package/src/commands/state/advance.js +18 -0
  79. package/src/commands/state/approve.js +15 -0
  80. package/src/commands/state/archive.js +62 -9
  81. package/src/commands/state/finish.js +62 -14
  82. package/src/commands/state/gate-check.js +8 -1
  83. package/src/commands/state/gate-decision.js +8 -4
  84. package/src/commands/state/report-check.js +90 -9
  85. package/src/commands/state/usecase.js +66 -0
  86. package/src/commands/tasks/dag.js +17 -2
  87. package/src/commands/validation/validate-feature.js +6 -1
  88. package/src/commands/validation/verify.js +20 -1
  89. package/src/dashboard/scanner/agents-scanner.js +5 -1
  90. package/src/dashboard/scanner/hooks-scanner.js +8 -1
  91. package/src/dashboard/scanner/standards-scanner.js +2 -1
  92. package/src/lib/ai/comment-stripper.js +101 -4
  93. package/src/lib/evaluator-report.js +25 -0
  94. package/src/lib/generators/handoff-generator.js +4 -18
  95. package/src/lib/git/default-branch.js +52 -0
  96. package/src/lib/git/tree-fingerprint.js +58 -0
  97. package/src/lib/git/tree-state.js +167 -0
  98. package/src/lib/graph/god-nodes.js +12 -3
  99. package/src/lib/graph/graph-health.js +48 -2
  100. package/src/lib/graph/graph-loader.js +99 -25
  101. package/src/lib/graph/graphifyignore.js +145 -28
  102. package/src/lib/graph/sanitize.js +117 -11
  103. package/src/lib/risk-detector.js +90 -0
  104. package/src/lib/specs/acervo-format.js +70 -0
  105. package/src/lib/specs/acervo-lifecycle.js +264 -0
  106. package/src/lib/specs/adr-index.js +169 -0
  107. package/src/lib/specs/revocations.js +156 -0
  108. package/src/lib/specs/screen-checklist.js +357 -0
  109. package/src/lib/specs/usecase-check.js +20 -16
  110. package/src/lib/specs/usecase-promoter.js +103 -38
  111. package/src/lib/specs/usecase-registry.js +7 -7
  112. package/src/lib/tasks/tasks-json-parser.js +2 -3
  113. package/src/lib/telemetry/metrics.js +386 -0
  114. package/src/lib/telemetry/telemetry-reader.js +161 -51
  115. package/src/lib/telemetry/verify-event.js +79 -0
  116. package/src/lib/validators/code-budget/code-budget-validator.js +172 -0
  117. package/src/lib/validators/code-budget/comment-scanner.js +75 -0
  118. package/src/lib/validators/code-budget/index.js +9 -0
  119. package/src/lib/validators/reuse/reuse-validator.js +4 -2
  120. package/src/lib/validators/reuse/symbol-scanner.js +27 -22
  121. package/src/lib/validators/validation-runner.js +21 -5
  122. package/src/lib/validators/verify-runner.js +11 -0
  123. package/src/lib/worktree/finish-backup.js +739 -0
  124. package/src/lib/worktree/ignored-triage.js +15 -8
  125. package/src/lib/worktree/worktree-audit.js +5 -0
  126. package/src/scripts/setup-infra.js +29 -18
  127. package/src/utils/agents-installer.js +281 -12
  128. package/src/utils/hooks-installer.js +75 -15
@@ -38,6 +38,8 @@ const EVENT_KEYS = {
38
38
  UserPromptSubmit: 'user-prompt',
39
39
  PreToolUse: 'pre-tool-use',
40
40
  PostToolUse: 'post-tool-use',
41
+ PostToolUseFailure: 'post-tool-use',
42
+ PostToolBatch: 'post-tool-use',
41
43
  Stop: 'stop',
42
44
  SubagentStop: 'stop',
43
45
  PreCompact: 'pre-compact',
@@ -76,7 +78,12 @@ export function hookRegistrations(definitions = getHookDefinitions()) {
76
78
  : [file];
77
79
  for (const f of files) {
78
80
  if (!byFile.has(f)) byFile.set(f, []);
79
- byFile.get(f).push({ event: key, rawEvent: def.event, matcher: def.matcher || null });
81
+ const entries = byFile.get(f);
82
+ const matcher = def.matcher || null;
83
+ // Several raw events share one Inspector column (PostToolUse and its
84
+ // Failure/Batch siblings): one node per column, not one per event.
85
+ if (entries.some((e) => e.event === key && e.matcher === matcher)) continue;
86
+ entries.push({ event: key, rawEvent: def.event, matcher });
80
87
  }
81
88
  }
82
89
  }
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, existsSync } from 'fs';
2
2
  import { join } from 'path';
3
+ import { personaStandardIds } from '../../utils/agents-installer.js';
3
4
 
4
5
  export function scanStandards(frameworkDir, agents) {
5
6
  const standardsJsonPath = join(frameworkDir, 'standards', 'STANDARDS.json');
@@ -7,7 +8,7 @@ export function scanStandards(frameworkDir, agents) {
7
8
  const sJson = JSON.parse(readFileSync(standardsJsonPath, 'utf8'));
8
9
  const refMap = {};
9
10
  for (const agent of agents) {
10
- for (const std of agent.standards || []) {
11
+ for (const std of personaStandardIds(agent)) {
11
12
  // agents.json stores standards as plain id STRINGS. Reading `std.id` off
12
13
  // a string yields undefined, so every reference piled into refMap
13
14
  // [undefined] and the lookup below missed all 60 — the Standards graph
@@ -2,14 +2,17 @@
2
2
  * comment-stripper.js — apagar comentários SEM apagar o que está dentro de uma
3
3
  * string, preservando comprimento e quebras de linha.
4
4
  *
5
- * Existe uma vez só, e serve a dois consumidores que fariam a MESMA coisa de
5
+ * Existe uma vez só, e serve a consumidores que fariam a MESMA coisa de
6
6
  * jeitos ligeiramente diferentes se cada um escrevesse o seu:
7
7
  *
8
8
  * - `code-audit.js` — não pode acusar `AgentThread` que só aparece num
9
9
  * comentário ou numa mensagem de log;
10
10
  * - `registry-audit.js` — precisa ler **JSONC**, a forma que o exemplar
11
11
  * canônico `model-registry.v2.json` usa e que o leitor C# do kit aceita de
12
- * propósito (`JsonCommentHandling.Skip`, `AllowTrailingCommas`).
12
+ * propósito (`JsonCommentHandling.Skip`, `AllowTrailingCommas`);
13
+ * - `dotnet-test-introspection.js` — lê o `global.json` por `jsoncToJson`;
14
+ * - `validators/code-budget/comment-scanner.js` — acha as linhas de
15
+ * comentário de `.cs`/`.ts`/`.js`, e é o único que liga a opção `js`.
13
16
  *
14
17
  * **Por que não um regex.** O exemplar tem `"http://localhost:11434/v1"` e uma
15
18
  * `provenance` com URL. Um `replace(/\/\/.*$/gm, '')` decapitaria as duas —
@@ -36,13 +39,18 @@
36
39
  * `○ não verificado`. O B1 reintroduzido pela correção do B1.
37
40
  * @param {{ unterminated?: string|null }} [options.report]
38
41
  * Se um objeto for passado, recebe `unterminated` = `'block-comment'`,
39
- * `'string'`, `'verbatim-string'`, `'raw-string'` ou `null`. É o canal de
42
+ * `'string'`, `'verbatim-string'`, `'raw-string'`, `'template'` ou `null`. É o canal de
40
43
  * diagnóstico que `jsoncToJson` usa para ficar tão estrito quanto o leitor
41
44
  * de produção, sem que este lexer precise lançar (ele também varre `.cs`,
42
45
  * onde um comentário aberto é problema do arquivo, não da varredura).
46
+ * @param {boolean} [options.js=false]
47
+ * Lê também template literal e regex literal de TS/JS. Opt-in porque só
48
+ * quem varre `.ts`/`.js` precisa: sem template, `https://${h}` vira
49
+ * comentário; sem regex, uma crase dentro de `/[`']/` abre um template que
50
+ * engole o resto do arquivo.
43
51
  * @returns {string} texto do MESMO comprimento
44
52
  */
45
- export function stripComments(text, { blankStrings = false, report = null } = {}) {
53
+ export function stripComments(text, { blankStrings = false, report = null, js = false } = {}) {
46
54
  const out = text.split('');
47
55
  const blank = (from, to) => {
48
56
  for (let k = from; k < to && k < out.length; k++) {
@@ -73,6 +81,19 @@ export function stripComments(text, { blankStrings = false, report = null } = {}
73
81
  continue;
74
82
  }
75
83
 
84
+ if (js && c === '`') {
85
+ const end = templateEnd(text, i);
86
+ if (end === -1) flag('template');
87
+ const stop = end === -1 ? text.length : end;
88
+ if (blankStrings) blank(i + 1, end === -1 ? stop : stop - 1);
89
+ i = stop;
90
+ continue;
91
+ }
92
+ if (js && c === '/' && next !== '>' && regexMayStart(out, i)) {
93
+ const end = regexEnd(text, i);
94
+ if (end !== -1) { i = end; continue; }
95
+ }
96
+
76
97
  // Raw string literal do C#: """ ... """ — delimitadores de 3 aspas.
77
98
  if (c === '"' && next === '"' && text[i + 2] === '"') {
78
99
  const end = text.indexOf('"""', i + 3);
@@ -137,6 +158,82 @@ export function stripComments(text, { blankStrings = false, report = null } = {}
137
158
  return out.join('');
138
159
  }
139
160
 
161
+ /** Índice logo após a crase que fecha o template aberto em `start`, ou -1. */
162
+ function templateEnd(text, start) {
163
+ let j = start + 1;
164
+ while (j < text.length) {
165
+ if (text[j] === '\\') { j += 2; continue; }
166
+ if (text[j] === '`') return j + 1;
167
+ if (text[j] === '$' && text[j + 1] === '{') { j = holeEnd(text, j + 2); continue; }
168
+ j += 1;
169
+ }
170
+ return -1;
171
+ }
172
+
173
+ const REGEX_AFTER_WORD = new Set([
174
+ 'return', 'typeof', 'case', 'do', 'else', 'in', 'of', 'new', 'delete', 'void',
175
+ 'throw', 'instanceof', 'yield', 'await',
176
+ ]);
177
+
178
+ /**
179
+ * Uma `/` abre regex, e não divisão, quando o que vem antes não é um valor: o
180
+ * mesmo critério de contexto dos tokenizers de JS. `out` já tem os comentários
181
+ * apagados, então o caractere anterior é código. Sem `<`: em TSX, `</b>` é tag.
182
+ */
183
+ function regexMayStart(out, i) {
184
+ let k = i - 1;
185
+ while (k >= 0 && /\s/.test(out[k])) k--;
186
+ if (k < 0) return true;
187
+ if ('(,=:[!&|?{};+-*%>~^'.includes(out[k])) return true;
188
+ if (!/[A-Za-z_$]/.test(out[k])) return false;
189
+ let w = k;
190
+ while (w > 0 && /[\w$]/.test(out[w - 1])) w--;
191
+ return REGEX_AFTER_WORD.has(out.slice(w, k + 1).join(''));
192
+ }
193
+
194
+ /** Índice logo após a `/` (e as flags) que fecha a regex aberta em `start`, ou -1 se a linha acaba antes. */
195
+ function regexEnd(text, start) {
196
+ let inClass = false;
197
+ for (let j = start + 1; j < text.length; j++) {
198
+ const c = text[j];
199
+ if (c === '\n') return -1;
200
+ if (c === '\\') { j++; continue; }
201
+ if (c === '[') inClass = true;
202
+ else if (c === ']') inClass = false;
203
+ else if (c === '/' && !inClass) {
204
+ let k = j + 1;
205
+ while (k < text.length && /[a-z]/i.test(text[k])) k++;
206
+ return k;
207
+ }
208
+ }
209
+ return -1;
210
+ }
211
+
212
+ /** Índice logo após o `}` que fecha um `${`: o código do buraco aninha chaves, strings e templates. */
213
+ function holeEnd(text, start) {
214
+ let depth = 1;
215
+ let j = start;
216
+ while (j < text.length) {
217
+ const c = text[j];
218
+ if (c === '`') {
219
+ const end = templateEnd(text, j);
220
+ if (end === -1) return text.length;
221
+ j = end;
222
+ continue;
223
+ }
224
+ if (c === '"' || c === "'") {
225
+ j += 1;
226
+ while (j < text.length && text[j] !== c && text[j] !== '\n') j += text[j] === '\\' ? 2 : 1;
227
+ j += 1;
228
+ continue;
229
+ }
230
+ if (c === '{') depth += 1;
231
+ else if (c === '}' && --depth === 0) return j + 1;
232
+ j += 1;
233
+ }
234
+ return text.length;
235
+ }
236
+
140
237
  /**
141
238
  * Converte JSONC em JSON: apaga comentários e vírgulas sobrando antes de `}`/`]`.
142
239
  *
@@ -93,6 +93,12 @@ const MUTATION_SECTION_RE = /^##\s+Muta[çc][õo]es\s*$/m;
93
93
  */
94
94
  const MEDIA_SECTION_RE = /^##\s+M[íi]dia\s+gerada\s*$/m;
95
95
 
96
+ /**
97
+ * A seção em que o avaliador confere o checklist de tela do mockup contra a
98
+ * tela entregue (`morph-review` §4). Mesma disciplina: exige `##`.
99
+ */
100
+ const SCREEN_CHECKLIST_SECTION_RE = /^##\s+Checklist\s+de\s+tela\s*$/gim;
101
+
96
102
  /**
97
103
  * As chaves reconhecidas de `**Proof-Scores:**`, uma por rubrica de prova.
98
104
  * Chave fora desta lista é IGNORADA (não vira score, não vira erro): o relatório
@@ -287,6 +293,25 @@ export function evaluatorProofState(cwd, featureName) {
287
293
  };
288
294
  }
289
295
 
296
+ /**
297
+ * A seção `## Checklist de tela` do relatório, com o corpo da ÚLTIMA ocorrência
298
+ * — append-only outra vez: numa reavaliação a conferência nova vai para o fim.
299
+ * O corpo vai cru para `checklistCoverage` (`src/lib/specs/screen-checklist.js`),
300
+ * dono único do parser de linha do checklist.
301
+ *
302
+ * @returns {{ section: boolean, body: string }} arquivo ausente ou sem a seção:
303
+ * `{ section: false, body: '' }`. Nunca lança.
304
+ */
305
+ export function evaluatorScreenChecklist(cwd, featureName) {
306
+ const text = readReport(cwd, featureName);
307
+ const matches = text === null ? [] : [...text.matchAll(SCREEN_CHECKLIST_SECTION_RE)];
308
+ if (matches.length === 0) return { section: false, body: '' };
309
+ const last = matches[matches.length - 1];
310
+ const rest = text.slice(last.index + last[0].length);
311
+ const next = rest.search(/^#{1,2}\s/m);
312
+ return { section: true, body: next === -1 ? rest : rest.slice(0, next) };
313
+ }
314
+
290
315
  /**
291
316
  * O menor Proof-Score das provas de EVIDÊNCIA — o que impõe o teto em
292
317
  * `test-coverage`.
@@ -16,9 +16,9 @@
16
16
  * degrades that section, never the document.
17
17
  */
18
18
 
19
- import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'fs';
20
- import { join, resolve } from 'path';
21
- import { execSync } from 'child_process';
19
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
20
+ import { join } from 'path';
21
+ import { ensureCommonExclude } from '../../../framework/hooks/shared/git-exclude.js';
22
22
  import { derivePhaseForFeature, getPendingGates } from '../../../framework/hooks/shared/state-reader.js';
23
23
 
24
24
  const PHASE_DIRS = {
@@ -236,21 +236,7 @@ const HANDOFF_EXCLUDE_PATTERN = '/.morph/**/handoff.md';
236
236
  * untracked is noise, never data loss.
237
237
  */
238
238
  function ensureHandoffExcluded(cwd) {
239
- try {
240
- const common = execSync('git rev-parse --git-common-dir', { cwd, stdio: 'pipe' }).toString().trim();
241
- if (!common) return;
242
- const infoDir = join(resolve(cwd, common), 'info');
243
- const excludePath = join(infoDir, 'exclude');
244
- const current = existsSync(excludePath) ? readFileSync(excludePath, 'utf8') : '';
245
- const present = new Set(current.split(/\r?\n/).map((l) => l.trim()));
246
- if (present.has(HANDOFF_EXCLUDE_PATTERN)) return;
247
- mkdirSync(infoDir, { recursive: true });
248
- appendFileSync(excludePath, [
249
- current && !current.endsWith('\n') ? '\n' : '',
250
- present.has(HANDOFF_EXCLUDE_MARKER) ? '' : `${HANDOFF_EXCLUDE_MARKER}\n`,
251
- `${HANDOFF_EXCLUDE_PATTERN}\n`,
252
- ].join(''));
253
- } catch { /* fail-open */ }
239
+ ensureCommonExclude(cwd, [HANDOFF_EXCLUDE_PATTERN], HANDOFF_EXCLUDE_MARKER);
254
240
  }
255
241
 
256
242
  /**
@@ -260,3 +260,55 @@ export function changedPathsAgainst(baseRef, cwd = process.cwd()) {
260
260
 
261
261
  return [...new Set([...tracked, ...listUntrackedFiles(cwd)])].sort();
262
262
  }
263
+
264
+ /**
265
+ * The lines this working tree ADDED against `baseRef`, per file: the new-side
266
+ * line numbers of `git diff -U0`, plus every brand-new untracked file whole
267
+ * (the same probe as {@link changedPathsAgainst}).
268
+ *
269
+ * Measured from the merge-base, not the tip: a default branch that moved on
270
+ * after the feature branched must not lend its edits to the feature. `-M`
271
+ * keeps a moved file from reading as added. `--relative` gives paths relative
272
+ * to `cwd` and drops what lies outside it, the same base as the untracked
273
+ * listing: a project in a subfolder of the repo reads its own files.
274
+ *
275
+ * @param {string|null} baseRef - typically `detectDefaultBranch(cwd)`
276
+ * @param {string} [cwd]
277
+ * @returns {Map<string, number[]|null>|null} path → added line numbers, or
278
+ * `null` for a whole untracked file. `null` (not an empty map) when it cannot
279
+ * tell: falsy or unresolvable ref, not a repo, diff failed.
280
+ */
281
+ export function addedLinesAgainst(baseRef, cwd = process.cwd()) {
282
+ if (!baseRef) return null;
283
+ const git = (args) => execFileSync('git', args, { cwd, stdio: 'pipe', maxBuffer: 256 * 1024 * 1024 }).toString();
284
+
285
+ let diff;
286
+ try {
287
+ let base = baseRef;
288
+ try { base = git(['merge-base', 'HEAD', baseRef]).trim() || baseRef; } catch { /* diff against the ref itself */ }
289
+ diff = git(['-c', 'core.quotepath=off', 'diff', '-U0', '--no-color', '--no-ext-diff', '-M', '--relative',
290
+ '--src-prefix=a/', '--dst-prefix=b/', base]);
291
+ } catch {
292
+ return null;
293
+ }
294
+
295
+ const added = new Map();
296
+ let current = null;
297
+ for (const line of diff.split('\n')) {
298
+ if (line.startsWith('+++ ')) {
299
+ const target = line.slice(4).trim().replace(/^"|"$/g, '');
300
+ current = target.startsWith('b/') ? target.slice(2) : null;
301
+ continue;
302
+ }
303
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
304
+ if (!hunk || !current) continue;
305
+ const start = Number(hunk[1]);
306
+ const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
307
+ if (count === 0) continue;
308
+ const lines = added.get(current) ?? [];
309
+ for (let n = start; n < start + count; n++) lines.push(n);
310
+ added.set(current, lines);
311
+ }
312
+ for (const path of listUntrackedFiles(cwd)) added.set(path, null);
313
+ return added;
314
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * A hash of what a verify run reads: the commit, the uncommitted changes and
3
+ * the untracked files, plus the project's `config.json` (it names the test
4
+ * commands). Everything else under `.morph/` stays out — verify itself writes
5
+ * feature.json and the logs on every run, and counting that would make two
6
+ * runs over the same code look different.
7
+ */
8
+
9
+ import { execFileSync } from 'child_process';
10
+ import { createHash } from 'crypto';
11
+ import { existsSync, readFileSync, statSync } from 'fs';
12
+ import { join } from 'path';
13
+
14
+ /** Pathspec for "the project minus its `.morph/` bookkeeping". */
15
+ export const OUTSIDE_MORPH = ['--', '.', ':(exclude).morph'];
16
+ const MAX_OUTPUT = 64 * 1024 * 1024;
17
+
18
+ /** git output as a Buffer; stderr dropped, throws on a non-zero exit. */
19
+ export function git(projectPath, args, input) {
20
+ return execFileSync('git', args, {
21
+ cwd: projectPath, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'ignore'], maxBuffer: MAX_OUTPUT, input,
22
+ });
23
+ }
24
+
25
+ /**
26
+ * @param {string} projectPath
27
+ * @returns {string|null} sha256 hex, or null outside a git repo / on any git error
28
+ */
29
+ export function treeFingerprint(projectPath) {
30
+ try {
31
+ const hash = createHash('sha256');
32
+ let head = null;
33
+ try { head = git(projectPath, ['rev-parse', 'HEAD']).toString().trim(); } catch { /* no commit yet */ }
34
+ hash.update(`head\0${head ?? 'no-commit'}\0`);
35
+ // Before the first commit there is no HEAD to diff against: every file is
36
+ // untracked, and the listing below covers them.
37
+ if (head) hash.update(git(projectPath, ['diff', 'HEAD', '--binary', ...OUTSIDE_MORPH]));
38
+ hash.update('\0untracked\0');
39
+ const untracked = git(projectPath, ['ls-files', '--others', '--exclude-standard', '-z', ...OUTSIDE_MORPH])
40
+ .toString().split('\0').filter(Boolean).sort();
41
+ for (const rel of untracked) {
42
+ // Size and mtime stand in for content: hashing every untracked file of
43
+ // a large tree would cost more than the run the warning tries to save.
44
+ let stamp = 'gone';
45
+ try {
46
+ const st = statSync(join(projectPath, rel));
47
+ stamp = `${st.size}\0${st.mtimeMs}`;
48
+ } catch { /* deleted since the listing */ }
49
+ hash.update(`${rel}\0${stamp}\0`);
50
+ }
51
+ const config = join(projectPath, '.morph', 'config', 'config.json');
52
+ hash.update('\0config\0');
53
+ if (existsSync(config)) hash.update(readFileSync(config));
54
+ return hash.digest('hex');
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * The working tree as the orchestrator needs it when it takes control back
3
+ * from a dispatch, in one call instead of `git status` + `git diff --stat` +
4
+ * `git log` + a marker grep. Everything is read outside `.morph/`, the same
5
+ * cut the tree fingerprint makes.
6
+ */
7
+
8
+ import { existsSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { OUTSIDE_MORPH, treeFingerprint, git as gitBuffer } from './tree-fingerprint.js';
11
+ import { detectDefaultBranch } from './default-branch.js';
12
+ import { resolveBaseRef } from './branch-classifier.js';
13
+
14
+ /** What an evaluator writes next to a mutation it has not reverted yet. */
15
+ export const MUTATION_MARKER = 'MUTACAO';
16
+ const MAX_LISTED = 50;
17
+ const MAX_COMMITS = 10;
18
+
19
+ function git(cwd, args, input) {
20
+ return gitBuffer(cwd, args, input).toString();
21
+ }
22
+
23
+ function headSha(cwd) {
24
+ try { return git(cwd, ['rev-parse', 'HEAD']).trim(); } catch { return null; }
25
+ }
26
+
27
+ /** Every file that differs from HEAD outside `.morph/`, untracked ones one by one. */
28
+ function changedFiles(cwd) {
29
+ return parsePorcelain(git(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=all', ...OUTSIDE_MORPH]))
30
+ .map((e) => e.path);
31
+ }
32
+
33
+ /** Blob id of each file as it is on disk; null for a file that is gone. */
34
+ function diskBlobs(cwd, paths) {
35
+ const present = paths.filter((p) => existsSync(join(cwd, p)));
36
+ const ids = present.length ? git(cwd, ['hash-object', '--stdin-paths'], present.join('\n')).split('\n') : [];
37
+ const out = Object.fromEntries(paths.map((p) => [p, null]));
38
+ present.forEach((p, i) => { out[p] = ids[i].trim(); });
39
+ return out;
40
+ }
41
+
42
+ /** Blob id of each path in a commit; null where the commit does not have it. */
43
+ function commitBlobs(cwd, commit, paths) {
44
+ const out = Object.fromEntries(paths.map((p) => [p, null]));
45
+ if (!commit || !paths.length) return out;
46
+ const lines = git(cwd, ['cat-file', '--batch-check=%(objectname)'], paths.map((p) => `${commit}:${p}`).join('\n')).split('\n');
47
+ paths.forEach((p, i) => { out[p] = /^[0-9a-f]{40,64}$/.test(lines[i]?.trim()) ? lines[i].trim() : null; });
48
+ return out;
49
+ }
50
+
51
+ /**
52
+ * What the tree is before a dispatch whose work must leave it unchanged (the
53
+ * evaluator mutates and reverts): HEAD plus the blob of every file that
54
+ * differs from it. Kept on disk, so a session that resumes after the agent
55
+ * died compares against the tree before the dispatch, not its own start.
56
+ * @returns {{ at: string, head: string|null, files: Object<string, string|null> }|null}
57
+ */
58
+ export function snapshotTree(cwd) {
59
+ try {
60
+ return { at: new Date().toISOString(), head: headSha(cwd), files: diskBlobs(cwd, changedFiles(cwd)) };
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Files whose content on disk is not what it was in the snapshot. Content,
68
+ * not status: a mutation committed since shows up, and a file committed
69
+ * unchanged does not.
70
+ * @returns {{ at: string, headChanged: boolean, changed: string[], changedCount: number }}
71
+ */
72
+ export function compareToSnapshot(cwd, snapshot) {
73
+ const head = headSha(cwd);
74
+ const candidates = new Set([...Object.keys(snapshot.files || {}), ...changedFiles(cwd)]);
75
+ if (snapshot.head && head && snapshot.head !== head) {
76
+ for (const p of git(cwd, ['diff', '--name-only', '-z', snapshot.head, head, ...OUTSIDE_MORPH]).split('\0')) {
77
+ if (p) candidates.add(p);
78
+ }
79
+ }
80
+ const paths = [...candidates];
81
+ const now = diskBlobs(cwd, paths);
82
+ const unlisted = paths.filter((p) => !(p in (snapshot.files || {})));
83
+ const before = { ...commitBlobs(cwd, snapshot.head, unlisted), ...snapshot.files };
84
+ const changed = paths.filter((p) => before[p] !== now[p]).sort();
85
+ return { at: snapshot.at, headChanged: snapshot.head !== head, changed: changed.slice(0, MAX_LISTED), changedCount: changed.length };
86
+ }
87
+
88
+ /** `git status --porcelain -z`: a rename carries its old path as an extra entry. */
89
+ function parsePorcelain(out) {
90
+ const entries = [];
91
+ const parts = out.split('\0');
92
+ for (let i = 0; i < parts.length; i++) {
93
+ const item = parts[i];
94
+ if (!item) continue;
95
+ const status = item.slice(0, 2);
96
+ entries.push({ status: status.trim(), path: item.slice(3) });
97
+ if (status[0] === 'R' || status[0] === 'C') i++;
98
+ }
99
+ return entries;
100
+ }
101
+
102
+ function diffstat(cwd) {
103
+ const stat = { files: 0, insertions: 0, deletions: 0 };
104
+ for (const line of git(cwd, ['diff', 'HEAD', '--numstat', ...OUTSIDE_MORPH]).split('\n')) {
105
+ const [ins, del] = line.split('\t');
106
+ if (del === undefined) continue;
107
+ stat.files += 1;
108
+ stat.insertions += Number(ins) || 0;
109
+ stat.deletions += Number(del) || 0;
110
+ }
111
+ return stat;
112
+ }
113
+
114
+ function commitsOnTop(cwd) {
115
+ const base = resolveBaseRef(detectDefaultBranch(cwd), cwd);
116
+ if (!base) return null;
117
+ return git(cwd, ['log', `--max-count=${MAX_COMMITS}`, '--format=%h %s', `${base}..HEAD`])
118
+ .split('\n').filter(Boolean);
119
+ }
120
+
121
+ /** Untracked files count too; gitignored ones (build output) do not. */
122
+ function markers(cwd) {
123
+ try {
124
+ const out = git(cwd, ['grep', '-n', '-I', '--untracked', '-F', '-e', MUTATION_MARKER, ...OUTSIDE_MORPH]);
125
+ return out.split('\n').filter(Boolean).slice(0, MAX_LISTED).map((line) => {
126
+ const [file, lineNo] = line.split(':');
127
+ return { file, line: Number(lineNo) };
128
+ });
129
+ } catch (err) {
130
+ if (err.status === 1) return [];
131
+ throw err;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * @param {string} cwd
137
+ * @param {{ snapshot?: object|null }} [opts] - a `snapshotTree()` to compare with
138
+ * @returns {{ head: string|null, branch: string|null, fingerprint: string|null,
139
+ * dirty: {status: string, path: string}[], dirtyCount: number,
140
+ * diffstat: {files: number, insertions: number, deletions: number}|null,
141
+ * commits: string[]|null, markers: {file: string, line: number}[],
142
+ * sinceBeforeEval: ReturnType<typeof compareToSnapshot>|null }|null}
143
+ * null when git cannot answer (outside a repo, or an error): unknown, never
144
+ * a clean tree. `commits` is null when there is no default branch to count
145
+ * from; `diffstat` is null before the first commit; `sinceBeforeEval` is
146
+ * null without a snapshot.
147
+ */
148
+ export function treeState(cwd, { snapshot = null } = {}) {
149
+ try {
150
+ const dirty = parsePorcelain(git(cwd, ['status', '--porcelain=v1', '-z', ...OUTSIDE_MORPH]));
151
+ let head = null;
152
+ try { head = git(cwd, ['rev-parse', '--short', 'HEAD']).trim(); } catch { /* no commit yet */ }
153
+ return {
154
+ head,
155
+ branch: head ? git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']).trim() : null,
156
+ fingerprint: treeFingerprint(cwd),
157
+ dirty: dirty.slice(0, MAX_LISTED),
158
+ dirtyCount: dirty.length,
159
+ diffstat: head ? diffstat(cwd) : null,
160
+ commits: head ? commitsOnTop(cwd) : null,
161
+ markers: markers(cwd),
162
+ sinceBeforeEval: snapshot ? compareToSnapshot(cwd, snapshot) : null,
163
+ };
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
@@ -7,11 +7,16 @@
7
7
  * substituí-lo, porque exaustividade é um valor que o grafo não tem.
8
8
  *
9
9
  * Só entram nós COM source_file: um god node sem arquivo não orienta ninguém.
10
+ * E nada de teste: dublês e fixtures estão no grafo para serem achados, não
11
+ * para disputar centralidade — sem o filtro o `FakeGhlToolbelt` entra no top
12
+ * 10 do MORPH_GHLBrain e as arestas vindas de teste inflam o grau de `Result`
13
+ * de 380 para 443. Aresta com uma ponta em teste não conta para ninguém, e
14
+ * comunidade só de teste some da lista.
10
15
  *
11
16
  * @module god-nodes
12
17
  */
13
18
  import { loadGraph } from './graph-loader.js';
14
- import { buildIndex, resolveEdgeTarget } from './sanitize.js';
19
+ import { buildIndex, resolveEdgeTarget, linksOf, isTestPath } from './sanitize.js';
15
20
 
16
21
  /** Sem `graphify label`, as comunidades se chamam Community_N — ruído puro. */
17
22
  const PLACEHOLDER_LABEL = /^Community[_ ]?\d+$/i;
@@ -37,9 +42,12 @@ export async function scanGraph(projectPath, opts = {}) {
37
42
  const bump = (id) => {
38
43
  if (id) degree.set(id, (degree.get(id) ?? 0) + 1);
39
44
  };
40
- for (const link of graph.links ?? []) {
45
+ const isTest = (id) => isTestPath(index.byId.get(id)?.source_file);
46
+ for (const link of linksOf(index, graph)) {
47
+ const target = resolveEdgeTarget(index, link).id;
48
+ if (isTest(link.source) || (target && isTest(target))) continue;
41
49
  bump(link.source);
42
- bump(resolveEdgeTarget(index, link).id);
50
+ bump(target);
43
51
  }
44
52
 
45
53
  const godNodes = [...degree.entries()]
@@ -57,6 +65,7 @@ export async function scanGraph(projectPath, opts = {}) {
57
65
  const labels = new Map();
58
66
  for (const node of graph.nodes ?? []) {
59
67
  if (node.community === undefined || node.community === null) continue;
68
+ if (isTestPath(node.source_file)) continue;
60
69
  sizes.set(node.community, (sizes.get(node.community) ?? 0) + 1);
61
70
  if (node.community_label && !labels.has(node.community)) {
62
71
  labels.set(node.community, node.community_label);
@@ -6,9 +6,46 @@
6
6
  *
7
7
  * @module graph-health
8
8
  */
9
- import { loadGraph, getFreshness } from './graph-loader.js';
9
+ import { execFileSync } from 'node:child_process';
10
+ import { readFileSync } from 'node:fs';
11
+ import { join, resolve } from 'node:path';
12
+ import {
13
+ loadGraph, getFreshness, describeStale, staleCause,
14
+ } from './graph-loader.js';
10
15
  import { GRAPHIFY_VERSION, detectGraphifyVersion } from './graphify-runtime.js';
11
16
 
17
+ const GRAPHIFY_HOOK_MARKER = /# graphify-(?:checkout-)?hook-start/;
18
+
19
+ /**
20
+ * O hook git do próprio graphify (`graphify hook install`, que o setup-infra
21
+ * instalava até a 8.37) está no repositório? Ele é danoso aqui: num commit
22
+ * depois de um fast-forward carimba o HEAD num grafo SEM os arquivos que o
23
+ * merge trouxe — "fresh" falso — e disputa o graph.json com o refresh do
24
+ * morph-spec. Lê o diretório de hooks que o git usa (`core.hooksPath`
25
+ * incluído).
26
+ *
27
+ * @param {string} projectPath
28
+ * @returns {boolean}
29
+ */
30
+ export function graphifyGitHookInstalled(projectPath) {
31
+ let dir;
32
+ try {
33
+ const rel = execFileSync('git', ['rev-parse', '--git-path', 'hooks'], {
34
+ cwd: projectPath, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true,
35
+ }).trim();
36
+ dir = resolve(projectPath, rel);
37
+ } catch {
38
+ return false;
39
+ }
40
+ return ['post-commit', 'post-checkout'].some((hook) => {
41
+ try {
42
+ return GRAPHIFY_HOOK_MARKER.test(readFileSync(join(dir, hook), 'utf-8'));
43
+ } catch {
44
+ return false;
45
+ }
46
+ });
47
+ }
48
+
12
49
  /**
13
50
  * @param {string} projectPath
14
51
  * @param {{_version?: () => string|null}} [deps]
@@ -41,7 +78,16 @@ export function auditGraph(projectPath, deps = {}) {
41
78
  if (freshness.state === 'stale') {
42
79
  status = 'warn';
43
80
  details.push(
44
- `stale: construído em ${freshness.builtAtCommit.slice(0, 8)}, HEAD é ${freshness.headCommit.slice(0, 8)} — morph-spec graph refresh`,
81
+ `stale: ${staleCause(freshness)} — ${describeStale(projectPath, freshness)}`,
82
+ );
83
+ }
84
+
85
+ if (graphifyGitHookInstalled(projectPath)) {
86
+ status = 'warn';
87
+ details.push(
88
+ 'hook git do graphify instalado: depois de um fast-forward ele carimba o HEAD num grafo sem os arquivos do merge ' +
89
+ 'e disputa o graph.json com o refresh do morph-spec — remova com `graphify hook uninstall` ' +
90
+ '(tira também o merge driver do .gitattributes)',
45
91
  );
46
92
  }
47
93