@vantageos/backend-doctor 0.2.0 → 0.3.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.
Files changed (63) hide show
  1. package/README.md +48 -4
  2. package/dist/args.d.ts +1 -1
  3. package/dist/args.js +19 -1
  4. package/dist/build-stamp.json +2 -2
  5. package/dist/caller-resolution.js +11 -8
  6. package/dist/cli.js +164 -15
  7. package/dist/contract.d.ts +25 -7
  8. package/dist/contract.js +52 -7
  9. package/dist/coverage.d.ts +1 -1
  10. package/dist/coverage.js +2 -2
  11. package/dist/detectors/oracle-axis1.js +5 -4
  12. package/dist/detectors/oracle-axis2.d.ts +6 -0
  13. package/dist/detectors/oracle-axis2.js +32 -16
  14. package/dist/detectors/predicates.js +12 -3
  15. package/dist/detectors/shared.js +3 -2
  16. package/dist/detectors/source/_ast.d.ts +92 -0
  17. package/dist/detectors/source/_ast.js +353 -0
  18. package/dist/detectors/source/_scan.js +4 -3
  19. package/dist/detectors/source/backfill.js +62 -17
  20. package/dist/detectors/source/contract.js +21 -13
  21. package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
  22. package/dist/detectors/source/cross-tenant-write.js +1408 -0
  23. package/dist/detectors/source/guard-helper-flow.d.ts +22 -0
  24. package/dist/detectors/source/guard-helper-flow.js +49 -6
  25. package/dist/detectors/source/identity-by-id.d.ts +80 -0
  26. package/dist/detectors/source/identity-by-id.js +1783 -0
  27. package/dist/detectors/source/identity.d.ts +8 -1
  28. package/dist/detectors/source/identity.js +364 -35
  29. package/dist/detectors/source/jobs.js +13 -2
  30. package/dist/detectors/source/lifecycle.js +14 -2
  31. package/dist/detectors/source/local-identity.d.ts +26 -0
  32. package/dist/detectors/source/local-identity.js +661 -0
  33. package/dist/detectors/source/package-principal.d.ts +9 -0
  34. package/dist/detectors/source/package-principal.js +177 -0
  35. package/dist/detectors/source/r6-shapes.d.ts +20 -0
  36. package/dist/detectors/source/r6-shapes.js +285 -0
  37. package/dist/detectors/source/readbudget.js +291 -0
  38. package/dist/detectors/source/recipient-by-id.d.ts +40 -0
  39. package/dist/detectors/source/recipient-by-id.js +1108 -0
  40. package/dist/detectors/source/refusal.d.ts +14 -0
  41. package/dist/detectors/source/refusal.js +75 -2
  42. package/dist/detectors/source/security.js +455 -26
  43. package/dist/detectors/source/writesafety.js +27 -14
  44. package/dist/detectors/standard/_doc.d.ts +1 -1
  45. package/dist/detectors/standard/_doc.js +5 -4
  46. package/dist/detectors/standard/r41-frontier.js +3 -2
  47. package/dist/detectors/standard/r46-returns-validator.js +3 -2
  48. package/dist/detectors/structural.d.ts +6 -7
  49. package/dist/detectors/structural.js +75 -43
  50. package/dist/not-checkable.d.ts +1 -1
  51. package/dist/not-checkable.js +11 -0
  52. package/dist/registry.d.ts +1 -1
  53. package/dist/registry.js +27 -3
  54. package/dist/rule-class.d.ts +85 -0
  55. package/dist/rule-class.js +218 -0
  56. package/dist/tally.d.ts +13 -5
  57. package/dist/tally.js +14 -6
  58. package/dist/violation-count.d.ts +48 -0
  59. package/dist/violation-count.js +94 -0
  60. package/fixtures/backend-standard-2825122.md +722 -0
  61. package/fixtures/backend-standard-4dcd3e0.md +722 -0
  62. package/fixtures/rule-classes-2825122.json +228 -0
  63. package/package.json +1 -1
