carrick 0.3.104 → 0.3.106

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.
Files changed (33) hide show
  1. package/package.json +6 -6
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/sidecar/dist/src/capture/anchors.d.ts +13 -0
  4. package/sidecar/dist/src/capture/anchors.js +229 -37
  5. package/sidecar/dist/src/capture/api.d.ts +64 -4
  6. package/sidecar/dist/src/capture/check-classify.d.ts +10 -1
  7. package/sidecar/dist/src/capture/check-classify.js +23 -12
  8. package/sidecar/dist/src/capture/check-fields.js +4 -6
  9. package/sidecar/dist/src/capture/check-probe.js +16 -6
  10. package/sidecar/dist/src/capture/check-scrub.d.ts +3 -0
  11. package/sidecar/dist/src/capture/check-scrub.js +8 -4
  12. package/sidecar/dist/src/capture/check.js +75 -49
  13. package/sidecar/dist/src/capture/deep-walk.d.ts +20 -0
  14. package/sidecar/dist/src/capture/deep-walk.js +51 -11
  15. package/sidecar/dist/src/capture/guarded-fs.d.ts +5 -1
  16. package/sidecar/dist/src/capture/guarded-fs.js +23 -1
  17. package/sidecar/dist/src/capture/index.js +14 -2
  18. package/sidecar/dist/src/capture/member-name.d.ts +20 -0
  19. package/sidecar/dist/src/capture/member-name.js +24 -0
  20. package/sidecar/dist/src/capture/self-check.js +50 -9
  21. package/sidecar/dist/src/capture/service-config.d.ts +2 -0
  22. package/sidecar/dist/src/capture/service-config.js +1 -1
  23. package/sidecar/dist/src/failure-path.d.ts +67 -0
  24. package/sidecar/dist/src/failure-path.js +236 -0
  25. package/sidecar/dist/src/printed-names.d.ts +43 -0
  26. package/sidecar/dist/src/printed-names.js +186 -0
  27. package/sidecar/dist/src/retype.js +60 -99
  28. package/sidecar/dist/src/type-inferrer.d.ts +35 -3
  29. package/sidecar/dist/src/type-inferrer.js +208 -13
  30. package/sidecar/dist/src/type-structural-expander.js +10 -1
  31. package/sidecar/dist/src/types.d.ts +24 -0
  32. package/sidecar/dist/src/validators.d.ts +76 -0
  33. package/sidecar/dist/src/validators.js +8 -0
