@polymorphism-tech/morph-spec 8.29.0 → 8.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +11 -1
- package/README.md +13 -2
- package/bin/morph-spec.js +67 -1
- package/bin/validate.js +379 -379
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +4 -1
- package/framework/MORPH.md +4 -3
- package/framework/agents.json +6 -6
- package/framework/commands/morph-status.md +1 -1
- package/framework/docs/CLI.md +68 -45
- package/framework/evals/rubrics/code-quality.md +17 -0
- package/framework/evals/rubrics/condensed.md +3 -1
- package/framework/hooks/claude-code/statusline.sh +11 -11
- package/framework/hooks/git/commit-msg/conventional-commits.sh +33 -33
- package/framework/hooks/git/pre-commit/agents.sh +25 -25
- package/framework/hooks/git/pre-commit/orchestrator.sh +64 -64
- package/framework/hooks/git/pre-commit/specs.sh +50 -50
- package/framework/hooks/git/pre-push/run-tests.sh +44 -44
- package/framework/hooks/shared/phase-utils.js +2 -0
- package/framework/hooks/shared/stdin-reader.js +26 -26
- package/framework/mcp/registry.json +39 -4
- package/framework/rubrics/backend.json +2 -2
- package/framework/rubrics/default.json +2 -2
- package/framework/rubrics/frontend.json +2 -2
- package/framework/schemas/tasks.schema.json +25 -0
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +14 -4
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
- package/framework/skills/core/morph-implement/SKILL.md +12 -2
- package/framework/skills/core/morph-plan/SKILL.md +45 -3
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +12 -4
- package/framework/skills/core/morph-stack-scan/SKILL.md +13 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/ai-agents/agent-session.md +160 -160
- package/framework/standards/ai-agents/batching.md +193 -193
- package/framework/standards/ai-agents/context-providers.md +188 -188
- package/framework/standards/ai-agents/mcp-server.md +250 -250
- package/framework/standards/ai-agents/mcp-tools.md +197 -197
- package/framework/standards/ai-agents/middleware-patterns.md +207 -207
- package/framework/standards/ai-agents/modalities-image-gen.md +173 -173
- package/framework/standards/ai-agents/modalities-speech-to-text.md +171 -171
- package/framework/standards/ai-agents/modalities-text-to-speech.md +181 -181
- package/framework/standards/ai-agents/modalities-vision.md +201 -201
- package/framework/standards/ai-agents/multi-agent-patterns.md +155 -155
- package/framework/standards/ai-agents/production.md +231 -231
- package/framework/standards/ai-agents/prompts-in-db.md +214 -214
- package/framework/standards/ai-agents/providers/model-registry.md +158 -158
- package/framework/standards/ai-agents/service-tier-flex.md +135 -135
- package/framework/standards/ai-agents/structured-output.md +249 -249
- package/framework/standards/ai-agents/sweet-spot.md +109 -109
- package/framework/templates/.idea/morph-templates.xml +92 -0
- package/framework/templates/.vscode/morph-templates.code-snippets +186 -0
- package/framework/templates/IDE-SNIPPETS.md +262 -262
- package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
- package/framework/templates/code/dotnet/ai-agents/AgentBootstrap.cs.template +39 -39
- package/framework/templates/code/dotnet/ai-agents/EmbeddingService.cs.template +68 -68
- package/framework/templates/code/dotnet/ai-agents/ModelRegistry.cs.template +117 -117
- package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +95 -95
- package/framework/templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template +61 -61
- package/framework/templates/code/dotnet/ai-agents/VectorSearchTool.cs.template +107 -107
- package/framework/templates/code/dotnet/ai-agents/model-registry.json.template +58 -58
- package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +281 -281
- package/framework/templates/code/dotnet/database/migration.cs +83 -83
- package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +21 -21
- package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
- package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
- package/framework/templates/code/dotnet/feature/validator.cs.hbs +15 -15
- package/framework/templates/code/sql/ai-agents/agent-prompts-migration.sql.template +58 -58
- package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
- package/framework/templates/code/typescript/contracts.ts +167 -167
- package/framework/templates/context/ARCHITECTURE.md +20 -0
- package/framework/templates/docs/clarifications.md +253 -253
- package/framework/templates/docs/schema-analysis.md +119 -119
- package/framework/templates/docs/ui-components.md +52 -52
- package/framework/templates/docs/ui-design-system.md +76 -76
- package/framework/templates/docs/ui-flows.md +167 -167
- package/framework/templates/docs/ui-mockups.md +98 -98
- package/framework/templates/feature/decisions.md +122 -122
- package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +43 -43
- package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
- package/framework/templates/frontend/nextjs/env.mjs.hbs +32 -32
- package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +56 -56
- package/framework/templates/frontend/nextjs/page.tsx.hbs +22 -22
- package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
- package/framework/templates/frontend/nextjs/use-feature.ts.hbs +54 -54
- package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
- package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
- package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
- package/framework/templates/infrastructure/github/README.md +56 -56
- package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
- package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
- package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
- package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
- package/framework/templates/integrations/asaas-client.cs +387 -387
- package/framework/templates/integrations/asaas-webhook.cs +351 -351
- package/framework/templates/meta-prompts/fusion/fusion-agent.md +76 -76
- package/framework/templates/meta-prompts/fusion/fusion-aggregator.md +100 -100
- package/framework/templates/meta-prompts/validators/pre-commit-validator.md +95 -95
- package/framework/templates/project/validate.js +120 -120
- package/framework/templates/state.template.json +17 -17
- package/framework/templates/ui/design-system.css +226 -226
- package/package.json +1 -1
- package/src/commands/dashboard/dashboard.js +53 -53
- package/src/commands/graph/affected.js +89 -0
- package/src/commands/graph/build.js +94 -0
- package/src/commands/graph/explain.js +96 -0
- package/src/commands/graph/status.js +93 -0
- package/src/commands/index.js +13 -13
- package/src/commands/project/branch.js +170 -0
- package/src/commands/project/checkpoint.js +209 -209
- package/src/commands/project/doctor.js +148 -9
- package/src/commands/project/fleet.js +10 -3
- package/src/commands/project/worktree.js +73 -50
- package/src/commands/state/finish.js +41 -26
- package/src/commands/state/list.js +79 -6
- package/src/core/paths/output-schema.js +7 -0
- package/src/core/state/lifecycle-audit.js +127 -0
- package/src/core/state/phase-state-machine.js +197 -197
- package/src/core/templates/template-renderer.js +477 -477
- package/src/dashboard/app/components/DetailPanel.jsx +11 -11
- package/src/dashboard/app/components/FlowGraph.jsx +17 -17
- package/src/dashboard/app/components/SearchFilter.jsx +22 -22
- package/src/dashboard/app/hooks/useDetailPanel.js +15 -15
- package/src/dashboard/app/main.jsx +13 -13
- package/src/dashboard/app/pages/AgentMap.jsx +370 -370
- package/src/dashboard/app/pages/HookInspector.jsx +418 -418
- package/src/dashboard/app/pages/PhasePipeline.jsx +408 -408
- package/src/dashboard/app/pages/RulesMap.jsx +273 -273
- package/src/dashboard/app/pages/SkillsLibrary.jsx +366 -366
- package/src/dashboard/app/pages/Standards.jsx +332 -332
- package/src/dashboard/app/styles/index.css +47 -47
- package/src/dashboard/index.html +12 -12
- package/src/dashboard/package.json +24 -24
- package/src/dashboard/scanner/phases-scanner.js +15 -15
- package/src/dashboard/scanner/rubrics-scanner.js +12 -12
- package/src/dashboard/scanner/rules-scanner.js +37 -37
- package/src/dashboard/scanner/skills-scanner.js +43 -43
- package/src/dashboard/scanner/squads-scanner.js +13 -13
- package/src/dashboard/vite-plugin-morph-data.js +17 -17
- package/src/dashboard/vite.config.js +24 -24
- package/src/lib/detectors/design-system-detector.js +188 -188
- package/src/lib/detectors/index.js +217 -217
- package/src/lib/generators/architecture-scanner.js +45 -4
- package/src/lib/generators/design-system-generator.js +508 -508
- package/src/lib/generators/settings-generator.js +209 -209
- package/src/lib/git/branch-classifier.js +177 -0
- package/src/lib/git/gh.js +30 -0
- package/src/lib/git/pending-prs.js +89 -0
- package/src/lib/git/pr-index.js +70 -0
- package/src/lib/graph/god-nodes.js +75 -0
- package/src/lib/graph/graph-health.js +61 -0
- package/src/lib/graph/graph-loader.js +86 -0
- package/src/lib/graph/graphify-runtime.js +100 -0
- package/src/lib/graph/graphifyignore.js +86 -0
- package/src/lib/graph/sanitize.js +280 -0
- package/src/lib/hooks/hook-executor.js +167 -167
- package/src/lib/installers/mcp-installer.js +719 -719
- package/src/lib/tasks/task-classifier.js +30 -30
- package/src/lib/tasks/task-parser.js +209 -209
- package/src/lib/tasks/tasks-json-parser.js +26 -0
- package/src/lib/validators/checkpoint-persistence.js +39 -39
- package/src/lib/validators/css/css-validator.js +340 -340
- package/src/lib/validators/reuse/index.js +10 -0
- package/src/lib/validators/reuse/reuse-validator.js +324 -0
- package/src/lib/validators/reuse/symbol-scanner.js +144 -0
- package/src/lib/validators/shared/emit-validator-dispatch.js +14 -14
- package/src/lib/validators/shared/issue-counter.js +14 -14
- package/src/lib/validators/validation-runner.js +40 -1
- package/src/lib/worktree/fleet-scanner.js +51 -21
- package/src/lib/worktree/ignored-triage.js +197 -0
- package/src/lib/worktree/worktree-audit.js +200 -35
- package/src/scripts/setup-infra.js +60 -1
- package/src/utils/agent-id-mapper.js +11 -11
- package/src/utils/claude-settings-manager.js +300 -300
- package/src/utils/file-copier.js +40 -0
- package/src/utils/skills-installer.js +187 -187
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pending-prs.js — as features que existem SÓ como PR aberto.
|
|
3
|
+
*
|
|
4
|
+
* `finish --pr` deixa o root primário sem vestígio da feature: o
|
|
5
|
+
* `shedRootFeatureScope` apaga `.morph/features/{f}` e o archive foi commitado
|
|
6
|
+
* na branch `morph/{f}`, chegando na default apenas no merge. Entre abrir e
|
|
7
|
+
* mergear, portanto, nenhuma superfície local sabe que aquela branch tem dono —
|
|
8
|
+
* foi assim que uma branch com PR #51 aberto quase foi varrida.
|
|
9
|
+
*
|
|
10
|
+
* Este módulo devolve o dono. Não há estado persistido a consultar, e é de
|
|
11
|
+
* propósito: o PR aberto no GitHub É o registro, a branch é sempre
|
|
12
|
+
* `morph/{feature}`, e o `pr-index.js` já é o leitor único desse fato. Lembrar
|
|
13
|
+
* o número numa segunda cópia só criaria como divergir dele.
|
|
14
|
+
*
|
|
15
|
+
* `null` (do índice ou daqui) significa DESCONHECIDO e jamais "não há
|
|
16
|
+
* pendência" — a mesma regra que torna seguros os caminhos destrutivos que
|
|
17
|
+
* leem o índice.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync } from 'fs';
|
|
21
|
+
import { join } from 'path';
|
|
22
|
+
import { buildPrIndex } from './pr-index.js';
|
|
23
|
+
|
|
24
|
+
/** As branches que o framework cria por feature. */
|
|
25
|
+
export const MORPH_BRANCH_RE = /^morph\/(.+)$/;
|
|
26
|
+
|
|
27
|
+
const DAY_MS = 86400000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* O PR da branch canônica de uma feature.
|
|
31
|
+
*
|
|
32
|
+
* @param {Map<string,object>|null} index - de buildPrIndex
|
|
33
|
+
* @param {string} feature
|
|
34
|
+
* @returns {{number:number,state:string,mergedAt:string|null,url:string|null,createdAt:string|null}|null}
|
|
35
|
+
* null = desconhecido OU sem PR; quem precisa distinguir os dois olha o índice.
|
|
36
|
+
*/
|
|
37
|
+
export function prForFeature(index, feature) {
|
|
38
|
+
if (!index) return null;
|
|
39
|
+
return index.get(`morph/${feature}`) || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A feature tem pasta (ativa ou arquivada) nesta árvore? */
|
|
43
|
+
function hasLocalFolder(root, feature) {
|
|
44
|
+
return existsSync(join(root, '.morph', 'features', feature))
|
|
45
|
+
|| existsSync(join(root, '.morph', 'archive', feature));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* As features aguardando merge: PR OPEN numa branch `morph/*` cujo slug não
|
|
50
|
+
* tem pasta nesta árvore.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} root - a raiz a inspecionar (e de onde o `gh` roda)
|
|
53
|
+
* @param {{ index?: Map<string,object>|null, now?: Date }} [opts]
|
|
54
|
+
* `index` injeta um índice já construído (o `list`/`fleet` constroem um por
|
|
55
|
+
* processo e reaproveitam; os testes passam um sintético). Ausente = constrói.
|
|
56
|
+
* @returns {Array<{feature:string,branch:string,number:number,state:string,url:string|null,createdAt:string|null,ageDays:number|null}>|null}
|
|
57
|
+
* null = DESCONHECIDO. Nunca lista vazia por falta de informação.
|
|
58
|
+
*/
|
|
59
|
+
export function pendingPrFeatures(root, opts = {}) {
|
|
60
|
+
const index = opts.index !== undefined ? opts.index : buildPrIndex(root);
|
|
61
|
+
if (!index) return null;
|
|
62
|
+
|
|
63
|
+
const now = opts.now instanceof Date ? opts.now : new Date();
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const [branch, pr] of index) {
|
|
66
|
+
const m = branch.match(MORPH_BRANCH_RE);
|
|
67
|
+
if (!m) continue;
|
|
68
|
+
if (pr.state !== 'OPEN') continue;
|
|
69
|
+
const feature = m[1];
|
|
70
|
+
if (hasLocalFolder(root, feature)) continue;
|
|
71
|
+
|
|
72
|
+
let ageDays = null;
|
|
73
|
+
if (pr.createdAt) {
|
|
74
|
+
const t = Date.parse(pr.createdAt);
|
|
75
|
+
if (!Number.isNaN(t)) ageDays = Math.floor((now.getTime() - t) / DAY_MS);
|
|
76
|
+
}
|
|
77
|
+
out.push({
|
|
78
|
+
feature,
|
|
79
|
+
branch,
|
|
80
|
+
number: pr.number,
|
|
81
|
+
state: pr.state,
|
|
82
|
+
url: pr.url ?? null,
|
|
83
|
+
createdAt: pr.createdAt ?? null,
|
|
84
|
+
ageDays,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
out.sort((a, b) => a.feature.localeCompare(b.feature));
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pr-index.js — one `gh pr list` per process, shared by every caller that needs
|
|
3
|
+
* to answer "does a branch have a PR, and in what state".
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `fleet-scanner.js` because three independent leaders now ask
|
|
6
|
+
* the SAME question and must get the SAME answer:
|
|
7
|
+
*
|
|
8
|
+
* • `morph-spec fleet` — the PR column
|
|
9
|
+
* • `morph-spec branch prune` — a branch with an OPEN PR is never deleted,
|
|
10
|
+
* and a MERGED one is deletable even when git
|
|
11
|
+
* cannot see the merge (squash-merge)
|
|
12
|
+
* • `morph-spec doctor --worktrees` — a worktree whose branch carries an open
|
|
13
|
+
* PR must NOT be offered up for removal
|
|
14
|
+
*
|
|
15
|
+
* Fail-open by construction: no `gh`, not authenticated, not GitHub, or a
|
|
16
|
+
* timeout all yield `null`. `null` means "unknown", NEVER "no PR" — every
|
|
17
|
+
* caller must treat the two differently, because the destructive paths
|
|
18
|
+
* (prune, remove) are only safe when the answer is a positive fact.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { execFileSync } from 'child_process';
|
|
22
|
+
import { resolveGh } from './gh.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build the branch → PR index for a repository.
|
|
26
|
+
*
|
|
27
|
+
* Gated on the origin URL actually pointing at github.com: `gh` against any
|
|
28
|
+
* other host is a guaranteed error, and paying its start-up cost to learn that
|
|
29
|
+
* on every call is pure latency.
|
|
30
|
+
*
|
|
31
|
+
* `--state all` on purpose: a MERGED PR is exactly the evidence a squash-merge
|
|
32
|
+
* erases from git history, and it is the whole reason `branch prune` exists.
|
|
33
|
+
* First wins — `gh` lists newest-first, so an OPEN PR shadows an older closed
|
|
34
|
+
* one on the same branch.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} root - repository root to run `gh` from
|
|
37
|
+
* @param {{ limit?: number, timeout?: number }} [opts]
|
|
38
|
+
* @returns {Map<string,{number:number,state:string,mergedAt:string|null,url:string|null,createdAt:string|null}>|null}
|
|
39
|
+
* null = DESCONHECIDO (sem gh / não é GitHub / falhou), não "sem PRs"
|
|
40
|
+
*/
|
|
41
|
+
export function buildPrIndex(root, opts = {}) {
|
|
42
|
+
const limit = String(opts.limit ?? 200);
|
|
43
|
+
const timeout = opts.timeout ?? 8000;
|
|
44
|
+
try {
|
|
45
|
+
const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: root, stdio: 'pipe' }).toString();
|
|
46
|
+
if (!/github\.com/i.test(url)) return null;
|
|
47
|
+
const { bin, prefix } = resolveGh();
|
|
48
|
+
const out = execFileSync(
|
|
49
|
+
bin,
|
|
50
|
+
[...prefix, 'pr', 'list', '--state', 'all', '--limit', limit,
|
|
51
|
+
'--json', 'number,state,headRefName,mergedAt,url,createdAt'],
|
|
52
|
+
{ cwd: root, stdio: 'pipe', timeout },
|
|
53
|
+
).toString();
|
|
54
|
+
const map = new Map();
|
|
55
|
+
for (const pr of JSON.parse(out)) {
|
|
56
|
+
if (pr?.headRefName && !map.has(pr.headRefName)) {
|
|
57
|
+
map.set(pr.headRefName, {
|
|
58
|
+
number: pr.number,
|
|
59
|
+
state: pr.state,
|
|
60
|
+
mergedAt: pr.mergedAt ?? null,
|
|
61
|
+
url: pr.url ?? null,
|
|
62
|
+
createdAt: pr.createdAt ?? null,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return map;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* God nodes e comunidades para o contexto de arquitetura.
|
|
3
|
+
*
|
|
4
|
+
* O inventário do architecture-scanner diz o que EXISTE; os god nodes dizem o
|
|
5
|
+
* que é CENTRAL. No ProspectPRO, seis linhas (`Result` 223, `Abstractions` 217,
|
|
6
|
+
* `Repository` 156, `cn()` 141) orientam melhor que 665 de inventário — sem
|
|
7
|
+
* substituí-lo, porque exaustividade é um valor que o grafo não tem.
|
|
8
|
+
*
|
|
9
|
+
* Só entram nós COM source_file: um god node sem arquivo não orienta ninguém.
|
|
10
|
+
*
|
|
11
|
+
* @module god-nodes
|
|
12
|
+
*/
|
|
13
|
+
import { loadGraph } from './graph-loader.js';
|
|
14
|
+
import { buildIndex, resolveEdgeTarget } from './sanitize.js';
|
|
15
|
+
|
|
16
|
+
/** Sem `graphify label`, as comunidades se chamam Community_N — ruído puro. */
|
|
17
|
+
const PLACEHOLDER_LABEL = /^Community[_ ]?\d+$/i;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} projectPath
|
|
21
|
+
* @param {{top?: number, topCommunities?: number}} [opts]
|
|
22
|
+
* @returns {Promise<{available: boolean, godNodes: Array<{label: string, source_file: string, degree: number}>, communities: Array<{id: number, label: string, size: number, labeled: boolean}>}>}
|
|
23
|
+
*/
|
|
24
|
+
export async function scanGraph(projectPath, opts = {}) {
|
|
25
|
+
const top = opts.top ?? 10;
|
|
26
|
+
const topCommunities = opts.topCommunities ?? 10;
|
|
27
|
+
|
|
28
|
+
const loaded = loadGraph(projectPath);
|
|
29
|
+
if (!loaded.ok) return { available: false, godNodes: [], communities: [] };
|
|
30
|
+
|
|
31
|
+
const { graph } = loaded;
|
|
32
|
+
const index = buildIndex(graph);
|
|
33
|
+
|
|
34
|
+
// Grau depois do saneamento: a coalescência muda quem é central, porque as
|
|
35
|
+
// arestas que apontavam para stub passam a contar no nó real.
|
|
36
|
+
const degree = new Map();
|
|
37
|
+
const bump = (id) => {
|
|
38
|
+
if (id) degree.set(id, (degree.get(id) ?? 0) + 1);
|
|
39
|
+
};
|
|
40
|
+
for (const link of graph.links ?? []) {
|
|
41
|
+
bump(link.source);
|
|
42
|
+
bump(resolveEdgeTarget(index, link).id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const godNodes = [...degree.entries()]
|
|
46
|
+
.map(([id, d]) => ({ node: index.byId.get(id), degree: d }))
|
|
47
|
+
.filter(({ node }) => node?.source_file)
|
|
48
|
+
.sort((a, b) => b.degree - a.degree || a.node.label.localeCompare(b.node.label))
|
|
49
|
+
.slice(0, top)
|
|
50
|
+
.map(({ node, degree: d }) => ({
|
|
51
|
+
label: node.label,
|
|
52
|
+
source_file: node.source_file,
|
|
53
|
+
degree: d,
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
const sizes = new Map();
|
|
57
|
+
const labels = new Map();
|
|
58
|
+
for (const node of graph.nodes ?? []) {
|
|
59
|
+
if (node.community === undefined || node.community === null) continue;
|
|
60
|
+
sizes.set(node.community, (sizes.get(node.community) ?? 0) + 1);
|
|
61
|
+
if (node.community_label && !labels.has(node.community)) {
|
|
62
|
+
labels.set(node.community, node.community_label);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const communities = [...sizes.entries()]
|
|
67
|
+
.sort((a, b) => b[1] - a[1] || a[0] - b[0])
|
|
68
|
+
.slice(0, topCommunities)
|
|
69
|
+
.map(([id, size]) => {
|
|
70
|
+
const label = labels.get(id) ?? `Community_${id}`;
|
|
71
|
+
return { id, label, size, labeled: !PLACEHOLDER_LABEL.test(label) };
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return { available: true, godNodes, communities };
|
|
75
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auditoria do grafo para o `morph-spec doctor`.
|
|
3
|
+
*
|
|
4
|
+
* Nunca devolve estado de erro: o grafo é opcional (ADR-002) e não pode pintar
|
|
5
|
+
* de vermelho o doctor de um projeto que não o usa. O pior veredito é `warn`.
|
|
6
|
+
*
|
|
7
|
+
* @module graph-health
|
|
8
|
+
*/
|
|
9
|
+
import { loadGraph, getFreshness } from './graph-loader.js';
|
|
10
|
+
import { GRAPHIFY_VERSION, detectGraphifyVersion } from './graphify-runtime.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} projectPath
|
|
14
|
+
* @param {{_version?: () => string|null}} [deps]
|
|
15
|
+
* @returns {{status: 'ok'|'warn'|'absent', msg: string, details: string[]}}
|
|
16
|
+
*/
|
|
17
|
+
export function auditGraph(projectPath, deps = {}) {
|
|
18
|
+
const installed = (deps._version ?? detectGraphifyVersion)();
|
|
19
|
+
const loaded = loadGraph(projectPath);
|
|
20
|
+
|
|
21
|
+
if (!loaded.ok && loaded.reason === 'missing') {
|
|
22
|
+
return {
|
|
23
|
+
status: 'absent',
|
|
24
|
+
msg: 'opcional, não gerado — morph-spec graph build',
|
|
25
|
+
details: [],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (!loaded.ok) {
|
|
29
|
+
return {
|
|
30
|
+
status: 'warn',
|
|
31
|
+
msg: `graph.json ${loaded.reason} — regenere com: morph-spec graph build`,
|
|
32
|
+
details: [loaded.path],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { graph } = loaded;
|
|
37
|
+
const details = [`${graph.nodes.length} nós, ${graph.links.length} arestas`];
|
|
38
|
+
let status = 'ok';
|
|
39
|
+
|
|
40
|
+
const freshness = getFreshness(projectPath, graph);
|
|
41
|
+
if (freshness.state === 'stale') {
|
|
42
|
+
status = 'warn';
|
|
43
|
+
details.push(
|
|
44
|
+
`stale: construído em ${freshness.builtAtCommit.slice(0, 8)}, HEAD é ${freshness.headCommit.slice(0, 8)} — morph-spec graph refresh`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!installed) {
|
|
49
|
+
status = 'warn';
|
|
50
|
+
details.push(`graphify não está no PATH (pinado: ${GRAPHIFY_VERSION})`);
|
|
51
|
+
} else if (installed !== GRAPHIFY_VERSION) {
|
|
52
|
+
status = 'warn';
|
|
53
|
+
details.push(`graphify ${installed} instalado; ${GRAPHIFY_VERSION} é a versão pinada e medida`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
status,
|
|
58
|
+
msg: status === 'ok' ? details[0] : details[details.length - 1],
|
|
59
|
+
details,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leitura fail-open do graph.json produzido pelo graphify.
|
|
3
|
+
*
|
|
4
|
+
* Medido em 2026-08-27 sobre o MORPH_GHLBrain (11,46 MB, 6.829 nós, 15.668
|
|
5
|
+
* arestas): JSON.parse leva 52 ms e 28 MB de heap. Streaming e cache são
|
|
6
|
+
* desnecessários nesse porte — se um dia deixarem de ser, a troca acontece só
|
|
7
|
+
* aqui, porque este módulo é a única porta de leitura do grafo.
|
|
8
|
+
*
|
|
9
|
+
* Nada aqui lança: ausência de grafo é um estado válido do projeto (ADR-002).
|
|
10
|
+
*
|
|
11
|
+
* @module graph-loader
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} projectPath
|
|
19
|
+
* @returns {string} caminho canônico do graph.json do projeto
|
|
20
|
+
*/
|
|
21
|
+
export function resolveGraphPath(projectPath) {
|
|
22
|
+
return join(projectPath, 'graphify-out', 'graph.json');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} projectPath
|
|
27
|
+
* @returns {{ok: boolean, reason?: 'missing'|'unreadable'|'malformed', graph?: object, path: string}}
|
|
28
|
+
*/
|
|
29
|
+
export function loadGraph(projectPath) {
|
|
30
|
+
const path = resolveGraphPath(projectPath);
|
|
31
|
+
if (!existsSync(path)) return { ok: false, reason: 'missing', path };
|
|
32
|
+
|
|
33
|
+
let raw;
|
|
34
|
+
try {
|
|
35
|
+
raw = readFileSync(path, 'utf-8');
|
|
36
|
+
} catch {
|
|
37
|
+
return { ok: false, reason: 'unreadable', path };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const graph = JSON.parse(raw.replace(/^/, ''));
|
|
42
|
+
// A chave das arestas é `links`, não `edges`. Um JSON válido sem uma das
|
|
43
|
+
// duas listas não é um grafo utilizável.
|
|
44
|
+
if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.links)) {
|
|
45
|
+
return { ok: false, reason: 'malformed', path };
|
|
46
|
+
}
|
|
47
|
+
return { ok: true, graph, path };
|
|
48
|
+
} catch {
|
|
49
|
+
return { ok: false, reason: 'malformed', path };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
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
|
+
/**
|
|
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.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} projectPath
|
|
71
|
+
* @param {object} graph
|
|
72
|
+
* @param {{_head?: (projectPath: string) => string|null}} [deps]
|
|
73
|
+
* @returns {{builtAtCommit: string|null, headCommit: string|null, state: 'fresh'|'stale'|'unknown'}}
|
|
74
|
+
*/
|
|
75
|
+
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' };
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
builtAtCommit,
|
|
83
|
+
headCommit,
|
|
84
|
+
state: builtAtCommit === headCommit ? 'fresh' : 'stale',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -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
|
+
}
|