@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
@@ -12,7 +12,14 @@
12
12
  */
13
13
  import { readFileSync, existsSync } from 'node:fs';
14
14
  import { join } from 'node:path';
15
- import { execFileSync } from 'node:child_process';
15
+ import {
16
+ graphFreshness, readRefreshRecord,
17
+ } from '../../../framework/hooks/shared/graph-freshness.js';
18
+ import {
19
+ isPrimaryTree, readRefreshLock, resolveGraphify,
20
+ } from '../../../framework/hooks/shared/graph-refresh.js';
21
+ import { isSecondaryWorktree, workTreeTop } from '../../../framework/hooks/shared/git-paths.js';
22
+ import { GRAPHIFY_PACKAGE, GRAPHIFY_VERSION } from './graphify-runtime.js';
16
23
 
17
24
  /**
18
25
  * @param {string} projectPath
@@ -50,37 +57,104 @@ export function loadGraph(projectPath) {
50
57
  }
51
58
  }
52
59
 
53
- function readHead(projectPath) {
54
- try {
55
- return execFileSync('git', ['rev-parse', 'HEAD'], {
56
- cwd: projectPath,
57
- encoding: 'utf-8',
58
- stdio: ['ignore', 'pipe', 'ignore'],
59
- }).trim() || null;
60
- } catch {
61
- return null;
62
- }
63
- }
64
-
65
60
  /**
66
- * O hook post-commit do graphify rebuilda em BACKGROUND: logo depois de um
67
- * commit o grafo ainda é o do commit anterior. Quem consome precisa saber, ou
68
- * conclui "ninguém usa isso" a partir de um grafo velho.
61
+ * O frescor do grafo, pela regra única (framework/hooks/shared/graph-freshness.js):
62
+ * o carimbo e o registro da última rodada contra o HEAD, e as regras do
63
+ * `.graphifyignore` por conteúdo. Um grafo velho diz "não existe" sobre o que
64
+ * o último commit trouxe — quem consome precisa saber, ou conclui "ninguém usa
65
+ * isso" a partir dele.
69
66
  *
70
67
  * @param {string} projectPath
71
- * @param {object} graph
68
+ * @param {object} graph - o graph.json já parseado (o carimbo sai dele)
72
69
  * @param {{_head?: (projectPath: string) => string|null}} [deps]
73
- * @returns {{builtAtCommit: string|null, headCommit: string|null, state: 'fresh'|'stale'|'unknown'}}
70
+ * @returns {{builtAtCommit: string|null, headCommit: string|null, state: 'fresh'|'stale'|'unknown', reason?: 'ignore-changed'|'rules-unknown'}}
74
71
  */