@@ -0,0 +1,186 @@
1
+ /**
2
+ * carrick#1836: what each bare name in an inference's printed text meant.
3
+ *
4
+ * The structural printer (`type-structural-expander.ts`) hands some subtrees
5
+ * back to the compiler's own print with no enclosing declaration, and that
6
+ * print writes every named type by its bare name: a database client's row,
7
+ * which is a library's mapped type, prints `{ status: EntityStatus; ... }`.
8
+ * The file a request names often never imports `EntityStatus`, so the text
9
+ * names nothing where it is read (TS2304), and the member reads `any` in the
10
+ * capture's surface.
11
+ *
12
+ * The compiler knew the symbol when it printed the name, and only then. So
13
+ * every type print made while an inference runs is noted (`notePrintedType`),
14
+ * and when it ends (`PrintedTypes.namesIn`) the names its text prints that the
15
+ * request's file cannot resolve are looked up in those prints: the node
16
+ * builder gives each name it writes the symbol it wrote it for. Each such
17
+ * symbol is recorded as the module that declares it and its export path
18
+ * there. A name printed for two declarations is recorded twice, and the
19
+ * reader of the record decides what an ambiguous name means.
20
+ *
21
+ * Only the prints whose text writes such a name are read back, and only when
22
+ * the inference's text has one, so an inference whose names all resolve costs
23
+ * no more than keeping the list.
24
+ */
25
+ import { ts } from 'ts-morph';
26
+ /** The prints of the inference running now, when one is recording. */
27
+ let recording;
28
+ /**
29
+ * Note a type the compiler printed as `text`, so the inference running now can
30
+ * tell what the names in that print meant. Does nothing outside a recording.
31
+ */
32
+ export function notePrintedType(type, enclosing, text) {
33
+ recording?.push({ type: type.compilerType, enclosing: enclosing?.compilerNode, text });
34
+ }
35
+ /**
36
+ * Same flags the printers pass `typeToString` (`NoTruncation`, `InTypeAlias`),
37
+ * so the node read back is the one that was printed.
38
+ */
39
+ const NODE_FLAGS = ts.NodeBuilderFlags.NoTruncation | ts.NodeBuilderFlags.InTypeAlias | ts.NodeBuilderFlags.IgnoreErrors;
40
+ const REFERENCE_MEANING = ts.SymbolFlags.Type | ts.SymbolFlags.Namespace | ts.SymbolFlags.Alias;
41
+ /** The type prints one inference made. */
42
+ export class PrintedTypes {
43
+ prints = [];
44
+ /** Run `read`, noting every type it prints. */
45
+ during(read) {
46
+ const outer = recording;
47
+ recording = this.prints;
48
+ try {
49
+ return read();
50
+ }
51
+ finally {
52
+ recording = outer;
53
+ }
54
+ }
55
+ /**
56
+ * The declarations behind each name `texts` print that `source` does not
57
+ * resolve, sorted, one entry per distinct declaration. `checker` must be
58
+ * the one the prints were made with: call this before the program changes.
59
+ */
60
+ namesIn(texts, source, checker) {
61
+ const wanted = new Set();
62
+ for (const text of texts) {
63
+ for (const name of referencedNames(text)) {
64
+ if (!checker.resolveName(name, source, REFERENCE_MEANING, false))
65
+ wanted.add(name);
66
+ }
67
+ }
68
+ if (wanted.size === 0)
69
+ return [];
70
+ // Only a print whose text writes one of the names can say what it meant.
71
+ const alternatives = [...wanted].map((name) => name.replace(/\$/g, '\\$')).join('|');
72
+ const writes = new RegExp(`(?<![\\w$.])(?:${alternatives})(?![\\w$])`);
73
+ const found = new Map();
74
+ const identities = new Map();
75
+ const visited = new Map();
76
+ for (const { type, enclosing, text } of this.prints) {
77
+ if (!writes.test(text))
78
+ continue;
79
+ const at = visited.get(type) ?? new Set();
80
+ if (at.has(enclosing))
81
+ continue;
82
+ visited.set(type, at.add(enclosing));
83
+ let node;
84
+ try {
85
+ node = checker.typeToTypeNode(type, enclosing, NODE_FLAGS);
86
+ }
87
+ catch {
88
+ continue;
89
+ }
90
+ const visit = (current) => {
91
+ if (ts.isTypeReferenceNode(current)) {
92
+ const id = leftmost(current.typeName);
93
+ const name = ts.idText(id);
94
+ const symbol = wanted.has(name) ? symbolOf(id, checker) : undefined;
95
+ if (symbol) {
96
+ if (!identities.has(symbol))
97
+ identities.set(symbol, identityOf(symbol, checker));
98
+ const identity = identities.get(symbol);
99
+ if (identity) {
100
+ found.set(`${name}\0${identity.file}\0${identity.export_path.join('.')}`, { name, ...identity });
101
+ }
102
+ }
103
+ }
104
+ ts.forEachChild(current, visit);
105
+ };
106
+ if (node)
107
+ visit(node);
108
+ }
109
+ return [...found.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([, entry]) => entry);
110
+ }
111
+ }
112
+ function leftmost(name) {
113
+ return ts.isIdentifier(name) ? name : leftmost(name.left);
114
+ }
115
+ /**
116
+ * The leftmost names of the type references `text` makes, less the type
117
+ * parameters the text declares itself (`[K in ...]`, `infer U`).
118
+ */
119
+ function referencedNames(text) {
120
+ const parsed = ts.createSourceFile('printed.ts', `type __Printed = ${text};`, ts.ScriptTarget.Latest, true);
121
+ const names = new Set();
122
+ const declared = new Set();
123
+ const visit = (node) => {
124
+ if (ts.isTypeParameterDeclaration(node))
125
+ declared.add(node.name.text);
126
+ if (ts.isTypeReferenceNode(node))
127
+ names.add(ts.idText(leftmost(node.typeName)));
128
+ ts.forEachChild(node, visit);
129
+ };
130
+ visit(parsed);
131
+ for (const name of declared)
132
+ names.delete(name);
133
+ names.delete('__Printed');
134
+ return names;
135
+ }
136
+ /**
137
+ * The symbol a printed name was written for. The node builder sets it on
138
+ * every identifier it creates (`symbol` is not in the public typings); a name
139
+ * it copied from source carries none, and is read at its original node.
140
+ */
141
+ function symbolOf(id, checker) {
142
+ const written = id.symbol;
143
+ if (written)
144
+ return written;
145
+ const original = ts.getOriginalNode(id);
146
+ return original !== id && original.parent ? checker.getSymbolAtLocation(original) : undefined;
147
+ }
148
+ /**
149
+ * The module that declares `symbol`'s type and the export path to it there,
150
+ * or undefined for a type no module export reaches: a global, a type
151
+ * parameter, a declaration a function body holds, a module itself.
152
+ */
153
+ function identityOf(symbol, checker) {
154
+ const resolve = (s) => s.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s;
155
+ const target = resolve(symbol);
156
+ if (!(target.flags & (ts.SymbolFlags.Type | ts.SymbolFlags.Namespace)) ||
157
+ target.flags & ts.SymbolFlags.TypeParameter) {
158
+ return undefined;
159
+ }
160
+ const declaration = target.declarations?.[0];
161
+ if (!declaration || ts.isSourceFile(declaration))
162
+ return undefined;
163
+ const file = declaration.getSourceFile();
164
+ const moduleSymbol = checker.getSymbolAtLocation(file);
165
+ if (!moduleSymbol)
166
+ return undefined;
167
+ const direct = checker.getExportsOfModule(moduleSymbol).filter((e) => resolve(e) === target);
168
+ if (direct.length > 0) {
169
+ const chosen = direct.find((e) => e.getName() === target.getName()) ?? direct[0];
170
+ return { file: file.fileName, export_path: [chosen.getName()] };
171
+ }
172
+ // A namespace member: `"<module>".Billing.Kind`, checked step by step.
173
+ const qualified = checker.getFullyQualifiedName(target);
174
+ const close = qualified.startsWith('"') ? qualified.indexOf('"', 1) : -1;
175
+ if (close < 0)
176
+ return undefined;
177
+ const exportPath = qualified.slice(close + 2).split('.');
178
+ let current = moduleSymbol;
179
+ for (const part of exportPath) {
180
+ const next = current
181
+ ? checker.getExportsOfModule(current).find((e) => e.getName() === part)
182
+ : undefined;
183
+ current = next && resolve(next);
184
+ }
185
+ return current === target ? { file: file.fileName, export_path: exportPath } : undefined;
186
+ }
@@ -25,6 +25,7 @@
25
25
  * project other requests read is the project the scan loaded.
