candor-ts 0.5.12 → 0.5.14
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/AGENTS.md +4 -1
- package/package.json +1 -1
- package/policy.mjs +11 -3
- package/scan-core.mjs +8 -1
- package/scan.mjs +128 -22
package/AGENTS.md
CHANGED
|
@@ -144,7 +144,10 @@ curated-κ caveat cuts the other way:** a call into an npm package κ doesn't kn
|
|
|
144
144
|
NOTHING — invisible, not `Unknown`. The scan's receipt now DISCLOSES these by name (`κ doesn't
|
|
145
145
|
know N packages…`), so the blind spots are per-scan evidence, not a doc footnote: never conclude
|
|
146
146
|
"no effect" through a package that line names (the documented weaker edge of the
|
|
147
|
-
never-silently-pure promise, same as every candor engine's curated classifier).
|
|
147
|
+
never-silently-pure promise, same as every candor engine's curated classifier). Each function ALSO
|
|
148
|
+
carries an `invisible` list — the κ-unknown packages it (transitively) reaches — so `inferred` is
|
|
149
|
+
never an unqualified claim PER FUNCTION: `inferred: []` with a non-empty `invisible` means "pure as
|
|
150
|
+
far as candor could see, but it could not see through these" (a LOWER bound), not "pure". An uncurated
|
|
148
151
|
dependency can opt out of that blind spot by declaring `"candorEffects": ["Net", …]` in its
|
|
149
152
|
`package.json` (the §5.1 effect manifest, read declared-not-verified) — its calls then classify to
|
|
150
153
|
the declared set instead of contributing nothing.
|
package/package.json
CHANGED
package/policy.mjs
CHANGED
|
@@ -14,7 +14,11 @@ const ASCII_WS = /[ \t\n\v\f\r]+/;
|
|
|
14
14
|
const ASCII_WS_TRIM = /^[ \t\n\v\f\r]+|[ \t\n\v\f\r]+$/g;
|
|
15
15
|
export function parsePolicy(text) {
|
|
16
16
|
const deny = [], allow = [], forbid = [];
|
|
17
|
-
|
|
17
|
+
// Split LINES on \n / \r\n / bare \r — the three forms Java's Files.readAllLines (the reference parser)
|
|
18
|
+
// breaks on. Splitting on \n ONLY let a classic-Mac (bare-\r) file collapse to one line: \r is also an
|
|
19
|
+
// in-line ASCII-ws token separator (below), so every rule after the first was glued into the first rule's
|
|
20
|
+
// tokens and dropped — a gateless-green divergence (sweep [16]/[17]). \v/\f stay in-line separators.
|
|
21
|
+
for (const rawLine of text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")) {
|
|
18
22
|
const line = rawLine.split("#")[0].replace(ASCII_WS_TRIM, "");
|
|
19
23
|
if (!line) continue;
|
|
20
24
|
const t = line.split(ASCII_WS);
|
|
@@ -103,7 +107,7 @@ export function literalAllowed(effect, reached, values) {
|
|
|
103
107
|
* transitive inferred; AS-EFF-008 allowlists over the transitive literal surfaces, the no-visible-
|
|
104
108
|
* literal case flagged as uncertifiable; AS-EFF-009 forbid by reachability). One line per violation.
|
|
105
109
|
*/
|
|
106
|
-
export function evaluatePolicy(pol, functions, callgraph) {
|
|
110
|
+
export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()) {
|
|
107
111
|
const out = [];
|
|
108
112
|
const surfaces = { Net: "hosts", Exec: "cmds", Fs: "paths", Db: "tables" };
|
|
109
113
|
for (const f of functions) {
|
|
@@ -116,7 +120,11 @@ export function evaluatePolicy(pol, functions, callgraph) {
|
|
|
116
120
|
if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
|
|
117
121
|
if (!f.inferred.includes(r.effect)) continue;
|
|
118
122
|
const reached = f[surfaces[r.effect]] ?? [];
|
|
119
|
-
|
|
123
|
+
// An INCOMPLETE surface (a structurally-invisible reach — a host-establishing call with a runtime/
|
|
124
|
+
// invisible host) can't be certified even with visible hosts, else a benign literal masks the
|
|
125
|
+
// invisible forbidden endpoint (the masking evasion). Matches candor-java 0.5.29 / candor-rust.
|
|
126
|
+
const surfaceIncomplete = incomplete.get(f.fn)?.has(r.effect);
|
|
127
|
+
if (reached.length === 0 || surfaceIncomplete) {
|
|
120
128
|
out.push(`[AS-EFF-008] \`${f.fn}\` performs ${r.effect} with no visible literal — the surface cannot be certified: \`${r.raw}\``);
|
|
121
129
|
} else {
|
|
122
130
|
const bad = reached.filter((v) => !literalAllowed(r.effect, v, r.values));
|
package/scan-core.mjs
CHANGED
|
@@ -39,7 +39,14 @@ export const KAPPA_RULES = [
|
|
|
39
39
|
// FABRICATED Net — the cardinal sin — purely from this classification, with no local Net edge). Only
|
|
40
40
|
// these three named validators are freed; every genuine verb (connect/createConnection/createServer…)
|
|
41
41
|
// stays Net (the matcher excludes ONLY new + the three validators, nothing else).
|
|
42
|
-
|
|
42
|
+
// ALSO exempt the pure CONFIG/METADATA members the whole-module rule fabricated Net on (sweep [9], the
|
|
43
|
+
// cardinal sin — none touch a socket/fd/syscall): tls.getCiphers/createSecureContext/checkServerIdentity
|
|
44
|
+
// (cipher-list + cert helpers), http.validateHeaderName/validateHeaderValue (string validators, like
|
|
45
|
+
// isIP), and a Socket/Server's setKeepAlive/setNoDelay/ref/unref/address (TCP-option + bound-address
|
|
46
|
+
// metadata — no I/O). Every genuine verb still classifies; only these proven-pure names are freed.
|
|
47
|
+
[/^(node:)?(net|dgram|tls|http2?|https)$/,
|
|
48
|
+
/^(?!(new|isIP|isIPv4|isIPv6|getCiphers|createSecureContext|checkServerIdentity|validateHeaderName|validateHeaderValue|setKeepAlive|setNoDelay|ref|unref|address)$)/,
|
|
49
|
+
"Net"],
|
|
43
50
|
// node:dns — name resolution is NETWORK I/O (lookup/lookupService hit the OS resolver; resolve*/
|
|
44
51
|
// reverse query DNS servers directly). Was unclassified, so a `dns.resolve(...)` read silently pure.
|
|
45
52
|
// Same construction-and-pure-accessor carve-out as the net cluster: `new dns.Resolver()` ("new") is
|
package/scan.mjs
CHANGED
|
@@ -305,6 +305,25 @@ function firstStringLiteral(node) {
|
|
|
305
305
|
return null;
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
+
// Is this property/element access a SETTER target reached through a destructuring assignment —
|
|
309
|
+
// `({ k: x.prop } = src)` or `[x.prop] = arr` (sweep [32])? Walk up through PropertyAssignment /
|
|
310
|
+
// Object|ArrayLiteral wrappers to the enclosing `=`; it is a target only when the wrapping literal is the
|
|
311
|
+
// LHS (`.left`) of the assignment. A property access on the RHS (`src = { k: x.prop }`) walks to a literal
|
|
312
|
+
// that is `.right`, so it stays a getter READ — no false setter attribution.
|
|
313
|
+
function isDestructuringAssignTarget(node) {
|
|
314
|
+
let cur = node, parent = node.parent;
|
|
315
|
+
while (parent) {
|
|
316
|
+
if (ts.isPropertyAssignment(parent) && parent.initializer === cur) { cur = parent; parent = parent.parent; continue; }
|
|
317
|
+
if (ts.isShorthandPropertyAssignment(parent)) return false; // `{prop}` has no access node to attribute
|
|
318
|
+
if (ts.isSpreadAssignment(parent) || ts.isSpreadElement(parent)) { cur = parent; parent = parent.parent; continue; }
|
|
319
|
+
if (ts.isObjectLiteralExpression(parent) || ts.isArrayLiteralExpression(parent)) { cur = parent; parent = parent.parent; continue; }
|
|
320
|
+
if (ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken)
|
|
321
|
+
return parent.left === cur;
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
|
|
308
327
|
// The literal PROGRAM head a subprocess call NAMES — argv[0] specifically, never a later argument.
|
|
309
328
|
// Unlike firstStringLiteral (the first literal ANYWHERE in the args), this refuses to refine when
|
|
310
329
|
// the program (arg0) is a runtime value but a trailing arg is a literal whose basename hits the head
|
|
@@ -464,7 +483,7 @@ for (const sf of sources) {
|
|
|
464
483
|
const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
|
|
465
484
|
fns.set(ctorQual, { local: `${node.name.text}.constructor`, direct: new Set(), edges: new Set(),
|
|
466
485
|
hosts: new Set(), tables: new Set(), cmds: new Set(), paths: new Set(),
|
|
467
|
-
why: new Set(), entry: false,
|
|
486
|
+
blind: new Set(), incomplete: new Set(), why: new Set(), entry: false,
|
|
468
487
|
loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}` });
|
|
469
488
|
}
|
|
470
489
|
nodeName.set(node, ctorQual);
|
|
@@ -475,7 +494,7 @@ for (const sf of sources) {
|
|
|
475
494
|
const qual = `${mod}.${n}`;
|
|
476
495
|
const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
|
|
477
496
|
fns.set(qual, { local: n, direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
478
|
-
cmds: new Set(), paths: new Set(), why: new Set(), entry: false, isCjsExport,
|
|
497
|
+
cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(), entry: false, isCjsExport,
|
|
479
498
|
loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}` });
|
|
480
499
|
nodeName.set(node, qual);
|
|
481
500
|
if ((ts.isVariableDeclaration(node) || ts.isPropertyDeclaration(node)) && node.initializer)
|
|
@@ -754,8 +773,22 @@ function visitCalls(node) {
|
|
|
754
773
|
// `new ExternalClass()` with an implicit ctor: same posture as an explicit external ctor
|
|
755
774
|
// the classifier doesn't know — OPAQUE (contributes nothing), not Unknown. Consistency:
|
|
756
775
|
// whether a library declares its ctor must not change the verdict.
|
|
757
|
-
else if (cd && ts.isClassDeclaration(cd) && !projectFiles.has(path.resolve(cd.getSourceFile().fileName)))
|
|
776
|
+
else if (cd && ts.isClassDeclaration(cd) && !projectFiles.has(path.resolve(cd.getSourceFile().fileName))) {
|
|
758
777
|
externalClass = true;
|
|
778
|
+
// …but the construction DOES reach the class's package — disclose it as `invisible` (sweep
|
|
779
|
+
// [13]) so the pure verdict is qualified, exactly like an unmodeled METHOD call below. Without
|
|
780
|
+
// this, `new Pool()` from an unmodeled pkg read plain pure with no disclosure, no κ-ledger.
|
|
781
|
+
const cfile = cd.getSourceFile().fileName;
|
|
782
|
+
const cmod = declModule(cd);
|
|
783
|
+
const cpkg = cmod.startsWith("@types/") ? cmod.slice("@types/".length) : cmod;
|
|
784
|
+
const cdeclared = packageManifestEffects(cfile);
|
|
785
|
+
if (cdeclared !== null) { for (const e of cdeclared) rec.direct.add(e); }
|
|
786
|
+
else if (!cmod.startsWith("<") && !kappaKnows(cpkg) && !depCoveredPkgs.has(cpkg)
|
|
787
|
+
&& /node_modules\//.test(cfile) && !/node_modules\/(@types\/node|typescript)\//.test(cfile)) {
|
|
788
|
+
unlistedSeen.set(cpkg, (unlistedSeen.get(cpkg) ?? 0) + 1);
|
|
789
|
+
rec.blind.add(cpkg);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
759
792
|
}
|
|
760
793
|
if (!edged && !externalClass) {
|
|
761
794
|
rec.direct.add("Unknown"); // unresolvable call → Unknown, never silent-pure (SPEC §4)
|
|
@@ -980,6 +1013,25 @@ function visitCalls(node) {
|
|
|
980
1013
|
// needs no entry here. Inert ctors (Agent/Server/Socket/TLSSocket/Http2Server*/message shells)
|
|
981
1014
|
// still synthesize "new" and stay pure.
|
|
982
1015
|
const CONNECTING_CTORS = new Set(["ClientRequest"]);
|
|
1016
|
+
// Host-ESTABLISHING Net call names (the masking-fix allowlist): a Net call by one of these whose
|
|
1017
|
+
// host is not a captured literal leaves the host invisible. Excludes use-verbs (write/end/send on
|
|
1018
|
+
// a connected socket). `post/put/patch/delete/head/options` cover the axios/got/undici tier whose
|
|
1019
|
+
// URL is the call arg (sweep [18]); `dgram.send(buf,port,host)` is added module-aware below (UDP
|
|
1020
|
+
// has no connect, so send carries the destination — sweep [12]).
|
|
1021
|
+
const NET_ESTABLISHING = new Set(["request", "get", "post", "put", "patch", "delete", "head",
|
|
1022
|
+
"options", "connect", "createConnection", "fetch"]);
|
|
1023
|
+
// Fs/Exec USE-verbs whose LOCATOR was fixed earlier, not an arg of THIS call — so a missing literal
|
|
1024
|
+
// here is the legitimate split-construct/use shape, never the masking signal (the establishing-
|
|
1025
|
+
// allowlist discipline, generalized from Net to all 4 effects; sweep [11]). Fs: the fd/FileHandle
|
|
1026
|
+
// ops (fd came from open()); the path-taking fs.* fns are establishing. Exec: ChildProcess methods
|
|
1027
|
+
// (the command was fixed at spawn); the spawn fns are establishing.
|
|
1028
|
+
const FS_USE_VERBS = new Set(["write", "writeSync", "read", "readSync", "close", "closeSync",
|
|
1029
|
+
"fsync", "fsyncSync", "fdatasync", "fdatasyncSync", "ftruncate", "ftruncateSync", "fchmod",
|
|
1030
|
+
"fchmodSync", "fchown", "fchownSync", "futimes", "futimesSync", "fstat", "fstatSync"]);
|
|
1031
|
+
const EXEC_USE_VERBS = new Set(["kill", "send", "disconnect", "ref", "unref"]);
|
|
1032
|
+
const netEstablishing = (member) =>
|
|
1033
|
+
CONNECTING_CTORS.has(ctorClassName) || NET_ESTABLISHING.has(member)
|
|
1034
|
+
|| (/^(node:)?dgram$/.test(mod) && member === "send");
|
|
983
1035
|
const ctorClassName = ts.isNewExpression(node)
|
|
984
1036
|
? (ts.isConstructorDeclaration(decl) ? decl.parent?.name?.getText?.()
|
|
985
1037
|
: (decl.name ? decl.name.getText() : ""))
|
|
@@ -1005,9 +1057,18 @@ function visitCalls(node) {
|
|
|
1005
1057
|
const lit = firstStringLiteral(node);
|
|
1006
1058
|
const h = lit && hostLiteral(lit);
|
|
1007
1059
|
if (h) rec.hosts.add(h);
|
|
1060
|
+
// MASKING fix: a host-ESTABLISHING Net call whose host is NOT a captured literal (runtime URL, or
|
|
1061
|
+
// built elsewhere) leaves the host invisible to the gate → mark the surface incomplete so a
|
|
1062
|
+
// benign literal can't mask it. ALLOWLIST of establishing forms only — NEVER use-calls
|
|
1063
|
+
// (write/end/non-dgram send), which would false-positive on `socket.connect("h").write(data)`
|
|
1064
|
+
// (the host is captured at connect). Under-catches an unlisted establishing verb (safe
|
|
1065
|
+
// direction); never over-flags a use-call.
|
|
1066
|
+
else if (netEstablishing(member))
|
|
1067
|
+
rec.incomplete.add("Net");
|
|
1008
1068
|
}
|
|
1009
1069
|
if (eff === "Db") {
|
|
1010
1070
|
const lit = firstStringLiteral(node);
|
|
1071
|
+
const before = rec.tables.size;
|
|
1011
1072
|
for (const t of lit ? tablesInSql(lit) : []) rec.tables.add(t);
|
|
1012
1073
|
// ORM route: `this.userRepository.find(…)` — the receiver's `Repository<UserEntity>`
|
|
1013
1074
|
// type argument names the entity; its `@Entity("user")` decorator names the table.
|
|
@@ -1019,6 +1080,11 @@ function visitCalls(node) {
|
|
|
1019
1080
|
if (tbl) rec.tables.add(tbl);
|
|
1020
1081
|
}
|
|
1021
1082
|
}
|
|
1083
|
+
// masking: a Db call that surfaced NO table (no SQL literal, no entity-typed receiver) reaches a
|
|
1084
|
+
// runtime/invisible table — a benign sibling query's literal table must not mask it. The entity
|
|
1085
|
+
// route above is NOT a literal so it still counts as visible (a captured table); only a fully
|
|
1086
|
+
// invisible query marks incomplete. `new` (a connection ctor) carries no table — skip it.
|
|
1087
|
+
if (rec.tables.size === before && member !== "new") rec.incomplete.add("Db");
|
|
1022
1088
|
}
|
|
1023
1089
|
if (eff === "Exec") {
|
|
1024
1090
|
const lit = firstStringLiteral(node);
|
|
@@ -1028,10 +1094,18 @@ function visitCalls(node) {
|
|
|
1028
1094
|
// names no static program, so its trailing literal must not fabricate Net (spec §4).
|
|
1029
1095
|
const head = programHeadLiteral(node);
|
|
1030
1096
|
if (head) for (const e of commandHeadEffects(head)) rec.direct.add(e);
|
|
1097
|
+
// masking (sweep [11]): an Exec call whose program head is NOT a static literal (runtime
|
|
1098
|
+
// command) leaves the command invisible. Establishing = the spawn fns; ChildProcess use-verbs
|
|
1099
|
+
// (kill/send/disconnect/ref/unref) carry no command and are excluded.
|
|
1100
|
+
else if (!EXEC_USE_VERBS.has(member)) rec.incomplete.add("Exec");
|
|
1031
1101
|
}
|
|
1032
1102
|
if (eff === "Fs") {
|
|
1033
1103
|
const lit = firstStringLiteral(node);
|
|
1034
|
-
|
|
1104
|
+
const pathCaptured = lit && /[/\\]|^[.~]/.test(lit); // path-shaped literals only
|
|
1105
|
+
if (pathCaptured) rec.paths.add(lit);
|
|
1106
|
+
// masking (sweep [11]): a path-taking fs.* call whose path is NOT a captured literal (runtime
|
|
1107
|
+
// path) leaves it invisible. fd/FileHandle USE-verbs (fd came from a prior open()) are excluded.
|
|
1108
|
+
else if (!FS_USE_VERBS.has(member)) rec.incomplete.add("Fs");
|
|
1035
1109
|
}
|
|
1036
1110
|
// CANDOR_DEPS: an unclassified call into a package with a loaded sibling report inherits
|
|
1037
1111
|
// that function's recorded transitive effects (+ literal surfaces) by `hash`.
|
|
@@ -1079,6 +1153,11 @@ function visitCalls(node) {
|
|
|
1079
1153
|
} else if (!kappaKnows(pkg) && !depCoveredPkgs.has(pkg)
|
|
1080
1154
|
&& /node_modules\//.test(file) && !/node_modules\/(@types\/node|typescript)\//.test(file)) {
|
|
1081
1155
|
unlistedSeen.set(pkg, (unlistedSeen.get(pkg) ?? 0) + 1);
|
|
1156
|
+
// Per-fn HONESTY: this fn calls into a genuinely-blind package (κ-unknown, not dep-covered).
|
|
1157
|
+
// Recorded per fn, propagated transitively, emitted as `invisible` — so `inferred` is never an
|
|
1158
|
+
// unqualified completeness claim. This branch already IS the global-blind condition, so no
|
|
1159
|
+
// post-filter is needed (κ either knows a package or it doesn't).
|
|
1160
|
+
rec.blind.add(pkg);
|
|
1082
1161
|
}
|
|
1083
1162
|
}
|
|
1084
1163
|
}
|
|
@@ -1115,8 +1194,19 @@ function visitCalls(node) {
|
|
|
1115
1194
|
const callee = node.expression;
|
|
1116
1195
|
const ctext = callee.getText().replace(/\s+/g, "");
|
|
1117
1196
|
let geff = null;
|
|
1118
|
-
|
|
1119
|
-
|
|
1197
|
+
// `process.*` is matched by exact text, so a project's OWN `const process = {…}` shadow would
|
|
1198
|
+
// fabricate Clock/Ipc on a pure local method (sweep [31]) — guard it like the `fetch` arm: resolve the
|
|
1199
|
+
// ROOT `process` identifier and fire only when it is NOT a project-local declaration (i.e. the global).
|
|
1200
|
+
const processIsGlobal = () => {
|
|
1201
|
+
if (!ts.isPropertyAccessExpression(callee)) return false;
|
|
1202
|
+
let root = callee.expression;
|
|
1203
|
+
while (ts.isPropertyAccessExpression(root)) root = root.expression; // process.hrtime.bigint → process
|
|
1204
|
+
if (!ts.isIdentifier(root) || root.text !== "process") return false;
|
|
1205
|
+
return !(checker.getSymbolAtLocation(root)?.declarations ?? [])
|
|
1206
|
+
.some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName)));
|
|
1207
|
+
};
|
|
1208
|
+
if ((ctext === "process.hrtime" || ctext === "process.hrtime.bigint") && processIsGlobal()) geff = "Clock";
|
|
1209
|
+
else if (ctext === "process.send" && processIsGlobal()) geff = "Ipc";
|
|
1120
1210
|
else if (ts.isIdentifier(callee) && callee.text === "fetch"
|
|
1121
1211
|
&& !(checker.getSymbolAtLocation(callee)?.declarations ?? [])
|
|
1122
1212
|
.some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName))))
|
|
@@ -1145,21 +1235,28 @@ function visitCalls(node) {
|
|
|
1145
1235
|
// call), never silently pure. A resolved-but-UNSEEN accessor (external declaration) reads Unknown,
|
|
1146
1236
|
// following the same posture as an unresolvable call (SPEC §4).
|
|
1147
1237
|
if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
|
|
1148
|
-
// Is this property access
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
1238
|
+
// Is this property access an assignment TARGET? A simple `x.prop = v` invokes the SETTER only. A
|
|
1239
|
+
// COMPOUND/LOGICAL assignment (`+=`,`-=`,`??=`,`||=`,`&&=`,…) reads the current value AND writes — both
|
|
1240
|
+
// the getter and the setter run (sweep [10]; pre-fix only a bare `=` was a setter site, so an effectful
|
|
1241
|
+
// setter under `+=`/`??=` read PURE). A DESTRUCTURING-assignment target (`({k: x.prop} = src)` /
|
|
1242
|
+
// `[x.prop] = arr`) is also a setter site, invisible to the simple-LHS test (sweep [32]).
|
|
1151
1243
|
const p = node.parent;
|
|
1152
|
-
const
|
|
1153
|
-
|
|
1154
|
-
const
|
|
1155
|
-
|
|
1244
|
+
const isBinAssign = p && ts.isBinaryExpression(p) && p.left === node;
|
|
1245
|
+
const simpleAssign = isBinAssign && p.operatorToken.kind === ts.SyntaxKind.EqualsToken;
|
|
1246
|
+
const compoundAssign = isBinAssign && !simpleAssign
|
|
1247
|
+
&& p.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && p.operatorToken.kind <= ts.SyntaxKind.LastAssignment;
|
|
1248
|
+
const recordKind = (kind) => {
|
|
1249
|
+
const hit = accessorAt(node, kind);
|
|
1250
|
+
if (!hit) return;
|
|
1156
1251
|
const owner = enclosing(node);
|
|
1157
|
-
if (owner)
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1252
|
+
if (!owner) return;
|
|
1253
|
+
const an = hit.decl.parent?.name?.getText?.() ?? "?";
|
|
1254
|
+
const pn = node.name?.getText?.() ?? node.argumentExpression?.getText?.() ?? "?";
|
|
1255
|
+
recordAccessorHit(owner, hit, `${an}.${pn}`);
|
|
1256
|
+
};
|
|
1257
|
+
if (simpleAssign || isDestructuringAssignTarget(node)) recordKind("set");
|
|
1258
|
+
else if (compoundAssign) { recordKind("get"); recordKind("set"); }
|
|
1259
|
+
else recordKind("get");
|
|
1163
1260
|
}
|
|
1164
1261
|
// OBJECT-DESTRUCTURING getter read (`const { prop } = obj`): each bound property is a READ that may
|
|
1165
1262
|
// resolve to a getter whose body does I/O — the binding-pattern analog of `obj.prop`, invisible to
|
|
@@ -1271,7 +1368,7 @@ while (changed) {
|
|
|
1271
1368
|
if (!mine.has(e)) { mine.add(e); changed = true; }
|
|
1272
1369
|
}
|
|
1273
1370
|
}
|
|
1274
|
-
for (const m of ["hosts", "tables", "cmds", "paths"]) {
|
|
1371
|
+
for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete"]) {
|
|
1275
1372
|
let moved = true;
|
|
1276
1373
|
while (moved) {
|
|
1277
1374
|
moved = false;
|
|
@@ -1286,7 +1383,9 @@ for (const m of ["hosts", "tables", "cmds", "paths"]) {
|
|
|
1286
1383
|
const functions = [];
|
|
1287
1384
|
for (const [name, rec] of fns) {
|
|
1288
1385
|
const inf = [...inferred.get(name)].sort();
|
|
1289
|
-
|
|
1386
|
+
// entry points stay visible even when pure; a BLIND fn stays too, so the honesty disclosure survives
|
|
1387
|
+
// on exactly the `inferred: []` fns that need it.
|
|
1388
|
+
if (inf.length === 0 && !rec.entry && rec.blind.size === 0) continue;
|
|
1290
1389
|
const entry = {
|
|
1291
1390
|
fn: name,
|
|
1292
1391
|
loc: rec.loc,
|
|
@@ -1303,6 +1402,9 @@ for (const [name, rec] of fns) {
|
|
|
1303
1402
|
if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
|
|
1304
1403
|
if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
|
|
1305
1404
|
if (rec.direct.has("Unknown") && rec.why.size) entry.unknownWhy = [...rec.why].sort();
|
|
1405
|
+
// HONESTY: the npm packages this fn transitively reaches that κ couldn't see through — effects through
|
|
1406
|
+
// them are NOT in `inferred`, so it is a LOWER BOUND when this is non-empty. Omitted when none.
|
|
1407
|
+
if (rec.blind.size) entry.invisible = [...rec.blind].sort();
|
|
1306
1408
|
if (rec.entry) entry.entryPoint = true;
|
|
1307
1409
|
if (rec.isCjsExport) entry.unitKind = "export"; // spec 0.5 draft, informative — per-unit, not by name
|
|
1308
1410
|
functions.push(entry);
|
|
@@ -1339,7 +1441,11 @@ if (policyPath) {
|
|
|
1339
1441
|
console.error(`candor-ts: policy ${policyPath} could not be read; gate NOT enforced`);
|
|
1340
1442
|
process.exit(2);
|
|
1341
1443
|
}
|
|
1342
|
-
|
|
1444
|
+
// The masking-incompleteness map (fn -> effects whose surface is incomplete), kept INTERNAL like the
|
|
1445
|
+
// java/rust engines (not a report field) — passed to the gate so an incomplete surface fails closed.
|
|
1446
|
+
const incompleteMap = new Map();
|
|
1447
|
+
for (const [name, rec] of fns) if (rec.incomplete.size) incompleteMap.set(name, rec.incomplete);
|
|
1448
|
+
const v = evaluatePolicy(parsePolicy(text), functions, cg, incompleteMap);
|
|
1343
1449
|
for (const line of v) console.log(line);
|
|
1344
1450
|
if (v.length) {
|
|
1345
1451
|
console.error(`candor-ts: ${v.length} policy violation(s)`);
|