test-inventory 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/resolve.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { globSync } from 'tinyglobby';
3
4
  const EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
4
5
  const TYPESCRIPT_SOURCE = /(?<!\.d)\.[cm]?tsx?$/;
5
6
  function isFile(file) {
@@ -76,26 +77,62 @@ function targetStrings(target) {
76
77
  const conditions = record(target);
77
78
  return conditions ? Object.values(conditions).flatMap(targetStrings) : [];
78
79
  }
79
- /** `paths` and `baseUrl` from the root tsconfig.json, with the ones of the file it extends, one level deep. */
80
- function tsconfigPaths(root) {
81
- const options = { paths: null, pathsBase: root, baseUrl: null };
82
- const file = path.join(root, 'tsconfig.json');
83
- const config = readJson(file);
80
+ /** A package name and the subpath after it, as in `@trading/test-utils` and `./fixtures`; `.` when there is none. */
81
+ function packageName(specifier) {
82
+ const parts = specifier.split('/');
83
+ const nameLength = specifier.startsWith('@') ? 2 : 1;
84
+ return { name: parts.slice(0, nameLength).join('/'), subpath: ['.', ...parts.slice(nameLength)].join('/') };
85
+ }
86
+ /** The file a path names, with `.json` added when it has no extension and doesn't exist as written, as in `extends`. */
87
+ function jsonFile(file) {
88
+ if (isFile(file))
89
+ return file;
90
+ return !file.endsWith('.json') && isFile(`${file}.json`) ? `${file}.json` : null;
91
+ }
92
+ /** The file a tsconfig `extends` entry names: a path, or a package found in node_modules as TypeScript finds it. */
93
+ function extendedFile(entry, directory) {
94
+ if (entry.startsWith('.') || path.isAbsolute(entry))
95
+ return jsonFile(path.resolve(directory, entry));
96
+ const { name, subpath } = packageName(entry);
97
+ for (let folder = directory;; folder = path.dirname(folder)) {
98
+ const packageFolder = path.join(folder, 'node_modules', name);
99
+ if (existsSync(packageFolder)) {
100
+ if (subpath !== '.')
101
+ return jsonFile(path.join(packageFolder, subpath));
102
+ const field = readJson(path.join(packageFolder, 'package.json'))?.tsconfig;
103
+ return jsonFile(path.join(packageFolder, typeof field === 'string' ? field : 'tsconfig.json'));
104
+ }
105
+ if (path.dirname(folder) === folder)
106
+ return null;
107
+ }
108
+ }
109
+ /** A tsconfig and every file it extends, the furthest base first, in the order TypeScript applies them. */
110
+ function tsconfigLayers(file, seen = new Set()) {
111
+ const config = seen.has(file) ? null : readJson(file);
84
112
  if (!config)
85
- return options;
86
- const extended = (Array.isArray(config.extends) ? config.extends : [config.extends]).filter((item) => typeof item === 'string' && item.startsWith('.'));
87
- const layers = [
88
- ...extended.map((item) => {
89
- const parent = path.resolve(root, item.endsWith('.json') ? item : `${item}.json`);
90
- return { directory: path.dirname(parent), config: readJson(parent) };
91
- }),
92
- { directory: root, config },
93
- ];
94
- // Later layers override earlier ones, as `extends` does.
113
+ return [];
114
+ seen.add(file);
115
+ const directory = path.dirname(file);
116
+ const entries = (Array.isArray(config.extends) ? config.extends : [config.extends]).filter((item) => typeof item === 'string');
117
+ const bases = entries.flatMap((entry) => {
118
+ const base = extendedFile(entry, directory);
119
+ return base ? tsconfigLayers(base, seen) : [];
120
+ });
121
+ return [...bases, { directory, config }];
122
+ }
123
+ /**
124
+ * `paths` and `baseUrl` from a tsconfig and the files it extends, later files overriding earlier ones. A solution-style
125
+ * tsconfig without them, as Vite projects have, takes them from the first project in its `references` that sets them.
126
+ */
127
+ function tsconfigPaths(file, seen = new Set()) {
128
+ seen.add(file);
129
+ const configDir = path.dirname(file);
130
+ const options = { paths: null, pathsBase: configDir, baseUrl: null, configDir };
131
+ const layers = tsconfigLayers(file);
95
132
  for (const layer of layers) {
96
- const compilerOptions = record(layer.config?.compilerOptions);
133
+ const compilerOptions = record(layer.config.compilerOptions);
97
134
  if (typeof compilerOptions?.baseUrl === 'string') {
98
- options.baseUrl = path.resolve(layer.directory, compilerOptions.baseUrl);
135
+ options.baseUrl = path.resolve(layer.directory, compilerOptions.baseUrl.replaceAll('${configDir}', configDir));
99
136
  }
100
137
  const paths = record(compilerOptions?.paths);
101
138
  if (paths) {
@@ -103,16 +140,47 @@ function tsconfigPaths(root) {
103
140
  options.pathsBase = layer.directory;
104
141
  }
105
142
  }
106
- if (options.baseUrl)
143
+ if (options.baseUrl) {
107
144
  options.pathsBase = options.baseUrl;
145
+ }
146
+ else if (!options.paths) {
147
+ const references = layers.at(-1)?.config.references;
148
+ for (const reference of Array.isArray(references) ? references : []) {
149
+ const target = record(reference)?.path;
150
+ if (typeof target !== 'string')
151
+ continue;
152
+ const referenced = path.resolve(configDir, target);
153
+ const referencedFile = target.endsWith('.json') ? referenced : path.join(referenced, 'tsconfig.json');
154
+ // Projects may reference each other; each tsconfig is read once.
155
+ const found = isFile(referencedFile) && !seen.has(referencedFile) ? tsconfigPaths(referencedFile, seen) : null;
156
+ if (found && (found.paths ?? found.baseUrl))
157
+ return found;
158
+ }
159
+ }
108
160
  return options;
109
161
  }
162
+ /**
163
+ * The tsconfig TypeScript uses for a file in the folder: the nearest tsconfig.json above it. Without one, the nearest
164
+ * tsconfig.base.json, which holds the path aliases of an Nx workspace.
165
+ */
166
+ function nearestTsconfig(directory) {
167
+ let base = null;
168
+ for (let folder = directory;; folder = path.dirname(folder)) {
169
+ const file = path.join(folder, 'tsconfig.json');
170
+ if (isFile(file))
171
+ return file;
172
+ const baseFile = path.join(folder, 'tsconfig.base.json');
173
+ base ??= isFile(baseFile) ? baseFile : null;
174
+ if (path.dirname(folder) === folder)
175
+ return base;
176
+ }
177
+ }
110
178
  /** Files a path alias stands for in tsconfig `paths`; null when no pattern matches. */
111
179
  function aliasFiles(specifier, options) {
112
180
  const match = options.paths ? matchPattern(Object.keys(options.paths), specifier) : null;
113
181
  if (!match || !options.paths)
114
182
  return null;
115
- return targetStrings(options.paths[match.key]).map((target) => path.resolve(options.pathsBase, target.replace('*', match.star)));
183
+ return targetStrings(options.paths[match.key]).map((target) => path.resolve(options.pathsBase, target.replaceAll('${configDir}', options.configDir).replace('*', match.star)));
116
184
  }
117
185
  /** Files a `#name` import may stand for, from `imports` in the nearest package.json. */
118
186
  function subpathImportFiles(specifier, directory) {
@@ -129,12 +197,105 @@ function subpathImportFiles(specifier, directory) {
129
197
  return [];
130
198
  }
131
199
  }
132
- /** A package linked from the workspace into node_modules, followed to its TypeScript source. */
133
- function workspaceSource(specifier, directory) {
134
- const parts = specifier.split('/');
135
- const nameLength = specifier.startsWith('@') ? 2 : 1;
136
- const name = parts.slice(0, nameLength).join('/');
137
- const subpath = ['.', ...parts.slice(nameLength)].join('/');
200
+ const BUILD_FOLDERS = new Set(['dist', 'build', 'lib', 'out']);
201
+ const FORMAT_FOLDERS = new Set(['esm', 'cjs', 'es', 'mjs', 'umd', 'types']);
202
+ /**
203
+ * Where the TypeScript source of a built file usually is, for a workspace package that hasn't been built: both
204
+ * `dist/esm/index.js` and `dist/types/index.d.ts` come from `src/index.ts`.
205
+ */
206
+ function sourceOf(target) {
207
+ const parts = target.replace(/^\.\//, '').split('/');
208
+ if (parts.length < 2 || !BUILD_FOLDERS.has(parts[0]))
209
+ return [];
210
+ const rest = parts.length > 2 && FORMAT_FOLDERS.has(parts[1]) ? parts.slice(2) : parts.slice(1);
211
+ const base = ['src', ...rest].join('/').replace(/(\.d)?\.[cm]?[jt]sx?$/, '');
212
+ return [`${base}.ts`, `${base}.tsx`];
213
+ }
214
+ /**
215
+ * The TypeScript source a package's exports, types or main point to for a subpath such as `.` or `./fixtures`. When they
216
+ * point to build output, the source next to it is used, as in a workspace package that hasn't been built.
217
+ */
218
+ function packageEntry(folder, subpath) {
219
+ const manifest = readJson(path.join(folder, 'package.json'));
220
+ const exports = manifest?.exports;
221
+ const map = record(exports);
222
+ const isSubpathMap = map !== null && Object.keys(map).some((key) => key.startsWith('.'));
223
+ let targets;
224
+ if (isSubpathMap) {
225
+ const match = matchPattern(Object.keys(map), subpath);
226
+ targets = match ? targetStrings(map[match.key]).map((target) => target.replace('*', match.star)) : [];
227
+ }
228
+ else {
229
+ targets = subpath === '.' ? targetStrings(exports) : [];
230
+ }
231
+ if (subpath === '.')
232
+ targets.push(...targetStrings(manifest?.types), ...targetStrings(manifest?.main));
233
+ const file = [...targets.filter((target) => TYPESCRIPT_SOURCE.test(target)), ...targets.flatMap(sourceOf)]
234
+ .map((target) => path.resolve(folder, target))
235
+ .find(isFile);
236
+ return file
237
+ ? { file }
238
+ : { reason: "is a workspace package whose exports, types and main don't point to TypeScript source" };
239
+ }
240
+ /**
241
+ * The `packages` list of a pnpm-workspace.yaml, as a block list or a flow list. The file holds little more than this
242
+ * list, so it is read line by line rather than with a YAML parser.
243
+ */
244
+ function pnpmWorkspacePatterns(text) {
245
+ const unquote = (item) => item.trim().replace(/^(['"])(.*)\1$/, '$2');
246
+ const flow = /^packages:\s*\[(.*)\]/m.exec(text);
247
+ if (flow)
248
+ return flow[1].split(',').map(unquote).filter(Boolean);
249
+ const patterns = [];
250
+ let inList = false;
251
+ for (const line of text.split(/\r?\n/)) {
252
+ if (/^packages:\s*(#.*)?$/.test(line)) {
253
+ inList = true;
254
+ continue;
255
+ }
256
+ const item = inList ? /^\s+-\s*([^#]+)/.exec(line) : null;
257
+ if (item)
258
+ patterns.push(unquote(item[1]));
259
+ else if (inList && line.trim() !== '' && !line.trim().startsWith('#'))
260
+ inList = false;
261
+ }
262
+ return patterns;
263
+ }
264
+ /** The folder of each package in the workspace a folder belongs to, by name; empty outside a workspace. */
265
+ function workspacePackages(directory) {
266
+ for (let folder = directory;; folder = path.dirname(folder)) {
267
+ const workspaces = readJson(path.join(folder, 'package.json'))?.workspaces;
268
+ const fromManifest = Array.isArray(workspaces) ? workspaces : record(workspaces)?.packages;
269
+ const pnpm = readText(path.join(folder, 'pnpm-workspace.yaml'));
270
+ const patterns = (Array.isArray(fromManifest) ? fromManifest : pnpm ? pnpmWorkspacePatterns(pnpm) : null)?.filter((item) => typeof item === 'string');
271
+ if (patterns) {
272
+ // Packages are inside the workspace. A pattern or a link that leads out of its folder could send the search through
273
+ // the whole disk, so neither is followed.
274
+ const inside = patterns.filter((item) => !path.isAbsolute(item.replace(/^!/, '')) && !/(^|\/)\.\.(\/|$)/.test(item));
275
+ const manifests = globSync(inside.filter((item) => !item.startsWith('!')).map((item) => `${item.replace(/\/+$/, '')}/package.json`), {
276
+ cwd: folder,
277
+ absolute: true,
278
+ followSymbolicLinks: false,
279
+ ignore: ['**/node_modules/**', ...inside.filter((item) => item.startsWith('!')).map((item) => item.slice(1))],
280
+ });
281
+ const packages = new Map();
282
+ for (const manifest of manifests) {
283
+ const name = readJson(manifest)?.name;
284
+ if (typeof name === 'string')
285
+ packages.set(name, path.dirname(manifest));
286
+ }
287
+ return packages;
288
+ }
289
+ if (path.dirname(folder) === folder)
290
+ return new Map();
291
+ }
292
+ }
293
+ /**
294
+ * A workspace package followed to its TypeScript source: through its link in node_modules, or, before an install, from
295
+ * the packages the workspace lists. A package installed from a registry is not followed.
296
+ */
297
+ function workspaceSource(specifier, directory, packagesOf) {
298
+ const { name, subpath } = packageName(specifier);
138
299
  for (let folder = directory;; folder = path.dirname(folder)) {
139
300
  const linked = path.join(folder, 'node_modules', name);
140
301
  if (existsSync(linked)) {
@@ -142,41 +303,47 @@ function workspaceSource(specifier, directory) {
142
303
  if (real.split(path.sep).includes('node_modules')) {
143
304
  return { reason: 'is an installed package, which is not followed' };
144
305
  }
145
- const manifest = readJson(path.join(real, 'package.json'));
146
- const exports = manifest?.exports;
147
- const map = record(exports);
148
- const isSubpathMap = map !== null && Object.keys(map).some((key) => key.startsWith('.'));
149
- let targets;
150
- if (isSubpathMap) {
151
- const match = matchPattern(Object.keys(map), subpath);
152
- targets = match ? targetStrings(map[match.key]).map((target) => target.replace('*', match.star)) : [];
153
- }
154
- else {
155
- targets = subpath === '.' ? targetStrings(exports) : [];
156
- }
157
- if (subpath === '.')
158
- targets.push(...targetStrings(manifest?.types), ...targetStrings(manifest?.main));
159
- const file = targets
160
- .filter((target) => TYPESCRIPT_SOURCE.test(target))
161
- .map((target) => path.resolve(real, target))
162
- .find(isFile);
163
- return file
164
- ? { file }
165
- : { reason: "is a workspace package whose exports, types and main don't point to TypeScript source" };
306
+ return packageEntry(real, subpath);
166
307
  }
167
308
  if (path.dirname(folder) === folder)
168
- return null;
309
+ break;
169
310
  }
311
+ const local = packagesOf(directory).get(name);
312
+ return local ? packageEntry(local, subpath) : null;
170
313
  }
171
314
  /**
172
- * Returns a reader for the modules a test file imports. It follows relative paths, tsconfig `paths` and `baseUrl`,
173
- * package.json `imports` (`#name`), and workspace packages linked into node_modules whose entry is TypeScript source.
315
+ * Returns a reader for the modules a test file imports. It follows relative paths, tsconfig `paths` and `baseUrl` from
316
+ * the tsconfig nearest the importing file, package.json `imports` (`#name`), and workspace packages whose entry is
317
+ * TypeScript source. Each module it reads carries a reader for the modules that module imports in turn.
174
318
  */
175
319
  export function moduleResolver(root) {
176
- let options = null;
177
- // Many test files import the same fixtures file; it is read once, and parsed once by the code that follows it.
320
+ // Many test files import the same fixtures file and share a tsconfig; each is read once.
178
321
  const modules = new Map();
179
- return (fromFile) => (specifier) => {
322
+ const tsconfigs = new Map();
323
+ const pathOptions = new Map();
324
+ const workspaces = new Map();
325
+ const optionsFor = (directory) => {
326
+ if (!tsconfigs.has(directory))
327
+ tsconfigs.set(directory, nearestTsconfig(directory));
328
+ const file = tsconfigs.get(directory);
329
+ if (!file)
330
+ return null;
331
+ let options = pathOptions.get(file);
332
+ if (!options) {
333
+ options = tsconfigPaths(file);
334
+ pathOptions.set(file, options);
335
+ }
336
+ return options;
337
+ };
338
+ const packagesOf = (directory) => {
339
+ let packages = workspaces.get(directory);
340
+ if (!packages) {
341
+ packages = workspacePackages(directory);
342
+ workspaces.set(directory, packages);
343
+ }
344
+ return packages;
345
+ };
346
+ const reader = (fromFile) => (specifier) => {
180
347
  const directory = path.dirname(fromFile);
181
348
  let candidates;
182
349
  // `.` and `..` are paths too: the index of the file's folder, or of the folder above.
@@ -189,15 +356,15 @@ export function moduleResolver(root) {
189
356
  return { reason: 'is not in the imports of package.json' };
190
357
  }
191
358
  else {
192
- options ??= tsconfigPaths(root);
193
- const aliased = aliasFiles(specifier, options);
359
+ const options = optionsFor(directory);
360
+ const aliased = options ? aliasFiles(specifier, options) : null;
194
361
  if (aliased) {
195
362
  candidates = aliased;
196
363
  }
197
364
  else {
198
365
  // `baseUrl` turns any name into a path, so a name not found there may still be a workspace package.
199
- const fromBaseUrl = options.baseUrl ? findFile(path.resolve(options.baseUrl, specifier)) : null;
200
- const workspace = fromBaseUrl ? null : workspaceSource(specifier, directory);
366
+ const fromBaseUrl = options?.baseUrl ? findFile(path.resolve(options.baseUrl, specifier)) : null;
367
+ const workspace = fromBaseUrl ? null : workspaceSource(specifier, directory, packagesOf);
201
368
  if (workspace && 'reason' in workspace)
202
369
  return workspace;
203
370
  const file = fromBaseUrl ?? workspace?.file;
@@ -211,8 +378,11 @@ export function moduleResolver(root) {
211
378
  return { reason: 'could not be read' };
212
379
  if (!modules.has(found)) {
213
380
  const code = readText(found);
214
- modules.set(found, code === null ? null : { code, relativeFilePath: path.relative(root, found).replaceAll('\\', '/') });
381
+ modules.set(found, code === null
382
+ ? null
383
+ : { code, relativeFilePath: path.relative(root, found).replaceAll('\\', '/'), readModule: reader(found) });
215
384
  }
216
385
  return modules.get(found) ?? { reason: 'could not be read' };
217
386
  };
387
+ return reader;
218
388
  }
package/dist/scan-file.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { fileDiagnostic, toDiagnostic } from "./diagnostics.js";
2
2
  import { extract } from "./extract.js";
3
+ import { parseModule } from "./frameworks/bindings.js";
3
4
  import { problem } from "./frameworks/common.js";
4
5
  import { PlaywrightAdapter } from "./frameworks/playwright.js";
5
6
  import { VitestAdapter } from "./frameworks/vitest.js";
@@ -8,6 +9,14 @@ import { buildRecords } from "./records.js";
8
9
  import { snippet } from "./source.js";
9
10
  import { Values } from "./values.js";
10
11
  const TYPE_TEST = /\.(test|spec)-d\.[cm]?[jt]sx?$/;
12
+ /** A file read for the scan: the scanned file, or a module a helper function is imported from. */
13
+ function frame(parsed, relativeFilePath, settings, readModule) {
14
+ const file = { source: parsed.source, values: new Values(), version: settings.version };
15
+ const adapter = settings.framework === 'playwright'
16
+ ? new PlaywrightAdapter(parsed.program, file, readModule)
17
+ : new VitestAdapter(parsed, file, readModule);
18
+ return { parsed, adapter, values: file.values, readModule, relativeFilePath };
19
+ }
11
20
  /** Reads one file: parse, extract, build records, and drop the AST. Never throws. */
12
21
  export function scanFile(code, relativeFilePath, settings) {
13
22
  try {
@@ -20,11 +29,11 @@ export function scanFile(code, relativeFilePath, settings) {
20
29
  if (parsed.program.body.length === 0)
21
30
  return { suites: [], tests: [], diagnostics, usesGlobals: false };
22
31
  }
23
- const file = { source: parsed.source, values: new Values(), version: settings.version };
24
- const adapter = settings.framework === 'playwright'
25
- ? new PlaywrightAdapter(parsed.program, file, settings.readModule)
26
- : new VitestAdapter(parsed, file, settings.readModule);
27
- const extraction = extract(parsed, adapter, file.values);
32
+ const main = frame(parsed, relativeFilePath, settings, settings.readModule);
33
+ const extraction = extract(main, (module) => {
34
+ const helper = parseModule(module);
35
+ return helper.error ? null : frame(helper, module.relativeFilePath, settings, module.readModule);
36
+ });
28
37
  const records = buildRecords(extraction, parsed, {
29
38
  relativeFilePath,
30
39
  framework: settings.framework,
package/dist/types.d.ts CHANGED
@@ -101,6 +101,11 @@ export interface SuiteRecord {
101
101
  columnStart: Position;
102
102
  /** In UTF-16 code units. */
103
103
  columnEnd: Position;
104
+ /**
105
+ * Where a helper imported from another file writes the describe; the record's location is then the
106
+ * call that registers it. Null for code written in the scanned file.
107
+ */
108
+ declaredAt: DeclaredAt | null;
104
109
  /** As the runner shows it, tags included. */
105
110
  title: string;
106
111
  /** `title` with `@tag` words removed and spaces tidied. */
@@ -157,6 +162,11 @@ export interface TestRecord {
157
162
  columnStart: Position;
158
163
  /** In UTF-16 code units. */
159
164
  columnEnd: Position;
165
+ /**
166
+ * Where a helper imported from another file writes the test; the record's location is then the
167
+ * call that registers it. Null for code written in the scanned file.
168
+ */
169
+ declaredAt: DeclaredAt | null;
160
170
  /** As the runner shows it, tags included. */
161
171
  title: string;
162
172
  /** `title` with `@tag` words removed and spaces tidied. */
@@ -215,6 +225,14 @@ export interface TestRecord {
215
225
  isInFunction: boolean;
216
226
  }
217
227
  /** @inline */
228
+ export interface DeclaredAt {
229
+ /** The helper's file, from the project root. */
230
+ relativeFilePath: string;
231
+ lineStart: Position;
232
+ /** In UTF-16 code units. */
233
+ columnStart: Position;
234
+ }
235
+ /** @inline */
218
236
  export interface Annotation {
219
237
  type: string;
220
238
  description: string | null;
package/dist/values.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Expression, Node } from 'oxc-parser';
2
+ import { type FunctionNode } from './ast.ts';
2
3
  export type Value = string | number | boolean | null | undefined | Value[] | {
3
4
  [key: string]: Value;
4
5
  };
@@ -19,6 +20,15 @@ export declare class Values {
19
20
  private readonly bindingsByScope;
20
21
  private readonly cache;
21
22
  private readonly pending;
23
+ /** The functions being read at one of their calls, innermost last. */
24
+ private readonly calls;
25
+ /**
26
+ * Runs `read` with a function's parameters bound to the arguments of one call, worked out where the call is, so that
27
+ * titles and values built from them resolve, as in `test(\`${side} rejects a zero quantity\`)` inside
28
+ * `validatesQuantity('buy')`. `args` stop at a spread argument, after which no position is known. A missing argument
29
+ * takes the parameter's default value, worked out in `scopes`, the scopes around the function.
30
+ */
31
+ withArguments<T>(fn: FunctionNode, args: readonly Resolved[], scopes: readonly Node[], read: () => T): T;
22
32
  /** `scopes` lists the scope nodes around the expression, outermost first. */
23
33
  resolve(node: Expression, scopes: readonly Node[]): Resolved;
24
34
  /**
@@ -26,10 +36,14 @@ export declare class Values {
26
36
  * the file does not declare it, as with globals.
27
37
  */
28
38
  declarationOf(name: string, scopes: readonly Node[]): Node | null;
39
+ /** The module a name is imported from where it is used; null when it isn't an import. */
40
+ importSource(name: string, scopes: readonly Node[]): string | null;
29
41
  /** Whether a name refers to a function or class declared in the file. */
30
42
  isFunction(name: string, scopes: readonly Node[]): boolean;
31
43
  private propertyKey;
32
44
  private resolveName;
45
+ /** A parameter of a function read at one of its calls: the argument given there. */
46
+ private resolveParameter;
33
47
  private resolveConstant;
34
48
  private enumMembers;
35
49
  private lookup;
package/dist/values.js CHANGED
@@ -56,6 +56,35 @@ export class Values {
56
56
  bindingsByScope = new Map();
57
57
  cache = new Map();
58
58
  pending = new Set();
59
+ /** The functions being read at one of their calls, innermost last. */
60
+ calls = [];
61
+ /**
62
+ * Runs `read` with a function's parameters bound to the arguments of one call, worked out where the call is, so that
63
+ * titles and values built from them resolve, as in `test(\`${side} rejects a zero quantity\`)` inside
64
+ * `validatesQuantity('buy')`. `args` stop at a spread argument, after which no position is known. A missing argument
65
+ * takes the parameter's default value, worked out in `scopes`, the scopes around the function.
66
+ */
67
+ withArguments(fn, args, scopes, read) {
68
+ const parameters = new Map();
69
+ for (const [index, parameter] of fn.params.entries()) {
70
+ const target = parameter.type === 'AssignmentPattern' ? parameter.left : parameter;
71
+ if (target.type !== 'Identifier')
72
+ continue;
73
+ const argument = args.at(index);
74
+ if (argument)
75
+ parameters.set(target.name, () => argument);
76
+ else if (parameter.type === 'AssignmentPattern') {
77
+ parameters.set(target.name, () => this.resolve(parameter.right, [...scopes, fn]));
78
+ }
79
+ }
80
+ this.calls.push({ fn, parameters, cache: new Map() });
81
+ try {
82
+ return read();
83
+ }
84
+ finally {
85
+ this.calls.pop();
86
+ }
87
+ }
59
88
  /** `scopes` lists the scope nodes around the expression, outermost first. */
60
89
  resolve(node, scopes) {
61
90
  const expression = unwrap(node);
@@ -179,6 +208,11 @@ export class Values {
179
208
  declarationOf(name, scopes) {
180
209
  return this.lookup(name, scopes)?.binding.declaration ?? null;
181
210
  }
211
+ /** The module a name is imported from where it is used; null when it isn't an import. */
212
+ importSource(name, scopes) {
213
+ const binding = this.lookup(name, scopes)?.binding;
214
+ return binding?.kind === 'import' ? binding.source : null;
215
+ }
182
216
  /** Whether a name refers to a function or class declared in the file. */
183
217
  isFunction(name, scopes) {
184
218
  return this.lookup(name, scopes)?.binding.kind === 'function';
@@ -203,12 +237,19 @@ export class Values {
203
237
  case 'import':
204
238
  return { ok: false, importedFrom: binding.source };
205
239
  case 'function':
206
- case 'other':
207
240
  return UNRESOLVED;
241
+ case 'other':
242
+ return this.resolveParameter(name, binding.declaration);
208
243
  }
209
244
  }
245
+ /** A parameter of a function read at one of its calls: the argument given there. */
246
+ resolveParameter(name, fn) {
247
+ const call = this.calls.findLast((item) => item.fn === fn);
248
+ return call?.parameters.get(name)?.() ?? UNRESOLVED;
249
+ }
210
250
  resolveConstant(init, scopes) {
211
- const cached = this.cache.get(init);
251
+ const cache = this.calls.at(-1)?.cache ?? this.cache;
252
+ const cached = cache.get(init);
212
253
  if (cached)
213
254
  return cached;
214
255
  if (this.pending.has(init))
@@ -216,7 +257,7 @@ export class Values {
216
257
  this.pending.add(init);
217
258
  const resolved = this.resolve(init, scopes);
218
259
  this.pending.delete(init);
219
- this.cache.set(init, resolved);
260
+ cache.set(init, resolved);
220
261
  return resolved;
221
262
  }
222
263
  enumMembers(node, scopes) {
package/package.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "test-inventory",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Test metadata from source code, not from test results.",
5
5
  "keywords": [
6
6
  "testing",
7
+ "playwright",
8
+ "vitest",
7
9
  "test-suite",
8
10
  "test-inventory",
9
11
  "static-analysis",
@@ -77,26 +79,27 @@
77
79
  "changeset": "changeset"
78
80
  },
79
81
  "dependencies": {
80
- "oxc-parser": "0.153.0",
82
+ "oxc-parser": "0.154.0",
81
83
  "tinyglobby": "^0.2.17"
82
84
  },
83
85
  "devDependencies": {
84
86
  "@arethetypeswrong/cli": "^0.18.5",
85
87
  "@changesets/changelog-github": "^1.0.1",
86
88
  "@changesets/cli": "^3.0.3",
87
- "@types/node": "^22.12.0",
88
- "@vitest/coverage-v8": "5.0.3",
89
+ "@types/node": "~22.12.0",
90
+ "@vitest/coverage-v8": "5.0.4",
89
91
  "ajv": "^8.20.0",
90
- "eslint": "^10.12.0",
91
- "prettier": "^3.9.9",
92
+ "eslint": "~10.12.0",
93
+ "prettier": "3.9.10",
92
94
  "publint": "^0.3.25",
93
95
  "ts-json-schema-generator": "2.9.0",
94
- "typedoc": "0.28.20",
96
+ "typedoc": "^0.28.20",
95
97
  "typedoc-plugin-markdown": "^4.13.1",
96
98
  "typedoc-vitepress-theme": "^1.1.4",
97
99
  "typescript": "~6.0.3",
98
- "typescript-eslint": "^8.71.1",
100
+ "typescript-eslint": "~8.71.2",
99
101
  "vitepress": "2.0.0-alpha.20",
100
- "vitest": "5.0.3"
102
+ "vitepress-plugin-llms": "^1.14.0",
103
+ "vitest": "5.0.4"
101
104
  }
102
105
  }