26
26
  */
27
27
  import { Node, SyntaxKind, ts } from 'ts-morph';
28
+ import { readsResponseStatus, statusesIn, SUCCEEDED, testsOnPath, } from './failure-path.js';
28
29
  import { fileDiagnostics } from './unwidened.js';
29
30
  /** Names appended to a file that is not ours carry this prefix. */
30
31
  const PREFIX = '__carrick_';
@@ -688,111 +689,71 @@ function responseTest(read) {
688
689
  return (node) => Node.isIdentifier(node) && node.getSymbol() === symbol;
689
690
  }
690
691
  /**
691
- * Which side of a test of the response's status `node` runs on: inside a
692
- * branch of one, or after an `if (test) return/throw` that leaves the rest of
693
- * the block to the other side. `undefined` when no test decides it.
692
+ * Whether `node` sits on the response's error path (`'failure'`), on a path
693
+ * whose status tests do not say (`'unclear'`), or where the producer's body
694
+ * is read (`undefined`, also when no test of the response is on its path).
695
+ *
696
+ * The tests on the path are read as failure-path.ts reads them, each as the
697
+ * statuses that take the side `node` is on, and taken together. On top of
698
+ * that reading, in this order:
699
+ *
700
+ * - no success status reaches `node`: the error path;
701
+ * - a test lets through every status but one success status that carries a
702
+ * body (the side of `res.status === 200` the read after an early return on
703
+ * it is on): the error path. This is the consumer's own reading of that
704
+ * test, kept even when only success statuses get that far, because the
705
+ * read there is not the body the source singled out;
706
+ * - no status from 400 up reaches `node`: the producer's body;
707
+ * - a `switch` on the status, or a test the reading cannot follow exactly
708
+ * (`res.status === OK`, `res.ok || retry`), is on the path: unclear;
709
+ * - the tests take away a success status that carries a body
710
+ * (`res.status === 200 || res.status === 404`, `res.status > 200`):
711
+ * unclear;
712
+ * - otherwise they took away only error statuses, or statuses that carry no
713
+ * content (carrick#1813: the retype only runs against a producer that
714
+ * publishes a body, and that body never arrives with a 204 or 205), and
715
+ * `node` is where the body is read, as it is with no test at all.
694
716
  */
