@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.
@@ -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: extractTarget(call),
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
- if (type.getStringIndexType() || type.getNumberIndexType())
1577
- return { fields: undefined, resolved: false };
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
- return fields.size > 0 ? { fields: Array.from(fields).sort(), resolved: true } : { fields: [], resolved: true };
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 name = prop.getNameNode();
1611
- if (name.getKind() === SyntaxKind.ComputedPropertyName)
1612
- return { fields: undefined, resolved: false };
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
- fields.push(name.getText().replace(/['"]/g, ''));
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
- fields.push(prop.getName());
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 { fields, resolved: true };
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 extractTarget(call) {
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
- function extractQueryParams(call) {
1648
- const target = extractTarget(call);
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
  }