@@ -0,0 +1,26 @@
1
+ import type { Detector, DetectorResult } from "../../engine-types.js";
2
+ import { type Site, type SrcFile } from "./_scan.js";
3
+ export declare const CR2_ID = "CR-2 no application defines its own identity primitive (tenant resolution, membership, scope filtering, credential validation, identification by name); identity lives only in @vantageos/cloud-identity";
4
+ export declare const KIND: {
5
+ readonly tenant: "resolves a caller to a tenant or agent";
6
+ readonly membership: "asserts membership or role";
7
+ readonly scope: "filters by the caller's scope";
8
+ readonly credential: "validates a credential";
9
+ readonly name: "identifies an agent by name";
10
+ };
11
+ export type Kind = keyof typeof KIND;
12
+ /** The written justification, read FROM THE TARGET SOURCE at the site. */
13
+ export declare const EXEMPT_MARKER: RegExp;
14
+ export interface LocalIdentityScan {
15
+ /** files read (judged + unjudgeable), the population size. */
16
+ files: number;
17
+ violate: Site[];
18
+ exempt: Site[];
19
+ cnj: Site[];
20
+ }
21
+ export declare function scanLocalIdentity(files: SrcFile[]): LocalIdentityScan;
22
+ export declare function localIdentityResult(scan: LocalIdentityScan): DetectorResult;
23
+ /** The rendered section: EVERY violation, exemption and abstention is named. */
24
+ export declare function localIdentitySection(scan: LocalIdentityScan): string;
25
+ /** CR-2 as an engine Detector. */
26
+ export declare const CR2: Detector;
@@ -0,0 +1,661 @@
1
+ // CR-2 — no application, no MCP server and no starter defines its own identity
2
+ // primitive. Identity lives ONLY in @vantageos/cloud-identity.
3
+ //
4
+ // Operator order, 2026-10-09, verbatim: "No application, no MCP server, and no
5
+ // starter defines its own primitive for: resolving a caller to a tenant,
6
+ // asserting membership of an organisation, filtering rows by a caller's scope,
7
+ // or validating a master credential." The instrument detects it; a reviewer's
8
+ // judgement is not the mechanism.
9
+ //
10
+ // WHY A DOCTOR-OWNED ID (CR-2) AND NOT R-54. The pinned standard
11
+ // (fixtures/backend-standard-2825122.md) numbers R-1..R-53. An R- id the
12
+ // vendored standard does not define makes coverage.ts report an UNKNOWN rule and
13
+ // makes rule-class.ts refuse to classify it ("the pinned standard states no
14
+ // build kind"), which nulls the mechanical count. CR-1 set the precedent for a
15
+ // doctor-owned control: own section, own sidecar entry, drives the exit code.
16
+ // CR-2 is the next free number of that family. Upstreaming it as R-N is a
17
+ // separate change to the standard document in its own repository.
18
+ //
19
+ // WHAT IS A SITE. One site per function that DEFINES a primitive: the innermost
20
+ // named function holding the deciding node (a helper, an MCP tool handler, or a
21
+ // Convex registration whose own handler does it). A file-level statement is
22
+ // owned by `<module>`. Each site names every primitive kind it holds, each with
23
+ // its line.
24
+ //
25
+ // THE FIVE KINDS (any one is a violation):
26
+ // tenant resolves a caller to a tenant or agent — R-53's (d) "resolves
27
+ // identity itself" (an unsanctioned getUserIdentity() read, a
28
+ // client_org_mapping read keyed on a credential claim), R-53's doors
29
+ // whose principal is resolved "by the handler itself", plus any
30
+ // client_org_mapping read outside R-53's org-ID adapter ruling;
31
+ // membership asserts membership of an organisation or a role — a gate (an
32
+ // `if` that throws/returns, a conditional) reading a role/master
33
+ // field, a read of a membership table, R-53's (c) "continues as
34
+ // master when unresolved";
35
+ // scope filters rows by a caller's scope — a read of an allow-list field
36
+ // (allowedOrchestrators, fromAllowList, …) or a membership test
37
+ // over a caller's tenant / name / scope list;
38
+ // credential validates a credential — timingSafeEqual, a token hash
39
+ // (createHash / subtle.digest on a token, secret or key), JWT
40
+ // verification (jose, jsonwebtoken);
41
+ // name identifies an agent by NAME — R-53's (d) "compares names to
42
+ // admit", R-53 clause 1's recipient-by-name sites, an index or
43
+ // filter keyed on a name field, a comparison through a name
44
+ // normaliser.
45
+ // R-53's classifications are REUSED (scanR53Detailed), never re-derived; only
46
+ // the kinds R-53 does not carry are detected here.
47
+ //
48
+ // No package-call exit. A function that calls the package AND compares names
49
+ // locally still defines a local primitive: the order bans the primitive, not
50
+ // the absence of the package. What the package resolves is admitted by R-53's
51
+ // own sanction rules (a getUserIdentity() value that reaches only a package
52
+ // resolver; the org-ID adapter read) — those are not sites here either.
53
+ //
54
+ // EXEMPT, REPORTED: a file of @vantageos/cloud-identity itself, and a site
55
+ // carrying `allow-local-identity: <reason of 8+ characters>` in its leading
56
+ // comment or its body (the CR-1 `allow-no-caller-resolution:` convention). An
57
+ // exempt site is listed by file:line with its reason; it is never counted as a
58
+ // pass silently.
59
+ //
60
+ // COULD-NOT-JUDGE: a file with a syntax error is reported by name and judged
61
+ // nowhere (neither here nor through R-53), never counted as a pass. An empty
62
+ // population is could-not-judge, never a clean bill.
63
+ //
64
+ // CEILING 0. CR-2 is mechanical (fixtures/rule-classes-2825122.json): any
65
+ // violation is a mechanical violation, counted in mechanical_rule_counts with a
66
+ // per-module split, and gives a non-zero exit.
67
+ import * as ts from "typescript";
68
+ import { siteCount, withCount } from "../../violation-count.js";
69
+ import { calleeName, dotted, lineOf, parseFile, propRead, propReadResolved, sourceOf, unwrap, walk, } from "./_ast.js";
70
+ import { isConvexOrMcp, isJudgeable, } from "./_scan.js";
71
+ import { conformantMappingReads, IDENTITY_PACKAGE, scanR53Detailed, } from "./identity-by-id.js";
72
+ export const CR2_ID = "CR-2 no application defines its own identity primitive (tenant resolution, membership, scope filtering, credential validation, identification by name); identity lives only in @vantageos/cloud-identity";
73
+ export const KIND = {
74
+ tenant: "resolves a caller to a tenant or agent",
75
+ membership: "asserts membership or role",
76
+ scope: "filters by the caller's scope",
77
+ credential: "validates a credential",
78
+ name: "identifies an agent by name",
79
+ };
80
+ /** The written justification, read FROM THE TARGET SOURCE at the site. */
81
+ export const EXEMPT_MARKER = /allow-local-identity:\s*(\S[^\n]{7,})/;
82
+ /** A file of the identity package itself. */
83
+ const PACKAGE_PATH = /(^|\/)(node_modules\/@vantageos\/cloud-identity|packages\/cloud-identity)\//;
84
+ const SOURCE_EXT = /\.[cm]?[tj]sx?$/;
85
+ const TOOL_CALL = /^(defineTool|tool|registerTool)$/;
86
+ /** The innermost NAMED function holding `n`. A Convex registration's own
87
+ * `handler` is owned by the registration (`export const NAME = mutation(…)`),
88
+ * at the same line R-53 reports its doors. */
89
+ function ownerOf(n, rel) {
90
+ let viaHandler = false;
91
+ for (let x = n.parent; x; x = x.parent) {
92
+ if (ts.isFunctionDeclaration(x) && x.name)
93
+ return { rel, n: lineOf(x), name: x.name.text, node: x };
94
+ if (ts.isMethodDeclaration(x) && ts.isIdentifier(x.name)) {
95
+ if (x.name.text === "handler") {
96
+ viaHandler = true;
97
+ continue;
98
+ }
99
+ return { rel, n: lineOf(x), name: x.name.text, node: x };
100
+ }
101
+ if ((ts.isArrowFunction(x) || ts.isFunctionExpression(x)) && x.parent) {
102
+ const par = x.parent;
103
+ if (ts.isVariableDeclaration(par) && ts.isIdentifier(par.name))
104
+ return { rel, n: lineOf(par), name: par.name.text, node: par };
105
+ if (ts.isPropertyAssignment(par) && ts.isIdentifier(par.name)) {
106
+ if (par.name.text === "handler") {
107
+ viaHandler = true;
108
+ continue;
109
+ }
110
+ return { rel, n: lineOf(par), name: par.name.text, node: par };
111
+ }
112
+ if (ts.isCallExpression(par) &&
113
+ TOOL_CALL.test((dotted(par.expression) ?? "").split(".").pop() ?? "")) {
114
+ const label = par.arguments.find((a) => ts.isStringLiteralLike(a) ||
115
+ (ts.isIdentifier(a) && /_TOOL_NAME$/.test(a.text)));
116
+ return {
117
+ rel,
118
+ n: lineOf(par),
119
+ name: `tool ${label ? label.getText() : "<anonymous>"}`,
120
+ node: par,
121
+ };
122
+ }
123
+ }
124
+ // a registration reached by climbing out of its handler
125
+ if (viaHandler &&
126
+ ts.isVariableDeclaration(x) &&
127
+ ts.isIdentifier(x.name) &&
128
+ x.initializer &&
129
+ ts.isCallExpression(unwrap(x.initializer)))
130
+ return { rel, n: lineOf(x), name: x.name.text, node: x };
131
+ }
132
+ let top = n;
133
+ while (top.parent && !ts.isSourceFile(top.parent))
134
+ top = top.parent;
135
+ return { rel, n: lineOf(n), name: "<module>", node: top };
136
+ }
137
+ /** The deepest node starting on line `n` of `p` (for a site given by line). */
138
+ function nodeAtLine(p, n) {
139
+ let hit;
140
+ walk(p.sf, (x) => {
141
+ if (x !== p.sf && lineOf(x) === n)
142
+ hit = x;
143
+ });
144
+ return hit;
145
+ }
146
+ /** The marker's reason at an owner: its leading comment, or its own text. */
147
+ function markerOf(o) {
148
+ let host = o.node;
149
+ if (ts.isVariableDeclaration(host) && host.parent?.parent)
150
+ host = host.parent.parent; // the VariableStatement carries the comment
151
+ return EXEMPT_MARKER.exec(host.getFullText())?.[1].trim();
152
+ }
153
+ // ---- the kinds R-53 does not carry ----------------------------------------
154
+ const snippet = (n) => n.getText().replace(/\s+/g, " ").slice(0, 80);
155
+ /** Credential-validation callees, whatever module they come from. */
156
+ const CREDENTIAL_CALLS = new Set([
157
+ "timingSafeEqual",
158
+ "jwtVerify",
159
+ "jwtDecrypt",
160
+ "compactVerify",
161
+ "flattenedVerify",
162
+ "generalVerify",
163
+ "createRemoteJWKSet",
164
+ "createLocalJWKSet",
165
+ "importJWK",
166
+ "importSPKI",
167
+ "importX509",
168
+ ]);
169
+ const JWT_MODULES = new Set(["jose", "jsonwebtoken"]);
170
+ const SECRETISH = /token|secret|bearer|password|passphrase|credential|api_?key|apikey|signature/i;
171
+ const ROLE_PROPS = new Set([
172
+ "role",
173
+ "roles",
174
+ "orgRole",
175
+ "org_role",
176
+ "isMaster",
177
+ "masterSource",
178
+ "isAdmin",
179
+ "isOwner",
180
+ "isMember",
181
+ "membership",
182
+ "memberships",
183
+ ]);
184
+ const MEMBERSHIP_TABLE = /member/i;
185
+ const SCOPE_PROPS = new Set([
186
+ "allowedOrchestrators",
187
+ "fromAllowList",
188
+ "allowList",
189
+ "allowedNamespaces",
190
+ "namespacePrefixes",
191
+ "allowedTenants",
192
+ "allowedOrgs",
193
+ ]);
194
+ const SCOPE_LIST = /^(tenants|tenantIds|names|roster|scopes|orgs|orgIds|orgSlugs|allowed\w*|\w*allowList)$/i;
195
+ const MEMBER_TEST = new Set([
196
+ "includes",
197
+ "has",
198
+ "indexOf",
199
+ "some",
200
+ "every",
201
+ ]);
202
+ const NAME_FIELDS = new Set([
203
+ "name",
204
+ "normalizedName",
205
+ "recipient",
206
+ "from",
207
+ "assignedTo",
208
+ "createdBy",
209
+ "lastAssignedTo",
210
+ "orchestrator",
211
+ "orchestratorId",
212
+ "callerOrchestrator",
213
+ "agentName",
214
+ ]);
215
+ const NAME_NORMALISER = /^normali[sz]e\w*(Id|Name)$/;
216
+ const EQ = new Set([
217
+ ts.SyntaxKind.EqualsEqualsEqualsToken,
218
+ ts.SyntaxKind.ExclamationEqualsEqualsToken,
219
+ ts.SyntaxKind.EqualsEqualsToken,
220
+ ts.SyntaxKind.ExclamationEqualsToken,
221
+ ]);
222
+ function isLiteral(e) {
223
+ const u = unwrap(e);
224
+ return (ts.isStringLiteralLike(u) ||
225
+ ts.isNumericLiteral(u) ||
226
+ u.kind === ts.SyntaxKind.NullKeyword ||
227
+ u.kind === ts.SyntaxKind.TrueKeyword ||
228
+ u.kind === ts.SyntaxKind.FalseKeyword ||
229
+ (ts.isIdentifier(u) && u.text === "undefined"));
230
+ }
231
+ /** Does `n` read a property named in `props` (a value read, not a key)? */
232
+ function readsProp(n, props) {
233
+ let hit;
234
+ walk(n, (x) => {
235
+ const r = ts.isExpression(x) ? propReadResolved(x) : undefined;
236
+ if (!hit && r && props.has(r.name))
237
+ hit = x;
238
+ });
239
+ return hit;
240
+ }
241
+ function exits(s) {
242
+ let hit = false;
243
+ walk(s, (y) => {
244
+ if (ts.isThrowStatement(y) || ts.isReturnStatement(y))
245
+ hit = true;
246
+ }, true);
247
+ return hit;
248
+ }
249
+ /** Bindings a file imports from jose / jsonwebtoken. */
250
+ function jwtBindings(p) {
251
+ const out = new Set();
252
+ for (const st of p.sf.statements) {
253
+ if (!ts.isImportDeclaration(st))
254
+ continue;
255
+ if (!JWT_MODULES.has(st.moduleSpecifier.text))
256
+ continue;
257
+ const c = st.importClause;
258
+ if (c?.name)
259
+ out.add(c.name.text);
260
+ const b = c?.namedBindings;
261
+ if (b && ts.isNamespaceImport(b))
262
+ out.add(b.name.text);
263
+ else if (b)
264
+ for (const e of b.elements)
265
+ out.add(e.name.text);
266
+ }
267
+ return out;
268
+ }
269
+ /** The field a `q.eq(<field>, K)` / `q.eq(q.field("f"), K)` keys on. */
270
+ function eqField(c) {
271
+ const f = c.arguments[0] && unwrap(c.arguments[0]);
272
+ if (!f)
273
+ return undefined;
274
+ if (ts.isStringLiteralLike(f))
275
+ return f.text;
276
+ if (ts.isCallExpression(f) &&
277
+ calleeName(f) === "field" &&
278
+ f.arguments[0] &&
279
+ ts.isStringLiteralLike(f.arguments[0]))
280
+ return f.arguments[0].text;
281
+ return undefined;
282
+ }
283
+ /** Inside a `.withIndex(…)` / `.filter(…)` / `.withSearchIndex(…)` callback? */
284
+ function inQueryKey(n) {
285
+ for (let x = n.parent; x; x = x.parent) {
286
+ if (ts.isCallExpression(x) &&
287
+ /^(withIndex|filter|withSearchIndex)$/.test(calleeName(x) ?? "") &&
288
+ ts.isPropertyAccessExpression(unwrap(x.expression)))
289
+ return true;
290
+ if (ts.isBlock(x) || ts.isSourceFile(x))
291
+ return false;
292
+ }
293
+ return false;
294
+ }
295
+ const isNormaliserCall = (e) => {
296
+ const u = unwrap(e);
297
+ return ts.isCallExpression(u) && NAME_NORMALISER.test(calleeName(u) ?? "");
298
+ };
299
+ /** The primitives this module adds beside R-53's, one hit per deciding node. */
300
+ function ownHits(p, okReads) {
301
+ const out = [];
302
+ const jwt = jwtBindings(p);
303
+ walk(p.sf, (x) => {
304
+ // credential
305
+ if (ts.isCallExpression(x)) {
306
+ const name = calleeName(x) ?? "";
307
+ const head = (dotted(x.expression) ?? "").split(".")[0];
308
+ const chain = (() => {
309
+ let c = x;
310
+ while (c.parent &&
311
+ (ts.isPropertyAccessExpression(c.parent) ||
312
+ ts.isCallExpression(c.parent)))
313
+ c = c.parent;
314
+ return c.getText();
315
+ })();
316
+ const ownerName = ownerOf(x, p.rel).name;
317
+ if (CREDENTIAL_CALLS.has(name))
318
+ out.push({
319
+ node: x,
320
+ kind: "credential",
321
+ why: `\`${name}()\``,
322
+ });
323
+ else if (jwt.has(head) && /verify|decode|jwks|import/i.test(name))
324
+ out.push({
325
+ node: x,
326
+ kind: "credential",
327
+ why: `JWT verification \`${snippet(x.expression)}()\``,
328
+ });
329
+ else if ((name === "createHash" ||
330
+ name === "createHmac" ||
331
+ /\bsubtle\.digest$/.test(dotted(x.expression) ?? "")) &&
332
+ (SECRETISH.test(chain) || SECRETISH.test(ownerName)))
333
+ out.push({
334
+ node: x,
335
+ kind: "credential",
336
+ why: `token hashing \`${snippet(x)}\``,
337
+ });
338
+ // membership table read
339
+ const a0 = x.arguments[0];
340
+ if (name === "query" &&
341
+ /\bdb\.query$/.test(dotted(x.expression) ?? "") &&
342
+ a0 &&
343
+ ts.isStringLiteralLike(a0)) {
344
+ if (MEMBERSHIP_TABLE.test(a0.text))
345
+ out.push({
346
+ node: x,
347
+ kind: "membership",
348
+ why: `reads the membership table \`${a0.text}\``,
349
+ });
350
+ if (a0.text === "client_org_mapping" && !okReads.has(x))
351
+ out.push({
352
+ node: x,
353
+ kind: "tenant",
354
+ why: "reads `client_org_mapping` itself, outside the org-ID adapter ruling",
355
+ });
356
+ }
357
+ // scope: membership test over a caller's list
358
+ const callee = unwrap(x.expression);
359
+ if (ts.isPropertyAccessExpression(callee) &&
360
+ MEMBER_TEST.has(callee.name.text) &&
361
+ x.arguments[0] &&
362
+ !isLiteral(x.arguments[0])) {
363
+ const recv = unwrap(callee.expression);
364
+ const last = propReadResolved(recv)?.name ??
365
+ (ts.isIdentifier(recv) ? recv.text : "");
366
+ if (SCOPE_LIST.test(last) && !SCOPE_PROPS.has(last))
367
+ out.push({
368
+ node: x,
369
+ kind: "scope",
370
+ why: `\`${snippet(x)}\``,
371
+ });
372
+ }
373
+ // name: an index / filter keyed on a name field with a non-literal
374
+ if (name === "eq" &&
375
+ x.arguments.length === 2 &&
376
+ !isLiteral(x.arguments[1]) &&
377
+ inQueryKey(x)) {
378
+ const f = eqField(x);
379
+ if (f !== undefined && NAME_FIELDS.has(f))
380
+ out.push({
381
+ node: x,
382
+ kind: "name",
383
+ why: `selects rows by the name field \`${f}\` (\`${snippet(x)}\`)`,
384
+ });
385
+ }
386
+ }
387
+ // scope: a read of an allow-list field
388
+ const rd = propRead(x);
389
+ if (rd && SCOPE_PROPS.has(rd.name))
390
+ out.push({ node: x, kind: "scope", why: `reads \`${snippet(x)}\`` });
391
+ // membership: a gate on a role / master field
392
+ if (ts.isIfStatement(x) && exits(x.thenStatement)) {
393
+ const r = readsProp(x.expression, ROLE_PROPS);
394
+ if (r)
395
+ out.push({
396
+ node: x.expression,
397
+ kind: "membership",
398
+ why: `gates on \`${snippet(r)}\``,
399
+ });
400
+ }
401
+ if (ts.isConditionalExpression(x)) {
402
+ const r = readsProp(x.condition, ROLE_PROPS);
403
+ if (r)
404
+ out.push({
405
+ node: x.condition,
406
+ kind: "membership",
407
+ why: `branches on \`${snippet(r)}\``,
408
+ });
409
+ }
410
+ // name: a comparison through a name normaliser
411
+ if (ts.isBinaryExpression(x) &&
412
+ EQ.has(x.operatorToken.kind) &&
413
+ !isLiteral(x.left) &&
414
+ !isLiteral(x.right) &&
415
+ (isNormaliserCall(x.left) || isNormaliserCall(x.right)))
416
+ out.push({
417
+ node: x,
418
+ kind: "name",
419
+ why: `compares through a name normaliser (\`${snippet(x)}\`)`,
420
+ });
421
+ });
422
+ return out;
423
+ }
424
+ /** R-53's classifications that ARE local identity primitives, mapped to kinds. */
425
+ function reuseR53(files) {
426
+ const d = scanR53Detailed(files);
427
+ const sites = [];
428
+ for (const s of d.scan.violate) {
429
+ const layer = /: \(d\) local identity layer — (.*)$/.exec(s.note);
430
+ if (layer) {
431
+ for (const why of layer[1].split("; ")) {
432
+ if (/^compares names to admit/.test(why))
433
+ sites.push({
434
+ ...at(s),
435
+ kind: "name",
436
+ why: `R-53 (d): ${why}`,
437
+ atOwner: true,
438
+ });
439
+ else if (/^resolves identity itself/.test(why))
440
+ sites.push({
441
+ ...at(s),
442
+ kind: "tenant",
443
+ why: `R-53 (d): ${why}`,
444
+ atOwner: true,
445
+ });
446
+ }
447
+ continue;
448
+ }
449
+ const door = /\(a\) principal resolved by the handler itself[^|]*/.exec(s.note);
450
+ if (door) {
451
+ sites.push({
452
+ ...at(s),
453
+ kind: "tenant",
454
+ why: `R-53 door ${door[0].trim()}`,
455
+ atOwner: true,
456
+ });
457
+ continue;
458
+ }
459
+ if (/: \(1\) recipient resolved by name — /.test(s.note)) {
460
+ sites.push({
461
+ ...at(s),
462
+ kind: "name",
463
+ why: `R-53 clause 1: ${s.note.replace(/^[^:]*: \(1\) /, "").slice(0, 160)}`,
464
+ atOwner: false,
465
+ });
466
+ continue;
467
+ }
468
+ if (/: \(c\) continues as master when /.test(s.note))
469
+ sites.push({
470
+ ...at(s),
471
+ kind: "membership",
472
+ why: `R-53 (c): ${s.note.replace(/^[^:]*: \(c\) /, "")}`,
473
+ atOwner: false,
474
+ });
475
+ }
476
+ return { sites, cnj: d.scan.cnj };
477
+ }
478
+ const at = (s) => ({ rel: s.rel, n: s.n });
479
+ // ---- the scan -------------------------------------------------------------
480
+ /** Syntactic diagnostics of one file, through the public transpile API. */
481
+ function syntaxErrors(f) {
482
+ const out = ts.transpileModule(sourceOf(f), {
483
+ fileName: f.rel,
484
+ reportDiagnostics: true,
485
+ compilerOptions: { target: ts.ScriptTarget.Latest },
486
+ });
487
+ const errs = (out.diagnostics ?? []).filter((d) => d.category === ts.DiagnosticCategory.Error);
488
+ if (errs.length === 0)
489
+ return undefined;
490
+ const d = errs[0];
491
+ const line = d.file && d.start !== undefined
492
+ ? d.file.getLineAndCharacterOfPosition(d.start).line + 1
493
+ : 0;
494
+ return `line ${line}: ${ts.flattenDiagnosticMessageText(d.messageText, " ")}`;
495
+ }
496
+ export function scanLocalIdentity(files) {
497
+ const population = files.filter((f) => (isConvexOrMcp(f) || PACKAGE_PATH.test(f.rel)) &&
498
+ isJudgeable(f) &&
499
+ SOURCE_EXT.test(f.rel));
500
+ const scan = {
501
+ files: population.length,
502
+ violate: [],
503
+ exempt: [],
504
+ cnj: [],
505
+ };
506
+ const judged = [];
507
+ for (const f of population) {
508
+ const err = syntaxErrors(f);
509
+ if (err)
510
+ scan.cnj.push({
511
+ rel: f.rel,
512
+ n: 0,
513
+ note: `unparsable (${err}) — no site in this file was judged; NOT a pass`,
514
+ });
515
+ else
516
+ judged.push(f);
517
+ }
518
+ const parsed = judged.map(parseFile);
519
+ const byRel = new Map(parsed.map((p) => [p.rel, p]));
520
+ const okReads = conformantMappingReads(parsed);
521
+ // owner key -> owner + reasons
522
+ const sites = new Map();
523
+ const note = (o, kind, why) => {
524
+ const key = `${o.rel}:${o.n}`;
525
+ const e = sites.get(key) ?? { owner: o, why: [] };
526
+ const line = `${KIND[kind]} — ${why}`;
527
+ if (!e.why.includes(line))
528
+ e.why.push(line);
529
+ sites.set(key, e);
530
+ };
531
+ const reused = reuseR53(judged);
532
+ scan.cnj.push(...reused.cnj);
533
+ for (const r of reused.sites) {
534
+ const p = byRel.get(r.rel);
535
+ const node = p && nodeAtLine(p, r.n);
536
+ if (!p || !node)
537
+ continue;
538
+ // a (d) layer / door site already sits on its owner's line; the node
539
+ // starting there IS the owner's declaration, so own it directly.
540
+ const o = r.atOwner ? ownerAtDecl(node, p.rel, r.n) : ownerOf(node, p.rel);
541
+ note(o, r.kind, r.why);
542
+ }
543
+ for (const p of parsed)
544
+ for (const h of ownHits(p, okReads))
545
+ note(ownerOf(h.node, p.rel), h.kind, `${h.why}, line ${lineOf(h.node)}`);
546
+ for (const { owner, why } of [...sites.values()].sort((a, b) => a.owner.rel.localeCompare(b.owner.rel) || a.owner.n - b.owner.n)) {
547
+ const site = { rel: owner.rel, n: owner.n };
548
+ const body = `${owner.name}: ${why.join("; ")}`;
549
+ if (PACKAGE_PATH.test(owner.rel)) {
550
+ scan.exempt.push({
551
+ ...site,
552
+ note: `EXEMPT (package) — inside ${IDENTITY_PACKAGE} itself. ${body}`,
553
+ });
554
+ continue;
555
+ }
556
+ const reason = markerOf(owner);
557
+ if (reason !== undefined)
558
+ scan.exempt.push({
559
+ ...site,
560
+ note: `EXEMPT (marker) — "${reason.slice(0, 120)}". ${body}`,
561
+ });
562
+ else
563
+ scan.violate.push({ ...site, note: body });
564
+ }
565
+ return scan;
566
+ }
567
+ /** The owner whose declaration starts on line `n` at or above `node`. */
568
+ function ownerAtDecl(node, rel, n) {
569
+ for (let x = node; x; x = x.parent) {
570
+ if (lineOf(x) !== n)
571
+ break;
572
+ if (ts.isFunctionDeclaration(x) && x.name)
573
+ return { rel, n, name: x.name.text, node: x };
574
+ if ((ts.isVariableDeclaration(x) || ts.isPropertyAssignment(x)) &&
575
+ ts.isIdentifier(x.name))
576
+ return { rel, n, name: x.name.text, node: x };
577
+ if (ts.isMethodDeclaration(x) && ts.isIdentifier(x.name))
578
+ return { rel, n, name: x.name.text, node: x };
579
+ }
580
+ // the declaration may start on the line as a descendant's parent chain did
581
+ // not reach it (a VariableStatement whose first token is `export`)
582
+ let found;
583
+ walk(node.getSourceFile(), (x) => {
584
+ if (found || lineOf(x) !== n)
585
+ return;
586
+ if (ts.isFunctionDeclaration(x) && x.name)
587
+ found = { rel, n, name: x.name.text, node: x };
588
+ else if (ts.isVariableDeclaration(x) && ts.isIdentifier(x.name))
589
+ found = { rel, n, name: x.name.text, node: x };
590
+ else if (ts.isPropertyAssignment(x) && ts.isIdentifier(x.name))
591
+ found = { rel, n, name: x.name.text, node: x };
592
+ });
593
+ return found ?? ownerOf(node, rel);
594
+ }
595
+ // ---- verdict + rendering --------------------------------------------------
596
+ const list = (xs) => xs.map((s) => `${s.rel}:${s.n} (${s.note})`).join("; ");
597
+ export function localIdentityResult(scan) {
598
+ const nV = scan.violate.length;
599
+ const nE = scan.exempt.length;
600
+ const nJ = scan.cnj.length;
601
+ const counts = `[files=${scan.files} violations=${nV} exempt=${nE} could-not-judge=${nJ}]`;
602
+ const tail = `${nE ? ` Exempt (reported, not a pass by silence): ${list(scan.exempt)}.` : ""}${nJ ? ` could-not-judge (not decided): ${list(scan.cnj)}.` : ""}`;
603
+ if (nV > 0)
604
+ return withCount({
605
+ id: CR2_ID,
606
+ anchor: `${scan.violate[0].rel}:${scan.violate[0].n}`,
607
+ state: "non-conforming",
608
+ verdict: `CR-2: ${nV} site(s) define a local identity primitive instead of calling ${IDENTITY_PACKAGE}. ${counts} Violations: ${list(scan.violate)}.${tail}`,
609
+ }, siteCount(scan.violate));
610
+ if (nJ > 0) {
611
+ const reason = `CR-2: 0 violations, but ${nJ} file(s) could not be judged. ${counts}${tail} This is NOT a clean bill.`;
612
+ return {
613
+ id: CR2_ID,
614
+ anchor: `${scan.cnj[0].rel}:${scan.cnj[0].n}`,
615
+ state: "could-not-judge",
616
+ verdict: reason,
617
+ reason,
618
+ };
619
+ }
620
+ if (scan.files === 0) {
621
+ const reason = "CR-2: no applicable file — no convex/ or mcp-server/src source was read. NOT a clean bill.";
622
+ return {
623
+ id: CR2_ID,
624
+ anchor: "local-identity:no-applicable-file",
625
+ state: "could-not-judge",
626
+ verdict: reason,
627
+ reason,
628
+ };
629
+ }
630
+ return {
631
+ id: CR2_ID,
632
+ anchor: scan.exempt[0]
633
+ ? `${scan.exempt[0].rel}:${scan.exempt[0].n}`
634
+ : "local-identity:none",
635
+ state: "present",
636
+ verdict: `CR-2: 0 violations across ${scan.files} file(s); ${nE} exempt. ${counts}${tail}`,
637
+ };
638
+ }
639
+ /** The rendered section: EVERY violation, exemption and abstention is named. */
640
+ export function localIdentitySection(scan) {
641
+ const out = [
642
+ `LOCAL IDENTITY (CR-2) — DOCTOR-OWNED mechanical control, NOT one of the numbered rules in the pinned standard. Identity lives only in ${IDENTITY_PACKAGE}; ceiling 0. It drives the exit code.`,
643
+ ` files read: ${scan.files}`,
644
+ ];
645
+ for (const [label, xs] of [
646
+ ["VIOLATION", scan.violate],
647
+ ["EXEMPT", scan.exempt],
648
+ ["COULD-NOT-JUDGE", scan.cnj],
649
+ ]) {
650
+ out.push(` ${label} (${xs.length}):`);
651
+ for (const s of xs)
652
+ out.push(` ${s.rel}:${s.n} — ${s.note}`);
653
+ }
654
+ out.push(` CR-2 VERDICT: ${scan.violate.length} violation(s) — ${scan.violate.length ? "MECHANICAL, the gate refuses" : "none"}; ${scan.exempt.length} exempt; ${scan.cnj.length} could-not-judge.`);
655
+ return out.join("\n");
656
+ }
657
+ /** CR-2 as an engine Detector. */
658
+ export const CR2 = {
659
+ id: CR2_ID,
660
+ run: (tree) => localIdentityResult(scanLocalIdentity(tree.files)),
661
+ };