@fro.bot/systematic 3.16.4 → 3.17.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/dist/cli.js +1 -1
- package/dist/{index-65g87tgr.js → index-y33enbkc.js} +5 -5
- package/dist/index.js +81 -42
- package/dist/lib/bundled-names.d.ts +1 -1
- package/dist/lib/config-schema.d.ts +426 -3
- package/dist/pi.js +7 -6
- package/dist/schemas/systematic-config.schema.json +1 -0
- package/package.json +5 -5
- package/skills/ce-review/SKILL.md +16 -2
- package/skills/ce-review/references/synthesis-artifact-contract.md +6 -0
- package/skills/ce-review/scripts/ensure-ignore.mjs +599 -0
- package/skills/ce-review-cleanup/SKILL.md +69 -0
- package/skills/ce-review-cleanup/scripts/cleanup.mjs +1391 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Prepares the targeted `.context/.gitignore` protection for review artifacts
|
|
4
|
+
// before the `ce:review` producer creates a run directory. See
|
|
5
|
+
// docs/plans/2026-09-08-001-feat-review-artifact-cleanup-plan.md (Unit 1).
|
|
6
|
+
//
|
|
7
|
+
// Usage: node ensure-ignore.mjs --root <path>
|
|
8
|
+
//
|
|
9
|
+
// Exit 0: JSON { status: 'protected' | 'not-applicable', caveats: [...] }
|
|
10
|
+
// Exit 2: JSON { status: 'blocked', reason: <fixed category> }
|
|
11
|
+
//
|
|
12
|
+
// No absolute paths, file contents, raw Git stderr, or stack traces are ever
|
|
13
|
+
// printed. Every failure path resolves to one of the fixed BLOCK categories.
|
|
14
|
+
|
|
15
|
+
import { spawnSync } from 'node:child_process'
|
|
16
|
+
import fs from 'node:fs'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import { pathToFileURL } from 'node:url'
|
|
19
|
+
|
|
20
|
+
// ── Fixed contract ──────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export const REQUIRED_ENTRY = '/systematic/ce-review/'
|
|
23
|
+
export const NEGATED_ENTRY = `!${REQUIRED_ENTRY}`
|
|
24
|
+
export const ARTIFACT_DIR_REL = '.context/systematic/ce-review'
|
|
25
|
+
export const ARTIFACT_DESCENDANT_REL =
|
|
26
|
+
'.context/systematic/ce-review/probe-check/artifact.json'
|
|
27
|
+
|
|
28
|
+
export const BLOCK = Object.freeze({
|
|
29
|
+
GIT_AMBIGUOUS: 'git-ambiguous',
|
|
30
|
+
GIT_ERROR: 'git-error',
|
|
31
|
+
GIT_TIMEOUT: 'git-timeout',
|
|
32
|
+
IGNORE_FILE_TOO_LARGE: 'ignore-file-too-large',
|
|
33
|
+
INVALID_ARGUMENTS: 'invalid-arguments',
|
|
34
|
+
INVALID_ROOT: 'invalid-root',
|
|
35
|
+
MISSING_GIT: 'missing-git',
|
|
36
|
+
SYMLINK_REJECTED: 'symlink-rejected',
|
|
37
|
+
VERIFY_FAILED: 'verify-failed',
|
|
38
|
+
WRITE_CONFLICT: 'write-conflict',
|
|
39
|
+
WRITE_FAILED: 'write-failed',
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// Fixed upper bound on the `.context/.gitignore` file this helper will read
|
|
43
|
+
// or produce. Rejected before allocation, not truncated.
|
|
44
|
+
export const IGNORE_FILE_MAX_BYTES = 1024 * 1024
|
|
45
|
+
|
|
46
|
+
// Ordinary staging only: tracked files, force-add, and copies elsewhere are
|
|
47
|
+
// unaffected by this ignore entry (R3, R19).
|
|
48
|
+
const CAVEATS = Object.freeze(['tracked-files-and-force-add-unaffected'])
|
|
49
|
+
|
|
50
|
+
// The CLI always uses this fixed, bounded default. It is not configurable
|
|
51
|
+
// through environment variables; `classifyGitWorkTree`'s `timeoutMs` option
|
|
52
|
+
// exists so internal tests can inject a short value deterministically.
|
|
53
|
+
const DEFAULT_GIT_TIMEOUT_MS = 5000
|
|
54
|
+
|
|
55
|
+
// ── Filesystem helpers ───────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export function lstatOrNull(targetPath) {
|
|
58
|
+
try {
|
|
59
|
+
return fs.lstatSync(targetPath)
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error && error.code === 'ENOENT') return null
|
|
62
|
+
throw error
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Resolves the supplied root to its canonical existing directory, or null. */
|
|
67
|
+
export function canonicalizeRoot(rawRoot) {
|
|
68
|
+
try {
|
|
69
|
+
const real = fs.realpathSync(rawRoot)
|
|
70
|
+
const stat = fs.statSync(real)
|
|
71
|
+
if (!stat.isDirectory()) return null
|
|
72
|
+
return real
|
|
73
|
+
} catch {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Walks `segments` below `root` and reports whether any existing component is
|
|
80
|
+
* a symlink, or a non-directory occupies an intermediate position, or the
|
|
81
|
+
* final `.gitignore` segment exists but is not a regular file. A missing
|
|
82
|
+
* component (nothing yet created) is not rejected here.
|
|
83
|
+
*/
|
|
84
|
+
export function findUnsafeComponent(root, segments) {
|
|
85
|
+
let current = root
|
|
86
|
+
for (const [index, segment] of segments.entries()) {
|
|
87
|
+
current = path.join(current, segment)
|
|
88
|
+
const stat = lstatOrNull(current)
|
|
89
|
+
if (!stat) return false
|
|
90
|
+
if (stat.isSymbolicLink()) return true
|
|
91
|
+
const isLast = index === segments.length - 1
|
|
92
|
+
if (!isLast && !stat.isDirectory()) return true
|
|
93
|
+
if (isLast && segment === '.gitignore' && !stat.isFile()) return true
|
|
94
|
+
}
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** True when both `dev`+`ino` pairs identify the same underlying file (Node's `fs.Stats` exposes both across platforms; exact semantics depend on the filesystem). Not proof of atomicity -- only that two observations agree. */
|
|
99
|
+
function sameFileIdentity(a, b) {
|
|
100
|
+
return a.dev === b.dev && a.ino === b.ino
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Reads a file's bytes/mode/identity without following a symlink at the
|
|
105
|
+
* final component. Identity (`dev`+`ino`) is captured alongside content and
|
|
106
|
+
* mode so a later recheck can detect a same-bytes replacement (a different
|
|
107
|
+
* file swapped in via rename) or a mode-only change, not just a content diff.
|
|
108
|
+
*
|
|
109
|
+
* Two independent checks guard the read, since either alone is incomplete:
|
|
110
|
+
* 1. An `lstat` on the path *before* opening rejects a symlink or any
|
|
111
|
+
* non-regular file (socket, FIFO, device, ...) without ever calling
|
|
112
|
+
* `open()` on it -- some non-regular types otherwise produce a
|
|
113
|
+
* platform-specific `open()` error this function should never surface
|
|
114
|
+
* as an uncaught throw, and a target this function must never block on
|
|
115
|
+
* (e.g. a FIFO with no writer).
|
|
116
|
+
* 2. `O_NOFOLLOW` on the `open()` call itself rejects a symlink presented
|
|
117
|
+
* for the first time at open (a path that was a plain file at the lstat
|
|
118
|
+
* above, then replaced). `O_NOFOLLOW` is undefined on platforms that
|
|
119
|
+
* don't support it (`fs.constants.O_NOFOLLOW ?? 0` degrades to a no-op
|
|
120
|
+
* flag there), so `open()` would silently follow such a symlink. This
|
|
121
|
+
* function corroborates the opened descriptor's `dev`/`ino` against the
|
|
122
|
+
* pre-open `lstat` snapshot; a mismatch means the path did not
|
|
123
|
+
* consistently name the same file across the two observations, which is
|
|
124
|
+
* treated as unsafe. This corroboration narrows, but cannot close, the
|
|
125
|
+
* race window between the `lstat` and the `open()` -- it detects an
|
|
126
|
+
* observed identity change, not a guarantee against a well-timed racer.
|
|
127
|
+
*/
|
|
128
|
+
export function readTrustedFile(filePath) {
|
|
129
|
+
const preStat = lstatOrNull(filePath)
|
|
130
|
+
if (!preStat) return { exists: false }
|
|
131
|
+
if (preStat.isSymbolicLink() || !preStat.isFile()) {
|
|
132
|
+
return { exists: true, symlink: true }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const noFollow = fs.constants.O_NOFOLLOW ?? 0
|
|
136
|
+
let fd
|
|
137
|
+
try {
|
|
138
|
+
fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow)
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error && error.code === 'ENOENT') return { exists: false }
|
|
141
|
+
if (error && error.code === 'ELOOP') return { exists: true, symlink: true }
|
|
142
|
+
// Any other open error (EACCES, EPERM, resource limits, ...) is not
|
|
143
|
+
// evidence of a symlink -- rethrow for the caller's fixed-category catch.
|
|
144
|
+
throw error
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const stat = fs.fstatSync(fd)
|
|
148
|
+
if (!stat.isFile()) return { exists: true, symlink: true }
|
|
149
|
+
if (!sameFileIdentity(stat, preStat)) return { exists: true, symlink: true }
|
|
150
|
+
|
|
151
|
+
// Reject before allocating a buffer for an oversize file.
|
|
152
|
+
if (stat.size > IGNORE_FILE_MAX_BYTES) {
|
|
153
|
+
return oversizeResult(stat)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const bytes = readExactBounded(fd, stat.size)
|
|
157
|
+
if (bytes === null) {
|
|
158
|
+
// Fewer bytes were available than `fstat` reported (a concurrent
|
|
159
|
+
// shrink mid-read): the snapshot is untrustworthy. This is an
|
|
160
|
+
// observed change, not evidence of a symlink -- no partial buffer is
|
|
161
|
+
// returned in its place.
|
|
162
|
+
return { changed: true, exists: true }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The read window is not atomic: the file could have grown past the cap,
|
|
166
|
+
// grown or shrunk within the cap, or had its mode changed, between the
|
|
167
|
+
// `fstat` above and the read completing. Recheck the same descriptor
|
|
168
|
+
// before trusting the bytes just read -- an exact match on size and
|
|
169
|
+
// mode is required, not just "still under the cap".
|
|
170
|
+
const afterStat = fs.fstatSync(fd)
|
|
171
|
+
if (afterStat.size > IGNORE_FILE_MAX_BYTES) {
|
|
172
|
+
return oversizeResult(afterStat)
|
|
173
|
+
}
|
|
174
|
+
if (afterStat.size !== stat.size || afterStat.mode !== stat.mode) {
|
|
175
|
+
return { changed: true, exists: true }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
bytes,
|
|
180
|
+
dev: stat.dev,
|
|
181
|
+
exists: true,
|
|
182
|
+
ino: stat.ino,
|
|
183
|
+
mode: stat.mode & 0o777,
|
|
184
|
+
}
|
|
185
|
+
} finally {
|
|
186
|
+
fs.closeSync(fd)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function oversizeResult(stat) {
|
|
191
|
+
return {
|
|
192
|
+
dev: stat.dev,
|
|
193
|
+
exists: true,
|
|
194
|
+
ino: stat.ino,
|
|
195
|
+
mode: stat.mode & 0o777,
|
|
196
|
+
tooLarge: true,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Reads exactly `size` bytes from `fd` at fixed positions, or null if the descriptor produced fewer bytes than expected (a concurrent shrink). Never returns a silently short buffer. */
|
|
201
|
+
function readExactBounded(fd, size) {
|
|
202
|
+
if (size === 0) return Buffer.alloc(0)
|
|
203
|
+
const buffer = Buffer.allocUnsafe(size)
|
|
204
|
+
let offset = 0
|
|
205
|
+
while (offset < size) {
|
|
206
|
+
const bytesRead = fs.readSync(fd, buffer, offset, size - offset, offset)
|
|
207
|
+
if (bytesRead === 0) return null
|
|
208
|
+
offset += bytesRead
|
|
209
|
+
}
|
|
210
|
+
return buffer
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Content composition (byte-exact, never decoded to a string) ────────────
|
|
214
|
+
//
|
|
215
|
+
// Existing content may contain byte sequences that are not valid UTF-8.
|
|
216
|
+
// Decoding to a string and re-encoding would silently corrupt them (each
|
|
217
|
+
// invalid sequence becomes U+FFFD, which re-encodes to different bytes than
|
|
218
|
+
// the original), violating byte preservation (R2). Line-splitting and
|
|
219
|
+
// exact-line comparison against the ASCII-only required/negated entries work
|
|
220
|
+
// correctly directly on raw bytes.
|
|
221
|
+
|
|
222
|
+
const NEWLINE_BYTE = 0x0a
|
|
223
|
+
const CARRIAGE_RETURN_BYTE = 0x0d
|
|
224
|
+
const REQUIRED_ENTRY_BUFFER = Buffer.from(REQUIRED_ENTRY, 'utf8')
|
|
225
|
+
const NEGATED_ENTRY_BUFFER = Buffer.from(NEGATED_ENTRY, 'utf8')
|
|
226
|
+
const REQUIRED_ENTRY_LINE_BUFFER = Buffer.from(`${REQUIRED_ENTRY}\n`, 'utf8')
|
|
227
|
+
|
|
228
|
+
/** Splits bytes on `\n`, stripping a trailing `\r` per line -- byte-level equivalent of `text.split(/\r?\n/)`. */
|
|
229
|
+
function splitLinesBytes(bytes) {
|
|
230
|
+
const lines = []
|
|
231
|
+
let start = 0
|
|
232
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
233
|
+
if (bytes[i] === NEWLINE_BYTE) {
|
|
234
|
+
const end = i > start && bytes[i - 1] === CARRIAGE_RETURN_BYTE ? i - 1 : i
|
|
235
|
+
lines.push(bytes.subarray(start, end))
|
|
236
|
+
start = i + 1
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
lines.push(bytes.subarray(start, bytes.length))
|
|
240
|
+
return lines
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* True only when the required entry is present as an exact line and no later
|
|
245
|
+
* exact negation of it follows. A same-file negation after the entry makes it
|
|
246
|
+
* ineffective; a later re-assertion after the negation makes it effective
|
|
247
|
+
* again. Purely a byte-level judgment about this one file, independent of
|
|
248
|
+
* ancestor `.gitignore` protection -- the explicit nested entry is required
|
|
249
|
+
* even when an ancestor already ignores the parent directory.
|
|
250
|
+
*/
|
|
251
|
+
export function isEntryEffectiveInBytes(bytes) {
|
|
252
|
+
if (!bytes || bytes.length === 0) return false
|
|
253
|
+
let effective = false
|
|
254
|
+
for (const line of splitLinesBytes(bytes)) {
|
|
255
|
+
if (line.equals(REQUIRED_ENTRY_BUFFER)) effective = true
|
|
256
|
+
else if (line.equals(NEGATED_ENTRY_BUFFER)) effective = false
|
|
257
|
+
}
|
|
258
|
+
return effective
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Appends the required entry, preserving existing bytes exactly and adding a separator only if needed. */
|
|
262
|
+
export function composeAppendedBytes(existingBytes) {
|
|
263
|
+
if (!existingBytes || existingBytes.length === 0)
|
|
264
|
+
return REQUIRED_ENTRY_LINE_BUFFER
|
|
265
|
+
const needsSeparator =
|
|
266
|
+
existingBytes[existingBytes.length - 1] !== NEWLINE_BYTE
|
|
267
|
+
return needsSeparator
|
|
268
|
+
? Buffer.concat([
|
|
269
|
+
existingBytes,
|
|
270
|
+
Buffer.from('\n'),
|
|
271
|
+
REQUIRED_ENTRY_LINE_BUFFER,
|
|
272
|
+
])
|
|
273
|
+
: Buffer.concat([existingBytes, REQUIRED_ENTRY_LINE_BUFFER])
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── Conflict-checked atomic write ───────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
function cleanupTemp(tempPath) {
|
|
279
|
+
try {
|
|
280
|
+
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath)
|
|
281
|
+
} catch {
|
|
282
|
+
// Best-effort cleanup; the original error is what matters.
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function makeTempPath(parentDir) {
|
|
287
|
+
return path.join(
|
|
288
|
+
parentDir,
|
|
289
|
+
`.gitignore.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Writes `newContent` to `filePath` only if the file still matches the
|
|
295
|
+
* `existing` snapshot immediately before the rename. Detects a conflicting
|
|
296
|
+
* edit that landed between the initial read and this write; never overwrites
|
|
297
|
+
* an observed conflict, and never rolls back a later editor's change.
|
|
298
|
+
*/
|
|
299
|
+
export function writeIgnoreFileIfUnchanged(filePath, existing, newContent) {
|
|
300
|
+
const parentDir = path.dirname(filePath)
|
|
301
|
+
const mode =
|
|
302
|
+
existing.exists && existing.mode !== undefined ? existing.mode : 0o644
|
|
303
|
+
const tempPath = makeTempPath(parentDir)
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
fs.writeFileSync(tempPath, newContent, { flag: 'wx', mode })
|
|
307
|
+
} catch {
|
|
308
|
+
cleanupTemp(tempPath)
|
|
309
|
+
return { ok: false, reason: 'failed' }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
let recheck
|
|
313
|
+
try {
|
|
314
|
+
recheck = readTrustedFile(filePath)
|
|
315
|
+
} catch {
|
|
316
|
+
cleanupTemp(tempPath)
|
|
317
|
+
return { ok: false, reason: 'failed' }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// A conflict is any observed change to identity, mode, or content: a
|
|
321
|
+
// same-bytes replacement via a different inode, or a mode-only change,
|
|
322
|
+
// both count even though a naive content-only diff would miss them.
|
|
323
|
+
const unchanged = existing.exists
|
|
324
|
+
? recheck.exists &&
|
|
325
|
+
!recheck.symlink &&
|
|
326
|
+
sameFileIdentity(recheck, existing) &&
|
|
327
|
+
recheck.mode === existing.mode &&
|
|
328
|
+
Buffer.isBuffer(recheck.bytes) &&
|
|
329
|
+
Buffer.isBuffer(existing.bytes) &&
|
|
330
|
+
recheck.bytes.equals(existing.bytes)
|
|
331
|
+
: !recheck.exists
|
|
332
|
+
|
|
333
|
+
if (!unchanged) {
|
|
334
|
+
cleanupTemp(tempPath)
|
|
335
|
+
return { ok: false, reason: 'conflict' }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
fs.renameSync(tempPath, filePath)
|
|
340
|
+
} catch {
|
|
341
|
+
cleanupTemp(tempPath)
|
|
342
|
+
return { ok: false, reason: 'failed' }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return { ok: true }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ── Git classification ───────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
const GIT_ENV_STRIP = [
|
|
351
|
+
'GIT_DIR',
|
|
352
|
+
'GIT_WORK_TREE',
|
|
353
|
+
'GIT_INDEX_FILE',
|
|
354
|
+
'GIT_OBJECT_DIRECTORY',
|
|
355
|
+
'GIT_CEILING_DIRECTORIES',
|
|
356
|
+
'GIT_COMMON_DIR',
|
|
357
|
+
'GIT_NAMESPACE',
|
|
358
|
+
]
|
|
359
|
+
|
|
360
|
+
function sanitizedGitEnv() {
|
|
361
|
+
const env = { ...process.env }
|
|
362
|
+
for (const key of GIT_ENV_STRIP) delete env[key]
|
|
363
|
+
env.LC_ALL = 'C'
|
|
364
|
+
env.LANG = 'C'
|
|
365
|
+
env.GIT_TERMINAL_PROMPT = '0'
|
|
366
|
+
return env
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Runs a Git subcommand with an argument array, a bounded timeout, a
|
|
371
|
+
* controlled locale, and repository-redirection environment overrides
|
|
372
|
+
* stripped. `options.timeoutMs` defaults to a fixed bound; it is not exposed
|
|
373
|
+
* through a public environment variable -- only internal callers (and their
|
|
374
|
+
* tests) can override it per invocation.
|
|
375
|
+
*/
|
|
376
|
+
export function runGit(args, cwd, options = {}) {
|
|
377
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS
|
|
378
|
+
return spawnSync('git', args, {
|
|
379
|
+
cwd,
|
|
380
|
+
encoding: 'utf8',
|
|
381
|
+
env: sanitizedGitEnv(),
|
|
382
|
+
timeout: timeoutMs,
|
|
383
|
+
windowsHide: true,
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Controlled-locale message for git's unambiguous "no repository" diagnostic.
|
|
388
|
+
// Deliberately distinct from the corrupt-metadata message ("not a git
|
|
389
|
+
// repository: <path>"), which lacks the parenthetical and must not match.
|
|
390
|
+
const NOT_A_REPO_PATTERN =
|
|
391
|
+
/^fatal: not a git repository \(or any of the parent directories\): /m
|
|
392
|
+
|
|
393
|
+
export function classifyGitWorkTree(root, options = {}) {
|
|
394
|
+
const result = runGit(['rev-parse', '--is-inside-work-tree'], root, options)
|
|
395
|
+
|
|
396
|
+
if (result.error) {
|
|
397
|
+
if (result.error.code === 'ENOENT') return { kind: 'missing-git' }
|
|
398
|
+
if (result.error.code === 'ETIMEDOUT') return { kind: 'timeout' }
|
|
399
|
+
return { kind: 'error' }
|
|
400
|
+
}
|
|
401
|
+
if (result.signal) return { kind: 'timeout' }
|
|
402
|
+
|
|
403
|
+
const stdout = (result.stdout ?? '').trim()
|
|
404
|
+
const stderr = (result.stderr ?? '').trim()
|
|
405
|
+
|
|
406
|
+
if (result.status === 0) {
|
|
407
|
+
// "false" means inside a `.git` directory of a repo with no accessible
|
|
408
|
+
// work tree (e.g. a bare repository) -- ambiguous, not a plain work tree.
|
|
409
|
+
// Distinguishing this from "true" requires reading stdout, not just the
|
|
410
|
+
// (identical, zero) exit status.
|
|
411
|
+
return stdout === 'true' ? { kind: 'work-tree' } : { kind: 'ambiguous' }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (result.status === 128 && NOT_A_REPO_PATTERN.test(stderr)) {
|
|
415
|
+
return { kind: 'non-git' }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return { kind: 'error' }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Confirms the directory and a descendant are both ignored, without creating a canary file. */
|
|
422
|
+
export function verifyEffectiveIgnore(root) {
|
|
423
|
+
const dirCheck = runGit(
|
|
424
|
+
['check-ignore', '--no-index', '--quiet', '--', `${ARTIFACT_DIR_REL}/`],
|
|
425
|
+
root,
|
|
426
|
+
)
|
|
427
|
+
if (dirCheck.error || dirCheck.signal || dirCheck.status !== 0) return false
|
|
428
|
+
|
|
429
|
+
const descendantCheck = runGit(
|
|
430
|
+
['check-ignore', '--no-index', '--quiet', '--', ARTIFACT_DESCENDANT_REL],
|
|
431
|
+
root,
|
|
432
|
+
)
|
|
433
|
+
if (
|
|
434
|
+
descendantCheck.error ||
|
|
435
|
+
descendantCheck.signal ||
|
|
436
|
+
descendantCheck.status !== 0
|
|
437
|
+
) {
|
|
438
|
+
return false
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return true
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ── Orchestration ────────────────────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
function blocked(reason) {
|
|
447
|
+
return { exitCode: 2, result: { reason, status: 'blocked' } }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function protectedResult(status) {
|
|
451
|
+
return { exitCode: 0, result: { caveats: CAVEATS, status } }
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Converts any propagated non-ENOENT stat failure from `findUnsafeComponent` into the fixed blocked contract instead of an uncaught exception. */
|
|
455
|
+
function findUnsafeComponentOrBlock(root, segments) {
|
|
456
|
+
try {
|
|
457
|
+
return { unsafe: findUnsafeComponent(root, segments) }
|
|
458
|
+
} catch {
|
|
459
|
+
return { blockedResult: blocked(BLOCK.WRITE_FAILED) }
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function ensureIgnore(rawRoot) {
|
|
464
|
+
const root = canonicalizeRoot(rawRoot)
|
|
465
|
+
if (!root) return blocked(BLOCK.INVALID_ROOT)
|
|
466
|
+
|
|
467
|
+
const contextCheck = findUnsafeComponentOrBlock(root, ['.context'])
|
|
468
|
+
if (contextCheck.blockedResult) return contextCheck.blockedResult
|
|
469
|
+
if (contextCheck.unsafe) return blocked(BLOCK.SYMLINK_REJECTED)
|
|
470
|
+
|
|
471
|
+
const ignoreCheck = findUnsafeComponentOrBlock(root, [
|
|
472
|
+
'.context',
|
|
473
|
+
'.gitignore',
|
|
474
|
+
])
|
|
475
|
+
if (ignoreCheck.blockedResult) return ignoreCheck.blockedResult
|
|
476
|
+
if (ignoreCheck.unsafe) return blocked(BLOCK.SYMLINK_REJECTED)
|
|
477
|
+
|
|
478
|
+
const classification = classifyGitWorkTree(root)
|
|
479
|
+
if (classification.kind === 'missing-git') return blocked(BLOCK.MISSING_GIT)
|
|
480
|
+
if (classification.kind === 'timeout') return blocked(BLOCK.GIT_TIMEOUT)
|
|
481
|
+
if (classification.kind === 'ambiguous') return blocked(BLOCK.GIT_AMBIGUOUS)
|
|
482
|
+
if (classification.kind === 'error') return blocked(BLOCK.GIT_ERROR)
|
|
483
|
+
|
|
484
|
+
const contextDir = path.join(root, '.context')
|
|
485
|
+
const ignorePath = path.join(contextDir, '.gitignore')
|
|
486
|
+
|
|
487
|
+
try {
|
|
488
|
+
fs.mkdirSync(contextDir, { recursive: true })
|
|
489
|
+
} catch {
|
|
490
|
+
return blocked(BLOCK.WRITE_FAILED)
|
|
491
|
+
}
|
|
492
|
+
// The directory may have been replaced by a symlink between the earlier
|
|
493
|
+
// check and this creation; re-verify before touching the nested file.
|
|
494
|
+
const recheck = findUnsafeComponentOrBlock(root, ['.context'])
|
|
495
|
+
if (recheck.blockedResult) return recheck.blockedResult
|
|
496
|
+
if (recheck.unsafe) return blocked(BLOCK.SYMLINK_REJECTED)
|
|
497
|
+
|
|
498
|
+
let existing
|
|
499
|
+
try {
|
|
500
|
+
existing = readTrustedFile(ignorePath)
|
|
501
|
+
} catch {
|
|
502
|
+
return blocked(BLOCK.WRITE_FAILED)
|
|
503
|
+
}
|
|
504
|
+
if (existing.symlink) return blocked(BLOCK.SYMLINK_REJECTED)
|
|
505
|
+
if (existing.tooLarge) return blocked(BLOCK.IGNORE_FILE_TOO_LARGE)
|
|
506
|
+
if (existing.changed) return blocked(BLOCK.WRITE_CONFLICT)
|
|
507
|
+
|
|
508
|
+
const existingBytes =
|
|
509
|
+
existing.exists && existing.bytes ? existing.bytes : undefined
|
|
510
|
+
|
|
511
|
+
if (!isEntryEffectiveInBytes(existingBytes)) {
|
|
512
|
+
const newContent = composeAppendedBytes(existingBytes)
|
|
513
|
+
// The prospective composed length -- not an approximation -- must fit
|
|
514
|
+
// the cap before any write is attempted.
|
|
515
|
+
if (newContent.length > IGNORE_FILE_MAX_BYTES) {
|
|
516
|
+
return blocked(BLOCK.IGNORE_FILE_TOO_LARGE)
|
|
517
|
+
}
|
|
518
|
+
const writeResult = writeIgnoreFileIfUnchanged(
|
|
519
|
+
ignorePath,
|
|
520
|
+
existing,
|
|
521
|
+
newContent,
|
|
522
|
+
)
|
|
523
|
+
if (!writeResult.ok) {
|
|
524
|
+
return blocked(
|
|
525
|
+
writeResult.reason === 'conflict'
|
|
526
|
+
? BLOCK.WRITE_CONFLICT
|
|
527
|
+
: BLOCK.WRITE_FAILED,
|
|
528
|
+
)
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (classification.kind === 'non-git') {
|
|
533
|
+
return protectedResult('not-applicable')
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (!verifyEffectiveIgnore(root)) return blocked(BLOCK.VERIFY_FAILED)
|
|
537
|
+
return protectedResult('protected')
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ── CLI ──────────────────────────────────────────────────────────────────────
|
|
541
|
+
|
|
542
|
+
const KNOWN_FLAGS = new Set(['--root'])
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Strictly parses `--root <value>` and nothing else: an unknown flag, a
|
|
546
|
+
* duplicate `--root`, a missing value, or any stray positional argument is
|
|
547
|
+
* rejected rather than silently accepted or defaulted.
|
|
548
|
+
*/
|
|
549
|
+
export function parseArgs(argv) {
|
|
550
|
+
let root
|
|
551
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
552
|
+
const token = argv[i]
|
|
553
|
+
if (!KNOWN_FLAGS.has(token)) return null
|
|
554
|
+
if (root !== undefined) return null // duplicate --root
|
|
555
|
+
const value = argv[i + 1]
|
|
556
|
+
// Reject a missing value or any value starting with `--` (an option
|
|
557
|
+
// consumed as a path); `./--name` remains a valid literal path.
|
|
558
|
+
if (value === undefined || value.startsWith('--')) return null
|
|
559
|
+
root = value
|
|
560
|
+
i += 1
|
|
561
|
+
}
|
|
562
|
+
return root === undefined ? null : root
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function main() {
|
|
566
|
+
const root = parseArgs(process.argv.slice(2))
|
|
567
|
+
if (root === null) {
|
|
568
|
+
process.stdout.write(
|
|
569
|
+
`${JSON.stringify({ reason: BLOCK.INVALID_ARGUMENTS, status: 'blocked' })}\n`,
|
|
570
|
+
)
|
|
571
|
+
process.exit(2)
|
|
572
|
+
return
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Final boundary: any unanticipated exception still maps to the fixed
|
|
576
|
+
// blocked contract, never a raw error.
|
|
577
|
+
let outcome
|
|
578
|
+
try {
|
|
579
|
+
outcome = ensureIgnore(root)
|
|
580
|
+
} catch {
|
|
581
|
+
process.stdout.write(
|
|
582
|
+
`${JSON.stringify({ reason: BLOCK.WRITE_FAILED, status: 'blocked' })}\n`,
|
|
583
|
+
)
|
|
584
|
+
process.exit(2)
|
|
585
|
+
return
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const { exitCode, result } = outcome
|
|
589
|
+
process.stdout.write(`${JSON.stringify(result)}\n`)
|
|
590
|
+
process.exit(exitCode)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const invokedDirectly =
|
|
594
|
+
process.argv[1] !== undefined &&
|
|
595
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
596
|
+
|
|
597
|
+
if (invokedDirectly) {
|
|
598
|
+
main()
|
|
599
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ce:review-cleanup
|
|
3
|
+
description: "Use when the operator wants to delete old ce:review run directories under .context/systematic/ce-review/ to free disk space or purge stale local review evidence. Use when asked to clean up, prune, or remove old review runs, review artifacts, or review-summary.json history."
|
|
4
|
+
metadata:
|
|
5
|
+
harness-portability: neutral-v1
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Review Artifact Cleanup
|
|
9
|
+
|
|
10
|
+
Offline, operator-initiated deletion of old `ce:review` run directories. This skill must never dispatch a review, select a review mode, or create a new run while claiming writers are stopped. It only previews and deletes existing run directories through the bundled helper.
|
|
11
|
+
|
|
12
|
+
## Step 1: Identify the target root
|
|
13
|
+
|
|
14
|
+
Before asking anything else, fix the target project root for this invocation (the operator-supplied path, or the current checkout if none is given) and hold it as `TARGET_ROOT` for every command below. Every invocation in this skill -- preview and execute -- reuses this exact same value. The preview helper's JSON response carries no `root` field, so execute must never guess or reconstruct the root from that response; it must reuse the value fixed here.
|
|
15
|
+
|
|
16
|
+
## Step 2: Offline acknowledgment
|
|
17
|
+
|
|
18
|
+
Before any scan, ask the operator to confirm they have stopped all review writers using this checkout, including other sessions and other machines, and that they will remain stopped through deletion. This is a stated precondition, not a verified one: the helper does not detect active writers. If the operator refuses or cannot confirm, stop -- do not run preview.
|
|
19
|
+
|
|
20
|
+
## Step 3: Age cutoff
|
|
21
|
+
|
|
22
|
+
Ask for the age cutoff (days, or a number suffixed `d`/`w`) if the operator has not already supplied one. Age is a required argument with no default; never assume or infer a cutoff.
|
|
23
|
+
|
|
24
|
+
## Step 4: Preview
|
|
25
|
+
|
|
26
|
+
Fill `TARGET_ROOT` with the value fixed in Step 1 and `AGE` with the confirmed cutoff, then invoke the bundled helper for a read-only preview. Quote both values -- never interpolate operator text unquoted:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# Resolve helper scripts relative to this skill's directory.
|
|
30
|
+
SKILL_DIR="<skill directory stated when this skill loads>";
|
|
31
|
+
node "$SKILL_DIR/scripts/cleanup.mjs" preview --root "$TARGET_ROOT" --age "$AGE" --ack-offline
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Parse the JSON result:
|
|
35
|
+
|
|
36
|
+
- `result: "root-missing"` -- no review root exists; nothing to do, stop.
|
|
37
|
+
- `result: "nothing-eligible"` -- no candidate is old enough; report the `excludedRecent` count and the `skippedUnknownUnsafe` count separately (they are not the same thing: recency exclusion is not an unsafe/unknown skip), and stop.
|
|
38
|
+
- `result: "preview"` -- one or more candidates selected; continue to Step 5.
|
|
39
|
+
- `result: "error"` -- exit code 2 with a fixed `category` (e.g. `missing-acknowledgment`, `invalid-age`, `invalid-root`, `unsafe-review-root`, `root-enumeration-failed`). Report the category verbatim and stop. Never substitute an unchecked replacement command (no direct filesystem deletion, no ad hoc shell traversal) when the helper is missing or fails -- report a fixed diagnostic and stop.
|
|
40
|
+
|
|
41
|
+
Present each candidate's `name`, `displayId`, `label`, and `lastModified` -- the bounded, JSON-escaped `name` gives the operator a human-identifiable candidate alongside the hash-derived `displayId`, not instead of it. Render the `name` value as-is, never decode or reformat it into Markdown/terminal text: a candidate name is untrusted, JSON-escaped input, not markup. Present `skippedUnknownUnsafe` candidates with their `name`, `displayId`, and `reason`. Never print `review-summary.json` contents or any source excerpt. Status (completed, in-progress, failed, legacy, or missing) is never an eligibility signal; age past the cutoff under the offline precondition is the only selection criterion -- old in-progress and artifactless runs are exactly as eligible as old completed ones.
|
|
42
|
+
|
|
43
|
+
## Step 5: Deletion approval
|
|
44
|
+
|
|
45
|
+
After presenting the full preview, ask for a separate, explicit deletion approval -- never equate it with the offline acknowledgment or the initial cleanup request. Never treat the offline acknowledgment, the initial cleanup request, or the preview token as deletion approval. If the operator declines, stop; there is no autonomous default and no partial proceed.
|
|
46
|
+
|
|
47
|
+
## Step 6: Execute
|
|
48
|
+
|
|
49
|
+
On approval, fill `TARGET_ROOT` with the exact same value fixed in Step 1 (never a value read from the preview response) and `TOKEN` with the exact token string from the preceding preview response, then invoke execute. Quote both values; never recompute or pass `--age`:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# Resolve helper scripts relative to this skill's directory.
|
|
53
|
+
SKILL_DIR="<skill directory stated when this skill loads>";
|
|
54
|
+
node "$SKILL_DIR/scripts/cleanup.mjs" execute --root "$TARGET_ROOT" --ack-offline --token "$TOKEN"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Handle exit codes:
|
|
58
|
+
|
|
59
|
+
- `0`, `result: "deleted"` -- all approved candidates removed; report per-candidate outcomes.
|
|
60
|
+
- `1`, `result: "partial"` -- some candidates were skipped or failed; preserve and report every candidate's outcome (`deleted`/`skipped`/`failed`) rather than a single pass/fail summary.
|
|
61
|
+
- `2`, `result: "error"` -- invalid arguments, missing acknowledgment/token, or unsafe setup (including a future-dated token). Report the category and stop; no unchecked replacement command.
|
|
62
|
+
- `3`, `result: "preview-stale"` -- the reviewed tree changed since preview; zero deletions occurred. Run a fresh preview (Step 4) and ask for a renewed approval (Step 5) before executing again. Never auto-retry the same token.
|
|
63
|
+
|
|
64
|
+
## Scope and disclosures
|
|
65
|
+
|
|
66
|
+
- Only `.context/systematic/ce-review/` run directories are candidates; the root itself, `.context/.gitignore`, and other tools' artifacts are never deletion targets. An unknown or unreadable age skips the candidate; symlink and special-file candidates are refused.
|
|
67
|
+
- Deletion is irreversible and can partially remove a directory; there is no rollback or backup copy.
|
|
68
|
+
- Findings inside retained runs may contain source excerpts and remain on disk indefinitely until this cleanup runs; disclose that to the operator when relevant.
|
|
69
|
+
- This skill does not verify writer liveness, does not authenticate approval, and does not defend against a concurrent malicious filesystem replacement -- the offline precondition is the operator's responsibility, not the tool's.
|