@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
|
@@ -7,6 +7,21 @@ import { assessCommand } from '../lib/danger.mjs'
|
|
|
7
7
|
|
|
8
8
|
if (process.env.OMIT_OFF === '1') process.exit(0)
|
|
9
9
|
|
|
10
|
+
// Claude Code's hook contract: exit 2 blocks the tool call, and every other
|
|
11
|
+
// non-zero code is a NON-BLOCKING error — the model sees it and can carry on.
|
|
12
|
+
// So the only safe exit for a hook that did not finish its job is 2. A crash at
|
|
13
|
+
// exit 1 is this hook silently not running, which is the one outcome it exists
|
|
14
|
+
// to prevent; a non-string `command` used to throw out of assessCommand and let
|
|
15
|
+
// the command through.
|
|
16
|
+
const block = (message) => {
|
|
17
|
+
console.error(message)
|
|
18
|
+
process.exit(2)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The payload comes from the harness, not the agent, so a payload we cannot even
|
|
22
|
+
// parse is not evidence about anything the agent did — and harnesses do not agree
|
|
23
|
+
// on shape (Codex's apply_patch sends different fields). Skip, do not block the
|
|
24
|
+
// session on it.
|
|
10
25
|
let data
|
|
11
26
|
try {
|
|
12
27
|
data = JSON.parse(readFileSync(0, 'utf8'))
|
|
@@ -14,16 +29,29 @@ try {
|
|
|
14
29
|
process.exit(0)
|
|
15
30
|
}
|
|
16
31
|
|
|
17
|
-
|
|
18
|
-
|
|
32
|
+
try {
|
|
33
|
+
const command = data.tool_input?.command
|
|
34
|
+
if (command === undefined || command === null || command === '') process.exit(0) // not a shell tool call
|
|
35
|
+
if (typeof command !== 'string') {
|
|
36
|
+
block(
|
|
37
|
+
'omit blocks this command: its text was not a string, so nothing about it could be assessed.\n' +
|
|
38
|
+
'Send the command as a string, or set OMIT_OFF=1 to run without the sentinel.'
|
|
39
|
+
)
|
|
40
|
+
}
|
|
19
41
|
|
|
20
|
-
const findings = assessCommand(command)
|
|
21
|
-
if (findings.length === 0) process.exit(0)
|
|
42
|
+
const findings = assessCommand(command)
|
|
43
|
+
if (findings.length === 0) process.exit(0)
|
|
22
44
|
|
|
23
|
-
console.error(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
)
|
|
29
|
-
process.exit(2)
|
|
45
|
+
console.error(
|
|
46
|
+
'omit blocks this command: the machine is load-bearing.\n' +
|
|
47
|
+
findings.map((f) => ` [${f.rule}] ${f.reason}`).join('\n') +
|
|
48
|
+
'\nUse a narrower target inside the workspace, guard variables with ${VAR:?}, ' +
|
|
49
|
+
'or, only after genuine review with the user, append: # omit-allow: <reason>'
|
|
50
|
+
)
|
|
51
|
+
process.exit(2)
|
|
52
|
+
} catch (e) {
|
|
53
|
+
block(
|
|
54
|
+
`omit could not assess this command (${e?.message ?? e}).\n` +
|
|
55
|
+
'Blocking the command rather than waving it through: a check that did not run is not a check that passed.'
|
|
56
|
+
)
|
|
57
|
+
}
|
package/hooks/dep-sentinel.mjs
CHANGED
|
@@ -1,45 +1,199 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// PostToolUse hook: raises a blocking objection when a new dependency lands
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
// PostToolUse hook: raises a blocking objection when a new dependency lands in
|
|
3
|
+
// a manifest without a receipt in .omit/receipts.jsonl.
|
|
4
|
+
//
|
|
5
|
+
// Fires on file edits (Edit|Write|MultiEdit|NotebookEdit) and on Bash: a
|
|
6
|
+
// manifest is also rewritten by `npm pkg set`, a redirect, `tee` or `sed -i`,
|
|
7
|
+
// and the same change through a different tool is the same change.
|
|
8
|
+
import { readFileSync, realpathSync } from 'node:fs'
|
|
9
|
+
import { basename, resolve } from 'node:path'
|
|
10
|
+
import { isManifest, addedDeps, MANIFESTS } from '../lib/deps.mjs'
|
|
11
|
+
import { probe, fileAtRevision, repoRelPath } from '../lib/git.mjs'
|
|
12
|
+
import { execDisabled, flagOn } from '../lib/exec.mjs'
|
|
13
|
+
import { newDepCitations } from '../lib/receipts.mjs'
|
|
8
14
|
|
|
9
15
|
if (process.env.OMIT_OFF === '1') process.exit(0)
|
|
10
16
|
|
|
17
|
+
// Exit 2 blocks the tool call; any other non-zero code is a non-blocking error
|
|
18
|
+
// the model can shrug off. This hook's whole value is the objection, so nothing
|
|
19
|
+
// here is allowed to end in an uncaught throw.
|
|
20
|
+
const block = (message) => {
|
|
21
|
+
console.error(message)
|
|
22
|
+
process.exit(2)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The ledger is executable in the Makefile sense, and `.omit/receipts.jsonl` is
|
|
26
|
+
// a file the agent under check can Write. Confirmed: a Write of the manifest plus
|
|
27
|
+
// a Write of the ledger ran the receipt's `run` argv as the developer from this
|
|
28
|
+
// PostToolUse hook — no Bash call, no permission prompt, exit 0. So the hook
|
|
29
|
+
// declines to execute ledger snippets by default and the operator opts in per
|
|
30
|
+
// project. OMIT_NO_EXEC is not set here but in the environment of this process,
|
|
31
|
+
// which is the same flag lib/receipts.mjs `execDisabled()` reads; the off-values
|
|
32
|
+
// are kept identical on purpose ( '', '0', 'false' mean "the flag is off" ), so
|
|
33
|
+
// OMIT_HOOK_EXEC=0 really does mean no execution.
|
|
34
|
+
const mayExec = flagOn(process.env.OMIT_HOOK_EXEC)
|
|
35
|
+
if (!mayExec) process.env.OMIT_NO_EXEC = '1'
|
|
36
|
+
|
|
37
|
+
// repoRoot() answers null both for "not a repository" and for "git could not
|
|
38
|
+
// answer", and those want opposite exits: no repository means there is no HEAD
|
|
39
|
+
// to compare against, while a git that cannot run at all (missing binary, dubious
|
|
40
|
+
// ownership, unreadable object store) means the check never happened. git's own
|
|
41
|
+
// message is the only thing that separates the two, so match on it and fail
|
|
42
|
+
// closed on everything else.
|
|
43
|
+
function repoRootOrNull(cwd) {
|
|
44
|
+
const r = probe(cwd, ['rev-parse', '--show-toplevel'])
|
|
45
|
+
if (r.ok) return r.out.trim()
|
|
46
|
+
if (/not a git repository|must be run in a work tree/i.test(String(r.reason))) return null
|
|
47
|
+
throw new Error(`git could not report the repository root (${r.reason})`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The manifests a shell command plausibly rewrote, decided by pure string work:
|
|
51
|
+
// this runs on every Bash call, so nothing may spawn a process until a manifest
|
|
52
|
+
// is actually in play. Two signals — a package manager whose subcommand writes a
|
|
53
|
+
// manifest, and a watched manifest filename anywhere in the command (which is
|
|
54
|
+
// what `cat > package.json`, `tee`, `sed -i` and `cp` look like).
|
|
55
|
+
const MANAGER_WRITES = [
|
|
56
|
+
[/\b(?:npm|pnpm|yarn|bun)\s+(?:i|install|add|remove|rm|uninstall|upgrade|update|pkg|link)\b/, 'package.json'],
|
|
57
|
+
[/\b(?:poetry|uv|pipenv)\s+(?:add|remove|install)\b/, 'pyproject.toml'],
|
|
58
|
+
[/\bcargo\s+(?:add|remove|install)\b/, 'Cargo.toml'],
|
|
59
|
+
[/\bgo\s+(?:get|mod\s+(?:tidy|edit))\b/, 'go.mod'],
|
|
60
|
+
[/\b(?:bundle|bundler)\s+(?:add|install|update)\b/, 'Gemfile'],
|
|
61
|
+
[/\bcomposer\s+(?:require|remove|update|install)\b/, 'composer.json'],
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
function manifestsTouched(command) {
|
|
65
|
+
const out = new Set()
|
|
66
|
+
for (const [re, name] of MANAGER_WRITES) if (re.test(command)) out.add(name)
|
|
67
|
+
for (const name of MANIFESTS) {
|
|
68
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
69
|
+
const m = command.match(new RegExp(`(?:^|[\\s'"=<>|(])([\\w./\\\\-]*${escaped})`, 'i'))
|
|
70
|
+
if (m) out.add(m[1])
|
|
71
|
+
}
|
|
72
|
+
return [...out]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// repoRelPath is lexical, and the path the harness sends and the root git
|
|
76
|
+
// reports do not always agree on symlinks: on macOS `/tmp` and `/var` are links
|
|
77
|
+
// into `/private`, and git answers with the resolved root. A mismatch reads as
|
|
78
|
+
// "outside the repo" — which exits 0, a manifest edit silently unaudited. So
|
|
79
|
+
// resolve the file and ask again before giving up on it.
|
|
80
|
+
function relInRoot(root, abs) {
|
|
81
|
+
const lexical = repoRelPath(root, abs)
|
|
82
|
+
if (lexical !== null) return lexical
|
|
83
|
+
try {
|
|
84
|
+
return repoRelPath(root, realpathSync(abs))
|
|
85
|
+
} catch {
|
|
86
|
+
return null // no such file: nothing to compare, nothing added
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A manifest we cannot read as text declares nothing, so nothing was added to it.
|
|
91
|
+
// Deleting or replacing it with a directory is not a dependency landing.
|
|
92
|
+
function readManifest(abs) {
|
|
93
|
+
try {
|
|
94
|
+
return readFileSync(abs, 'utf8')
|
|
95
|
+
} catch (e) {
|
|
96
|
+
if (e.code === 'ENOENT' || e.code === 'EISDIR') return null
|
|
97
|
+
throw e
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
11
101
|
let data
|
|
12
102
|
try {
|
|
13
103
|
data = JSON.parse(readFileSync(0, 'utf8'))
|
|
14
104
|
} catch {
|
|
15
|
-
process.exit(0)
|
|
105
|
+
process.exit(0) // harness-authored payload we cannot read: not evidence about a file
|
|
16
106
|
}
|
|
17
107
|
|
|
18
|
-
const file = data.tool_input?.file_path
|
|
19
|
-
if (!file || !isManifest(file)) process.exit(0)
|
|
20
|
-
|
|
21
|
-
const cwd = data.cwd ?? process.cwd()
|
|
22
|
-
let diff = ''
|
|
23
108
|
try {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
109
|
+
const ti = data.tool_input ?? {}
|
|
110
|
+
for (const f of ['file_path', 'notebook_path', 'command']) {
|
|
111
|
+
if (ti[f] !== undefined && typeof ti[f] !== 'string') {
|
|
112
|
+
block(
|
|
113
|
+
`omit blocks this edit: the hook was handed a \`${f}\` that is not a path or a command, so no manifest behind it could be checked.\n` +
|
|
114
|
+
'Send the tool input as a string, or set OMIT_OFF=1 to run without the sentinel.'
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const cwd = data.cwd ?? process.cwd()
|
|
120
|
+
const targets =
|
|
121
|
+
typeof ti.file_path === 'string' ? (isManifest(ti.file_path) ? [ti.file_path] : []) : typeof ti.command === 'string' ? manifestsTouched(ti.command) : []
|
|
122
|
+
if (targets.length === 0) process.exit(0) // an edit that cannot be a manifest edit
|
|
28
123
|
|
|
29
|
-
const
|
|
30
|
-
if (
|
|
124
|
+
const root = repoRootOrNull(cwd)
|
|
125
|
+
if (root === null) process.exit(0) // not a repository: no history to compare against
|
|
31
126
|
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
127
|
+
const citations = newDepCitations(root)
|
|
128
|
+
const uncited = new Map()
|
|
129
|
+
for (const target of targets) {
|
|
130
|
+
const abs = resolve(cwd, target)
|
|
131
|
+
const rel = relInRoot(root, abs)
|
|
132
|
+
if (rel === null) continue // outside the repo: not ours to audit
|
|
133
|
+
const after = readManifest(abs)
|
|
134
|
+
if (after === null) continue
|
|
135
|
+
const before = fileAtRevision(root, 'HEAD', rel)
|
|
136
|
+
// `{failed:true}` is not an empty file. Rendering it as one would turn every
|
|
137
|
+
// dependency in the manifest into a "new" one, or hide the added one
|
|
138
|
+
// entirely: either way the hook would be reporting on a comparison it never
|
|
139
|
+
// made. Throw, and let the wrapper object.
|
|
140
|
+
if (before.failed) throw new Error(`git could not read ${rel} at HEAD (${before.reason})`)
|
|
35
141
|
|
|
36
|
-
|
|
37
|
-
|
|
142
|
+
// Compare declared dependency SETS across the two revisions rather than
|
|
143
|
+
// parsing the diff: a brand-new manifest has no earlier revision, so
|
|
144
|
+
// everything in it is new, and a minified manifest declares the same set as
|
|
145
|
+
// a pretty-printed one.
|
|
146
|
+
for (const dep of addedDeps(basename(rel), before.present ? before.text : '', after)) {
|
|
147
|
+
const status = citations.get(dep)?.status
|
|
148
|
+
// Only a VERIFIED new-dep receipt cites a dependency — a ledger line that
|
|
149
|
+
// merely names it is an assertion, which is what this hook exists to stop
|
|
150
|
+
// accepting.
|
|
151
|
+
if (status !== 'verified' && !uncited.has(dep)) uncited.set(dep, status ?? null)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (uncited.size === 0) process.exit(0)
|
|
38
155
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
156
|
+
const deps = [...uncited]
|
|
157
|
+
const why = deps
|
|
158
|
+
.map(([dep, status]) =>
|
|
159
|
+
status === 'failed'
|
|
160
|
+
? ` ${dep}: its receipt was refuted by its own check`
|
|
161
|
+
: status === 'unverifiable'
|
|
162
|
+
? ` ${dep}: its receipt could not be verified`
|
|
163
|
+
: ` ${dep}: no receipt names it`
|
|
164
|
+
)
|
|
165
|
+
.join('\n')
|
|
166
|
+
|
|
167
|
+
const decl = deps.length > 1 ? 'ies' : 'y'
|
|
168
|
+
console.error(
|
|
169
|
+
`omit objects: new dependenc${decl} without a verified receipt: ${deps.map(([d]) => d).join(', ')}.\n` +
|
|
170
|
+
why +
|
|
171
|
+
'\n' +
|
|
172
|
+
// Honest when it declines to execute: a receipt whose only evidence is a
|
|
173
|
+
// `run` snippet was not checked, and saying it is unverified is true where
|
|
174
|
+
// "verified" or silence would not be. It names which switch did the
|
|
175
|
+
// declining, so the reader is not told to set a flag they already set.
|
|
176
|
+
(execDisabled()
|
|
177
|
+
? 'Ledger snippets were NOT executed here: ' +
|
|
178
|
+
(mayExec
|
|
179
|
+
? 'OMIT_NO_EXEC is set in this environment.\n'
|
|
180
|
+
: 'a hook does not run `run` argv by default, because a Write of .omit/receipts.jsonl would\n' +
|
|
181
|
+
'otherwise execute as you, with no approval prompt. Set OMIT_HOOK_EXEC=1 to let this hook run one.\n') +
|
|
182
|
+
'A receipt whose evidence is a `run` snippet is unverified here, not refuted — check it with `omit verify`.\n'
|
|
183
|
+
: '') +
|
|
184
|
+
`Omissions 2-5 come first: show the codebase, stdlib, platform, and installed deps cannot cover this.\n` +
|
|
185
|
+
`If they truly cannot, append one line to .omit/receipts.jsonl — the entries under "tried" are re-checked, and must NOT hold:\n` +
|
|
186
|
+
// The printed line has to be a receipt that verifies. `absent` names a
|
|
187
|
+
// symbol the checker searches the whole tracked tree for, so replace it
|
|
188
|
+
// with the one you actually looked for; the claim survives only if that
|
|
189
|
+
// search comes back empty.
|
|
190
|
+
` {"claim":"new-dep","rung":7,"dep":"${deps[0][0]}","tried":[{"rung":2,"absent":"<symbol you searched for>"}]}\n` +
|
|
191
|
+
`\`omit verify\` checks it before you commit. Otherwise revert the manifest change.`
|
|
192
|
+
)
|
|
193
|
+
process.exit(2)
|
|
194
|
+
} catch (e) {
|
|
195
|
+
block(
|
|
196
|
+
`omit could not complete the new-dependency check (${e?.message ?? e}).\n` +
|
|
197
|
+
'Blocking the edit rather than accepting a manifest change nothing audited: a check that did not run is not a check that passed.'
|
|
198
|
+
)
|
|
199
|
+
}
|
|
@@ -1,52 +1,246 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Stop hook: the turn may not end with an edited working tree and no Final Draft.
|
|
3
3
|
// The deletion pass is a gate, not a suggestion.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
//
|
|
5
|
+
// The draft has to be a REPORT, not a file. Existence and mtime alone let a
|
|
6
|
+
// 0-byte .omit/final-draft.md satisfy a gate whose promise is that a session
|
|
7
|
+
// cannot end with an edited tree and no net report, so the counts the skill asks
|
|
8
|
+
// for — files touched, lines added/removed, new dependencies — are read out of
|
|
9
|
+
// the draft and cross-checked against the tree.
|
|
10
|
+
import { readFileSync, lstatSync, statSync } from 'node:fs'
|
|
11
|
+
import { basename, join } from 'node:path'
|
|
12
|
+
import { probe, git, fileAtRevision } from '../lib/git.mjs'
|
|
13
|
+
import { isManifest, addedDeps } from '../lib/deps.mjs'
|
|
7
14
|
|
|
8
15
|
if (process.env.OMIT_OFF === '1') process.exit(0)
|
|
9
16
|
|
|
17
|
+
// Exit 2 blocks the stop (the model gets another turn); anything else non-zero is
|
|
18
|
+
// a non-blocking error and the session ends on an unchecked report.
|
|
19
|
+
const block = (message) => {
|
|
20
|
+
console.error(message)
|
|
21
|
+
process.exit(2)
|
|
22
|
+
}
|
|
23
|
+
|
|
10
24
|
let data = {}
|
|
11
25
|
try {
|
|
12
26
|
data = JSON.parse(readFileSync(0, 'utf8'))
|
|
13
27
|
} catch {}
|
|
14
28
|
if (data.stop_hook_active) process.exit(0) // never loop the gate
|
|
15
29
|
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
30
|
+
const DRAFT_MAX = 1 << 20
|
|
31
|
+
|
|
32
|
+
// repoRoot() answers null for "not a repository" and for "git could not answer",
|
|
33
|
+
// which want opposite exits — see dep-sentinel for the full note.
|
|
34
|
+
function repoRootOrNull(cwd) {
|
|
35
|
+
const r = probe(cwd, ['rev-parse', '--show-toplevel'])
|
|
36
|
+
if (r.ok) return r.out.trim()
|
|
37
|
+
if (/not a git repository|must be run in a work tree/i.test(String(r.reason))) return null
|
|
38
|
+
throw new Error(`git could not report the repository root (${r.reason})`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Text, or null when there is nothing to count (binary, unreadable, gone).
|
|
42
|
+
function readText(path) {
|
|
43
|
+
try {
|
|
44
|
+
const text = readFileSync(path, 'utf8')
|
|
45
|
+
return text.includes('\0') ? null : text
|
|
46
|
+
} catch {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// omit counts a file's lines the way an editor does: a trailing newline ends the
|
|
52
|
+
// last line rather than starting an empty one.
|
|
53
|
+
const lineCount = (text) => {
|
|
54
|
+
const lines = text.split('\n')
|
|
55
|
+
if (lines[lines.length - 1] === '') lines.pop()
|
|
56
|
+
return lines.length
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Everything `omit audit` counts, per file: tracked changes (staged or not —
|
|
60
|
+
// `git diff HEAD` sees both) plus untracked files, which `git diff` cannot see
|
|
61
|
+
// and which are most of a working tree mid-session. Binary and unreadable files
|
|
62
|
+
// carry no line counts and are left out, which is the same choice audit makes —
|
|
63
|
+
// that is what lets a draft pasting audit's verdict match unchanged.
|
|
64
|
+
function changeSet(root) {
|
|
65
|
+
const rows = []
|
|
66
|
+
let binaries = 0
|
|
67
|
+
for (const line of git(root, ['diff', 'HEAD', '--numstat']).split('\n').filter(Boolean)) {
|
|
68
|
+
const [a, d, rel] = line.split('\t')
|
|
69
|
+
if (a === '-') {
|
|
70
|
+
binaries++
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
rows.push({ rel, added: +a, deleted: +d, untracked: false })
|
|
74
|
+
}
|
|
75
|
+
for (const rel of git(root, ['ls-files', '--others', '--exclude-standard']).split('\n').filter(Boolean)) {
|
|
76
|
+
// omit's own bookkeeping is not the change, and audit skips it the same way —
|
|
77
|
+
// that is what lets a draft pasting audit's verdict match this count.
|
|
78
|
+
// omitted: the same for a tracked .omit/ change, which audit does count; the
|
|
79
|
+
// caller excludes it from `changed` and keeps this set for that comparison.
|
|
80
|
+
if (rel.startsWith('.omit/')) continue
|
|
81
|
+
const text = readText(join(root, rel))
|
|
82
|
+
if (text === null) continue
|
|
83
|
+
rows.push({ rel, added: lineCount(text), deleted: 0, untracked: true })
|
|
84
|
+
}
|
|
85
|
+
return { rows, binaries }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The counts the report is checked against. `files` is the size of the set, so
|
|
89
|
+
// every call here passes the set it wants counted.
|
|
90
|
+
function counts(root, rows) {
|
|
91
|
+
let added = 0
|
|
92
|
+
let deleted = 0
|
|
93
|
+
let deps = 0
|
|
94
|
+
for (const r of rows) {
|
|
95
|
+
added += r.added
|
|
96
|
+
deleted += r.deleted
|
|
97
|
+
if (!isManifest(r.rel)) continue
|
|
98
|
+
const after = readText(join(root, r.rel)) ?? '' // deleted or unreadable: declares nothing
|
|
99
|
+
const before = fileAtRevision(root, 'HEAD', r.rel)
|
|
100
|
+
// `{failed:true}` is not an empty manifest: reading it as one would turn
|
|
101
|
+
// every dependency it declares into a "new" one.
|
|
102
|
+
if (before.failed) throw new Error(`git could not read ${r.rel} at HEAD (${before.reason}) — the new-dependency count would be a guess`)
|
|
103
|
+
deps += addedDeps(basename(r.rel), before.present ? before.text : '', after).length
|
|
104
|
+
}
|
|
105
|
+
return { files: rows.length, added, deleted, deps }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The draft is untrusted input like the ledger: a symlink to /dev/zero (or any
|
|
109
|
+
// pipe) would hang the Stop hook forever. lstat, not stat, so nothing is
|
|
110
|
+
// followed, and the size is capped rather than read into memory.
|
|
111
|
+
function readDraft(path) {
|
|
112
|
+
const st = lstatSync(path)
|
|
113
|
+
if (!st.isFile()) throw new Error(`${path} is not a regular file`)
|
|
114
|
+
if (st.size > DRAFT_MAX) throw new Error(`${path} is ${st.size} bytes, over the ${DRAFT_MAX}-byte cap`)
|
|
115
|
+
return { text: readFileSync(path, 'utf8'), mtimeMs: st.mtimeMs }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Form is read leniently — audit's own verdict line parses, and so does prose like
|
|
119
|
+
// "files touched: 2" — content strictly: all three counts must be stated, because
|
|
120
|
+
// a report that cannot be checked is not a net report.
|
|
121
|
+
const firstInt = (text, patterns) => {
|
|
122
|
+
for (const re of patterns) {
|
|
123
|
+
const m = text.match(re)
|
|
124
|
+
if (m) return +m[1]
|
|
125
|
+
}
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const firstPair = (text, patterns) => {
|
|
130
|
+
for (const re of patterns) {
|
|
131
|
+
const m = text.match(re)
|
|
132
|
+
if (m) return [+m[1], +m[2]]
|
|
133
|
+
}
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseReport(text) {
|
|
138
|
+
const files = firstInt(text, [
|
|
139
|
+
/across\s+(\d+)\s+files?\b/i, // audit's line: "net: +3 −0 lines across 1 file"
|
|
140
|
+
/\bfiles?\s+(?:touched|changed|edited)\s*[:=]\s*\**(\d+)/i,
|
|
141
|
+
/\bfiles?\s*[:=]\s*\**(\d+)/i,
|
|
142
|
+
/(\d+)\s+files?\b/i,
|
|
143
|
+
])
|
|
144
|
+
const lines = firstPair(text, [
|
|
145
|
+
/net\s*[:=]?\s*\+\s*(\d+)\s*[−–-]\s*(\d+)/i, // audit's line
|
|
146
|
+
/\+\s*(\d+)\s*[−–/-]\s*(\d+)/, // "+12 −3", "+12/-3"
|
|
147
|
+
/\blines?\b[^\n]{0,32}?(\d+)\s*[−–/-]\s*(\d+)/i,
|
|
148
|
+
])
|
|
149
|
+
const none = /\bno\s+new\s+(?:deps?|dependencies)\b|\bnew\s+(?:deps?|dependencies)\s*[:=]\s*\**\s*(?:none|zero|nil|n\/a)\b/i
|
|
150
|
+
const deps = none.test(text)
|
|
151
|
+
? 0
|
|
152
|
+
: firstInt(text, [/\bnew\s+(?:deps?|dependencies)\s*[:=]\s*\**(\d+)/i, /\bnew\s+dependenc\w*\s*[:=]\s*\**(\d+)/i, /\bdependenc\w*\s+(?:added|introduced)\s*[:=]?\s*(\d+)/i])
|
|
153
|
+
return { files, lines, deps }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function main() {
|
|
157
|
+
const cwd = data.cwd ?? process.cwd()
|
|
158
|
+
const root = repoRootOrNull(cwd)
|
|
159
|
+
if (root === null) process.exit(0) // not a repository: there is no diff to gate
|
|
160
|
+
|
|
161
|
+
const { rows, binaries } = changeSet(root)
|
|
162
|
+
const changed = rows.filter((r) => !r.rel.startsWith('.omit/'))
|
|
163
|
+
// omitted: distinguishing this session's edits from pre-existing dirt: git has no
|
|
164
|
+
// session concept; set OMIT_OFF=1 in dirty-tree workflows until a marker file lands.
|
|
165
|
+
if (changed.length === 0) process.exit(0)
|
|
166
|
+
|
|
167
|
+
const draftPath = join(root, '.omit', 'final-draft.md')
|
|
168
|
+
let draft
|
|
169
|
+
try {
|
|
170
|
+
draft = readDraft(draftPath)
|
|
171
|
+
} catch (e) {
|
|
172
|
+
block(
|
|
173
|
+
`omit: the working tree changed but there is no readable .omit/final-draft.md${e.code === 'ENOENT' ? '' : ` (${e.message})`}.\n` +
|
|
174
|
+
'Working code is a first draft. Edit your own diff once (dead branches, unused params/imports, ' +
|
|
175
|
+
'speculative options, restating comments, single-caller indirection), then write the net report to ' +
|
|
176
|
+
'.omit/final-draft.md: files touched, lines +/-, new dependencies, footnotes recorded. Then finish.'
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// A draft older than the newest edit describes a tree that no longer exists.
|
|
181
|
+
const newestEdit = Math.max(...changed.map((r) => statSync(join(root, r.rel), { throwIfNoEntry: false })?.mtimeMs ?? 0))
|
|
182
|
+
if (draft.mtimeMs < newestEdit) {
|
|
183
|
+
block(
|
|
184
|
+
'omit: the Final Draft is older than the newest edit — it reports a tree that no longer exists.\n' +
|
|
185
|
+
'Re-run `omit audit`, update the counts in .omit/final-draft.md, then finish.'
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const actual = counts(root, changed)
|
|
190
|
+
const report = parseReport(draft.text)
|
|
191
|
+
const missing = []
|
|
192
|
+
if (report.files === null) missing.push('"files touched: <n>"')
|
|
193
|
+
if (report.lines === null) missing.push('"lines +<added> −<removed>"')
|
|
194
|
+
if (report.deps === null) missing.push('"new dependencies: <n>"')
|
|
195
|
+
if (missing.length) {
|
|
196
|
+
block(
|
|
197
|
+
`omit: .omit/final-draft.md does not state ${missing.join(', ')}.\n` +
|
|
198
|
+
`The gate is on the report, not on the file existing. This tree right now: ${actual.files} file(s), ` +
|
|
199
|
+
`+${actual.added} −${actual.deleted} lines, ${actual.deps} new dependenc${actual.deps === 1 ? 'y' : 'ies'}.\n` +
|
|
200
|
+
'Those are the numbers `omit audit` prints. Write them down, say what you cut, then finish.'
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const wrong = []
|
|
205
|
+
if (report.files !== actual.files) wrong.push(` files touched: draft says ${report.files}, the tree has ${actual.files}`)
|
|
206
|
+
if (report.lines[0] !== actual.added || report.lines[1] !== actual.deleted) {
|
|
207
|
+
wrong.push(` lines: draft says +${report.lines[0]} −${report.lines[1]}, the tree has +${actual.added} −${actual.deleted}`)
|
|
208
|
+
}
|
|
209
|
+
if (report.deps !== actual.deps) wrong.push(` new dependencies: draft says ${report.deps}, the tree has ${actual.deps}`)
|
|
210
|
+
if (wrong.length === 0) {
|
|
211
|
+
if (binaries > 0) console.error(`omit: note — ${binaries} changed file(s) are binary; no line counts describe them.`)
|
|
212
|
+
process.exit(0)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// One honest mismatch passes with a note. `omit audit` counts omit's own ledger
|
|
216
|
+
// files when they are tracked, and this gate counts the change itself: a draft
|
|
217
|
+
// that pasted audit's verdict is reporting the tree accurately, and the only
|
|
218
|
+
// thing it includes that the gate excludes is omit's bookkeeping. Nothing a
|
|
219
|
+
// developer did is hidden by letting that through — which is not true of any
|
|
220
|
+
// other mismatch, where the draft is simply stating a number the tree
|
|
221
|
+
// contradicts.
|
|
222
|
+
const audited = counts(root, rows)
|
|
223
|
+
if (report.files === audited.files && report.lines[0] === audited.added && report.lines[1] === audited.deleted && report.deps === audited.deps) {
|
|
224
|
+
console.error(
|
|
225
|
+
"omit: note — the Final Draft matches `omit audit`'s counts, which include omit's own tracked files under .omit/.\n" +
|
|
226
|
+
'The gate counts the change itself; the difference is omit\'s bookkeeping, not your work.'
|
|
227
|
+
)
|
|
228
|
+
process.exit(0)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
block(
|
|
232
|
+
`omit: the Final Draft reports numbers the tree contradicts:\n${wrong.join('\n')}\n` +
|
|
233
|
+
`This tree right now: ${actual.files} file(s), +${actual.added} −${actual.deleted} lines, ${actual.deps} new dependenc${actual.deps === 1 ? 'y' : 'ies'}.\n` +
|
|
234
|
+
'Fix the numbers in .omit/final-draft.md (untracked files count — `git diff --stat` cannot see them), then finish.'
|
|
42
235
|
)
|
|
43
|
-
if (draftTime >= newestEdit) process.exit(0)
|
|
44
236
|
}
|
|
45
237
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
238
|
+
try {
|
|
239
|
+
main()
|
|
240
|
+
} catch (e) {
|
|
241
|
+
block(
|
|
242
|
+
`omit could not complete the Final Draft check (${e?.message ?? e}).\n` +
|
|
243
|
+
'Blocking the stop rather than ending the session on a report nothing was compared against. ' +
|
|
244
|
+
'Fix the error, or set OMIT_OFF=1 if this is not a working session to gate.'
|
|
245
|
+
)
|
|
246
|
+
}
|