candor-ts 0.5.21 → 0.5.23
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/package.json +1 -1
- package/scan-core.mjs +57 -0
- package/scan.mjs +148 -1
package/package.json
CHANGED
package/scan-core.mjs
CHANGED
|
@@ -66,11 +66,60 @@ export const KAPPA_RULES = [
|
|
|
66
66
|
[/^(node:)?sqlite$/, null, "Db"],
|
|
67
67
|
// the curated npm tier
|
|
68
68
|
[/^(axios|got|node-fetch|undici|ws|socket\.io(-client)?|nodemailer)$/, null, "Net"],
|
|
69
|
+
// gaxios is the axios-like HTTP client under googleapis (request/get/post/put/patch/delete/head do
|
|
70
|
+
// the network; it has no notable pure surface, but be VERB-precise like the rest of the Net tier so a
|
|
71
|
+
// future config accessor can't fabricate). `createAPIRequest` is googleapis-common's transport entry
|
|
72
|
+
// (every googleapis service method funnels through it → the real network). The deeper `googleapis`
|
|
73
|
+
// service chains (`calendar.events.insert()`) resolve their verb into the `googleapis` package, but
|
|
74
|
+
// those verbs are GENERIC (insert/list/get/update) and shared with pure builders — modeling them by
|
|
75
|
+
// name would fabricate; the actual network is the gaxios/createAPIRequest transport, modeled here, so
|
|
76
|
+
// a googleapis call that reaches the wire does so through a modeled unit when its source is scanned.
|
|
77
|
+
[/^gaxios$/, /^(request|get|post|put|patch|delete|head)$/, "Net"],
|
|
78
|
+
[/^googleapis-common$/, /^createAPIRequest$/, "Net"],
|
|
79
|
+
// google-auth-library mints/refreshes OAuth tokens and verifies ID tokens over the network. The
|
|
80
|
+
// verb surface only (the GoogleAuth/OAuth2Client/JWT constructors are config — inert until a verb).
|
|
81
|
+
[/^google-auth-library$/,
|
|
82
|
+
/^(request|getClient|getAccessToken|getRequestHeaders|authorize|refreshAccessToken|refreshToken|getTokenInfo|verifyIdToken|fetchIdToken|getCredentials|getProjectId|getSignedJwt)$/,
|
|
83
|
+
"Net"],
|
|
84
|
+
// stripe: methods land on a `new Stripe()` instance's resource chains
|
|
85
|
+
// (`stripe.customers.create()`, `stripe.checkout.sessions.create()`, `charges.*`, `paymentIntents.*`).
|
|
86
|
+
// A chained member call resolves its verb's DECLARATION into the `stripe` package (declModule keys on
|
|
87
|
+
// the source file, not the chain depth — verified), so keying on stripe's resource VERBS catches the
|
|
88
|
+
// deep chains. VERB-precise: the I/O verbs only (the SDK's resources share these); pure helpers
|
|
89
|
+
// (toString/JSON) and inert `new Stripe()` construction stay pure.
|
|
90
|
+
[/^stripe$/,
|
|
91
|
+
/^(create|retrieve|update|list|listLineItems|listPaymentMethods|del|delete|cancel|capture|confirm|expire|finalizeInvoice|pay|sendInvoice|markUncollectible|voidInvoice|refund|reverse|verify|search|approve|decline|attach|detach|deactivate)$/,
|
|
92
|
+
"Net"],
|
|
93
|
+
// error/telemetry SaaS — the capture/flush verbs ship the payload over the network. init/config are
|
|
94
|
+
// inert. @sentry/* re-exports captureException etc. from @sentry/core/@sentry/browser, so a consumer's
|
|
95
|
+
// import may resolve into any @sentry sub-package — match the whole scope, verb-precise.
|
|
96
|
+
[/^@sentry\/[^/]+$/,
|
|
97
|
+
/^(captureException|captureMessage|captureEvent|captureCheckIn|flush|close)$/, "Net"],
|
|
98
|
+
// posthog-node: capture/identify/group enqueue then flush over HTTP; flush/shutdown/captureImmediate
|
|
99
|
+
// and the feature-flag fetches (isFeatureEnabled/getFeatureFlag*) hit the API. Verb-precise; the
|
|
100
|
+
// `new PostHog()` ctor is inert (config).
|
|
101
|
+
[/^posthog-node$/,
|
|
102
|
+
/^(capture|captureImmediate|identify|identifyImmediate|alias|groupIdentify|flush|shutdown|isFeatureEnabled|getFeatureFlag|getFeatureFlagPayload|getAllFlags|getAllFlagsAndPayloads|getRemoteConfigPayload|reloadFeatureFlags)$/,
|
|
103
|
+
"Net"],
|
|
69
104
|
[/^(pg|mysql2?|mongodb|ioredis|redis|sqlite3|better-sqlite3|knex)$/, null, "Db"],
|
|
105
|
+
// bull/bullmq are Redis-backed job queues — the queue/worker/job ops issue Redis commands (Db). Their
|
|
106
|
+
// surface is almost entirely I/O, but be VERB-precise (the I/O ops) so inert event-wiring
|
|
107
|
+
// (`queue.on(...)`) and `new Queue()`/`new Worker()` construction (which only opens a lazy connection)
|
|
108
|
+
// don't fabricate. The connection IS Redis — Db, consistent with the ioredis/redis classification.
|
|
109
|
+
[/^(bull|bullmq)$/,
|
|
110
|
+
/^(add|addBulk|getJob|getJobs|getJobCounts|getJobCountByTypes|getWaiting|getActive|getCompleted|getFailed|getDelayed|getWaitingChildren|getRepeatableJobs|removeRepeatable|removeRepeatableByKey|getMetrics|count|pause|resume|isPaused|drain|clean|obliterate|empty|close|remove|retry|retryJobs|promote|moveToCompleted|moveToFailed|updateData|updateProgress|process|waitUntilReady|getState|getDependencies|getChildrenValues)$/,
|
|
111
|
+
"Db"],
|
|
70
112
|
[/^(execa|cross-spawn|shelljs)$/, null, "Exec"],
|
|
113
|
+
// the `open` package spawns the OS handler (xdg-open/open/start) — Exec. Default export `open(target)`
|
|
114
|
+
// resolves to member `open` (its declared fn name — verified); `openApp` likewise. The `apps` const is
|
|
115
|
+
// pure (a property read, never a call).
|
|
116
|
+
[/^open$/, /^(open|openApp)$/, "Exec"],
|
|
71
117
|
[/^(fs-extra|graceful-fs|rimraf|glob|chokidar)$/, null, "Fs"],
|
|
72
118
|
[/^dotenv$/, null, "Env"],
|
|
73
119
|
[/^(winston|pino|bunyan|npmlog)$/, null, "Log"],
|
|
120
|
+
// nest-winston wraps winston; the injected logger's level verbs are the Log boundary (the
|
|
121
|
+
// WinstonModule.createLogger/forRoot config is inert).
|
|
122
|
+
[/^nest-winston$/, /^(log|info|warn|error|debug|verbose|silly|http)$/, "Log"],
|
|
74
123
|
// entropy: node:crypto's random surface + the password-hashing libs (salted -> Rand). Found by
|
|
75
124
|
// the CTA dogfood on a Nest app: argon2.hash came out SILENTLY PURE (the curated-kappa caveat
|
|
76
125
|
// landing on exactly the call a security review cares about).
|
|
@@ -78,6 +127,14 @@ export const KAPPA_RULES = [
|
|
|
78
127
|
// were silently pure inside the covered `crypto` module (the κ-coverage floor can't tell an unmodeled
|
|
79
128
|
// entropy draw from a pure unmodeled member; the fix is to MODEL the member, not drop coverage).
|
|
80
129
|
[/^(node:)?crypto$/, /^(random|getRandomValues|generateKey|generatePrime)/, "Rand"],
|
|
130
|
+
// uuid: the random-based generators draw from the CSPRNG (v4) / clock+MAC+random (v1) / random (v6/v7).
|
|
131
|
+
// v3 (MD5) and v5 (SHA-1) are DETERMINISTIC namespace hashes — same input, same UUID — so they are
|
|
132
|
+
// PURE and excluded. parse/stringify/validate/version/NIL/MAX are pure too (not matched).
|
|
133
|
+
[/^uuid$/, /^(v1|v4|v6|v7)$/, "Rand"],
|
|
134
|
+
// nanoid: nanoid()/customRandom() draw from crypto.getRandomValues; customAlphabet() returns a
|
|
135
|
+
// generator that does the same. `nanoid/non-secure` uses Math.random — still Rand. The `urlAlphabet`
|
|
136
|
+
// const is pure (a property read). Sound over-approximation: the factory call is the resolvable site.
|
|
137
|
+
[/^nanoid(\/non-secure)?$/, /^(nanoid|customAlphabet|customRandom)$/, "Rand"],
|
|
81
138
|
// node:os identity reads — userInfo (the OS user record) and hostname (the machine name) are
|
|
82
139
|
// environment/host reads (Env), like System.getenv's host-identity cousins. The rest of node:os
|
|
83
140
|
// (platform/arch/cpus/totalmem/…) is inert host introspection, left pure.
|
package/scan.mjs
CHANGED
|
@@ -962,6 +962,71 @@ function iterationTargets(expr, isAsync) {
|
|
|
962
962
|
function edgeToTargets(rec, decls) {
|
|
963
963
|
for (const d of decls) { const t = nodeName.get(d); if (t) rec.edges.add(t); }
|
|
964
964
|
}
|
|
965
|
+
|
|
966
|
+
// ---- implicit VALUE-COERCION desugaring (the silent-pure holes where the JS coercion protocol calls a
|
|
967
|
+
// user method the AST walk never visits) ----------------------------------------------------------
|
|
968
|
+
// JS coerces an object to a primitive by INVOKING a method on it: `a + b`/`` `${x}` ``/`String(x)` call
|
|
969
|
+
// `toString` (or `[Symbol.toPrimitive]`); `x + 1`/`-x`/`+x`/relational call `valueOf` (or
|
|
970
|
+
// `[Symbol.toPrimitive]`); `JSON.stringify(x)` calls `toJSON`. None of these surface as a
|
|
971
|
+
// CallExpression on the user method, so an effectful `toString`/`valueOf`/`toJSON`/`[Symbol.toPrimitive]`
|
|
972
|
+
// reached this way read SILENT-PURE (the cardinal sin). We model the desugar EXACTLY as the spec demands:
|
|
973
|
+
// resolve the operand's type's coercion member via the checker and edge to it ONLY when it is a LOCAL
|
|
974
|
+
// unit. A built-in/external member (lib.es `Object.prototype.toString`, a stdlib `toJSON`) resolves
|
|
975
|
+
// non-local → no edge → stays pure (the precision invariant); a PURE local member edges to a pure unit
|
|
976
|
+
// (contributes nothing). NEVER a fabricated edge: a non-object operand, or a type with no such member,
|
|
977
|
+
// resolves to nothing.
|
|
978
|
+
|
|
979
|
+
// Resolve coercion members of `expr`'s type to their LOCAL decls. `names` is the ordered set of plain
|
|
980
|
+
// member names to try; `withPrimitive` also consults the well-known `[Symbol.toPrimitive]` (which JS
|
|
981
|
+
// prefers over toString/valueOf when present). A union operand is widened to its constituents so a
|
|
982
|
+
// `A | B` value edges to whichever side declares a LOCAL coercion member. Returns LOCAL member decls.
|
|
983
|
+
function coercionTargets(expr, names, withPrimitive) {
|
|
984
|
+
const t = checker.getTypeAtLocation(expr);
|
|
985
|
+
if (!t) return [];
|
|
986
|
+
// Widen unions/intersections so each branch's coercion member is considered (a `Foo | string` operand
|
|
987
|
+
// can be a Foo at runtime → its local toString runs). A primitive/literal constituent has no LOCAL
|
|
988
|
+
// member and contributes nothing.
|
|
989
|
+
const parts = t.isUnionOrIntersection?.() ? t.types : [t];
|
|
990
|
+
const out = [];
|
|
991
|
+
for (const part of parts) {
|
|
992
|
+
if (!part || !part.getProperty) continue;
|
|
993
|
+
if (withPrimitive) {
|
|
994
|
+
const pd = declOfSym(wellKnownSymbolMember(part, ["__@toPrimitive"]));
|
|
995
|
+
if (pd && declIsLocal(pd) && !out.includes(pd)) out.push(pd);
|
|
996
|
+
}
|
|
997
|
+
for (const n of names) {
|
|
998
|
+
const md = declOfSym(part.getProperty(n));
|
|
999
|
+
// A METHOD (or function-valued property) member — not a getter/data field of an unrelated shape.
|
|
1000
|
+
if (md && declIsLocal(md)
|
|
1001
|
+
&& (ts.isMethodDeclaration(md) || ts.isMethodSignature(md)
|
|
1002
|
+
|| ts.isPropertyDeclaration(md) || ts.isPropertyAssignment(md)
|
|
1003
|
+
|| ts.isFunctionDeclaration(md) || ts.isFunctionExpression(md) || ts.isArrowFunction(md))
|
|
1004
|
+
&& !out.includes(md))
|
|
1005
|
+
out.push(md);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
return out;
|
|
1009
|
+
}
|
|
1010
|
+
// True when `expr`'s type is an OBJECT type that could carry a coercion method (so `a + b` may trigger
|
|
1011
|
+
// one). A pure primitive operand (string/number/boolean/bigint/null/undefined) never invokes
|
|
1012
|
+
// toString/valueOf in `+` (string+string concatenates, number+number adds — no method call), so we skip
|
|
1013
|
+
// it: edging there would be at best inert and the type-narrowing keeps `coercionTargets` from widening a
|
|
1014
|
+
// huge `string | Foo` into spurious work. An object/union-containing-object is a candidate.
|
|
1015
|
+
function mayCoerceObject(expr) {
|
|
1016
|
+
const t = checker.getTypeAtLocation(expr);
|
|
1017
|
+
if (!t) return false;
|
|
1018
|
+
const parts = t.isUnionOrIntersection?.() ? t.types : [t];
|
|
1019
|
+
const PRIM = ts.TypeFlags.StringLike | ts.TypeFlags.NumberLike | ts.TypeFlags.BigIntLike
|
|
1020
|
+
| ts.TypeFlags.BooleanLike | ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.VoidLike
|
|
1021
|
+
| ts.TypeFlags.ESSymbolLike;
|
|
1022
|
+
for (const part of parts) {
|
|
1023
|
+
if (!part) continue;
|
|
1024
|
+
if (part.flags & PRIM) continue; // a pure primitive branch never coerces via a method
|
|
1025
|
+
if (part.flags & ts.TypeFlags.Object) return true;
|
|
1026
|
+
if (part.isUnionOrIntersection?.()) return true; // nested — let coercionTargets sort it out
|
|
1027
|
+
}
|
|
1028
|
+
return false;
|
|
1029
|
+
}
|
|
965
1030
|
// Does iterating `expr` FORCE an OPAQUE (caller-supplied) iterable? Forcing an iterable runs its
|
|
966
1031
|
// `[Symbol.iterator]`/`next` body, which — when the iterable is a PARAMETER / `any` / a bare type-
|
|
967
1032
|
// parameter — is caller-chosen code that can perform arbitrary I/O. This is epistemically identical to
|
|
@@ -1388,9 +1453,25 @@ function visitCalls(node) {
|
|
|
1388
1453
|
: (decl.name ? decl.name.getText() : ""))
|
|
1389
1454
|
: "";
|
|
1390
1455
|
const isConstruction = ts.isConstructorDeclaration(decl) || ts.isNewExpression(node);
|
|
1456
|
+
// The κ member token. A named decl (function/method declaration) carries its own name; but a
|
|
1457
|
+
// VALUE-BINDING export — `export const v4 = (...) => ...` (the shape REAL uuid v9+/nanoid ship,
|
|
1458
|
+
// and the `type v4 = v4Buffer & v4String` callable type-alias of @types/uuid v8) resolves to an
|
|
1459
|
+
// ANONYMOUS arrow/function-type whose `decl.name` is empty, so κ saw `""` and the package's
|
|
1460
|
+
// entropy/net verb read silent-pure (verified against installed uuid/nanoid). Fall back to the
|
|
1461
|
+
// BINDING name: an arrow/fn-expr's parent VariableDeclaration / PropertyAssignment / property,
|
|
1462
|
+
// or a callable type-alias's TypeAliasDeclaration. Precision no-op where the old path already
|
|
1463
|
+
// had a name (this only fills a former `""`); never synthesizes a name for `new`.
|
|
1464
|
+
const bindingName = (d) => {
|
|
1465
|
+
const p = d.parent;
|
|
1466
|
+
if (!p) return "";
|
|
1467
|
+
if ((ts.isVariableDeclaration(p) || ts.isPropertyDeclaration(p) || ts.isPropertyAssignment(p)
|
|
1468
|
+
|| ts.isPropertySignature(p) || ts.isBindingElement(p) || ts.isTypeAliasDeclaration(p))
|
|
1469
|
+
&& p.name && ts.isIdentifier(p.name)) return p.name.getText();
|
|
1470
|
+
return "";
|
|
1471
|
+
};
|
|
1391
1472
|
const member = isConstruction
|
|
1392
1473
|
? (CONNECTING_CTORS.has(ctorClassName) ? ctorClassName : "new")
|
|
1393
|
-
: (decl.name ? decl.name.getText() :
|
|
1474
|
+
: (decl.name ? decl.name.getText() : bindingName(decl));
|
|
1394
1475
|
let eff = kappa(mod, member); // (CLASSIFY)
|
|
1395
1476
|
// process.stdout/stderr/stdin are typed `tty.WriteStream`, which EXTENDS `net.Socket`, so a
|
|
1396
1477
|
// `.write()`/`.end()` on them resolves to `net.Socket.write` and the whole-module Net rule
|
|
@@ -1708,6 +1789,72 @@ function visitCalls(node) {
|
|
|
1708
1789
|
}
|
|
1709
1790
|
}
|
|
1710
1791
|
}
|
|
1792
|
+
// IMPLICIT VALUE-COERCION desugaring (HIGH): the JS coercion protocol invokes a user method the AST
|
|
1793
|
+
// walk never visits as a CallExpression. Resolve the operand's type's coercion member and edge to it
|
|
1794
|
+
// when LOCAL (a built-in/external member resolves non-local → no edge → stays pure). NEVER fabricate.
|
|
1795
|
+
{
|
|
1796
|
+
const owner = enclosing(node);
|
|
1797
|
+
const recOf = () => owner && fns.get(owner);
|
|
1798
|
+
// 1+2. BINARY operators. `+` with an OBJECT operand triggers toString/valueOf (string+string,
|
|
1799
|
+
// number+number have no coercion method — stay pure, gated by mayCoerceObject). Arithmetic
|
|
1800
|
+
// (`-`/`*`/`/`/`%`/`**`) and relational (`<`/`>`/`<=`/`>=`) coerce to a NUMBER → valueOf (then
|
|
1801
|
+
// toString). `[Symbol.toPrimitive]` is preferred by JS over both — always consulted.
|
|
1802
|
+
if (ts.isBinaryExpression(node) && owner) {
|
|
1803
|
+
const op = node.operatorToken.kind;
|
|
1804
|
+
const K = ts.SyntaxKind;
|
|
1805
|
+
const ARITH = new Set([K.MinusToken, K.AsteriskToken, K.SlashToken, K.PercentToken,
|
|
1806
|
+
K.AsteriskAsteriskToken, K.LessThanToken, K.GreaterThanToken, K.LessThanEqualsToken,
|
|
1807
|
+
K.GreaterThanEqualsToken, K.AmpersandToken, K.BarToken, K.CaretToken,
|
|
1808
|
+
K.LessThanLessThanToken, K.GreaterThanGreaterThanToken, K.GreaterThanGreaterThanGreaterThanToken]);
|
|
1809
|
+
const COMPOUND_ARITH = new Set([K.MinusEqualsToken, K.AsteriskEqualsToken, K.SlashEqualsToken,
|
|
1810
|
+
K.PercentEqualsToken, K.AsteriskAsteriskEqualsToken]);
|
|
1811
|
+
if (op === K.PlusToken || op === K.PlusEqualsToken) {
|
|
1812
|
+
// string concat / `+` arithmetic: an OBJECT operand is coerced via toString OR valueOf (the
|
|
1813
|
+
// order depends on the hint, but EITHER may run — edge to both when local). string+string and
|
|
1814
|
+
// number+number have only primitive operands → mayCoerceObject false → no edge (pure).
|
|
1815
|
+
for (const operand of [node.left, node.right]) {
|
|
1816
|
+
if (mayCoerceObject(operand))
|
|
1817
|
+
edgeToTargets(recOf(), coercionTargets(operand, ["valueOf", "toString"], true));
|
|
1818
|
+
}
|
|
1819
|
+
} else if (ARITH.has(op) || COMPOUND_ARITH.has(op)) {
|
|
1820
|
+
for (const operand of [node.left, node.right]) {
|
|
1821
|
+
if (mayCoerceObject(operand))
|
|
1822
|
+
edgeToTargets(recOf(), coercionTargets(operand, ["valueOf", "toString"], true));
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
// 2. UNARY arithmetic `-x` / `+x` / `~x` coerces the operand to a number → valueOf (then toString /
|
|
1827
|
+
// [Symbol.toPrimitive]). (`!x` is boolean coercion — no method call; excluded.)
|
|
1828
|
+
if (ts.isPrefixUnaryExpression(node) && owner
|
|
1829
|
+
&& (node.operator === ts.SyntaxKind.MinusToken || node.operator === ts.SyntaxKind.PlusToken
|
|
1830
|
+
|| node.operator === ts.SyntaxKind.TildeToken)
|
|
1831
|
+
&& mayCoerceObject(node.operand)) {
|
|
1832
|
+
edgeToTargets(recOf(), coercionTargets(node.operand, ["valueOf", "toString"], true));
|
|
1833
|
+
}
|
|
1834
|
+
// 1. TEMPLATE expression `` `${x}` ``: each interpolated substitution is string-coerced → toString
|
|
1835
|
+
// (then [Symbol.toPrimitive]/valueOf). (A TaggedTemplate is handled separately below — the tag fn
|
|
1836
|
+
// receives the raw substitution values, no per-sub coercion, so we exclude tagged templates here.)
|
|
1837
|
+
if (ts.isTemplateExpression(node) && owner && !ts.isTaggedTemplateExpression(node.parent)) {
|
|
1838
|
+
for (const span of node.templateSpans)
|
|
1839
|
+
if (mayCoerceObject(span.expression))
|
|
1840
|
+
edgeToTargets(recOf(), coercionTargets(span.expression, ["toString", "valueOf"], true));
|
|
1841
|
+
}
|
|
1842
|
+
// 1+4. CALL forms `String(x)` (→ toString) and `JSON.stringify(x)` (→ toJSON). These resolve to the
|
|
1843
|
+
// es-lib `StringConstructor`/`JSON.stringify` signature (not the user method), so the CallExpression
|
|
1844
|
+
// walk above never follows the coercion. Edge to the argument's LOCAL toString / toJSON.
|
|
1845
|
+
if (ts.isCallExpression(node) && owner && node.arguments?.[0]) {
|
|
1846
|
+
const callee = node.expression.getText().replace(/\s+/g, "");
|
|
1847
|
+
const arg0 = node.arguments[0];
|
|
1848
|
+
if (callee === "String" && ts.isIdentifier(node.expression) && mayCoerceObject(arg0))
|
|
1849
|
+
edgeToTargets(recOf(), coercionTargets(arg0, ["toString", "valueOf"], true));
|
|
1850
|
+
// `"" + x` is covered by the binary arm; `String(x)` is the explicit conversion form.
|
|
1851
|
+
else if (callee === "JSON.stringify")
|
|
1852
|
+
// toJSON is consulted regardless of operand shape (JSON.stringify checks for it on any value);
|
|
1853
|
+
// a plain object with no LOCAL toJSON resolves to nothing → pure (no fabrication). NO Symbol-
|
|
1854
|
+
// toPrimitive here — JSON.stringify uses toJSON only, not the primitive-coercion protocol.
|
|
1855
|
+
edgeToTargets(recOf(), coercionTargets(arg0, ["toJSON"], false));
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1711
1858
|
// TAGGED TEMPLATE (LOW): `` tag`…` `` calls `tag(strings, ...subs)`. getResolvedSignature resolves
|
|
1712
1859
|
// the TaggedTemplateExpression to the tag fn cleanly — a node form the CallExpression walk never
|
|
1713
1860
|
// visits. Edge to the tag when LOCAL; a built-in/external tag (`String.raw`) resolves non-local and
|