@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/lib/deps.mjs CHANGED
@@ -1,56 +1,352 @@
1
- // Shared manifest/dependency detection for hooks and `omit audit`.
1
+ // Shared manifest/dependency detection for the hooks, `omit audit`, and the bench.
2
+ //
3
+ // Dependencies are read by PARSING each manifest's declared dependency tables,
4
+ // never by matching diff lines. Line matching fails in both directions: a
5
+ // minified one-line package.json matches no line pattern (a false pass on a real
6
+ // dependency), while `"version": "1.0.0"` matches the version-range pattern (a
7
+ // false objection to metadata). Formatting is not a dependency, so neither
8
+ // spelling should change the verdict.
9
+ //
10
+ // Pure functions only — content in, dependency names out. Callers get the two
11
+ // revisions' contents from lib/git.mjs.
2
12
  import { basename } from 'node:path'
3
13
 
4
- const MANIFESTS = new Set([
5
- 'package.json', 'requirements.txt', 'pyproject.toml', 'go.mod',
14
+ export const MANIFESTS = new Set([
15
+ 'package.json', 'requirements.txt', 'requirements-dev.txt', 'pyproject.toml',
16
+ 'setup.cfg', 'Pipfile', 'environment.yml', 'environment.yaml', 'go.mod',
6
17
  'Cargo.toml', 'Gemfile', 'composer.json',
7
18
  ])
8
19
 
9
20
  export const isManifest = (path) => MANIFESTS.has(basename(path))
10
21
 
11
- // Extracts dependency names that are genuinely NEW in a unified diff of one
12
- // manifest: present in added lines and absent from removed lines (a dep whose
13
- // line was merely reformatted: trailing comma, version bump: is not new).
14
- // omitted: full manifest parsing: heuristics per format are enough to raise an
15
- // objection; false negatives are acceptable, the agent's own rules still apply.
16
- export function addedDeps(manifestName, diff) {
17
- const side = (prefix) =>
18
- depsIn(
19
- manifestName,
20
- diff
21
- .split('\n')
22
- .filter((l) => l.startsWith(prefix) && !l.startsWith(prefix.repeat(3)))
23
- .map((l) => l.slice(1).trim())
24
- )
25
- const removed = new Set(side('-'))
26
- return side('+').filter((d) => !removed.has(d))
27
- }
28
-
29
- function depsIn(manifestName, lines) {
22
+ // Dependency-shaped files we still cannot read. A file that declares
23
+ // dependencies and is not parsed has to be reported by name, because `new deps:
24
+ // 0 ✅` for a manifest we never opened is the same silent pass as a missed
25
+ // dependency. Lockfiles are generated, not authored, so they are no gap.
26
+ // omitted: setup.py / build.gradle / *.csproj parsing; the predicate reports
27
+ // them instead. Add a parser the day one of them is actually depended on.
28
+ const UNPARSED_DEPENDENCY_FILES = new Set(['setup.py', 'build.gradle', 'build.gradle.kts', 'Package.swift'])
29
+ const LOCKFILES = new Set(['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb', 'Cargo.lock', 'poetry.lock', 'Pipfile.lock', 'uv.lock', 'Gemfile.lock', 'composer.lock', 'go.sum', 'paket.lock', 'packages.lock.json'])
30
+
31
+ export const unparsedDependencyFile = (path) => {
32
+ const name = basename(path)
33
+ if (LOCKFILES.has(name) || name.endsWith('.lock')) return false
34
+ return UNPARSED_DEPENDENCY_FILES.has(name) || name.endsWith('.csproj')
35
+ }
36
+
37
+ // Dependency names declared in `after` that were not declared in `before`.
38
+ // A manifest that did not exist before declares everything as new — pass ''.
39
+ export function addedDeps(manifestName, before, after) {
40
+ const had = new Set(parseDeps(manifestName, before))
41
+ return parseDeps(manifestName, after).filter((d) => !had.has(d))
42
+ }
43
+
44
+ export function parseDeps(manifestName, content) {
45
+ if (!content) return []
46
+ // npm and conda read a BOM-terminated manifest fine, JSON.parse does not, and
47
+ // a parser that rejects the file falls back to guessing. One strip here keeps
48
+ // every format honest about what it declares.
49
+ const text = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content
50
+ switch (manifestName) {
51
+ case 'package.json':
52
+ case 'composer.json':
53
+ return jsonDeps(text)
54
+ case 'requirements.txt':
55
+ case 'requirements-dev.txt':
56
+ return requirementDeps(text)
57
+ case 'go.mod':
58
+ return goModDeps(text)
59
+ case 'Cargo.toml':
60
+ return tomlDeps(text, cargoTables)
61
+ case 'pyproject.toml':
62
+ return tomlDeps(text, pyprojectTables)
63
+ case 'Pipfile':
64
+ return tomlDeps(text, pipfileTables)
65
+ case 'environment.yml':
66
+ case 'environment.yaml':
67
+ return condaDeps(text)
68
+ case 'setup.cfg':
69
+ return setupCfgDeps(text)
70
+ case 'Gemfile':
71
+ return gemfileDeps(text)
72
+ default:
73
+ return []
74
+ }
75
+ }
76
+
77
+ // ---- package.json / composer.json ----
78
+ // Both declare dependencies as named objects at fixed top-level keys; composer
79
+ // spells them `require`/`require-dev`. Parsing the JSON is what makes a minified
80
+ // manifest and a pretty-printed one declare the same set.
81
+ const JSON_DEP_KEYS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies', 'require', 'require-dev']
82
+ const JSON_BUNDLE_KEYS = ['bundledDependencies', 'bundleDependencies']
83
+ const JSON_TABLE_KEYS = new Set([...JSON_DEP_KEYS, ...JSON_BUNDLE_KEYS])
84
+
85
+ function jsonDeps(content) {
86
+ let doc
87
+ try {
88
+ doc = JSON.parse(content)
89
+ } catch {
90
+ return jsonFallbackDeps(content)
91
+ }
92
+ const names = new Set()
93
+ for (const key of JSON_DEP_KEYS) {
94
+ const table = doc?.[key]
95
+ if (table && typeof table === 'object') for (const name of Object.keys(table)) names.add(name)
96
+ }
97
+ for (const key of JSON_BUNDLE_KEYS) {
98
+ if (Array.isArray(doc?.[key])) for (const name of doc[key]) names.add(name)
99
+ }
100
+ return [...names]
101
+ }
102
+
103
+ // The fallback covers a partial write or JSON5-ish syntax npm tolerates. It has
104
+ // to stay table-scoped: scanning every quoted key/version pair reports the
105
+ // manifest's own metadata as a dependency, and narrowing that scan to
106
+ // version-shaped values instead loses `"left-pad": "latest"`. So walk the
107
+ // text's strings and brackets and read only the dependency tables out of them.
108
+ function jsonFallbackDeps(content) {
109
+ const tokens = jsonTokens(content)
110
+ const names = new Set()
111
+ let depth = 0
112
+ let root = -1 // a truncated manifest can be missing its root brace
113
+ for (let i = 0; i < tokens.length; i++) {
114
+ const token = tokens[i].t
115
+ if (token === '{' || token === '[') { depth++; continue }
116
+ if (token === '}' || token === ']') { depth--; continue }
117
+ if (token !== 's' || tokens[i + 1]?.t !== ':') continue
118
+ if (root < 0) root = depth
119
+ if (depth !== root || !JSON_TABLE_KEYS.has(tokens[i].v)) continue
120
+ const value = tokens[i + 2]
121
+ if (value?.t === '{' || value?.t === '[') for (const n of spanNames(tokens, i + 2, value.t === '{')) names.add(n)
122
+ }
123
+ return [...names]
124
+ }
125
+
126
+ // Strings and structural characters, in order — enough structure to scope a
127
+ // lookup to a table without a JSON parser.
128
+ function jsonTokens(content) {
129
+ const tokens = []
130
+ for (let i = 0; i < content.length; i++) {
131
+ const c = content[i]
132
+ if (c !== '"' && c !== "'") {
133
+ if (c === '{' || c === '}' || c === '[' || c === ']' || c === ':') tokens.push({ t: c })
134
+ continue
135
+ }
136
+ let v = ''
137
+ for (i++; i < content.length && content[i] !== c; i++) v += content[i] === '\\' ? content[++i] ?? '' : content[i]
138
+ tokens.push({ t: 's', v })
139
+ }
140
+ return tokens
141
+ }
142
+
143
+ // The strings of one value span: an object's keys (`object`, when the value is
144
+ // a table), or every string in an array (`bundledDependencies`).
145
+ function spanNames(tokens, start, object) {
30
146
  const out = []
31
- for (const line of lines) {
32
- let m = null
33
- switch (manifestName) {
34
- case 'package.json':
35
- case 'composer.json':
36
- // dep values start with a version range (^1.2, ~3, >=2, 1.0): script lines don't
37
- m = line.match(/^"(@?[a-z0-9._/-]+)"\s*:\s*"[~^><=]?\d/i)
38
- break
39
- case 'requirements.txt':
40
- m = line.match(/^([A-Za-z0-9._-]+)\s*(?:[><=~[]|$)/)
41
- break
42
- case 'go.mod':
43
- m = line.match(/^(?:require\s+)?([\w.-]+\.[\w./-]+)\s+v\d/)
44
- break
45
- case 'Cargo.toml':
46
- case 'pyproject.toml':
47
- m = line.match(/^([A-Za-z0-9_-]+)\s*=\s*["{]/)
48
- break
49
- case 'Gemfile':
50
- m = line.match(/^gem\s+['"]([\w-]+)['"]/)
51
- break
147
+ let depth = 0
148
+ for (let i = start; i < tokens.length; i++) {
149
+ const t = tokens[i].t
150
+ if (t === '{' || t === '[') depth++
151
+ else if (t === '}' || t === ']') { if (--depth === 0) break }
152
+ else if (depth === 1 && t === 's' && (!object || tokens[i + 1]?.t === ':')) out.push(tokens[i].v)
153
+ }
154
+ return out
155
+ }
156
+
157
+ // ---- requirements.txt ----
158
+ // Line-oriented: a name, then optionally extras, a version specifier, or an
159
+ // environment marker. Options (-r, -e, --hash) and comments declare nothing.
160
+ // omitted: PEP 503 name folding (foo_bar == foo-bar): lowercasing covers the
161
+ // installed spelling; add folding if a repo is ever bitten by the other.
162
+ function requirementDeps(content) {
163
+ const names = []
164
+ for (const raw of content.split('\n')) {
165
+ const line = raw.split('#')[0].trim()
166
+ if (!line || line.startsWith('-')) continue
167
+ const m = line.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)/)
168
+ if (m) names.push(m[1].toLowerCase())
169
+ }
170
+ return [...new Set(names)]
171
+ }
172
+
173
+ // ---- go.mod ----
174
+ // Modules live in a `require ( ... )` block or a single-line `require x v1.2.3`.
175
+ // The `go`/`toolchain` directives name no module.
176
+ function goModDeps(content) {
177
+ const names = []
178
+ let block = false
179
+ for (const raw of content.split('\n')) {
180
+ const line = raw.trim()
181
+ if (!line || line.startsWith('//')) continue
182
+ if (block) {
183
+ if (line === ')') { block = false; continue }
184
+ const m = line.match(/^([^\s]+)\s+v\d/)
185
+ if (m) names.push(m[1])
186
+ continue
187
+ }
188
+ if (line === 'require (') { block = true; continue }
189
+ const m = line.match(/^require\s+([^\s]+)\s+v\d/)
190
+ if (m) names.push(m[1])
191
+ }
192
+ return [...new Set(names)]
193
+ }
194
+
195
+ // ---- Cargo.toml / pyproject.toml / Pipfile ----
196
+ // TOML is table-scoped, and table membership is what decides whether a key names
197
+ // a dependency: `version = "1.0.0"` under [package] is the crate's own version,
198
+ // while the same line under [dependencies] would declare a package called
199
+ // "version". `names(table, key, value)` returns the dependencies a key declares;
200
+ // key is null on a table header, which is how [dependencies.serde] is read.
201
+ function tomlDeps(content, names) {
202
+ const out = new Set()
203
+ const lines = content.split('\n')
204
+ let table = ''
205
+ for (let i = 0; i < lines.length; i++) {
206
+ const line = stripComment(lines[i]).trim()
207
+ if (!line) continue
208
+ const header = line.match(/^\[\[?([^\]]+)\]\]?$/)
209
+ if (header) {
210
+ table = header[1].trim()
211
+ for (const n of names(table, null, '')) out.add(n)
212
+ continue
52
213
  }
53
- if (m) out.push(m[1])
214
+ const kv = line.match(/^([A-Za-z0-9_"'.-]+)\s*=\s*(.*)$/)
215
+ if (!kv) continue
216
+ let value = kv[2]
217
+ // An array may span lines (`dependencies = [` … `]` in pyproject). Depth is
218
+ // tracked as each line is appended: re-scanning the whole accumulator per
219
+ // line is quadratic (40k lines cost >1s), and a line that opens a table or
220
+ // assigns a key ends the array — otherwise an unclosed `[` swallows the rest
221
+ // of the file and hands its `version = "1.0.0"` back as a dependency name.
222
+ if (value.startsWith('[')) {
223
+ let depth = bracketDepth(value)
224
+ while (depth > 0 && i + 1 < lines.length) {
225
+ const next = lines[i + 1]
226
+ if (tomlRow(next) || value.length > TOML_VALUE_MAX) break
227
+ i++
228
+ depth += bracketDepth(next)
229
+ value += '\n' + next
230
+ }
231
+ }
232
+ for (const n of names(table, kv[1].replace(/^["']|["']$/g, ''), value)) out.add(n)
233
+ }
234
+ return [...out]
235
+ }
236
+
237
+ // `workspace.` is the shared table every member crate resolves through, so a
238
+ // dependency declared there is a dependency of the repo. TOML also allows any
239
+ // key to be quoted, nested table names included.
240
+ const CARGO_DEP_TABLE = /^(?:workspace\.)?(?:target\..+\.)?(?:dev-|build-)?dependencies$/
241
+ const CARGO_NESTED = /^(?:workspace\.)?(?:target\..+\.)?(?:dev-|build-)?dependencies\.(["']?)([A-Za-z0-9_-]+)\1$/
242
+
243
+ function cargoTables(table, key) {
244
+ const nested = table.match(CARGO_NESTED)
245
+ if (nested) return key === null ? [nested[2]] : []
246
+ return CARGO_DEP_TABLE.test(table) && key !== null ? [key] : []
247
+ }
248
+
249
+ function pyprojectTables(table, key, value) {
250
+ if (key === null) return []
251
+ if (table === 'project' && key === 'dependencies') return specNames(value)
252
+ if (table === 'project.optional-dependencies' || table === 'dependency-groups') return specNames(value)
253
+ if (table === 'build-system' && key === 'requires') return specNames(value)
254
+ if (table === 'tool.poetry.dependencies' || /^tool\.poetry\.group\..+\.dependencies$/.test(table)) {
255
+ return key === 'python' ? [] : [key] // poetry's `python` key is an interpreter constraint
256
+ }
257
+ return []
258
+ }
259
+
260
+ // Names out of an array of PEP 508 specifiers: "requests[security]>=2.31".
261
+ const specNames = (value) => [...value.matchAll(/["']([^"']+)["']/g)].flatMap((m) => m[1].match(/^([A-Za-z0-9][A-Za-z0-9._-]*)/)?.[1].toLowerCase() ?? [])
262
+
263
+ // ---- Pipfile ----
264
+ // TOML, but only two of its tables declare packages: [requires] pins the
265
+ // interpreter and [[source]] describes an index.
266
+ const pipfileTables = (table, key) =>
267
+ key !== null && (table === 'packages' || table === 'dev-packages') ? [key] : []
268
+
269
+ // ---- environment.yml / environment.yaml ----
270
+ // A conda environment lists its packages under the top-level `dependencies:`,
271
+ // as `name`, `name=1.2` or `name>=1.2`; a pip list nests one level deeper. An
272
+ // item that ends in `:` is a nested mapping key, not a package.
273
+ function condaDeps(content) {
274
+ const names = []
275
+ let list = false
276
+ for (const raw of content.split('\n')) {
277
+ const line = raw.split('#')[0]
278
+ if (!line.trim()) continue
279
+ if (line.trimStart() === line) { list = /^dependencies\s*:/.test(line); continue }
280
+ if (!list || /:\s*$/.test(line)) continue
281
+ const m = line.trim().replace(/^-\s*/, '').match(/^([A-Za-z0-9][A-Za-z0-9._-]*)/)
282
+ if (m) names.push(m[1].toLowerCase())
283
+ }
284
+ return [...new Set(names)]
285
+ }
286
+
287
+ // ---- setup.cfg ----
288
+ // INI, not TOML. Requirement specs are the continuation lines of
289
+ // `install_requires` under [options], and of every key under
290
+ // [options.extras_require]; a key sits at column 0, its value below it.
291
+ function setupCfgDeps(content) {
292
+ const names = []
293
+ let table = ''
294
+ let list = false
295
+ for (const raw of content.split('\n')) {
296
+ const line = raw.split('#')[0].trim()
297
+ if (!line) continue
298
+ const header = line.match(/^\[([^\]]+)\]$/)
299
+ if (header) { table = header[1].trim(); list = false; continue }
300
+ const kv = raw.trimStart() === raw ? line.match(/^([^=]+?)\s*=\s*(.*)$/) : null
301
+ if (kv) {
302
+ list = (table === 'options' && kv[1].trim() === 'install_requires') || table === 'options.extras_require'
303
+ if (list) names.push(...requirementDeps(kv[2]))
304
+ continue
305
+ }
306
+ if (list) names.push(...requirementDeps(line))
307
+ }
308
+ return [...new Set(names)]
309
+ }
310
+
311
+ // ---- Gemfile ----
312
+ // `gem 'name', '~> 7.0'` — every further argument is a version constraint.
313
+ function gemfileDeps(content) {
314
+ const names = []
315
+ for (const raw of content.split('\n')) {
316
+ const m = stripComment(raw).trim().match(/^gem\s+['"]([^'"]+)['"]/)
317
+ if (m) names.push(m[1])
318
+ }
319
+ return [...new Set(names)]
320
+ }
321
+
322
+ // ---- helpers ----
323
+ // Bounds a multi-line array. Real manifests are orders of magnitude smaller;
324
+ // anything past this is malformed or hostile, and swallowing it has no value.
325
+ const TOML_VALUE_MAX = 1 << 20
326
+
327
+ // How far a value string is from closing its brackets, counted once per line.
328
+ const bracketDepth = (s) => {
329
+ let d = 0
330
+ for (let i = 0; i < s.length; i++) {
331
+ if (s[i] === '[') d++
332
+ else if (s[i] === ']') d--
333
+ }
334
+ return d
335
+ }
336
+
337
+ // A line that opens a table or assigns a key cannot be an array element, so it
338
+ // closes an array that is still looking for its bracket. Tables are end-anchored
339
+ // because a quoted spec may itself start with `[` — `"requests[security]>=2.31"`.
340
+ const tomlRow = (line) => /^\s*(?:\[[^\]]*\]\s*$|(?:"[^"]*"|'[^']*'|[A-Za-z0-9_-]+)\s*=)/.test(stripComment(line))
341
+
342
+ // A `#` inside a quoted string is not a comment — poetry and uv pin git deps as
343
+ // `"pkg @ git+https://host/repo#subdirectory=lib"`.
344
+ function stripComment(line) {
345
+ let quote = ''
346
+ for (let i = 0; i < line.length; i++) {
347
+ const c = line[i]
348
+ if (quote) { if (c === quote) quote = '' } else if (c === '"' || c === "'") quote = c
349
+ else if (c === '#') return line.slice(0, i)
54
350
  }
55
- return [...new Set(out)]
351
+ return line
56
352
  }
package/lib/exec.mjs ADDED
@@ -0,0 +1,18 @@
1
+ // One definition of "may this process run code it did not ship".
2
+ //
3
+ // Four places need this answer — the receipt verifier, the lint bridge, the
4
+ // dependency hook, and the GitHub Action's environment — and three of them
5
+ // previously carried their own copy of the predicate. Copies of a safety rule
6
+ // drift: one of them accepted only the literal `1`, so `OMIT_NO_EXEC=true`, the
7
+ // spelling a user is most likely to reach for, silently left execution enabled.
8
+ //
9
+ // The rule is "explicitly off wins, and only these three spellings mean off",
10
+ // so an unset variable, an empty one, and `0` all leave the default behaviour
11
+ // alone while any deliberate value disarms.
12
+
13
+ // Off-values, kept identical across every flag that gates execution.
14
+ const OFF = ['', '0', 'false']
15
+
16
+ export const flagOn = (value) => value !== undefined && !OFF.includes(value)
17
+
18
+ export const execDisabled = () => flagOn(process.env.OMIT_NO_EXEC)
package/lib/git.mjs ADDED
@@ -0,0 +1,107 @@
1
+ // Git access shared by the CLI and the hooks.
2
+ //
3
+ // This contract exists because the previous one was fail-open. `git()` caught
4
+ // everything and returned null, and callers coalesced that to '' — so "git
5
+ // could not answer" (a diff past the buffer limit, a ref that does not exist)
6
+ // was indistinguishable from "there is nothing here". A gate reporting
7
+ // "hazards: 0" because it never read the diff is worse than one that crashes:
8
+ // the verdict looks healthy while nothing was examined.
9
+ //
10
+ // So: absence is a legitimate answer only where it is returned explicitly
11
+ // (`fileAtRevision` says `present: false`), and every other failure throws.
12
+ // Callers that cannot tolerate a throw use `probe` and must surface `ok:false`
13
+ // as a hard error of their own — never as an empty result.
14
+ import { execFileSync } from 'node:child_process'
15
+ import { relative, resolve } from 'node:path'
16
+ import { realpathSync } from 'node:fs'
17
+
18
+ // Real diffs exceed Node's 1 MiB default routinely — a lockfile, a vendored
19
+ // asset, a generated bundle. The old code threw ENOBUFS past that and swallowed
20
+ // it, which turned any large commit into a clean verdict. The ceiling is high
21
+ // rather than absent so a runaway still fails loudly instead of exhausting memory.
22
+ const MAX_BUFFER = 1 << 30
23
+
24
+ export class GitError extends Error {
25
+ constructor(args, reason) {
26
+ super(`git ${args.join(' ')} — ${reason}`)
27
+ this.name = 'GitError'
28
+ this.args = args
29
+ this.reason = reason
30
+ }
31
+ }
32
+
33
+ // { ok: true, out } | { ok: false, reason }, never throws. stderr is captured
34
+ // because git's message is what distinguishes "this path is not in that
35
+ // revision" from "that revision does not exist".
36
+ export function probe(cwd, args) {
37
+ try {
38
+ const out = execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: MAX_BUFFER })
39
+ return { ok: true, out }
40
+ } catch (e) {
41
+ const reason = String(e.stderr ?? '').trim() || e.code || e.message || 'failed'
42
+ return { ok: false, reason }
43
+ }
44
+ }
45
+
46
+ export function git(cwd, args) {
47
+ const r = probe(cwd, args)
48
+ if (!r.ok) throw new GitError(args, r.reason)
49
+ return r.out
50
+ }
51
+
52
+ // The absolute repo root, or null when cwd is not inside a repository.
53
+ // Every path handed to a `rev:path` argument must be resolved against this,
54
+ // never against the caller's cwd: git reports and resolves those root-relative.
55
+ export function repoRoot(cwd) {
56
+ const r = probe(cwd, ['rev-parse', '--show-toplevel'])
57
+ return r.ok ? r.out.trim() : null
58
+ }
59
+
60
+ // A path relative to the repo ROOT, or null when the file is outside the repo.
61
+ // Resolved, so a `..` cannot smuggle something past the caller's containment
62
+ // check by being lexically inside.
63
+ export function repoRelPath(root, file) {
64
+ const abs = resolve(root, file)
65
+ const rel = relative(root, abs)
66
+ if (rel !== '' && !rel.startsWith('..')) return rel
67
+ // The harness's file_path and git's `--show-toplevel` can disagree on a
68
+ // symlinked prefix — macOS resolves /var to /private/var, so a repo under a
69
+ // tmpdir yields a path that reads as outside itself. Retry once through
70
+ // realpath before concluding the file is genuinely elsewhere; without this a
71
+ // real manifest edit is waved through as "outside the repo".
72
+ try {
73
+ const real = relative(realpathSync(root), realpathSync(abs))
74
+ if (real !== '' && !real.startsWith('..')) return real
75
+ } catch {}
76
+ return null
77
+ }
78
+
79
+ // Content of `file` at `rev`, where `rev` is any tree-ish: 'HEAD', an empty
80
+ // string for the staged (index) blob, or a branch/tag. Three outcomes:
81
+ //
82
+ // { present: true, text } it exists there
83
+ // { present: false } it legitimately does not — a new file has no
84
+ // earlier revision, and neither does anything in a
85
+ // repo whose HEAD has no commits yet
86
+ // { failed: true, reason } git could not answer, which is not the same as
87
+ // empty and must not be rendered as one
88
+ export function fileAtRevision(cwd, rev, path) {
89
+ if (!path) return { present: false }
90
+ // An unborn HEAD has no revision to look in — absence, not failure. Checked
91
+ // separately because `git show HEAD:x` reports it as an invalid object name,
92
+ // which is otherwise indistinguishable from a genuinely broken revision.
93
+ if (rev === 'HEAD' && !probe(cwd, ['rev-parse', '--verify', 'HEAD']).ok) return { present: false }
94
+
95
+ // An empty `rev` means the staged blob. Spelled `:0:<path>` rather than a bare
96
+ // `:<path>` because git parses the latter as a malformed revision and refuses
97
+ // it — an error indistinguishable from a refusal to answer, which made the
98
+ // gate fail in every repo that had never used omit.
99
+ const r = probe(cwd, ['show', rev === '' ? `:0:${path}` : `${rev}:${path}`])
100
+ if (r.ok) return { present: true, text: r.out }
101
+ // git words this differently per revision: a tree says "does not exist in
102
+ // 'HEAD'", the index says "does not exist (neither on disk nor in the index)".
103
+ // Matching only the first spelling turned an absent staged file into a hard
104
+ // failure, which refuses a commit over a file that was never there.
105
+ if (/does not exist|exists on disk, but not in/i.test(r.reason)) return { present: false }
106
+ return { failed: true, reason: r.reason }
107
+ }
package/lib/hazards.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  // Hazard scanning: hardcoded secrets and injection-prone patterns in added lines.
2
- // Heuristics raise objections, not verdicts: suppress a reviewed line with `omit-allow: <reason>`.
2
+ // Heuristics raise objections, not verdicts: a reviewed *injection* line is
3
+ // suppressed with `omit-allow: <reason>`. Secrets have no override at all.
3
4
 
4
- const SECRET_RULES = [
5
+ export const SECRET_RULES = [
5
6
  ['aws-access-key', /AKIA[0-9A-Z]{16}/],
6
7
  ['private-key-block', /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/],
7
8
  ['github-token', /gh[pousr]_[A-Za-z0-9]{36,}/],
@@ -10,6 +11,7 @@ const SECRET_RULES = [
10
11
  ['stripe-live-key', /sk_live_[A-Za-z0-9]{20,}/],
11
12
  ['openai-style-key', /\bsk-[A-Za-z0-9_-]{32,}/],
12
13
  ['google-api-key', /AIza[0-9A-Za-z_-]{35}/],
14
+ ['chitragupta-key', /chg_[A-Za-z0-9_-]{20,}/],
13
15
  ['hardcoded-credential', /(?:api[_-]?key|secret|token|password|passwd)["']?\s*[:=]\s*["'][A-Za-z0-9+/_-]{16,}["']/i],
14
16
  ]
15
17
 
@@ -19,7 +21,9 @@ const INJECTION_RULES = [
19
21
  ['shell-concat', /\bexec(?:Sync)?\s*\(\s*(?:`[^`]*\$\{|[^)"'`]*\+)/],
20
22
  ['subprocess-shell-true', /subprocess\.(?:run|call|Popen)\s*\(.*shell\s*=\s*True/],
21
23
  ['os-system-dynamic', /os\.system\s*\(\s*(?:f["']|[^)"']*\+)/],
22
- ['inner-html', /\.innerHTML\s*=|dangerouslySetInnerHTML/],
24
+ // The character class is not decoration: spelled literally, this pattern
25
+ // matches its own source text and the scanner reports the rule table.
26
+ ['inner-html', /\.innerHTML\s*=|dangerouslySet[A-Za-z]*HTML/],
23
27
  ['pickle-load', /pickle\.loads?\s*\(/],
24
28
  ['yaml-unsafe-load', /yaml\.load\s*\((?![^)]*SafeLoader)/],
25
29
  ]
@@ -29,13 +33,34 @@ const sqlInjection = (line) =>
29
33
  /\b(?:SELECT|INSERT|UPDATE|DELETE)\b/i.test(line) &&
30
34
  (/\$\{/.test(line) || /["'`]\s*\+/.test(line) || /f["']/.test(line) || /%\s*\(/.test(line))
31
35
 
36
+ // The reviewed-line marker, shared with danger.mjs and leaks.mjs so one form
37
+ // means one thing everywhere. Three properties turn it from free text into a
38
+ // review:
39
+ // - it must be a comment, not a substring: quoted spans are blanked first, so
40
+ // `echo "omit-allow:"` and `const s = "// omit-allow: nope"` don't count;
41
+ // - it must carry a reason — the docs have always said `omit-allow: <reason>`,
42
+ // and a bare token costs a reviewer nothing;
43
+ // - it must be the trailing comment, so what it covers is what precedes it;
44
+ // a marker buried mid-command (or on an earlier line of a multi-line
45
+ // command) reads as text, not as the documented `append: # omit-allow: …`.
46
+ // It is deliberately NOT wired to SECRET_RULES — see findHazards.
47
+ const QUOTED_SPAN_RE = /"(?:[^"\\]|\\.)*"|'[^']*'/g
48
+ const ALLOW_MARKER_RE = /(?:^|[\s;&|])(?:\/\/|#)[ \t]*omit-allow:[ \t]*\S[^\n]*$/
49
+
50
+ export const isAllowSuppressed = (text) =>
51
+ ALLOW_MARKER_RE.test(text.trimEnd().replace(QUOTED_SPAN_RE, (m) => ' '.repeat(m.length)))
52
+
32
53
  export function findHazards(lines) {
33
54
  const findings = []
34
55
  lines.forEach((text, i) => {
35
- if (text.includes('omit-allow:')) return
36
56
  for (const [rule, re] of SECRET_RULES) {
37
57
  if (re.test(text)) findings.push({ type: 'secret', rule, line: i + 1, text: text.trim().slice(0, 120) })
38
58
  }
59
+ // Secrets are scanned before the marker runs, on purpose: `omit-allow:`
60
+ // restores a reviewed judgement call, and a hardcoded key is not one. The
61
+ // README's "Secrets have no override" is true only while this line stays
62
+ // below that loop.
63
+ if (isAllowSuppressed(text)) return
39
64
  for (const [rule, re] of INJECTION_RULES) {
40
65
  if (re.test(text)) findings.push({ type: 'injection', rule, line: i + 1, text: text.trim().slice(0, 120) })
41
66
  }