@sriinnu/omit 0.3.0 → 0.4.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/.clinerules/omit.md +1 -1
- package/.cursor/rules/omit.mdc +1 -1
- package/.github/copilot-instructions.md +15 -0
- package/.github/workflows/publish.yml +69 -0
- package/.github/workflows/test.yml +40 -0
- package/.windsurf/rules/omit.md +1 -1
- package/AGENTS.md +11 -2
- package/README.md +72 -15
- package/action.yml +18 -1
- package/bench/run.mjs +25 -4
- package/bin/omit.mjs +518 -58
- package/hooks/command-sentinel.mjs +39 -11
- package/hooks/dep-sentinel.mjs +184 -30
- package/hooks/final-draft-gate.mjs +231 -37
- package/hooks/hazard-sentinel.mjs +157 -39
- package/hooks/hooks.json +18 -1
- package/hooks/leak-sentinel.mjs +55 -0
- package/hooks/lint-sentinel.mjs +34 -11
- package/lib/danger.mjs +9 -1
- package/lib/deps.mjs +341 -45
- package/lib/exec.mjs +18 -0
- package/lib/git.mjs +107 -0
- package/lib/hazards.mjs +29 -4
- package/lib/leaks.mjs +313 -0
- package/lib/lint.mjs +85 -5
- package/lib/receipts.mjs +356 -0
- package/package.json +5 -1
- package/skills/omit/SKILL.md +25 -13
package/bin/omit.mjs
CHANGED
|
@@ -3,21 +3,26 @@
|
|
|
3
3
|
// CI, and any other agent's hook system all call the same commands.
|
|
4
4
|
//
|
|
5
5
|
// omit init [agents|claude|cursor|cline|windsurf|all] copy rule files into this repo
|
|
6
|
-
// omit audit [--base <ref>] [--json|--markdown] net diff, new deps, hazards
|
|
6
|
+
// omit audit [--base <ref>] [--json|--markdown] net diff, new deps, hazards
|
|
7
7
|
// omit check <file...> hazard-scan specific files
|
|
8
8
|
// omit gate audit the staged diff; fail on hazards/uncited deps
|
|
9
|
+
// omit verify re-check every claim in .omit/receipts.jsonl
|
|
10
|
+
// omit leak "<cmd>" would this command print a real secret to stdout?
|
|
9
11
|
// omit hook install add the gate to .git/hooks/pre-commit
|
|
12
|
+
// omit hook install codex write .codex/hooks.json (live sentinels inside Codex CLI)
|
|
10
13
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs'
|
|
11
|
-
import { execSync } from 'node:child_process'
|
|
12
14
|
import { basename, dirname, join } from 'node:path'
|
|
13
15
|
import { fileURLToPath } from 'node:url'
|
|
14
|
-
import { isManifest, addedDeps } from '../lib/deps.mjs'
|
|
16
|
+
import { isManifest, addedDeps, unparsedDependencyFile } from '../lib/deps.mjs'
|
|
17
|
+
import { git, probe, repoRoot, fileAtRevision, GitError } from '../lib/git.mjs'
|
|
18
|
+
import { LEDGER, verifyLedger, newDepCitations } from '../lib/receipts.mjs'
|
|
15
19
|
import { findHazards } from '../lib/hazards.mjs'
|
|
16
20
|
import { lintFiles } from '../lib/lint.mjs'
|
|
17
21
|
import { assessCommand } from '../lib/danger.mjs'
|
|
22
|
+
import { assessLeak } from '../lib/leaks.mjs'
|
|
18
23
|
|
|
24
|
+
const cwd = process.cwd()
|
|
19
25
|
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
20
|
-
const git = (args) => execSync(`git ${args}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
21
26
|
|
|
22
27
|
// ---------- init ----------
|
|
23
28
|
const targets = {
|
|
@@ -50,69 +55,332 @@ function init(pick = 'agents') {
|
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
// ---------- audit / gate ----------
|
|
58
|
+
const die = (msg) => {
|
|
59
|
+
console.error(`omit: ${msg}`)
|
|
60
|
+
process.exit(1)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The repo root, resolved once and threaded through everything below. git prints
|
|
64
|
+
// and resolves `rev:path` against the ROOT, so a cwd-relative join reads the
|
|
65
|
+
// wrong tree the moment this runs from a subdirectory — and silently: the path is
|
|
66
|
+
// simply not found, and a file that is not found declares no dependencies.
|
|
67
|
+
function rootOrDie() {
|
|
68
|
+
const root = repoRoot(cwd)
|
|
69
|
+
if (!root) die('not a git repository — audit and gate measure a diff against git history')
|
|
70
|
+
return root
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const splitLines = (text) => {
|
|
74
|
+
const lines = text.split('\n')
|
|
75
|
+
if (lines[lines.length - 1] === '') lines.pop()
|
|
76
|
+
return lines
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The blob the index holds for a path — the file as the commit would contain it,
|
|
80
|
+
// or null when the index does not hold it at all. Written as `:0:<path>` rather
|
|
81
|
+
// than `:<path>` because git reads `:.omit/receipts.jsonl` as a malformed
|
|
82
|
+
// revision and refuses to answer: for a path that is not staged, a refusal is
|
|
83
|
+
// indistinguishable from an absence, and the ledger lives under `.omit/`.
|
|
84
|
+
function indexText(root, path) {
|
|
85
|
+
if (!path) return null
|
|
86
|
+
const r = fileAtRevision(root, '', path)
|
|
87
|
+
if (r.present) return r.text
|
|
88
|
+
if (r.failed) die(`could not read ${path} from the index — ${r.reason}`)
|
|
89
|
+
return null // not in the index: nothing there
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// What a path actually holds: the working copy, or the index blob when the gate
|
|
93
|
+
// is judging a commit. null means there is genuinely nothing to read there (a
|
|
94
|
+
// deleted path); a git that cannot answer is an error, never an empty file.
|
|
95
|
+
function contentOf(root, path, staged) {
|
|
96
|
+
if (staged) return indexText(root, path)
|
|
97
|
+
try {
|
|
98
|
+
return readFileSync(join(root, path), 'utf8')
|
|
99
|
+
} catch (e) {
|
|
100
|
+
if (e.code === 'ENOENT') return null
|
|
101
|
+
die(`could not read ${path} — ${e.message}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A file's lines, or null when it isn't text to be counted (binary, unreadable).
|
|
106
|
+
function readLines(root, path) {
|
|
107
|
+
const text = contentOf(root, path, false)
|
|
108
|
+
return text === null || text.includes('\0') ? null : splitLines(text)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function manifestText(root, path, oldPath, version) {
|
|
112
|
+
if (version === '') return indexText(root, path) ?? '' // the index: what is being committed
|
|
113
|
+
if (version !== null) {
|
|
114
|
+
const r = fileAtRevision(root, version, oldPath)
|
|
115
|
+
// A revision that could not be read is not "this manifest declares nothing".
|
|
116
|
+
// That substitution is the difference between a new dependency and no new
|
|
117
|
+
// dependency, made silently.
|
|
118
|
+
if (r.failed) die(`could not read ${oldPath} at ${version} — ${r.reason}`)
|
|
119
|
+
return r.present ? r.text : '' // deleted, or new here: declares nothing
|
|
120
|
+
}
|
|
121
|
+
return contentOf(root, path, false) ?? '' // deleted: declares nothing
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// `--base` names a revision. An unresolvable one — a typo, a shallow clone, a
|
|
125
|
+
// force-push survivor — used to diff to nothing and render as a clean verdict,
|
|
126
|
+
// which is an audit of nothing reported as a pass. git would also read an
|
|
127
|
+
// unresolvable argument as a PATHSPEC and silently diff a directory, so it is
|
|
128
|
+
// resolved as a revision here rather than left to `git diff` to guess.
|
|
129
|
+
function requireRevision(root, range) {
|
|
130
|
+
if (/\.\./.test(range)) die(`--base ${range}: expected a single revision — audit measures the change against one revision`)
|
|
131
|
+
if (!probe(root, ['rev-parse', '--verify', '--quiet', `${range}^{commit}`]).ok) {
|
|
132
|
+
die(`${range} is not a revision this repo can resolve — refusing to measure a change against nothing (no commits yet, a typo, a shallow clone, or a rewritten history?)`)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// numstat rows. `-z` because a renamed path is otherwise printed as the brace
|
|
137
|
+
// composite `{old => new}/package.json` — not a path at all: `git show` and a
|
|
138
|
+
// filesystem read both fail on it, so both revisions of a rename commit read as
|
|
139
|
+
// empty and a dependency added there goes uncited. With -z the pre-image and
|
|
140
|
+
// post-image names arrive as separate fields:
|
|
141
|
+
// ordinary `A\tD\t<path>\0`
|
|
142
|
+
// rename `A\tD\t\0<old>\0<new>\0`
|
|
143
|
+
function parseNumstat(out) {
|
|
144
|
+
const fields = out.split('\0')
|
|
145
|
+
const rows = []
|
|
146
|
+
for (let i = 0; i < fields.length; i++) {
|
|
147
|
+
const head = fields[i]
|
|
148
|
+
if (!head) continue
|
|
149
|
+
const [added, deleted, path] = head.split('\t')
|
|
150
|
+
if (path === '') {
|
|
151
|
+
const oldPath = fields[++i] ?? ''
|
|
152
|
+
const newPath = fields[++i] ?? ''
|
|
153
|
+
rows.push({ path: newPath, oldPath, added, deleted })
|
|
154
|
+
} else {
|
|
155
|
+
rows.push({ path, oldPath: path, added, deleted })
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return rows
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The added lines, which is the set every hazard rule reads — so it has to be all
|
|
162
|
+
// of them. A `startsWith('+++')` filter drops a real content line that begins
|
|
163
|
+
// with `++` (the patch prefixes it to `++++`), which hides a secret on that line
|
|
164
|
+
// and makes the count disagree with numstat. Skip the file header as a block.
|
|
165
|
+
function addedLinesOf(patch) {
|
|
166
|
+
const added = []
|
|
167
|
+
let header = true
|
|
168
|
+
for (const l of patch.split('\n')) {
|
|
169
|
+
if (l.startsWith('diff --git ')) {
|
|
170
|
+
header = true
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
if (header) {
|
|
174
|
+
// The block ends at the post-image header or the first hunk. Every other
|
|
175
|
+
// header line (index, mode, similarity, rename) starts with neither, so
|
|
176
|
+
// skipping the block cannot drop an added line.
|
|
177
|
+
if (l.startsWith('+++ ') || l.startsWith('@@')) header = false
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
if (l.startsWith('+')) added.push(l.slice(1))
|
|
181
|
+
}
|
|
182
|
+
return added
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The receipts the gate weighs are the ones in the COMMIT, not the ones on disk.
|
|
186
|
+
// `--cached` judges the index, and a working-tree ledger the commit does not
|
|
187
|
+
// carry — or contradicts — verified nothing that ships: an untracked ledger used
|
|
188
|
+
// to cite a dependency while the commit contained no ledger at all.
|
|
189
|
+
function ledgerForChange(root, staged) {
|
|
190
|
+
if (!staged) return { inChange: true, results: verifyLedger(root), citations: newDepCitations(root) }
|
|
191
|
+
const stagedLedger = indexText(root, LEDGER)
|
|
192
|
+
if (stagedLedger === null) return { inChange: false, citations: new Map(), results: [] }
|
|
193
|
+
let disk = null
|
|
194
|
+
try {
|
|
195
|
+
disk = readFileSync(join(root, LEDGER), 'utf8')
|
|
196
|
+
} catch {}
|
|
197
|
+
const lf = (s) => s.replace(/\r\n/g, '\n') // a CRLF checkout is not a different ledger
|
|
198
|
+
if (disk === null || lf(disk) !== lf(stagedLedger)) {
|
|
199
|
+
die(
|
|
200
|
+
`the staged ${LEDGER} is not the file on disk — receipts are checked against the working tree, so a ledger that differs from the one being committed was never the one checked. Stage the same content, or restore it with \`git checkout -- ${LEDGER}\`.`
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
return { inChange: true, results: verifyLedger(root), citations: newDepCitations(root) }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// `git config core.hooksPath /tmp/empty` disarms the pre-commit gate for good and
|
|
207
|
+
// leaves no trace in `git status` or in any diff. This is the only evidence there
|
|
208
|
+
// is, so it is read and reported rather than assumed absent.
|
|
209
|
+
// `--show-origin` because where it is set is what tells a machine-wide setting
|
|
210
|
+
// apart from `git config core.hooksPath /tmp/empty` run a moment ago.
|
|
211
|
+
function hooksPathAt(root) {
|
|
212
|
+
const r = probe(root, ['config', '--show-origin', '--get', 'core.hooksPath'])
|
|
213
|
+
if (!r.ok) return null
|
|
214
|
+
const line = r.out.trim().replace(/\n.*$/, '')
|
|
215
|
+
const tab = line.indexOf('\t')
|
|
216
|
+
return tab === -1 ? { origin: '(unknown)', value: line } : { origin: line.slice(0, tab), value: line.slice(tab + 1) }
|
|
217
|
+
}
|
|
218
|
+
|
|
53
219
|
function collect(diffRange) {
|
|
54
|
-
const
|
|
220
|
+
const root = rootOrDie()
|
|
221
|
+
const staged = diffRange === '--cached'
|
|
222
|
+
if (!staged) requireRevision(root, diffRange)
|
|
223
|
+
|
|
55
224
|
const files = []
|
|
225
|
+
const binaries = []
|
|
56
226
|
let added = 0
|
|
227
|
+
let countedAdded = 0
|
|
57
228
|
let deleted = 0
|
|
58
|
-
for (const row of numstat) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
deleted
|
|
229
|
+
for (const row of parseNumstat(git(root, ['diff', '--numstat', '-z', '-M', diffRange]))) {
|
|
230
|
+
if (row.added === '-') {
|
|
231
|
+
binaries.push(row) // git says binary — read it by content below
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
files.push({ path: row.path, oldPath: row.oldPath, added: +row.added, deleted: +row.deleted })
|
|
235
|
+
added += +row.added
|
|
236
|
+
countedAdded += +row.added
|
|
237
|
+
deleted += +row.deleted
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Untracked files are invisible to `git diff`, and a working tree mid-task is
|
|
241
|
+
// mostly untracked: new files, not yet added. Counting them is the difference
|
|
242
|
+
// between auditing the change and auditing only the part that got staged.
|
|
243
|
+
// Excluded from the staged range — a gate judges what is being committed, and
|
|
244
|
+
// an untracked file is not in the commit.
|
|
245
|
+
const untracked = new Map()
|
|
246
|
+
if (!staged) {
|
|
247
|
+
for (const path of git(root, ['ls-files', '--others', '--exclude-standard']).split('\n').filter(Boolean)) {
|
|
248
|
+
if (path.startsWith('.omit/')) continue // omit's own ledger, not the change
|
|
249
|
+
const lines = readLines(root, path)
|
|
250
|
+
if (!lines) continue // no text to count: a binary file adds no line to scan
|
|
251
|
+
untracked.set(path, lines)
|
|
252
|
+
files.push({ path, oldPath: path, added: lines.length, deleted: 0 })
|
|
253
|
+
added += lines.length
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const patchAdded = addedLinesOf(git(root, ['diff', '-M', diffRange]))
|
|
258
|
+
// numstat and the patch describe the same diff, so their added-line totals have
|
|
259
|
+
// to agree. If they do not, part of the change went unread — and "scanned
|
|
260
|
+
// nothing" must not be able to render as "found nothing".
|
|
261
|
+
if (patchAdded.length !== countedAdded) {
|
|
262
|
+
die(`the diff reports ${countedAdded} added lines but only ${patchAdded.length} could be read — refusing to report a verdict on a change that could not be read in full`)
|
|
64
263
|
}
|
|
65
264
|
|
|
265
|
+
const addedLines = [...patchAdded]
|
|
266
|
+
for (const lines of untracked.values()) addedLines.push(...lines)
|
|
267
|
+
|
|
268
|
+
// numstat calls a path binary: either it is one, or an attribute says so — and
|
|
269
|
+
// the second is a way to erase a file from the scan. `src/config.js -diff` in
|
|
270
|
+
// .git/info/attributes makes numstat report `-` and the patch carry no `+`
|
|
271
|
+
// lines, so a committed secret renders as hazards: 0. The content is read
|
|
272
|
+
// directly instead: how git chose to represent a file is a formatting decision,
|
|
273
|
+
// and it must not decide whether the file is examined.
|
|
274
|
+
for (const b of binaries) {
|
|
275
|
+
const text = contentOf(root, b.path, staged)
|
|
276
|
+
if (text === null) continue // gone from the index, or deleted: nothing left to scan
|
|
277
|
+
files.push({ path: b.path, oldPath: b.oldPath, added: 0, deleted: 0, binary: true })
|
|
278
|
+
addedLines.push(...splitLines(text))
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Dependencies are compared as parsed sets between the two revisions, so a
|
|
282
|
+
// minified manifest is read the same as a pretty-printed one.
|
|
66
283
|
const newDeps = []
|
|
67
284
|
for (const f of files.filter((f) => isManifest(f.path))) {
|
|
68
|
-
const
|
|
69
|
-
|
|
285
|
+
const before = manifestText(root, f.path, f.oldPath, staged ? 'HEAD' : diffRange)
|
|
286
|
+
const after = manifestText(root, f.path, f.path, staged ? '' : null)
|
|
287
|
+
newDeps.push(...addedDeps(basename(f.path), before, after).map((dep) => ({ dep, manifest: f.path })))
|
|
70
288
|
}
|
|
71
289
|
|
|
72
|
-
|
|
73
|
-
|
|
290
|
+
// Dependency-shaped files omit cannot parse — setup.py, build.gradle, a csproj.
|
|
291
|
+
// A dependency added to one of those is invisible here, and an invisible
|
|
292
|
+
// dependency renders as `new deps: 0 ✅`, which is a claim about a file nobody
|
|
293
|
+
// read. It is reported, never failed: plenty of repos carry these for reasons
|
|
294
|
+
// that have nothing to do with a dependency.
|
|
295
|
+
const unparsed = [...new Set(files.map((f) => f.path).filter((p) => unparsedDependencyFile(p)))].sort()
|
|
296
|
+
|
|
74
297
|
const hazards = findHazards(addedLines)
|
|
75
298
|
const footnotes = addedLines.filter((l) => /omitted:/.test(l)).length
|
|
76
299
|
const loadBearing = addedLines.filter((l) => /load-bearing:/.test(l)).length
|
|
77
300
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
301
|
+
// A new dependency is cited only by a VERIFIED new-dep receipt. A ledger line
|
|
302
|
+
// that merely names the dep is an assertion, and assertions are the thing this
|
|
303
|
+
// mechanism exists to stop accepting.
|
|
304
|
+
const ledger = ledgerForChange(root, staged)
|
|
305
|
+
const uncitedDeps = newDeps.filter((d) => ledger.citations.get(d.dep)?.status !== 'verified')
|
|
81
306
|
|
|
82
|
-
const lint = lintFiles(
|
|
307
|
+
const lint = lintFiles(root, files.map((f) => f.path).filter((p) => existsSync(join(root, p))))
|
|
83
308
|
|
|
84
|
-
return {
|
|
309
|
+
return {
|
|
310
|
+
files,
|
|
311
|
+
added,
|
|
312
|
+
deleted,
|
|
313
|
+
net: added - deleted,
|
|
314
|
+
newDeps,
|
|
315
|
+
unparsed,
|
|
316
|
+
uncitedDeps,
|
|
317
|
+
ledgerInChange: ledger.inChange,
|
|
318
|
+
receipts: summarize(ledger.results),
|
|
319
|
+
receiptFailures: ledger.results.filter((r) => r.status === 'failed'),
|
|
320
|
+
hazards,
|
|
321
|
+
footnotes,
|
|
322
|
+
loadBearing,
|
|
323
|
+
lint,
|
|
324
|
+
hooksPath: hooksPathAt(root),
|
|
325
|
+
}
|
|
85
326
|
}
|
|
86
327
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
328
|
+
// A linter that never started is neither a pass nor a failure, and `ok` alone
|
|
329
|
+
// cannot tell them apart: it stays true for `unavailable` (configured, nothing to
|
|
330
|
+
// run it with) and `not-run` (execution is off) so that `!ok` keeps meaning "the
|
|
331
|
+
// lint found something". Rendering on `ok` alone prints `eslint ✅` for a linter
|
|
332
|
+
// that never ran, which is a false pass — worse than the false "no linter
|
|
333
|
+
// configured" it replaced. So the verdict says which of the four it was.
|
|
334
|
+
// The reason a linter did not run, one line and short enough for a verdict row.
|
|
335
|
+
// `not run: X` is the entry's own phrasing and reads as a stutter under a "not
|
|
336
|
+
// run" label, so the prefix comes off.
|
|
337
|
+
const lintReason = (l) => (l.output ?? '').replace(/^not run: /, '').split('\n')[0].slice(0, 80)
|
|
338
|
+
|
|
339
|
+
const lintLine = (lint) => {
|
|
340
|
+
if (!lint || lint.length === 0) return 'no linter configured'
|
|
341
|
+
const ran = lint.filter((l) => l.status === 'ok' || l.status === 'fail')
|
|
342
|
+
const idle = lint.filter((l) => l.status !== 'ok' && l.status !== 'fail')
|
|
343
|
+
const parts = []
|
|
344
|
+
const failing = ran.filter((l) => l.status === 'fail')
|
|
345
|
+
if (failing.length) parts.push(`${failing.map((l) => l.linter).join(', ')} failing ⛔`)
|
|
346
|
+
const passing = ran.filter((l) => l.status === 'ok')
|
|
347
|
+
if (passing.length) parts.push(`${passing.map((l) => l.linter).join(', ')} ✅`)
|
|
348
|
+
if (idle.length) parts.push(`${idle.map((l) => `${l.linter} ${l.status === 'not-run' ? 'not run' : 'unavailable'} (${lintReason(l)})`).join(' · ')} ⚠`)
|
|
349
|
+
return parts.join(' · ')
|
|
96
350
|
}
|
|
97
351
|
|
|
352
|
+
const summarize = (rs) => ({
|
|
353
|
+
total: rs.length,
|
|
354
|
+
verified: rs.filter((r) => r.status === 'verified').length,
|
|
355
|
+
failed: rs.filter((r) => r.status === 'failed').length,
|
|
356
|
+
unverifiable: rs.filter((r) => r.status === 'unverifiable').length,
|
|
357
|
+
})
|
|
358
|
+
|
|
98
359
|
function render(r, fmt) {
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
360
|
+
if (fmt === 'json') return JSON.stringify(r, null, 2)
|
|
361
|
+
const binaries = r.files.filter((f) => f.binary).length
|
|
362
|
+
const unparsed = r.unparsed ?? []
|
|
102
363
|
const rows = [
|
|
103
|
-
`net: +${r.added} −${r.deleted} lines across ${r.files.length} file${r.files.length === 1 ? '' : 's'}`,
|
|
104
|
-
|
|
364
|
+
`net: +${r.added} −${r.deleted} lines across ${r.files.length} file${r.files.length === 1 ? '' : 's'}${binaries ? ` (${binaries} binary — scanned by content, git counts no lines)` : ''}`,
|
|
365
|
+
// "0" with a ✅ next to it is the overclaim: where a dependency-shaped file
|
|
366
|
+
// omit cannot read changed, nothing was counted AND nothing could be.
|
|
367
|
+
r.newDeps.length || unparsed.length === 0
|
|
368
|
+
? `new deps: ${r.newDeps.length}${r.newDeps.length ? ': ' + r.newDeps.map((d) => d.dep).join(', ') : ' ✅'}${r.uncitedDeps.length ? ` (${r.uncitedDeps.length} without receipts ⚠)` : ''}`
|
|
369
|
+
: `new deps: 0 (${unparsed.length} dependency-shaped file${unparsed.length === 1 ? '' : 's'} not parsed)`,
|
|
105
370
|
`hazards: ${r.hazards.length === 0 ? '0 ✅' : r.hazards.map((h) => `${h.type}:${h.rule}`).join(', ') + ' ⛔'}`,
|
|
106
371
|
`footnotes: ${r.footnotes} recorded · load-bearing: ${r.loadBearing} marked`,
|
|
107
|
-
`
|
|
108
|
-
`
|
|
372
|
+
`receipts: ${r.receipts.total === 0 ? 'none recorded' : `${r.receipts.verified}/${r.receipts.total} verified${r.receipts.failed ? ` · ${r.receipts.failed} FAILED ⛔` : ''}${r.receipts.unverifiable ? ` · ${r.receipts.unverifiable} unverifiable ⚠` : ''}`}`,
|
|
373
|
+
`lint: ${lintLine(r.lint)}`,
|
|
109
374
|
]
|
|
375
|
+
if (unparsed.length) rows.push(`unparsed deps: ${unparsed.join(', ')} — dependency-shaped, omit does not parse ${unparsed.length === 1 ? 'it' : 'them'} ⚠`)
|
|
376
|
+
if (r.hooksPath) rows.push(`hooks: core.hooksPath=${r.hooksPath.value} (set in ${r.hooksPath.origin}) — the pre-commit gate installed here is NOT the hook git runs ⛔`)
|
|
110
377
|
if (fmt === 'markdown') return `### omit verdict\n\n${rows.map((x) => `- ${x}`).join('\n')}\n`
|
|
111
378
|
return `omit verdict\n────────────\n${rows.join('\n')}`
|
|
112
379
|
}
|
|
113
380
|
|
|
114
381
|
function audit(args) {
|
|
115
382
|
const baseIdx = args.indexOf('--base')
|
|
383
|
+
if (baseIdx !== -1 && (!args[baseIdx + 1] || args[baseIdx + 1].startsWith('-'))) die('usage: omit audit [--base <ref>] [--json|--markdown]')
|
|
116
384
|
const base = baseIdx !== -1 ? args[baseIdx + 1] : 'HEAD'
|
|
117
385
|
const fmt = args.includes('--json') ? 'json' : args.includes('--markdown') ? 'markdown' : 'text'
|
|
118
386
|
console.log(render(collect(base), fmt))
|
|
@@ -121,13 +389,41 @@ function audit(args) {
|
|
|
121
389
|
function gate() {
|
|
122
390
|
const r = collect('--cached')
|
|
123
391
|
console.log(render(r, 'text'))
|
|
392
|
+
// A shadowed hooks directory is reported, not fatal: `core.hooksPath` is a
|
|
393
|
+
// legitimate global setup (a hooks manager, a shared hooks repo), and failing
|
|
394
|
+
// every commit over the user's own configuration would be a false objection.
|
|
395
|
+
// The row above already says the gate omit installs is not the hook git runs;
|
|
396
|
+
// `hook install` now writes where git actually looks, so the honest fix lives
|
|
397
|
+
// there rather than in refusing to work.
|
|
124
398
|
const secrets = r.hazards.filter((h) => h.type === 'secret')
|
|
125
399
|
if (secrets.length) {
|
|
126
400
|
console.error(`\n⛔ omit gate: ${secrets.length} secret(s) staged. Move to env/secrets manager, rotate if real, restage.`)
|
|
127
401
|
process.exit(1)
|
|
128
402
|
}
|
|
403
|
+
if (r.receiptFailures.length) {
|
|
404
|
+
console.error(
|
|
405
|
+
`\n⛔ omit gate: ${r.receiptFailures.length} receipt(s) in ${LEDGER} did not survive their own check — a refuted claim is a fabricated citation:\n` +
|
|
406
|
+
r.receiptFailures.map((f) => ` line ${f.at} (${f.claim ?? 'no claim'}): ${f.checks.filter((c) => c.ok === false).map((c) => c.detail).join('; ')}`).join('\n') +
|
|
407
|
+
`\nFix the receipt or the code it cites, then re-run.`
|
|
408
|
+
)
|
|
409
|
+
process.exit(1)
|
|
410
|
+
}
|
|
129
411
|
if (r.uncitedDeps.length) {
|
|
130
|
-
console.error(
|
|
412
|
+
console.error(
|
|
413
|
+
`\n⛔ omit gate: new dep(s) without a verified receipt: ${r.uncitedDeps.map((d) => d.dep).join(', ')}.` +
|
|
414
|
+
(r.ledgerInChange
|
|
415
|
+
? ''
|
|
416
|
+
: `\n ${LEDGER} is not part of this commit — receipts are read from the index, so a ledger that is not being committed cites nothing.`) +
|
|
417
|
+
`\nAdd one line to ${LEDGER} naming the dep and the omission you tried (each entry is re-checked, and must NOT hold):\n` +
|
|
418
|
+
// The printed line has to be a receipt that verifies: a template whose
|
|
419
|
+
// shape the checker rejects leaves an agent that followed it verbatim
|
|
420
|
+
// blocked on its own instructions. `absent` names a symbol the checker
|
|
421
|
+
// searches the whole tracked tree for, and the claim survives only if the
|
|
422
|
+
// search comes back empty — so replace it with the symbol you actually
|
|
423
|
+
// looked for.
|
|
424
|
+
` {"claim":"new-dep","rung":7,"dep":"${r.uncitedDeps[0].dep}","tried":[{"rung":2,"absent":"<symbol you searched for>"}]}\n` +
|
|
425
|
+
`Run \`omit verify\` to check it before committing.`
|
|
426
|
+
)
|
|
131
427
|
process.exit(1)
|
|
132
428
|
}
|
|
133
429
|
if (r.hazards.length) {
|
|
@@ -158,19 +454,29 @@ function check(files) {
|
|
|
158
454
|
}
|
|
159
455
|
|
|
160
456
|
function lint(files) {
|
|
457
|
+
// Files named on the command line are relative to where the caller stands; a
|
|
458
|
+
// list derived from git is root-relative, because that is how git reports it.
|
|
459
|
+
// Keep the two apart instead of resolving one against the other's base.
|
|
460
|
+
let base = cwd
|
|
161
461
|
if (!files.length) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
462
|
+
base = rootOrDie()
|
|
463
|
+
files = [
|
|
464
|
+
...git(base, ['diff', '--name-only', '-M', 'HEAD']).split('\n'),
|
|
465
|
+
...git(base, ['ls-files', '--others', '--exclude-standard']).split('\n'),
|
|
466
|
+
].filter((f) => f && existsSync(join(base, f)))
|
|
167
467
|
}
|
|
168
|
-
const results = lintFiles(
|
|
468
|
+
const results = lintFiles(base, files)
|
|
169
469
|
if (results.length === 0) {
|
|
170
470
|
console.log('omit lint: no configured linter applies to these files')
|
|
171
471
|
return
|
|
172
472
|
}
|
|
173
|
-
for (const r of results)
|
|
473
|
+
for (const r of results) {
|
|
474
|
+
// Same distinction as the verdict: a linter that did not run gets neither a
|
|
475
|
+
// pass nor a failure, and exits 0 — it is unrun, not clean.
|
|
476
|
+
if (!r.ok) console.log(`[${r.linter}] fail ⛔\n${r.output}`)
|
|
477
|
+
else if (r.status === 'ok') console.log(`[${r.linter}] pass ✅`)
|
|
478
|
+
else console.log(`[${r.linter}] not run ⚠ — ${lintReason(r)}`)
|
|
479
|
+
}
|
|
174
480
|
process.exit(results.some((r) => !r.ok) ? 1 : 0)
|
|
175
481
|
}
|
|
176
482
|
|
|
@@ -189,14 +495,103 @@ function guard(args) {
|
|
|
189
495
|
process.exit(1)
|
|
190
496
|
}
|
|
191
497
|
|
|
498
|
+
function leak(args) {
|
|
499
|
+
const command = args.join(' ')
|
|
500
|
+
if (!command) {
|
|
501
|
+
console.error('usage: omit leak "<shell command>"')
|
|
502
|
+
process.exit(1)
|
|
503
|
+
}
|
|
504
|
+
const findings = assessLeak(command)
|
|
505
|
+
if (findings.length === 0) {
|
|
506
|
+
console.log('ok')
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
for (const f of findings) console.error(`[${f.rule}] ${f.reason}`)
|
|
510
|
+
process.exit(1)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Codex CLI's hook schema is the same shape as Claude Code's (confirmed against
|
|
514
|
+
// developers.openai.com/codex/hooks: PreToolUse fires with tool_input.command for
|
|
515
|
+
// Bash, exit 2 blocks). Command-sentinel and leak-sentinel run on that shape as-is.
|
|
516
|
+
// The PostToolUse file hooks (dep/hazard/lint-sentinel) and the Stop gate are wired
|
|
517
|
+
// too since Codex documents the same events, but Codex's apply_patch tool_input
|
|
518
|
+
// shape for PostToolUse isn't confirmed here — those three no-op safely if the
|
|
519
|
+
// file_path field isn't present, so this is best-effort, not verified parity.
|
|
520
|
+
function hookInstallCodex() {
|
|
521
|
+
const dir = '.codex'
|
|
522
|
+
const hooksPath = join(dir, 'hooks.json')
|
|
523
|
+
mkdirSync(dir, { recursive: true })
|
|
524
|
+
|
|
525
|
+
let doc = { hooks: {} }
|
|
526
|
+
if (existsSync(hooksPath)) {
|
|
527
|
+
try {
|
|
528
|
+
doc = JSON.parse(readFileSync(hooksPath, 'utf8'))
|
|
529
|
+
} catch {
|
|
530
|
+
console.error(`omit: ${hooksPath} exists but isn't valid JSON — fix or remove it first`)
|
|
531
|
+
process.exit(1)
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
doc.hooks ??= {}
|
|
535
|
+
for (const [event, entry] of Object.entries(doc.hooks)) {
|
|
536
|
+
if (entry !== undefined && !Array.isArray(entry)) {
|
|
537
|
+
console.error(`omit: ${hooksPath} has a malformed "${event}" entry (expected an array of matcher groups) — fix or remove it first`)
|
|
538
|
+
process.exit(1)
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const scriptCmd = (script) => `node "${join(pkgRoot, 'hooks', script)}"`
|
|
543
|
+
const mergeHook = (event, matcher, script) => {
|
|
544
|
+
doc.hooks[event] ??= []
|
|
545
|
+
let group = doc.hooks[event].find((g) => g.matcher === matcher)
|
|
546
|
+
if (!group) {
|
|
547
|
+
group = { matcher, hooks: [] }
|
|
548
|
+
doc.hooks[event].push(group)
|
|
549
|
+
}
|
|
550
|
+
const command = scriptCmd(script)
|
|
551
|
+
if (!group.hooks.some((h) => h.command === command)) group.hooks.push({ type: 'command', command })
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
mergeHook('PreToolUse', 'Bash', 'command-sentinel.mjs')
|
|
555
|
+
mergeHook('PreToolUse', 'Bash', 'leak-sentinel.mjs')
|
|
556
|
+
mergeHook('PostToolUse', 'apply_patch|Edit|Write', 'dep-sentinel.mjs')
|
|
557
|
+
mergeHook('PostToolUse', 'apply_patch|Edit|Write', 'hazard-sentinel.mjs')
|
|
558
|
+
mergeHook('PostToolUse', 'apply_patch|Edit|Write', 'lint-sentinel.mjs')
|
|
559
|
+
mergeHook('Stop', '', 'final-draft-gate.mjs')
|
|
560
|
+
|
|
561
|
+
writeFileSync(hooksPath, JSON.stringify(doc, null, 2) + '\n')
|
|
562
|
+
console.log(`wrote ${hooksPath}`)
|
|
563
|
+
console.log(' verified against Codex\'s documented schema: command sentinel + leak sentinel run on Bash commands (same tool_input.command shape as Claude Code)')
|
|
564
|
+
console.log(' best-effort, unverified: dep/hazard/lint sentinels + Final Draft gate on apply_patch/Edit/Write — they no-op safely if the field shape differs, report back if you see them miss real edits')
|
|
565
|
+
console.log('\nCodex requires trusting new hook definitions once per session: run `/hooks` in Codex to review, or start with --dangerously-bypass-hook-trust for unattended runs.')
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// The gate goes in the repository's own hooks directory — always, never into
|
|
569
|
+
// `core.hooksPath`. That setting is global on most machines that use it (a
|
|
570
|
+
// hooks manager, a shared hooks repo), so writing there would install ONE
|
|
571
|
+
// pre-commit gate for EVERY repository on the machine when the user asked to
|
|
572
|
+
// gate this one. `hookInstall` reports the shadowing instead and leaves that
|
|
573
|
+
// choice to the user.
|
|
574
|
+
function hooksDir() {
|
|
575
|
+
return join('.git', 'hooks')
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// `core.hooksPath` and the file it came from, or null when it is not set.
|
|
579
|
+
function hooksPathShadow() {
|
|
580
|
+
const r = probe(process.cwd(), ['config', '--show-origin', '--get', 'core.hooksPath'])
|
|
581
|
+
if (!r.ok) return null
|
|
582
|
+
const [origin, value] = r.out.trim().split('\t')
|
|
583
|
+
return value ? { origin, value } : null
|
|
584
|
+
}
|
|
585
|
+
|
|
192
586
|
function hookInstall() {
|
|
193
|
-
const hookPath = join('.git', 'hooks', 'pre-commit')
|
|
194
587
|
if (!existsSync('.git')) {
|
|
195
588
|
console.error('omit: not a git repository')
|
|
196
589
|
process.exit(1)
|
|
197
590
|
}
|
|
591
|
+
const dir = hooksDir()
|
|
592
|
+
const hookPath = join(dir, 'pre-commit')
|
|
198
593
|
if (existsSync(hookPath) && readFileSync(hookPath, 'utf8').includes('omit gate')) {
|
|
199
|
-
console.log(
|
|
594
|
+
console.log(`skip pre-commit gate already installed at ${hookPath}`)
|
|
200
595
|
return
|
|
201
596
|
}
|
|
202
597
|
const line = '\nnpx -y @sriinnu/omit gate || exit 1\n'
|
|
@@ -206,20 +601,85 @@ function hookInstall() {
|
|
|
206
601
|
writeFileSync(hookPath, '#!/bin/sh' + line)
|
|
207
602
|
}
|
|
208
603
|
chmodSync(hookPath, 0o755)
|
|
209
|
-
console.log(
|
|
604
|
+
console.log(`wrote ${hookPath}: every commit now passes the omit gate, whatever agent wrote it`)
|
|
605
|
+
const shadow = hooksPathShadow()
|
|
606
|
+
if (shadow) {
|
|
607
|
+
console.log(
|
|
608
|
+
`\n⚠ core.hooksPath is set to ${shadow.value} (${shadow.origin}), so git will not run ${hookPath}.\n` +
|
|
609
|
+
' omit installs into the repository, not into a global hooks directory: one gate for every\n' +
|
|
610
|
+
' repo on this machine is not what "install the gate here" means.\n' +
|
|
611
|
+
` To make this gate effective here: git config core.hooksPath .git/hooks\n` +
|
|
612
|
+
` To use your global directory instead, add this line to ${shadow.value}/pre-commit:\n` +
|
|
613
|
+
' npx -y @sriinnu/omit gate || exit 1'
|
|
614
|
+
)
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// The manifests the working tree change touches, tracked or not. `verify` needs
|
|
619
|
+
// them because "no claims recorded" and "nothing to check" are not the same
|
|
620
|
+
// thing: a manifest that changed with no ledger at all is the case this
|
|
621
|
+
// mechanism exists to catch.
|
|
622
|
+
function changedManifests(root) {
|
|
623
|
+
const out = new Set()
|
|
624
|
+
for (const f of git(root, ['ls-files', '--others', '--exclude-standard']).split('\n')) if (f && isManifest(f)) out.add(f)
|
|
625
|
+
if (probe(root, ['rev-parse', '--verify', '--quiet', 'HEAD']).ok) {
|
|
626
|
+
for (const f of git(root, ['diff', '--name-only', '-M', 'HEAD']).split('\n')) if (f && isManifest(f)) out.add(f)
|
|
627
|
+
}
|
|
628
|
+
return [...out]
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Every claim in the ledger, re-checked. This is the command the whole receipts
|
|
632
|
+
// mechanism exists for: `verify` passes only when nothing was taken on faith.
|
|
633
|
+
function verify() {
|
|
634
|
+
const root = repoRoot(cwd)
|
|
635
|
+
const results = verifyLedger(root ?? cwd)
|
|
636
|
+
if (results.length === 0) {
|
|
637
|
+
const manifests = root ? changedManifests(root) : []
|
|
638
|
+
if (manifests.length) {
|
|
639
|
+
die(`no ${LEDGER}, but ${manifests.join(', ')} changed — a manifest change with no receipt is the thing this checks, and it used to read as "nothing to check". Record a receipt in ${LEDGER}, or revert the manifest change.`)
|
|
640
|
+
}
|
|
641
|
+
console.log(`omit verify: no ${LEDGER} — no claims recorded, nothing to check`)
|
|
642
|
+
return
|
|
643
|
+
}
|
|
644
|
+
for (const r of results) {
|
|
645
|
+
console.log(`${r.status === 'verified' ? '✅' : r.status === 'failed' ? '⛔' : '⚠ '} line ${String(r.at).padStart(3)} ${r.status.padEnd(13)} ${r.claim ?? '(no claim)'}`)
|
|
646
|
+
for (const c of r.checks) if (c.ok !== true) console.log(` [${c.kind}] ${c.detail}`)
|
|
647
|
+
}
|
|
648
|
+
const s = summarize(results)
|
|
649
|
+
console.log(`\n${s.verified}/${s.total} claims survived re-checking · ${s.failed} refuted · ${s.unverifiable} unverifiable`)
|
|
650
|
+
if (s.failed || s.unverifiable) {
|
|
651
|
+
console.error('omit verify: every claim has to survive its own check. A refuted one is a citation that does not hold; an unverifiable one is still a self-report.')
|
|
652
|
+
process.exit(1)
|
|
653
|
+
}
|
|
210
654
|
}
|
|
211
655
|
|
|
212
656
|
// ---------- dispatch ----------
|
|
657
|
+
// A git call that could not answer reaches here as a throw, and it exits non-zero
|
|
658
|
+
// on purpose: a verdict rendered from a diff that could not be read is the
|
|
659
|
+
// failure this whole contract exists to prevent, and a gate that cannot read the
|
|
660
|
+
// change has to block it rather than pass it.
|
|
213
661
|
const [cmd, ...rest] = process.argv.slice(2)
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
else if (cmd === '
|
|
217
|
-
else if (cmd === '
|
|
218
|
-
else if (cmd === '
|
|
219
|
-
else if (cmd === '
|
|
220
|
-
else if (cmd === '
|
|
221
|
-
else if (
|
|
222
|
-
else
|
|
223
|
-
|
|
224
|
-
|
|
662
|
+
try {
|
|
663
|
+
if (cmd === 'audit') audit(rest)
|
|
664
|
+
else if (cmd === 'gate') gate()
|
|
665
|
+
else if (cmd === 'check') check(rest)
|
|
666
|
+
else if (cmd === 'lint') lint(rest)
|
|
667
|
+
else if (cmd === 'guard') guard(rest)
|
|
668
|
+
else if (cmd === 'leak') leak(rest)
|
|
669
|
+
else if (cmd === 'verify') verify()
|
|
670
|
+
else if (cmd === 'hook' && rest[0] === 'install' && rest[1] === 'codex') hookInstallCodex()
|
|
671
|
+
else if (cmd === 'hook' && rest[0] === 'install' && rest[1] === undefined) hookInstall()
|
|
672
|
+
else if (cmd === 'hook' && rest[0] === 'install') {
|
|
673
|
+
console.error(`omit: unrecognized 'hook install' target '${rest[1]}' — usage: omit hook install [codex]`)
|
|
674
|
+
process.exit(1)
|
|
675
|
+
}
|
|
676
|
+
else if (cmd === 'init') init(rest[0])
|
|
677
|
+
else if (targets[cmd]) init(cmd) // back-compat: `omit cursor`
|
|
678
|
+
else {
|
|
679
|
+
console.error('usage: omit <init|audit|check|gate|lint|guard|leak|verify|hook install|hook install codex>')
|
|
680
|
+
process.exit(cmd ? 1 : 0)
|
|
681
|
+
}
|
|
682
|
+
} catch (e) {
|
|
683
|
+
if (!(e instanceof GitError)) throw e
|
|
684
|
+
die(`git could not answer: ${e.message}\nNothing was checked. Fix the repository state (or the revision named), then re-run.`)
|
|
225
685
|
}
|