@glubean/scanner 0.4.0 → 0.5.1

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.
@@ -1,23 +1,15 @@
1
1
  /**
2
- * Static analysis extractor for Glubean test files.
3
- *
4
- * Uses regex patterns to extract test metadata WITHOUT importing files.
5
- * This is useful for:
6
- * - Build systems that scan code without execution
7
- * - CI/CD pipelines
8
- * - IDE extensions (VSCode)
9
- *
10
- * Note: Static analysis may miss dynamically computed metadata.
11
- *
12
- * **Limitations:**
13
- * - Template variables (`$id`, `$_pick`) in IDs are preserved as-is, not resolved.
14
- * - Dynamically computed IDs or tags are not detected.
15
- * - `test.each()` / `test.pick()` produce one ExportMeta with the template ID,
16
- * not one per data row (row count is unknown statically).
17
- * - Deeply nested or multi-line object literals with complex expressions may
18
- * not be fully parsed.
2
+ * Static guards + shared static-metadata types for Glubean scanning.
3
+ *
4
+ * The test/contract/pick EXTRACTORS that used to live here are gone they are
5
+ * now AST-based (@babel/parser) in `extractor-ast.ts`. What remains are the two
6
+ * cheap regex GUARDS that run over every candidate file in phase 1 (no TS
7
+ * expression parsing, so a parser would only add cost):
8
+ * - `isGlubeanFile` does this file import the SDK / a test-like function?
9
+ * - `extractAliasesFromSource` — `const x = test.extend(...)` alias names.
10
+ * Plus the result types consumed across packages (`ExportMeta` lives in
11
+ * `types.ts`; `PickMeta` / `Contract*StaticMeta` live here).
19
12
  */
20
- import { resolveDataPath } from "./data-path.js";
21
13
  // ---------------------------------------------------------------------------
22
14
  // SDK import detection
23
15
  // ---------------------------------------------------------------------------
