@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
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* graph-freshness.js — a regra ÚNICA de "o grafo desta árvore está em dia".
|
|
3
|
+
*
|
|
4
|
+
* Todo mundo que pergunta lê daqui: o refresh em background
|
|
5
|
+
* (graph-refresh.js, decide se roda), o `graph status|explain|affected` e o
|
|
6
|
+
* `doctor` (via src/lib/graph/graph-loader.js `getFreshness`) e o
|
|
7
|
+
* `graph build|refresh` (grava o resultado). Mora em hooks/shared porque hook
|
|
8
|
+
* não importa `src/`. Uma segunda cópia da regra é como o status dizia
|
|
9
|
+
* "fresco" enquanto o refresh achava "velho", ou o contrário.
|
|
10
|
+
*
|
|
11
|
+
* Duas fontes, nesta ordem:
|
|
12
|
+
* 1. o carimbo `built_at_commit` do graph.json contra o HEAD;
|
|
13
|
+
* 2. o REGISTRO da última rodada, por árvore, em
|
|
14
|
+
* `<git-common-dir>/morph-spec/graph-refresh-state.json`:
|
|
15
|
+
* `{ [árvore]: { head, exit, ignoreHash?, at, unavailable?, seeded? } }`.
|
|
16
|
+
*
|
|
17
|
+
* O registro existe porque o `graphify update` do 0.9.50 NÃO recarimba quando
|
|
18
|
+
* a topologia não muda — "No code-graph topology changes detected; outputs
|
|
19
|
+
* left untouched" (watch.py): commit só de `.morph/`, corpo de método editado,
|
|
20
|
+
* commit num arquivo excluído. O carimbo fica no commit antigo e, sem o
|
|
21
|
+
* registro, o grafo parecia velho para sempre e cada disparo rodava o graphify
|
|
22
|
+
* de novo (~80 s no MORPH_GHLBrain). Rodada com exit 0 no HEAD H vale como
|
|
23
|
+
* grafo em dia em H: o 0.9.50 sai 1 quando recusa escrever (`_check_shrink`,
|
|
24
|
+
* grafo ilegível), e "untouched" sai 0.
|
|
25
|
+
*
|
|
26
|
+
* As regras do `.graphifyignore` entram por CONTEÚDO, nunca pelo relógio: o
|
|
27
|
+
* registro guarda o hash das regras sob as quais o grafo EM DISCO foi
|
|
28
|
+
* extraído (uma rodada que falhou não o muda), e um hash diferente do das
|
|
29
|
+
* regras que valem hoje é grafo velho — numa worktree sem arquivo próprio,
|
|
30
|
+
* as da raiz, que são as que o `graph refresh` usa ali. Comentário, linha
|
|
31
|
+
* em branco, BOM e CRLF não contam — trocar o cabeçalho não pede refresh.
|
|
32
|
+
* Grafo sem registro das regras (anterior a este estado) com regras valendo é
|
|
33
|
+
* `rules-unknown`: um refresh grava o registro e encerra.
|
|
34
|
+
*
|
|
35
|
+
* Fail-open: nada aqui lança.
|
|
36
|
+
*
|
|
37
|
+
* @module graph-freshness
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { createHash } from 'crypto';
|
|
41
|
+
import { execFileSync } from 'child_process';
|
|
42
|
+
import {
|
|
43
|
+
closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync,
|
|
44
|
+
renameSync, unlinkSync, writeFileSync,
|
|
45
|
+
} from 'fs';
|
|
46
|
+
import { basename, dirname, join, resolve } from 'path';
|
|
47
|
+
import { resolveGitCommonDir } from './git-paths.js';
|
|
48
|
+
|
|
49
|
+
export const GRAPH_DIR = 'graphify-out';
|
|
50
|
+
export const GRAPHIFY_IGNORE = '.graphifyignore';
|
|
51
|
+
const STATE_FILE = 'graph-refresh-state.json';
|
|
52
|
+
|
|
53
|
+
/** @param {string} tree */
|
|
54
|
+
export function graphFile(tree) {
|
|
55
|
+
return join(tree, GRAPH_DIR, 'graph.json');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `built_at_commit` do graph.json. O graphify o grava como a ÚLTIMA chave, então
|
|
60
|
+
* a leitura do fim do arquivo evita parsear 18 MB; a leitura inteira fica de
|
|
61
|
+
* reserva para qualquer outra forma.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} tree
|
|
64
|
+
* @returns {string|null}
|
|
65
|
+
*/
|
|
66
|
+
export function readBuiltAtCommit(tree) {
|
|
67
|
+
const file = graphFile(tree);
|
|
68
|
+
let fd;
|
|
69
|
+
try {
|
|
70
|
+
fd = openSync(file, 'r');
|
|
71
|
+
const size = fstatSync(fd).size;
|
|
72
|
+
const len = Math.min(size, 4096);
|
|
73
|
+
const buf = Buffer.alloc(len);
|
|
74
|
+
readSync(fd, buf, 0, len, size - len);
|
|
75
|
+
const m = /"built_at_commit"\s*:\s*"([0-9a-f]{7,64})"/.exec(buf.toString('utf8'));
|
|
76
|
+
if (m) return m[1];
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
} finally {
|
|
80
|
+
if (fd !== undefined) try { closeSync(fd); } catch { /* já fechado */ }
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, ''))?.built_at_commit ?? null;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {string} tree
|
|
91
|
+
* @returns {string|null}
|
|
92
|
+
*/
|
|
93
|
+
export function readHead(tree) {
|
|
94
|
+
try {
|
|
95
|
+
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
96
|
+
cwd: tree, encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000,
|
|
97
|
+
}).trim() || null;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* As REGRAS de um `.graphifyignore`, sem o que o graphify não lê: comentário,
|
|
105
|
+
* linha em branco, espaço no fim, BOM e CRLF. A ordem fica — no gitignore ela
|
|
106
|
+
* muda o resultado.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} text
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
export function ignoreRules(text) {
|
|
112
|
+
return String(text ?? '')
|
|
113
|
+
.replace(/^\uFEFF/, '')
|
|
114
|
+
.split(/\r?\n/)
|
|
115
|
+
.map((line) => line.trimEnd())
|
|
116
|
+
.filter((line) => line && !line.startsWith('#'))
|
|
117
|
+
.join('\n');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {string|null} text
|
|
122
|
+
* @returns {string|null} null quando não há arquivo
|
|
123
|
+
*/
|
|
124
|
+
export function rulesHash(text) {
|
|
125
|
+
if (text === null || text === undefined) return null;
|
|
126
|
+
return createHash('sha256').update(ignoreRules(text)).digest('hex').slice(0, 16);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A árvore principal do repositório — a única que o refresh em background
|
|
131
|
+
* toca. `null` fora de um repositório e quando o common dir não é o `.git` de
|
|
132
|
+
* uma árvore (repositório bare, `--separate-git-dir`): aí não há raiz.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} dir - qualquer diretório do repositório, raiz ou worktree
|
|
135
|
+
* @returns {string|null}
|
|
136
|
+
*/
|
|
137
|
+
export function primaryTree(dir) {
|
|
138
|
+
const common = dir ? resolveGitCommonDir(dir) : null;
|
|
139
|
+
if (!common || basename(common) !== '.git') return null;
|
|
140
|
+
return dirname(common);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 1ª linha do `.graphifyignore` que o `graph refresh` põe numa worktree só
|
|
145
|
+
* durante a rodada. Comentário não muda as regras nem o hash, e é por ele que a
|
|
146
|
+
* sobra de uma rodada cujo processo morreu no meio é reconhecida.
|
|
147
|
+
*/
|
|
148
|
+
export const TEMP_IGNORE_MARKER = '# morph-spec: cópia temporária do graph refresh, apagada ao fim da rodada';
|
|
149
|
+
|
|
150
|
+
/** @param {string|null} text */
|
|
151
|
+
export function isTemporaryIgnore(text) {
|
|
152
|
+
return String(text ?? '').replace(/^/, '').split(/\r?\n/, 1)[0] === TEMP_IGNORE_MARKER;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function readText(path) {
|
|
156
|
+
try {
|
|
157
|
+
return readFileSync(path, 'utf8');
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* O `.graphifyignore` com que o graphify roda nesta árvore: o do checkout;
|
|
165
|
+
* numa worktree sem um (ou só com a sobra temporária), o da raiz, que é o que o
|
|
166
|
+
* `graph refresh` põe no lugar durante a rodada.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} tree
|
|
169
|
+
* @returns {string|null} null quando nenhum arquivo vale
|
|
170
|
+
*/
|
|
171
|
+
export function ignoreTextOf(tree) {
|
|
172
|
+
const own = readText(join(tree, GRAPHIFY_IGNORE));
|
|
173
|
+
if (own !== null && !isTemporaryIgnore(own)) return own;
|
|
174
|
+
const root = primaryTree(tree);
|
|
175
|
+
if (!root || treeKey(root) === treeKey(tree)) return null;
|
|
176
|
+
const inherited = readText(join(root, GRAPHIFY_IGNORE));
|
|
177
|
+
return inherited !== null && !isTemporaryIgnore(inherited) ? inherited : null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @param {string} tree
|
|
182
|
+
* @returns {string|null} null quando nenhum `.graphifyignore` vale para a árvore
|
|
183
|
+
*/
|
|
184
|
+
export function ignoreRulesHash(tree) {
|
|
185
|
+
return rulesHash(ignoreTextOf(tree));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* As linhas de `git status --porcelain` de `tree` sem a da sobra do
|
|
190
|
+
* `.graphifyignore` temporário: ela é só uma cópia das regras da raiz, e o
|
|
191
|
+
* `?? .graphifyignore` que deixa fazia o `finish` e o
|
|
192
|
+
* `worktree remove --if-clean` recusarem. Só lê — quem confere a árvore chama
|
|
193
|
+
* isto antes das recusas, e `dropLeftoverIgnore` só depois delas.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} tree
|
|
196
|
+
* @param {string[]} lines
|
|
197
|
+
* @returns {string[]}
|
|
198
|
+
*/
|
|
199
|
+
export function withoutLeftoverIgnore(tree, lines) {
|
|
200
|
+
const leftover = `?? ${GRAPHIFY_IGNORE}`;
|
|
201
|
+
if (!lines.includes(leftover) || !isTemporaryIgnore(readText(join(tree, GRAPHIFY_IGNORE)))) return lines;
|
|
202
|
+
return lines.filter((line) => line !== leftover);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Apaga a sobra do `.graphifyignore` temporário, reconhecida pelo marcador.
|
|
207
|
+
*
|
|
208
|
+
* @param {string} tree
|
|
209
|
+
* @returns {boolean} apagou
|
|
210
|
+
*/
|
|
211
|
+
export function dropLeftoverIgnore(tree) {
|
|
212
|
+
const path = join(tree, GRAPHIFY_IGNORE);
|
|
213
|
+
if (!isTemporaryIgnore(readText(path))) return false;
|
|
214
|
+
try {
|
|
215
|
+
unlinkSync(path);
|
|
216
|
+
return true;
|
|
217
|
+
} catch {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Chave de uma árvore no registro. No win32 o caminho não distingue caixa, e
|
|
224
|
+
* `R:\x` e `r:\x` são a mesma árvore.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} tree
|
|
227
|
+
*/
|
|
228
|
+
export function treeKey(tree) {
|
|
229
|
+
const path = resolve(tree);
|
|
230
|
+
return process.platform === 'win32' ? path.toLowerCase() : path;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* @param {string} dir - qualquer diretório do repositório
|
|
235
|
+
* @returns {string|null}
|
|
236
|
+
*/
|
|
237
|
+
export function refreshStatePath(dir) {
|
|
238
|
+
const common = dir ? resolveGitCommonDir(dir) : null;
|
|
239
|
+
return common ? join(common, 'morph-spec', STATE_FILE) : null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function readState(path) {
|
|
243
|
+
try {
|
|
244
|
+
const state = JSON.parse(readFileSync(path, 'utf8'));
|
|
245
|
+
return state && typeof state === 'object' && !Array.isArray(state) ? state : {};
|
|
246
|
+
} catch {
|
|
247
|
+
return {};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* @param {string} tree
|
|
253
|
+
* @returns {{head: string|null, exit: number|null, ignoreHash?: string|null, at: string, unavailable?: boolean, seeded?: boolean}|null}
|
|
254
|
+
*/
|
|
255
|
+
export function readRefreshRecord(tree) {
|
|
256
|
+
const path = refreshStatePath(tree);
|
|
257
|
+
if (!path) return null;
|
|
258
|
+
const record = readState(path)[treeKey(tree)];
|
|
259
|
+
return record && typeof record === 'object' ? record : null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function pause(ms) {
|
|
263
|
+
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* sem espera */ }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Grava o registro de `tree` (substitui o anterior). Por tmp + rename: quem lê
|
|
268
|
+
* no meio vê o arquivo velho ou o novo, nunca meio. As árvores que não existem
|
|
269
|
+
* mais saem na mesma escrita — uma worktree removida não deixa lixo.
|
|
270
|
+
*
|
|
271
|
+
* @param {string} tree
|
|
272
|
+
* @param {object} record
|
|
273
|
+
* @returns {object|null} o registro gravado, ou null (sem git, disco)
|
|
274
|
+
*/
|
|
275
|
+
export function writeRefreshRecord(tree, record) {
|
|
276
|
+
const path = refreshStatePath(tree);
|
|
277
|
+
if (!path) return null;
|
|
278
|
+
const key = treeKey(tree);
|
|
279
|
+
const entry = { ...record, at: new Date().toISOString() };
|
|
280
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
281
|
+
try {
|
|
282
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
283
|
+
const state = readState(path);
|
|
284
|
+
for (const other of Object.keys(state)) {
|
|
285
|
+
if (other !== key && !existsSync(other)) delete state[other];
|
|
286
|
+
}
|
|
287
|
+
state[key] = entry;
|
|
288
|
+
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
|
|
289
|
+
// No win32 o rename sobre um arquivo aberto por outro leitor falha por um
|
|
290
|
+
// instante (EPERM/EBUSY); o registro vale umas tentativas.
|
|
291
|
+
for (let attempt = 0; ; attempt++) {
|
|
292
|
+
try {
|
|
293
|
+
renameSync(tmp, path);
|
|
294
|
+
return entry;
|
|
295
|
+
} catch (err) {
|
|
296
|
+
if (attempt >= 4 || !['EPERM', 'EBUSY', 'EACCES'].includes(err?.code)) throw err;
|
|
297
|
+
pause(25);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
try { unlinkSync(tmp); } catch { /* nunca criado */ }
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Registra uma rodada do graphify sobre `tree`. As regras só mudam com exit 0:
|
|
308
|
+
* uma rodada que falhou não mexeu no grafo, e o hash descreve o grafo em
|
|
309
|
+
* disco, não a tentativa.
|
|
310
|
+
*
|
|
311
|
+
* @param {string} tree
|
|
312
|
+
* @param {{head: string|null, exit: number|null, ignoreHash: string|null, unavailable?: boolean}} run
|
|
313
|
+
* `head` e `ignoreHash` lidos ANTES da rodada — é o que o graphify leu
|
|
314
|
+
*/
|
|
315
|
+
export function recordRefreshRun(tree, { head, exit, ignoreHash, unavailable = false }) {
|
|
316
|
+
const previous = readRefreshRecord(tree);
|
|
317
|
+
let rules = {};
|
|
318
|
+
if (exit === 0) rules = { ignoreHash };
|
|
319
|
+
else if (previous && 'ignoreHash' in previous) rules = { ignoreHash: previous.ignoreHash };
|
|
320
|
+
return writeRefreshRecord(tree, {
|
|
321
|
+
head: head ?? null,
|
|
322
|
+
exit: exit ?? null,
|
|
323
|
+
...rules,
|
|
324
|
+
...(unavailable ? { unavailable: true } : {}),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* O frescor do grafo de `tree`.
|
|
330
|
+
*
|
|
331
|
+
* missing sem graph.json
|
|
332
|
+
* unknown sem git, ou sem carimbo nem registro que diga o commit
|
|
333
|
+
* stale atrás do HEAD (`reason` ausente), ou em dia no commit mas com as
|
|
334
|
+
* regras do ignore mudadas (`ignore-changed`) ou desconhecidas
|
|
335
|
+
* (`rules-unknown`)
|
|
336
|
+
* fresh carimbo = HEAD, ou rodada com exit 0 neste HEAD — e as regras
|
|
337
|
+
* que valem para a árvore são as do grafo (`ignoreTextOf`; nenhum
|
|
338
|
+
* arquivo valendo não afirma nada)
|
|
339
|
+
*
|
|
340
|
+
* @param {string} tree - o diretório que contém `graphify-out/`
|
|
341
|
+
* @param {{graph?: object, head?: string|null}} [known] o que o chamador já
|
|
342
|
+
* leu: o grafo parseado (o carimbo sai dele) e o HEAD
|
|
343
|
+
* @returns {{state: 'missing'|'unknown'|'stale'|'fresh', built: string|null, head: string|null, reason?: 'ignore-changed'|'rules-unknown', record: object|null}}
|
|
344
|
+
*/
|
|
345
|
+
export function graphFreshness(tree, { graph, head } = {}) {
|
|
346
|
+
if (!graph && !existsSync(graphFile(tree))) {
|
|
347
|
+
return { state: 'missing', built: null, head: null, record: null };
|
|
348
|
+
}
|
|
349
|
+
const built = graph ? (graph.built_at_commit ?? null) : readBuiltAtCommit(tree);
|
|
350
|
+
const current = head !== undefined ? head : readHead(tree);
|
|
351
|
+
const record = readRefreshRecord(tree);
|
|
352
|
+
const base = { built, head: current, record };
|
|
353
|
+
if (!current) return { state: 'unknown', ...base };
|
|
354
|
+
|
|
355
|
+
const atHead = built === current || (record?.exit === 0 && record.head === current);
|
|
356
|
+
if (!atHead) return { state: built ? 'stale' : 'unknown', ...base };
|
|
357
|
+
|
|
358
|
+
const rules = ignoreRulesHash(tree);
|
|
359
|
+
if (rules !== null) {
|
|
360
|
+
if (!record || !('ignoreHash' in record)) return { state: 'stale', reason: 'rules-unknown', ...base };
|
|
361
|
+
if (record.ignoreHash !== rules) return { state: 'stale', reason: 'ignore-changed', ...base };
|
|
362
|
+
}
|
|
363
|
+
return { state: 'fresh', ...base };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Um refresh em background resolveria? Grafo velho, e sem uma rodada que já
|
|
368
|
+
* FALHOU neste mesmo HEAD — essa só se repete quando o HEAD andar (ou por
|
|
369
|
+
* `morph-spec graph refresh`, que é explícito). "graphify ausente" não conta
|
|
370
|
+
* como falha: instalado, ele roda no próximo disparo.
|
|
371
|
+
*
|
|
372
|
+
* @param {{state: string, head: string|null, record: object|null}} freshness
|
|
373
|
+
* @returns {boolean}
|
|
374
|
+
*/
|
|
375
|
+
export function refreshPending(freshness) {
|
|
376
|
+
if (freshness?.state !== 'stale') return false;
|
|
377
|
+
const r = freshness.record;
|
|
378
|
+
const failedHere = r && r.head === freshness.head && r.exit !== 0 && !r.unavailable;
|
|
379
|
+
return !failedHere;
|
|
380
|
+
}
|