@dev-loops/core 1.0.2-slim.0 → 1.0.3

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.
@@ -0,0 +1,302 @@
1
+ /**
2
+ * watcher-exclusivity.mjs — dev-loop execution-cap watcher-exclusivity
3
+ * resolver. Mirrors ./reviewer-unit-bound.mjs and ./role-budget-bound.mjs's
4
+ * style: a bounded, deterministic primitive that resolves the current
5
+ * watch-owner verdict for a (target, head, wait-kind) boundary from
6
+ * caller-supplied lease/transition evidence, and always fails closed
7
+ * (blocked, never a silent pass) on any owner mismatch, stale lease, or
8
+ * malformed transition.
9
+ *
10
+ * The whole point of this primitive: the coordinator never becomes a SECOND
11
+ * observer of an in-flight external wait (Copilot review, CI, workflow run).
12
+ * `secondObserverAuthorized` is `false` in every branch of
13
+ * `resolveWatchOwnership` — there is no verdict shape that authorizes the
14
+ * caller to start its own competing watch/probe loop. The owner and
15
+ * transition evidence each carry a `target` field, compared against
16
+ * `boundary.target` alongside `head` and `waitKind`, so the resolver
17
+ * genuinely keys on the full (target, head, wait-kind) triple instead of
18
+ * accepting evidence for a different target.
19
+ *
20
+ * This resolver is caller-AGNOSTIC: it reports the single-owner verdict over
21
+ * SUPPLIED evidence and does not authenticate the calling runner. It cannot
22
+ * tell whether the process invoking it IS the recorded lease owner — that
23
+ * caller-identity check (verifying the calling runner matches
24
+ * `evidence.owner`, and gating the wait before it starts) is the consumer's
25
+ * responsibility and is deferred to the slice-b live wiring.
26
+ *
27
+ * Pure and offline: no runtime/harness adapter import, no file reads, no
28
+ * network, no state held across calls. This primitive is a post-hoc verdict
29
+ * over caller-supplied `evidence` — it does not read or write any lease
30
+ * file itself; that stays the caller's concern (the existing
31
+ * runner-coordination lease read/write path).
32
+ */
33
+
34
+ import { EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY } from "./timeout-policy.mjs";
35
+
36
+ /** Wait kinds this primitive recognizes. */
37
+ export const WATCH_KINDS = Object.freeze(["copilot_review", "ci", "workflow_run"]);
38
+
39
+ /**
40
+ * Coordinator operation kinds that are always prohibited under watcher
41
+ * exclusivity: every one of these would make the coordinator a second
42
+ * observer of an in-flight external wait instead of the sole lease owner.
43
+ *
44
+ * Exported as a frozen ARRAY, not a Set: `Object.freeze(new Set(...))`
45
+ * freezes only the Set's own properties, not its contents — `.add`/
46
+ * `.delete`/`.clear` still work on a frozen Set and the mutation persists on
47
+ * this module-singleton export. A frozen array has no such escape hatch.
48
+ */
49
+ export const PROHIBITED_COORDINATOR_OBSERVER_OPERATIONS = Object.freeze([
50
+ "direct_probe",
51
+ "start_watcher",
52
+ "sleep_retry",
53
+ "second_watch_loop",
54
+ ]);
55
+
56
+ const PROHIBITED_COORDINATOR_OBSERVER_OPERATIONS_SET = new Set(PROHIBITED_COORDINATOR_OBSERVER_OPERATIONS);
57
+
58
+ /** Transition statuses that authorize a phase advance once owner+transition match the boundary. */
59
+ const ADVANCING_TRANSITION_STATUSES = new Set(["changed", "completed"]);
60
+ /** Transition statuses that keep a matching owner in a healthy wait (never advance). */
61
+ const WAITING_TRANSITION_STATUSES = new Set(["timeout", "idle", "pending"]);
62
+
63
+ /** @param {unknown} value @returns {boolean} */
64
+ function isNonEmptyString(value) {
65
+ return typeof value === "string" && value.trim().length > 0;
66
+ }
67
+
68
+ /**
69
+ * Recursively freeze a plain object/array value's own nested plain
70
+ * objects/arrays. A shallow Object.freeze leaves nested values mutable.
71
+ * Recurses into children even when the current container is already frozen.
72
+ * A WeakSet cycle guard prevents infinite recursion on a cyclic object graph.
73
+ * @param {unknown} value
74
+ * @param {WeakSet<object>} [seen]
75
+ * @returns {unknown} the same value, deep-frozen.
76
+ */
77
+ function deepFreeze(value, seen = new WeakSet()) {
78
+ if (value === null || typeof value !== "object" || seen.has(value)) {
79
+ return value;
80
+ }
81
+ seen.add(value);
82
+ for (const key of Object.keys(value)) {
83
+ deepFreeze(value[key], seen);
84
+ }
85
+ return Object.freeze(value);
86
+ }
87
+
88
+ /**
89
+ * Validate + normalize the trust-boundary identity at the boundary. Fails
90
+ * closed (TypeError naming the violation) on any malformed field.
91
+ * @param {{target:string, head:string, waitKind:string}} boundary
92
+ * @returns {{target:string, head:string, waitKind:string}}
93
+ */
94
+ function validateBoundary(boundary) {
95
+ if (!boundary || typeof boundary !== "object") {
96
+ throw new TypeError("resolveWatchOwnership requires boundary to be an object");
97
+ }
98
+ if (!isNonEmptyString(boundary.target)) {
99
+ throw new TypeError("boundary.target must be a non-empty string");
100
+ }
101
+ if (!isNonEmptyString(boundary.head)) {
102
+ throw new TypeError("boundary.head must be a non-empty string");
103
+ }
104
+ if (!WATCH_KINDS.includes(boundary.waitKind)) {
105
+ throw new TypeError(`boundary.waitKind must be one of ${WATCH_KINDS.join(", ")}, got ${JSON.stringify(boundary.waitKind)}`);
106
+ }
107
+ return { target: boundary.target.trim(), head: boundary.head.trim(), waitKind: boundary.waitKind };
108
+ }
109
+
110
+ /**
111
+ * Validate + normalize `evidence.owner`. `null` (no active owner) is valid;
112
+ * a present owner must carry every required field.
113
+ * @param {unknown} owner
114
+ * @returns {{runId:string, target:string, head:string, waitKind:string, updatedAt:string}|null}
115
+ */
116
+ function validateOwner(owner) {
117
+ if (owner === null || owner === undefined) return null;
118
+ if (typeof owner !== "object") {
119
+ throw new TypeError("evidence.owner must be null or an object");
120
+ }
121
+ if (!isNonEmptyString(owner.runId)) {
122
+ throw new TypeError("evidence.owner.runId must be a non-empty string");
123
+ }
124
+ if (!isNonEmptyString(owner.target)) {
125
+ throw new TypeError("evidence.owner.target must be a non-empty string");
126
+ }
127
+ if (!isNonEmptyString(owner.head)) {
128
+ throw new TypeError("evidence.owner.head must be a non-empty string");
129
+ }
130
+ if (!WATCH_KINDS.includes(owner.waitKind)) {
131
+ throw new TypeError(`evidence.owner.waitKind must be one of ${WATCH_KINDS.join(", ")}, got ${JSON.stringify(owner.waitKind)}`);
132
+ }
133
+ if (!isNonEmptyString(owner.updatedAt) || Number.isNaN(Date.parse(owner.updatedAt))) {
134
+ throw new TypeError("evidence.owner.updatedAt must be a non-empty, Date.parse-able timestamp string");
135
+ }
136
+ return {
137
+ runId: owner.runId.trim(),
138
+ target: owner.target.trim(),
139
+ head: owner.head.trim(),
140
+ waitKind: owner.waitKind,
141
+ updatedAt: owner.updatedAt.trim(),
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Validate + normalize `evidence.transition`. `null`/absent (no transition
147
+ * observed yet) is valid; a present transition must carry every required
148
+ * field.
149
+ * @param {unknown} transition
150
+ * @returns {{target:string, head:string, waitKind:string, status:string}|null}
151
+ */
152
+ function validateTransition(transition) {
153
+ if (transition === null || transition === undefined) return null;
154
+ if (typeof transition !== "object") {
155
+ throw new TypeError("evidence.transition must be null or an object");
156
+ }
157
+ if (!isNonEmptyString(transition.target)) {
158
+ throw new TypeError("evidence.transition.target must be a non-empty string");
159
+ }
160
+ if (!isNonEmptyString(transition.head)) {
161
+ throw new TypeError("evidence.transition.head must be a non-empty string");
162
+ }
163
+ if (!WATCH_KINDS.includes(transition.waitKind)) {
164
+ throw new TypeError(`evidence.transition.waitKind must be one of ${WATCH_KINDS.join(", ")}, got ${JSON.stringify(transition.waitKind)}`);
165
+ }
166
+ if (!isNonEmptyString(transition.status)) {
167
+ throw new TypeError("evidence.transition.status must be a non-empty string");
168
+ }
169
+ return {
170
+ target: transition.target.trim(),
171
+ head: transition.head.trim(),
172
+ waitKind: transition.waitKind,
173
+ status: transition.status.trim(),
174
+ };
175
+ }
176
+
177
+ /** @param {string} a @param {string} b @returns {boolean} case-insensitive, trimmed equality. */
178
+ function sameNormalized(a, b) {
179
+ return a.trim().toLowerCase() === b.trim().toLowerCase();
180
+ }
181
+
182
+ /**
183
+ * Resolve the current watch-owner verdict for one (target, head, wait-kind)
184
+ * boundary from caller-supplied lease/transition evidence. Validates every
185
+ * input at the trust boundary and fails closed on malformed input
186
+ * (TypeError) or on any owner/transition mismatch (a "blocked" verdict —
187
+ * never a silent pass).
188
+ *
189
+ * `secondObserverAuthorized` is `false` in every returned verdict: there is
190
+ * no shape this function returns that authorizes the caller to start its
191
+ * own competing watch/probe loop.
192
+ *
193
+ * @param {object} options
194
+ * @param {{target:string, head:string, waitKind:"copilot_review"|"ci"|"workflow_run"}} options.boundary
195
+ * @param {{owner: object|null, transition?: object|null}} options.evidence
196
+ * @param {number} options.now non-negative integer ms epoch.
197
+ * @param {number} options.staleAfterMs positive integer.
198
+ * @returns {object} a frozen verdict; see module header for shapes.
199
+ */
200
+ export function resolveWatchOwnership({ boundary, evidence, now, staleAfterMs } = {}) {
201
+ const normalizedBoundary = validateBoundary(boundary);
202
+ if (!Number.isInteger(now) || now < 0) {
203
+ throw new TypeError("resolveWatchOwnership requires now to be a non-negative integer");
204
+ }
205
+ if (!Number.isInteger(staleAfterMs) || staleAfterMs <= 0) {
206
+ throw new TypeError("resolveWatchOwnership requires staleAfterMs to be a positive integer");
207
+ }
208
+ if (!evidence || typeof evidence !== "object") {
209
+ throw new TypeError("resolveWatchOwnership requires evidence to be an object with an owner field");
210
+ }
211
+ const owner = validateOwner(evidence.owner);
212
+ const transition = validateTransition(evidence.transition);
213
+
214
+ const blocked = (reason) => deepFreeze({
215
+ ok: false,
216
+ verdict: "blocked",
217
+ reason,
218
+ boundary: normalizedBoundary,
219
+ secondObserverAuthorized: false,
220
+ advancePhaseAuthorized: false,
221
+ probeAuthorized: false,
222
+ waitTimeoutPolicy: EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY,
223
+ });
224
+
225
+ if (owner === null) {
226
+ return blocked("no_active_owner");
227
+ }
228
+ if (!sameNormalized(owner.target, normalizedBoundary.target)) {
229
+ return blocked("owner_target_mismatch");
230
+ }
231
+ if (!sameNormalized(owner.head, normalizedBoundary.head)) {
232
+ return blocked("owner_head_mismatch");
233
+ }
234
+ if (owner.waitKind !== normalizedBoundary.waitKind) {
235
+ return blocked("owner_wait_kind_mismatch");
236
+ }
237
+ if (now - Date.parse(owner.updatedAt) > staleAfterMs) {
238
+ return blocked("owner_lease_stale");
239
+ }
240
+
241
+ const ownedWaiting = () => deepFreeze({
242
+ ok: true,
243
+ status: "owned_waiting",
244
+ owner,
245
+ advancePhaseAuthorized: false,
246
+ probeAuthorized: false,
247
+ secondObserverAuthorized: false,
248
+ waitTimeoutPolicy: EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY,
249
+ });
250
+
251
+ if (transition === null) {
252
+ return ownedWaiting();
253
+ }
254
+ if (
255
+ !sameNormalized(transition.target, normalizedBoundary.target) ||
256
+ !sameNormalized(transition.head, normalizedBoundary.head) ||
257
+ transition.waitKind !== normalizedBoundary.waitKind
258
+ ) {
259
+ return blocked("stale_or_malformed_transition");
260
+ }
261
+ if (ADVANCING_TRANSITION_STATUSES.has(transition.status)) {
262
+ return deepFreeze({
263
+ ok: true,
264
+ status: "transition_ready",
265
+ owner,
266
+ transition,
267
+ advancePhaseAuthorized: true,
268
+ probeAuthorized: false,
269
+ secondObserverAuthorized: false,
270
+ waitTimeoutPolicy: EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY,
271
+ });
272
+ }
273
+ if (WAITING_TRANSITION_STATUSES.has(transition.status)) {
274
+ // A timeout does not advance the phase — the existing timeout policy
275
+ // governs escalation, this resolver only reports the healthy wait.
276
+ return ownedWaiting();
277
+ }
278
+ return blocked("stale_or_malformed_transition");
279
+ }
280
+
281
+ /**
282
+ * Pure default-deny guard: the coordinator has no sanctioned observer
283
+ * operation while watcher exclusivity holds — the lease owner is the sole
284
+ * observer. Every explicitly prohibited kind throws a named prohibition
285
+ * error; every OTHER kind (there is no allow-list) throws
286
+ * unknown_coordinator_observer_operation. This mirrors
287
+ * assertReviewerOperationAllowed's default-deny posture, but with an empty
288
+ * allow-list: there is nothing a coordinator may do here except wait for the
289
+ * lease owner's evidence to change.
290
+ * @param {{kind:string}} operation
291
+ * @returns {never}
292
+ */
293
+ export function assertNoOverlappingObserver(operation) {
294
+ if (!operation || typeof operation !== "object" || !isNonEmptyString(operation.kind)) {
295
+ throw new TypeError("assertNoOverlappingObserver requires operation.kind to be a non-empty string");
296
+ }
297
+ const { kind } = operation;
298
+ if (PROHIBITED_COORDINATOR_OBSERVER_OPERATIONS_SET.has(kind)) {
299
+ throw new Error(`coordinator observer operation prohibited under watcher exclusivity: ${kind}`);
300
+ }
301
+ throw new Error(`unknown_coordinator_observer_operation: ${kind}`);
302
+ }
@@ -35,12 +35,13 @@ export function isUnderWorktreePath(cwd) {
35
35
  * @returns {string | null} The main worktree path, or null if it cannot be parsed.
36
36
  */
37
37
  export function parseMainWorktreePath(worktreeListOutput) {
38
- const firstLine = worktreeListOutput.split("\n")[0].trim();
39
- if (!firstLine) return null;
40
- // Find the first hex SHA (7+ chars) preceded by whitespace; take everything before it as the path.
41
- const shaIdx = firstLine.search(/\s[0-9a-f]{7,64}\b/iu);
42
- if (shaIdx === -1) return null;
43
- return firstLine.slice(0, shaIdx).trim();
38
+ // The main worktree is the FIRST parseable entry — derived from the same
39
+ // parser `parseAllWorktreePaths` uses, so the two can never disagree about
40
+ // which path is the main checkout. Parsing only the raw first line would
41
+ // return null on a leading blank/SHA-less line while `parseAllWorktreePaths`
42
+ // still parses the real main line, nulling the main-checkout guard while the
43
+ // path stays admittable (the leading-blank-line fail-open).
44
+ return parseAllWorktreePaths(worktreeListOutput)[0] ?? null;
44
45
  }
45
46
 
46
47
  /**
@@ -115,10 +116,15 @@ export function isListedWorktree(cwd, worktreePaths) {
115
116
  /**
116
117
  * Resolve the root of the listed git worktree that contains `cwd`.
117
118
  *
118
- * Mirrors `isListedWorktree`'s matching (realpath-resolved, tmp/worktrees/-scoped,
119
- * exact-or-subdirectory) but returns the worktree ROOT instead of a boolean, so
120
- * callers can address files relative to the worktree's own subtree (`packages/`,
121
- * `node_modules/`) rather than the possibly-nested `cwd`.
119
+ * Returns the worktree ROOT (realpath-resolved, exact-or-subdirectory match)
120
+ * instead of a boolean, so callers can address files relative to the worktree's
121
+ * own subtree (`packages/`, `node_modules/`) rather than the possibly-nested
122
+ * `cwd`. Matches ANY listed worktree — not just `tmp/worktrees/`-scoped ones —
123
+ * so the core-isolation invariant is evaluable for a sibling/linked checkout
124
+ * that lives outside `tmp/worktrees/`; the caller decides admit/reject.
125
+ * When `cwd` sits under nested worktrees (a `tmp/worktrees/` child inside its
126
+ * parent checkout), the LONGEST matching root wins, so the innermost worktree's
127
+ * own subtree is addressed regardless of `git worktree list` order.
122
128
  *
123
129
  * @param {string} cwd - Absolute or relative path inside the worktree.
124
130
  * @param {string[]} worktreePaths - Array of paths from `parseAllWorktreePaths`.
@@ -128,16 +134,16 @@ export function resolveContainingWorktreeRoot(cwd, worktreePaths) {
128
134
  let resolvedCwd;
129
135
  try { resolvedCwd = realpathSync(cwd); } catch { resolvedCwd = cwd; }
130
136
  const normalizedCwd = resolvedCwd.replace(/\\/g, "/").replace(/\/+$/u, "");
137
+ let best = null;
131
138
  for (const p of worktreePaths) {
132
139
  let resolvedP;
133
140
  try { resolvedP = realpathSync(p); } catch { resolvedP = p; }
134
141
  const normalizedP = resolvedP.replace(/\\/g, "/").replace(/\/+$/u, "");
135
- if (!isUnderWorktreePath(normalizedP)) continue;
136
142
  if (normalizedCwd === normalizedP || normalizedCwd.startsWith(normalizedP + "/")) {
137
- return normalizedP;
143
+ if (best === null || normalizedP.length > best.length) best = normalizedP;
138
144
  }
139
145
  }
140
- return null;
146
+ return best;
141
147
  }
142
148
 
143
149
  /**
@@ -191,6 +197,67 @@ export function isWorktreeCoreIsolated(cwd, worktreePaths) {
191
197
  return linkReal === coreReal;
192
198
  }
193
199
 
200
+ /**
201
+ * Shared admit/reject decision for local-implementation worktree isolation.
202
+ *
203
+ * The single source of truth both enforcement sites route through
204
+ * (`pre-flight-gate.mjs` `checkWorktreeIsolation` and
205
+ * `resolve-dev-loop-startup.mjs`'s `local_implementation` block), so they
206
+ * cannot diverge. Each caller maps the returned `error`/`detail` to its own
207
+ * guidance/reason wording; this function owns only the decision.
208
+ *
209
+ * `tmp/worktrees/` stays the default and recommended location, but it is not
210
+ * the invariant. The real invariant is core isolation: a checkout's
211
+ * `node_modules/@dev-loops/core` resolves to its OWN `packages/core`.
212
+ * A checkout OUTSIDE `tmp/worktrees/` that is not the main checkout and
213
+ * satisfies that invariant is admitted rather than rejected on path prefix
214
+ * alone; one that does not satisfy it (its core link escapes its own
215
+ * `packages/core`) fails closed.
216
+ *
217
+ * Decision order:
218
+ * - outside `tmp/worktrees/` + main checkout -> reject `main_checkout_detected`
219
+ * - outside `tmp/worktrees/` + unresolvable worktree root -> reject `not_in_worktree` (fail closed, no vacuous admit)
220
+ * - outside `tmp/worktrees/` + core-isolated -> ADMIT (verified-isolation)
221
+ * - outside `tmp/worktrees/` + not isolated -> reject `not_in_worktree`
222
+ * - under `tmp/worktrees/` + not a real worktree -> reject `not_in_worktree`
223
+ * - under `tmp/worktrees/` + core link escapes -> reject `core_link_escapes`
224
+ * - otherwise -> ADMIT
225
+ *
226
+ * @param {object} params
227
+ * @param {string} params.cwd - Current working directory (absolute or relative).
228
+ * @param {string | null} params.mainWorktreePath - From `parseMainWorktreePath`.
229
+ * @param {string[]} params.allWorktreePaths - From `parseAllWorktreePaths`.
230
+ * @returns {{ ok: true } | { ok: false, error: string, detail: string }}
231
+ */
232
+ export function classifyWorktreeIsolation({ cwd, mainWorktreePath, allWorktreePaths }) {
233
+ if (!isUnderWorktreePath(cwd)) {
234
+ if (mainWorktreePath !== null && isMainCheckout(cwd, mainWorktreePath)) {
235
+ return { ok: false, error: "main_checkout_detected", detail: "main_checkout" };
236
+ }
237
+ // Outside tmp/worktrees and not the main checkout: assert the REAL
238
+ // core-isolation invariant instead of rejecting on path prefix alone.
239
+ // The invariant is only meaningful for a checkout that resolves to a real
240
+ // listed git worktree root; when the root cannot be resolved (an unlisted
241
+ // checkout, or an empty/unparseable `git worktree list` — which also nulls
242
+ // mainWorktreePath and skips the main-checkout guard above) the core-isolation
243
+ // check would vacuously return true, so fail closed rather than admit.
244
+ if (resolveContainingWorktreeRoot(cwd, allWorktreePaths) === null) {
245
+ return { ok: false, error: "not_in_worktree", detail: "outside_not_isolated" };
246
+ }
247
+ if (isWorktreeCoreIsolated(cwd, allWorktreePaths)) {
248
+ return { ok: true };
249
+ }
250
+ return { ok: false, error: "not_in_worktree", detail: "outside_not_isolated" };
251
+ }
252
+ if (!isListedWorktree(cwd, allWorktreePaths)) {
253
+ return { ok: false, error: "not_in_worktree", detail: "fake_worktree" };
254
+ }
255
+ if (!isWorktreeCoreIsolated(cwd, allWorktreePaths)) {
256
+ return { ok: false, error: "core_link_escapes", detail: "core_escapes" };
257
+ }
258
+ return { ok: true };
259
+ }
260
+
194
261
 
195
262
  /**
196
263
  * Realpath-normalize a path that MAY NOT EXIST yet.
@@ -319,6 +319,17 @@ export function parseAddedLines(diffText) {
319
319
  * @param {string} diffText
320
320
  * @returns {{ ok: boolean, findings: { file: string, line: number, detectorClass: string, reason: string }[] }}
321
321
  */
322
+ // The committed `.claude/package-lock.json` is, like `bun.lock`, a fully
323
+ // machine-generated lockfile whose every token derives from public npm registry metadata
324
+ // (Subresource Integrity digests, `resolved` tarball URLs, and long hyphenated per-platform
325
+ // package names from its real transitive tree) — none of it is a secret by construction.
326
+ // Unlike bun.lock's narrow tuple shape, an npm v3 lock spreads those long tokens across many
327
+ // distinct field shapes, so rather than enumerate every
328
+ // field individually the HIGH_ENTROPY detector is skipped for this one generated file; the
329
+ // literal-credential and sink-pattern detectors still run over every line unchanged, so a real
330
+ // credential shape landing here is still caught.
331
+ const HIGH_ENTROPY_EXEMPT_FILES = new Set([".claude/package-lock.json"]);
332
+
322
333
  export function scanDiffText(diffText) {
323
334
  const findings = [];
324
335
  for (const entry of parseAddedLines(diffText)) {
@@ -334,7 +345,9 @@ export function scanDiffText(diffText) {
334
345
  // names cross the generic entropy threshold despite containing no value.
335
346
  .replace(/@mariozechner\/clipboard-[a-z0-9-]+/gu, "<clipboard-platform-package>")
336
347
  : entry.text;
348
+ const skipHighEntropy = HIGH_ENTROPY_EXEMPT_FILES.has(entry.file ?? "");
337
349
  for (const hit of scanLineText(text)) {
350
+ if (skipHighEntropy && hit.detectorClass === DETECTOR_CLASSES.HIGH_ENTROPY) continue;
338
351
  findings.push({ file: entry.file, line: entry.line, ...hit });
339
352
  }
340
353
  }