@@ -50,7 +42,7 @@ function buildFnAlternation(customFns) {
50
42
  /**
51
43
  * Check if a file's content looks like a Glubean test/task file.
52
44
  *
53
- * Useful as a fast guard before running the more expensive `extractFromSource`.
45
+ * A fast, parse-free guard run before the (AST) extractors.
54
46
  *
55
47
  * Detection layers (any match → true):
56
48
  * 1. Direct SDK module import (`jsr:@glubean/sdk`, `@glubean/sdk`)
@@ -141,264 +133,6 @@ function stripComments(source) {
141
133
  return result;
142
134
  }
143
135
  // ---------------------------------------------------------------------------
144
- // Parsing helpers
145
- // ---------------------------------------------------------------------------
146
- /** Count newlines before `offset` to compute 1-based line number. */
147
- function getLineNumber(content, offset) {
148
- let line = 1;
149
- for (let i = 0; i < offset && i < content.length; i++) {
150
- if (content[i] === "\n")
151
- line++;
152
- }
153
- return line;
154
- }
155
- /**
156
- * Find the index of the matching closing bracket starting from `startIndex`
157
- * (which must point to the opening bracket). Respects string boundaries.
158
- * Returns -1 if no match is found.
159
- */
160
- function findMatching(source, startIndex, open, close) {
161
- let depth = 0;
162
- let inString = false;
163
- let stringChar = "";
164
- for (let i = startIndex; i < source.length; i++) {
165
- const ch = source[i];
166
- if (inString) {
167
- if (ch === "\\" && i + 1 < source.length) {
168
- i++; // skip escaped char
169
- continue;
170
- }
171
- if (ch === stringChar)
172
- inString = false;
173
- continue;
174
- }
175
- if (ch === '"' || ch === "'" || ch === "`") {
176
- inString = true;
177
- stringChar = ch;
178
- continue;
179
- }
180
- if (ch === open)
181
- depth++;
182
- if (ch === close) {
183
- depth--;
184
- if (depth === 0)
185
- return i;
186
- }
187
- }
188
- return -1;
189
- }
190
- /** Shorthand: find closing `)` for an opening `(`. */
191
- function findCloseParen(source, openIndex) {
192
- return findMatching(source, openIndex, "(", ")");
193
- }
194
- /** Shorthand: find closing `}` for an opening `{`. */
195
- function findCloseBrace(source, openIndex) {
196
- return findMatching(source, openIndex, "{", "}");
197
- }
198
- // ---------------------------------------------------------------------------
199
- // Metadata extraction from object literals
200
- // ---------------------------------------------------------------------------
201
- /**
202
- * Parse `id`, `name`, `tags`, and `timeout` from a TestMeta-like object literal string.
203
- * Handles both `tags: ["a", "b"]` and `tags: "a"` forms, with single or double quotes.
204
- */
205
- function parseMetaObject(source) {
206
- const result = {};
207
- const idMatch = source.match(/id:\s*(['"])([^'"]+)\1/);
208
- if (idMatch)
209
- result.id = idMatch[2];
210
- const nameMatch = source.match(/name:\s*(['"])([^'"]+)\1/);
211
- if (nameMatch)
212
- result.name = nameMatch[2];
213
- // Tags as array: tags: ["smoke", "auth"] or tags: ['smoke', 'auth']
214
- const tagsArrayMatch = source.match(/tags:\s*\[([^\]]*)\]/);
215
- if (tagsArrayMatch) {
216
- result.tags = [...tagsArrayMatch[1].matchAll(/(['"])([^'"]+)\1/g)].map((m) => m[2]);
217
- }
218
- else {
219
- // Tags as single string: tags: "smoke" or tags: 'smoke'
220
- const tagsStringMatch = source.match(/tags:\s*(['"])([^'"]+)\1/);
221
- if (tagsStringMatch)
222
- result.tags = [tagsStringMatch[2]];
223
- }
224
- const timeoutMatch = source.match(/timeout:\s*(\d+)/);
225
- if (timeoutMatch)
226
- result.timeout = Number(timeoutMatch[1]);
227
- const requiresMatch = source.match(/requires:\s*(['"])(headless|browser|out-of-band)\1/);
228
- if (requiresMatch) {
229
- result.requires = requiresMatch[2];
230
- }
231
- const defaultRunMatch = source.match(/defaultRun:\s*(['"])(always|opt-in)\1/);
232
- if (defaultRunMatch) {
233
- result.defaultRun = defaultRunMatch[2];
234
- }
235
- return result;
236
- }
237
- /**
238
- * Extract `name` and `tags` from a `.meta({...})` builder call within `scope`.
239
- */
240
- function extractBuilderMeta(scope) {
241
- // Caller (parseTestDeclaration) already bounds `scope` to the text
242
- // AFTER the test() call's closing paren, so we only see the builder
243
- // chain. Anchor on either start-of-scope (so the first chained
244
- // `.meta(...)` matches when the test takes no callback, e.g.
245
- // `test("id").meta(...)`) or a preceding `)` (e.g.
246
- // `test("id").step(...).meta(...)`). This belt-and-suspenders defense
247
- // also protects callers that pass a wider scope.
248
- const match = scope.match(/(?:^|\))\s*\.\s*meta\s*\(\s*\{/);
249
- if (!match || match.index === undefined)
250
- return {};
251
- const braceStart = scope.indexOf("{", match.index);
252
- const braceEnd = findCloseBrace(scope, braceStart);
253
- if (braceEnd === -1)
254
- return {};
255
- const obj = scope.substring(braceStart, braceEnd + 1);
256
- return parseMetaObject(obj);
257
- }
258
- /**
259
- * Extract step names from `.step("name", ...)` or `.step('name', ...)` chains within `scope`.
260
- */
261
- function extractSteps(scope) {
262
- const steps = [];
263
- const stepPattern = /\.step\(\s*(['"])([^'"]+)\1/g;
264
- let m;
265
- while ((m = stepPattern.exec(scope)) !== null) {
266
- steps.push({ name: m[2] });
267
- }
268
- return steps;
269
- }
270
- // ---------------------------------------------------------------------------
271
- // Declaration parser
272
- // ---------------------------------------------------------------------------
273
- /**
274
- * Parse a single test declaration from the text that follows `test` in
275
- * `export const NAME = test<scope>`. Returns null if the pattern is not
276
- * recognized.
277
- */
278
- function parseTestDeclaration(scope, exportName, line) {
279
- let rest = scope;
280
- let variant;
281
- // Check for .each() or .pick() — may appear on same line or next line
282
- let parallel = false;
283
- const dataMatch = rest.match(/^\s*\.\s*(each|pick)\s*\(/);
284
- if (dataMatch) {
285
- variant = dataMatch[1];
286
- const openIndex = rest.indexOf("(", dataMatch.index);
287
- const closeIndex = findCloseParen(rest, openIndex);
288
- if (closeIndex === -1)
289
- return null;
290
- // Check for { parallel: true } in .each() args
291
- if (variant === "each") {
292
- const eachArgs = rest.substring(openIndex + 1, closeIndex);
293
- if (/parallel\s*:\s*true/.test(eachArgs)) {
294
- parallel = true;
295
- }
296
- }
297
- rest = rest.substring(closeIndex + 1);
298
- }
299
- // Expect opening paren of the test call: test( or test.each(...)( or <generic>test<T>(
300
- const callMatch = rest.match(/^\s*(?:<[^>]*>)?\s*\(/);
301
- if (!callMatch)
302
- return null;
303
- const callOpenIndex = rest.indexOf("(", callMatch.index);
304
- // Bound the builder-chain search to text AFTER the test() call closes
305
- // AND BEFORE the test statement ends (first depth-0 semicolon). Without
306
- // both bounds `scope` runs until the next export and could pick up a
307
- // sibling `foo().meta({ requires: "browser" })` between this test and
308
- // the next export, mis-attributing capability metadata.
309
- const callCloseIndex = findCloseParen(rest, callOpenIndex);
310
- let builderChainScope = "";
311
- if (callCloseIndex !== -1) {
312
- const chainStart = callCloseIndex + 1;
313
- let depth = 0;
314
- let chainEnd = rest.length;
315
- for (let i = chainStart; i < rest.length; i++) {
316
- const c = rest[i];
317
- if (c === "(" || c === "{" || c === "[")
318
- depth++;
319
- else if (c === ")" || c === "}" || c === "]")
320
- depth--;
321
- else if (c === ";" && depth === 0) {
322
- chainEnd = i;
323
- break;
324
- }
325
- }
326
- builderChainScope = rest.substring(chainStart, chainEnd);
327
- }
328
- const afterOpen = rest.substring(callOpenIndex + 1).trimStart();
329
- let id;
330
- let name;
331
- let tags;
332
- let timeout;
333
- let requires;
334
- let defaultRun;
335
- if (afterOpen.startsWith('"') || afterOpen.startsWith("'")) {
336
- // String ID
337
- const quote = afterOpen[0];
338
- const endQuote = afterOpen.indexOf(quote, 1);
339
- if (endQuote === -1)
340
- return null;
341
- id = afterOpen.substring(1, endQuote);
342
- }
343
- else if (afterOpen.startsWith("{")) {
344
- // TestMeta object
345
- const braceEnd = findCloseBrace(afterOpen, 0);
346
- if (braceEnd === -1)
347
- return null;
348
- const objStr = afterOpen.substring(0, braceEnd + 1);
349
- const parsed = parseMetaObject(objStr);
350
- id = parsed.id;
351
- name = parsed.name;
352
- tags = parsed.tags;
353
- timeout = parsed.timeout;
354
- requires = parsed.requires;
355
- defaultRun = parsed.defaultRun;
356
- }
357
- if (!id)
358
- return null;
359
- // Extract builder .meta({...}) ONLY from the chain after the test()
360
- // call closes — not from inside the callback body.
361
- const builderMeta = extractBuilderMeta(builderChainScope);
362
- if (!name && builderMeta.name)
363
- name = builderMeta.name;
364
- if (!tags && builderMeta.tags)
365
- tags = builderMeta.tags;
366
- if (timeout === undefined && builderMeta.timeout !== undefined) {
367
- timeout = builderMeta.timeout;
368
- }
369
- if (requires === undefined && builderMeta.requires !== undefined) {
370
- requires = builderMeta.requires;
371
- }
372
- if (defaultRun === undefined && builderMeta.defaultRun !== undefined) {
373
- defaultRun = builderMeta.defaultRun;
374
- }
375
- // Extract .step("name", ...) chains from the full scope
376
- const steps = extractSteps(scope);
377
- const result = {
378
- type: "test",
379
- id,
380
- exportName,
381
- location: { line, col: 1 },
382
- };
383
- if (name)
384
- result.name = name;
385
- if (tags && tags.length > 0)
386
- result.tags = tags;
387
- if (timeout !== undefined)
388
- result.timeout = timeout;
389
- if (requires !== undefined)
390
- result.requires = requires;
391
- if (defaultRun !== undefined)
392
- result.defaultRun = defaultRun;
393
- if (variant)
394
- result.variant = variant;
395
- if (steps.length > 0)
396
- result.steps = steps;
397
- if (parallel)
398
- result.parallel = true;
399
- return result;
400
- }
401
- // ---------------------------------------------------------------------------
402
136
  // Alias discovery (auto-detect test.extend / task.extend)
403
137
  // ---------------------------------------------------------------------------
404
138
  /**
@@ -426,480 +160,4 @@ export function extractAliasesFromSource(content) {
426
160
  }
427
161
  return aliases;
428
162
  }
429
- // ---------------------------------------------------------------------------
430
- // Public API
431
- // ---------------------------------------------------------------------------
432
- /**
433
- * Extract test metadata from TypeScript source using static analysis (regex).
434
- *
435
- * Recognizes the following patterns:
436
- * - `export const x = test("id", fn)` — simple test with string ID
437
- * - `export const x = test({ id, name, tags }, fn)` — simple test with meta
438
- * - `export const x = test("id").step(...)` — builder with steps
439
- * - `export const x = test.each(data)("id-$key", fn)` — data-driven
440
- * - `export const x = test.pick(examples)("id-$_pick", fn)` — example selection
441
- *
442
- * This is a pure function — no file system or runtime access needed.
443
- *
444
- * @param content - TypeScript source code
445
- * @param customFns - Additional function names discovered via `extractAliasesFromSource`.
446
- * When provided, these names are matched alongside `test` and `task`.
447
- * When omitted, falls back to `*Test` / `*Task` convention matching.
448
- * @returns Array of extracted export metadata
449
- */
450
- export function extractFromSource(content, customFns) {
451
- const results = [];
452
- const stripped = stripComments(content);
453
- // Build the function-name alternation — either explicit aliases or convention fallback
454
- const alt = buildFnAlternation(customFns);
455
- const exportPattern = new RegExp(`export\\s+const\\s+(\\w+)\\s*=\\s*(${alt})\\b`, "g");
456
- const matches = [];
457
- let m;
458
- while ((m = exportPattern.exec(stripped)) !== null) {
459
- matches.push({
460
- exportName: m[1],
461
- offset: m.index,
462
- afterTest: m.index + m[0].length,
463
- });
464
- }
465
- for (let i = 0; i < matches.length; i++) {
466
- const { exportName, offset, afterTest } = matches[i];
467
- // Scope from right after the function name to the start of the next export (or EOF)
468
- const endOffset = i + 1 < matches.length ? matches[i + 1].offset : stripped.length;
469
- const scope = stripped.substring(afterTest, endOffset);
470
- const line = getLineNumber(stripped, offset);
471
- const meta = parseTestDeclaration(scope, exportName, line);
472
- if (meta)
473
- results.push(meta);
474
- }
475
- return results;
476
- }
477
- /**
478
- * Create a static metadata extractor that uses file system to read content.
479
- *
480
- * Aliases can be supplied at two levels:
481
- * - `customFns` (construction-time): baked-in aliases known upfront.
482
- * - `runtimeFns` (call-time): aliases discovered during a Scanner two-phase
483
- * scan. These are merged with `customFns` so the extractor benefits from
484
- * aliases discovered after construction.
485
- *
486
- * @param readFile - Function to read file content as string
487
- * @param customFns - Additional function names (from alias discovery)
488
- * @returns MetadataExtractor function
489
- */
490
- export function createStaticExtractor(readFile, customFns) {
491
- return async (filePath, runtimeFns) => {
492
- const content = await readFile(filePath);
493
- // Merge construction-time and call-time aliases
494
- const merged = customFns || runtimeFns ? [...new Set([...(customFns ?? []), ...(runtimeFns ?? [])])] : undefined;
495
- return extractFromSource(content, merged);
496
- };
497
- }
498
- /**
499
- * Extract test.pick() metadata from TypeScript source for CodeLens rendering.
500
- *
501
- * Handles data source patterns:
502
- * 1. Inline object literal: `test.pick({ "key1": ..., "key2": ... })`
503
- * 2. JSON import variable: `import X from "./data.json"` then `test.pick(X)`
504
- * 3. fromDir.merge variable: `const X = await fromDir.merge("./dir/")` then `test.pick(X)`
505
- * 4. fromDir variable: `const X = await fromDir("./dir/")` then `test.pick(X)`
506
- * 5. fromDir.concat variable: `const X = await fromDir.concat("./dir/")` then `test.pick(X)`
507
- * 6. fromYaml.map variable: `const X = await fromYaml.map("./file.yaml")` then `test.pick(X)`
508
- * 7. fromJson variable: `const X = await fromJson("./file.json")` then `test.each(X)`
509
- * 8. fromJson.map variable: `const X = await fromJson.map("./file.json")` then `test.pick(X)`
510
- *
511
- * For other patterns (dynamic vars, etc.), returns keys: null.
512
- *
513
- * @param content - TypeScript source code
514
- * @param options - Optional settings
515
- * @param options.customFns - Additional function names discovered via alias scanning.
516
- * @param options.filePath - Source file path. When provided, file-relative
517
- * paths are resolved against this file's directory.
518
- * @param options.projectRoot - Project root. When provided, bare paths are
519
- * resolved against the project root instead of
520
- * the source file directory.
521
- * @returns Array of PickMeta, or empty if no test.pick calls found
522
- */
523
- export function extractPickExamples(content, options) {
524
- const customFns = options?.customFns;
525
- const filePath = options?.filePath;
526
- const projectRoot = options?.projectRoot;
527
- const results = [];
528
- // Build function-name alternation for pick patterns
529
- const fnAlt = customFns && customFns.length > 0
530
- ? [...new Set(["test", "task", ...customFns])].map(s => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")
531
- : "\\w*(?:Test|Task)|test|task";
532
- // Build a map of JSON imports: variable name → file path
533
- const jsonImports = new Map();
534
- const importPattern = /import\s+(\w+)\s+from\s+["']([^"']+\.json)["']/g;
535
- let importMatch;
536
- while ((importMatch = importPattern.exec(content)) !== null) {
537
- jsonImports.set(importMatch[1], importMatch[2]);
538
- }
539
- // Build a map of fromDir.merge assignments: variable name → directory path
540
- const dirMergeSources = new Map();
541
- const dirMergePattern = /(?:const|let)\s+(\w+)\s*=\s*await\s+fromDir\.merge\s*(?:<[^>]*>)?\s*\(\s*["']([^"']+)["']/g;
542
- let dirMergeMatch;
543
- while ((dirMergeMatch = dirMergePattern.exec(content)) !== null) {
544
- dirMergeSources.set(dirMergeMatch[1], dirMergeMatch[2]);
545
- }
546
- // Build a map of fromDir assignments: variable name → directory path
547
- const dirSources = new Map();
548
- const dirPattern = /(?:const|let)\s+(\w+)\s*=\s*await\s+fromDir\s*(?:<[^>]*>)?\s*\(\s*["']([^"']+)["']/g;
549
- let dirMatch;
550
- while ((dirMatch = dirPattern.exec(content)) !== null) {
551
- // Exclude fromDir.merge and fromDir.concat which are already matched
552
- const fullMatch = dirMatch[0];
553
- if (!fullMatch.includes("fromDir.merge") && !fullMatch.includes("fromDir.concat")) {
554
- dirSources.set(dirMatch[1], dirMatch[2]);
555
- }
556
- }
557
- // Build a map of fromDir.concat assignments: variable name → directory path
558
- const dirConcatSources = new Map();
559
- const dirConcatPattern = /(?:const|let)\s+(\w+)\s*=\s*await\s+fromDir\.concat\s*(?:<[^>]*>)?\s*\(\s*["']([^"']+)["']/g;
560
- let dirConcatMatch;
561
- while ((dirConcatMatch = dirConcatPattern.exec(content)) !== null) {
562
- dirConcatSources.set(dirConcatMatch[1], dirConcatMatch[2]);
563
- }
564
- // Build a map of fromYaml.map assignments: variable name → file path
565
- const yamlMapSources = new Map();
566
- const yamlMapPattern = /(?:const|let)\s+(\w+)\s*=\s*await\s+fromYaml\.map\s*(?:<[^>]*>)?\s*\(\s*["']([^"']+)["']/g;
567
- let yamlMapMatch;
568
- while ((yamlMapMatch = yamlMapPattern.exec(content)) !== null) {
569
- yamlMapSources.set(yamlMapMatch[1], yamlMapMatch[2]);
570
- }
571
- // Build a map of fromJson assignments: variable name → file path
572
- const jsonLoaderSources = new Map();
573
- const jsonLoaderPattern = /(?:const|let)\s+(\w+)\s*=\s*await\s+fromJson\s*(?:<[^>]*>)?\s*\(\s*["']([^"']+)["']/g;
574
- let jsonLoaderMatch;
575
- while ((jsonLoaderMatch = jsonLoaderPattern.exec(content)) !== null) {
576
- // Exclude fromJson.map which is matched separately
577
- if (!jsonLoaderMatch[0].includes("fromJson.map")) {
578
- jsonLoaderSources.set(jsonLoaderMatch[1], jsonLoaderMatch[2]);
579
- }
580
- }
581
- // Build a map of fromJson.map assignments: variable name → file path
582
- const jsonMapSources = new Map();
583
- const jsonMapPattern = /(?:const|let)\s+(\w+)\s*=\s*await\s+fromJson\.map\s*(?:<[^>]*>)?\s*\(\s*["']([^"']+)["']/g;
584
- let jsonMapMatch;
585
- while ((jsonMapMatch = jsonMapPattern.exec(content)) !== null) {
586
- jsonMapSources.set(jsonMapMatch[1], jsonMapMatch[2]);
587
- }
588
- // ── Pattern 1: Inline object literal ────────────────────────────────────
589
- const inlinePickPattern = new RegExp(`export\\s+const\\s+(\\w+)\\s*=\\s*(?:${fnAlt})\\s*\\.pick\\s*\\(\\s*\\{([\\s\\S]*?)\\}\\s*\\)\\s*\\(\\s*(?:["']([^"']+)["']|\\{\\s*id:\\s*["']([^"']+)["'])`, "g");
590
- let match;
591
- while ((match = inlinePickPattern.exec(content)) !== null) {
592
- const exportName = match[1];
593
- const objectBody = match[2];
594
- const testId = match[3] ?? match[4];
595
- const line = getLineNumber(content, match.index);
596
- const keys = [];
597
- let depth = 0;
598
- for (let i = 0; i < objectBody.length; i++) {
599
- const ch = objectBody[i];
600
- if (ch === "{" || ch === "[") {
601
- depth++;
602
- }
603
- else if (ch === "}" || ch === "]") {
604
- depth--;
605
- }
606
- else if (depth === 0) {
607
- const remaining = objectBody.slice(i);
608
- const keyMatch = remaining.match(/^(?:["']([^"']+)["']|([a-zA-Z_]\w*))\s*:/);
609
- if (keyMatch) {
610
- keys.push(keyMatch[1] || keyMatch[2]);
611
- i += keyMatch[0].length - 1;
612
- }
613
- }
614
- }
615
- results.push({
616
- testId,
617
- line,
618
- exportName,
619
- keys: keys.length > 0 ? keys : null,
620
- dataSource: keys.length > 0 ? { type: "inline" } : undefined,
621
- });
622
- }
623
- // ── Pattern 2: Variable reference ────────────────────────────────────────
624
- const varPickPattern = new RegExp(`export\\s+const\\s+(\\w+)\\s*=\\s*(?:${fnAlt})\\s*\\.pick\\s*\\(\\s*(\\w+)\\s*\\)\\s*\\(\\s*(?:["']([^"']+)["']|\\{\\s*id:\\s*["']([^"']+)["'])`, "g");
625
- while ((match = varPickPattern.exec(content)) !== null) {
626
- const exportName = match[1];
627
- const varName = match[2];
628
- const testId = match[3] ?? match[4];
629
- const line = getLineNumber(content, match.index);
630
- // Check JSON import
631
- const jsonPath = jsonImports.get(varName);
632
- if (jsonPath) {
633
- results.push({
634
- testId,
635
- line,
636
- exportName,
637
- keys: null,
638
- dataSource: {
639
- type: "json-import",
640
- path: resolveDataPath(jsonPath, {
641
- filePath,
642
- projectRoot,
643
- }).resolvedPath,
644
- },
645
- });
646
- continue;
647
- }
648
- // Check fromDir.merge
649
- const dirMergePath = dirMergeSources.get(varName);
650
- if (dirMergePath) {
651
- results.push({
652
- testId,
653
- line,
654
- exportName,
655
- keys: null,
656
- dataSource: {
657
- type: "dir-merge",
658
- path: resolveDataPath(dirMergePath, {
659
- filePath,
660
- projectRoot,
661
- }).resolvedPath,
662
- },
663
- });
664
- continue;
665
- }
666
- // Check fromDir
667
- const dirPathVal = dirSources.get(varName);
668
- if (dirPathVal) {
669
- results.push({
670
- testId,
671
- line,
672
- exportName,
673
- keys: null,
674
- dataSource: {
675
- type: "dir",
676
- path: resolveDataPath(dirPathVal, {
677
- filePath,
678
- projectRoot,
679
- }).resolvedPath,
680
- },
681
- });
682
- continue;
683
- }
684
- // Check fromDir.concat
685
- const dirConcatPath = dirConcatSources.get(varName);
686
- if (dirConcatPath) {
687
- results.push({
688
- testId,
689
- line,
690
- exportName,
691
- keys: null,
692
- dataSource: {
693
- type: "dir-concat",
694
- path: resolveDataPath(dirConcatPath, {
695
- filePath,
696
- projectRoot,
697
- }).resolvedPath,
698
- },
699
- });
700
- continue;
701
- }
702
- // Check fromYaml.map
703
- const yamlMapPath = yamlMapSources.get(varName);
704
- if (yamlMapPath) {
705
- results.push({
706
- testId,
707
- line,
708
- exportName,
709
- keys: null,
710
- dataSource: {
711
- type: "yaml-map",
712
- path: resolveDataPath(yamlMapPath, {
713
- filePath,
714
- projectRoot,
715
- }).resolvedPath,
716
- },
717
- });
718
- continue;
719
- }
720
- // Check fromJson
721
- const jsonLoaderPath = jsonLoaderSources.get(varName);
722
- if (jsonLoaderPath) {
723
- results.push({
724
- testId,
725
- line,
726
- exportName,
727
- keys: null,
728
- dataSource: {
729
- type: "json-loader",
730
- path: resolveDataPath(jsonLoaderPath, {
731
- filePath,
732
- projectRoot,
733
- }).resolvedPath,
734
- },
735
- });
736
- continue;
737
- }
738
- // Check fromJson.map
739
- const jsonMapPath = jsonMapSources.get(varName);
740
- if (jsonMapPath) {
741
- results.push({
742
- testId,
743
- line,
744
- exportName,
745
- keys: null,
746
- dataSource: {
747
- type: "json-map",
748
- path: resolveDataPath(jsonMapPath, {
749
- filePath,
750
- projectRoot,
751
- }).resolvedPath,
752
- },
753
- });
754
- continue;
755
- }
756
- // Unknown variable
757
- results.push({
758
- testId,
759
- line,
760
- exportName,
761
- keys: null,
762
- dataSource: undefined,
763
- });
764
- }
765
- return results;
766
- }
767
- /**
768
- * Extract contract.http()/contract.grpc()/etc. metadata from TypeScript source.
769
- *
770
- * Statically extracts:
771
- * - Contract ID, endpoint, export name, line number
772
- * - Each case key with its line number
773
- * - Expected status code (if literal number)
774
- * - Deferred reason (if literal string)
775
- *
776
- * @param content - TypeScript source code
777
- * @returns Array of ContractStaticMeta, or empty if no contract calls found
778
- */
779
- export function extractContractCases(content) {
780
- const results = [];
781
- // Match: export const X = contract.http("id", {
782
- const contractPattern = /export\s+const\s+(\w+)\s*=\s*contract\.(\w+)\s*\(\s*["']([^"']+)["']\s*,\s*\{/g;
783
- let contractMatch;
784
- while ((contractMatch = contractPattern.exec(content)) !== null) {
785
- const exportName = contractMatch[1];
786
- const protocol = contractMatch[2];
787
- const contractId = contractMatch[3];
788
- const line = getLineNumber(content, contractMatch.index);
789
- const afterContract = content.slice(contractMatch.index + contractMatch[0].length);
790
- // Find the cases: { ... } block
791
- const casesStart = afterContract.indexOf("cases:");
792
- // Extract contract-level fields from the region BEFORE cases: to avoid
793
- // matching case-level fields inside the cases block.
794
- const specHeader = casesStart !== -1 ? afterContract.slice(0, casesStart) : afterContract;
795
- let endpoint = "";
796
- const endpointMatch = specHeader.match(/endpoint\s*:\s*["']([^"']+)["']/);
797
- if (endpointMatch) {
798
- endpoint = endpointMatch[1];
799
- }
800
- let description;
801
- const descriptionMatch = specHeader.match(/description\s*:\s*["']([^"']+)["']/);
802
- if (descriptionMatch) {
803
- description = descriptionMatch[1];
804
- }
805
- let feature;
806
- const featureMatch = specHeader.match(/feature\s*:\s*["']([^"']+)["']/);
807
- if (featureMatch) {
808
- feature = featureMatch[1];
809
- }
810
- if (casesStart === -1) {
811
- results.push({ contractId, exportName, line, endpoint, protocol, description, feature, cases: [] });
812
- continue;
813
- }
814
- // Find the opening brace after "cases:"
815
- const afterCases = afterContract.slice(casesStart);
816
- const braceIdx = afterCases.indexOf("{");
817
- if (braceIdx === -1) {
818
- results.push({ contractId, exportName, line, endpoint, protocol, description, feature, cases: [] });
819
- continue;
820
- }
821
- // Extract top-level keys in the cases object by tracking brace depth
822
- const casesContent = afterCases.slice(braceIdx);
823
- const cases = [];
824
- const topLevelKeys = [];
825
- let depth = 0;
826
- for (let i = 0; i < casesContent.length; i++) {
827
- if (casesContent[i] === "{") {
828
- if (depth === 1) {
829
- // Look backward for the key name
830
- const before = casesContent.slice(0, i).trimEnd();
831
- const keyMatch = before.match(/["']?(\w+)["']?\s*:\s*$/);
832
- if (keyMatch) {
833
- topLevelKeys.push({ key: keyMatch[1], offset: i });
834
- }
835
- }
836
- depth++;
837
- }
838
- else if (casesContent[i] === "}") {
839
- depth--;
840
- if (depth === 0)
841
- break;
842
- }
843
- }
844
- for (const { key, offset } of topLevelKeys) {
845
- const absoluteOffset = contractMatch.index +
846
- contractMatch[0].length +
847
- casesStart +
848
- braceIdx +
849
- offset;
850
- const caseLine = getLineNumber(content, absoluteOffset);
851
- // Extract case body between matching braces
852
- let caseDepth = 0;
853
- let caseEnd = offset;
854
- for (let i = offset; i < casesContent.length; i++) {
855
- if (casesContent[i] === "{")
856
- caseDepth++;
857
- else if (casesContent[i] === "}") {
858
- caseDepth--;
859
- if (caseDepth === 0) {
860
- caseEnd = i;
861
- break;
862
- }
863
- }
864
- }
865
- const caseBody = casesContent.slice(offset, caseEnd + 1);
866
- let description;
867
- const descMatch = caseBody.match(/description\s*:\s*["']([^"']+)["']/);
868
- if (descMatch)
869
- description = descMatch[1];
870
- let expectStatus;
871
- const statusMatch = caseBody.match(/status\s*:\s*(\d+)/);
872
- if (statusMatch)
873
- expectStatus = parseInt(statusMatch[1], 10);
874
- let deferred;
875
- const deferredMatch = caseBody.match(/deferred\s*:\s*["']([^"']+)["']/);
876
- if (deferredMatch)
877
- deferred = deferredMatch[1];
878
- let requires;
879
- const requiresMatch = caseBody.match(/requires\s*:\s*["'](headless|browser|out-of-band)["']/);
880
- if (requiresMatch)
881
- requires = requiresMatch[1];
882
- let defaultRun;
883
- const defaultRunMatch = caseBody.match(/defaultRun\s*:\s*["'](always|opt-in)["']/);
884
- if (defaultRunMatch)
885
- defaultRun = defaultRunMatch[1];
886
- let given;
887
- const givenMatch = caseBody.match(/given\s*:\s*["']([^"']+)["']/);
888
- if (givenMatch)
889
- given = givenMatch[1];
890
- cases.push({
891
- key,
892
- line: caseLine,
893
- description,
894
- expectStatus,
895
- deferred,
896
- requires,
897
- defaultRun,
898
- given,
899
- });
900
- }
901
- results.push({ contractId, exportName, line, endpoint, protocol, description, feature, cases });
902
- }
903
- return results;
904
- }
905
163
  //# sourceMappingURL=extractor-static.js.map