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