@polymorphism-tech/morph-spec 8.37.0 → 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.
- package/CLAUDE.md +19 -6
- package/README.md +2 -2
- package/bin/morph-spec.js +16 -7
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +3 -3
- package/framework/MORPH.md +25 -22
- package/framework/agents.json +76 -27
- package/framework/docs/CLI.md +55 -18
- package/framework/evals/rubrics/code-quality.md +5 -0
- package/framework/evals/rubrics/condensed.md +6 -2
- package/framework/evals/rubrics/contracts.md +20 -0
- package/framework/evals/rubrics/test-coverage.md +17 -6
- package/framework/hooks/README.md +9 -3
- package/framework/hooks/claude-code/core/graph-refresh-kick.js +52 -0
- package/framework/hooks/claude-code/core/telemetry-log.js +172 -87
- package/framework/hooks/claude-code/core/validate-completion.js +16 -90
- package/framework/hooks/claude-code/core/worktree-context.js +10 -0
- package/framework/hooks/shared/command-tokens.js +96 -0
- package/framework/hooks/shared/git-exclude.js +44 -0
- package/framework/hooks/shared/git-paths.js +52 -0
- package/framework/hooks/shared/graph-freshness.js +380 -0
- package/framework/hooks/shared/graph-refresh.js +416 -0
- package/framework/hooks/shared/morph-cli.js +17 -5
- package/framework/hooks/shared/redact-secrets.js +55 -0
- package/framework/hooks/shared/telemetry-logger.js +25 -2
- package/framework/hooks/shared/transcript-usage.js +80 -0
- package/framework/rubrics/backend.json +2 -2
- package/framework/rubrics/default.json +2 -2
- package/framework/rubrics/frontend.json +2 -2
- package/framework/rules/csharp-standards.md +1 -0
- package/framework/rules/morph-workflow.md +1 -0
- package/framework/rules/nextjs-standards.md +1 -0
- package/framework/schemas/config.schema.json +41 -1
- package/framework/schemas/feature.schema.json +5 -1
- 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 +36 -11
- package/framework/skills/core/morph-brainstorming/SKILL.md +16 -7
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +39 -10
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +72 -15
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +51 -13
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +34 -9
- package/framework/standards/STANDARDS.json +187 -12
- package/framework/standards/ai-agents/rag-custom-pgvector.md +7 -3
- package/framework/standards/ai-agents/testing-ai.md +2 -1
- package/framework/standards/architecture/code-comments.md +64 -0
- package/framework/standards/backend/dotnet/testing.md +22 -0
- package/framework/standards/frontend/admin-app/data-table.md +562 -0
- package/framework/standards/frontend/admin-app/feedback.md +236 -0
- package/framework/standards/frontend/admin-app/forms.md +232 -0
- package/framework/standards/frontend/admin-app/page-structure.md +203 -0
- package/framework/standards/frontend/nextjs/components.md +37 -58
- package/framework/standards/frontend/nextjs/data-fetching.md +1 -0
- package/framework/standards/frontend/nextjs/forms.md +31 -30
- package/framework/standards/frontend/nextjs/state-management.md +2 -1
- package/framework/standards/frontend/nextjs/testing.md +18 -0
- package/framework/templates/context/design-system.md +3 -3
- package/framework/templates/docs/proposal.md +11 -0
- package/framework/templates/docs/spec.md +15 -0
- package/framework/templates/docs/ui-mockups.md +115 -98
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaPlan.cs +12 -6
- package/framework/templates/feature/decisions.md +124 -122
- package/package.json +1 -1
- package/src/commands/graph/affected.js +4 -3
- package/src/commands/graph/build.js +109 -18
- package/src/commands/graph/explain.js +8 -5
- package/src/commands/graph/status.js +14 -8
- package/src/commands/observability/cost.js +148 -18
- package/src/commands/project/doctor.js +82 -10
- package/src/commands/project/update.js +25 -0
- package/src/commands/project/worktree.js +73 -90
- package/src/commands/state/advance.js +18 -0
- package/src/commands/state/approve.js +15 -0
- package/src/commands/state/archive.js +62 -9
- package/src/commands/state/finish.js +62 -14
- package/src/commands/state/gate-check.js +8 -1
- package/src/commands/state/gate-decision.js +8 -4
- package/src/commands/state/report-check.js +90 -9
- package/src/commands/state/usecase.js +66 -0
- package/src/commands/tasks/dag.js +17 -2
- package/src/commands/validation/validate-feature.js +6 -1
- package/src/commands/validation/verify.js +20 -1
- package/src/dashboard/scanner/agents-scanner.js +5 -1
- package/src/dashboard/scanner/hooks-scanner.js +8 -1
- package/src/dashboard/scanner/standards-scanner.js +2 -1
- package/src/lib/ai/comment-stripper.js +101 -4
- package/src/lib/evaluator-report.js +25 -0
- package/src/lib/generators/handoff-generator.js +4 -18
- package/src/lib/git/default-branch.js +52 -0
- package/src/lib/git/tree-fingerprint.js +58 -0
- package/src/lib/git/tree-state.js +167 -0
- package/src/lib/graph/god-nodes.js +12 -3
- package/src/lib/graph/graph-health.js +48 -2
- package/src/lib/graph/graph-loader.js +99 -25
- package/src/lib/graph/graphifyignore.js +145 -28
- package/src/lib/graph/sanitize.js +117 -11
- package/src/lib/risk-detector.js +90 -0
- package/src/lib/specs/acervo-format.js +70 -0
- package/src/lib/specs/acervo-lifecycle.js +264 -0
- package/src/lib/specs/adr-index.js +169 -0
- package/src/lib/specs/revocations.js +156 -0
- package/src/lib/specs/screen-checklist.js +357 -0
- package/src/lib/specs/usecase-check.js +20 -16
- package/src/lib/specs/usecase-promoter.js +103 -38
- package/src/lib/specs/usecase-registry.js +7 -7
- package/src/lib/tasks/tasks-json-parser.js +2 -3
- package/src/lib/telemetry/metrics.js +386 -0
- package/src/lib/telemetry/telemetry-reader.js +161 -51
- package/src/lib/telemetry/verify-event.js +79 -0
- package/src/lib/validators/code-budget/code-budget-validator.js +172 -0
- package/src/lib/validators/code-budget/comment-scanner.js +75 -0
- package/src/lib/validators/code-budget/index.js +9 -0
- package/src/lib/validators/reuse/reuse-validator.js +4 -2
- package/src/lib/validators/reuse/symbol-scanner.js +27 -22
- package/src/lib/validators/validation-runner.js +21 -5
- package/src/lib/validators/verify-runner.js +11 -0
- package/src/lib/worktree/finish-backup.js +739 -0
- package/src/lib/worktree/ignored-triage.js +15 -8
- package/src/lib/worktree/worktree-audit.js +5 -0
- package/src/scripts/setup-infra.js +29 -18
- package/src/utils/agents-installer.js +281 -12
- 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 {
|
|
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
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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ê.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
[
|
|
85
|
+
[header, ''],
|
|
55
86
|
['# Sempre', ...ALWAYS_IGNORED],
|
|
56
87
|
];
|
|
57
|
-
if (
|
|
58
|
-
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
*
|
|
45
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
378
|
+
via,
|
|
379
|
+
test: isTestPath(target.source_file),
|
|
274
380
|
});
|
|
275
381
|
}
|
|
276
382
|
}
|