@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.
- package/LICENSE +202 -0
- package/README.md +155 -0
- package/dist/adapter-schema.d.ts +83 -0
- package/dist/adapter-schema.d.ts.map +1 -0
- package/dist/adapter-schema.js +71 -0
- package/dist/adapter-schema.js.map +1 -0
- package/dist/detector.d.ts +48 -0
- package/dist/detector.d.ts.map +1 -0
- package/dist/detector.js +701 -0
- package/dist/detector.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/obligations.d.ts +41 -0
- package/dist/obligations.d.ts.map +1 -0
- package/dist/obligations.js +53 -0
- package/dist/obligations.js.map +1 -0
- package/dist/version.d.ts +12 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +12 -0
- package/dist/version.js.map +1 -0
- package/package.json +44 -0
package/dist/detector.js
ADDED
|
@@ -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
|