carrick 0.3.109 → 0.3.111

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 (31) hide show
  1. package/dist/render.d.ts +14 -7
  2. package/dist/render.js +49 -36
  3. package/dist/render.js.map +1 -1
  4. package/package.json +6 -6
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/sidecar/dist/src/bundler.d.ts +10 -0
  7. package/sidecar/dist/src/bundler.js +39 -0
  8. package/sidecar/dist/src/capture/anchors.js +35 -0
  9. package/sidecar/dist/src/capture/api.d.ts +5 -0
  10. package/sidecar/dist/src/capture/check-workspace.d.ts +10 -0
  11. package/sidecar/dist/src/capture/check-workspace.js +68 -6
  12. package/sidecar/dist/src/capture/index.d.ts +1 -0
  13. package/sidecar/dist/src/capture/index.js +18 -3
  14. package/sidecar/dist/src/capture/installed-package-directory.d.ts +22 -0
  15. package/sidecar/dist/src/capture/installed-package-directory.js +30 -0
  16. package/sidecar/dist/src/capture/resolution-edges.d.ts +36 -0
  17. package/sidecar/dist/src/capture/resolution-edges.js +150 -0
  18. package/sidecar/dist/src/capture/status-table.d.ts +62 -0
  19. package/sidecar/dist/src/capture/status-table.js +104 -0
  20. package/sidecar/dist/src/failure-path.d.ts +42 -2
  21. package/sidecar/dist/src/failure-path.js +38 -13
  22. package/sidecar/dist/src/library-claims.js +2 -0
  23. package/sidecar/dist/src/line-index.d.ts +9 -0
  24. package/sidecar/dist/src/line-index.js +24 -0
  25. package/sidecar/dist/src/response-modes.d.ts +77 -0
  26. package/sidecar/dist/src/response-modes.js +459 -0
  27. package/sidecar/dist/src/type-inferrer.d.ts +93 -1
  28. package/sidecar/dist/src/type-inferrer.js +436 -97
  29. package/sidecar/dist/src/types.d.ts +20 -1
  30. package/sidecar/dist/src/validators.d.ts +39 -16
  31. package/sidecar/dist/src/validators.js +4 -0
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Where an installed package sits on disk, by path alone, for capture's
3
+ * resolution walk. It mirrors `DeclarationReader.installedPackage` in
4
+ * library-claims.ts: the capture seam (pinned decision 11a) lets neither side
5
+ * import the other, so a change to what counts as installed changes both.
6
+ *
7
+ * Seam: node builtins only.
8
+ */
9
+ import * as path from 'node:path';
10
+ /**
11
+ * The installed package directory a real path lies in, by path alone: the
12
+ * directory after the path's last `node_modules` segment (two segments for a
13
+ * scope), taken relative to `root`, and the name it is installed as. A source
14
+ * file in a directory named `node_modules` above `root`, or a checkout under a
15
+ * `node_modules` ancestor, is not installed; an install hoisted above `root`
16
+ * (`../../node_modules/pkg`) and a pnpm store
17
+ * (`node_modules/.pnpm/pkg@1/node_modules/pkg`) are. The caller reads the
18
+ * package.json there: without one, nothing is installed.
19
+ */
20
+ export function installedPackageDirectory(root, realPath) {
21
+ const segments = path.relative(root, realPath).split(path.sep);
22
+ const last = segments.lastIndexOf('node_modules');
23
+ if (last < 0)
24
+ return undefined;
25
+ const width = segments[last + 1]?.startsWith('@') ? 2 : 1;
26
+ return {
27
+ directory: path.resolve(root, ...segments.slice(0, last + 1 + width)),
28
+ name: segments.slice(last + 1, last + 1 + width).join('/'),
29
+ };
30
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * How the scanned repo's installed tree resolved the packages a stub pins
3
+ * (carrick#2091).
4
+ *
5
+ * A stub pins only the externals its declarations import. The check
6
+ * workspace has no lockfile, so without this record every package those pins
7
+ * depend on resolves fresh from the registry: the check can install a version
8
+ * the repo never ran, or fail on one the registry lists but cannot serve.
9
+ *
10
+ * The walk reads the installed tree the way Node's lookup does, so one reader
11
+ * covers npm, pnpm (isolated store and hoisted), yarn's node-modules linker
12
+ * and bun. Starting at each pin installed at the pinned version, it follows
13
+ * `dependencies` and `optionalDependencies` breadth first and records
14
+ * `<parent>@<version> -> <child>: <version>` for every child installed as a
15
+ * published package of that name. Workspace members, `link:` targets and npm
16
+ * aliases are left out: the registry cannot serve them by that name. An edge
17
+ * whose parent version is installed twice with different children is left
18
+ * out, since no one answer is the repo's.
19
+ *
20
+ * The walk only reads. Seam: node builtins and this bundle only.
21
+ */
22
+ /** The stub file the check reads to pin transitive edges. */
23
+ export declare const RESOLUTION_FILE = "carrick-resolution.json";
24
+ /** `<parent>@<version>` -> child name -> child version. */
25
+ export type ResolutionEdges = Record<string, Record<string, string>>;
26
+ export interface InstalledResolution {
27
+ /** Key-sorted at both levels; byte-stable for one tree. */
28
+ edges: ResolutionEdges;
29
+ /** Dependencies of walked packages that no edge records: the check resolves these from the registry. */
30
+ unrecorded: number;
31
+ }
32
+ /**
33
+ * The edges the installed tree under `serviceRoot` resolved, from the stub's
34
+ * pins outward. A pin not installed at its pinned version starts no walk.
35
+ */
36
+ export declare function installedResolutionEdges(serviceRoot: string, pins: Record<string, string>): InstalledResolution;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * How the scanned repo's installed tree resolved the packages a stub pins
3
+ * (carrick#2091).
4
+ *
5
+ * A stub pins only the externals its declarations import. The check
6
+ * workspace has no lockfile, so without this record every package those pins
7
+ * depend on resolves fresh from the registry: the check can install a version
8
+ * the repo never ran, or fail on one the registry lists but cannot serve.
9
+ *
10
+ * The walk reads the installed tree the way Node's lookup does, so one reader
11
+ * covers npm, pnpm (isolated store and hoisted), yarn's node-modules linker
12
+ * and bun. Starting at each pin installed at the pinned version, it follows
13
+ * `dependencies` and `optionalDependencies` breadth first and records
14
+ * `<parent>@<version> -> <child>: <version>` for every child installed as a
15
+ * published package of that name. Workspace members, `link:` targets and npm
16
+ * aliases are left out: the registry cannot serve them by that name. An edge
17
+ * whose parent version is installed twice with different children is left
18
+ * out, since no one answer is the repo's.
19
+ *
20
+ * The walk only reads. Seam: node builtins and this bundle only.
21
+ */
22
+ import * as fs from 'node:fs';
23
+ import * as path from 'node:path';
24
+ import { installedPackageDirectory } from './installed-package-directory.js';
25
+ import { isPublishedSemver } from './lockfile.js';
26
+ /** The stub file the check reads to pin transitive edges. */
27
+ export const RESOLUTION_FILE = 'carrick-resolution.json';
28
+ function validName(name) {
29
+ return !name.split('/').some((s) => s === '' || s === '.' || s === '..');
30
+ }
31
+ /**
32
+ * Node's lookup of `name` from `fromDir`: `<dir>/node_modules/<name>` at each
33
+ * ancestor, skipping an ancestor that is itself a `node_modules` directory.
34
+ * That skip is how a pnpm store package finds its sibling links.
35
+ */
36
+ function lookup(fromDir, name) {
37
+ let dir = fromDir;
38
+ for (;;) {
39
+ if (path.basename(dir) !== 'node_modules') {
40
+ const candidate = path.join(dir, 'node_modules', ...name.split('/'));
41
+ if (fs.existsSync(path.join(candidate, 'package.json')))
42
+ return candidate;
43
+ }
44
+ const parent = path.dirname(dir);
45
+ if (parent === dir)
46
+ return undefined;
47
+ dir = parent;
48
+ }
49
+ }
50
+ /**
51
+ * The package `name` installed as Node would find it from `fromDir`: a
52
+ * directory `installedPackageDirectory` accepts, holding a package.json of
53
+ * that name with a published version.
54
+ */
55
+ function installedFrom(root, fromDir, name) {
56
+ if (!validName(name))
57
+ return undefined;
58
+ const found = lookup(fromDir, name);
59
+ if (found === undefined)
60
+ return undefined;
61
+ try {
62
+ const dir = fs.realpathSync(found);
63
+ const installed = installedPackageDirectory(root, dir);
64
+ if (installed === undefined || installed.directory !== dir || installed.name !== name) {
65
+ return undefined;
66
+ }
67
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
68
+ if (manifest?.name !== name || typeof manifest.version !== 'string')
69
+ return undefined;
70
+ if (!isPublishedSemver(manifest.version))
71
+ return undefined;
72
+ const names = new Set();
73
+ for (const field of [manifest.dependencies, manifest.optionalDependencies]) {
74
+ if (field && typeof field === 'object')
75
+ for (const dep of Object.keys(field))
76
+ names.add(dep);
77
+ }
78
+ return { dir, name, version: manifest.version, dependencies: [...names].sort() };
79
+ }
80
+ catch {
81
+ // Dangling link, unreadable or corrupt manifest: nothing installed here.
82
+ return undefined;
83
+ }
84
+ }
85
+ function sorted(record) {
86
+ return Object.fromEntries(Object.keys(record)
87
+ .sort()
88
+ .map((k) => [k, record[k]]));
89
+ }
90
+ /**
91
+ * The edges the installed tree under `serviceRoot` resolved, from the stub's
92
+ * pins outward. A pin not installed at its pinned version starts no walk.
93
+ */
94
+ export function installedResolutionEdges(serviceRoot, pins) {
95
+ let root;
96
+ try {
97
+ root = fs.realpathSync(path.resolve(serviceRoot));
98
+ }
99
+ catch {
100
+ return { edges: {}, unrecorded: 0 };
101
+ }
102
+ const queue = [];
103
+ const visited = new Set();
104
+ for (const name of Object.keys(pins).sort()) {
105
+ const pkg = installedFrom(root, root, name);
106
+ if (pkg === undefined || pkg.version !== pins[name] || visited.has(pkg.dir))
107
+ continue;
108
+ visited.add(pkg.dir);
109
+ queue.push(pkg);
110
+ }
111
+ // parent key -> child -> every version a copy of that parent resolved.
112
+ const seen = new Map();
113
+ // Each (parent key, child) a walked package declares, recorded or not.
114
+ const declared = new Set();
115
+ for (let i = 0; i < queue.length; i++) {
116
+ const parent = queue[i];
117
+ const key = `${parent.name}@${parent.version}`;
118
+ let children = seen.get(key);
119
+ if (children === undefined)
120
+ seen.set(key, (children = new Map()));
121
+ for (const name of parent.dependencies) {
122
+ declared.add(`${key}>${name}`);
123
+ const child = installedFrom(root, parent.dir, name);
124
+ if (child === undefined)
125
+ continue;
126
+ let versions = children.get(name);
127
+ if (versions === undefined)
128
+ children.set(name, (versions = new Set()));
129
+ versions.add(child.version);
130
+ if (!visited.has(child.dir)) {
131
+ visited.add(child.dir);
132
+ queue.push(child);
133
+ }
134
+ }
135
+ }
136
+ const edges = {};
137
+ let recorded = 0;
138
+ for (const [key, children] of seen) {
139
+ const kept = {};
140
+ for (const [name, versions] of children) {
141
+ if (versions.size === 1)
142
+ kept[name] = [...versions][0];
143
+ }
144
+ if (Object.keys(kept).length === 0)
145
+ continue;
146
+ edges[key] = sorted(kept);
147
+ recorded += Object.keys(kept).length;
148
+ }
149
+ return { edges: sorted(edges), unrecorded: declared.size - recorded };
150
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Response tables keyed by HTTP status code (carrick#1841).
3
+ *
4
+ * An object type whose every key is a status code (`{ 200: Item; 404: Problem
5
+ * }`, or a range key such as `2XX`) is not a body: it is a table of the bodies
6
+ * each status carries. What a caller receives on success is the union of its
7
+ * 2xx values. The producer side reads the same rule off a schema object
8
+ * literal (`successStatusEntry` in the inferrer); this module reads it off a
9
+ * TYPE, through the compiler's own API, so the inferrer, the v1 bundle and
10
+ * the capture all decide a table the same way.
11
+ *
12
+ * Nothing here names a library or a generator. The shape is the HTTP status
13
+ * vocabulary, which is the language of the wire, not of any one client.
14
+ */
15
+ import ts from 'typescript';
16
+ /** `value` when it is an integer in the HTTP status range, else `undefined`. */
17
+ export declare function httpStatus(value: unknown): number | undefined;
18
+ /** One verdict for a set of status codes, or `mixed` when they disagree. */
19
+ export declare function classifyStatusCodes(codes: number[]): 'success' | 'error' | 'redirect' | 'mixed';
20
+ /**
21
+ * The status code a response-table KEY names: `200`, or a range key (`2XX`,
22
+ * `2xx`) read as the bottom of its range. Quotes around the key are ignored.
23
+ * `undefined` for any other key.
24
+ */
25
+ export declare function statusKeyCode(name: string): number | undefined;
26
+ /** One row of a response table: the key as written and the status it names. */
27
+ export interface StatusTableEntry {
28
+ symbol: ts.Symbol;
29
+ /** The key as the type declares it (`200`, `2XX`). */
30
+ key: string;
31
+ code: number;
32
+ }
33
+ /**
34
+ * The rows of `type` when it is a response table: a plain object type with at
35
+ * least one property, every key a status code, no index signature and no call
36
+ * or construct signature. `undefined` for anything else, so a payload that
37
+ * happens to hold one numeric key among others is never read as a table.
38
+ */
39
+ export declare function statusTableOf(checker: ts.TypeChecker, type: ts.Type): StatusTableEntry[] | undefined;
40
+ /** The rows of a table that answer a success: 2xx, in the order declared. */
41
+ export declare function successEntries(entries: StatusTableEntry[]): StatusTableEntry[];
42
+ /**
43
+ * Whether a body type states nothing: `void`, `undefined`, `never`, or a union
44
+ * made only of them. A `204: void` row is such a body.
45
+ */
46
+ export declare function statesNoBody(type: ts.Type): boolean;
47
+ /**
48
+ * What a response table's success rows carry, read for a consumer:
49
+ * - `body`: the 2xx rows' value types, at least one of which states a body;
50
+ * - `no_body`: the table has no 2xx row, or every 2xx row states nothing.
51
+ * `undefined` when `type` is not a response table.
52
+ */
53
+ export declare function statusTableBody(checker: ts.TypeChecker, type: ts.Type, at: ts.Node): {
54
+ kind: 'body';
55
+ entries: StatusTableEntry[];
56
+ types: ts.Type[];
57
+ } | {
58
+ kind: 'no_body';
59
+ entries: StatusTableEntry[];
60
+ } | undefined;
61
+ /** The members of a union, or the type itself: what a set comparison reads. */
62
+ export declare function constituents(type: ts.Type): ts.Type[];
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Response tables keyed by HTTP status code (carrick#1841).
3
+ *
4
+ * An object type whose every key is a status code (`{ 200: Item; 404: Problem
5
+ * }`, or a range key such as `2XX`) is not a body: it is a table of the bodies
6
+ * each status carries. What a caller receives on success is the union of its
7
+ * 2xx values. The producer side reads the same rule off a schema object
8
+ * literal (`successStatusEntry` in the inferrer); this module reads it off a
9
+ * TYPE, through the compiler's own API, so the inferrer, the v1 bundle and
10
+ * the capture all decide a table the same way.
11
+ *
12
+ * Nothing here names a library or a generator. The shape is the HTTP status
13
+ * vocabulary, which is the language of the wire, not of any one client.
14
+ */
15
+ import ts from 'typescript';
16
+ /** `value` when it is an integer in the HTTP status range, else `undefined`. */
17
+ export function httpStatus(value) {
18
+ return typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599
19
+ ? value
20
+ : undefined;
21
+ }
22
+ /** One verdict for a set of status codes, or `mixed` when they disagree. */
23
+ export function classifyStatusCodes(codes) {
24
+ if (codes.every((code) => code >= 400))
25
+ return 'error';
26
+ if (codes.every((code) => code >= 300 && code < 400))
27
+ return 'redirect';
28
+ if (codes.every((code) => code < 300))
29
+ return 'success';
30
+ return 'mixed';
31
+ }
32
+ /**
33
+ * The status code a response-table KEY names: `200`, or a range key (`2XX`,
34
+ * `2xx`) read as the bottom of its range. Quotes around the key are ignored.
35
+ * `undefined` for any other key.
36
+ */
37
+ export function statusKeyCode(name) {
38
+ const key = name.replace(/['"`]/g, '').trim();
39
+ if (/^\d{3}$/.test(key))
40
+ return httpStatus(Number(key));
41
+ if (/^[1-5]xx$/i.test(key))
42
+ return Number(key[0]) * 100;
43
+ return undefined;
44
+ }
45
+ /**
46
+ * The rows of `type` when it is a response table: a plain object type with at
47
+ * least one property, every key a status code, no index signature and no call
48
+ * or construct signature. `undefined` for anything else, so a payload that
49
+ * happens to hold one numeric key among others is never read as a table.
50
+ */
51
+ export function statusTableOf(checker, type) {
52
+ if (!(type.flags & ts.TypeFlags.Object))
53
+ return undefined;
54
+ if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) {
55
+ return undefined;
56
+ }
57
+ if (checker.getIndexInfosOfType(type).length > 0)
58
+ return undefined;
59
+ const properties = checker.getPropertiesOfType(type);
60
+ if (properties.length === 0)
61
+ return undefined;
62
+ const entries = [];
63
+ for (const symbol of properties) {
64
+ const key = symbol.getName();
65
+ const code = statusKeyCode(key);
66
+ if (code === undefined)
67
+ return undefined;
68
+ entries.push({ symbol, key, code });
69
+ }
70
+ return entries;
71
+ }
72
+ /** The rows of a table that answer a success: 2xx, in the order declared. */
73
+ export function successEntries(entries) {
74
+ return entries.filter((entry) => entry.code >= 200 && entry.code <= 299);
75
+ }
76
+ /**
77
+ * Whether a body type states nothing: `void`, `undefined`, `never`, or a union
78
+ * made only of them. A `204: void` row is such a body.
79
+ */
80
+ export function statesNoBody(type) {
81
+ const members = type.isUnion() ? type.types : [type];
82
+ return members.every((member) => (member.flags & (ts.TypeFlags.Void | ts.TypeFlags.Undefined | ts.TypeFlags.Never)) !== 0);
83
+ }
84
+ /**
85
+ * What a response table's success rows carry, read for a consumer:
86
+ * - `body`: the 2xx rows' value types, at least one of which states a body;
87
+ * - `no_body`: the table has no 2xx row, or every 2xx row states nothing.
88
+ * `undefined` when `type` is not a response table.
89
+ */
90
+ export function statusTableBody(checker, type, at) {
91
+ const entries = statusTableOf(checker, type);
92
+ if (!entries)
93
+ return undefined;
94
+ const success = successEntries(entries);
95
+ const types = success.map((entry) => checker.getTypeOfSymbolAtLocation(entry.symbol, at));
96
+ if (success.length === 0 || types.every((body) => statesNoBody(body))) {
97
+ return { kind: 'no_body', entries };
98
+ }
99
+ return { kind: 'body', entries: success, types };
100
+ }
101
+ /** The members of a union, or the type itself: what a set comparison reads. */
102
+ export function constituents(type) {
103
+ return type.isUnion() ? type.types : [type];
104
+ }
@@ -27,7 +27,7 @@
27
27
  * the other way round: after `if (res.status === 204) return null`, 200 still
28
28
  * gets through, and the json read that follows is the payload.
29
29
  */
30
- import { Node } from 'ts-morph';
30
+ import { Node, type CaseOrDefaultClause, type SwitchStatement } from 'ts-morph';
31
31
  /** A set of HTTP statuses, as the predicate that admits them. */
32
32
  export type Statuses = (status: number) => boolean;
33
33
  /** The statuses `ok` is true for. */
@@ -60,8 +60,48 @@ export declare function reachedOnlyOnFailure(node: Node, boundary: Node, isRespo
60
60
  * an `if` or a conditional expression it sits in, or an earlier `if` in an
61
61
  * enclosing block one of whose branches cannot complete, which leaves the
62
62
  * rest of the block to the other side. A test that does not read the
63
- * response's `ok` or `status` is not listed.
63
+ * response's `ok` or `status` is not listed. A `switch` is not read here:
64
+ * `retype.ts` finds one by its own climb.
64
65
  */
65
66
  export declare function testsOnPath(node: Node, boundary: Node | undefined, isResponse: (node: Node) => boolean): SideTaken[];
67
+ /**
68
+ * One test on the path to a node, as the side the node is on. `test` is the
69
+ * statement or expression that tests, so two nodes under the same test share
70
+ * it.
71
+ *
72
+ * - `branch`: an `if` or a conditional expression, and whether the node is
73
+ * on its true side: the branch the node sits in or, for an earlier `if` in
74
+ * an enclosing block one of whose branches cannot complete, the side that
75
+ * did not leave.
76
+ * - `clause`: the node sits in this clause of a `switch`.
77
+ * - `after-switch`: the node follows a `switch` in an enclosing block.
78
+ */
79
+ export type PathStep = {
80
+ kind: 'branch';
81
+ test: Node;
82
+ condition: Node;
83
+ whenTrue: boolean;
84
+ } | {
85
+ kind: 'clause';
86
+ test: SwitchStatement;
87
+ clause: CaseOrDefaultClause;
88
+ } | {
89
+ kind: 'after-switch';
90
+ test: SwitchStatement;
91
+ };
92
+ /**
93
+ * Every test on the path from `node` up to `boundary` (the whole file when it
94
+ * is `undefined`), whatever it reads, innermost first. `testsOnPath` reads
95
+ * the branches as statuses; `response-modes.ts` reads branches and switches
96
+ * as values of a request field (carrick#2054).
97
+ */
98
+ export declare function stepsOnPath(node: Node, boundary: Node | undefined): PathStep[];
66
99
  /** `node` reads the response's `ok` or `status`, itself or anywhere inside it. */
67
100
  export declare function readsResponseStatus(node: Node, isResponse: (node: Node) => boolean): boolean;
101
+ /**
102
+ * A statement that never runs on into the statement after it: it returns,
103
+ * throws, breaks or continues, or every path through it does. A loop, a
104
+ * `switch` or a `try` is read as one that can complete, which only ever
105
+ * leaves a read on the path it was on.
106
+ */
107
+ export declare function cannotComplete(statement: Node): boolean;
@@ -64,30 +64,51 @@ export function reachedOnlyOnFailure(node, boundary, isResponse) {
64
64
  * an `if` or a conditional expression it sits in, or an earlier `if` in an
65
65
  * enclosing block one of whose branches cannot complete, which leaves the
66
66
  * rest of the block to the other side. A test that does not read the
67
- * response's `ok` or `status` is not listed.
67
+ * response's `ok` or `status` is not listed. A `switch` is not read here:
68
+ * `retype.ts` finds one by its own climb.
68
69
  */
69
70
  export function testsOnPath(node, boundary, isResponse) {
70
71
  const sides = [];
71
- const take = (test, whenTrue) => {
72
+ for (const step of stepsOnPath(node, boundary)) {
73
+ if (step.kind !== 'branch')
74
+ continue;
75
+ const test = readTest(step.condition, isResponse);
72
76
  if (test === UNRELATED)
73
- return;
74
- sides.push({ admits: whenTrue ? test.whenTrue : test.whenFalse, inexact: test.inexact });
75
- };
77
+ continue;
78
+ sides.push({ admits: step.whenTrue ? test.whenTrue : test.whenFalse, inexact: test.inexact });
79
+ }
80
+ return sides;
81
+ }
82
+ /**
83
+ * Every test on the path from `node` up to `boundary` (the whole file when it
84
+ * is `undefined`), whatever it reads, innermost first. `testsOnPath` reads
85
+ * the branches as statuses; `response-modes.ts` reads branches and switches
86
+ * as values of a request field (carrick#2054).
87
+ */
88
+ export function stepsOnPath(node, boundary) {
89
+ const steps = [];
90
+ const branch = (test, condition, whenTrue) => steps.push({ kind: 'branch', test, condition, whenTrue });
76
91
  for (let child = node, parent = node.getParent(); parent && parent !== boundary; child = parent, parent = parent.getParent()) {
77
92
  if (Node.isIfStatement(parent)) {
78
93
  if (child === parent.getThenStatement()) {
79
- take(readTest(parent.getExpression(), isResponse), true);
94
+ branch(parent, parent.getExpression(), true);
80
95
  }
81
96
  else if (child === parent.getElseStatement()) {
82
- take(readTest(parent.getExpression(), isResponse), false);
97
+ branch(parent, parent.getExpression(), false);
83
98
  }
84
99
  }
85
100
  else if (Node.isConditionalExpression(parent)) {
86
101
  if (child === parent.getWhenTrue()) {
87
- take(readTest(parent.getCondition(), isResponse), true);
102
+ branch(parent, parent.getCondition(), true);
88
103
  }
89
104
  else if (child === parent.getWhenFalse()) {
90
- take(readTest(parent.getCondition(), isResponse), false);
105
+ branch(parent, parent.getCondition(), false);
106
+ }
107
+ }
108
+ else if (Node.isCaseClause(parent) || Node.isDefaultClause(parent)) {
109
+ const statement = parent.getParent()?.getParent();
110
+ if (statement && Node.isSwitchStatement(statement)) {
111
+ steps.push({ kind: 'clause', test: statement, clause: parent });
91
112
  }
92
113
  }
93
114
  if (Node.isBlock(parent) ||
@@ -97,21 +118,25 @@ export function testsOnPath(node, boundary, isResponse) {
97
118
  for (const statement of parent.getStatements()) {
98
119
  if (statement === child)
99
120
  break;
121
+ if (Node.isSwitchStatement(statement)) {
122
+ steps.push({ kind: 'after-switch', test: statement });
123
+ continue;
124
+ }
100
125
  if (!Node.isIfStatement(statement))
101
126
  continue;
102
127
  const otherwise = statement.getElseStatement();
103
128
  const thenLeaves = cannotComplete(statement.getThenStatement());
104
129
  const elseLeaves = otherwise !== undefined && cannotComplete(otherwise);
105
130
  if (thenLeaves && !elseLeaves) {
106
- take(readTest(statement.getExpression(), isResponse), false);
131
+ branch(statement, statement.getExpression(), false);
107
132
  }
108
133
  else if (elseLeaves && !thenLeaves) {
109
- take(readTest(statement.getExpression(), isResponse), true);
134
+ branch(statement, statement.getExpression(), true);
110
135
  }
111
136
  }
112
137
  }
113
138
  }
114
- return sides;
139
+ return steps;
115
140
  }
116
141
  /** The statuses `condition` lets through when it is true and when it is false. */
117
142
  function readTest(condition, isResponse) {
@@ -216,7 +241,7 @@ export function readsResponseStatus(node, isResponse) {
216
241
  * `switch` or a `try` is read as one that can complete, which only ever
217
242
  * leaves a read on the path it was on.
218
243
  */
219
- function cannotComplete(statement) {
244
+ export function cannotComplete(statement) {
220
245
  if (Node.isReturnStatement(statement) ||
221
246
  Node.isThrowStatement(statement) ||
222
247
  Node.isBreakStatement(statement) ||
@@ -2584,6 +2584,8 @@ class DeclarationReader {
2584
2584
  * repository checked out under a `node_modules` ancestor, is not installed.
2585
2585
  * An install hoisted above the service root (`../../node_modules/pkg`) and
2586
2586
  * a pnpm store (`node_modules/.pnpm/pkg@1/node_modules/pkg`) are.
2587
+ * Mirrored by `installedPackageDirectory` in capture/, which the capture
2588
+ * seam keeps from being shared.
2587
2589
  */
2588
2590
  installedPackage(fileName) {
2589
2591
  const segments = path.relative(this.root, this.realpath(fileName)).split(path.sep);
@@ -1,3 +1,4 @@
1
+ import type { Node } from 'ts-morph';
1
2
  /**
2
3
  * The 1-based line of a position in a text, counted by line feeds alone: one
3
4
  * more than the `\n`s before the position. That is the count ts-morph's
@@ -7,3 +8,11 @@
7
8
  * The text is read once; each lookup after that is a binary search.
8
9
  */
9
10
  export declare function lineIndex(text: string): (pos: number) => number;
11
+ /**
12
+ * `node.getStartLineNumber()` from a per-file index. ts-morph counts the
13
+ * file's line feeds from position 0 on every call, so a walk that asks of
14
+ * every node of a file pays the file's size once per node (carrick#1935).
15
+ */
16
+ export declare function startLineOf(node: Node): number;
17
+ /** `node.getEndLineNumber()` from the same per-file index. */
18
+ export declare function endLineOf(node: Node): number;
@@ -24,3 +24,27 @@ export function lineIndex(text) {
24
24
  return lo + 1;
25
25
  };
26
26
  }
27
+ const fileLineIndexes = new WeakMap();
28
+ function lineAtIn(node, pos) {
29
+ // Keyed on the compiler node: a manipulation (`replaceWithText`, a retype)
30
+ // gives the file a new one, so the index is built again from the new text.
31
+ const key = node.getSourceFile().compilerNode;
32
+ let lineAt = fileLineIndexes.get(key);
33
+ if (!lineAt) {
34
+ lineAt = lineIndex(key.text);
35
+ fileLineIndexes.set(key, lineAt);
36
+ }
37
+ return lineAt(pos);
38
+ }
39
+ /**
40
+ * `node.getStartLineNumber()` from a per-file index. ts-morph counts the
41
+ * file's line feeds from position 0 on every call, so a walk that asks of
42
+ * every node of a file pays the file's size once per node (carrick#1935).
43
+ */
44
+ export function startLineOf(node) {
45
+ return lineAtIn(node, node.getStart());
46
+ }
47
+ /** `node.getEndLineNumber()` from the same per-file index. */
48
+ export function endLineOf(node) {
49
+ return lineAtIn(node, node.getEnd());
50
+ }