75
72
  export function getFreshness(projectPath, graph, deps = {}) {
76
- const headCommit = (deps._head ?? readHead)(projectPath);
77
- const builtAtCommit = graph?.built_at_commit ?? null;
78
- if (!headCommit || !builtAtCommit) {
79
- return { builtAtCommit, headCommit, state: 'unknown' };
73
+ const f = graphFreshness(projectPath, {
74
+ graph,
75
+ ...(deps._head ? { head: deps._head(projectPath) } : {}),
76
+ });
77
+ return {
78
+ builtAtCommit: f.built,
79
+ headCommit: f.head,
80
+ state: f.state === 'missing' ? 'unknown' : f.state,
81
+ ...(f.reason ? { reason: f.reason } : {}),
82
+ };
83
+ }
84
+
85
+ const short = (sha) => (sha ? String(sha).slice(0, 8) : '?');
86
+
87
+ /**
88
+ * O porquê de um grafo stale, em uma linha — carimbo antigo ou regras.
89
+ *
90
+ * @param {{builtAtCommit: string|null, headCommit: string|null, reason?: string}} freshness
91
+ * @returns {string}
92
+ */
93
+ export function staleCause(freshness) {
94
+ if (freshness.reason === 'ignore-changed') {
95
+ return 'as regras do .graphifyignore mudaram depois da extração';
96
+ }
97
+ if (freshness.reason === 'rules-unknown') {
98
+ return 'sem registro das regras do .graphifyignore com que o grafo foi extraído';
80
99
  }
100
+ return `construído em ${short(freshness.builtAtCommit)}, HEAD é ${short(freshness.headCommit)}`;
101
+ }
102
+
103
+ /**
104
+ * O que o refresh automático faz por esta árvore. Ele cobre só a árvore
105
+ * principal (`auto`); numa worktree, `running` é sempre false — um lock vivo
106
+ * ali seria o refresh da raiz. `last` é a última rodada registrada para esta
107
+ * árvore: em background, manual ou a semeadura.
108
+ *
109
+ * @param {string} projectPath
110
+ * @returns {{auto: boolean, running: boolean, since: string|null, pid: number|null, last: {ok: boolean, exit: number|null, head: string|null, ts: string, unavailable?: boolean, seeded?: boolean}|null}}
111
+ */
112
+ export function refreshState(projectPath) {
113
+ const auto = isPrimaryTree(projectPath);
114
+ const lock = auto ? readRefreshLock(projectPath) : null;
115
+ const record = readRefreshRecord(projectPath);
81
116
  return {
82
- builtAtCommit,
83
- headCommit,
84
- state: builtAtCommit === headCommit ? 'fresh' : 'stale',
117
+ auto,
118
+ running: Boolean(lock?.live),
119
+ since: lock?.live ? lock.since : null,
120
+ pid: lock?.live ? lock.pid : null,
121
+ last: record ? {
122
+ ok: record.exit === 0,
123
+ exit: record.exit ?? null,
124
+ head: record.head ?? null,
125
+ ts: record.at,
126
+ ...(record.unavailable ? { unavailable: true } : {}),
127
+ ...(record.seeded ? { seeded: true } : {}),
128
+ } : null,
85
129
  };
86
130
  }
131
+
132
+ /**
133
+ * A frase de grafo stale — a mesma no status, no explain, no affected e no
134
+ * doctor, e verdadeira sobre o mecanismo: o refresh em background só cobre a
135
+ * raiz, disparado pelos hooks do morph-spec; a worktree só anda à mão.
136
+ *
137
+ * @param {string} projectPath
138
+ * @param {{builtAtCommit?: string|null, headCommit?: string|null}} [freshness]
139
+ * @returns {string}
140
+ */
141
+ export function describeStale(projectPath, freshness = {}) {
142
+ const tree = workTreeTop(projectPath) ?? projectPath;
143
+ const state = refreshState(projectPath);
144
+ if (!state.auto && isSecondaryWorktree(tree)) {
145
+ return state.last?.seeded
146
+ ? `grafo semeado da raiz em ${short(state.last.head ?? freshness.builtAtCommit)}; \`morph-spec graph refresh\` atualiza esta worktree`
147
+ : 'o refresh automático só cobre a raiz; `morph-spec graph refresh` atualiza esta worktree';
148
+ }
149
+ if (state.running) {
150
+ return `refresh em background em andamento desde ${new Date(state.since).toLocaleTimeString()} — confirme no código antes de agir`;
151
+ }
152
+ // Registrado como ausente mas já de volta ao PATH: o próximo disparo roda.
153
+ if (state.last?.unavailable && !resolveGraphify()) {
154
+ return `graphify fora do PATH: o refresh automático está parado até ele voltar (uv tool install ${GRAPHIFY_PACKAGE}==${GRAPHIFY_VERSION}); depois, morph-spec graph refresh`;
155
+ }
156
+ if (state.last && !state.last.ok && state.last.head === (freshness.headCommit ?? null)) {
157
+ return `o último refresh falhou neste HEAD (exit ${state.last.exit ?? '?'}) e só se repete quando o HEAD andar — rode morph-spec graph refresh e leia a saída`;
158
+ }
159
+ return 'o refresh roda em background depois dos comandos git/gh/morph-spec do agente e no início da sessão; para já: morph-spec graph refresh';
160
+ }
@@ -2,30 +2,53 @@
2
2
  * Geração stack-aware do .graphifyignore (ADR-004).
3
3
  *
4
4
  * Filtrar teste não é cosmético: sem isso o god node #1 do MORPH_GHLBrain é
5
- * `FakeGhlToolbelt`, um dublê. Pior, os dublês criam um segundo implementador e
6
- * quebram o guard de "implementador único" do `dispatches_to` — a aresta que
7
- * atravessa a injeção de dependência cai de 109 para 1.
5
+ * `FakeGhlToolbelt`, um dublê. E o padrão genérico `tests/` NÃO serve em .NET:
6
+ * no ProspectPRO os testes vivem em `src/backend/ProspectPRO.Tests/`. Com
7
+ * `**\/*.Tests/` o grafo caiu de 10.450 para 7.384 nós — 30% era ruído de teste.
8
8
  *
9
- * E o padrão genérico `tests/` NÃO serve em .NET: no ProspectPRO os testes
10
- * vivem em `src/backend/ProspectPRO.Tests/`. Com `**\/*.Tests/` o grafo caiu de
11
- * 10.450 para 7.384 nós — 30% era ruído de teste.
9
+ * Mas dublê, fixture e helper de teste precisam ser ACHÁVEIS: são o que um
10
+ * teste novo deveria reusar, e fora do grafo `graph explain FakeX` dá
11
+ * not-found. Por isso o conteúdo dos projetos de teste sai e esses arquivos
12
+ * voltam. A forma é ditada pela regra do gitignore que o graphify 0.9.50 segue
13
+ * (detect.py `_is_ignored`): um `!` não reinclui nada sob um diretório já
14
+ * excluído. Medido num fixture: `**\/*.Tests/` + `!**\/Fakes/` não traz nada de
15
+ * volta; excluir o CONTEÚDO (`**\/*.Tests/**`), reincluir os diretórios
16
+ * (`!**\/*.Tests/**\/`) e então os helpers traz. `bin/`/`obj/` vêm DEPOIS das
17
+ * reinclusões — antes, a reinclusão de diretório os devolveria (`obj/` tem
18
+ * `.cs` gerado). No MORPH_GHLBrain: +9,6% de nós (11.591 → 12.706), 94
19
+ * arquivos de teste, nenhum de bin/obj.
20
+ *
21
+ * Os dublês quebram o guard de "implementador único" do `dispatches_to` do
22
+ * graphify (de 122 para 5 arestas no GHLBrain); o saneamento as recupera
23
+ * (sanitize.js `recoverDispatch`) e tira o teste dos god nodes.
12
24
  *
13
25
  * Em Next.js o problema é outro: metade dos nós vinha de JSON de configuração,
14
26
  * e os god nodes viravam `dependencies` e `compilerOptions`.
15
27
  *
16
28
  * @module graphifyignore
17
29
  */
18
- import { writeFileSync, existsSync } from 'node:fs';
30
+ import { writeFileSync, existsSync, readFileSync } from 'node:fs';
31
+ import { execFileSync } from 'node:child_process';
19
32
  import { join } from 'node:path';
20
33
  import { WORKTREE_DIR_NAMES } from '../worktree/dirs.js';
34
+ import { ignoreRules } from '../../../framework/hooks/shared/graph-freshness.js';
21
35
 
22
36
  export const ALWAYS_IGNORED = [
23
37
  '.git/', '.morph/', 'artifacts/', ...WORKTREE_DIR_NAMES.map((d) => `${d}/`),
24
38
  'coverage/', 'graphify-out/', 'dist/', 'out/',
25
39
  ];
26
40
 
41
+ const TEST_PROJECTS = ['**/*.Tests', '**/*Tests', '**/*.Test'];
42
+ const TEST_HELPER_DIRS = ['TestDoubles', 'Fakes', 'Stubs', 'Mocks', 'Fixtures', 'Helpers', 'Builders'];
43
+ const TEST_HELPER_FILES = ['*Fixture.cs', '*Fixtures.cs', 'Fake*.cs', 'Stub*.cs', '*Stub.cs'];
44
+ const DOTNET_BUILD_OUTPUT = ['bin/', 'obj/', 'TestResults/'];
45
+
27
46
  const DOTNET_IGNORED = [
28
- '**/*.Tests/', '**/*Tests/', '**/*.Test/', 'bin/', 'obj/', 'TestResults/',
47
+ ...TEST_PROJECTS.map((p) => `${p}/**`),
48
+ ...TEST_PROJECTS.map((p) => `!${p}/**/`),
49
+ ...TEST_HELPER_DIRS.map((d) => `!**/${d}/**`),
50
+ ...TEST_HELPER_FILES.map((f) => `!**/${f}`),
51
+ ...DOTNET_BUILD_OUTPUT,
29
52
  ];
30
53
 
31
54
  const WEB_IGNORED = [
@@ -35,31 +58,35 @@ const WEB_IGNORED = [
35
58
  '/next.config.js', '/next.config.mjs', '/tailwind.config.js', '/tailwind.config.ts',
36
59
  ];
37
60
 
61
+ const HEADER = '# Gerado por morph-spec (ADR-004). Editável: um arquivo editado nunca é sobrescrito.';
62
+
63
+ /**
64
+ * O que a versão anterior gerava — os testes .NET inteiros fora. Só existe
65
+ * para reconhecer um arquivo que ninguém editou e trocá-lo pelo atual.
66
+ */
67
+ const PREVIOUS = {
68
+ header: '# Gerado por morph-spec (ADR-004). Editável: o CLI não sobrescreve.',
69
+ dotnetTitle: '# .NET — testes em qualquer nível (tests/ genérico não serve)',
70
+ dotnet: ['**/*.Tests/', '**/*Tests/', '**/*.Test/', ...DOTNET_BUILD_OUTPUT],
71
+ };
72
+
38
73
  const DOTNET_TAGS = new Set(['dotnet', 'aspnet']);
39
74
  const WEB_TAGS = new Set([
40
75
  'nextjs', 'next', 'next.js', 'react', 'vue', 'nodejs', 'nodejs-cli', 'node-cli',
41
76
  ]);
42
77
 
43
- /**
44
- * @param {string[]} [projectTags] saída de `getProjectTags(config)` — stack
45
- * splitado por `-`/`+` mais as integrations
46
- * @returns {string} conteúdo completo do .graphifyignore
47
- */
48
- export function buildGraphifyignore(projectTags = []) {
78
+ const hasDotnetTag = (tags) => [...new Set(tags)].some((t) => DOTNET_TAGS.has(t));
79
+
80
+ function render(projectTags, { header, dotnetTitle, dotnet }) {
49
81
  const tags = new Set(projectTags);
50
- const hasDotnet = [...tags].some((t) => DOTNET_TAGS.has(t));
51
82
  const hasWeb = [...tags].some((t) => WEB_TAGS.has(t));
52
83
 
53
84
  const sections = [
54
- ['# Gerado por morph-spec (ADR-004). Editável: o CLI não sobrescreve.', ''],
85
+ [header, ''],
55
86
  ['# Sempre', ...ALWAYS_IGNORED],
56
87
  ];
57
- if (hasDotnet) {
58
- sections.push(['', '# .NET — testes em qualquer nível (tests/ genérico não serve)', ...DOTNET_IGNORED]);
59
- }
60
- if (hasWeb) {
61
- sections.push(['', '# Web — dependências e JSON de configuração de raiz', ...WEB_IGNORED]);
62
- }
88
+ if (hasDotnetTag(tags)) sections.push(['', dotnetTitle, ...dotnet]);
89
+ if (hasWeb) sections.push(['', '# Web — dependências e JSON de configuração de raiz', ...WEB_IGNORED]);
63
90
 
64
91
  const seen = new Set();
65
92
  const lines = [];
@@ -73,15 +100,105 @@ export function buildGraphifyignore(projectTags = []) {
73
100
  }
74
101
 
75
102
  /**
76
- * Escreve o .graphifyignore. Arquivo já existente é do usuário: não é tocado.
103
+ * @param {string[]} [projectTags] saída de `getProjectTags(config)` — stack
104
+ * splitado por `-`/`+` mais as integrations
105
+ * @returns {string} conteúdo completo do .graphifyignore
106
+ */
107
+ export function buildGraphifyignore(projectTags = []) {
108
+ return render(projectTags, {
109
+ header: HEADER,
110
+ dotnetTitle: '# .NET — conteúdo dos projetos de teste fora; dublês, fixtures e helpers dentro',
111
+ dotnet: DOTNET_IGNORED,
112
+ });
113
+ }
114
+
115
+ const normalize = (text) => String(text).replace(/^/, '').replace(/\r\n/g, '\n');
116
+
117
+ /**
118
+ * Escreve ou migra o .graphifyignore.
119
+ *
120
+ * ausente → escreve o atual
121
+ * idêntico ao que a versão anterior
122
+ * gerava para as mesmas tags → troca pelo atual (`migrated`) —
123
+ * só se as REGRAS mudam (em
124
+ * projeto sem .NET a diferença é
125
+ * só o cabeçalho, e reescrever
126
+ * seria um diff à toa num arquivo
127
+ * versionado)
128
+ * qualquer outra coisa → é do usuário: não toca, e em
129
+ * projeto .NET que ainda exclui os
130
+ * testes inteiros devolve em
131
+ * `outdated` a troca a fazer
77
132
  *
78
133
  * @param {string} projectPath
79
134
  * @param {string[]} [projectTags]
80
- * @returns {{written: boolean, path: string}}
135
+ * @param {{fx?: {writeFile: (path: string, content: string) => void}}} [opts]
136
+ * coletor de efeitos do `update` — com ele, o `--dry-run` prevê a escrita
137
+ * @returns {{written: boolean, migrated?: boolean, outdated?: {remove: string[], add: string[]}, path: string}}
81
138
  */
82
- export function writeGraphifyignore(projectPath, projectTags = []) {
139
+ export function writeGraphifyignore(projectPath, projectTags = [], { fx } = {}) {
83
140
  const path = join(projectPath, '.graphifyignore');
84
- if (existsSync(path)) return { written: false, path };
85
- writeFileSync(path, buildGraphifyignore(projectTags), 'utf-8');
86
- return { written: true, path };
141
+ const current = buildGraphifyignore(projectTags);
142
+ const write = () => (fx ? fx.writeFile(path, current) : writeFileSync(path, current, 'utf-8'));
143
+
144
+ if (!existsSync(path)) {
145
+ write();
146
+ return { written: true, path };
147
+ }
148
+
149
+ let existing;
150
+ try {
151
+ existing = normalize(readFileSync(path, 'utf-8'));
152
+ } catch {
153
+ return { written: false, path };
154
+ }
155
+ if (existing === current) return { written: false, path };
156
+ if (existing === render(projectTags, PREVIOUS)) {
157
+ // A régua é a mesma do frescor do grafo (graph-freshness.js): o que o
158
+ // graphify lê, sem cabeçalho nem comentário.
159
+ if (ignoreRules(existing) === ignoreRules(current)) return { written: false, path };
160
+ write();
161
+ return { written: true, migrated: true, path };
162
+ }
163
+
164
+ // Acrescentar não basta: enquanto `**/*.Tests/` excluir o DIRETÓRIO, nenhum
165
+ // `!` reinclui o que está dentro dele.
166
+ const lines = new Set(existing.split('\n').map((l) => l.trim()));
167
+ const remove = PREVIOUS.dotnet.slice(0, 3).filter((l) => lines.has(l));
168
+ if (hasDotnetTag(projectTags) && remove.length) {
169
+ const add = DOTNET_IGNORED.filter((l) => !DOTNET_BUILD_OUTPUT.includes(l) && !lines.has(l));
170
+ return { written: false, outdated: { remove, add }, path };
171
+ }
172
+ return { written: false, path };
173
+ }
174
+
175
+ /**
176
+ * A frase que todo chamador imprime para um `outdated` — uma só.
177
+ *
178
+ * @param {{remove: string[], add: string[]}} outdated
179
+ * @returns {string}
180
+ */
181
+ export function describeOutdatedIgnore(outdated) {
182
+ return `.graphifyignore editado mantém os testes .NET inteiros fora do grafo ` +
183
+ `(dublês e fixtures dão not-found). Troque ${outdated.remove.join(' ')} por, nesta ordem e ` +
184
+ `antes de bin/ e obj/: ${outdated.add.join(' ')} — depois, morph-spec graph refresh`;
185
+ }
186
+
187
+ /**
188
+ * O `.graphifyignore` é versionado? Então uma migração só chega às worktrees
189
+ * e ao resto do time depois de commitada — cada checkout lê o seu. Fail-open:
190
+ * sem git, false.
191
+ *
192
+ * @param {string} projectPath
193
+ * @returns {boolean}
194
+ */
195
+ export function graphifyignoreTracked(projectPath) {
196
+ try {
197
+ execFileSync('git', ['ls-files', '--error-unmatch', '--', '.graphifyignore'], {
198
+ cwd: projectPath, stdio: 'ignore', windowsHide: true, timeout: 10000,
199
+ });
200
+ return true;
201
+ } catch {
202
+ return false;
203
+ }
87
204
  }
@@ -41,13 +41,25 @@ const LANG_BY_EXT = {
41
41
  };
42
42
 
43
43
  /**
44
- * Projeto de teste em qualquer nível. Três formas, e só três, porque
45
- * `[^/]*[Tt]ests?/` genérico também casaria `latest/` e `contests/`:
44
+ * Código de teste — o predicado ÚNICO de "isto é teste" do grafo e do
45
+ * validador de reuso. Pasta de projeto de teste em qualquer nível, em três
46
+ * formas, e só três, porque `[^/]*[Tt]ests?/` genérico também casaria
47
+ * `latest/` e `contests/`:
46
48
  * - o segmento inteiro é `test`/`tests` (qualquer caixa)
47
49
  * - termina em `.Test`/`.Tests` — a convenção .NET (ProspectPRO.Tests/)
48
50
  * - termina em `Test`/`Tests` com T maiúsculo — PascalCase (AlvoTests/)
51
+ * mais o teste colocado ao lado do código em JS/TS: `__tests__/`,
52
+ * `*.test.*` e `*.spec.*`.
49
53
  */
50
- const TEST_PATH = /(^|\/)(?:[Tt]ests?|[^/]*\.[Tt]ests?|[^/]*[a-z0-9_]Tests?)\//;
54
+ const TEST_PATH = /(^|\/)__tests__\/|\.(test|spec)\.[jt]sx?$|(^|\/)(?:[Tt]ests?|[^/]*\.[Tt]ests?|[^/]*[a-z0-9_]Tests?)\//;
55
+
56
+ /**
57
+ * @param {string} sourceFile
58
+ * @returns {boolean} o arquivo é código de teste (dublê, fixture ou o teste em si)
59
+ */
60
+ export function isTestPath(sourceFile) {
61
+ return TEST_PATH.test(String(sourceFile || '').replace(/\\/g, '/'));
62
+ }
51
63
 
52
64
  /**
53
65
  * @param {string} sourceFile
@@ -75,6 +87,7 @@ const baseOf = (norm) => norm.replace(/\(\s*\)$/, '');
75
87
  * @property {Map<string, {real: object[], stub: object[]}>} byLabel
76
88
  * @property {Map<string, object[]>} byBaseLabel label sem `()` -> nós reais
77
89
  * @property {Map<string, string>} coalesce stubId -> id do nó real
90
+ * @property {object[]} recovered `dispatches_to` recuperados (ver recoverDispatch)
78
91
  */
79
92
 
80
93
  /**
@@ -114,7 +127,94 @@ export function buildIndex(graph) {
114
127
  else byBaseLabel.set(base, [...entry.real]);
115
128
  }
116
129
 
117
- return { byId, byLabel, byBaseLabel, coalesce };
130
+ const index = { byId, byLabel, byBaseLabel, coalesce, recovered: [] };
131
+ index.recovered = recoverDispatch(index, graph);
132
+ return index;
133
+ }
134
+
135
+ const isCsharp = (node) => Boolean(node?.source_file) && /\.cs$/i.test(node.source_file);
136
+
137
+ /** Nome do método sem o ponto inicial e sem a assinatura, como o graphify casa. */
138
+ const methodName = (node) => String(node.label ?? '').trim().replace(/^\./, '').split('(')[0];
139
+
140
+ /**
141
+ * `dispatches_to` que o graphify deixou de emitir por causa de um dublê de
142
+ * teste. O resolvedor dele (graphify/csharp_dispatch.py) liga o método da
143
+ * interface ao da implementação só quando há EXATAMENTE um implementador — e
144
+ * um `FakeX : IX` no grafo é o segundo. Este é o mesmo algoritmo, com o guard
145
+ * contando só implementadores de produção. Medido no MORPH_GHLBrain: com os
146
+ * dublês no grafo o graphify cai de 122 para 5 arestas, e esta função devolve
147
+ * as 122 do grafo sem testes, nenhuma a mais.
148
+ *
149
+ * Aditivo: um par que o graphify já emitiu não é repetido.
150
+ *
151
+ * @param {GraphIndex} index
152
+ * @param {{links?: object[]}} graph
153
+ * @returns {object[]} arestas sintéticas, marcadas `_recovered: true`
154
+ */
155
+ function recoverDispatch(index, graph) {
156
+ const implementers = new Map();
157
+ const methodsOf = new Map();
158
+ const existing = new Set();
159
+
160
+ for (const link of graph.links ?? []) {
161
+ if (link.relation === 'dispatches_to') {
162
+ existing.add(`${link.source}|${link.target}`);
163
+ } else if (link.relation === 'implements') {
164
+ const impl = index.byId.get(link.source);
165
+ if (!impl || isTestPath(impl.source_file)) continue;
166
+ const iface = resolveEdgeTarget(index, link).id;
167
+ if (!iface) continue;
168
+ if (!implementers.has(iface)) implementers.set(iface, new Set());
169
+ implementers.get(iface).add(link.source);
170
+ } else if (link.relation === 'method') {
171
+ const method = index.byId.get(link.target);
172
+ if (!isCsharp(method)) continue;
173
+ const name = methodName(method);
174
+ if (!name) continue;
175
+ if (!methodsOf.has(link.source)) methodsOf.set(link.source, new Map());
176
+ const byName = methodsOf.get(link.source);
177
+ if (!byName.has(name)) byName.set(name, new Set());
178
+ byName.get(name).add(link.target);
179
+ }
180
+ }
181
+
182
+ const recovered = [];
183
+ for (const [ifaceId, impls] of implementers) {
184
+ if (impls.size !== 1) continue;
185
+ const [implId] = impls;
186
+ const impl = index.byId.get(implId);
187
+ if (!isCsharp(index.byId.get(ifaceId)) || !isCsharp(impl)) continue;
188
+ const implMethods = methodsOf.get(implId) ?? new Map();
189
+ for (const [name, declared] of methodsOf.get(ifaceId) ?? new Map()) {
190
+ const candidates = implMethods.get(name);
191
+ if (declared.size !== 1 || candidates?.size !== 1) continue;
192
+ const [source] = declared;
193
+ const [target] = candidates;
194
+ const key = `${source}|${target}`;
195
+ if (source === target || existing.has(key)) continue;
196
+ existing.add(key);
197
+ recovered.push({
198
+ source, target, relation: 'dispatches_to', context: 'call',
199
+ source_file: impl.source_file, source_location: impl.source_location ?? '',
200
+ _recovered: true,
201
+ });
202
+ }
203
+ }
204
+ return recovered;
205
+ }
206
+
207
+ /**
208
+ * As arestas do grafo mais as recuperadas pelo saneamento — o conjunto que
209
+ * todo consumidor deve percorrer.
210
+ *
211
+ * @param {GraphIndex} index
212
+ * @param {{links?: object[]}} graph
213
+ * @returns {object[]}
214
+ */
215
+ export function linksOf(index, graph) {
216
+ const own = graph.links ?? [];
217
+ return index.recovered?.length ? own.concat(index.recovered) : own;
118
218
  }
119
219
 
120
220
  /**
@@ -201,20 +301,21 @@ export function resolveLabel(index, label) {
201
301
  * @param {{links: object[]}} graph
202
302
  * @param {string} id
203
303
  * @param {{prod?: boolean, excludeInheritance?: boolean}} [opts]
204
- * @returns {Array<{id: string, label: string, source_file: string, source_location: string, relation: string}>}
304
+ * @returns {Array<{id: string, label: string, source_file: string, source_location: string, relation: string, test: boolean}>}
205
305
  */
206
306
  export function affected(index, graph, id, opts = {}) {
207
307
  const seen = new Set();
208
308
  const out = [];
209
309
 
210
- for (const link of graph.links ?? []) {
310
+ for (const link of linksOf(index, graph)) {
211
311
  if (!USE_RELATIONS.has(link.relation)) continue;
212
312
  if (opts.excludeInheritance && INHERITANCE_RELATIONS.has(link.relation)) continue;
213
313
  if (resolveEdgeTarget(index, link).id !== id) continue;
214
314
 
215
315
  const source = index.byId.get(link.source);
216
316
  if (!source || !source.source_file) continue;
217
- if (opts.prod && TEST_PATH.test(source.source_file)) continue;
317
+ const test = isTestPath(source.source_file);
318
+ if (opts.prod && test) continue;
218
319
 
219
320
  const key = `${source.id}|${link.relation}`;
220
321
  if (seen.has(key)) continue;
@@ -226,6 +327,7 @@ export function affected(index, graph, id, opts = {}) {
226
327
  source_file: source.source_file,
227
328
  source_location: source.source_location ?? '',
228
329
  relation: link.relation,
330
+ test,
229
331
  });
230
332
  }
231
333
 
@@ -234,7 +336,8 @@ export function affected(index, graph, id, opts = {}) {
234
336
 
235
337
  /**
236
338
  * Vizinhança de um nó, para `graph explain`. Cada aresta carrega a procedência
237
- * (`via`), então dá para ver o que o saneamento recuperou.
339
+ * (`via`), então dá para ver o que o saneamento recuperou, e `test` diz se a
340
+ * outra ponta é código de teste.
238
341
  *
239
342
  * @param {GraphIndex} index
240
343
  * @param {{links: object[]}} graph
@@ -244,8 +347,9 @@ export function neighborhood(index, graph, id) {
244
347
  const incoming = [];
245
348
  const outgoing = [];
246
349
 
247
- for (const link of graph.links ?? []) {
350
+ for (const link of linksOf(index, graph)) {
248
351
  const resolved = resolveEdgeTarget(index, link);
352
+ const via = link._recovered ? 'recovered-dispatch' : resolved.kind;
249
353
 
250
354
  if (resolved.id === id) {
251
355
  const source = index.byId.get(link.source);
@@ -256,7 +360,8 @@ export function neighborhood(index, graph, id) {
256
360
  relation: link.relation,
257
361
  source_file: link.source_file || source.source_file,
258
362
  source_location: link.source_location || source.source_location || '',
259
- via: resolved.kind,
363
+ via,
364
+ test: isTestPath(source.source_file),
260
365
  });
261
366
  }
262
367
  }
@@ -270,7 +375,8 @@ export function neighborhood(index, graph, id) {
270
375
  relation: link.relation,
271
376
  source_file: link.source_file || target.source_file,
272
377
  source_location: link.source_location || '',
273
- via: resolved.kind,
378
+ via,
379
+ test: isTestPath(target.source_file),
274
380
  });
275
381
  }
276
382
  }