@polymorphism-tech/morph-spec 8.29.0 → 8.31.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 (170) hide show
  1. package/CLAUDE.md +6 -1
  2. package/README.md +13 -2
  3. package/bin/morph-spec.js +48 -0
  4. package/bin/validate.js +379 -379
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +1 -1
  7. package/docs/QUICKSTART.md +1 -1
  8. package/framework/CLAUDE.md +2 -0
  9. package/framework/MORPH.md +2 -1
  10. package/framework/agents.json +6 -6
  11. package/framework/commands/morph-status.md +1 -1
  12. package/framework/docs/CLI.md +18 -0
  13. package/framework/evals/rubrics/code-quality.md +17 -0
  14. package/framework/evals/rubrics/condensed.md +3 -1
  15. package/framework/hooks/claude-code/statusline.sh +11 -11
  16. package/framework/hooks/git/commit-msg/conventional-commits.sh +33 -33
  17. package/framework/hooks/git/pre-commit/agents.sh +25 -25
  18. package/framework/hooks/git/pre-commit/orchestrator.sh +64 -64
  19. package/framework/hooks/git/pre-commit/specs.sh +50 -50
  20. package/framework/hooks/git/pre-push/run-tests.sh +44 -44
  21. package/framework/hooks/shared/phase-utils.js +2 -0
  22. package/framework/hooks/shared/stdin-reader.js +26 -26
  23. package/framework/mcp/registry.json +39 -4
  24. package/framework/rubrics/backend.json +2 -2
  25. package/framework/rubrics/default.json +2 -2
  26. package/framework/rubrics/frontend.json +2 -2
  27. package/framework/schemas/tasks.schema.json +25 -0
  28. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  29. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  30. package/framework/skills/core/morph-apply/SKILL.md +12 -3
  31. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  32. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  33. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  34. package/framework/skills/core/morph-implement/SKILL.md +12 -2
  35. package/framework/skills/core/morph-plan/SKILL.md +45 -3
  36. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  37. package/framework/skills/core/morph-review/SKILL.md +11 -3
  38. package/framework/skills/core/morph-stack-scan/SKILL.md +13 -1
  39. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  40. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  41. package/framework/standards/ai-agents/agent-session.md +160 -160
  42. package/framework/standards/ai-agents/batching.md +193 -193
  43. package/framework/standards/ai-agents/context-providers.md +188 -188
  44. package/framework/standards/ai-agents/mcp-server.md +250 -250
  45. package/framework/standards/ai-agents/mcp-tools.md +197 -197
  46. package/framework/standards/ai-agents/middleware-patterns.md +207 -207
  47. package/framework/standards/ai-agents/modalities-image-gen.md +173 -173
  48. package/framework/standards/ai-agents/modalities-speech-to-text.md +171 -171
  49. package/framework/standards/ai-agents/modalities-text-to-speech.md +181 -181
  50. package/framework/standards/ai-agents/modalities-vision.md +201 -201
  51. package/framework/standards/ai-agents/multi-agent-patterns.md +155 -155
  52. package/framework/standards/ai-agents/production.md +231 -231
  53. package/framework/standards/ai-agents/prompts-in-db.md +214 -214
  54. package/framework/standards/ai-agents/providers/model-registry.md +158 -158
  55. package/framework/standards/ai-agents/service-tier-flex.md +135 -135
  56. package/framework/standards/ai-agents/structured-output.md +249 -249
  57. package/framework/standards/ai-agents/sweet-spot.md +109 -109
  58. package/framework/templates/.idea/morph-templates.xml +92 -0
  59. package/framework/templates/.vscode/morph-templates.code-snippets +186 -0
  60. package/framework/templates/IDE-SNIPPETS.md +262 -262
  61. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  62. package/framework/templates/code/dotnet/ai-agents/AgentBootstrap.cs.template +39 -39
  63. package/framework/templates/code/dotnet/ai-agents/EmbeddingService.cs.template +68 -68
  64. package/framework/templates/code/dotnet/ai-agents/ModelRegistry.cs.template +117 -117
  65. package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +95 -95
  66. package/framework/templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template +61 -61
  67. package/framework/templates/code/dotnet/ai-agents/VectorSearchTool.cs.template +107 -107
  68. package/framework/templates/code/dotnet/ai-agents/model-registry.json.template +58 -58
  69. package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +281 -281
  70. package/framework/templates/code/dotnet/database/migration.cs +83 -83
  71. package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +21 -21
  72. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  73. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  74. package/framework/templates/code/dotnet/feature/validator.cs.hbs +15 -15
  75. package/framework/templates/code/sql/ai-agents/agent-prompts-migration.sql.template +58 -58
  76. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  77. package/framework/templates/code/typescript/contracts.ts +167 -167
  78. package/framework/templates/context/ARCHITECTURE.md +20 -0
  79. package/framework/templates/docs/clarifications.md +253 -253
  80. package/framework/templates/docs/schema-analysis.md +119 -119
  81. package/framework/templates/docs/ui-components.md +52 -52
  82. package/framework/templates/docs/ui-design-system.md +76 -76
  83. package/framework/templates/docs/ui-flows.md +167 -167
  84. package/framework/templates/docs/ui-mockups.md +98 -98
  85. package/framework/templates/feature/decisions.md +122 -122
  86. package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +43 -43
  87. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  88. package/framework/templates/frontend/nextjs/env.mjs.hbs +32 -32
  89. package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +56 -56
  90. package/framework/templates/frontend/nextjs/page.tsx.hbs +22 -22
  91. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  92. package/framework/templates/frontend/nextjs/use-feature.ts.hbs +54 -54
  93. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  94. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  95. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  96. package/framework/templates/infrastructure/github/README.md +56 -56
  97. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  98. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  99. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  100. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  101. package/framework/templates/integrations/asaas-client.cs +387 -387
  102. package/framework/templates/integrations/asaas-webhook.cs +351 -351
  103. package/framework/templates/meta-prompts/fusion/fusion-agent.md +76 -76
  104. package/framework/templates/meta-prompts/fusion/fusion-aggregator.md +100 -100
  105. package/framework/templates/meta-prompts/validators/pre-commit-validator.md +95 -95
  106. package/framework/templates/project/validate.js +120 -120
  107. package/framework/templates/state.template.json +17 -17
  108. package/framework/templates/ui/design-system.css +226 -226
  109. package/package.json +1 -1
  110. package/src/commands/dashboard/dashboard.js +53 -53
  111. package/src/commands/graph/affected.js +89 -0
  112. package/src/commands/graph/build.js +94 -0
  113. package/src/commands/graph/explain.js +96 -0
  114. package/src/commands/graph/status.js +93 -0
  115. package/src/commands/index.js +13 -13
  116. package/src/commands/project/checkpoint.js +209 -209
  117. package/src/commands/project/doctor.js +11 -0
  118. package/src/core/paths/output-schema.js +7 -0
  119. package/src/core/state/phase-state-machine.js +197 -197
  120. package/src/core/templates/template-renderer.js +477 -477
  121. package/src/dashboard/app/components/DetailPanel.jsx +11 -11
  122. package/src/dashboard/app/components/FlowGraph.jsx +17 -17
  123. package/src/dashboard/app/components/SearchFilter.jsx +22 -22
  124. package/src/dashboard/app/hooks/useDetailPanel.js +15 -15
  125. package/src/dashboard/app/main.jsx +13 -13
  126. package/src/dashboard/app/pages/AgentMap.jsx +370 -370
  127. package/src/dashboard/app/pages/HookInspector.jsx +418 -418
  128. package/src/dashboard/app/pages/PhasePipeline.jsx +408 -408
  129. package/src/dashboard/app/pages/RulesMap.jsx +273 -273
  130. package/src/dashboard/app/pages/SkillsLibrary.jsx +366 -366
  131. package/src/dashboard/app/pages/Standards.jsx +332 -332
  132. package/src/dashboard/app/styles/index.css +47 -47
  133. package/src/dashboard/index.html +12 -12
  134. package/src/dashboard/package.json +24 -24
  135. package/src/dashboard/scanner/phases-scanner.js +15 -15
  136. package/src/dashboard/scanner/rubrics-scanner.js +12 -12
  137. package/src/dashboard/scanner/rules-scanner.js +37 -37
  138. package/src/dashboard/scanner/skills-scanner.js +43 -43
  139. package/src/dashboard/scanner/squads-scanner.js +13 -13
  140. package/src/dashboard/vite-plugin-morph-data.js +17 -17
  141. package/src/dashboard/vite.config.js +24 -24
  142. package/src/lib/detectors/design-system-detector.js +188 -188
  143. package/src/lib/detectors/index.js +217 -217
  144. package/src/lib/generators/architecture-scanner.js +45 -4
  145. package/src/lib/generators/design-system-generator.js +508 -508
  146. package/src/lib/generators/settings-generator.js +209 -209
  147. package/src/lib/graph/god-nodes.js +75 -0
  148. package/src/lib/graph/graph-health.js +61 -0
  149. package/src/lib/graph/graph-loader.js +86 -0
  150. package/src/lib/graph/graphify-runtime.js +100 -0
  151. package/src/lib/graph/graphifyignore.js +86 -0
  152. package/src/lib/graph/sanitize.js +280 -0
  153. package/src/lib/hooks/hook-executor.js +167 -167
  154. package/src/lib/installers/mcp-installer.js +719 -719
  155. package/src/lib/tasks/task-classifier.js +30 -30
  156. package/src/lib/tasks/task-parser.js +209 -209
  157. package/src/lib/tasks/tasks-json-parser.js +26 -0
  158. package/src/lib/validators/checkpoint-persistence.js +39 -39
  159. package/src/lib/validators/css/css-validator.js +340 -340
  160. package/src/lib/validators/reuse/index.js +10 -0
  161. package/src/lib/validators/reuse/reuse-validator.js +324 -0
  162. package/src/lib/validators/reuse/symbol-scanner.js +144 -0
  163. package/src/lib/validators/shared/emit-validator-dispatch.js +14 -14
  164. package/src/lib/validators/shared/issue-counter.js +14 -14
  165. package/src/lib/validators/validation-runner.js +40 -1
  166. package/src/scripts/setup-infra.js +60 -1
  167. package/src/utils/agent-id-mapper.js +11 -11
  168. package/src/utils/claude-settings-manager.js +300 -300
  169. package/src/utils/file-copier.js +40 -0
  170. package/src/utils/skills-installer.js +187 -187
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Descoberta do runtime Python e argv de instalação do graphify.
3
+ *
4
+ * O binário instalado chama-se `graphify` (e `graphify-mcp` para o servidor
5
+ * MCP). NUNCA use `python -m graphify.serve`, que é o que o README oficial
6
+ * manda: com `uv tool install` o pacote vive num venv isolado e o Python do
7
+ * sistema não o enxerga (ModuleNotFoundError) — medido no eval, Eixo 2.
8
+ *
9
+ * Toda descoberta é injetável (`_which`, `_run`) para que o teste cubra Windows
10
+ * e POSIX sem depender do PATH da máquina que roda o CI.
11
+ *
12
+ * @module graphify-runtime
13
+ */
14
+ import { execFileSync } from 'node:child_process';
15
+
16
+ /**
17
+ * Pin exato: 0.9.50 é a única versão medida no eval e a base do fixture de
18
+ * regressão. O graphify é pré-1.0 com release quase diária, então semver não
19
+ * garante nada — bump é decisão consciente, com re-run do fixture.
20
+ */
21
+ export const GRAPHIFY_VERSION = '0.9.50';
22
+ export const GRAPHIFY_PACKAGE = 'graphifyy';
23
+ export const GRAPHIFY_BIN = 'graphify';
24
+ export const GRAPHIFY_MCP_BIN = 'graphify-mcp';
25
+
26
+ const PREFERENCE = ['uv', 'pipx', 'python3', 'python'];
27
+
28
+ function whichSync(bin) {
29
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
30
+ try {
31
+ const out = execFileSync(cmd, [bin], {
32
+ encoding: 'utf-8',
33
+ stdio: ['ignore', 'pipe', 'ignore'],
34
+ }).split(/\r?\n/)[0].trim();
35
+ return out || null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * uv > pipx > python3 > python. `kind: null` significa "sem Python": o step do
43
+ * setup-infra registra e segue, e o framework continua funcionando por grep
44
+ * (ADR-002).
45
+ *
46
+ * @param {{_which?: (bin: string) => string|null}} [deps]
47
+ * @returns {{kind: 'uv'|'pipx'|'python3'|'python'|null, bin: string|null}}
48
+ */
49
+ export function detectInstaller(deps = {}) {
50
+ const which = deps._which ?? whichSync;
51
+ for (const kind of PREFERENCE) {
52
+ let bin = null;
53
+ try {
54
+ bin = which(kind);
55
+ } catch {
56
+ bin = null;
57
+ }
58
+ if (bin) return { kind, bin };
59
+ }
60
+ return { kind: null, bin: null };
61
+ }
62
+
63
+ /**
64
+ * @param {'uv'|'pipx'|'python3'|'python'} kind
65
+ * @param {{withSql: boolean}} opts sem o extra `[sql]` os arquivos .sql são
66
+ * ignorados em silêncio (92 no ProspectPRO) — só um aviso no meio do output
67
+ * denuncia
68
+ * @returns {{cmd: string, args: string[]}}
69
+ */
70
+ export function installArgs(kind, { withSql }) {
71
+ const spec = `${GRAPHIFY_PACKAGE}${withSql ? '[sql]' : ''}==${GRAPHIFY_VERSION}`;
72
+ switch (kind) {
73
+ case 'uv':
74
+ return { cmd: 'uv', args: ['tool', 'install', spec] };
75
+ case 'pipx':
76
+ return { cmd: 'pipx', args: ['install', spec] };
77
+ case 'python3':
78
+ case 'python':
79
+ return { cmd: kind, args: ['-m', 'pip', 'install', '--user', spec] };
80
+ default:
81
+ throw new Error(`installArgs: gerenciador desconhecido "${kind}"`);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * @param {{_run?: () => string}} [deps]
87
+ * @returns {string|null} versão instalada, ou null se o binário não responde
88
+ */
89
+ export function detectGraphifyVersion(deps = {}) {
90
+ const run = deps._run ?? (() => execFileSync(GRAPHIFY_BIN, ['--version'], {
91
+ encoding: 'utf-8',
92
+ stdio: ['ignore', 'pipe', 'ignore'],
93
+ }));
94
+ try {
95
+ const m = /(\d+\.\d+\.\d+)/.exec(run());
96
+ return m ? m[1] : null;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Geração stack-aware do .graphifyignore (ADR-004).
3
+ *
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.
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.
12
+ *
13
+ * Em Next.js o problema é outro: metade dos nós vinha de JSON de configuração,
14
+ * e os god nodes viravam `dependencies` e `compilerOptions`.
15
+ *
16
+ * @module graphifyignore
17
+ */
18
+ import { writeFileSync, existsSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+
21
+ export const ALWAYS_IGNORED = [
22
+ '.git/', '.morph/', 'artifacts/', 'worktrees/', '.worktrees/',
23
+ 'coverage/', 'graphify-out/', 'dist/', 'out/',
24
+ ];
25
+
26
+ const DOTNET_IGNORED = [
27
+ '**/*.Tests/', '**/*Tests/', '**/*.Test/', 'bin/', 'obj/', 'TestResults/',
28
+ ];
29
+
30
+ const WEB_IGNORED = [
31
+ 'node_modules/', '.next/', '.turbo/', 'build/',
32
+ // Só os JSON da RAIZ, para não esconder JSON que seja dado do projeto.
33
+ '/package.json', '/package-lock.json', '/pnpm-lock.yaml', '/tsconfig.json',
34
+ '/next.config.js', '/next.config.mjs', '/tailwind.config.js', '/tailwind.config.ts',
35
+ ];
36
+
37
+ const DOTNET_TAGS = new Set(['dotnet', 'aspnet']);
38
+ const WEB_TAGS = new Set([
39
+ 'nextjs', 'next', 'next.js', 'react', 'vue', 'nodejs', 'nodejs-cli', 'node-cli',
40
+ ]);
41
+
42
+ /**
43
+ * @param {string[]} [projectTags] saída de `getProjectTags(config)` — stack
44
+ * splitado por `-`/`+` mais as integrations
45
+ * @returns {string} conteúdo completo do .graphifyignore
46
+ */
47
+ export function buildGraphifyignore(projectTags = []) {
48
+ const tags = new Set(projectTags);
49
+ const hasDotnet = [...tags].some((t) => DOTNET_TAGS.has(t));
50
+ const hasWeb = [...tags].some((t) => WEB_TAGS.has(t));
51
+
52
+ const sections = [
53
+ ['# Gerado por morph-spec (ADR-004). Editável: o CLI não sobrescreve.', ''],
54
+ ['# Sempre', ...ALWAYS_IGNORED],
55
+ ];
56
+ if (hasDotnet) {
57
+ sections.push(['', '# .NET — testes em qualquer nível (tests/ genérico não serve)', ...DOTNET_IGNORED]);
58
+ }
59
+ if (hasWeb) {
60
+ sections.push(['', '# Web — dependências e JSON de configuração de raiz', ...WEB_IGNORED]);
61
+ }
62
+
63
+ const seen = new Set();
64
+ const lines = [];
65
+ for (const line of sections.flat()) {
66
+ const isPattern = line && !line.startsWith('#');
67
+ if (isPattern && seen.has(line)) continue;
68
+ if (isPattern) seen.add(line);
69
+ lines.push(line);
70
+ }
71
+ return `${lines.join('\n')}\n`;
72
+ }
73
+
74
+ /**
75
+ * Escreve o .graphifyignore. Arquivo já existente é do usuário: não é tocado.
76
+ *
77
+ * @param {string} projectPath
78
+ * @param {string[]} [projectTags]
79
+ * @returns {{written: boolean, path: string}}
80
+ */
81
+ export function writeGraphifyignore(projectPath, projectTags = []) {
82
+ const path = join(projectPath, '.graphifyignore');
83
+ if (existsSync(path)) return { written: false, path };
84
+ writeFileSync(path, buildGraphifyignore(projectTags), 'utf-8');
85
+ return { written: true, path };
86
+ }
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Saneamento determinístico do grafo, sem LLM (ADR-003 + errata D2/D3).
3
+ *
4
+ * O graphify 0.9.50 não implementa escopo hierárquico de namespace em C#
5
+ * (graphify/extractors/csharp.py:322-335): um filho que usa um tipo do
6
+ * namespace pai sem `using` explícito vira um STUB por arquivo, e o blast
7
+ * radius do nó real devolve zero consumidores onde existem dois. É falso
8
+ * negativo silencioso — o pior modo de falha possível antes de um hotfix.
9
+ *
10
+ * Três categorias de stub, medidas no MORPH_GHLBrain em 2026-08-27:
11
+ *
12
+ * label com 1 nó real (60 labels, 226 arestas) => coalesce no real
13
+ * label com 0 nós reais (126 labels, 2.479 arestas) => externo (BCL/NuGet)
14
+ * label com 2+ nós reais (22 labels, 93 arestas) => AMBÍGUO
15
+ *
16
+ * O terceiro grupo são símbolos REAIS do projeto (CatalogSchema, CatalogItem,
17
+ * Result, Error, KillSwitch — declarados em CatalogContracts.cs e vizinhos),
18
+ * não BCL. Filtrar "o que sobrou depois de coalescer" os descartaria em
19
+ * silêncio, reintroduzindo o falso negativo que este módulo existe para matar.
20
+ * Por isso o critério de externo é `real.length === 0`, nunca "remanescente".
21
+ *
22
+ * @module graph-sanitize
23
+ */
24
+
25
+ /** Arestas que contam como dependência para blast radius. */
26
+ export const USE_RELATIONS = new Set([
27
+ 'calls', 'references', 'uses', 'imports', 'imports_from',
28
+ 'inherits', 'implements', 'extends', 'dispatches_to', 'indirect_call',
29
+ ]);
30
+
31
+ /**
32
+ * Herança não é uso. No ProspectPRO, `IHandler` aparece em 193 arquivos no
33
+ * grep e tem 2 usos reais: é implementada por 98.
34
+ */
35
+ export const INHERITANCE_RELATIONS = new Set(['inherits', 'implements', 'extends']);
36
+
37
+ const LANG_BY_EXT = {
38
+ cs: 'dotnet', csproj: 'dotnet', razor: 'dotnet', cshtml: 'dotnet',
39
+ ts: 'web', tsx: 'web', js: 'web', jsx: 'web', mjs: 'web', cjs: 'web',
40
+ sql: 'sql', py: 'python', go: 'go', rs: 'rust', java: 'jvm', kt: 'jvm',
41
+ };
42
+
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/`:
46
+ * - o segmento inteiro é `test`/`tests` (qualquer caixa)
47
+ * - termina em `.Test`/`.Tests` — a convenção .NET (ProspectPRO.Tests/)
48
+ * - termina em `Test`/`Tests` com T maiúsculo — PascalCase (AlvoTests/)
49
+ */
50
+ const TEST_PATH = /(^|\/)(?:[Tt]ests?|[^/]*\.[Tt]ests?|[^/]*[a-z0-9_]Tests?)\//;
51
+
52
+ /**
53
+ * @param {string} sourceFile
54
+ * @returns {string} família de linguagem do arquivo, ou '' quando desconhecida
55
+ */
56
+ export function languageOf(sourceFile) {
57
+ const m = /\.([a-z0-9]+)$/i.exec(sourceFile || '');
58
+ if (!m) return '';
59
+ return LANG_BY_EXT[m[1].toLowerCase()] ?? m[1].toLowerCase();
60
+ }
61
+
62
+ const normOf = (node) => node.norm_label ?? String(node.label ?? '').toLowerCase();
63
+
64
+ /**
65
+ * Label sem os parênteses de chamada. O graphify rotula função como `tchau()`
66
+ * em JS/TS, mas quem consulta digita `tchau` — e em C# o mesmo símbolo aparece
67
+ * sem parênteses. Sem esta normalização, `graph affected tchau` devolve
68
+ * not-found num grafo que tem o símbolo.
69
+ */
70
+ const baseOf = (norm) => norm.replace(/\(\s*\)$/, '');
71
+
72
+ /**
73
+ * @typedef {object} GraphIndex
74
+ * @property {Map<string, object>} byId
75
+ * @property {Map<string, {real: object[], stub: object[]}>} byLabel
76
+ * @property {Map<string, object[]>} byBaseLabel label sem `()` -> nós reais
77
+ * @property {Map<string, string>} coalesce stubId -> id do nó real
78
+ */
79
+
80
+ /**
81
+ * Índice por id e por label + mapa de coalescência. Uma passada pelos nós.
82
+ *
83
+ * @param {{nodes: object[], links: object[]}} graph
84
+ * @returns {GraphIndex}
85
+ */
86
+ export function buildIndex(graph) {
87
+ const byId = new Map();
88
+ const byLabel = new Map();
89
+
90
+ for (const node of graph.nodes ?? []) {
91
+ byId.set(node.id, node);
92
+ const key = normOf(node);
93
+ let entry = byLabel.get(key);
94
+ if (!entry) {
95
+ entry = { real: [], stub: [] };
96
+ byLabel.set(key, entry);
97
+ }
98
+ // Nó não resolvido tem source_file: "" (string vazia), não campo ausente.
99
+ (node.source_file ? entry.real : entry.stub).push(node);
100
+ }
101
+
102
+ const coalesce = new Map();
103
+ const byBaseLabel = new Map();
104
+ for (const [key, entry] of byLabel) {
105
+ // 0 real => externo; 2+ reais => ambíguo. Só o caso de 1 é seguro.
106
+ if (entry.real.length === 1) {
107
+ for (const stub of entry.stub) coalesce.set(stub.id, entry.real[0].id);
108
+ }
109
+ if (entry.real.length === 0) continue;
110
+ const base = baseOf(key);
111
+ if (base === key) continue; // já consultável pelo caminho exato
112
+ const bucket = byBaseLabel.get(base);
113
+ if (bucket) bucket.push(...entry.real);
114
+ else byBaseLabel.set(base, [...entry.real]);
115
+ }
116
+
117
+ return { byId, byLabel, byBaseLabel, coalesce };
118
+ }
119
+
120
+ /**
121
+ * @param {GraphIndex} index
122
+ * @param {string} normLabel label já normalizado (minúsculo)
123
+ * @returns {'resolved'|'coalescible'|'external'|'ambiguous'|'unknown'}
124
+ */
125
+ export function classifyLabel(index, normLabel) {
126
+ const entry = index.byLabel.get(normLabel);
127
+ if (!entry) return 'unknown';
128
+ if (entry.real.length === 0) return 'external';
129
+ if (entry.real.length >= 2) return 'ambiguous';
130
+ return entry.stub.length > 0 ? 'coalescible' : 'resolved';
131
+ }
132
+
133
+ /**
134
+ * Para onde uma aresta REALMENTE aponta depois do saneamento.
135
+ *
136
+ * @param {GraphIndex} index
137
+ * @param {object} link
138
+ * @returns {{id: string|null, kind: 'direct'|'coalesced'|'language-disambiguated'|'external'|'ambiguous'|'unknown'}}
139
+ */
140
+ export function resolveEdgeTarget(index, link) {
141
+ const target = index.byId.get(link.target);
142
+ if (!target) return { id: null, kind: 'unknown' };
143
+ if (target.source_file) return { id: target.id, kind: 'direct' };
144
+
145
+ const coalesced = index.coalesce.get(target.id);
146
+ if (coalesced) return { id: coalesced, kind: 'coalesced' };
147
+
148
+ const entry = index.byLabel.get(normOf(target));
149
+ if (!entry || entry.real.length === 0) return { id: null, kind: 'external' };
150
+
151
+ // D3: uma aresta nascida num .cs só pode apontar para o candidato .cs.
152
+ // Recupera 54% dos casos ambíguos sem LLM e sem heurística de grau — que é
153
+ // a alternativa (b) rejeitada na ADR-003 por errar justamente nas interfaces
154
+ // muito referenciadas.
155
+ const originLang = languageOf(link.source_file);
156
+ if (originLang) {
157
+ const matches = entry.real.filter((n) => languageOf(n.source_file) === originLang);
158
+ if (matches.length === 1) return { id: matches[0].id, kind: 'language-disambiguated' };
159
+ }
160
+
161
+ return { id: null, kind: 'ambiguous' };
162
+ }
163
+
164
+ /**
165
+ * label -> id. Empate real devolve as opções em vez de escolher (ADR-003).
166
+ *
167
+ * @param {GraphIndex} index
168
+ * @param {string} label
169
+ * @returns {{ok: true, id: string, node: object}|{ok: false, reason: 'not-found'|'ambiguous', options: Array<{id: string, source_file: string}>}}
170
+ */
171
+ export function resolveLabel(index, label) {
172
+ const key = String(label).toLowerCase();
173
+ const exact = index.byLabel.get(key);
174
+
175
+ // Match exato primeiro; só depois o label sem `()`, para que quem digitar
176
+ // `tchau()` e quem digitar `tchau` cheguem no mesmo nó.
177
+ let candidates = exact && exact.real.length > 0 ? exact.real : null;
178
+ if (!candidates) candidates = index.byBaseLabel?.get(baseOf(key)) ?? null;
179
+
180
+ if (!candidates || candidates.length === 0) {
181
+ return { ok: false, reason: 'not-found', options: [] };
182
+ }
183
+ if (candidates.length === 1) {
184
+ return { ok: true, id: candidates[0].id, node: candidates[0] };
185
+ }
186
+ return {
187
+ ok: false,
188
+ reason: 'ambiguous',
189
+ options: candidates.map((n) => ({ id: n.id, source_file: n.source_file })),
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Blast radius saneado: quem depende de `id`.
195
+ *
196
+ * O valor medido não é recall — contra o grep, o grafo achou ZERO arquivos a
197
+ * mais. É precisão: 58% menos ruído em 12 símbolos do ProspectPRO (1.092
198
+ * arquivos no grep, 266 usos reais) e a classificação da relação.
199
+ *
200
+ * @param {GraphIndex} index
201
+ * @param {{links: object[]}} graph
202
+ * @param {string} id
203
+ * @param {{prod?: boolean, excludeInheritance?: boolean}} [opts]
204
+ * @returns {Array<{id: string, label: string, source_file: string, source_location: string, relation: string}>}
205
+ */
206
+ export function affected(index, graph, id, opts = {}) {
207
+ const seen = new Set();
208
+ const out = [];
209
+
210
+ for (const link of graph.links ?? []) {
211
+ if (!USE_RELATIONS.has(link.relation)) continue;
212
+ if (opts.excludeInheritance && INHERITANCE_RELATIONS.has(link.relation)) continue;
213
+ if (resolveEdgeTarget(index, link).id !== id) continue;
214
+
215
+ const source = index.byId.get(link.source);
216
+ if (!source || !source.source_file) continue;
217
+ if (opts.prod && TEST_PATH.test(source.source_file)) continue;
218
+
219
+ const key = `${source.id}|${link.relation}`;
220
+ if (seen.has(key)) continue;
221
+ seen.add(key);
222
+
223
+ out.push({
224
+ id: source.id,
225
+ label: source.label,
226
+ source_file: source.source_file,
227
+ source_location: source.source_location ?? '',
228
+ relation: link.relation,
229
+ });
230
+ }
231
+
232
+ return out;
233
+ }
234
+
235
+ /**
236
+ * 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.
238
+ *
239
+ * @param {GraphIndex} index
240
+ * @param {{links: object[]}} graph
241
+ * @param {string} id
242
+ */
243
+ export function neighborhood(index, graph, id) {
244
+ const incoming = [];
245
+ const outgoing = [];
246
+
247
+ for (const link of graph.links ?? []) {
248
+ const resolved = resolveEdgeTarget(index, link);
249
+
250
+ if (resolved.id === id) {
251
+ const source = index.byId.get(link.source);
252
+ if (source) {
253
+ incoming.push({
254
+ id: source.id,
255
+ label: source.label,
256
+ relation: link.relation,
257
+ source_file: link.source_file || source.source_file,
258
+ source_location: link.source_location || source.source_location || '',
259
+ via: resolved.kind,
260
+ });
261
+ }
262
+ }
263
+
264
+ if (link.source === id && resolved.id) {
265
+ const target = index.byId.get(resolved.id);
266
+ if (target) {
267
+ outgoing.push({
268
+ id: target.id,
269
+ label: target.label,
270
+ relation: link.relation,
271
+ source_file: link.source_file || target.source_file,
272
+ source_location: link.source_location || '',
273
+ via: resolved.kind,
274
+ });
275
+ }
276
+ }
277
+ }
278
+
279
+ return { node: index.byId.get(id) ?? null, incoming, outgoing };
280
+ }