carrick 0.3.111 → 0.3.112

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.
@@ -257,6 +257,83 @@ function typeText(type, enclosingNode) {
257
257
  * `text` is `string`, alone or beside `null` and `undefined` in either order:
258
258
  * the only payload a raw-text read publishes (carrick#1842).
259
259
  */
260
+ /**
261
+ * Source text with every plain string literal written in double quotes:
262
+ * `'/a'`, `"/a"` and an interpolation-free `` `/a` `` are one value, so text
263
+ * that differs only in its quotes compares equal (carrick#2056).
264
+ *
265
+ * The text is read left to right, pairing quotes the way the scanner does,
266
+ * so a template's closing backtick never pairs with the next template's
267
+ * opening one. A literal holding a quote, a backslash or an interpolation is
268
+ * copied as it is, and so is an unterminated one with everything after it.
269
+ */
270
+ export function canonicalStringQuotes(text) {
271
+ const isQuote = (ch) => ch === "'" || ch === '"' || ch === '`';
272
+ // The index just past the literal opening at `start`, or -1.
273
+ const literalEnd = (start) => {
274
+ const quote = text[start];
275
+ let j = start + 1;
276
+ while (j < text.length) {
277
+ const ch = text[j];
278
+ if (ch === '\\') {
279
+ j += 2;
280
+ }
281
+ else if (ch === quote) {
282
+ return j + 1;
283
+ }
284
+ else if (quote === '`' && ch === '$' && text[j + 1] === '{') {
285
+ const close = codeEnd(j + 2);
286
+ if (close < 0)
287
+ return -1;
288
+ j = close + 1;
289
+ }
290
+ else {
291
+ j++;
292
+ }
293
+ }
294
+ return -1;
295
+ };
296
+ // The index of the `}` that closes an interpolation whose code starts at `start`, or -1.
297
+ const codeEnd = (start) => {
298
+ let depth = 0;
299
+ let j = start;
300
+ while (j < text.length) {
301
+ const ch = text[j];
302
+ if (isQuote(ch)) {
303
+ const end = literalEnd(j);
304
+ if (end < 0)
305
+ return -1;
306
+ j = end;
307
+ continue;
308
+ }
309
+ if (ch === '{')
310
+ depth++;
311
+ if (ch === '}') {
312
+ if (depth === 0)
313
+ return j;
314
+ depth--;
315
+ }
316
+ j++;
317
+ }
318
+ return -1;
319
+ };
320
+ let out = '';
321
+ let i = 0;
322
+ while (i < text.length) {
323
+ if (!isQuote(text[i])) {
324
+ out += text[i];
325
+ i++;
326
+ continue;
327
+ }
328
+ const end = literalEnd(i);
329
+ if (end < 0)
330
+ return out + text.slice(i);
331
+ const body = text.slice(i + 1, end - 1);
332
+ out += /['"`\\]|\$\{/.test(body) ? text.slice(i, end) : `"${body}"`;
333
+ i = end;
334
+ }
335
+ return out;
336
+ }
260
337
  function isBareStringText(text) {
261
338
  const members = text.split('|').map((member) => member.trim());
262
339
  return (members.includes('string') &&
@@ -1516,8 +1593,12 @@ export class TypeInferrer {
1516
1593
  }
1517
1594
  inferCallResult(sourceFile, request, extractionConfig) {
1518
1595
  const callExpr = this.resolveTargetCallExpression(sourceFile, request);
1596
+ // A call's result is read off the call. A locator that finds no call names
1597
+ // nothing this can read, and whatever other node its text overlaps is not
1598
+ // the call's result (carrick#2056); the retype abstains on the same miss.
1519
1599
  if (!callExpr) {
1520
- return this.inferExpression(sourceFile, request, extractionConfig);
1600
+ this.log(`Call-result locator at ${request.file_path}:${request.line_number} finds no call; leaving unresolved`);
1601
+ return null;
1521
1602
  }
1522
1603
  // Walk up from the already-found call expression instead of re-searching
1523
1604
  const func = this.findContainingFunctionForNode(callExpr);
@@ -1928,18 +2009,25 @@ export class TypeInferrer {
1928
2009
  parent.getInitializer() === current) {
1929
2010
  break;
1930
2011
  }
1931
- if (!Node.isParenthesizedExpression(parent) &&
1932
- !Node.isAwaitExpression(parent) &&
1933
- !Node.isNonNullExpression(parent) &&
1934
- !Node.isAsExpression(parent) &&
1935
- !Node.isTypeAssertion(parent) &&
1936
- !Node.isSatisfiesExpression(parent)) {
2012
+ if (!this.wrapsReadTransparently(parent)) {
1937
2013
  return undefined;
1938
2014
  }
1939
2015
  current = parent;
1940
2016
  }
1941
2017
  return typeNode;
1942
2018
  }
2019
+ /**
2020
+ * A wrapper between a body read and the binding it initialises that leaves
2021
+ * the read's value as it is: `await`, parentheses, `!`, a cast, `satisfies`.
2022
+ */
2023
+ wrapsReadTransparently(node) {
2024
+ return (Node.isParenthesizedExpression(node) ||
2025
+ Node.isAwaitExpression(node) ||
2026
+ Node.isNonNullExpression(node) ||
2027
+ Node.isAsExpression(node) ||
2028
+ Node.isTypeAssertion(node) ||
2029
+ Node.isSatisfiesExpression(node));
2030
+ }
1943
2031
  /**
1944
2032
  * A stated type with `any` or `unknown` written anywhere in it (`unknown`,
1945
2033
  * `Record<string, unknown>`, `{ items: any[] }`) leaves a position open: it
@@ -2183,6 +2271,24 @@ export class TypeInferrer {
2183
2271
  'to one body field by a keyed decorator; publishing the body those fields make up');
2184
2272
  return this.declaredRequestInferredType(request, keyedBody, this.getNodeLocation(located));
2185
2273
  }
2274
+ // carrick#2057: the locator named a member of the body the handler parsed
2275
+ // (`body.items` after `const body = (await request.json()) as ImportBody`,
2276
+ // or a binding destructured from that read). A member is one field of the
2277
+ // body, so publishing its type states the whole body is that field. The
2278
+ // body is what the source states at the read; where it states nothing,
2279
+ // nothing is published.
2280
+ const bodyRead = this.parsedBodyReadHolding(located);
2281
+ if (bodyRead) {
2282
+ const stated = this.requestStatedAtBodyRead(request, bodyRead);
2283
+ if (stated) {
2284
+ this.log(`Request locator at ${request.file_path}:${request.line_number} names a member of the ` +
2285
+ 'body its handler parses; publishing the body the source states at that read');
2286
+ return stated;
2287
+ }
2288
+ this.log(`Request locator at ${request.file_path}:${request.line_number} names a member of the ` +
2289
+ 'body its handler parses, and the source states no type at that read; leaving unresolved');
2290
+ return null;
2291
+ }
2186
2292
  // Mirror inferCallResult: strip `await`/`as`/parens/`!` so the inner
2187
2293
  // expression's type (not the surrounding `Promise<any>`) is read.
2188
2294
  let unwrapped = this.unwrapExpressionNode(located);
@@ -4681,6 +4787,25 @@ export class TypeInferrer {
4681
4787
  }
4682
4788
  return [expression];
4683
4789
  }
4790
+ /**
4791
+ * The symbol an anchor is named from: the type's own symbol, unless that
4792
+ * symbol's name is synthetic (`__type`, `__object`, ...), in which case the
4793
+ * alias symbol (carrick#2148).
4794
+ *
4795
+ * `type Widget = { id: string }` and an instantiated `Page<Order>` carry
4796
+ * the anonymous `__type` as their own symbol and the alias as their alias
4797
+ * symbol. Reading the own symbol first and stopping because it is truthy
4798
+ * left every object-literal alias without an anchor, while the same shape
4799
+ * declared as an interface had one. The own symbol still wins whenever it
4800
+ * has a real name, so every type anchored before keeps its anchor.
4801
+ */
4802
+ anchorSymbolOf(type) {
4803
+ const own = type.getSymbol();
4804
+ if (own && !own.getName().startsWith('__')) {
4805
+ return own;
4806
+ }
4807
+ return type.getAliasSymbol() ?? own;
4808
+ }
4684
4809
  /**
4685
4810
  * The deterministic source symbol of a resolved type (`Payment` for a payload
4686
4811
  * typed `Payment`), or `undefined` when there is no single user-defined
@@ -4691,7 +4816,7 @@ export class TypeInferrer {
4691
4816
  * `primary_type_symbol` so the manifest anchor no longer depends on the LLM.
4692
4817
  */
4693
4818
  primaryTypeSymbol(type) {
4694
- const name = (type.getSymbol() || type.getAliasSymbol())?.getName();
4819
+ const name = this.anchorSymbolOf(type)?.getName();
4695
4820
  // Reject the compiler's synthetic names for anonymous shapes (`__type`,
4696
4821
  // `__object`, `__function`, …). They are not user-facing type names, so
4697
4822
  // they would be a meaningless — and non-resolvable — anchor.
@@ -4711,7 +4836,8 @@ export class TypeInferrer {
4711
4836
  * declarations IN its `source_file`, so the request must point at the file
4712
4837
  * that actually declares the tsc-witnessed payload type.
4713
4838
  *
4714
- * The declarations read are those of the type's own symbol, so a name
4839
+ * The declarations read are those of the symbol the anchor is named from
4840
+ * (`anchorSymbolOf`: the type's own symbol, or its alias's), so a name
4715
4841
  * imported through a barrel reports the file that declares it, and a name
4716
4842
  * two files declare reports the one this type resolves to. A declaration in
4717
4843
  * an installed package or a TypeScript lib is reported as it is found, as
@@ -4730,7 +4856,7 @@ export class TypeInferrer {
4730
4856
  if (this.primaryTypeSymbol(type) === undefined) {
4731
4857
  return undefined;
4732
4858
  }
4733
- const decls = (type.getSymbol() || type.getAliasSymbol())?.getDeclarations() ?? [];
4859
+ const decls = this.anchorSymbolOf(type)?.getDeclarations() ?? [];
4734
4860
  const declaring = decls.find((d) => Node.isInterfaceDeclaration(d) ||
4735
4861
  Node.isTypeAliasDeclaration(d) ||
4736
4862
  Node.isClassDeclaration(d) ||
@@ -5388,6 +5514,56 @@ export class TypeInferrer {
5388
5514
  }
5389
5515
  return undefined;
5390
5516
  }
5517
+ /**
5518
+ * The platform body read whose result `located` is a MEMBER of, or
5519
+ * `undefined` (carrick#2057).
5520
+ *
5521
+ * A member is a property or element access off a binding, or a name
5522
+ * destructured out of one. The binding must be initialised by the body read
5523
+ * `platformBodyReadIn` finds in the function that declares it, with only
5524
+ * transparent wrappers between the two (`await`, parentheses, `!`, a cast,
5525
+ * `satisfies`). The binding itself is not a member, and neither is a member
5526
+ * of anything else (a query object, a parameter, an outbound response).
5527
+ */
5528
+ parsedBodyReadHolding(located) {
5529
+ let node = this.unwrapExpressionNode(located);
5530
+ let member = false;
5531
+ while (Node.isPropertyAccessExpression(node) ||
5532
+ Node.isElementAccessExpression(node) ||
5533
+ Node.isNonNullExpression(node) ||
5534
+ Node.isParenthesizedExpression(node)) {
5535
+ if (Node.isPropertyAccessExpression(node) || Node.isElementAccessExpression(node)) {
5536
+ member = true;
5537
+ }
5538
+ node = node.getExpression();
5539
+ }
5540
+ if (!Node.isIdentifier(node))
5541
+ return undefined;
5542
+ let declaration = node.getSymbol()?.getDeclarations()?.[0];
5543
+ while (declaration && Node.isBindingElement(declaration)) {
5544
+ member = true;
5545
+ declaration = declaration.getParent()?.getParent();
5546
+ }
5547
+ if (!member || !declaration || !Node.isVariableDeclaration(declaration))
5548
+ return undefined;
5549
+ const handler = this.findContainingFunctionForNode(declaration);
5550
+ const read = handler ? this.platformBodyReadIn(handler) : undefined;
5551
+ if (!read)
5552
+ return undefined;
5553
+ let current = read;
5554
+ for (;;) {
5555
+ const parent = current.getParent();
5556
+ if (!parent)
5557
+ return undefined;
5558
+ if (parent === declaration) {
5559
+ return declaration.getInitializer() === current ? read : undefined;
5560
+ }
5561
+ if (!this.wrapsReadTransparently(parent)) {
5562
+ return undefined;
5563
+ }
5564
+ current = parent;
5565
+ }
5566
+ }
5391
5567
  /**
5392
5568
  * The request contract the source states AT a body read, or null when it
5393
5569
  * states none there (carrick#807).
@@ -6785,21 +6961,119 @@ export class TypeInferrer {
6785
6961
  // locator whose exact match failed would bind to the smallest embedded
6786
6962
  // sub-expression (pickBestMatch prefers small nodes), e.g. the 8-char
6787
6963
  // literal "active" inside a res.json object payload (#335).
6964
+ //
6965
+ // A node the text itself places strictly inside what it names is a
6966
+ // fragment, however much of the text it covers (carrick#2056): the
6967
+ // `new URLSearchParams(…)` inside a call's URL argument, or the object a
6968
+ // callback nested in the named object returns. Only the node the text
6969
+ // names, or a wrapper the text adds around it, may be bound this way. A
6970
+ // fragment still points at what the text names where the source agrees
6971
+ // with the text up to it: the enclosing node of the named kind whose text
6972
+ // opens with the text's own opening up to the fragment (`new Response(`
6973
+ // before a payload, when the text dropped the call's second argument).
6788
6974
  const MIN_REVERSE_MATCH_LEN = 8;
6789
6975
  const MIN_REVERSE_MATCH_COVERAGE = 0.5;
6790
- const substringMatches = candidates.filter((c) => c.text.includes(normalizedTarget) ||
6791
- (c.text.length >= MIN_REVERSE_MATCH_LEN &&
6792
- c.text.length >= normalizedTarget.length * MIN_REVERSE_MATCH_COVERAGE &&
6793
- normalizedTarget.includes(c.text)));
6794
- if (substringMatches.length > 0) {
6795
- // Prefer nodes where the LLM text is contained in the node text
6796
- const containingMatches = substringMatches.filter((c) => c.text.includes(normalizedTarget));
6797
- if (containingMatches.length > 0) {
6798
- return this.pickBestMatch(containingMatches.map((c) => c.node), lineNumber);
6799
- }
6800
- return this.pickBestMatch(substringMatches.map((c) => c.node), lineNumber);
6976
+ const containingMatches = candidates.filter((c) => c.text.includes(normalizedTarget));
6977
+ if (containingMatches.length > 0) {
6978
+ return this.pickBestMatch(containingMatches.map((c) => c.node), lineNumber);
6979
+ }
6980
+ const reverseMatches = candidates.filter((c) => c.text.length >= MIN_REVERSE_MATCH_LEN &&
6981
+ c.text.length >= normalizedTarget.length * MIN_REVERSE_MATCH_COVERAGE &&
6982
+ normalizedTarget.includes(c.text));
6983
+ if (reverseMatches.length === 0)
6984
+ return undefined;
6985
+ const named = this.locatorNames(normalizedTarget);
6986
+ if (!named) {
6987
+ return this.pickBestMatch(reverseMatches.map((c) => c.node), lineNumber);
6988
+ }
6989
+ const textOf = new Map(candidates.map((c) => [c.node, c.text]));
6990
+ const bound = new Set();
6991
+ for (const c of reverseMatches) {
6992
+ if (named.texts.has(c.text)) {
6993
+ bound.add(c.node);
6994
+ continue;
6995
+ }
6996
+ const at = normalizedTarget.indexOf(c.text, named.rootStart + 1);
6997
+ if (at < 0)
6998
+ continue;
6999
+ const opening = normalizedTarget.slice(named.rootStart, at);
7000
+ const enclosing = c.node.getFirstAncestor((ancestor) => ancestor.getKind() === named.rootKind &&
7001
+ textOf.get(ancestor)?.startsWith(opening) === true);
7002
+ if (enclosing)
7003
+ bound.add(enclosing);
6801
7004
  }
6802
- return undefined;
7005
+ return bound.size > 0 ? this.pickBestMatch([...bound], lineNumber) : undefined;
7006
+ }
7007
+ /**
7008
+ * What a locator's text names, read off its own parse (carrick#2056):
7009
+ *
7010
+ * - `texts`: the normalized text of the expression it names and of each
7011
+ * wrapper it carries around it (`return`, a `const x =` binding, a label,
7012
+ * `await`, parentheses, a cast, `satisfies`, `!`). A node strictly inside
7013
+ * that expression is not among them;
7014
+ * - `rootKind` and `rootStart`: the innermost of those, its kind, and where
7015
+ * its text starts in the locator.
7016
+ *
7017
+ * `null` when the text does not parse as one expression or one statement:
7018
+ * there is no structure to read, and the caller keeps its text rule.
7019
+ */
7020
+ locatorNames(normalizedTarget) {
7021
+ const parsed = (source) => {
7022
+ const file = ts.createSourceFile('locator.ts', source, ts.ScriptTarget.Latest, true);
7023
+ const diagnostics = file
7024
+ .parseDiagnostics;
7025
+ return diagnostics && diagnostics.length === 0 && file.statements.length === 1 ? file : null;
7026
+ };
7027
+ // An object literal is a block as a statement, so try the text as an
7028
+ // expression first.
7029
+ const EXPRESSION_OPENING = '(\n';
7030
+ const asExpression = parsed(`${EXPRESSION_OPENING}${normalizedTarget}\n);`);
7031
+ let node;
7032
+ let file = asExpression;
7033
+ let offset = 0;
7034
+ if (asExpression) {
7035
+ const statement = asExpression.statements[0];
7036
+ if (ts.isExpressionStatement(statement) && ts.isParenthesizedExpression(statement.expression)) {
7037
+ node = statement.expression.expression;
7038
+ offset = EXPRESSION_OPENING.length;
7039
+ }
7040
+ }
7041
+ else {
7042
+ file = parsed(normalizedTarget);
7043
+ node = file?.statements[0];
7044
+ }
7045
+ if (!file || !node)
7046
+ return null;
7047
+ const texts = new Set();
7048
+ let root;
7049
+ while (node) {
7050
+ if (ts.isLabeledStatement(node)) {
7051
+ node = node.statement;
7052
+ }
7053
+ else if (ts.isExpressionStatement(node) || ts.isReturnStatement(node)) {
7054
+ node = node.expression;
7055
+ }
7056
+ else if (ts.isVariableStatement(node)) {
7057
+ const declarations = node.declarationList.declarations;
7058
+ node = declarations.length === 1 ? declarations[0].initializer : undefined;
7059
+ }
7060
+ else {
7061
+ texts.add(this.normalizeWhitespace(node.getText(file)));
7062
+ root = node;
7063
+ node =
7064
+ ts.isParenthesizedExpression(node) ||
7065
+ ts.isAwaitExpression(node) ||
7066
+ ts.isAsExpression(node) ||
7067
+ ts.isSatisfiesExpression(node) ||
7068
+ ts.isNonNullExpression(node) ||
7069
+ ts.isTypeAssertionExpression(node)
7070
+ ? node.expression
7071
+ : undefined;
7072
+ }
7073
+ }
7074
+ if (!root)
7075
+ return null;
7076
+ return { texts, rootKind: root.kind, rootStart: root.getStart(file) - offset };
6803
7077
  }
6804
7078
  /**
6805
7079
  * Pick the best match from a set of candidate nodes:
@@ -6840,7 +7114,7 @@ export class TypeInferrer {
6840
7114
  * for the same reason.
6841
7115
  */
6842
7116
  normalizeWhitespace(text) {
6843
- return text
7117
+ const collapsed = text
6844
7118
  .replace(/\s+/g, ' ')
6845
7119
  // A member chain broken before its dot (`client\n .list(…)`) reads the
6846
7120
  // same as `client.list(…)`; a space around `.` / `?.` means nothing
@@ -6849,6 +7123,8 @@ export class TypeInferrer {
6849
7123
  .replace(/\s*,?\s*([}\)\]])/g, '$1')
6850
7124
  .replace(/([\(\[{])\s+/g, '$1')
6851
7125
  .trim();
7126
+ // A string literal reads the same in any quotes (carrick#2056).
7127
+ return canonicalStringQuotes(collapsed);
6852
7128
  }
6853
7129
  /**
6854
7130
  * Format a human-readable location string for error messages.