695
717
  function sideOf(node, isResponse) {
696
- let found;
697
- const note = (side) => {
698
- if (side === 'failure' || found === 'failure')
699
- found = 'failure';
700
- else if (side === 'unclear' || found === 'unclear')
701
- found = 'unclear';
702
- else
703
- found = side ?? found;
704
- };
705
- for (let child = node, parent = node.getParent(); parent; child = parent, parent = parent.getParent()) {
706
- if (Node.isIfStatement(parent) && child !== parent.getExpression()) {
707
- note(branchSide(parent.getExpression(), child === parent.getThenStatement(), isResponse));
708
- }
709
- else if (Node.isConditionalExpression(parent) && child !== parent.getCondition()) {
710
- note(branchSide(parent.getCondition(), child === parent.getWhenTrue(), isResponse));
711
- }
712
- else if (Node.isCaseClause(parent) || Node.isDefaultClause(parent)) {
713
- const swtch = parent.getParent()?.getParent();
714
- if (swtch && Node.isSwitchStatement(swtch) && testsResponse(swtch.getExpression(), isResponse)) {
715
- note('unclear');
716
- }
717
- }
718
- if (Node.isBlock(parent) || Node.isSourceFile(parent) || Node.isCaseClause(parent)) {
719
- for (const statement of parent.getStatements()) {
720
- if (statement === child)
721
- break;
722
- if (Node.isIfStatement(statement) &&
723
- !statement.getElseStatement() &&
724
- exits(statement.getThenStatement())) {
725
- note(branchSide(statement.getExpression(), false, isResponse));
726
- }
727
- }
728
- }
729
- }
730
- return found;
718
+ const sides = testsOnPath(node, undefined, isResponse);
719
+ const switched = node.getAncestors().some((ancestor) => {
720
+ if (!Node.isCaseClause(ancestor) && !Node.isDefaultClause(ancestor))
721
+ return false;
722
+ const statement = ancestor.getParent()?.getParent();
723
+ return (!!statement &&
724
+ Node.isSwitchStatement(statement) &&
725
+ readsResponseStatus(statement.getExpression(), isResponse));
726
+ });
727
+ if (sides.length === 0 && !switched)
728
+ return undefined;
729
+ const admitted = (status) => sides.every((side) => side.admits(status));
730
+ const reaching = statusesIn(admitted);
731
+ if (!reaching.some(SUCCEEDED))
732
+ return 'failure';
733
+ if (sides.some((side) => singlesOutABody(side.admits)))
734
+ return 'failure';
735
+ if (!reaching.some((status) => status >= 400))
736
+ return undefined;
737
+ if (switched || sides.some((side) => side.inexact))
738
+ return 'unclear';
739
+ if (statusesIn((status) => !admitted(status)).some(carriesBody))
740
+ return 'unclear';
741
+ return undefined;
731
742
  }
