@kernlang/review 3.4.2 → 3.4.3-canary.11.1.8a8b6d69
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/dist/concept-rules/error-contract-drift.d.ts +36 -0
- package/dist/concept-rules/error-contract-drift.js +127 -0
- package/dist/concept-rules/error-contract-drift.js.map +1 -0
- package/dist/concept-rules/index.js +4 -0
- package/dist/concept-rules/index.js.map +1 -1
- package/dist/concept-rules/mixed-host-same-endpoint.d.ts +42 -0
- package/dist/concept-rules/mixed-host-same-endpoint.js +117 -0
- package/dist/concept-rules/mixed-host-same-endpoint.js.map +1 -0
- package/dist/mappers/ts-concepts.js +468 -36
- package/dist/mappers/ts-concepts.js.map +1 -1
- package/package.json +2 -2
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Phase 2: entrypoint, guard, state_mutation, call, dependency
|
|
6
6
|
*/
|
|
7
7
|
import { conceptId, conceptSpan } from '@kernlang/core';
|
|
8
|
-
import { SyntaxKind } from 'ts-morph';
|
|
8
|
+
import { SyntaxKind, VariableDeclarationKind } from 'ts-morph';
|
|
9
9
|
const EXTRACTOR_VERSION = '1.0.0';
|
|
10
10
|
// ── Network effect signatures ────────────────────────────────────────────
|
|
11
11
|
const NETWORK_CALLS = new Set(['fetch', 'axios', 'got', 'request', 'superagent', 'ky']);
|
|
@@ -282,6 +282,7 @@ function hasIntentComment(text) {
|
|
|
282
282
|
// ── effect ───────────────────────────────────────────────────────────────
|
|
283
283
|
function extractEffects(sf, filePath, nodes) {
|
|
284
284
|
const clientIdents = collectClientIdentifiers(sf);
|
|
285
|
+
const constLiterals = buildConstLiteralMap(sf);
|
|
285
286
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
286
287
|
const callee = call.getExpression();
|
|
287
288
|
let funcName = '';
|
|
@@ -300,8 +301,9 @@ function extractEffects(sf, filePath, nodes) {
|
|
|
300
301
|
const isWrappedClientCall = CLIENT_HTTP_METHODS.has(funcName) && clientIdents.has(objName);
|
|
301
302
|
if (isDirectNetwork || isKnownLibraryMethod || isWrappedClientCall) {
|
|
302
303
|
const isAsync = isInAsyncContext(call);
|
|
304
|
+
const target = extractTarget(call, constLiterals);
|
|
303
305
|
const sentFieldsInfo = extractSentFields(call, funcName);
|
|
304
|
-
const queryParamsInfo = extractQueryParams(call);
|
|
306
|
+
const queryParamsInfo = extractQueryParams(call, constLiterals);
|
|
305
307
|
const hasAuthHeader = extractHasAuthHeader(call, funcName);
|
|
306
308
|
nodes.push({
|
|
307
309
|
id: conceptId(filePath, 'effect', call.getStart()),
|
|
@@ -315,13 +317,16 @@ function extractEffects(sf, filePath, nodes) {
|
|
|
315
317
|
kind: 'effect',
|
|
316
318
|
subtype: 'network',
|
|
317
319
|
async: isAsync,
|
|
318
|
-
target
|
|
320
|
+
target,
|
|
321
|
+
host: extractHost(target),
|
|
322
|
+
handledErrorStatusCodes: extractHandledErrorStatusCodes(call),
|
|
319
323
|
responseAsserted: isResponseAsserted(call, isWrappedClientCall),
|
|
320
324
|
bodyKind: extractBodyKind(call, funcName),
|
|
321
325
|
method: extractHttpMethod(call, funcName, isDirectNetwork, isKnownLibraryMethod, isWrappedClientCall),
|
|
322
326
|
hasAuthHeader,
|
|
323
327
|
sentFields: sentFieldsInfo.fields,
|
|
324
328
|
sentFieldsResolved: sentFieldsInfo.resolved,
|
|
329
|
+
sentFieldTypes: sentFieldsInfo.types,
|
|
325
330
|
handlesApiErrors: extractHandlesApiErrors(call, isWrappedClientCall),
|
|
326
331
|
authPropagation: extractAuthPropagation(call, funcName, objName, isWrappedClientCall, hasAuthHeader),
|
|
327
332
|
queryParams: queryParamsInfo.params,
|
|
@@ -1494,16 +1499,10 @@ function isInAsyncContext(node) {
|
|
|
1494
1499
|
}
|
|
1495
1500
|
return false;
|
|
1496
1501
|
}
|
|
1497
|
-
// Extract the field names a network call sends on the wire. High-confidence
|
|
1498
|
-
// sources:
|
|
1499
|
-
// - literal objects: `JSON.stringify({ a, b })`, `axios.post(url, { a, b })`
|
|
1500
|
-
// - typed payload variables: `JSON.stringify(input)` where `input: CreateUser`
|
|
1501
|
-
// Everything else returns `{ fields: undefined, resolved: false }` so
|
|
1502
|
-
// body-shape-drift stays silent on opaque shapes rather than guessing.
|
|
1503
1502
|
function extractSentFields(call, funcName) {
|
|
1504
1503
|
const payload = extractNetworkPayloadExpression(call, funcName);
|
|
1505
1504
|
if (!payload)
|
|
1506
|
-
return { fields: undefined, resolved: false };
|
|
1505
|
+
return { fields: undefined, resolved: false, types: undefined };
|
|
1507
1506
|
return extractPayloadFields(payload);
|
|
1508
1507
|
}
|
|
1509
1508
|
function extractNetworkPayloadExpression(call, funcName) {
|
|
@@ -1532,10 +1531,11 @@ function extractPayloadFields(node) {
|
|
|
1532
1531
|
const payload = unwrapPayloadExpression(node);
|
|
1533
1532
|
if (payload.getKind() === SyntaxKind.CallExpression) {
|
|
1534
1533
|
const bodyCall = payload;
|
|
1535
|
-
if (bodyCall.getExpression().getText() !== 'JSON.stringify')
|
|
1536
|
-
return { fields: undefined, resolved: false };
|
|
1534
|
+
if (bodyCall.getExpression().getText() !== 'JSON.stringify') {
|
|
1535
|
+
return { fields: undefined, resolved: false, types: undefined };
|
|
1536
|
+
}
|
|
1537
1537
|
const stringifyArg = bodyCall.getArguments()[0];
|
|
1538
|
-
return stringifyArg ? extractPayloadFields(stringifyArg) : { fields: undefined, resolved: false };
|
|
1538
|
+
return stringifyArg ? extractPayloadFields(stringifyArg) : { fields: undefined, resolved: false, types: undefined };
|
|
1539
1539
|
}
|
|
1540
1540
|
if (payload.getKind() === SyntaxKind.ObjectLiteralExpression) {
|
|
1541
1541
|
return extractLiteralObjectFields(payload);
|
|
@@ -1543,7 +1543,7 @@ function extractPayloadFields(node) {
|
|
|
1543
1543
|
if (payload.getKind() === SyntaxKind.Identifier || payload.getKind() === SyntaxKind.PropertyAccessExpression) {
|
|
1544
1544
|
return extractObjectFieldsFromType(payload);
|
|
1545
1545
|
}
|
|
1546
|
-
return { fields: undefined, resolved: false };
|
|
1546
|
+
return { fields: undefined, resolved: false, types: undefined };
|
|
1547
1547
|
}
|
|
1548
1548
|
function unwrapPayloadExpression(node) {
|
|
1549
1549
|
let current = node;
|
|
@@ -1571,26 +1571,91 @@ function unwrapPayloadExpression(node) {
|
|
|
1571
1571
|
}
|
|
1572
1572
|
function extractObjectFieldsFromType(node) {
|
|
1573
1573
|
const type = node.getType();
|
|
1574
|
-
if (type.isAny() || type.isUnknown() || type.isUnion())
|
|
1575
|
-
return { fields: undefined, resolved: false };
|
|
1576
|
-
|
|
1577
|
-
|
|
1574
|
+
if (type.isAny() || type.isUnknown() || type.isUnion()) {
|
|
1575
|
+
return { fields: undefined, resolved: false, types: undefined };
|
|
1576
|
+
}
|
|
1577
|
+
if (type.getStringIndexType() || type.getNumberIndexType()) {
|
|
1578
|
+
return { fields: undefined, resolved: false, types: undefined };
|
|
1579
|
+
}
|
|
1578
1580
|
const fields = new Set();
|
|
1581
|
+
const types = {};
|
|
1579
1582
|
for (const prop of type.getProperties()) {
|
|
1580
1583
|
const declarations = prop.getDeclarations();
|
|
1581
1584
|
if (declarations.length === 0)
|
|
1582
|
-
return { fields: undefined, resolved: false };
|
|
1585
|
+
return { fields: undefined, resolved: false, types: undefined };
|
|
1583
1586
|
if (declarations.some((decl) => isExternalSourcePath(decl.getSourceFile().getFilePath()))) {
|
|
1584
|
-
return { fields: undefined, resolved: false };
|
|
1587
|
+
return { fields: undefined, resolved: false, types: undefined };
|
|
1585
1588
|
}
|
|
1586
1589
|
if (declarations.some((decl) => declarationIsOptional(decl)))
|
|
1587
1590
|
continue;
|
|
1588
1591
|
const name = prop.getName();
|
|
1589
1592
|
if (!/^[A-Za-z_$][\w$-]*$/.test(name))
|
|
1590
|
-
return { fields: undefined, resolved: false };
|
|
1593
|
+
return { fields: undefined, resolved: false, types: undefined };
|
|
1591
1594
|
fields.add(name);
|
|
1595
|
+
// Coarse type tag from the property's TS type at the use site.
|
|
1596
|
+
const propType = prop.getTypeAtLocation(node);
|
|
1597
|
+
types[name] = coarsenTsType(propType);
|
|
1598
|
+
}
|
|
1599
|
+
if (fields.size === 0) {
|
|
1600
|
+
return { fields: [], resolved: true, types: {} };
|
|
1601
|
+
}
|
|
1602
|
+
return { fields: Array.from(fields).sort(), resolved: true, types };
|
|
1603
|
+
}
|
|
1604
|
+
// Map a ts-morph Type to a coarse tag. Conservative: anything we don't
|
|
1605
|
+
// recognise becomes 'unknown' rather than silently dropped — body-shape-drift
|
|
1606
|
+
// can then choose to skip 'unknown' tags or treat them as wildcards.
|
|
1607
|
+
function coarsenTsType(type) {
|
|
1608
|
+
if (type.isAny() || type.isUnknown() || type.isNever())
|
|
1609
|
+
return 'unknown';
|
|
1610
|
+
if (type.isString() || type.isStringLiteral())
|
|
1611
|
+
return 'string';
|
|
1612
|
+
if (type.isNumber() || type.isNumberLiteral())
|
|
1613
|
+
return 'number';
|
|
1614
|
+
if (type.isBoolean() || type.isBooleanLiteral())
|
|
1615
|
+
return 'boolean';
|
|
1616
|
+
if (type.isNull())
|
|
1617
|
+
return 'null';
|
|
1618
|
+
// Bare `undefined` is NOT 'null' on the wire — `JSON.stringify({x: undefined})`
|
|
1619
|
+
// omits the key entirely. We tag it `unknown` so downstream rules don't
|
|
1620
|
+
// confuse it with an explicit-null field. Codex review caught this.
|
|
1621
|
+
if (type.isUndefined())
|
|
1622
|
+
return 'unknown';
|
|
1623
|
+
if (type.isArray() || type.isTuple())
|
|
1624
|
+
return 'array';
|
|
1625
|
+
if (type.isUnion()) {
|
|
1626
|
+
// Drop nullability branches (`T | null | undefined → T`) before
|
|
1627
|
+
// coarsening — `null`/`undefined` are absorbed since the rule's question
|
|
1628
|
+
// is "what is the shape of the present value?" not "is the field
|
|
1629
|
+
// optional?". Then if the remaining branches all coarsen to one tag,
|
|
1630
|
+
// return it; else `unknown`.
|
|
1631
|
+
const branches = type.getUnionTypes().filter((t) => !t.isNull() && !t.isUndefined());
|
|
1632
|
+
if (branches.length === 0)
|
|
1633
|
+
return 'null';
|
|
1634
|
+
if (branches.length === 1)
|
|
1635
|
+
return coarsenTsType(branches[0]);
|
|
1636
|
+
const tags = new Set(branches.map(coarsenTsType));
|
|
1637
|
+
if (tags.size === 1)
|
|
1638
|
+
return [...tags][0];
|
|
1639
|
+
return 'unknown';
|
|
1640
|
+
}
|
|
1641
|
+
// Branded primitives (`string & { __brand: 'UserId' }`) are very common
|
|
1642
|
+
// for ID/count types. We walk the intersection branches: if any branch
|
|
1643
|
+
// coarsens to a primitive, take that tag — the brand is structural noise
|
|
1644
|
+
// we don't need on the wire. Codex review caught the original
|
|
1645
|
+
// implementation collapsing brands to 'unknown'.
|
|
1646
|
+
if (type.isIntersection()) {
|
|
1647
|
+
const branchTags = type.getIntersectionTypes().map(coarsenTsType);
|
|
1648
|
+
for (const tag of branchTags) {
|
|
1649
|
+
if (tag === 'string' || tag === 'number' || tag === 'boolean')
|
|
1650
|
+
return tag;
|
|
1651
|
+
}
|
|
1652
|
+
if (branchTags.every((t) => t === 'object'))
|
|
1653
|
+
return 'object';
|
|
1654
|
+
return 'unknown';
|
|
1592
1655
|
}
|
|
1593
|
-
|
|
1656
|
+
if (type.isObject())
|
|
1657
|
+
return 'object';
|
|
1658
|
+
return 'unknown';
|
|
1594
1659
|
}
|
|
1595
1660
|
function declarationIsOptional(decl) {
|
|
1596
1661
|
const maybeOptional = decl;
|
|
@@ -1602,33 +1667,282 @@ function declarationIsOptional(decl) {
|
|
|
1602
1667
|
// produce false positives downstream.
|
|
1603
1668
|
function extractLiteralObjectFields(obj) {
|
|
1604
1669
|
const fields = [];
|
|
1670
|
+
const types = {};
|
|
1605
1671
|
for (const prop of obj.getProperties()) {
|
|
1606
1672
|
const kind = prop.getKind();
|
|
1607
1673
|
if (kind === SyntaxKind.SpreadAssignment)
|
|
1608
|
-
return { fields: undefined, resolved: false };
|
|
1674
|
+
return { fields: undefined, resolved: false, types: undefined };
|
|
1609
1675
|
if (kind === SyntaxKind.PropertyAssignment) {
|
|
1610
|
-
const
|
|
1611
|
-
|
|
1612
|
-
|
|
1676
|
+
const pa = prop;
|
|
1677
|
+
const name = pa.getNameNode();
|
|
1678
|
+
if (name.getKind() === SyntaxKind.ComputedPropertyName) {
|
|
1679
|
+
return { fields: undefined, resolved: false, types: undefined };
|
|
1680
|
+
}
|
|
1613
1681
|
if (name.getKind() === SyntaxKind.Identifier || name.getKind() === SyntaxKind.StringLiteral) {
|
|
1614
|
-
|
|
1682
|
+
const fieldName = name.getText().replace(/['"]/g, '');
|
|
1683
|
+
fields.push(fieldName);
|
|
1684
|
+
// Type tag from the value expression. Tries syntactic recognition
|
|
1685
|
+
// first (cheap, exact for literals), then falls back to TS type
|
|
1686
|
+
// inference for variables / property accesses.
|
|
1687
|
+
const init = pa.getInitializer();
|
|
1688
|
+
types[fieldName] = init ? coarsenValueExpression(init) : 'unknown';
|
|
1615
1689
|
}
|
|
1616
1690
|
else {
|
|
1617
|
-
return { fields: undefined, resolved: false };
|
|
1691
|
+
return { fields: undefined, resolved: false, types: undefined };
|
|
1618
1692
|
}
|
|
1619
1693
|
}
|
|
1620
1694
|
else if (kind === SyntaxKind.ShorthandPropertyAssignment) {
|
|
1621
|
-
|
|
1695
|
+
const sh = prop;
|
|
1696
|
+
const fieldName = sh.getName();
|
|
1697
|
+
fields.push(fieldName);
|
|
1698
|
+
// Shorthand: `{ foo }` is sugar for `{ foo: foo }`. Use the binding's
|
|
1699
|
+
// inferred TS type at this location.
|
|
1700
|
+
const ident = sh.getNameNode();
|
|
1701
|
+
types[fieldName] = coarsenTsType(ident.getType());
|
|
1622
1702
|
}
|
|
1623
1703
|
else {
|
|
1624
1704
|
// Method definitions, getters, setters — unusual in a fetch body,
|
|
1625
1705
|
// treat as unresolved.
|
|
1626
|
-
return { fields: undefined, resolved: false };
|
|
1706
|
+
return { fields: undefined, resolved: false, types: undefined };
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
return { fields, resolved: true, types };
|
|
1710
|
+
}
|
|
1711
|
+
// Coarsen the value-expression of a literal object property. Syntactic
|
|
1712
|
+
// fast paths cover the common literal cases without invoking the TS type
|
|
1713
|
+
// checker; everything else falls through to type-based coarsening.
|
|
1714
|
+
function coarsenValueExpression(expr) {
|
|
1715
|
+
const k = expr.getKind();
|
|
1716
|
+
if (k === SyntaxKind.StringLiteral ||
|
|
1717
|
+
k === SyntaxKind.NoSubstitutionTemplateLiteral ||
|
|
1718
|
+
k === SyntaxKind.TemplateExpression) {
|
|
1719
|
+
return 'string';
|
|
1720
|
+
}
|
|
1721
|
+
if (k === SyntaxKind.NumericLiteral)
|
|
1722
|
+
return 'number';
|
|
1723
|
+
if (k === SyntaxKind.TrueKeyword || k === SyntaxKind.FalseKeyword)
|
|
1724
|
+
return 'boolean';
|
|
1725
|
+
if (k === SyntaxKind.NullKeyword)
|
|
1726
|
+
return 'null';
|
|
1727
|
+
if (k === SyntaxKind.ObjectLiteralExpression)
|
|
1728
|
+
return 'object';
|
|
1729
|
+
if (k === SyntaxKind.ArrayLiteralExpression)
|
|
1730
|
+
return 'array';
|
|
1731
|
+
// PrefixUnary: the unary operator dictates the result type, NOT the
|
|
1732
|
+
// operand. `+x`, `-x`, `~x` always yield number even if x is a string
|
|
1733
|
+
// (`+'1'` is type `number`); `!x` always yields boolean. Recursing into
|
|
1734
|
+
// the operand would lie. Codex review caught this.
|
|
1735
|
+
if (k === SyntaxKind.PrefixUnaryExpression) {
|
|
1736
|
+
const op = expr.getOperatorToken();
|
|
1737
|
+
if (op === SyntaxKind.PlusToken || op === SyntaxKind.MinusToken || op === SyntaxKind.TildeToken) {
|
|
1738
|
+
return 'number';
|
|
1739
|
+
}
|
|
1740
|
+
if (op === SyntaxKind.ExclamationToken) {
|
|
1741
|
+
return 'boolean';
|
|
1742
|
+
}
|
|
1743
|
+
// Unknown prefix operator — fall through to TS type checker.
|
|
1744
|
+
return coarsenTsType(expr.getType());
|
|
1745
|
+
}
|
|
1746
|
+
// Parenthesized / as-cast / non-null: unwrap and recurse.
|
|
1747
|
+
if (k === SyntaxKind.ParenthesizedExpression) {
|
|
1748
|
+
return coarsenValueExpression(expr.getExpression());
|
|
1749
|
+
}
|
|
1750
|
+
if (k === SyntaxKind.AsExpression) {
|
|
1751
|
+
return coarsenValueExpression(expr.getExpression());
|
|
1752
|
+
}
|
|
1753
|
+
if (k === SyntaxKind.NonNullExpression) {
|
|
1754
|
+
return coarsenValueExpression(expr.getExpression());
|
|
1755
|
+
}
|
|
1756
|
+
// Fallback: ask the TS type checker. Catches `Identifier`,
|
|
1757
|
+
// `PropertyAccessExpression`, `ConditionalExpression` (`x ?? null`),
|
|
1758
|
+
// `BinaryExpression`, etc.
|
|
1759
|
+
return coarsenTsType(expr.getType());
|
|
1760
|
+
}
|
|
1761
|
+
function declKey(decl) {
|
|
1762
|
+
return `${decl.getSourceFile().getFilePath()}:${decl.getStart()}`;
|
|
1763
|
+
}
|
|
1764
|
+
// Extract a literal string from a const initializer. Handles:
|
|
1765
|
+
// const X = 'lit';
|
|
1766
|
+
// const X = `lit`; (no substitutions)
|
|
1767
|
+
// const X = process.env.FOO || 'fallback'; (env-with-fallback)
|
|
1768
|
+
// const X = process.env.FOO ?? 'fallback'; (nullish-coalescing)
|
|
1769
|
+
//
|
|
1770
|
+
// The `||` / `??` fallback case is the dominant real-world shape — audiofacets
|
|
1771
|
+
// has 10+ `const API_URL = process.env.X || "https://..."` sites that the
|
|
1772
|
+
// pure-literal collector silently dropped. Resolution captures the FALLBACK
|
|
1773
|
+
// branch as the "no-env-set" value. At runtime when env IS set the URL
|
|
1774
|
+
// differs, but for cross-stack matching the path part is identical and
|
|
1775
|
+
// `host` is "best effort, stay silent if not absolute" anyway.
|
|
1776
|
+
//
|
|
1777
|
+
// Conservative: only the right-hand side of `||`/`??` is taken as the literal.
|
|
1778
|
+
// Chained `a || b || 'lit'` bails (right is BinaryExpression, not literal).
|
|
1779
|
+
// Ternaries and other shapes stay out of scope.
|
|
1780
|
+
function literalStringFromInit(init) {
|
|
1781
|
+
if (init.getKind() === SyntaxKind.StringLiteral) {
|
|
1782
|
+
return init.getLiteralValue();
|
|
1783
|
+
}
|
|
1784
|
+
if (init.getKind() === SyntaxKind.NoSubstitutionTemplateLiteral) {
|
|
1785
|
+
return init.getLiteralValue();
|
|
1786
|
+
}
|
|
1787
|
+
if (init.getKind() === SyntaxKind.BinaryExpression) {
|
|
1788
|
+
const bin = init;
|
|
1789
|
+
const op = bin.getOperatorToken().getKind();
|
|
1790
|
+
if (op === SyntaxKind.BarBarToken || op === SyntaxKind.QuestionQuestionToken) {
|
|
1791
|
+
const right = bin.getRight();
|
|
1792
|
+
if (right.getKind() === SyntaxKind.StringLiteral) {
|
|
1793
|
+
return right.getLiteralValue();
|
|
1794
|
+
}
|
|
1795
|
+
if (right.getKind() === SyntaxKind.NoSubstitutionTemplateLiteral) {
|
|
1796
|
+
return right.getLiteralValue();
|
|
1797
|
+
}
|
|
1627
1798
|
}
|
|
1628
1799
|
}
|
|
1629
|
-
return
|
|
1800
|
+
return undefined;
|
|
1801
|
+
}
|
|
1802
|
+
function buildConstLiteralMap(sf) {
|
|
1803
|
+
const candidates = [];
|
|
1804
|
+
for (const decl of sf.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
1805
|
+
const stmt = decl.getVariableStatement();
|
|
1806
|
+
if (!stmt)
|
|
1807
|
+
continue;
|
|
1808
|
+
if (stmt.getDeclarationKind() !== VariableDeclarationKind.Const)
|
|
1809
|
+
continue;
|
|
1810
|
+
if (decl.getNameNode().getKind() !== SyntaxKind.Identifier)
|
|
1811
|
+
continue; // skip destructure
|
|
1812
|
+
const init = decl.getInitializer();
|
|
1813
|
+
if (!init)
|
|
1814
|
+
continue;
|
|
1815
|
+
candidates.push({ decl, key: declKey(decl), init });
|
|
1816
|
+
}
|
|
1817
|
+
const direct = new Map();
|
|
1818
|
+
for (const { key, init } of candidates) {
|
|
1819
|
+
const lit = literalStringFromInit(init);
|
|
1820
|
+
if (lit !== undefined)
|
|
1821
|
+
direct.set(key, lit);
|
|
1822
|
+
}
|
|
1823
|
+
// Pass 2: template-with-refs against pass 1. One hop — no transitive
|
|
1824
|
+
// resolution. A `const A = \`\${B}/x\`` whose `B` is itself a
|
|
1825
|
+
// template-with-refs won't resolve here; that's a phase-1 cap.
|
|
1826
|
+
const all = new Map(direct);
|
|
1827
|
+
for (const { key, init } of candidates) {
|
|
1828
|
+
if (init.getKind() !== SyntaxKind.TemplateExpression)
|
|
1829
|
+
continue;
|
|
1830
|
+
const lit = resolveTemplateAgainst(init, direct);
|
|
1831
|
+
if (lit !== undefined)
|
|
1832
|
+
all.set(key, lit);
|
|
1833
|
+
}
|
|
1834
|
+
return all;
|
|
1630
1835
|
}
|
|
1631
|
-
function
|
|
1836
|
+
function resolveTemplateAgainst(tmpl, map) {
|
|
1837
|
+
let out = tmpl.getHead().getLiteralText();
|
|
1838
|
+
for (const span of tmpl.getTemplateSpans()) {
|
|
1839
|
+
const expr = span.getExpression();
|
|
1840
|
+
if (expr.getKind() !== SyntaxKind.Identifier)
|
|
1841
|
+
return undefined;
|
|
1842
|
+
const inner = lookupConstLiteral(expr, map);
|
|
1843
|
+
if (inner === undefined)
|
|
1844
|
+
return undefined;
|
|
1845
|
+
out += inner;
|
|
1846
|
+
out += span.getLiteral().getLiteralText();
|
|
1847
|
+
}
|
|
1848
|
+
return out;
|
|
1849
|
+
}
|
|
1850
|
+
// Symbol-based lookup. Resolves the identifier to its binding via the TS
|
|
1851
|
+
// type checker, then checks whether that binding is a literal const we
|
|
1852
|
+
// collected. Returns `undefined` for any identifier that does NOT bind to
|
|
1853
|
+
// a single same-file VariableDeclaration in the map — including parameters,
|
|
1854
|
+
// `let` / `var`, function names, and shadowing declarations. (Phase 2A
|
|
1855
|
+
// extends this to follow named imports from relative paths to a sibling
|
|
1856
|
+
// source file.)
|
|
1857
|
+
//
|
|
1858
|
+
// Same-file filter on the in-file declarations: TypeScript's symbol for a
|
|
1859
|
+
// name that shadows a global (e.g. `URL`, `Request`, `Response`) returns
|
|
1860
|
+
// BOTH the local declaration AND the lib.dom.d.ts ambient. We only care
|
|
1861
|
+
// about the in-file declaration; the lib ambient never has a literal value.
|
|
1862
|
+
function lookupConstLiteral(ident, map) {
|
|
1863
|
+
const sym = ident.getSymbol();
|
|
1864
|
+
if (!sym)
|
|
1865
|
+
return undefined;
|
|
1866
|
+
const sfPath = ident.getSourceFile().getFilePath();
|
|
1867
|
+
const inFile = [];
|
|
1868
|
+
const importSpecs = [];
|
|
1869
|
+
for (const d of sym.getDeclarations()) {
|
|
1870
|
+
if (d.getSourceFile().getFilePath() !== sfPath)
|
|
1871
|
+
continue;
|
|
1872
|
+
const k = d.getKind();
|
|
1873
|
+
if (k === SyntaxKind.VariableDeclaration) {
|
|
1874
|
+
inFile.push(d);
|
|
1875
|
+
}
|
|
1876
|
+
else if (k === SyntaxKind.ImportSpecifier) {
|
|
1877
|
+
importSpecs.push(d);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
// Same-file binding wins (phase 1 path).
|
|
1881
|
+
if (inFile.length === 1 && importSpecs.length === 0) {
|
|
1882
|
+
return map.get(declKey(inFile[0]));
|
|
1883
|
+
}
|
|
1884
|
+
// Phase 2A: cross-file named import. Single ImportSpecifier in this file,
|
|
1885
|
+
// no in-file VariableDeclaration. Follow the import to its source.
|
|
1886
|
+
if (importSpecs.length === 1 && inFile.length === 0) {
|
|
1887
|
+
return resolveImportedConst(importSpecs[0]);
|
|
1888
|
+
}
|
|
1889
|
+
return undefined;
|
|
1890
|
+
}
|
|
1891
|
+
// Phase 2A: follow `import { X } from './c'` to the source file and resolve
|
|
1892
|
+
// the imported const. Same FP gates as phase 1: the source decl must be a
|
|
1893
|
+
// single exported `const X = <literal>` (or env-fallback / no-sub template
|
|
1894
|
+
// or template-with-refs against the source file's own const map).
|
|
1895
|
+
//
|
|
1896
|
+
// Out of scope (phase 2A):
|
|
1897
|
+
// - Default imports (`import X from './c'`)
|
|
1898
|
+
// - Namespace imports (`import * as M from './c'; M.X`)
|
|
1899
|
+
// - Non-relative module specifiers (`react`, `@/lib/x`, `@shared/y`)
|
|
1900
|
+
// - Re-export chains beyond what ts-morph's symbol resolver already follows
|
|
1901
|
+
function resolveImportedConst(spec) {
|
|
1902
|
+
const imp = spec.getImportDeclaration();
|
|
1903
|
+
const moduleSpec = imp.getModuleSpecifierValue();
|
|
1904
|
+
if (!moduleSpec)
|
|
1905
|
+
return undefined;
|
|
1906
|
+
if (!moduleSpec.startsWith('./') && !moduleSpec.startsWith('../'))
|
|
1907
|
+
return undefined;
|
|
1908
|
+
const srcSf = imp.getModuleSpecifierSourceFile();
|
|
1909
|
+
if (!srcSf)
|
|
1910
|
+
return undefined;
|
|
1911
|
+
// The imported name as declared in the source module. `import { X as Y }`
|
|
1912
|
+
// means we look for `X` in the source, regardless of `Y` alias here.
|
|
1913
|
+
const importedName = spec.getNameNode().getText();
|
|
1914
|
+
for (const decl of srcSf.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
1915
|
+
if (decl.getNameNode().getKind() !== SyntaxKind.Identifier)
|
|
1916
|
+
continue;
|
|
1917
|
+
if (decl.getName() !== importedName)
|
|
1918
|
+
continue;
|
|
1919
|
+
const stmt = decl.getVariableStatement();
|
|
1920
|
+
if (!stmt)
|
|
1921
|
+
continue;
|
|
1922
|
+
if (stmt.getDeclarationKind() !== VariableDeclarationKind.Const)
|
|
1923
|
+
continue;
|
|
1924
|
+
if (!stmt.isExported())
|
|
1925
|
+
continue;
|
|
1926
|
+
const init = decl.getInitializer();
|
|
1927
|
+
if (!init)
|
|
1928
|
+
return undefined;
|
|
1929
|
+
// Direct literal / no-sub template / env-fallback.
|
|
1930
|
+
const direct = literalStringFromInit(init);
|
|
1931
|
+
if (direct !== undefined)
|
|
1932
|
+
return direct;
|
|
1933
|
+
// Template-with-refs in the SOURCE file's scope. Build that file's own
|
|
1934
|
+
// const map and resolve the template against it. resolveTemplateAgainst
|
|
1935
|
+
// recurses through lookupConstLiteral, so chained imports
|
|
1936
|
+
// (`c.ts → d.ts`) work via the same gate.
|
|
1937
|
+
if (init.getKind() === SyntaxKind.TemplateExpression) {
|
|
1938
|
+
const srcMap = buildConstLiteralMap(srcSf);
|
|
1939
|
+
return resolveTemplateAgainst(init, srcMap);
|
|
1940
|
+
}
|
|
1941
|
+
return undefined;
|
|
1942
|
+
}
|
|
1943
|
+
return undefined;
|
|
1944
|
+
}
|
|
1945
|
+
function extractTarget(call, consts) {
|
|
1632
1946
|
const args = call.getArguments();
|
|
1633
1947
|
if (args.length === 0)
|
|
1634
1948
|
return undefined;
|
|
@@ -1640,12 +1954,116 @@ function extractTarget(call) {
|
|
|
1640
1954
|
return first.getLiteralValue();
|
|
1641
1955
|
}
|
|
1642
1956
|
if (first.getKind() === SyntaxKind.TemplateExpression) {
|
|
1643
|
-
return extractTemplateUrl(first);
|
|
1957
|
+
return extractTemplateUrl(first, consts);
|
|
1958
|
+
}
|
|
1959
|
+
if (first.getKind() === SyntaxKind.Identifier && consts) {
|
|
1960
|
+
return lookupConstLiteral(first, consts);
|
|
1644
1961
|
}
|
|
1645
1962
|
return undefined;
|
|
1646
1963
|
}
|
|
1647
|
-
|
|
1648
|
-
|
|
1964
|
+
// Phase 1 of surface-fingerprinting: pull the host out of an absolute URL so
|
|
1965
|
+
// downstream cross-stack rules can later filter third-party calls (e.g. a
|
|
1966
|
+
// frontend `fetch` to `stripe.com/api/charges` shouldn't match against the
|
|
1967
|
+
// partner backend's `/api/charges` route). Phase 2 will use this — phase 1
|
|
1968
|
+
// only captures it.
|
|
1969
|
+
//
|
|
1970
|
+
// Accepts the already-extracted target string (lit / no-substitution / template
|
|
1971
|
+
// post-extraction). Rejects targets where the host slot is a placeholder like
|
|
1972
|
+
// `:HOST` produced by `extractTemplateUrl` from `https://${HOST}/...` — those
|
|
1973
|
+
// aren't real DNS names. Returns the host lower-cased so case-insensitive
|
|
1974
|
+
// comparison is free downstream.
|
|
1975
|
+
const HOST_LIKE_RE = /^[a-z0-9][a-z0-9.-]*(:[0-9]+)?$/i;
|
|
1976
|
+
function extractHost(target) {
|
|
1977
|
+
if (!target)
|
|
1978
|
+
return undefined;
|
|
1979
|
+
if (!target.startsWith('http://') && !target.startsWith('https://'))
|
|
1980
|
+
return undefined;
|
|
1981
|
+
const hostStart = target.indexOf('://') + 3;
|
|
1982
|
+
const pathStart = target.indexOf('/', hostStart);
|
|
1983
|
+
const host = pathStart === -1 ? target.slice(hostStart) : target.slice(hostStart, pathStart);
|
|
1984
|
+
if (!HOST_LIKE_RE.test(host))
|
|
1985
|
+
return undefined;
|
|
1986
|
+
return host.toLowerCase();
|
|
1987
|
+
}
|
|
1988
|
+
// Phase 1 of error-contract-drift: pull the literal status codes the
|
|
1989
|
+
// call-site EXPLICITLY branches on. Phase 2 will compare these against
|
|
1990
|
+
// the server's `errorStatusCodes` to flag PRs that introduce a server
|
|
1991
|
+
// status the client doesn't handle.
|
|
1992
|
+
//
|
|
1993
|
+
// Buddies' converged 0.9 gate: ONLY explicit status-dispatch counts.
|
|
1994
|
+
// Generic `catch (e) { log(e) }` and `if (!response.ok) {…}` checks do
|
|
1995
|
+
// not — those treat every failure identically and are not contract
|
|
1996
|
+
// evidence. We walk a bounded distance from the network call (the
|
|
1997
|
+
// enclosing function), look for `<X>.status === N` and `case N:` in
|
|
1998
|
+
// switches over `<X>.status`, where `<X>` is any expression — keeping
|
|
1999
|
+
// it permissive on the receiver lets us catch the common patterns
|
|
2000
|
+
// (`response.status`, `err.status`, `err.response.status`,
|
|
2001
|
+
// `result.status`) without trying to bind the response variable back
|
|
2002
|
+
// to this specific call.
|
|
2003
|
+
//
|
|
2004
|
+
// Returns:
|
|
2005
|
+
// - empty array — saw the call, walked the scope, found no explicit
|
|
2006
|
+
// status branch (the call-site is generic-handle-only).
|
|
2007
|
+
// - non-empty sorted array — explicit codes the call-site branches on.
|
|
2008
|
+
// - undefined — analysis was inconclusive (no enclosing function /
|
|
2009
|
+
// scope walk wasn't safe).
|
|
2010
|
+
const STATUS_PROP_RE = /(?:^|\.)status$/;
|
|
2011
|
+
const STATUS_LIKELY_RECEIVER_RE = /\b(res|response|reply|err|error|e|ex|result|r)\b/i;
|
|
2012
|
+
function extractHandledErrorStatusCodes(call) {
|
|
2013
|
+
const enclosing = call.getFirstAncestorByKind(SyntaxKind.FunctionDeclaration) ??
|
|
2014
|
+
call.getFirstAncestorByKind(SyntaxKind.ArrowFunction) ??
|
|
2015
|
+
call.getFirstAncestorByKind(SyntaxKind.FunctionExpression) ??
|
|
2016
|
+
call.getFirstAncestorByKind(SyntaxKind.MethodDeclaration);
|
|
2017
|
+
if (!enclosing)
|
|
2018
|
+
return undefined;
|
|
2019
|
+
const codes = new Set();
|
|
2020
|
+
// Pattern 1: binary `===` / `==` of a `<X>.status` against a numeric literal.
|
|
2021
|
+
for (const bin of enclosing.getDescendantsOfKind(SyntaxKind.BinaryExpression)) {
|
|
2022
|
+
const op = bin.getOperatorToken().getText();
|
|
2023
|
+
if (op !== '===' && op !== '==')
|
|
2024
|
+
continue;
|
|
2025
|
+
const left = bin.getLeft();
|
|
2026
|
+
const right = bin.getRight();
|
|
2027
|
+
const code = numericLiteralValue(left) ?? numericLiteralValue(right);
|
|
2028
|
+
if (code === undefined)
|
|
2029
|
+
continue;
|
|
2030
|
+
const other = code === numericLiteralValue(left) ? right : left;
|
|
2031
|
+
if (!isStatusPropertyAccess(other))
|
|
2032
|
+
continue;
|
|
2033
|
+
codes.add(code);
|
|
2034
|
+
}
|
|
2035
|
+
// Pattern 2: `case N:` inside a `switch (<X>.status) {…}`.
|
|
2036
|
+
for (const sw of enclosing.getDescendantsOfKind(SyntaxKind.SwitchStatement)) {
|
|
2037
|
+
const expr = sw.getExpression();
|
|
2038
|
+
if (!isStatusPropertyAccess(expr))
|
|
2039
|
+
continue;
|
|
2040
|
+
for (const caseClause of sw.getDescendantsOfKind(SyntaxKind.CaseClause)) {
|
|
2041
|
+
const code = numericLiteralValue(caseClause.getExpression());
|
|
2042
|
+
if (code !== undefined)
|
|
2043
|
+
codes.add(code);
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
return Array.from(codes).sort((a, b) => a - b);
|
|
2047
|
+
}
|
|
2048
|
+
// Recogniser for `<expr>.status` shape. The `<expr>` is intentionally
|
|
2049
|
+
// loose — we accept any property access whose final property name is
|
|
2050
|
+
// `status` AND whose receiver text mentions a known response/error
|
|
2051
|
+
// identifier somewhere. That accepts `response.status`, `err.status`,
|
|
2052
|
+
// `err.response.status`, `result.status`, `e.response?.status`, …
|
|
2053
|
+
// without binding to the specific response variable from THIS call.
|
|
2054
|
+
// False-positive tax: a stray `obj.status === 200` for some unrelated
|
|
2055
|
+
// object would count, but the `STATUS_LIKELY_RECEIVER_RE` filter on
|
|
2056
|
+
// the receiver text keeps it tight.
|
|
2057
|
+
function isStatusPropertyAccess(node) {
|
|
2058
|
+
if (!node)
|
|
2059
|
+
return false;
|
|
2060
|
+
const text = node.getText();
|
|
2061
|
+
if (!STATUS_PROP_RE.test(text.replace(/\?\./g, '.')))
|
|
2062
|
+
return false;
|
|
2063
|
+
return STATUS_LIKELY_RECEIVER_RE.test(text);
|
|
2064
|
+
}
|
|
2065
|
+
function extractQueryParams(call, consts) {
|
|
2066
|
+
const target = extractTarget(call, consts);
|
|
1649
2067
|
if (!target)
|
|
1650
2068
|
return { params: undefined, resolved: false };
|
|
1651
2069
|
const q = target.indexOf('?');
|
|
@@ -1672,7 +2090,7 @@ function extractQueryParams(call) {
|
|
|
1672
2090
|
// correlate it against backend routes. Without this every `fetch(` \`${BASE}/…\` `)`
|
|
1673
2091
|
// call is silently dropped by `normalizeClientUrl` (which rejects targets that
|
|
1674
2092
|
// start with \`$ instead of \`/).
|
|
1675
|
-
function extractTemplateUrl(tmpl) {
|
|
2093
|
+
function extractTemplateUrl(tmpl, consts) {
|
|
1676
2094
|
const head = tmpl.getHead();
|
|
1677
2095
|
let out = head.getLiteralText();
|
|
1678
2096
|
const spans = tmpl.getTemplateSpans();
|
|
@@ -1681,6 +2099,20 @@ function extractTemplateUrl(tmpl) {
|
|
|
1681
2099
|
const expr = span.getExpression();
|
|
1682
2100
|
const exprText = expr.getText();
|
|
1683
2101
|
const isBareIdent = expr.getKind() === SyntaxKind.Identifier;
|
|
2102
|
+
// Phase 1 const resolution: substitute `${IDENT}` with its same-file literal
|
|
2103
|
+
// value when the symbol-resolved binding is a literal const we collected.
|
|
2104
|
+
// Lifts host extraction and route matching on the common
|
|
2105
|
+
// `${API_BASE}/users/${id}` shape. Symbol-based (not name-based) so that
|
|
2106
|
+
// a shadowing parameter / `let` / inner non-literal const cannot
|
|
2107
|
+
// fabricate a wrong absolute URL.
|
|
2108
|
+
if (isBareIdent && consts) {
|
|
2109
|
+
const resolved = lookupConstLiteral(expr, consts);
|
|
2110
|
+
if (resolved !== undefined) {
|
|
2111
|
+
out += resolved;
|
|
2112
|
+
out += span.getLiteral().getLiteralText();
|
|
2113
|
+
continue;
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
1684
2116
|
if (i === 0 && out === '' && isBareIdent && looksLikeBaseUrlName(exprText)) {
|
|
1685
2117
|
// Drop a leading `${BASE_URL}` interpolation so the path starts with `/`.
|
|
1686
2118
|
}
|