@vantageos/backend-doctor 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/README.md +88 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +94 -0
- package/dist/contract.d.ts +18 -0
- package/dist/contract.js +29 -0
- package/dist/coverage-cli.d.ts +2 -0
- package/dist/coverage-cli.js +17 -0
- package/dist/coverage.d.ts +32 -0
- package/dist/coverage.js +77 -0
- package/dist/detectors/oracle-axis1.d.ts +86 -0
- package/dist/detectors/oracle-axis1.js +221 -0
- package/dist/detectors/oracle-axis2.d.ts +22 -0
- package/dist/detectors/oracle-axis2.js +149 -0
- package/dist/detectors/predicates.d.ts +19 -0
- package/dist/detectors/predicates.js +334 -0
- package/dist/detectors/shared.d.ts +56 -0
- package/dist/detectors/shared.js +103 -0
- package/dist/detectors/source/_scan.d.ts +43 -0
- package/dist/detectors/source/_scan.js +82 -0
- package/dist/detectors/source/backfill.d.ts +27 -0
- package/dist/detectors/source/backfill.js +211 -0
- package/dist/detectors/source/contract.d.ts +33 -0
- package/dist/detectors/source/contract.js +385 -0
- package/dist/detectors/source/identity.d.ts +47 -0
- package/dist/detectors/source/identity.js +410 -0
- package/dist/detectors/source/jobs.d.ts +10 -0
- package/dist/detectors/source/jobs.js +209 -0
- package/dist/detectors/source/lifecycle.d.ts +6 -0
- package/dist/detectors/source/lifecycle.js +147 -0
- package/dist/detectors/source/readbudget.d.ts +6 -0
- package/dist/detectors/source/readbudget.js +133 -0
- package/dist/detectors/source/refusal.d.ts +6 -0
- package/dist/detectors/source/refusal.js +125 -0
- package/dist/detectors/source/security.d.ts +37 -0
- package/dist/detectors/source/security.js +392 -0
- package/dist/detectors/source/writesafety.d.ts +14 -0
- package/dist/detectors/source/writesafety.js +345 -0
- package/dist/detectors/standard/_doc.d.ts +66 -0
- package/dist/detectors/standard/_doc.js +152 -0
- package/dist/detectors/standard/r41-frontier.d.ts +58 -0
- package/dist/detectors/standard/r41-frontier.js +176 -0
- package/dist/detectors/standard/r46-returns-validator.d.ts +61 -0
- package/dist/detectors/standard/r46-returns-validator.js +474 -0
- package/dist/detectors/standard/standard-doc.d.ts +12 -0
- package/dist/detectors/standard/standard-doc.js +271 -0
- package/dist/detectors/structural.d.ts +14 -0
- package/dist/detectors/structural.js +83 -0
- package/dist/engine-types.d.ts +10 -0
- package/dist/engine-types.js +1 -0
- package/dist/not-checkable.d.ts +22 -0
- package/dist/not-checkable.js +37 -0
- package/dist/oracle.d.ts +33 -0
- package/dist/oracle.js +133 -0
- package/dist/registry.d.ts +11 -0
- package/dist/registry.js +140 -0
- package/dist/report.d.ts +35 -0
- package/dist/report.js +138 -0
- package/dist/severity.d.ts +30 -0
- package/dist/severity.js +121 -0
- package/dist/tally.d.ts +57 -0
- package/dist/tally.js +152 -0
- package/dist/trigger.d.ts +47 -0
- package/dist/trigger.js +143 -0
- package/fixtures/backend-standard-51ec7af1.md +648 -0
- package/fixtures/backend-standard-a9d7877b.md +629 -0
- package/fixtures/mcp-app-standard-a74a042b.md +279 -0
- package/fixtures/mcp-extension-standard-a74a042b.md +259 -0
- package/fixtures/mcp-server-standard-a74a042b.md +434 -0
- package/fixtures/mcp-standard-a74a042b.md +354 -0
- package/fixtures/vp-1200-green/.pin +1 -0
- package/fixtures/vp-1200-green/convex/migrations.ts +350 -0
- package/fixtures/vp-1200-green/convex/schema.ts +1243 -0
- package/fixtures/vp-1200-red/.pin +1 -0
- package/fixtures/vp-1200-red/convex/migrations.ts +239 -0
- package/fixtures/vp-1200-red/convex/schema.ts +1243 -0
- package/fixtures/vp-by-tool-day158.csv +190 -0
- package/fixtures/vp-convex/.github/workflows/npm-publish.yml +163 -0
- package/fixtures/vp-convex/.pin +1 -0
- package/fixtures/vp-convex/bun.lock +783 -0
- package/fixtures/vp-convex/convex/auth.config.ts +8 -0
- package/fixtures/vp-convex/convex/briefingNotes.ts +321 -0
- package/fixtures/vp-convex/convex/convex.config.ts +14 -0
- package/fixtures/vp-convex/convex/credentials.ts +591 -0
- package/fixtures/vp-convex/convex/crons.ts +72 -0
- package/fixtures/vp-convex/convex/dashboard.ts +199 -0
- package/fixtures/vp-convex/convex/gumroadWebhook.ts +815 -0
- package/fixtures/vp-convex/convex/issues.ts +544 -0
- package/fixtures/vp-convex/convex/kb.ts +305 -0
- package/fixtures/vp-convex/convex/kbMutations.ts +192 -0
- package/fixtures/vp-convex/convex/kbShared.ts +56 -0
- package/fixtures/vp-convex/convex/licenses.ts +443 -0
- package/fixtures/vp-convex/convex/memories.ts +406 -0
- package/fixtures/vp-convex/convex/memoriesScoped.ts +220 -0
- package/fixtures/vp-convex/convex/messages.ts +971 -0
- package/fixtures/vp-convex/convex/migrations.ts +239 -0
- package/fixtures/vp-convex/convex/missionTemplates.ts +369 -0
- package/fixtures/vp-convex/convex/oauth.ts +1608 -0
- package/fixtures/vp-convex/convex/oauthMigrations.ts +96 -0
- package/fixtures/vp-convex/convex/okfBundleNode.ts +1031 -0
- package/fixtures/vp-convex/convex/recurringTasks.ts +386 -0
- package/fixtures/vp-convex/convex/schema.ts +1229 -0
- package/fixtures/vp-convex/convex/tasks.ts +2782 -0
- package/fixtures/vp-r31-green/.pin +1 -0
- package/fixtures/vp-r31-green/convex/migrations.ts +350 -0
- package/fixtures/vp-r31-red/.pin +1 -0
- package/fixtures/vp-r31-red/convex/migrations.ts +286 -0
- package/fixtures/vr-by-tool-day159.csv +89 -0
- package/package.json +39 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { base, code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// R-17 — a schema migration is expand-contract: additive + multi-step, never a
|
|
4
|
+
// destructive single-deploy rename a rollback cannot undo. (standard §15 line
|
|
5
|
+
// 113 / detection heuristic line 406: "migration is multi-step additive")
|
|
6
|
+
//
|
|
7
|
+
// SOURCE READ: convex/migrations.ts backfills additively with `ctx.db.patch`
|
|
8
|
+
// (backfillTaskTimes patches startedAt/completedAt at line 41; migrateMemories-
|
|
9
|
+
// Namespace patches `namespace` at 195), and convex/oauthMigrations.ts
|
|
10
|
+
// backfillTokenEndpointAuthMethod patches the new field at line 87 and documents
|
|
11
|
+
// itself "Safe to re-run (idempotent)". PATTERN derived from that source: inside
|
|
12
|
+
// a *migration* file, a DB write that `.patch(`es (additive backfill / dual-write)
|
|
13
|
+
// is the expand-contract step and conforms; a `.replace(` rewrites a whole doc in
|
|
14
|
+
// one deploy and can DROP unlisted fields — the destructive single-step violation.
|
|
15
|
+
// A row-level `.delete(` is data cleanup (test rows), not a schema contract — out
|
|
16
|
+
// of R-17's surface. No `.replace(` exists in a migration file today, so the
|
|
17
|
+
// violation half is exercised by a crafted line.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
const MIGRATION_FILE = /migration/i;
|
|
20
|
+
const ADDITIVE_PATCH = /\.patch\s*\(/;
|
|
21
|
+
const DESTRUCTIVE_REPLACE = /\.replace\s*\(/;
|
|
22
|
+
function isMigrationFile(f) {
|
|
23
|
+
return MIGRATION_FILE.test(base(f.rel));
|
|
24
|
+
}
|
|
25
|
+
export function scanR17(files) {
|
|
26
|
+
const scan = emptyScan();
|
|
27
|
+
for (const f of files) {
|
|
28
|
+
if (!isJudgeable(f) || !isMigrationFile(f))
|
|
29
|
+
continue;
|
|
30
|
+
for (const l of f.lines) {
|
|
31
|
+
if (!code(l))
|
|
32
|
+
continue;
|
|
33
|
+
if (DESTRUCTIVE_REPLACE.test(l.raw)) {
|
|
34
|
+
scan.violate.push({
|
|
35
|
+
rel: f.rel,
|
|
36
|
+
n: l.n,
|
|
37
|
+
note: "migration uses ctx.db.replace — rewrites a whole doc in one deploy, can drop unlisted fields (not expand-contract)",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
else if (ADDITIVE_PATCH.test(l.raw)) {
|
|
41
|
+
scan.conform.push({
|
|
42
|
+
rel: f.rel,
|
|
43
|
+
n: l.n,
|
|
44
|
+
note: "migration backfills additively with ctx.db.patch (expand-contract migrate step)",
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return scan;
|
|
50
|
+
}
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// R-18 — a retriable write is idempotent: a duplicate delivery is a no-op, not a
|
|
53
|
+
// second row. (standard §15 line 115 / line 407)
|
|
54
|
+
//
|
|
55
|
+
// SOURCE READ: convex/licenses.ts findOrCreateForGumroad queries `by_gumroadOrderId`
|
|
56
|
+
// `.unique()` (lines 312-317) BEFORE `ctx.db.insert("licenses"` (line 365) — a
|
|
57
|
+
// duplicate sale_id returns the existing row. convex/gumroadWebhook.ts drives it
|
|
58
|
+
// through `findOrCreateForGumroad`. convex/okfBundleNode.ts importOkfBundle takes
|
|
59
|
+
// an `idempotencyKey` arg (line 884) and dedups each row via `findExisting…`
|
|
60
|
+
// before insert. PATTERN: a RETRIABLE file (a webhook / license / bundle-import
|
|
61
|
+
// path a client or job may replay) that INSERTs must carry an idempotency guard
|
|
62
|
+
// somewhere in the file — a unique-key read, an idempotencyKey, or a findOrCreate/
|
|
63
|
+
// findExisting dedup. An insert on a retriable path with no such guard is the
|
|
64
|
+
// violation.
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
const RETRIABLE_FILE = /webhook|license|okfbundle/i;
|
|
67
|
+
const INSERT_CALL = /ctx\.db\.insert\s*\(/;
|
|
68
|
+
const IDEMPOTENCY_GUARD = /\.unique\s*\(\s*\)|idempotencykey|findorcreate|findexisting|by_gumroadorderid|by_keyhash|deliveryid|delivery_id/i;
|
|
69
|
+
function isRetriableFile(f) {
|
|
70
|
+
return RETRIABLE_FILE.test(base(f.rel));
|
|
71
|
+
}
|
|
72
|
+
export function scanR18(files) {
|
|
73
|
+
const scan = emptyScan();
|
|
74
|
+
for (const f of files) {
|
|
75
|
+
if (!isJudgeable(f) || !isRetriableFile(f))
|
|
76
|
+
continue;
|
|
77
|
+
let insertLine = 0;
|
|
78
|
+
let guard = false;
|
|
79
|
+
for (const l of f.lines) {
|
|
80
|
+
if (!code(l))
|
|
81
|
+
continue;
|
|
82
|
+
if (!insertLine && INSERT_CALL.test(l.raw))
|
|
83
|
+
insertLine = l.n;
|
|
84
|
+
if (IDEMPOTENCY_GUARD.test(l.raw))
|
|
85
|
+
guard = true;
|
|
86
|
+
}
|
|
87
|
+
if (!insertLine)
|
|
88
|
+
continue; // no retriable write in this file — nothing to judge here
|
|
89
|
+
if (guard) {
|
|
90
|
+
scan.conform.push({
|
|
91
|
+
rel: f.rel,
|
|
92
|
+
n: insertLine,
|
|
93
|
+
note: "retriable insert guarded by an idempotency/unique key (findOrCreate / .unique() / idempotencyKey)",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
scan.violate.push({
|
|
98
|
+
rel: f.rel,
|
|
99
|
+
n: insertLine,
|
|
100
|
+
note: "insert on a retriable (webhook/license/import) path with no idempotency guard — a replayed delivery writes a second row",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return scan;
|
|
105
|
+
}
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// R-29 — an atomic invariant runs in ONE mutation; an action never orchestrates
|
|
108
|
+
// several mutations to fake a transaction. (standard §15 line 262 / line 418)
|
|
109
|
+
//
|
|
110
|
+
// SOURCE READ: convex/okfBundleNode.ts importOkfBundle is a public `action`
|
|
111
|
+
// (line 874) whose handler calls `ctx.runMutation(internal.okfBundle._insert…)`
|
|
112
|
+
// at THREE sites (lines 966, 989, 1014) inside its import loop — memories,
|
|
113
|
+
// briefings, and tasks are inserted across separate transactions, so a mid-loop
|
|
114
|
+
// failure leaves the bundle half-imported. PATTERN: an `action` handler that
|
|
115
|
+
// issues MORE THAN ONE `ctx.runMutation(` orchestrates several mutations where an
|
|
116
|
+
// atomic invariant (the bundle imported wholesale) is implied — flagged
|
|
117
|
+
// non-conforming (judgement: a human confirms the invariant truly needs
|
|
118
|
+
// atomicity). An action with ≤1 runMutation conforms.
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
const ACTION_OPEN = /export\s+const\s+(\w+)\s*=\s*action\s*\(/;
|
|
121
|
+
const NEXT_EXPORT = /export\s+const\s+\w+\s*=/;
|
|
122
|
+
const RUN_MUTATION = /ctx\.runMutation\s*\(/;
|
|
123
|
+
export function scanR29(files) {
|
|
124
|
+
const scan = emptyScan();
|
|
125
|
+
for (const f of files) {
|
|
126
|
+
if (!isJudgeable(f))
|
|
127
|
+
continue;
|
|
128
|
+
let inAction = "";
|
|
129
|
+
let openLine = 0;
|
|
130
|
+
let count = 0;
|
|
131
|
+
let firstMut = 0;
|
|
132
|
+
const flush = () => {
|
|
133
|
+
if (!inAction)
|
|
134
|
+
return;
|
|
135
|
+
if (count > 1) {
|
|
136
|
+
scan.violate.push({
|
|
137
|
+
rel: f.rel,
|
|
138
|
+
n: firstMut || openLine,
|
|
139
|
+
note: `action ${inAction} orchestrates ${count} ctx.runMutation calls — several mutations across a race window, not one atomic transaction`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
else if (count === 1) {
|
|
143
|
+
scan.conform.push({
|
|
144
|
+
rel: f.rel,
|
|
145
|
+
n: firstMut,
|
|
146
|
+
note: `action ${inAction} issues a single ctx.runMutation — no multi-mutation atomicity gap`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
for (const l of f.lines) {
|
|
151
|
+
if (!code(l))
|
|
152
|
+
continue;
|
|
153
|
+
const open = ACTION_OPEN.exec(l.raw);
|
|
154
|
+
if (open) {
|
|
155
|
+
flush();
|
|
156
|
+
inAction = open[1];
|
|
157
|
+
openLine = l.n;
|
|
158
|
+
count = 0;
|
|
159
|
+
firstMut = 0;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (inAction && NEXT_EXPORT.test(l.raw) && !ACTION_OPEN.test(l.raw)) {
|
|
163
|
+
flush();
|
|
164
|
+
inAction = "";
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (inAction && RUN_MUTATION.test(l.raw)) {
|
|
168
|
+
count++;
|
|
169
|
+
if (!firstMut)
|
|
170
|
+
firstMut = l.n;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
flush();
|
|
174
|
+
}
|
|
175
|
+
return scan;
|
|
176
|
+
}
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// R-30 — the declared max is OURS, below the platform limit; a bare `.take(n)`
|
|
179
|
+
// with no truncation signal has declared no bound at all. (standard §15 line 264
|
|
180
|
+
// / line 419)
|
|
181
|
+
//
|
|
182
|
+
// SOURCE READ: convex/tasks.ts declares `TASK_LIST_SCAN_CAP = 2000` (line 359)
|
|
183
|
+
// and every wide scan takes `TASK_LIST_SCAN_CAP + 1` (line 625) then throws
|
|
184
|
+
// `SCAN_CAP_EXCEEDED` (line 719) — a named bound with a loud truncation signal.
|
|
185
|
+
// The standard's illustrated violation is REAL and current: convex/dashboard.ts
|
|
186
|
+
// `.take(500)`/`.take(1000)` (lines 65-163), convex/issues.ts (401-403),
|
|
187
|
+
// convex/messages.ts (744), convex/memories.ts (386), convex/missionTemplates.ts
|
|
188
|
+
// (72) are bare numeric literals ≥500 with no truncation signal. PATTERN: a
|
|
189
|
+
// `.take(<identifier>)` or a small literal (<500) is a declared, ours-and-below
|
|
190
|
+
// bound and conforms; a bare `.take(<literal ≥500>)` has merely inherited the
|
|
191
|
+
// platform ceiling and is the violation.
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
const TAKE_LITERAL = /\.take\s*\(\s*(\d[\d_]*)\s*\)/;
|
|
194
|
+
const BARE_LIMIT = 500;
|
|
195
|
+
export function scanR30(files) {
|
|
196
|
+
const scan = emptyScan();
|
|
197
|
+
for (const f of files) {
|
|
198
|
+
if (!isJudgeable(f))
|
|
199
|
+
continue;
|
|
200
|
+
for (const l of f.lines) {
|
|
201
|
+
if (!code(l))
|
|
202
|
+
continue;
|
|
203
|
+
const m = TAKE_LITERAL.exec(l.raw);
|
|
204
|
+
if (m) {
|
|
205
|
+
const n = Number(m[1].replace(/_/g, ""));
|
|
206
|
+
if (n >= BARE_LIMIT) {
|
|
207
|
+
scan.violate.push({
|
|
208
|
+
rel: f.rel,
|
|
209
|
+
n: l.n,
|
|
210
|
+
note: `bare .take(${n}) — a fixed numeric literal ≥${BARE_LIMIT} with no named cap or truncation signal; declares no bound of its own`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
scan.conform.push({
|
|
215
|
+
rel: f.rel,
|
|
216
|
+
n: l.n,
|
|
217
|
+
note: `.take(${n}) — a small declared max, ours and below the platform ceiling`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else if (/\.take\s*\(\s*[A-Za-z_]/.test(l.raw)) {
|
|
222
|
+
// .take(<identifier or expression>) — a NAMED bound (e.g. TASK_LIST_SCAN_CAP + 1, fetchCap).
|
|
223
|
+
const idx = l.raw.indexOf(".take(");
|
|
224
|
+
scan.conform.push({
|
|
225
|
+
rel: f.rel,
|
|
226
|
+
n: l.n,
|
|
227
|
+
note: `.take(<named bound>) — a declared constant, not a bare literal (${l.raw.slice(idx, idx + 40).trim()})`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return scan;
|
|
233
|
+
}
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// R-31 — a bulk write beyond one transaction self-schedules in batches:
|
|
236
|
+
// `.take(n)` + `scheduler.runAfter(0, self, …)`. (standard §15 line 266 / 420)
|
|
237
|
+
//
|
|
238
|
+
// SOURCE READ (divergence, reported as R-33's §9a was): convex/tasks.ts
|
|
239
|
+
// bulkComplete (line 2104) is bounded by `BULK_COMPLETE_HARD_CAP = 500` (line
|
|
240
|
+
// 2084), drains one batch, and reports `remaining: true` (lines 2241/2281) so the
|
|
241
|
+
// CALLER calls again to continue — a caller-driven drain, NOT the
|
|
242
|
+
// `scheduler.runAfter(0, self)` self-scheduling the standard names as THE
|
|
243
|
+
// mechanism. bulkComplete NEVER attempts an unbounded write (so it is not a
|
|
244
|
+
// violation), but it does not self-schedule (so it is not a strict conform). The
|
|
245
|
+
// detector reports could-not-judge with that divergence as its machine-readable
|
|
246
|
+
// reason. PATTERN: a `bulk`-named write that pairs a `.take(`/cap with
|
|
247
|
+
// `scheduler.runAfter(0, self)` conforms; one with an unbounded `.collect()`-and-
|
|
248
|
+
// patch-all and no cap/continuation is the violation; a bounded one that
|
|
249
|
+
// continues by a caller-driven `remaining`/`cappedAt` drain instead of
|
|
250
|
+
// self-scheduling is the could-not-judge divergence.
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
const BULK_FN = /export\s+const\s+(bulk\w*)\s*=\s*(?:internal)?mutation\s*\(/i;
|
|
253
|
+
const NEXT_EXPORT_R31 = /export\s+const\s+\w+\s*=/;
|
|
254
|
+
const SELF_SCHEDULE = /scheduler\.runafter\s*\(\s*0\s*,/i;
|
|
255
|
+
const CAP_TOKEN = /\.take\s*\(|hard_cap|_cap\b|slice\s*\(/i;
|
|
256
|
+
const CALLER_DRAIN = /\bremaining\b|cappedat/i;
|
|
257
|
+
const UNBOUNDED = /\.collect\s*\(\s*\)/;
|
|
258
|
+
export function scanR31(files) {
|
|
259
|
+
const scan = emptyScan();
|
|
260
|
+
for (const f of files) {
|
|
261
|
+
if (!isJudgeable(f))
|
|
262
|
+
continue;
|
|
263
|
+
let name = "";
|
|
264
|
+
let openLine = 0;
|
|
265
|
+
let selfSched = false;
|
|
266
|
+
let cap = false;
|
|
267
|
+
let drain = false;
|
|
268
|
+
let unbounded = false;
|
|
269
|
+
const flush = () => {
|
|
270
|
+
if (!name)
|
|
271
|
+
return;
|
|
272
|
+
if (selfSched && cap) {
|
|
273
|
+
scan.conform.push({
|
|
274
|
+
rel: f.rel,
|
|
275
|
+
n: openLine,
|
|
276
|
+
note: `${name} continues via .take + scheduler.runAfter(0, self) — batched self-scheduling within the transaction limit`,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
else if (unbounded && !cap) {
|
|
280
|
+
scan.violate.push({
|
|
281
|
+
rel: f.rel,
|
|
282
|
+
n: openLine,
|
|
283
|
+
note: `${name} does an unbounded .collect()-and-write with no cap or continuation — can exceed the per-transaction limit`,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
else if (cap && drain) {
|
|
287
|
+
scan.cnj.push({
|
|
288
|
+
rel: f.rel,
|
|
289
|
+
n: openLine,
|
|
290
|
+
note: `${name} is bounded (cap + remaining/cappedAt) but continues by a CALLER-DRIVEN drain, not scheduler.runAfter(0, self) — the standard §15:420 names self-scheduling as THE mechanism; the source diverges (bounded, not a violation, but not the named mechanism)`,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
scan.cnj.push({
|
|
295
|
+
rel: f.rel,
|
|
296
|
+
n: openLine,
|
|
297
|
+
note: `${name} bulk write — continuation mechanism could not be statically classified (no self-schedule, cap=${cap}, drain=${drain})`,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
for (const l of f.lines) {
|
|
302
|
+
if (!code(l))
|
|
303
|
+
continue;
|
|
304
|
+
const open = BULK_FN.exec(l.raw);
|
|
305
|
+
if (open) {
|
|
306
|
+
flush();
|
|
307
|
+
name = open[1];
|
|
308
|
+
openLine = l.n;
|
|
309
|
+
selfSched = cap = drain = unbounded = false;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (name && NEXT_EXPORT_R31.test(l.raw) && !BULK_FN.test(l.raw)) {
|
|
313
|
+
flush();
|
|
314
|
+
name = "";
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (!name)
|
|
318
|
+
continue;
|
|
319
|
+
if (SELF_SCHEDULE.test(l.raw))
|
|
320
|
+
selfSched = true;
|
|
321
|
+
if (CAP_TOKEN.test(l.raw))
|
|
322
|
+
cap = true;
|
|
323
|
+
if (CALLER_DRAIN.test(l.raw))
|
|
324
|
+
drain = true;
|
|
325
|
+
if (UNBOUNDED.test(l.raw))
|
|
326
|
+
unbounded = true;
|
|
327
|
+
}
|
|
328
|
+
flush();
|
|
329
|
+
}
|
|
330
|
+
return scan;
|
|
331
|
+
}
|
|
332
|
+
// ---- detectors ------------------------------------------------------------
|
|
333
|
+
export const R17 = srcDetector("R-17", "R-17 migration is expand-contract additive, not a destructive single deploy", "a migration backfills additively with ctx.db.patch, never a whole-doc ctx.db.replace in one deploy", scanR17, "R-17: zero migration files matched in the scanned tree — cannot certify expand-contract migration. NOT a clean bill.");
|
|
334
|
+
export const R18 = srcDetector("R-18", "R-18 retriable write is idempotent", "an insert on a webhook/license/import path carries an idempotency/unique guard", scanR18, "R-18: zero retriable insert sites matched (webhook/license/okfbundle) — cannot certify idempotency. NOT a clean bill.");
|
|
335
|
+
export const R29 = srcDetector("R-29", "R-29 atomic invariant is one mutation, not an action orchestrating writes", "no action issues more than one ctx.runMutation to fake a transaction", scanR29, "R-29: no action handlers matched in the scanned tree — cannot certify single-mutation atomicity. NOT a clean bill.");
|
|
336
|
+
export const R30 = srcDetector("R-30", "R-30 declared max is ours and below the platform limit", "no bare .take(literal>=500) as the only bound; a named cap or a small declared max instead", scanR30, "R-30: no .take(...) sites matched in the scanned tree — cannot certify the declared-bound rule. NOT a clean bill.");
|
|
337
|
+
export const R31 = srcDetector("R-31", "R-31 bulk write self-schedules in batches within the transaction limit", "a bulk write pairs .take with scheduler.runAfter(0, self); a caller-driven drain is a reported divergence", scanR31, "R-31: no bulk-named mutation matched in the scanned tree — cannot certify batched self-scheduling. NOT a clean bill.");
|
|
338
|
+
/** The five, in a form the orchestrator spreads into REGISTRY. */
|
|
339
|
+
export const SOURCE_WRITESAFETY_DETECTORS = [
|
|
340
|
+
{ detector: R17, ruleId: "R-17", standardPin: SOURCE_STANDARD_PIN },
|
|
341
|
+
{ detector: R18, ruleId: "R-18", standardPin: SOURCE_STANDARD_PIN },
|
|
342
|
+
{ detector: R29, ruleId: "R-29", standardPin: SOURCE_STANDARD_PIN },
|
|
343
|
+
{ detector: R30, ruleId: "R-30", standardPin: SOURCE_STANDARD_PIN },
|
|
344
|
+
{ detector: R31, ruleId: "R-31", standardPin: SOURCE_STANDARD_PIN },
|
|
345
|
+
];
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Detector, DetectorResult } from "../../engine-types.js";
|
|
2
|
+
/** The vendored-fixture basename used as the anchor file for every doc site. */
|
|
3
|
+
export declare const STANDARD_FILE = "backend-standard-51ec7af1.md";
|
|
4
|
+
export declare const VENDORED_STANDARD_PATH: string;
|
|
5
|
+
/** The standard all four are pinned against (short hash a9d7877b). */
|
|
6
|
+
export declare const STANDARD_DOC_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) \u00A714/\u00A711/\u00A710/\u00A73";
|
|
7
|
+
/** Read the vendored standard's raw text. Injectable for tests. */
|
|
8
|
+
export declare function readStandard(path?: string): string;
|
|
9
|
+
export interface DocLine {
|
|
10
|
+
/** 1-based line number in the vendored file. */
|
|
11
|
+
n: number;
|
|
12
|
+
raw: string;
|
|
13
|
+
}
|
|
14
|
+
/** Split the standard text into 1-based numbered lines. */
|
|
15
|
+
export declare function toLines(text: string): DocLine[];
|
|
16
|
+
/**
|
|
17
|
+
* The lines belonging to a numbered section `## <num>.` — from its header to the
|
|
18
|
+
* next top-level `## <n>.` header (exclusive). Returns [] when the section is
|
|
19
|
+
* absent (a finding the caller surfaces, never a silent pass).
|
|
20
|
+
*/
|
|
21
|
+
export declare function sectionLines(lines: DocLine[], num: string): DocLine[];
|
|
22
|
+
export interface DocRow {
|
|
23
|
+
n: number;
|
|
24
|
+
cells: string[];
|
|
25
|
+
}
|
|
26
|
+
/** Parse the pipe-table rows in a slice of lines — skipping the header separator
|
|
27
|
+
* (`|---|`). Each row keeps its 1-based line number so a site anchors to it. */
|
|
28
|
+
export declare function tableRows(lines: DocLine[]): DocRow[];
|
|
29
|
+
/** A placeholder / empty answer — never a real filled value. */
|
|
30
|
+
export declare function isDocPlaceholder(v: string): boolean;
|
|
31
|
+
export interface DocSite {
|
|
32
|
+
n: number;
|
|
33
|
+
note: string;
|
|
34
|
+
}
|
|
35
|
+
export interface DocScan {
|
|
36
|
+
conform: DocSite[];
|
|
37
|
+
violate: DocSite[];
|
|
38
|
+
cnj: DocSite[];
|
|
39
|
+
}
|
|
40
|
+
export declare function emptyDocScan(): DocScan;
|
|
41
|
+
/**
|
|
42
|
+
* Reduce a doc scan to ONE engine DetectorResult. Precedence for the
|
|
43
|
+
* STANDARD-DOCUMENT kind:
|
|
44
|
+
* - any STATIC violation (a source cell outside the known set, an unnamed
|
|
45
|
+
* mechanism, a mismatched limit-governed set) is decisive → non-conforming;
|
|
46
|
+
* - else any could-not-judge (the delivery-gate / human half surfaced PER
|
|
47
|
+
* SITE) → could-not-judge WITH a reason enumerating the sites;
|
|
48
|
+
* - else conform-only → present (these gate rules always add a gate cnj, so
|
|
49
|
+
* this branch is the pure-static-rule fallback);
|
|
50
|
+
* - else (zero applicable rows) → could-not-judge naming the empty scan.
|
|
51
|
+
*
|
|
52
|
+
* GREEN IS NOT the detection running — it is the human/gate half surfaced per
|
|
53
|
+
* site, named and countable (Eta's acceptance property). So on real material,
|
|
54
|
+
* with the static half clean, these detectors return could-not-judge naming the
|
|
55
|
+
* concrete sites a human/gate verdict is owed on — never `present`, never
|
|
56
|
+
* `absent`, while the subject exists in the material.
|
|
57
|
+
*/
|
|
58
|
+
export declare function decideDoc(ruleId: string, id: string, check: string, scan: DocScan, emptyReason: string): DetectorResult;
|
|
59
|
+
/** Wrap a doc-scan into a Detector that ignores the tree and reads the vendored
|
|
60
|
+
* standard fixture. `textProvider` is injectable for tests. */
|
|
61
|
+
export declare function docDetector(ruleId: string, id: string, check: string, scanFn: (lines: DocLine[]) => DocScan, emptyReason: string, textProvider?: () => string): Detector;
|
|
62
|
+
export interface StandardDocEntry {
|
|
63
|
+
detector: Detector;
|
|
64
|
+
ruleId: string;
|
|
65
|
+
standardPin: string;
|
|
66
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Shared STANDARD-DOCUMENT scan primitives for the four process/judgement
|
|
2
|
+
// detectors that read THE STANDARD ITSELF as their material — R-42, R-15, R-32,
|
|
3
|
+
// R-39. Unlike the source-tree detectors (which read convex/ via `tree.files`)
|
|
4
|
+
// and the oracle detectors (which read the CSV), these read the VENDORED standard
|
|
5
|
+
// fixture `fixtures/backend-standard-a9d7877b.md` — the material at the pin. The
|
|
6
|
+
// engine's contract types are DERIVED from engine-types.ts, never re-spelled.
|
|
7
|
+
//
|
|
8
|
+
// THE LAW (the one that caught R-5): every predicate is derived from the ACTUAL
|
|
9
|
+
// text of the vendored standard — the §14 source table, the §11 refinements, the
|
|
10
|
+
// §10 exclusions, the §3 vocabulary — NEVER from a prose illustration of what a
|
|
11
|
+
// rule "should" find.
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
/** The vendored-fixture basename used as the anchor file for every doc site. */
|
|
16
|
+
export const STANDARD_FILE = "backend-standard-51ec7af1.md";
|
|
17
|
+
/** The vendored standard at the pin — DATA on the same footing as the oracle CSV.
|
|
18
|
+
* Resolved locally (not via coverage.ts) so this module carries no import cycle
|
|
19
|
+
* with registry.ts. Same file coverage.ts's VENDORED_STANDARD_PATH points at. */
|
|
20
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
export const VENDORED_STANDARD_PATH = join(HERE, "..", "..", "..", "fixtures", STANDARD_FILE);
|
|
22
|
+
/** The standard all four are pinned against (short hash a9d7877b). */
|
|
23
|
+
export const STANDARD_DOC_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §14/§11/§10/§3";
|
|
24
|
+
/** Read the vendored standard's raw text. Injectable for tests. */
|
|
25
|
+
export function readStandard(path = VENDORED_STANDARD_PATH) {
|
|
26
|
+
return readFileSync(path, "utf8");
|
|
27
|
+
}
|
|
28
|
+
/** Split the standard text into 1-based numbered lines. */
|
|
29
|
+
export function toLines(text) {
|
|
30
|
+
return text.split("\n").map((raw, i) => ({ n: i + 1, raw }));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The lines belonging to a numbered section `## <num>.` — from its header to the
|
|
34
|
+
* next top-level `## <n>.` header (exclusive). Returns [] when the section is
|
|
35
|
+
* absent (a finding the caller surfaces, never a silent pass).
|
|
36
|
+
*/
|
|
37
|
+
export function sectionLines(lines, num) {
|
|
38
|
+
const head = new RegExp(`^##\\s+${num.replace(".", "\\.")}\\.?\\s`);
|
|
39
|
+
const nextTop = /^##\s+\d+\./;
|
|
40
|
+
let start = -1;
|
|
41
|
+
for (let i = 0; i < lines.length; i++) {
|
|
42
|
+
if (start < 0) {
|
|
43
|
+
if (head.test(lines[i].raw))
|
|
44
|
+
start = i;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (nextTop.test(lines[i].raw))
|
|
48
|
+
return lines.slice(start, i);
|
|
49
|
+
}
|
|
50
|
+
return start < 0 ? [] : lines.slice(start);
|
|
51
|
+
}
|
|
52
|
+
/** Parse the pipe-table rows in a slice of lines — skipping the header separator
|
|
53
|
+
* (`|---|`). Each row keeps its 1-based line number so a site anchors to it. */
|
|
54
|
+
export function tableRows(lines) {
|
|
55
|
+
const rows = [];
|
|
56
|
+
for (const l of lines) {
|
|
57
|
+
const t = l.raw.trim();
|
|
58
|
+
if (!t.startsWith("|"))
|
|
59
|
+
continue;
|
|
60
|
+
if (/^\|[\s|:-]+\|?\s*$/.test(t) && /-/.test(t))
|
|
61
|
+
continue; // separator row
|
|
62
|
+
const cells = t
|
|
63
|
+
.replace(/^\|/, "")
|
|
64
|
+
.replace(/\|$/, "")
|
|
65
|
+
.split("|")
|
|
66
|
+
.map((c) => c.trim());
|
|
67
|
+
rows.push({ n: l.n, cells });
|
|
68
|
+
}
|
|
69
|
+
return rows;
|
|
70
|
+
}
|
|
71
|
+
/** A placeholder / empty answer — never a real filled value. */
|
|
72
|
+
export function isDocPlaceholder(v) {
|
|
73
|
+
const t = v.trim().toLowerCase();
|
|
74
|
+
return (t === "" ||
|
|
75
|
+
t === "tbd" ||
|
|
76
|
+
t === "?" ||
|
|
77
|
+
t === "~" ||
|
|
78
|
+
t === "n/a" ||
|
|
79
|
+
t === "-" ||
|
|
80
|
+
t === "—");
|
|
81
|
+
}
|
|
82
|
+
export function emptyDocScan() {
|
|
83
|
+
return { conform: [], violate: [], cnj: [] };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Reduce a doc scan to ONE engine DetectorResult. Precedence for the
|
|
87
|
+
* STANDARD-DOCUMENT kind:
|
|
88
|
+
* - any STATIC violation (a source cell outside the known set, an unnamed
|
|
89
|
+
* mechanism, a mismatched limit-governed set) is decisive → non-conforming;
|
|
90
|
+
* - else any could-not-judge (the delivery-gate / human half surfaced PER
|
|
91
|
+
* SITE) → could-not-judge WITH a reason enumerating the sites;
|
|
92
|
+
* - else conform-only → present (these gate rules always add a gate cnj, so
|
|
93
|
+
* this branch is the pure-static-rule fallback);
|
|
94
|
+
* - else (zero applicable rows) → could-not-judge naming the empty scan.
|
|
95
|
+
*
|
|
96
|
+
* GREEN IS NOT the detection running — it is the human/gate half surfaced per
|
|
97
|
+
* site, named and countable (Eta's acceptance property). So on real material,
|
|
98
|
+
* with the static half clean, these detectors return could-not-judge naming the
|
|
99
|
+
* concrete sites a human/gate verdict is owed on — never `present`, never
|
|
100
|
+
* `absent`, while the subject exists in the material.
|
|
101
|
+
*/
|
|
102
|
+
export function decideDoc(ruleId, id, check, scan, emptyReason) {
|
|
103
|
+
const nC = scan.conform.length;
|
|
104
|
+
const nV = scan.violate.length;
|
|
105
|
+
const nJ = scan.cnj.length;
|
|
106
|
+
const breakdown = `[conform=${nC} non-conforming=${nV} could-not-judge=${nJ}]`;
|
|
107
|
+
const fmt = (s) => s
|
|
108
|
+
.slice(0, 4)
|
|
109
|
+
.map((x) => `${STANDARD_FILE}:${x.n} (${x.note})`)
|
|
110
|
+
.join("; ");
|
|
111
|
+
if (nV > 0) {
|
|
112
|
+
return {
|
|
113
|
+
id,
|
|
114
|
+
anchor: `${STANDARD_FILE}:${scan.violate[0].n}`,
|
|
115
|
+
state: "non-conforming",
|
|
116
|
+
verdict: `${ruleId}: ${nV} STATIC violation(s) in the standard document (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform: ${fmt(scan.conform)}.` : ""}`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (nJ > 0) {
|
|
120
|
+
const reason = `${ruleId}: static half clean (${nC} conform); ${nJ} site(s) owe a human/gate verdict — ${fmt(scan.cnj)}. This is NOT a clean bill: GREEN is the gate half surfaced per site, not the detection running. ${breakdown}`;
|
|
121
|
+
return {
|
|
122
|
+
id,
|
|
123
|
+
anchor: `${STANDARD_FILE}:${scan.cnj[0].n}`,
|
|
124
|
+
state: "could-not-judge",
|
|
125
|
+
verdict: reason,
|
|
126
|
+
reason,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
if (nC > 0) {
|
|
130
|
+
return {
|
|
131
|
+
id,
|
|
132
|
+
anchor: `${STANDARD_FILE}:${scan.conform[0].n}`,
|
|
133
|
+
state: "present",
|
|
134
|
+
verdict: `${ruleId}: ${nC} site(s) conform (${check}); 0 violations. ${breakdown} Conform: ${fmt(scan.conform)}.`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
id,
|
|
139
|
+
anchor: `${STANDARD_FILE}:0`,
|
|
140
|
+
state: "could-not-judge",
|
|
141
|
+
verdict: emptyReason,
|
|
142
|
+
reason: emptyReason,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** Wrap a doc-scan into a Detector that ignores the tree and reads the vendored
|
|
146
|
+
* standard fixture. `textProvider` is injectable for tests. */
|
|
147
|
+
export function docDetector(ruleId, id, check, scanFn, emptyReason, textProvider = readStandard) {
|
|
148
|
+
return {
|
|
149
|
+
id,
|
|
150
|
+
run: () => decideDoc(ruleId, id, check, scanFn(toLines(textProvider())), emptyReason),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Detector, DetectorResult } from "../../engine-types.js";
|
|
2
|
+
import { type DocLine } from "./_doc.js";
|
|
3
|
+
/** The four frontier prefixes and the sibling document each resolves against. */
|
|
4
|
+
export type Prefix = "S" | "C" | "E" | "A";
|
|
5
|
+
/** prefix → { the sibling fixture basename, its recorded upstream blob hash }.
|
|
6
|
+
* The blob hash is the PINS.txt record — a vendored document that cannot be
|
|
7
|
+
* hash-checked is an authority granted to oneself, so the hash travels here and
|
|
8
|
+
* the integrity test asserts `git hash-object` reproduces it. */
|
|
9
|
+
export declare const SIBLING: Record<Prefix, {
|
|
10
|
+
file: string;
|
|
11
|
+
blob: string;
|
|
12
|
+
label: string;
|
|
13
|
+
}>;
|
|
14
|
+
/** The pin all four siblings are fixed against. */
|
|
15
|
+
export declare const SIBLING_PIN = "the four MCP-standard-family documents @ a74a042b53c71a095ab88b3073bd47728eeda814 (elpiarthera/ElPi-Corp) \u2014 S-/C-/E-/A- defined-id sets";
|
|
16
|
+
/** Absolute path of a sibling fixture. */
|
|
17
|
+
export declare function siblingPath(prefix: Prefix): string;
|
|
18
|
+
/** Read a sibling document's raw text. Injectable for tests (mutation bite). */
|
|
19
|
+
export declare function readSibling(prefix: Prefix): string;
|
|
20
|
+
/**
|
|
21
|
+
* Every id a sibling document DEFINES — matched by the NUMBERED-LIST-ITEM form
|
|
22
|
+
* `N. **`X-N` — title.**` (a heading- or `^\*\*`-anchored matcher finds zero).
|
|
23
|
+
* Returns the set for whatever prefix the file defines; the resolver only ever
|
|
24
|
+
* asks a file for its own prefix, so a single form suffices.
|
|
25
|
+
*/
|
|
26
|
+
export declare function definedIds(text: string): Set<string>;
|
|
27
|
+
export interface Cite {
|
|
28
|
+
id: string;
|
|
29
|
+
prefix: Prefix;
|
|
30
|
+
n: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Every `S-`/`C-`/`E-`/`A-` identifier CITED in a frontier statement of the
|
|
34
|
+
* backend standard — DERIVED from the real text by matching the citation form
|
|
35
|
+
* (a backtick-wrapped id on a line that opens `Frontier — MCP standard.`), never
|
|
36
|
+
* hardcoded. Deduped per id, keeping the first citing line as its site.
|
|
37
|
+
*/
|
|
38
|
+
export declare function citedIds(lines: DocLine[]): Cite[];
|
|
39
|
+
export interface FrontierResolution {
|
|
40
|
+
cited: Cite[];
|
|
41
|
+
/** cited ids that do NOT resolve in their sibling — the dead pointers. */
|
|
42
|
+
dead: Cite[];
|
|
43
|
+
/** cited ids that resolve — the conforming sites. */
|
|
44
|
+
resolved: Cite[];
|
|
45
|
+
/** the count of defined ids per prefix — for the enumeration proof. */
|
|
46
|
+
definedCounts: Record<Prefix, number>;
|
|
47
|
+
}
|
|
48
|
+
/** Resolve every cited frontier id against its sibling's defined-id set.
|
|
49
|
+
* `standardText` and `siblingReader` are injectable for the mutation bite. */
|
|
50
|
+
export declare function resolveFrontier(standardText?: string, siblingReader?: (p: Prefix) => string): FrontierResolution;
|
|
51
|
+
/** Build R-41's single DetectorResult from a resolution. Present when every
|
|
52
|
+
* cited id resolves; non-conforming on any dead pointer (a red, not a note);
|
|
53
|
+
* could-not-judge ONLY if an input yields no cited/defined ids at all (an
|
|
54
|
+
* absent input, not a clean bill). */
|
|
55
|
+
export declare function decideR41(r: FrontierResolution): DetectorResult;
|
|
56
|
+
/** The R-41 detector — ignores the tree; reads the vendored standard + the four
|
|
57
|
+
* vendored siblings. Fully mechanical, no human half. */
|
|
58
|
+
export declare const R41: Detector;
|