732
- function branchSide(condition, whenTrue, isResponse) {
733
- const ok = okWhenTrue(condition, isResponse);
734
- if (ok === undefined || ok === 'unclear')
735
- return ok;
736
- return ok === whenTrue ? 'success' : 'failure';
743
+ /** `statuses` is every status but one success status that carries a body. */
744
+ function singlesOutABody(statuses) {
745
+ const excluded = statusesIn((status) => !statuses(status));
746
+ return excluded.length === 1 && carriesBody(excluded[0]);
747
+ }
748
+ /** A success status whose response can carry the producer's body. */
749
+ function carriesBody(status) {
750
+ return SUCCEEDED(status) && !NO_CONTENT_STATUSES.has(status);
737
751
  }
738
752
  /**
739
- * Whether `condition` being true means the response succeeded: `res.ok`,
740
- * `res.status === 200`, `res.status !== 200`, `res.status >= 400` and their
741
- * negations. Any other test of the response is `'unclear'`; a condition that does not
742
- * test the response is `undefined`.
753
+ * The success statuses whose response carries no content (RFC 9110: 204 No
754
+ * Content, 205 Reset Content).
743
755
  */
744
- function okWhenTrue(condition, isResponse) {
745
- let e = condition;
746
- while (Node.isParenthesizedExpression(e))
747
- e = e.getExpression();
748
- if (Node.isPrefixUnaryExpression(e) && e.getOperatorToken() === SyntaxKind.ExclamationToken) {
749
- const inner = okWhenTrue(e.getOperand(), isResponse);
750
- return typeof inner === 'boolean' ? !inner : inner;
751
- }
752
- if (isMember(e, 'ok', isResponse))
753
- return true;
754
- if (Node.isBinaryExpression(e)) {
755
- const op = e.getOperatorToken().getKind();
756
- const [left, right] = [e.getLeft(), e.getRight()];
757
- const value = isMember(left, 'status', isResponse) && Node.isNumericLiteral(right)
758
- ? right.getLiteralValue()
759
- : undefined;
760
- if (value !== undefined) {
761
- const success = value >= 200 && value < 300;
762
- switch (op) {
763
- case SyntaxKind.EqualsEqualsEqualsToken:
764
- case SyntaxKind.EqualsEqualsToken:
765
- return success;
766
- case SyntaxKind.ExclamationEqualsEqualsToken:
767
- case SyntaxKind.ExclamationEqualsToken:
768
- return !success;
769
- case SyntaxKind.GreaterThanEqualsToken:
770
- if (value >= 300)
771
- return false;
772
- break;
773
- }
774
- return 'unclear';
775
- }
776
- }
777
- return testsResponse(e, isResponse) ? 'unclear' : undefined;
778
- }
779
- function isMember(node, name, isResponse) {
780
- return (Node.isPropertyAccessExpression(node) && node.getName() === name && isResponse(node.getExpression()));
781
- }
782
- /** The expression reads the response's `ok` or `status`. */
783
- function testsResponse(node, isResponse) {
784
- return [node, ...node.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)].some((n) => isMember(n, 'ok', isResponse) || isMember(n, 'status', isResponse));
785
- }
786
- /** A statement that always leaves the function. */
787
- function exits(statement) {
788
- if (Node.isReturnStatement(statement) || Node.isThrowStatement(statement))
789
- return true;
790
- if (Node.isBlock(statement)) {
791
- const last = statement.getStatements().at(-1);
792
- return !!last && exits(last);
793
- }
794
- return false;
795
- }
756
+ const NO_CONTENT_STATUSES = new Set([204, 205]);
796
757
  /** `node.json()` with no arguments, where `node` is the receiver. */
