@gate-forge/pack-task 0.1.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.
@@ -0,0 +1,701 @@
1
+ /**
2
+ * The pack's discover entry: a pure TypeScript GPP/3 in-process
3
+ * detector that scans `.ts`/`.js`/`.mjs` files for background-task
4
+ * signatures. The outcome carries the pack's typed blocking
5
+ * vocabulary (`AMBIGUOUS_HANDLER`, `PARSE_ERROR` findings and the
6
+ * typed `UNPROVEN_QUEUE_REGISTRATION` unresolved entry) plus per-file
7
+ * scan coverage; it emits no resources and no classification signals
8
+ * (see the phase 4 note below).
9
+ *
10
+ * Mirrors the public shape of `packages/pack-sqlalchemy/src/detector.ts`:
11
+ * - `discover(paths: string[])` returns the GPP/3 `DiscoveryOutcome`
12
+ * shape (resources, unresolved, findings).
13
+ * - `createTaskDetector()` is the factory.
14
+ * - The output is deterministic (no `Date.now()` / `Math.random()`).
15
+ *
16
+ * Patterns detected (regex-based AST-light; matches pack-auth's strategy):
17
+ * - BullMQ: `new Queue('name', ...)` / `new BullMQ.Queue(...)` calls;
18
+ * `defaultJobOptions` like `{ attempts, backoff: { type } }`.
19
+ * - Bee-Queue: `new Bee('name', ...)` calls.
20
+ * - Custom queue: `new CustomQueue({ name, handler })` (multi-line,
21
+ * balanced-brace walk) or `register('name', handler)` (single-line).
22
+ * - Message handlers: `onmessage = handler` / `.on('message', handler)`
23
+ * / `addEventListener('message', handler)`.
24
+ * - Recurring: `setInterval(handler, ms, ...)` / `setImmediate(handler, ...)`.
25
+ * - Decorators: `@Task` / `@Queue` annotations on exports.
26
+ *
27
+ * Precision guards (Phase 3): the single-line `register('name', ...)`
28
+ * shape is receiver-agnostic by nature, so it is narrowed two ways.
29
+ * - Browser/platform registration APIs (service workers, caches,
30
+ * workbox routes) are NEVER queue registrations: a receiver rooted
31
+ * at a browser global (`navigator`/`window`/`document`/`caches`/
32
+ * `workbox`) — or any `*.serviceWorker.register(...)` chain — is
33
+ * out of scope and produces no detector output at all. The receiver
34
+ * is rebuilt across MULTI-LINE call expressions too (bounded
35
+ * lookback): in `navigator.serviceWorker\n .register('/sw.js')`
36
+ * the receiver sits on the previous line, and a line-only
37
+ * extraction used to miss it (real dogfood false positive).
38
+ * - A handler-less `register('name')` is only task-shaped when the
39
+ * file shows real queue evidence (a known queue-library import or
40
+ * a queue constructor). Otherwise the shape is not provably a task
41
+ * registration: the detector emits a typed
42
+ * `UNPROVEN_QUEUE_REGISTRATION` unresolved entry instead of a
43
+ * vague `AMBIGUOUS_HANDLER` finding (never a silent swallow, and
44
+ * never a FALSE reason).
45
+ *
46
+ * Classification signals (dogfood remediation phase 4): NONE. This
47
+ * pack once minted `internality`/`worker` reachability signals for
48
+ * every detected task, targeted at model names GUESSED from the
49
+ * worker file (model/repository import-path segments, every
50
+ * PascalCase identifier, stripped task-name fragments). Those targets
51
+ * are guesses about OTHER detectors' resources — this pack emits no
52
+ * resources of its own — so in real repos they mostly matched nothing
53
+ * and every miss surfaced as a `STALE_SIGNAL_TARGET` blocker (236 in
54
+ * the unified dogfood) while adding no information: a reachability
55
+ * signal can only ever SUPPORT an internality certificate, and
56
+ * guessed certification is exactly what the certificate must not
57
+ * rest on. Unknown exposure already defaults user-facing and unknown
58
+ * lifecycle already defaults enabled (ADR 0003 D5), so removal flips
59
+ * no classification and shrinks no obligation set — it only stops
60
+ * false certification and stale-target noise. Core's
61
+ * `STALE_SIGNAL_TARGET` detection remains for genuinely stale
62
+ * authority signals; the `trustedInternalEntryPoints` worker binding
63
+ * (`detector: gateforge.pack-task`) simply stays unexercised, so
64
+ * internality certification via that category is honestly unavailable
65
+ * (`INCOMPLETE_PROOF_SCOPE`, user-facing) instead of guess-based.
66
+ * The `classificationSignals` outcome field stays in the wire shape
67
+ * (protocol contract) and is always empty.
68
+ *
69
+ * The detector never executes user code. Each detection is a regex
70
+ * match against the file's text, with an attached line/col offset.
71
+ */
72
+ import { readFile, readdir, stat } from 'node:fs/promises';
73
+ import { join, relative, sep, posix } from 'node:path';
74
+ import { GATEFORGE_SCHEMA_VERSION } from '@gate-forge/core';
75
+ /** Default retry policy when the source omits it. */
76
+ const DEFAULT_RETRY_POLICY = {
77
+ maxAttempts: 1,
78
+ backoff: 'fixed',
79
+ };
80
+ /** Source extensions this pack scans. */
81
+ const SCAN_EXTS = {
82
+ '.ts': true,
83
+ '.js': true,
84
+ '.mjs': true,
85
+ '.cjs': true,
86
+ };
87
+ /** Directories skipped during recursive scans. */
88
+ const SKIP_DIRS = {
89
+ node_modules: true,
90
+ dist: true,
91
+ coverage: true,
92
+ '.git': true,
93
+ };
94
+ /**
95
+ * Receiver roots that are browser/platform globals and therefore can
96
+ * never be task queues. WHY: the single-line `register('name', ...)`
97
+ * heuristic is receiver-agnostic, so a browser Service Worker
98
+ * registration like `navigator.serviceWorker.register('/sw.js',
99
+ * { scope: '/' })` used to be misread as a custom-queue registration
100
+ * (real dogfood false positive). A call whose receiver is rooted at
101
+ * one of these globals is a platform registration API by definition.
102
+ */
103
+ const BROWSER_RECEIVER_ROOTS = {
104
+ navigator: true,
105
+ window: true,
106
+ document: true,
107
+ caches: true,
108
+ workbox: true,
109
+ };
110
+ /**
111
+ * Module names that mark a file as task-queue domain (real queue
112
+ * evidence): known queue libraries the detector recognizes elsewhere
113
+ * (`bullmq`, `bee-queue`, celery, ...). Tested against import/require
114
+ * specifiers only — never the whole file text — so a comment mention
115
+ * cannot fabricate evidence.
116
+ */
117
+ const QUEUE_LIBRARY_MODULE = /\b(?:bullmq|bull|bee-queue|celery|kue|agenda|pg-boss|sidekiq)\b/i;
118
+ /**
119
+ * Creates a discover-capable detector module. The default export of the
120
+ * pack is an instance with no overrides.
121
+ *
122
+ * Args:
123
+ * options: Optional `{ rootDir }` override (defaults to `process.cwd()`).
124
+ *
125
+ * Returns:
126
+ * TaskDetector: A `{ discover(paths) }` callable.
127
+ */
128
+ export function createTaskDetector(options = {}) {
129
+ const rootDir = options.rootDir ?? process.cwd();
130
+ async function discover(paths) {
131
+ const files = await collectFiles(rootDir, paths);
132
+ const detections = [];
133
+ const findings = [];
134
+ const unresolved = [];
135
+ const scanned = [];
136
+ for (const absPath of files) {
137
+ const relPath = relative(rootDir, absPath).split(sep).join(posix.sep);
138
+ let text;
139
+ try {
140
+ text = await readFile(absPath, 'utf8');
141
+ }
142
+ catch (cause) {
143
+ findings.push({
144
+ code: 'PARSE_ERROR',
145
+ detail: `failed to read ${relPath}: ${cause.message}`,
146
+ locations: [{ file: relPath, line: 1, col: 0 }],
147
+ });
148
+ continue;
149
+ }
150
+ scanned.push(relPath);
151
+ detections.push(...scanFile(relPath, text, findings, unresolved));
152
+ }
153
+ return finalize(detections, findings, unresolved, scanned);
154
+ }
155
+ return { discover };
156
+ }
157
+ /**
158
+ * Recursively walks the supplied paths, returning absolute file paths
159
+ * matching the scan extensions. Skips `SKIP_DIRS` and any non-existent
160
+ * paths (the caller surfaces those as `PARSE_ERROR` findings when read).
161
+ *
162
+ * Args:
163
+ * rootDir: Absolute base for path resolution.
164
+ * paths: Repo-relative or absolute paths (files or dirs).
165
+ *
166
+ * Returns:
167
+ * Promise<string[]>: Absolute file paths ready for `readFile`.
168
+ */
169
+ async function collectFiles(rootDir, paths) {
170
+ const out = [];
171
+ for (const p of paths) {
172
+ const abs = p.startsWith('/') ? p : join(rootDir, p);
173
+ let info;
174
+ try {
175
+ info = await stat(abs);
176
+ }
177
+ catch {
178
+ continue;
179
+ }
180
+ if (info.isFile()) {
181
+ for (const ext in SCAN_EXTS) {
182
+ if (abs.endsWith(ext)) {
183
+ out.push(abs);
184
+ break;
185
+ }
186
+ }
187
+ continue;
188
+ }
189
+ if (info.isDirectory()) {
190
+ const entries = await readdir(abs, { withFileTypes: true });
191
+ for (const entry of entries) {
192
+ if (entry.name.startsWith('.'))
193
+ continue;
194
+ if (entry.isDirectory()) {
195
+ if (SKIP_DIRS[entry.name] === true)
196
+ continue;
197
+ out.push(...(await collectFiles(rootDir, [join(abs, entry.name)])));
198
+ continue;
199
+ }
200
+ if (entry.isFile()) {
201
+ for (const ext in SCAN_EXTS) {
202
+ if (entry.name.endsWith(ext)) {
203
+ out.push(join(abs, entry.name));
204
+ break;
205
+ }
206
+ }
207
+ }
208
+ }
209
+ }
210
+ }
211
+ return out.sort();
212
+ }
213
+ /**
214
+ * Scans one file's source text, returning one `RawDetection` per
215
+ * matched pattern. Emits `AMBIGUOUS_HANDLER` findings for queue-proven
216
+ * registrations that lack a resolvable handler reference, and typed
217
+ * `UNPROVEN_QUEUE_REGISTRATION` unresolved entries for handler-less
218
+ * `register(...)` calls in files with no queue evidence. Browser /
219
+ * platform registration calls (service workers, caches, workbox)
220
+ * produce no output at all.
221
+ *
222
+ * Args:
223
+ * relPath: Repo-relative file path.
224
+ * text: The file's full text content.
225
+ * findings: Findings array to push diagnostic codes into.
226
+ * unresolved: Unresolved array to push typed reasons into.
227
+ *
228
+ * Returns:
229
+ * RawDetection[]: One entry per matched pattern (pre-dedup).
230
+ */
231
+ function scanFile(relPath, text, findings, unresolved) {
232
+ const detections = [];
233
+ const lines = text.split('\n');
234
+ // Queue evidence is file-scoped and only needed when a `register(`
235
+ // call lacks a resolvable handler; computed lazily (at most once
236
+ // per file) to keep the per-line hot path regex-only.
237
+ let queueEvidence = null;
238
+ const hasQueueEvidenceCached = () => {
239
+ if (queueEvidence === null)
240
+ queueEvidence = hasQueueEvidence(text);
241
+ return queueEvidence;
242
+ };
243
+ for (let idx = 0; idx < lines.length; idx += 1) {
244
+ const line = lines[idx] ?? '';
245
+ const lineNumber = idx + 1;
246
+ // BullMQ: `new Queue('name', ...)` / `Queue('name', ...)`
247
+ const bullmq = matchBullmqLine(line);
248
+ if (bullmq) {
249
+ detections.push({
250
+ name: bullmq,
251
+ framework: 'bullmq',
252
+ retryPolicy: extractRetryPolicy(text, idx) ?? DEFAULT_RETRY_POLICY,
253
+ idempotencyKey: text.includes('jobId') || text.includes('dedupKey'),
254
+ terminalOn: extractTerminalOn(text),
255
+ observability: extractObservabilityHint(text),
256
+ location: { file: relPath, line: lineNumber, col: line.indexOf(bullmq) },
257
+ });
258
+ continue;
259
+ }
260
+ // Bee-Queue: `new Bee('name', ...)`
261
+ const bee = matchBeeLine(line);
262
+ if (bee) {
263
+ detections.push({
264
+ name: bee,
265
+ framework: 'bee-queue',
266
+ retryPolicy: extractRetryPolicy(text, idx) ?? { maxAttempts: 3, backoff: 'exponential' },
267
+ idempotencyKey: text.includes('jobId') || text.includes('dedupKey'),
268
+ terminalOn: extractTerminalOn(text),
269
+ observability: extractObservabilityHint(text),
270
+ location: { file: relPath, line: lineNumber, col: line.indexOf(bee) },
271
+ });
272
+ continue;
273
+ }
274
+ // Custom queue: `register('name'[, handler])`. A bare receiver is
275
+ // the pack's custom-queue runtime; a receiver rooted at a browser
276
+ // global is a platform registration (service worker, caches,
277
+ // workbox) and is skipped entirely. The receiver is rebuilt across
278
+ // multi-line call expressions (see `receiverOfCall`), so a browser
279
+ // chain split over lines is excluded just like the single-line one.
280
+ const regMatch = matchRegisterLine(line, lines, idx);
281
+ if (regMatch) {
282
+ if (!isBrowserRegistrationReceiver(regMatch.receiver)) {
283
+ if (regMatch.hasHandler || hasQueueEvidenceCached()) {
284
+ detections.push({
285
+ name: regMatch.name,
286
+ framework: 'custom-queue',
287
+ retryPolicy: extractRetryPolicy(text, idx) ?? DEFAULT_RETRY_POLICY,
288
+ idempotencyKey: text.includes('idempotencyKey') || text.includes('dedupe'),
289
+ terminalOn: extractTerminalOn(text),
290
+ observability: extractObservabilityHint(text),
291
+ location: { file: relPath, line: lineNumber, col: line.indexOf(regMatch.name) },
292
+ });
293
+ if (!regMatch.hasHandler) {
294
+ findings.push({
295
+ code: 'AMBIGUOUS_HANDLER',
296
+ detail: `custom-queue registration at ${relPath}:${lineNumber} has no resolvable handler reference`,
297
+ locations: [{ file: relPath, line: lineNumber, col: 0 }],
298
+ });
299
+ }
300
+ }
301
+ else {
302
+ // Handler-less `register('name')` in a file with no queue
303
+ // evidence: the shape is not provably a task registration, so
304
+ // it must NOT yield a vague AMBIGUOUS_HANDLER finding (and,
305
+ // since phase 4, no signal exists to mint either). Keep the
306
+ // blocking contract with a typed unresolved reason that is
307
+ // TRUE instead.
308
+ unresolved.push({
309
+ code: 'UNPROVEN_QUEUE_REGISTRATION',
310
+ detail: `register('${regMatch.name}') at ${relPath}:${lineNumber} has no resolvable handler reference and the file shows no task-queue evidence (known queue-library import or queue constructor)`,
311
+ location: { file: relPath, line: lineNumber, col: 0 },
312
+ });
313
+ }
314
+ }
315
+ continue;
316
+ }
317
+ // Message handlers: `onmessage = handler` / `.on('message', handler)`
318
+ const message = matchMessageLine(line);
319
+ if (message) {
320
+ detections.push({
321
+ name: message,
322
+ framework: 'message-handler',
323
+ retryPolicy: DEFAULT_RETRY_POLICY,
324
+ idempotencyKey: text.includes('messageId') || text.includes('dedupKey'),
325
+ terminalOn: extractTerminalOn(text),
326
+ observability: extractObservabilityHint(text),
327
+ location: {
328
+ file: relPath,
329
+ line: lineNumber,
330
+ col: Math.max(0, line.indexOf(message)),
331
+ },
332
+ });
333
+ continue;
334
+ }
335
+ // Recurring: `setInterval(handler, ms, ...)` / `setImmediate(handler, ...)`
336
+ const recurring = matchRecurringLine(line);
337
+ if (recurring) {
338
+ detections.push({
339
+ name: recurring,
340
+ framework: 'recurring',
341
+ retryPolicy: DEFAULT_RETRY_POLICY,
342
+ idempotencyKey: false,
343
+ terminalOn: extractTerminalOn(text),
344
+ observability: extractObservabilityHint(text),
345
+ location: { file: relPath, line: lineNumber, col: line.indexOf(recurring) },
346
+ });
347
+ continue;
348
+ }
349
+ // Decorators: `@Task` / `@Queue` on the next non-decorator export
350
+ const decorator = matchDecoratorLine(line);
351
+ if (decorator) {
352
+ detections.push({
353
+ name: 'decorated',
354
+ framework: 'decorator',
355
+ retryPolicy: extractRetryPolicy(text, idx) ?? DEFAULT_RETRY_POLICY,
356
+ idempotencyKey: text.includes('idempotent') || text.includes('jobId'),
357
+ terminalOn: extractTerminalOn(text),
358
+ observability: extractObservabilityHint(text),
359
+ location: { file: relPath, line: lineNumber, col: line.indexOf('@') },
360
+ });
361
+ }
362
+ }
363
+ // Multi-line: `new CustomQueue({ ... name: 'name' ... })` — scan the
364
+ // entire source as one string so the regex can span newlines. The
365
+ // body may contain inner `{}` (e.g. destructured types), so we look
366
+ // for `name:` first and then walk braces to find the matching `}`.
367
+ // No queue-evidence gate is needed here: the `CustomQueue` receiver
368
+ // is constructed from the pack's own queue runtime, so the shape is
369
+ // provably queue-related (unlike the receiver-agnostic single-line
370
+ // `register(...)` heuristic).
371
+ for (const m of text.matchAll(/new\s+CustomQueue\s*\(/g)) {
372
+ const openIdx = text.indexOf('{', m.index ?? 0);
373
+ if (openIdx < 0)
374
+ continue;
375
+ const closeIdx = matchingBrace(text, openIdx);
376
+ if (closeIdx < 0)
377
+ continue;
378
+ const block = text.slice(openIdx + 1, closeIdx);
379
+ const nameMatch = /name\s*:\s*['"]([^'"]+)['"]/.exec(block);
380
+ if (!nameMatch || nameMatch[1] === undefined)
381
+ continue;
382
+ const name = nameMatch[1];
383
+ const hasHandler = /handler\s*:/.test(block);
384
+ const before = text.slice(0, m.index ?? 0);
385
+ const lineNumber = before.split('\n').length;
386
+ detections.push({
387
+ name,
388
+ framework: 'custom-queue',
389
+ retryPolicy: extractRetryPolicy(text, lineNumber - 1) ?? DEFAULT_RETRY_POLICY,
390
+ idempotencyKey: text.includes('idempotencyKey') || text.includes('dedupe'),
391
+ terminalOn: extractTerminalOn(block),
392
+ observability: /observability\s*:\s*true/.test(block),
393
+ location: { file: relPath, line: lineNumber, col: 0 },
394
+ });
395
+ if (!hasHandler) {
396
+ findings.push({
397
+ code: 'AMBIGUOUS_HANDLER',
398
+ detail: `custom-queue registration at ${relPath}:${lineNumber} has no resolvable handler reference`,
399
+ locations: [{ file: relPath, line: lineNumber, col: 0 }],
400
+ });
401
+ }
402
+ }
403
+ return detections;
404
+ }
405
+ /** Matches a BullMQ-style queue declaration; returns the literal name. */
406
+ function matchBullmqLine(line) {
407
+ const m = /new\s+(?:BullMQ\.Queue|Queue)\s*\(\s*['"]([^'"]+)['"]/.exec(line);
408
+ return m && m[1] !== undefined ? m[1] : null;
409
+ }
410
+ /** Matches a Bee-Queue-style queue declaration; returns the literal name. */
411
+ function matchBeeLine(line) {
412
+ const m = /new\s+Bee\s*\(\s*['"]([^'"]+)['"]/.exec(line);
413
+ return m && m[1] !== undefined ? m[1] : null;
414
+ }
415
+ /** Matches a single-line `register('name'[, handler])` call. */
416
+ function matchRegisterLine(line, lines, idx) {
417
+ const m = /register\s*\(\s*['"]([^'"]+)['"]\s*(?=,|\))/.exec(line);
418
+ if (!m || m[1] === undefined)
419
+ return null;
420
+ // A handler is anything substantive after the second positional arg:
421
+ // an `async`/`function` keyword, an arrow `=>`, a `handler`/`fn`/`cb`
422
+ // token, or another identifier. If the comma has nothing after it on
423
+ // the same line, ambiguous.
424
+ const hasHandler = /(?:async|function|=>|handler\b|\bfn\b|\bcb\b|\bfnc\b)/.test(line);
425
+ return { name: m[1], hasHandler, receiver: receiverOfCall(lines, idx, m.index) };
426
+ }
427
+ /**
428
+ * Extracts the receiver chain preceding a `register(` call, e.g.
429
+ * `navigator.serviceWorker` in `navigator.serviceWorker.register(...)`.
430
+ * Returns null for a bare `register(...)` call (the pack's custom-queue
431
+ * runtime shape). Tolerates optional chaining (`obj?.register(...)`).
432
+ */
433
+ function receiverOf(line, callIndex) {
434
+ const before = line.slice(0, callIndex).replace(/\?\.$/, '.');
435
+ const m = /([\w$]+(?:\.[\w$]+)*)\.$/.exec(before);
436
+ return m && m[1] !== undefined ? m[1] : null;
437
+ }
438
+ /**
439
+ * How many previous non-empty lines the multi-line receiver rebuild may
440
+ * inspect. Bounded so a runaway chain can never scan the whole file.
441
+ */
442
+ const RECEIVER_LOOKBACK_LINES = 3;
443
+ /**
444
+ * Receiver extraction with multi-line support (dogfood phase 4): when
445
+ * the match line itself carries no receiver, the call is a MULTI-LINE
446
+ * member expression —
447
+ *
448
+ * navigator.serviceWorker
449
+ * .register('/sw.js', { scope: '/' })
450
+ *
451
+ * — and the receiver lives on the preceding line(s). The chain is
452
+ * rebuilt by joining the tail of each previous non-empty line with the
453
+ * head accumulated so far and re-running the exact single-line
454
+ * receiver grammar, so a receiver split over up to three continuation
455
+ * lines (`navigator` / `.serviceWorker` / `.register(...)`) resolves to
456
+ * `navigator.serviceWorker`. Comment lines never contribute a receiver
457
+ * (a mention in a comment is not a receiver), and a statement
458
+ * terminator (`;`/`{`/`}`) breaks the chain. Single-line calls return
459
+ * from the direct extraction before any lookback, so their behavior is
460
+ * byte-identical.
461
+ */
462
+ function receiverOfCall(lines, idx, callIndex) {
463
+ const line = lines[idx] ?? '';
464
+ const direct = receiverOf(line, callIndex);
465
+ if (direct !== null)
466
+ return direct;
467
+ // Only a member continuation (head ends with the access dot — `.`,
468
+ // `?.`, `(expr).`) or a bare call (head empty) can be continued from
469
+ // a previous line; anything else has no receiver to rebuild.
470
+ let head = line.slice(0, callIndex).trim();
471
+ if (head !== '' && !head.endsWith('.'))
472
+ return null;
473
+ let inspected = 0;
474
+ for (let back = idx - 1; back >= 0 && inspected < RECEIVER_LOOKBACK_LINES; back -= 1) {
475
+ const raw = lines[back] ?? '';
476
+ const trimmed = raw.trim();
477
+ if (trimmed.length === 0 ||
478
+ trimmed.startsWith('//') ||
479
+ trimmed.startsWith('/*') ||
480
+ trimmed.startsWith('*')) {
481
+ continue;
482
+ }
483
+ inspected += 1;
484
+ const candidate = raw.trimEnd() + head;
485
+ const receiver = receiverOf(candidate, candidate.length);
486
+ // A previous line that itself begins with a member dot is an
487
+ // INTERMEDIATE continuation (`navigator` / `.serviceWorker` /
488
+ // `.register(...)`): keep accumulating instead of returning the
489
+ // partial chain rooted mid-expression.
490
+ if (receiver !== null && !trimmed.startsWith('.'))
491
+ return receiver;
492
+ head = candidate.trimStart();
493
+ if (/[;{}]$/.test(head))
494
+ return null;
495
+ }
496
+ return null;
497
+ }
498
+ /**
499
+ * True when the receiver of a `register(` call is a browser/platform
500
+ * registration API rather than a task queue. WHY: service-worker,
501
+ * cache, and workbox registrations share the `register('...')` shape
502
+ * but have nothing to do with background tasks (real dogfood false
503
+ * positive on `navigator.serviceWorker.register('/sw.js', ...)`).
504
+ * Conservative by design: a receiver rooted at a browser global is
505
+ * NEVER a queue, and the service-worker handle is often aliased
506
+ * (`serviceWorkerRegistration`), so both the root and any
507
+ * `serviceWorker` segment are checked.
508
+ */
509
+ function isBrowserRegistrationReceiver(receiver) {
510
+ if (receiver === null)
511
+ return false;
512
+ const segments = receiver.split('.');
513
+ const root = segments[0] ?? '';
514
+ // Rooted at a browser global: `navigator.*`, `window.*`,
515
+ // `document.*`, `caches.*`, `workbox.*`.
516
+ if (BROWSER_RECEIVER_ROOTS[root] === true)
517
+ return true;
518
+ // Any `*.serviceWorker.register(...)` chain (aliased receivers too).
519
+ if (segments.includes('serviceWorker'))
520
+ return true;
521
+ // The conventional alias for the `navigator.serviceWorker.ready` handle.
522
+ return receiver === 'serviceWorkerRegistration';
523
+ }
524
+ /**
525
+ * True when the file shows real task-queue evidence: an import (or
526
+ * require / dynamic import) from a known queue library — bullmq, bull,
527
+ * celery, kue, agenda, pg-boss, sidekiq, bee-queue — or from any
528
+ * queue-named module (the pack's custom-queue runtime, e.g.
529
+ * `./custom-queue-runtime.js`), or a queue constructor
530
+ * (`new Queue|Bee|CustomQueue(`) in the file. Used to gate the loose
531
+ * single-line `register('name')` heuristic so it only fires on shapes
532
+ * that are provably queue-related.
533
+ */
534
+ function hasQueueEvidence(text) {
535
+ const specifierRe = /(?:\bfrom\s*|\brequire\s*\(\s*|\bimport\s*(?:\(\s*)?)['"]([^'"]+)['"]/g;
536
+ for (const m of text.matchAll(specifierRe)) {
537
+ const spec = m[1];
538
+ if (spec === undefined)
539
+ continue;
540
+ if (QUEUE_LIBRARY_MODULE.test(spec))
541
+ return true;
542
+ if (/queue/i.test(spec))
543
+ return true;
544
+ }
545
+ // A receiver constructed from a queue library in the same file is
546
+ // evidence too (`new Queue(...)` then `queue.register(...)`), as is
547
+ // the pack's own custom-queue constructor.
548
+ return /\bnew\s+(?:BullMQ\.Queue|Queue|Bee|CustomQueue)\s*\(/.test(text);
549
+ }
550
+ /** Matches a message-handler registration; returns the handler identifier. */
551
+ function matchMessageLine(line) {
552
+ if (/\bonmessage\s*=/.test(line))
553
+ return 'onmessage';
554
+ const onRe = /\.\s*on\s*\(\s*['"]message['"]/;
555
+ if (onRe.test(line)) {
556
+ const nameMatch = /['"]([^'"]+)['"]\s*,\s*(\w+)/.exec(line);
557
+ return nameMatch?.[2] ?? 'message-handler';
558
+ }
559
+ if (/addEventListener\s*\(\s*['"]message['"]/.test(line))
560
+ return 'message-handler';
561
+ return null;
562
+ }
563
+ /** Matches a recurring schedule (`setInterval(...)` / `setImmediate(...)`); returns the handler identifier. */
564
+ function matchRecurringLine(line) {
565
+ const intervalRe = /setInterval\s*\(\s*(\w+)/;
566
+ const intervalMatch = intervalRe.exec(line);
567
+ if (intervalMatch && intervalMatch[1] !== undefined)
568
+ return intervalMatch[1];
569
+ const immediateRe = /setImmediate\s*\(\s*(\w+)/;
570
+ const immediateMatch = immediateRe.exec(line);
571
+ if (immediateMatch && immediateMatch[1] !== undefined)
572
+ return immediateMatch[1];
573
+ return null;
574
+ }
575
+ /** Matches a `@Task` / `@Queue` decorator line. */
576
+ function matchDecoratorLine(line) {
577
+ return /@(Task|Queue)\b/.test(line);
578
+ }
579
+ /**
580
+ * Returns the index of the `}` matching the `{` at `openIdx`, honoring
581
+ * nested braces and single-line string literals. Returns -1 if
582
+ * unbalanced.
583
+ */
584
+ function matchingBrace(source, openIdx) {
585
+ let depth = 0;
586
+ let inString = false;
587
+ for (let i = openIdx; i < source.length; i += 1) {
588
+ const ch = source[i];
589
+ const next = source[i + 1];
590
+ if (inString) {
591
+ if (ch === '\\') {
592
+ i += 1;
593
+ continue;
594
+ }
595
+ if (ch === inString)
596
+ inString = false;
597
+ continue;
598
+ }
599
+ if (ch === "'" || ch === '"' || ch === '`') {
600
+ inString = ch;
601
+ continue;
602
+ }
603
+ if (ch === '/' && next === '/') {
604
+ while (i < source.length && source[i] !== '\n')
605
+ i += 1;
606
+ continue;
607
+ }
608
+ if (ch === '/' && next === '*') {
609
+ i += 2;
610
+ while (i < source.length && !(source[i] === '*' && source[i + 1] === '/'))
611
+ i += 1;
612
+ i += 1;
613
+ continue;
614
+ }
615
+ if (ch === '{')
616
+ depth += 1;
617
+ else if (ch === '}') {
618
+ depth -= 1;
619
+ if (depth === 0)
620
+ return i;
621
+ }
622
+ }
623
+ return -1;
624
+ }
625
+ /**
626
+ * Extracts `{ attempts, backoff }` from `defaultJobOptions` or inline
627
+ * `{ attempts, backoff: { type } }` patterns. Returns null when no
628
+ * retry-policy hint is present (callers fall back to the default).
629
+ */
630
+ function extractRetryPolicy(source, anchorLine) {
631
+ const window = surroundingWindow(source, anchorLine, 6);
632
+ const attemptsMatch = /attempts\s*:\s*(\d+)/.exec(window);
633
+ const backoffTypeMatch = /backoff\s*:\s*\{\s*type\s*:\s*['"](\w+)['"]/.exec(window);
634
+ if (attemptsMatch && attemptsMatch[1] !== undefined) {
635
+ const backoff = backoffTypeMatch && backoffTypeMatch[1] === 'exponential' ? 'exponential' : 'fixed';
636
+ return { maxAttempts: Number(attemptsMatch[1]), backoff };
637
+ }
638
+ const retriesMatch = /retries\s*:\s*(\d+)/.exec(window);
639
+ if (retriesMatch && retriesMatch[1] !== undefined) {
640
+ return { maxAttempts: Number(retriesMatch[1]) + 1, backoff: 'fixed' };
641
+ }
642
+ return null;
643
+ }
644
+ /** Extracts error-type names from patterns like `terminalOn: ['AuthError', ...]`. */
645
+ function extractTerminalOn(source) {
646
+ const match = /terminalOn\s*:\s*\[([^\]]+)\]/.exec(source);
647
+ if (!match || match[1] === undefined)
648
+ return [];
649
+ return match[1]
650
+ .split(',')
651
+ .map((part) => part.trim().replace(/^['"]|['"]$/g, ''))
652
+ .filter((part) => part.length > 0);
653
+ }
654
+ /** Extracts an observability hint from the file (presence of metrics/tracing/listeners). */
655
+ function extractObservabilityHint(source) {
656
+ return /\bmetrics\s*[:({]|\btracing\b|\.on\(['"](?:completed|failed|stalled)['"]/.test(source);
657
+ }
658
+ /** Builds a window of source text around `anchorLine` (±radius). */
659
+ function surroundingWindow(source, anchorLine, radius) {
660
+ const all = source.split('\n');
661
+ const start = Math.max(0, anchorLine - radius);
662
+ const end = Math.min(all.length, anchorLine + radius + 1);
663
+ return all.slice(start, end).join('\n');
664
+ }
665
+ /**
666
+ * Finalizes raw detections into the discovery outcome (dogfood
667
+ * remediation phase 4): NO classification signals are minted. The
668
+ * targets this pack once guessed for its `internality`/`worker`
669
+ * reachability signals (model/repository import-path segments, every
670
+ * PascalCase identifier in the file, stripped task-name fragments) are
671
+ * path-derived guesses about OTHER detectors' resources — this pack
672
+ * emits no resources of its own — so in real repos they mostly matched
673
+ * nothing and every miss became a `STALE_SIGNAL_TARGET` blocker while
674
+ * adding no information. Unknown exposure already defaults user-facing
675
+ * and unknown lifecycle already defaults enabled (ADR 0003 D5), so the
676
+ * removal flips no classification and shrinks no obligation set. Core
677
+ * keeps detecting genuinely stale authority signals; the field stays
678
+ * in the wire shape (protocol contract) and is always empty.
679
+ *
680
+ * `_detections` is intentionally unused: the detection inventory still
681
+ * drives the register-branch findings/unresolved gates inside
682
+ * `scanFile`, and the pack's outcome carries no resource channel.
683
+ */
684
+ function finalize(_detections, findings, unresolved, scanned = []) {
685
+ findings.sort((a, b) => {
686
+ if (a.code !== b.code)
687
+ return a.code < b.code ? -1 : 1;
688
+ return a.detail < b.detail ? -1 : a.detail > b.detail ? 1 : 0;
689
+ });
690
+ return {
691
+ resources: [],
692
+ unresolved,
693
+ findings,
694
+ classificationSignals: [],
695
+ scannedPaths: scanned.sort(),
696
+ };
697
+ }
698
+ const defaultDetector = createTaskDetector();
699
+ /** The pinned in-process plugin contract: `{ discover(paths) }`. */
700
+ export const discover = (paths) => defaultDetector.discover(paths);
701
+ //# sourceMappingURL=detector.js.map