@aperant/framework 0.19.0 → 0.20.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/CHANGELOG.md +109 -0
- package/agents/apt-executor.md +6 -0
- package/agents/apt-planner.md +3 -2
- package/bin/features-reconcile-check.mjs +77 -0
- package/dist/cli/artifacts/self-stage.d.mts.map +1 -1
- package/dist/cli/artifacts/self-stage.mjs +2 -1
- package/dist/cli/artifacts/self-stage.mjs.map +1 -1
- package/dist/cli/cli-wrappers/features-reconcile.d.mts +2 -0
- package/dist/cli/cli-wrappers/features-reconcile.d.mts.map +1 -0
- package/dist/cli/cli-wrappers/features-reconcile.mjs +9 -0
- package/dist/cli/cli-wrappers/features-reconcile.mjs.map +1 -0
- package/dist/cli/commands/features-reconcile.d.mts +2 -0
- package/dist/cli/commands/features-reconcile.d.mts.map +1 -0
- package/dist/cli/commands/features-reconcile.mjs +143 -0
- package/dist/cli/commands/features-reconcile.mjs.map +1 -0
- package/dist/cli/commands/health-check.d.mts +2 -2
- package/dist/cli/commands/health-check.mjs +2 -2
- package/dist/cli/dispatch.mjs +2 -2
- package/dist/cli/dispatch.mjs.map +1 -1
- package/dist/cli/features/area-shape.d.mts +11 -0
- package/dist/cli/features/area-shape.d.mts.map +1 -0
- package/dist/cli/features/area-shape.mjs +141 -0
- package/dist/cli/features/area-shape.mjs.map +1 -0
- package/dist/cli/features/delta.d.mts +78 -0
- package/dist/cli/features/delta.d.mts.map +1 -0
- package/dist/cli/features/delta.mjs +591 -0
- package/dist/cli/features/delta.mjs.map +1 -0
- package/dist/cli/features/reconcile.d.mts +236 -0
- package/dist/cli/features/reconcile.d.mts.map +1 -0
- package/dist/cli/features/reconcile.mjs +1126 -0
- package/dist/cli/features/reconcile.mjs.map +1 -0
- package/dist/cli/features/surface-map.d.mts +63 -0
- package/dist/cli/features/surface-map.d.mts.map +1 -0
- package/dist/cli/features/surface-map.mjs +191 -0
- package/dist/cli/features/surface-map.mjs.map +1 -0
- package/dist/cli/{commands/features-audit.d.mts → features/write-root.d.mts} +4 -3
- package/dist/cli/features/write-root.d.mts.map +1 -0
- package/dist/cli/features/write-root.mjs +137 -0
- package/dist/cli/features/write-root.mjs.map +1 -0
- package/dist/cli/gate/gates/features-reconciled.d.mts +36 -0
- package/dist/cli/gate/gates/features-reconciled.d.mts.map +1 -0
- package/dist/cli/gate/gates/features-reconciled.mjs +67 -0
- package/dist/cli/gate/gates/features-reconciled.mjs.map +1 -0
- package/dist/cli/gate/gates/index.mjs +1 -0
- package/dist/cli/gate/gates/index.mjs.map +1 -1
- package/dist/cli/help.mjs +1 -1
- package/dist/cli/release-notes/ship-autodraft.mjs +2 -2
- package/dist/cli/variant/gallery.d.mts.map +1 -1
- package/dist/cli/variant/gallery.mjs +7 -3
- package/dist/cli/variant/gallery.mjs.map +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/agents/apt-executor.md +6 -0
- package/dist/plugin/agents/apt-planner.md +3 -2
- package/dist/plugin/skills/apt-close-task/SKILL.md +2 -2
- package/dist/plugin/skills/apt-debug/SKILL.md +8 -0
- package/dist/plugin/skills/apt-execute/SKILL.md +35 -26
- package/dist/plugin/skills/apt-mockup/SKILL.md +1 -1
- package/dist/plugin/skills/apt-quick/SKILL.md +13 -11
- package/dist/plugin/skills/apt-review/SKILL.md +14 -11
- package/dist/plugin/skills/apt-run/SKILL.md +9 -3
- package/dist/plugin/skills/apt-ship/SKILL.md +27 -5
- package/dist/plugin/skills/apt-watch-ci/SKILL.md +2 -0
- package/dist/schemas/feature-registry.d.ts.map +1 -1
- package/dist/schemas/feature-registry.js +34 -3
- package/dist/schemas/feature-registry.js.map +1 -1
- package/dist/types/config.d.ts +8 -5
- package/dist/types/config.d.ts.map +1 -1
- package/package.json +1 -1
- package/skills/apt-close-task/SKILL.md +2 -2
- package/skills/apt-debug/SKILL.md +8 -0
- package/skills/apt-execute/SKILL.md +35 -26
- package/skills/apt-mockup/SKILL.md +1 -1
- package/skills/apt-quick/SKILL.md +13 -11
- package/skills/apt-review/SKILL.md +14 -11
- package/skills/apt-run/SKILL.md +9 -3
- package/skills/apt-ship/SKILL.md +27 -5
- package/skills/apt-watch-ci/SKILL.md +2 -0
- package/src/cli/artifacts/self-stage.mjs +2 -1
- package/src/cli/cli-wrappers/features-reconcile.mjs +9 -0
- package/src/cli/commands/features-reconcile.mjs +157 -0
- package/src/cli/commands/health-check.mjs +2 -2
- package/src/cli/dispatch.mjs +2 -2
- package/src/cli/features/area-shape.mjs +140 -0
- package/src/cli/features/delta.mjs +625 -0
- package/src/cli/features/reconcile.mjs +1169 -0
- package/src/cli/features/surface-map.mjs +192 -0
- package/src/cli/features/write-root.mjs +140 -0
- package/src/cli/gate/gates/features-reconciled.mjs +70 -0
- package/src/cli/gate/gates/index.mjs +1 -0
- package/src/cli/help.mjs +1 -1
- package/src/cli/release-notes/ship-autodraft.mjs +2 -2
- package/src/cli/variant/gallery.mjs +7 -3
- package/templates/aperant-claude-md-appendix.md +2 -2
- package/workflows/scan-features.md +17 -11
- package/dist/cli/cli-wrappers/features-audit.d.mts +0 -2
- package/dist/cli/cli-wrappers/features-audit.d.mts.map +0 -1
- package/dist/cli/cli-wrappers/features-audit.mjs +0 -8
- package/dist/cli/cli-wrappers/features-audit.mjs.map +0 -1
- package/dist/cli/commands/features-audit.d.mts.map +0 -1
- package/dist/cli/commands/features-audit.mjs +0 -293
- package/dist/cli/commands/features-audit.mjs.map +0 -1
- package/dist/cli/features/registry-audit.d.mts +0 -56
- package/dist/cli/features/registry-audit.d.mts.map +0 -1
- package/dist/cli/features/registry-audit.mjs +0 -264
- package/dist/cli/features/registry-audit.mjs.map +0 -1
- package/src/cli/cli-wrappers/features-audit.mjs +0 -8
- package/src/cli/commands/features-audit.mjs +0 -302
- package/src/cli/features/registry-audit.mjs +0 -254
|
@@ -0,0 +1,1169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reconcile.mjs — the features-reconcile engine: diff-driven delta drafting,
|
|
3
|
+
* the 7-step ordered idempotent materializer, the 4-clause read-only checker,
|
|
4
|
+
* and squash-safe receipt GC.
|
|
5
|
+
*
|
|
6
|
+
* DEPENDENCY-FREE (ID-08): this module's import closure is exclusively
|
|
7
|
+
* node:-builtins + the sibling pure feature modules + docs-audit/policy.mjs
|
|
8
|
+
* (node:fs/node:path only). It must NEVER import util/io.mjs or
|
|
9
|
+
* util/atomic-write.mjs (their closure pulls proper-lockfile) — the bare-node
|
|
10
|
+
* CI entry `bin/features-reconcile-check.mjs` runs with NO node_modules.
|
|
11
|
+
* A closure-walker test locks this purity.
|
|
12
|
+
*
|
|
13
|
+
* TRUST MODEL (ID-04): receipt trust for --check is IN-TREE PRESENCE plus
|
|
14
|
+
* hash matching — NEVER head-sha ancestry. This repo squash-merges, so branch
|
|
15
|
+
* heads are never ancestors of main. GC trusts `git cat-file -e
|
|
16
|
+
* origin/main:<receipt>` presence + a >30d created_at age.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execFileSync } from 'node:child_process'
|
|
20
|
+
import {
|
|
21
|
+
closeSync,
|
|
22
|
+
existsSync,
|
|
23
|
+
fsyncSync,
|
|
24
|
+
mkdirSync,
|
|
25
|
+
openSync,
|
|
26
|
+
readdirSync,
|
|
27
|
+
readFileSync,
|
|
28
|
+
renameSync,
|
|
29
|
+
unlinkSync,
|
|
30
|
+
writeSync,
|
|
31
|
+
} from 'node:fs'
|
|
32
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
33
|
+
import { loadDocsPolicy, matchSurfaceToPolicy } from '../docs-audit/policy.mjs'
|
|
34
|
+
import { validateAreaShape } from './area-shape.mjs'
|
|
35
|
+
import {
|
|
36
|
+
CHANGE_KINDS,
|
|
37
|
+
canonicalJson,
|
|
38
|
+
DRAFT_ONLY_DISPOSITION,
|
|
39
|
+
isValidIsoDatetime,
|
|
40
|
+
mergeDeltas,
|
|
41
|
+
parseDeltaFile,
|
|
42
|
+
sha256Bytes,
|
|
43
|
+
validateDelta,
|
|
44
|
+
} from './delta.mjs'
|
|
45
|
+
import { qualifiesAsFeatureSurface } from './surface-map.mjs'
|
|
46
|
+
|
|
47
|
+
export const FEATURES_DIR = '.aperant/features'
|
|
48
|
+
export const DELTAS_DIR = `${FEATURES_DIR}/deltas`
|
|
49
|
+
export const RECEIPTS_DIR = `${FEATURES_DIR}/receipts`
|
|
50
|
+
export const MATERIALIZER_VERSION = 1
|
|
51
|
+
|
|
52
|
+
/** The exact repair command surfaced in every coverage failure. */
|
|
53
|
+
export function draftRepairCommand(taskId) {
|
|
54
|
+
return `node packages/framework/bin/apt-tools.mjs features-reconcile . --draft --task-id ${taskId || '<task-id>'}`
|
|
55
|
+
}
|
|
56
|
+
const WRITE_REPAIR = 'node packages/framework/bin/apt-tools.mjs features-reconcile . --write'
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// small pure helpers (no npm deps — see module header)
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/** tmp+rename atomic JSON write, tab-indented + trailing LF (deterministic bytes). */
|
|
63
|
+
function atomicWriteJsonPure(targetPath, data) {
|
|
64
|
+
atomicWriteTextPure(targetPath, `${JSON.stringify(data, null, '\t')}\n`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function atomicWriteTextPure(targetPath, text) {
|
|
68
|
+
mkdirSync(dirname(targetPath), { recursive: true })
|
|
69
|
+
const tmpPath = `${targetPath}.tmp.${process.pid}.${Date.now()}`
|
|
70
|
+
let fd = null
|
|
71
|
+
try {
|
|
72
|
+
fd = openSync(tmpPath, 'wx')
|
|
73
|
+
writeSync(fd, text, 0, 'utf-8')
|
|
74
|
+
fsyncSync(fd)
|
|
75
|
+
closeSync(fd)
|
|
76
|
+
fd = null
|
|
77
|
+
renameSync(tmpPath, targetPath)
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (fd !== null) {
|
|
80
|
+
try {
|
|
81
|
+
closeSync(fd)
|
|
82
|
+
} catch {
|
|
83
|
+
/* already closed */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
unlinkSync(tmpPath)
|
|
88
|
+
} catch {
|
|
89
|
+
/* tmp may not exist */
|
|
90
|
+
}
|
|
91
|
+
throw err
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function git(root, args) {
|
|
96
|
+
try {
|
|
97
|
+
return execFileSync('git', args, {
|
|
98
|
+
cwd: root,
|
|
99
|
+
encoding: 'utf-8',
|
|
100
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
101
|
+
}).trim()
|
|
102
|
+
} catch {
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve the diff base COMMIT. An explicit ref is normalized through
|
|
109
|
+
* `git merge-base HEAD <ref>` — NOT used verbatim — for two reasons:
|
|
110
|
+
* (1) the ship gate / apt-ship pass a base BRANCH (`base_branch: 'main'`);
|
|
111
|
+
* once main advances past the fork point, `git diff main..HEAD`
|
|
112
|
+
* compares SNAPSHOTS and reports main-side changes reversed (phantom
|
|
113
|
+
* modifications/deletions the task never made) → false-red coverage
|
|
114
|
+
* findings on every stale branch. merge-base pins the fork point.
|
|
115
|
+
* (2) a garbage/unresolvable ref previously flowed into diffNameStatus,
|
|
116
|
+
* whose catch returned [] — an EMPTY diff → clause (a) vacuously green
|
|
117
|
+
* (fail-open). merge-base fails → null → callers fail CLOSED.
|
|
118
|
+
* merge-base(HEAD, X) === X whenever X is already an ancestor of HEAD, so the
|
|
119
|
+
* CI workflow's precomputed merge-base and sha-based callers are byte-identical.
|
|
120
|
+
* No explicit ref → merge-base HEAD origin/main (local main fallback).
|
|
121
|
+
*/
|
|
122
|
+
export function resolveBase(root, baseFlag) {
|
|
123
|
+
if (baseFlag && typeof baseFlag === 'string') {
|
|
124
|
+
return git(root, ['merge-base', 'HEAD', baseFlag])
|
|
125
|
+
}
|
|
126
|
+
const originMain = git(root, ['merge-base', 'HEAD', 'origin/main'])
|
|
127
|
+
if (originMain) return originMain
|
|
128
|
+
const localMain = git(root, ['merge-base', 'HEAD', 'main'])
|
|
129
|
+
if (localMain) return localMain
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Blob fingerprint of a path at a rev (`HEAD` or the base). null when absent. */
|
|
134
|
+
function blobAt(root, rev, path) {
|
|
135
|
+
return git(root, ['rev-parse', `${rev}:${path}`])
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Parse `git diff --name-status -z base..HEAD` preserving rename pairs. */
|
|
139
|
+
export function diffNameStatus(root, base) {
|
|
140
|
+
let out
|
|
141
|
+
try {
|
|
142
|
+
out = execFileSync('git', ['diff', '--name-status', '-z', `${base}..HEAD`], {
|
|
143
|
+
cwd: root,
|
|
144
|
+
encoding: 'utf-8',
|
|
145
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
146
|
+
})
|
|
147
|
+
} catch {
|
|
148
|
+
return []
|
|
149
|
+
}
|
|
150
|
+
const parts = out.split('\0').filter((p) => p.length > 0)
|
|
151
|
+
const entries = []
|
|
152
|
+
for (let i = 0; i < parts.length; ) {
|
|
153
|
+
const status = parts[i]
|
|
154
|
+
if (status.startsWith('R') || status.startsWith('C')) {
|
|
155
|
+
entries.push({ status: status[0], from: parts[i + 1], to: parts[i + 2] })
|
|
156
|
+
i += 3
|
|
157
|
+
} else {
|
|
158
|
+
entries.push({ status: status[0], path: parts[i + 1] })
|
|
159
|
+
i += 2
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return entries
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function listTopLevelAreaFiles(root) {
|
|
166
|
+
const dir = join(root, FEATURES_DIR)
|
|
167
|
+
if (!existsSync(dir)) return []
|
|
168
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
169
|
+
.filter((d) => d.isFile() && d.name.endsWith('.json'))
|
|
170
|
+
.map((d) => join(dir, d.name))
|
|
171
|
+
.sort()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function listDeltaFiles(root) {
|
|
175
|
+
const dir = join(root, DELTAS_DIR)
|
|
176
|
+
if (!existsSync(dir)) return []
|
|
177
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
178
|
+
.filter((d) => d.isFile() && d.name.endsWith('.json'))
|
|
179
|
+
.map((d) => join(dir, d.name))
|
|
180
|
+
.sort()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function listReceiptFiles(root) {
|
|
184
|
+
const dir = join(root, RECEIPTS_DIR)
|
|
185
|
+
if (!existsSync(dir)) return []
|
|
186
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
187
|
+
.filter((d) => d.isFile() && d.name.endsWith('.json'))
|
|
188
|
+
.map((d) => join(dir, d.name))
|
|
189
|
+
.sort()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function readJsonSafe(path) {
|
|
193
|
+
try {
|
|
194
|
+
return JSON.parse(readFileSync(path, 'utf-8'))
|
|
195
|
+
} catch {
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Structural receipt validator (N1): clause (a) coverage and clause (c)
|
|
202
|
+
* integrity TRUST receipt contents, so a malformed receipt must be EXCLUDED
|
|
203
|
+
* from trust rather than silently consumed.
|
|
204
|
+
*/
|
|
205
|
+
export function validateReceiptShape(data) {
|
|
206
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return false
|
|
207
|
+
if (typeof data.head !== 'string' || !data.head) return false
|
|
208
|
+
if (typeof data.materializer_version !== 'number') return false
|
|
209
|
+
if (!Array.isArray(data.consumed_delta_hashes)) return false
|
|
210
|
+
for (const c of data.consumed_delta_hashes) {
|
|
211
|
+
// A [null] (or path/sha-less) entry would crash the clause-(d) orphan
|
|
212
|
+
// scan (`c.sha256`) — entries must be {path, sha256} of non-empty strings.
|
|
213
|
+
if (!c || typeof c !== 'object') return false
|
|
214
|
+
if (typeof c.path !== 'string' || !c.path) return false
|
|
215
|
+
if (typeof c.sha256 !== 'string' || !c.sha256) return false
|
|
216
|
+
}
|
|
217
|
+
if (!Array.isArray(data.files)) return false
|
|
218
|
+
for (const f of data.files) {
|
|
219
|
+
if (!f || typeof f !== 'object') return false
|
|
220
|
+
if (typeof f.path !== 'string' || !f.path) return false
|
|
221
|
+
if (!CHANGE_KINDS.includes(f.change)) return false
|
|
222
|
+
if (typeof f.blob_sha !== 'string' || !f.blob_sha) return false
|
|
223
|
+
}
|
|
224
|
+
if (
|
|
225
|
+
!data.area_hashes ||
|
|
226
|
+
typeof data.area_hashes !== 'object' ||
|
|
227
|
+
Array.isArray(data.area_hashes)
|
|
228
|
+
) {
|
|
229
|
+
return false
|
|
230
|
+
}
|
|
231
|
+
for (const v of Object.values(data.area_hashes)) {
|
|
232
|
+
if (typeof v !== 'string' || !v) return false
|
|
233
|
+
}
|
|
234
|
+
return true
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Read every in-tree receipt (newest last by created_at, filename tiebreak).
|
|
239
|
+
* Malformed receipts are EXCLUDED from the trusted set and surfaced in
|
|
240
|
+
* `malformed` so --check can fail loudly instead of trusting them (N1).
|
|
241
|
+
*/
|
|
242
|
+
function readReceipts(root) {
|
|
243
|
+
const receipts = []
|
|
244
|
+
const malformed = []
|
|
245
|
+
for (const path of listReceiptFiles(root)) {
|
|
246
|
+
const data = readJsonSafe(path)
|
|
247
|
+
if (validateReceiptShape(data)) receipts.push({ path, data })
|
|
248
|
+
else malformed.push({ path })
|
|
249
|
+
}
|
|
250
|
+
receipts.sort((a, b) => {
|
|
251
|
+
const ca = String(a.data.created_at ?? '')
|
|
252
|
+
const cb = String(b.data.created_at ?? '')
|
|
253
|
+
if (ca !== cb) return ca < cb ? -1 : 1
|
|
254
|
+
return a.path < b.path ? -1 : 1
|
|
255
|
+
})
|
|
256
|
+
return { receipts, malformed }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function knownAreaNames(root) {
|
|
260
|
+
return listTopLevelAreaFiles(root).map((p) => basename(p, '.json'))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function slugify(name) {
|
|
264
|
+
const slug = name
|
|
265
|
+
.replace(/\.[a-z]+$/i, '')
|
|
266
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
267
|
+
.toLowerCase()
|
|
268
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
269
|
+
.replace(/^-+|-+$/g, '')
|
|
270
|
+
return slug || 'feature'
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Relative path of an absolute file under root (posix separators). */
|
|
274
|
+
function relTo(root, absPath) {
|
|
275
|
+
return absPath.slice(root.length + 1).replace(/\\/g, '/')
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** docs_policy consult (ID-13): are area-JSON writes allowed? */
|
|
279
|
+
function areaPolicyOptOut(root) {
|
|
280
|
+
const policy = loadDocsPolicy(root)
|
|
281
|
+
const probe = matchSurfaceToPolicy(`${FEATURES_DIR}/probe.json`, policy)
|
|
282
|
+
return probe !== 'auto-update' ? (probe ?? 'unmatched') : null
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// --draft
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Scaffold (or idempotently re-scaffold) a per-writer delta from
|
|
291
|
+
* `git diff --name-status <base>..HEAD`.
|
|
292
|
+
*
|
|
293
|
+
* - renames preserved (`change: "renamed"` + previous_path/previous_blob_sha)
|
|
294
|
+
* - deletions pre-filled `remove` with the BASE blob fingerprint
|
|
295
|
+
* - additions pre-filled `upsert` + a minimal upsert operation
|
|
296
|
+
* - modifications marked `needs-disposition` (draft-only sentinel)
|
|
297
|
+
* - re-runs refresh fingerprints but PRESERVE agent-authored dispositions,
|
|
298
|
+
* areas, reasons, and all operations[].
|
|
299
|
+
*
|
|
300
|
+
* @param {{root: string, base?: string, taskId?: string, subtaskId?: string, dryRun?: boolean}} opts
|
|
301
|
+
* @returns {{ ok: boolean, deltaPath?: string, delta?: Record<string, any>, wrote?: boolean,
|
|
302
|
+
* qualifying?: number, base?: string, error?: string }}
|
|
303
|
+
*/
|
|
304
|
+
export function draftDelta({ root: rootIn, base: baseFlag, taskId, subtaskId, dryRun = false }) {
|
|
305
|
+
const root = resolve(rootIn)
|
|
306
|
+
if (!taskId || typeof taskId !== 'string') {
|
|
307
|
+
return { ok: false, error: 'draftDelta requires --task-id (the delta path is per-writer)' }
|
|
308
|
+
}
|
|
309
|
+
const base = resolveBase(root, baseFlag)
|
|
310
|
+
if (!base) {
|
|
311
|
+
return {
|
|
312
|
+
ok: false,
|
|
313
|
+
error:
|
|
314
|
+
'cannot resolve diff base (unresolvable --base ref, or no origin/main / main); pass --base <ref>',
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const known = knownAreaNames(root)
|
|
318
|
+
const entries = diffNameStatus(root, base)
|
|
319
|
+
const files = []
|
|
320
|
+
const autoOps = []
|
|
321
|
+
for (const entry of entries) {
|
|
322
|
+
if (entry.status === 'D') {
|
|
323
|
+
const q = qualifiesAsFeatureSurface(entry.path, known)
|
|
324
|
+
if (!q) continue
|
|
325
|
+
files.push({
|
|
326
|
+
path: entry.path,
|
|
327
|
+
change: 'deleted',
|
|
328
|
+
blob_sha: blobAt(root, base, entry.path) ?? '',
|
|
329
|
+
area: q.suggestedArea,
|
|
330
|
+
disposition: 'remove',
|
|
331
|
+
})
|
|
332
|
+
continue
|
|
333
|
+
}
|
|
334
|
+
if (entry.status === 'R' || entry.status === 'C') {
|
|
335
|
+
const qNew = qualifiesAsFeatureSurface(entry.to, known)
|
|
336
|
+
if (qNew) {
|
|
337
|
+
files.push({
|
|
338
|
+
path: entry.to,
|
|
339
|
+
change: 'renamed',
|
|
340
|
+
previous_path: entry.from,
|
|
341
|
+
previous_blob_sha: blobAt(root, base, entry.from) ?? '',
|
|
342
|
+
blob_sha: blobAt(root, 'HEAD', entry.to) ?? '',
|
|
343
|
+
area: qNew.suggestedArea,
|
|
344
|
+
disposition: DRAFT_ONLY_DISPOSITION,
|
|
345
|
+
})
|
|
346
|
+
continue
|
|
347
|
+
}
|
|
348
|
+
// Renamed OUT of every qualifying family — the old surface is gone;
|
|
349
|
+
// treat like a deletion of the previous path.
|
|
350
|
+
const qOld = qualifiesAsFeatureSurface(entry.from, known)
|
|
351
|
+
if (qOld) {
|
|
352
|
+
files.push({
|
|
353
|
+
path: entry.from,
|
|
354
|
+
change: 'deleted',
|
|
355
|
+
blob_sha: blobAt(root, base, entry.from) ?? '',
|
|
356
|
+
area: qOld.suggestedArea,
|
|
357
|
+
disposition: 'remove',
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
continue
|
|
361
|
+
}
|
|
362
|
+
// A / M (and anything else git reports on a surviving path).
|
|
363
|
+
const q = qualifiesAsFeatureSurface(entry.path, known)
|
|
364
|
+
if (!q) continue
|
|
365
|
+
const added = entry.status === 'A'
|
|
366
|
+
files.push({
|
|
367
|
+
path: entry.path,
|
|
368
|
+
change: added ? 'added' : 'modified',
|
|
369
|
+
blob_sha: blobAt(root, 'HEAD', entry.path) ?? '',
|
|
370
|
+
area: q.suggestedArea,
|
|
371
|
+
disposition: added ? 'upsert' : DRAFT_ONLY_DISPOSITION,
|
|
372
|
+
})
|
|
373
|
+
if (added && q.suggestedArea) {
|
|
374
|
+
const featureId = slugify(basename(entry.path))
|
|
375
|
+
autoOps.push({
|
|
376
|
+
op: 'upsert',
|
|
377
|
+
area: q.suggestedArea,
|
|
378
|
+
feature_id: featureId,
|
|
379
|
+
value: {
|
|
380
|
+
id: featureId,
|
|
381
|
+
name: basename(entry.path),
|
|
382
|
+
component: entry.path,
|
|
383
|
+
sub_features: [],
|
|
384
|
+
},
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const deltaName = subtaskId ? `${taskId}-${subtaskId}.json` : `${taskId}.json`
|
|
390
|
+
const deltaPath = join(root, DELTAS_DIR, deltaName)
|
|
391
|
+
let delta = {
|
|
392
|
+
task_id: taskId,
|
|
393
|
+
...(subtaskId ? { subtask_id: subtaskId } : {}),
|
|
394
|
+
base,
|
|
395
|
+
created_at: new Date().toISOString(),
|
|
396
|
+
files,
|
|
397
|
+
operations: autoOps,
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Idempotent re-draft: refresh fingerprints, preserve agent enrichment.
|
|
401
|
+
if (existsSync(deltaPath)) {
|
|
402
|
+
const prior = readJsonSafe(deltaPath)
|
|
403
|
+
if (prior && typeof prior === 'object') {
|
|
404
|
+
const priorFiles = new Map((prior.files ?? []).map((f) => [f.path, f]))
|
|
405
|
+
const mergedFiles = files.map((fresh) => {
|
|
406
|
+
const old = priorFiles.get(fresh.path)
|
|
407
|
+
if (!old) return fresh
|
|
408
|
+
return {
|
|
409
|
+
...fresh,
|
|
410
|
+
// Agent-authored fields survive the re-draft; the draft only
|
|
411
|
+
// refreshes fingerprints + change kind.
|
|
412
|
+
area: old.area ?? fresh.area,
|
|
413
|
+
disposition:
|
|
414
|
+
old.disposition && old.disposition !== DRAFT_ONLY_DISPOSITION
|
|
415
|
+
? old.disposition
|
|
416
|
+
: fresh.disposition,
|
|
417
|
+
...(old.reason !== undefined ? { reason: old.reason } : {}),
|
|
418
|
+
}
|
|
419
|
+
})
|
|
420
|
+
const priorOps = Array.isArray(prior.operations) ? prior.operations : []
|
|
421
|
+
const priorOpKeys = new Set(
|
|
422
|
+
priorOps.map((o) => `${o.op} ${o.area} ${o.feature_id} ${o.sub_feature_id ?? ''}`),
|
|
423
|
+
)
|
|
424
|
+
const keptAutoOps = autoOps.filter(
|
|
425
|
+
(o) => !priorOpKeys.has(`${o.op} ${o.area} ${o.feature_id} ${o.sub_feature_id ?? ''}`),
|
|
426
|
+
)
|
|
427
|
+
delta = {
|
|
428
|
+
...prior,
|
|
429
|
+
task_id: taskId,
|
|
430
|
+
...(subtaskId ? { subtask_id: subtaskId } : {}),
|
|
431
|
+
base,
|
|
432
|
+
files: mergedFiles,
|
|
433
|
+
operations: [...priorOps, ...keptAutoOps],
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const validation = validateDelta(delta, { mode: 'draft' })
|
|
439
|
+
if (!validation.ok) {
|
|
440
|
+
return { ok: false, error: `draft failed validation: ${validation.errors.join('; ')}` }
|
|
441
|
+
}
|
|
442
|
+
if (!dryRun) {
|
|
443
|
+
if (files.length === 0 && !existsSync(deltaPath)) {
|
|
444
|
+
// Nothing qualifying and no prior delta — do not write an empty file.
|
|
445
|
+
return {
|
|
446
|
+
ok: true,
|
|
447
|
+
deltaPath: relTo(root, deltaPath),
|
|
448
|
+
delta,
|
|
449
|
+
wrote: false,
|
|
450
|
+
qualifying: 0,
|
|
451
|
+
base,
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
atomicWriteJsonPure(deltaPath, delta)
|
|
455
|
+
}
|
|
456
|
+
return {
|
|
457
|
+
ok: true,
|
|
458
|
+
deltaPath: relTo(root, deltaPath),
|
|
459
|
+
delta,
|
|
460
|
+
wrote: !dryRun && (files.length > 0 || existsSync(deltaPath)),
|
|
461
|
+
qualifying: files.length,
|
|
462
|
+
base,
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
// --write (the ID-03 7-step ordered idempotent materializer)
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
function receiptTimestamp(now) {
|
|
471
|
+
return now.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '')
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Apply merged operations + files[] bookkeeping to in-memory post-images.
|
|
476
|
+
* `stampDate` (YYYY-MM-DD) is derived DETERMINISTICALLY from the consumed
|
|
477
|
+
* deltas' `created_at` (FIX 2 / AC5) — never from wall-clock `now` — so a
|
|
478
|
+
* crash-before-receipt replay on a LATER day still writes byte-identical
|
|
479
|
+
* area files.
|
|
480
|
+
*/
|
|
481
|
+
function computePostImages(root, mergedOps, mergedFiles, stampDate) {
|
|
482
|
+
const areasDir = join(root, FEATURES_DIR)
|
|
483
|
+
const images = new Map() // area name → { data, touched }
|
|
484
|
+
const conflicts = []
|
|
485
|
+
const load = (area) => {
|
|
486
|
+
if (images.has(area)) return images.get(area)
|
|
487
|
+
const path = join(areasDir, `${area}.json`)
|
|
488
|
+
const existing = existsSync(path) ? readJsonSafe(path) : null
|
|
489
|
+
const data = existing ?? {
|
|
490
|
+
area,
|
|
491
|
+
route: '(cross-cutting)',
|
|
492
|
+
components: [],
|
|
493
|
+
last_scanned: stampDate,
|
|
494
|
+
features: [],
|
|
495
|
+
}
|
|
496
|
+
if (!Array.isArray(data.features)) data.features = []
|
|
497
|
+
if (!Array.isArray(data.components)) data.components = []
|
|
498
|
+
const image = { data, touched: false }
|
|
499
|
+
images.set(area, image)
|
|
500
|
+
return image
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
for (const op of mergedOps) {
|
|
504
|
+
const image = load(op.area)
|
|
505
|
+
const features = image.data.features
|
|
506
|
+
const idx = features.findIndex((f) => f?.id === op.feature_id)
|
|
507
|
+
if (op.op === 'upsert') {
|
|
508
|
+
if (idx >= 0) features[idx] = op.value
|
|
509
|
+
else features.push(op.value)
|
|
510
|
+
image.touched = true
|
|
511
|
+
} else if (op.op === 'add-group') {
|
|
512
|
+
if (idx >= 0) {
|
|
513
|
+
if (canonicalJson(features[idx]) !== canonicalJson(op.value)) {
|
|
514
|
+
conflicts.push({
|
|
515
|
+
kind: 'add-group-collision',
|
|
516
|
+
message: `${op.source ?? ''}: add-group collides with existing divergent group ${op.area}/${op.feature_id}`,
|
|
517
|
+
sources: [op.source].filter(Boolean),
|
|
518
|
+
})
|
|
519
|
+
}
|
|
520
|
+
// byte-equal existing group → idempotent no-op
|
|
521
|
+
} else {
|
|
522
|
+
features.push(op.value)
|
|
523
|
+
image.touched = true
|
|
524
|
+
}
|
|
525
|
+
} else if (op.op === 'patch-sub-feature') {
|
|
526
|
+
const group = idx >= 0 ? features[idx] : null
|
|
527
|
+
const sub = group?.sub_features?.find((s) => s?.id === op.sub_feature_id)
|
|
528
|
+
if (!sub) {
|
|
529
|
+
conflicts.push({
|
|
530
|
+
kind: 'patch-missing-entity',
|
|
531
|
+
message: `${op.source ?? ''}: patch-sub-feature targets missing ${op.area}/${op.feature_id}/${op.sub_feature_id} — ids are never auto-generated (ID-02 g)`,
|
|
532
|
+
sources: [op.source].filter(Boolean),
|
|
533
|
+
})
|
|
534
|
+
continue
|
|
535
|
+
}
|
|
536
|
+
Object.assign(sub, op.patch)
|
|
537
|
+
image.touched = true
|
|
538
|
+
} else if (op.op === 'remove') {
|
|
539
|
+
if (op.sub_feature_id) {
|
|
540
|
+
const group = idx >= 0 ? features[idx] : null
|
|
541
|
+
if (group?.sub_features) {
|
|
542
|
+
const before = group.sub_features.length
|
|
543
|
+
group.sub_features = group.sub_features.filter((s) => s?.id !== op.sub_feature_id)
|
|
544
|
+
if (group.sub_features.length !== before) image.touched = true
|
|
545
|
+
}
|
|
546
|
+
} else if (idx >= 0) {
|
|
547
|
+
features.splice(idx, 1)
|
|
548
|
+
image.touched = true
|
|
549
|
+
}
|
|
550
|
+
// removing an already-missing entity is an idempotent no-op (replay safety)
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// files[] bookkeeping — keep each area's components list current.
|
|
555
|
+
for (const f of mergedFiles) {
|
|
556
|
+
if (!f.area) continue
|
|
557
|
+
const image = load(f.area)
|
|
558
|
+
const components = image.data.components
|
|
559
|
+
const ensure = (p) => {
|
|
560
|
+
if (!components.includes(p)) {
|
|
561
|
+
components.push(p)
|
|
562
|
+
image.touched = true
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const drop = (p) => {
|
|
566
|
+
const i = components.indexOf(p)
|
|
567
|
+
if (i >= 0) {
|
|
568
|
+
components.splice(i, 1)
|
|
569
|
+
image.touched = true
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (f.change === 'deleted' || f.disposition === 'remove') drop(f.path)
|
|
573
|
+
else if (f.change === 'renamed') {
|
|
574
|
+
drop(f.previous_path)
|
|
575
|
+
if (f.disposition !== 'no-semantic-change') ensure(f.path)
|
|
576
|
+
} else if (f.disposition === 'upsert' || f.disposition === 'wiring-transition') ensure(f.path)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
for (const [, image] of images) {
|
|
580
|
+
if (image.touched) image.data.last_scanned = stampDate
|
|
581
|
+
}
|
|
582
|
+
return { images, conflicts }
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* materialize — the single ordered idempotent writer (ID-03 / AC5):
|
|
587
|
+
* (1) parse all deltas → (2) post-images in memory → (3) validate every
|
|
588
|
+
* post-image → (4) atomic tmp+rename area replace → (5) write receipt →
|
|
589
|
+
* (6) delete ONLY consumed deltas → (7) regenerate REGISTRY.md wholly from
|
|
590
|
+
* the JSONs.
|
|
591
|
+
*
|
|
592
|
+
* Crash-after-receipt recovery: a re-run detects deltas whose sha256 appears
|
|
593
|
+
* in an in-tree receipt's consumed_delta_hashes with matching area_hashes and
|
|
594
|
+
* completes the cleanup without rewriting.
|
|
595
|
+
*
|
|
596
|
+
* @returns envelope-ready object; never throws for expected failure classes.
|
|
597
|
+
*/
|
|
598
|
+
export function materialize({ root: rootIn, taskId, now = new Date() }) {
|
|
599
|
+
const root = resolve(rootIn)
|
|
600
|
+
|
|
601
|
+
// ID-13/AC20 — docs_policy opt-out wins before anything is touched.
|
|
602
|
+
const optOut = areaPolicyOptOut(root)
|
|
603
|
+
if (optOut) {
|
|
604
|
+
return { status: 'skip', reason: 'policy-opt-out', policy: optOut }
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Crash-after-receipt cleanup (step 5→6 tail): consume-marked deltas whose
|
|
608
|
+
// receipt's area_hashes still match the on-disk areas are deleted here.
|
|
609
|
+
const { receipts } = readReceipts(root)
|
|
610
|
+
const cleanedUp = []
|
|
611
|
+
let deltaFiles = listDeltaFiles(root)
|
|
612
|
+
for (const deltaPath of deltaFiles) {
|
|
613
|
+
let raw
|
|
614
|
+
try {
|
|
615
|
+
raw = readFileSync(deltaPath)
|
|
616
|
+
} catch {
|
|
617
|
+
continue
|
|
618
|
+
}
|
|
619
|
+
const sha = sha256Bytes(raw)
|
|
620
|
+
const consumingReceipt = receipts.find((r) =>
|
|
621
|
+
(r.data.consumed_delta_hashes ?? []).some((c) => c.sha256 === sha),
|
|
622
|
+
)
|
|
623
|
+
if (!consumingReceipt) continue
|
|
624
|
+
const areaHashes = consumingReceipt.data.area_hashes ?? {}
|
|
625
|
+
const allMatch = Object.entries(areaHashes).every(([area, hash]) => {
|
|
626
|
+
const areaPath = join(root, FEATURES_DIR, `${area}.json`)
|
|
627
|
+
if (!existsSync(areaPath)) return false
|
|
628
|
+
return sha256Bytes(readFileSync(areaPath)) === hash
|
|
629
|
+
})
|
|
630
|
+
if (allMatch) {
|
|
631
|
+
unlinkSync(deltaPath)
|
|
632
|
+
cleanedUp.push(relTo(root, deltaPath))
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
deltaFiles = listDeltaFiles(root)
|
|
636
|
+
|
|
637
|
+
// Step 1 — parse all deltas fail-closed (strict mode).
|
|
638
|
+
const parsed = []
|
|
639
|
+
const invalid = []
|
|
640
|
+
for (const path of deltaFiles) {
|
|
641
|
+
const res = parseDeltaFile(path, { mode: 'strict' })
|
|
642
|
+
if (res.ok) parsed.push({ source: relTo(root, path), path, delta: res.delta })
|
|
643
|
+
else invalid.push({ path: relTo(root, path), errors: res.errors })
|
|
644
|
+
}
|
|
645
|
+
if (invalid.length > 0) {
|
|
646
|
+
return {
|
|
647
|
+
status: 'error',
|
|
648
|
+
kind: 'invalid-deltas',
|
|
649
|
+
invalid,
|
|
650
|
+
repair: draftRepairCommand(taskId),
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (parsed.length === 0) {
|
|
655
|
+
// Zero-delta mode — regenerate REGISTRY.md only; NO receipt. N2:
|
|
656
|
+
// changed_paths reflects ACTUAL byte changes (a content-identical
|
|
657
|
+
// regeneration is skipped) so apt-ship §0.0 never attempts an empty
|
|
658
|
+
// commit.
|
|
659
|
+
const reg = regenerateRegistryMd(root, now)
|
|
660
|
+
return {
|
|
661
|
+
status: 'ok',
|
|
662
|
+
materialized: 0,
|
|
663
|
+
receipt: null,
|
|
664
|
+
consumed_deltas: [],
|
|
665
|
+
cleaned_up: cleanedUp,
|
|
666
|
+
registry_regenerated: true,
|
|
667
|
+
changed_paths: [...(reg.changed ? [relTo(root, reg.path)] : []), ...cleanedUp],
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Step 2a — merge deltas per the ID-02 conflict matrix against the pre-image.
|
|
672
|
+
const areasDir = join(root, FEATURES_DIR)
|
|
673
|
+
const lookup = {
|
|
674
|
+
getFeature: (area, fid) => {
|
|
675
|
+
const data = readJsonSafe(join(areasDir, `${area}.json`))
|
|
676
|
+
return data?.features?.find?.((f) => f?.id === fid) ?? null
|
|
677
|
+
},
|
|
678
|
+
getSubFeature: (area, fid, sid) => {
|
|
679
|
+
const data = readJsonSafe(join(areasDir, `${area}.json`))
|
|
680
|
+
return (
|
|
681
|
+
data?.features?.find?.((f) => f?.id === fid)?.sub_features?.find?.((s) => s?.id === sid) ??
|
|
682
|
+
null
|
|
683
|
+
)
|
|
684
|
+
},
|
|
685
|
+
}
|
|
686
|
+
const mergedRes = mergeDeltas(
|
|
687
|
+
parsed.map(({ source, delta }) => ({ source, delta })),
|
|
688
|
+
lookup,
|
|
689
|
+
)
|
|
690
|
+
if (!mergedRes.ok) {
|
|
691
|
+
return {
|
|
692
|
+
status: 'error',
|
|
693
|
+
kind: 'hard-conflict',
|
|
694
|
+
conflicts: mergedRes.conflicts,
|
|
695
|
+
repair: draftRepairCommand(taskId),
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Step 2b — post-images in memory. The last_scanned stamp derives from the
|
|
700
|
+
// consumed deltas' created_at (max, sliced to YYYY-MM-DD) — deterministic
|
|
701
|
+
// under replay (FIX 2 / AC5). Wall-clock `now` is the documented fallback
|
|
702
|
+
// ONLY when no consumed delta carries created_at (hand-written deltas).
|
|
703
|
+
const createdAts = parsed
|
|
704
|
+
.map(({ delta }) => delta.created_at)
|
|
705
|
+
// Strict validation already enforces real ISO 8601; this SEMANTIC filter
|
|
706
|
+
// is the belt so a garbage date on a legacy/hand-written delta that
|
|
707
|
+
// slipped past validation (e.g. 2026-99-99T99:99:99Z — shape-valid,
|
|
708
|
+
// calendar-invalid) can never max-sort above real dates (ST11).
|
|
709
|
+
.filter((v) => isValidIsoDatetime(v))
|
|
710
|
+
.sort()
|
|
711
|
+
const stampDate =
|
|
712
|
+
createdAts.length > 0
|
|
713
|
+
? createdAts[createdAts.length - 1].slice(0, 10)
|
|
714
|
+
: now.toISOString().slice(0, 10)
|
|
715
|
+
const { images, conflicts } = computePostImages(
|
|
716
|
+
root,
|
|
717
|
+
mergedRes.operations,
|
|
718
|
+
mergedRes.files,
|
|
719
|
+
stampDate,
|
|
720
|
+
)
|
|
721
|
+
if (conflicts.length > 0) {
|
|
722
|
+
return { status: 'error', kind: 'hard-conflict', conflicts, repair: draftRepairCommand(taskId) }
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Step 3 — validate EVERY post-image before ANY write.
|
|
726
|
+
const invalidImages = []
|
|
727
|
+
for (const [area, image] of images) {
|
|
728
|
+
const res = validateAreaShape(image.data)
|
|
729
|
+
if (!res.ok) invalidImages.push({ area, errors: res.errors })
|
|
730
|
+
}
|
|
731
|
+
if (invalidImages.length > 0) {
|
|
732
|
+
return { status: 'error', kind: 'invalid-post-image', invalid: invalidImages }
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Step 4 — atomic tmp+rename replace of touched area files.
|
|
736
|
+
const touchedAreas = [...images.entries()].filter(([, image]) => image.touched)
|
|
737
|
+
for (const [area, image] of touchedAreas) {
|
|
738
|
+
atomicWriteJsonPure(join(areasDir, `${area}.json`), image.data)
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Step 5 — write the receipt (ID-04 shape; fingerprints carried VERBATIM).
|
|
742
|
+
const areaHashes = {}
|
|
743
|
+
for (const [area] of touchedAreas) {
|
|
744
|
+
areaHashes[area] = sha256Bytes(readFileSync(join(areasDir, `${area}.json`)))
|
|
745
|
+
}
|
|
746
|
+
const receiptTaskId = taskId || parsed[0].delta.task_id || 'unknown'
|
|
747
|
+
const receipt = {
|
|
748
|
+
head: git(root, ['rev-parse', 'HEAD']),
|
|
749
|
+
materializer_version: MATERIALIZER_VERSION,
|
|
750
|
+
task_id: receiptTaskId,
|
|
751
|
+
created_at: now.toISOString(),
|
|
752
|
+
consumed_delta_hashes: parsed.map(({ source, path }) => ({
|
|
753
|
+
path: source,
|
|
754
|
+
sha256: sha256Bytes(readFileSync(path)),
|
|
755
|
+
})),
|
|
756
|
+
files: mergedRes.files.map((f) => ({
|
|
757
|
+
path: f.path,
|
|
758
|
+
change: f.change,
|
|
759
|
+
area: f.area,
|
|
760
|
+
disposition: f.disposition,
|
|
761
|
+
blob_sha: f.blob_sha,
|
|
762
|
+
...(f.previous_path !== undefined ? { previous_path: f.previous_path } : {}),
|
|
763
|
+
...(f.previous_blob_sha !== undefined ? { previous_blob_sha: f.previous_blob_sha } : {}),
|
|
764
|
+
})),
|
|
765
|
+
area_hashes: areaHashes,
|
|
766
|
+
}
|
|
767
|
+
const receiptName = `${receiptTimestamp(now)}-${receiptTaskId}.json`
|
|
768
|
+
const receiptPath = join(root, RECEIPTS_DIR, receiptName)
|
|
769
|
+
atomicWriteJsonPure(receiptPath, receipt)
|
|
770
|
+
|
|
771
|
+
// Step 6 — delete ONLY the deltas named in the receipt.
|
|
772
|
+
for (const { path } of parsed) {
|
|
773
|
+
unlinkSync(path)
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Step 7 — regenerate REGISTRY.md wholly from the JSONs.
|
|
777
|
+
const reg = regenerateRegistryMd(root, now)
|
|
778
|
+
|
|
779
|
+
return {
|
|
780
|
+
status: 'ok',
|
|
781
|
+
materialized: touchedAreas.length,
|
|
782
|
+
areas: touchedAreas.map(([area]) => area),
|
|
783
|
+
receipt: relTo(root, receiptPath),
|
|
784
|
+
consumed_deltas: parsed.map(({ source }) => source),
|
|
785
|
+
cleaned_up: cleanedUp,
|
|
786
|
+
registry_regenerated: true,
|
|
787
|
+
changed_paths: [
|
|
788
|
+
...touchedAreas.map(([area]) => `${FEATURES_DIR}/${area}.json`),
|
|
789
|
+
relTo(root, receiptPath),
|
|
790
|
+
...(reg.changed ? [relTo(root, reg.path)] : []),
|
|
791
|
+
...parsed.map(({ source }) => source),
|
|
792
|
+
...cleanedUp,
|
|
793
|
+
],
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** Wired-state buckets for the REGISTRY.md table. */
|
|
798
|
+
function tallyArea(data) {
|
|
799
|
+
let groups = 0
|
|
800
|
+
let subs = 0
|
|
801
|
+
let wired = 0
|
|
802
|
+
let stubbed = 0
|
|
803
|
+
let unwired = 0
|
|
804
|
+
for (const group of data.features ?? []) {
|
|
805
|
+
groups += 1
|
|
806
|
+
for (const sub of group?.sub_features ?? []) {
|
|
807
|
+
subs += 1
|
|
808
|
+
if (sub?.wired === true) wired += 1
|
|
809
|
+
else if (sub?.wired === 'stubbed' || sub?.wired === 'partial') stubbed += 1
|
|
810
|
+
else unwired += 1
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return { groups, subs, wired, stubbed, unwired }
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Regenerate REGISTRY.md WHOLLY from the area JSONs (never patched in place).
|
|
818
|
+
* Non-conforming files are listed loudly — never silently skipped.
|
|
819
|
+
*
|
|
820
|
+
* @returns {{ path: string, changed: boolean }} — `changed:false` when the
|
|
821
|
+
* regeneration was content-identical (timestamp line ignored) and the
|
|
822
|
+
* existing file was kept byte-for-byte (N2).
|
|
823
|
+
*/
|
|
824
|
+
export function regenerateRegistryMd(root, now = new Date()) {
|
|
825
|
+
const rows = []
|
|
826
|
+
const nonConforming = []
|
|
827
|
+
const totals = { groups: 0, subs: 0, wired: 0, stubbed: 0, unwired: 0 }
|
|
828
|
+
for (const path of listTopLevelAreaFiles(root)) {
|
|
829
|
+
const name = basename(path, '.json')
|
|
830
|
+
const data = readJsonSafe(path)
|
|
831
|
+
const valid = data ? validateAreaShape(data) : { ok: false, errors: ['unparseable JSON'] }
|
|
832
|
+
if (!valid.ok) {
|
|
833
|
+
nonConforming.push({ name, errors: valid.errors })
|
|
834
|
+
continue
|
|
835
|
+
}
|
|
836
|
+
const t = tallyArea(data)
|
|
837
|
+
totals.groups += t.groups
|
|
838
|
+
totals.subs += t.subs
|
|
839
|
+
totals.wired += t.wired
|
|
840
|
+
totals.stubbed += t.stubbed
|
|
841
|
+
totals.unwired += t.unwired
|
|
842
|
+
rows.push({ name, route: data.route, last_scanned: data.last_scanned, ...t })
|
|
843
|
+
}
|
|
844
|
+
rows.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
845
|
+
const pct = (n, d) => (d > 0 ? `${Math.round((n / d) * 100)}%` : '—')
|
|
846
|
+
const lines = [
|
|
847
|
+
'# Feature Registry',
|
|
848
|
+
'',
|
|
849
|
+
`Last reconciled: ${now.toISOString()}`,
|
|
850
|
+
'Regenerated wholly from `.aperant/features/*.json` by `apt-tools features-reconcile --write`.',
|
|
851
|
+
'Schema: `@aperant/framework/schemas` → `FeatureRegistrySchema` (zod-validated; twin: `area-shape.mjs`)',
|
|
852
|
+
'',
|
|
853
|
+
'## Areas',
|
|
854
|
+
'',
|
|
855
|
+
'| Area | Route | Groups | Sub-features | Wired | Stubbed | Unwired | Wired % |',
|
|
856
|
+
'|------|-------|-------:|-------------:|------:|--------:|--------:|--------:|',
|
|
857
|
+
...rows.map(
|
|
858
|
+
(r) =>
|
|
859
|
+
`| ${r.name} | \`${r.route}\` | ${r.groups} | ${r.subs} | ${r.wired} | ${r.stubbed} | ${r.unwired} | ${pct(r.wired, r.subs)} |`,
|
|
860
|
+
),
|
|
861
|
+
`| **TOTAL** | | **${totals.groups}** | **${totals.subs}** | **${totals.wired}** | **${totals.stubbed}** | **${totals.unwired}** | **${pct(totals.wired, totals.subs)}** |`,
|
|
862
|
+
'',
|
|
863
|
+
'## Coverage Summary',
|
|
864
|
+
'',
|
|
865
|
+
`- **Total feature areas:** ${rows.length}`,
|
|
866
|
+
`- **Total feature groups:** ${totals.groups}`,
|
|
867
|
+
`- **Total sub-features:** ${totals.subs}`,
|
|
868
|
+
`- **Wired (connected to backend):** ${totals.wired} / ${totals.subs}`,
|
|
869
|
+
`- **Stubbed / partial:** ${totals.stubbed} / ${totals.subs}`,
|
|
870
|
+
`- **Unwired (pure mock/display-only):** ${totals.unwired} / ${totals.subs}`,
|
|
871
|
+
]
|
|
872
|
+
if (nonConforming.length > 0) {
|
|
873
|
+
lines.push('', '## Non-conforming files (must fix)', '')
|
|
874
|
+
for (const nc of nonConforming) {
|
|
875
|
+
lines.push(
|
|
876
|
+
`- \`${nc.name}.json\` — ${nc.errors[0]}${nc.errors.length > 1 ? ` (+${nc.errors.length - 1} more)` : ''}`,
|
|
877
|
+
)
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
lines.push(
|
|
881
|
+
'',
|
|
882
|
+
'## Files',
|
|
883
|
+
'',
|
|
884
|
+
...rows.map((r) => `- \`${r.name}.json\` — last_scanned ${r.last_scanned}`),
|
|
885
|
+
'',
|
|
886
|
+
)
|
|
887
|
+
const registryPath = join(root, FEATURES_DIR, 'REGISTRY.md')
|
|
888
|
+
const next = lines.join('\n')
|
|
889
|
+
// N2: skip a content-identical regeneration (ignoring only the
|
|
890
|
+
// `Last reconciled:` timestamp line) so callers can report REAL byte
|
|
891
|
+
// changes and apt-ship §0.0 never attempts an empty commit.
|
|
892
|
+
const TS_LINE = /^Last reconciled: .*$/m
|
|
893
|
+
if (existsSync(registryPath)) {
|
|
894
|
+
const prior = readFileSync(registryPath, 'utf-8')
|
|
895
|
+
if (prior.replace(TS_LINE, '') === next.replace(TS_LINE, '')) {
|
|
896
|
+
return { path: registryPath, changed: false }
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
atomicWriteTextPure(registryPath, next)
|
|
900
|
+
return { path: registryPath, changed: true }
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// ---------------------------------------------------------------------------
|
|
904
|
+
// --check (the ID-05 read-only 4-clause verifier)
|
|
905
|
+
// ---------------------------------------------------------------------------
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* checkReconciled — four read-only clauses (AC7/AC8). Judges ONLY base..HEAD
|
|
909
|
+
* deltas — never absolute registry completeness. Trust = in-tree presence +
|
|
910
|
+
* blob-fingerprint matching (squash-safe; NO git ancestry calls anywhere).
|
|
911
|
+
*
|
|
912
|
+
* @returns {Record<string, any>} envelope-ready result: status ('ok'|'fail'|
|
|
913
|
+
* 'warn'|'skip'), findings[], coverage_ok, schema_valid, integrity_ok,
|
|
914
|
+
* orphans_ok, base, qualifying_files, pending_deltas, receipts.
|
|
915
|
+
*/
|
|
916
|
+
export function checkReconciled({ root: rootIn, base: baseFlag, warnOnly = false, taskId }) {
|
|
917
|
+
const root = resolve(rootIn)
|
|
918
|
+
|
|
919
|
+
const optOut = areaPolicyOptOut(root)
|
|
920
|
+
if (optOut) {
|
|
921
|
+
return { status: 'skip', reason: 'policy-opt-out', policy: optOut, findings: [] }
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const findings = []
|
|
925
|
+
const base = resolveBase(root, baseFlag)
|
|
926
|
+
|
|
927
|
+
// Pending deltas: strict-parse. An invalid pending delta (incl. the
|
|
928
|
+
// needs-disposition sentinel) is itself a failure — it can never cover.
|
|
929
|
+
const deltaFiles = listDeltaFiles(root)
|
|
930
|
+
const pendingDeltas = []
|
|
931
|
+
for (const path of deltaFiles) {
|
|
932
|
+
const res = parseDeltaFile(path, { mode: 'strict' })
|
|
933
|
+
if (res.ok) {
|
|
934
|
+
pendingDeltas.push({ path, delta: res.delta })
|
|
935
|
+
} else {
|
|
936
|
+
findings.push({
|
|
937
|
+
clause: 'coverage',
|
|
938
|
+
file: relTo(root, path),
|
|
939
|
+
message: `invalid pending delta: ${res.errors.join('; ')}`,
|
|
940
|
+
repair: draftRepairCommand(taskId),
|
|
941
|
+
})
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
const { receipts, malformed } = readReceipts(root)
|
|
945
|
+
// N1: a malformed receipt is EXCLUDED from coverage/integrity/orphan trust
|
|
946
|
+
// and surfaced as a finding — never silently consumed.
|
|
947
|
+
for (const m of malformed) {
|
|
948
|
+
findings.push({
|
|
949
|
+
clause: 'receipts',
|
|
950
|
+
file: relTo(root, m.path),
|
|
951
|
+
message:
|
|
952
|
+
'malformed receipt (missing head/materializer_version/files fingerprints/area_hashes/consumed_delta_hashes) — excluded from coverage and integrity',
|
|
953
|
+
repair: `re-materialize via ${WRITE_REPAIR} or remove the malformed receipt`,
|
|
954
|
+
})
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// Clause (a) — disposition coverage over base..HEAD, fingerprint-matched.
|
|
958
|
+
const listings = []
|
|
959
|
+
for (const { path, delta } of pendingDeltas) {
|
|
960
|
+
for (const f of delta.files ?? []) listings.push({ ...f, from: relTo(root, path) })
|
|
961
|
+
}
|
|
962
|
+
for (const r of receipts) {
|
|
963
|
+
for (const f of r.data.files ?? []) listings.push({ ...f, from: relTo(root, r.path) })
|
|
964
|
+
}
|
|
965
|
+
const known = knownAreaNames(root)
|
|
966
|
+
const qualifying = []
|
|
967
|
+
if (base) {
|
|
968
|
+
for (const entry of diffNameStatus(root, base)) {
|
|
969
|
+
if (entry.status === 'D') {
|
|
970
|
+
if (qualifiesAsFeatureSurface(entry.path, known)) {
|
|
971
|
+
qualifying.push({
|
|
972
|
+
path: entry.path,
|
|
973
|
+
requiredBlob: blobAt(root, base, entry.path),
|
|
974
|
+
kind: 'deleted',
|
|
975
|
+
})
|
|
976
|
+
}
|
|
977
|
+
} else if (entry.status === 'R' || entry.status === 'C') {
|
|
978
|
+
if (qualifiesAsFeatureSurface(entry.to, known)) {
|
|
979
|
+
qualifying.push({
|
|
980
|
+
path: entry.to,
|
|
981
|
+
requiredBlob: blobAt(root, 'HEAD', entry.to),
|
|
982
|
+
kind: 'current',
|
|
983
|
+
})
|
|
984
|
+
} else if (qualifiesAsFeatureSurface(entry.from, known)) {
|
|
985
|
+
qualifying.push({
|
|
986
|
+
path: entry.from,
|
|
987
|
+
requiredBlob: blobAt(root, base, entry.from),
|
|
988
|
+
kind: 'deleted',
|
|
989
|
+
})
|
|
990
|
+
}
|
|
991
|
+
} else if (qualifiesAsFeatureSurface(entry.path, known)) {
|
|
992
|
+
qualifying.push({
|
|
993
|
+
path: entry.path,
|
|
994
|
+
requiredBlob: blobAt(root, 'HEAD', entry.path),
|
|
995
|
+
kind: 'current',
|
|
996
|
+
})
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
} else {
|
|
1000
|
+
findings.push({
|
|
1001
|
+
clause: 'coverage',
|
|
1002
|
+
file: '(repo)',
|
|
1003
|
+
message:
|
|
1004
|
+
'cannot resolve diff base (unresolvable --base ref, or no origin/main / main); pass --base <ref>',
|
|
1005
|
+
repair: 'features-reconcile . --check --base <ref>',
|
|
1006
|
+
})
|
|
1007
|
+
}
|
|
1008
|
+
for (const q of qualifying) {
|
|
1009
|
+
// FIX 1: the covering listing must be KIND-COMPATIBLE, never just
|
|
1010
|
+
// path+blob. A prior receipt's HEAD-blob listing would otherwise cover
|
|
1011
|
+
// a DELETION in the next PR (that receipt's HEAD blob === this PR's
|
|
1012
|
+
// base blob). Rules:
|
|
1013
|
+
// - kind 'deleted' → only a deletion listing (change 'deleted' or
|
|
1014
|
+
// disposition 'remove') with blob_sha === the BASE blob covers;
|
|
1015
|
+
// - kind 'current' → only a NON-deleted listing (added/modified/
|
|
1016
|
+
// renamed — the qualifying builder emits renames as 'current' on
|
|
1017
|
+
// the NEW path, mirrored here) with blob_sha === the HEAD blob.
|
|
1018
|
+
const covered = listings.some((l) => {
|
|
1019
|
+
if (l.path !== q.path || !q.requiredBlob || l.blob_sha !== q.requiredBlob) return false
|
|
1020
|
+
if (q.kind === 'deleted') return l.change === 'deleted' || l.disposition === 'remove'
|
|
1021
|
+
// 'current': any NON-deleted listing (a remove-DISPOSITION on a
|
|
1022
|
+
// still-existing file is a deregistration, not a deletion — its
|
|
1023
|
+
// HEAD-anchored blob legitimately covers the edit).
|
|
1024
|
+
return l.change !== 'deleted'
|
|
1025
|
+
})
|
|
1026
|
+
if (!covered) {
|
|
1027
|
+
findings.push({
|
|
1028
|
+
clause: 'coverage',
|
|
1029
|
+
file: q.path,
|
|
1030
|
+
message: `feature-qualifying change has no covering delta or receipt with a matching blob fingerprint (${q.kind === 'deleted' ? 'base blob' : 'HEAD blob'})`,
|
|
1031
|
+
repair: draftRepairCommand(taskId),
|
|
1032
|
+
})
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Clause (b) — schema validity of every top-level area JSON.
|
|
1037
|
+
let schemaValid = true
|
|
1038
|
+
for (const path of listTopLevelAreaFiles(root)) {
|
|
1039
|
+
const data = readJsonSafe(path)
|
|
1040
|
+
const res = data ? validateAreaShape(data) : { ok: false, errors: ['unparseable JSON'] }
|
|
1041
|
+
if (!res.ok) {
|
|
1042
|
+
schemaValid = false
|
|
1043
|
+
findings.push({
|
|
1044
|
+
clause: 'schema',
|
|
1045
|
+
file: relTo(root, path),
|
|
1046
|
+
message:
|
|
1047
|
+
res.errors.slice(0, 3).join('; ') +
|
|
1048
|
+
(res.errors.length > 3 ? ` (+${res.errors.length - 3} more)` : ''),
|
|
1049
|
+
repair: 'fix the area file so it passes FeatureRegistrySchema (see area-shape.mjs)',
|
|
1050
|
+
})
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Clause (c) — per-area LATEST-receipt integrity, only when NO deltas pending.
|
|
1055
|
+
let integrityOk = true
|
|
1056
|
+
if (pendingDeltas.length === 0 && deltaFiles.length === 0 && receipts.length > 0) {
|
|
1057
|
+
for (const path of listTopLevelAreaFiles(root)) {
|
|
1058
|
+
const area = basename(path, '.json')
|
|
1059
|
+
// The latest receipt whose area_hashes names THIS area — validating
|
|
1060
|
+
// only the globally newest receipt would leave other areas unchecked.
|
|
1061
|
+
const latest = [...receipts]
|
|
1062
|
+
.reverse()
|
|
1063
|
+
.find((r) => r.data.area_hashes && area in r.data.area_hashes)
|
|
1064
|
+
if (!latest) continue // pre-receipt legacy area — exempt
|
|
1065
|
+
const recorded = latest.data.area_hashes[area]
|
|
1066
|
+
const current = sha256Bytes(readFileSync(path))
|
|
1067
|
+
if (recorded !== current) {
|
|
1068
|
+
integrityOk = false
|
|
1069
|
+
findings.push({
|
|
1070
|
+
clause: 'integrity',
|
|
1071
|
+
file: relTo(root, path),
|
|
1072
|
+
message: `area file bytes differ from its latest receipt (${basename(latest.path)}) — manual edit after materialization?`,
|
|
1073
|
+
repair: `${draftRepairCommand(taskId)} then ${WRITE_REPAIR}`,
|
|
1074
|
+
})
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// Clause (d) — no orphan consumed deltas (crash-tail state).
|
|
1080
|
+
let orphansOk = true
|
|
1081
|
+
for (const path of deltaFiles) {
|
|
1082
|
+
let sha
|
|
1083
|
+
try {
|
|
1084
|
+
sha = sha256Bytes(readFileSync(path))
|
|
1085
|
+
} catch {
|
|
1086
|
+
continue
|
|
1087
|
+
}
|
|
1088
|
+
const consumed = receipts.some((r) =>
|
|
1089
|
+
(r.data.consumed_delta_hashes ?? []).some((c) => c.sha256 === sha),
|
|
1090
|
+
)
|
|
1091
|
+
if (consumed) {
|
|
1092
|
+
orphansOk = false
|
|
1093
|
+
findings.push({
|
|
1094
|
+
clause: 'orphans',
|
|
1095
|
+
file: relTo(root, path),
|
|
1096
|
+
message: 'delta already consumed by an in-tree receipt but still on disk (crash tail)',
|
|
1097
|
+
repair: WRITE_REPAIR,
|
|
1098
|
+
})
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const coverageFindings = findings.filter((f) => f.clause === 'coverage')
|
|
1103
|
+
const ok = findings.length === 0
|
|
1104
|
+
return {
|
|
1105
|
+
status: ok ? 'ok' : warnOnly ? 'warn' : 'fail',
|
|
1106
|
+
base,
|
|
1107
|
+
qualifying_files: qualifying.map((q) => q.path),
|
|
1108
|
+
pending_deltas: pendingDeltas.length,
|
|
1109
|
+
receipts: receipts.length,
|
|
1110
|
+
coverage_ok: coverageFindings.length === 0,
|
|
1111
|
+
schema_valid: schemaValid,
|
|
1112
|
+
integrity_ok: integrityOk,
|
|
1113
|
+
orphans_ok: orphansOk,
|
|
1114
|
+
findings,
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// ---------------------------------------------------------------------------
|
|
1119
|
+
// --gc (squash-safe receipt GC)
|
|
1120
|
+
// ---------------------------------------------------------------------------
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* gcReceipts — prune ONLY receipts that (1) exist on origin/main AND (2) are
|
|
1124
|
+
* older than maxAgeDays by created_at. A fetch failure degrades to a warned
|
|
1125
|
+
* NO-OP — never a deletion.
|
|
1126
|
+
*/
|
|
1127
|
+
export function gcReceipts({ root: rootIn, maxAgeDays = 30, now = new Date() }) {
|
|
1128
|
+
const root = resolve(rootIn)
|
|
1129
|
+
try {
|
|
1130
|
+
execFileSync('git', ['fetch', 'origin', 'main'], {
|
|
1131
|
+
cwd: root,
|
|
1132
|
+
encoding: 'utf-8',
|
|
1133
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1134
|
+
})
|
|
1135
|
+
} catch {
|
|
1136
|
+
return {
|
|
1137
|
+
status: 'ok',
|
|
1138
|
+
pruned: [],
|
|
1139
|
+
kept: listReceiptFiles(root).length,
|
|
1140
|
+
warning: 'git fetch origin main failed — gc is a no-op',
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
const cutoffMs = now.getTime() - maxAgeDays * 24 * 60 * 60 * 1000
|
|
1144
|
+
const pruned = []
|
|
1145
|
+
let kept = 0
|
|
1146
|
+
for (const path of listReceiptFiles(root)) {
|
|
1147
|
+
const rel = relTo(root, path)
|
|
1148
|
+
const data = readJsonSafe(path)
|
|
1149
|
+
const createdAt = data?.created_at ? Date.parse(data.created_at) : Number.NaN
|
|
1150
|
+
const oldEnough = Number.isFinite(createdAt) && createdAt < cutoffMs
|
|
1151
|
+
let onMain = false
|
|
1152
|
+
try {
|
|
1153
|
+
execFileSync('git', ['cat-file', '-e', `origin/main:${rel}`], {
|
|
1154
|
+
cwd: root,
|
|
1155
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
1156
|
+
})
|
|
1157
|
+
onMain = true
|
|
1158
|
+
} catch {
|
|
1159
|
+
onMain = false
|
|
1160
|
+
}
|
|
1161
|
+
if (oldEnough && onMain) {
|
|
1162
|
+
unlinkSync(path)
|
|
1163
|
+
pruned.push(rel)
|
|
1164
|
+
} else {
|
|
1165
|
+
kept += 1
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
return { status: 'ok', pruned, kept }
|
|
1169
|
+
}
|