797
758
  function bodyReadOn(node) {
798
759
  const access = node.getParent();
@@ -97,6 +97,11 @@ export declare class TypeInferrer {
97
97
  * @returns InferResult with inferred types or errors
98
98
  */
99
99
  infer(requests: InferRequestItem[], extractionConfig?: ExtractionConfig): InferResult;
100
+ /**
101
+ * carrick#1836: list on `result` the declarations behind the names its text
102
+ * prints that the request's file cannot resolve (`PrintedTypes.namesIn`).
103
+ */
104
+ private recordPrintedNames;
100
105
  /**
101
106
  * carrick#1516: read each response inference again with the literals on its
102
107
  * handler's path marked `as const`, and record the narrower type the handler
@@ -257,6 +262,25 @@ export declare class TypeInferrer {
257
262
  */
258
263
  private receivingCallOf;
259
264
  private inferCallResult;
265
+ /**
266
+ * The terminal of a call's def-use walk is a zero-argument `.text()` read of
267
+ * the call's own result (carrick#1842): on its binding (`res.text()`,
268
+ * through a declaration, `await` or a cast), or in the callback a `then` on
269
+ * the call hands the response to (`fetch(u).then((res) => res.text())`).
270
+ */
271
+ private textReadAtTerminal;
272
+ /** `call` is `<receiver>.text()` with no arguments, and `accept` takes its receiver. */
273
+ private isTextReadOf;
274
+ /**
275
+ * The call's resolved signature, at this site, types a member of an object
276
+ * argument as exactly the literal `'text'`, and the source passes that
277
+ * literal there (carrick#1842). That is how a request library lets a caller
278
+ * choose a text body from the formats it offers (`{ type: 'text' }`): a
279
+ * generic config instantiated by the literal, or an overload taken by it.
280
+ * A member typed as a wider union (`kind: 'text' | 'image'`) chooses no
281
+ * format, and no member name is read.
282
+ */
283
+ private callChoosesTextBody;
260
284
  /**
261
285
  * What the source states the body read at `terminal` to be, when the type
262
286
  * `extractExplicitTypeFromAncestor` printed for it is stated AT the read
@@ -349,10 +373,10 @@ export declare class TypeInferrer {
349
373
  * `query.data`, `envelope` in `envelope.list[0]` — or `undefined` when the
350
374
  * identifier names the value itself.
351
375
  *
352
- * A member CALL is not a projection: `res.text()` yields a body rather than
376
+ * A member CALL is not a projection: `res.blob()` yields a body rather than
353
377
  * a part of one, and what it returns stays the walk's business. The
354
- * zero-argument json body read has its own branch and is taken before this
355
- * is asked.
378
+ * zero-argument json and text body reads have their own branch and are
379
+ * taken before this is asked.
356
380
  */
357
381
  private projectionOnReceiver;
358
382
  /**
@@ -431,9 +455,17 @@ export declare class TypeInferrer {
431
455
  * about the body and is skipped.
432
456
  */
433
457
  private castsOfUnreadParameter;
458
+ /**
459
+ * The zero-argument whole-body read that takes `identifier` as its receiver,
460
+ * `res.json()` or `res.text()`, or `undefined`. A text read is a body read
461
+ * like a json one (carrick#1842): without it, `return res.text()` left the
462
+ * walk on the response binding and published the transport object.
463
+ */
434
464
  private bodyReadOnReceiver;
435
465
  private collectDefUseNodes;
436
466
  private expressionUsesNames;
467
+ /** `expr` is a use of a tracked name, or contains one. */
468
+ private usesTrackedNames;
437
469
  private isIdentifierUsage;
438
470
  private isInFunctionScope;
439
471
  /**