@opencraw/studio 0.0.6 → 0.0.7

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/index.esm.js CHANGED
@@ -3,7 +3,7 @@ import { createServer } from 'node:http';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { z } from 'zod';
5
5
  import { randomBytes, createHash } from 'node:crypto';
6
- import { BrowserClient, HttpClient, NODE_ID_ATTRIBUTE, readRecipeSource, parseInputRecipe, parseOutputRecipe, bindRecipeSet, memorySink, createCrawler, traceLine, countMatches, RecipeBindingError, RecipeValidationError } from '@opencraw/core';
6
+ import { readRecipeSource, parseInputRecipe, parseOutputRecipe, bindRecipeSet, memorySink, createCrawler, traceLine, resolveMissingPolicy, BrowserClient, HttpClient, NODE_ID_ATTRIBUTE, countMatches, RecipeBindingError, RecipeValidationError } from '@opencraw/core';
7
7
  import { load } from 'cheerio';
8
8
  import { writeFile } from 'node:fs/promises';
9
9
  import { existsSync, createReadStream } from 'node:fs';
@@ -119,7 +119,32 @@ const inferSelectorCommandSchema = z.object({
119
119
  path: z.string().min(1),
120
120
  nodeIds: z.union([onePickedNodeSchema, twoPickedNodesSchema])
121
121
  });
122
- const studioCommandSchema = z.discriminatedUnion('type', [openWorkspaceCommandSchema, runSampleCommandSchema, stopRunCommandSchema, saveRecipeCommandSchema, fetchStartPageCommandSchema, saveOutlineCommandSchema, takeSnapshotCommandSchema, verifySelectorCommandSchema, inferSelectorCommandSchema]);
122
+ /** A missing value in an emitted record, by its position in the last sample run's records (the order they streamed in, matching the UI's own `records` list). */
123
+ const missingWhyTargetSchema = z.object({
124
+ kind: z.literal('missing'),
125
+ recipeId: z.string().min(1),
126
+ recordIndex: z.int().nonnegative(),
127
+ field: z.string().min(1)
128
+ });
129
+ /** A rejected record, by its position in the last sample run's `rejectedRecords`. */
130
+ const rejectedWhyTargetSchema = z.object({
131
+ kind: z.literal('rejected'),
132
+ recipeId: z.string().min(1),
133
+ rejectedIndex: z.int().nonnegative()
134
+ });
135
+ const whyTargetSchema = z.discriminatedUnion('kind', [missingWhyTargetSchema, rejectedWhyTargetSchema]);
136
+ /**
137
+ * Explains one missing or rejected value from the recipe's last sample run
138
+ * (`explain-why.use-case.ts`, issue #92's Why? tab): the mapping trace and
139
+ * scope snapshot `run-sample` already kept, the step that bound the value's
140
+ * source id and whether it ran, was skipped or found nothing, and the
141
+ * missing-value policy that applied.
142
+ */
143
+ const explainWhyCommandSchema = z.object({
144
+ type: z.literal('explain-why'),
145
+ target: whyTargetSchema
146
+ });
147
+ const studioCommandSchema = z.discriminatedUnion('type', [openWorkspaceCommandSchema, runSampleCommandSchema, stopRunCommandSchema, saveRecipeCommandSchema, fetchStartPageCommandSchema, saveOutlineCommandSchema, takeSnapshotCommandSchema, verifySelectorCommandSchema, inferSelectorCommandSchema, explainWhyCommandSchema]);
123
148
 
124
149
  /** What `take-snapshot` answers with: the rewritten document ready for the content pane's sandboxed iframe (studio plan §3.1, issue #91). */
125
150
  const snapshotViewSchema = z.object({
@@ -186,12 +211,32 @@ const traceLineEventSchema = z.object({
186
211
  type: z.literal('trace-line'),
187
212
  line: z.string()
188
213
  });
189
- /** One record a sample run emitted. */
214
+ /** One transform's effect within a field's trace: its `op` and the value it returned. */
215
+ const traceStepSchema = z.object({
216
+ op: z.string(),
217
+ value: z.unknown()
218
+ });
219
+ /** One entry of a mapping trace field: what a rule's `from` read, and the value after each transform, in order (`@opencraw/core`'s `FieldTrace`). */
220
+ const fieldTraceSchema = z.object({
221
+ from: z.unknown(),
222
+ steps: z.array(traceStepSchema)
223
+ });
224
+ /** One record a sample run emitted: `scope` (what it was mapped from) and `mapping` (each field's trace) ride along, so the Record tab (#92) can show a transform chain's real values without a second round trip. */
190
225
  const recordEventSchema = z.object({
191
226
  type: z.literal('record'),
192
227
  recipeId: z.string(),
193
228
  key: z.string().nullable(),
194
- data: z.record(z.string(), z.unknown())
229
+ data: z.record(z.string(), z.unknown()),
230
+ scope: z.record(z.string(), z.unknown()).optional(),
231
+ mapping: z.record(z.string(), fieldTraceSchema).optional()
232
+ });
233
+ /** One record a sample run rejected (a field's `skip-record` policy on a missing or uncoercible value). */
234
+ const recordRejectedEventSchema = z.object({
235
+ type: z.literal('record-rejected'),
236
+ recipeId: z.string(),
237
+ field: z.string(),
238
+ reason: z.string(),
239
+ scope: z.record(z.string(), z.unknown()).optional()
195
240
  });
196
241
  /** A sample run ended: how many records, and why it stopped when it did not finish on its own. */
197
242
  const runFinishedEventSchema = z.object({
@@ -208,7 +253,7 @@ const runFinishedEventSchema = z.object({
208
253
  const workspaceChangedEventSchema = z.object({
209
254
  type: z.literal('workspace-changed')
210
255
  });
211
- const studioEventSchema = z.discriminatedUnion('type', [traceLineEventSchema, recordEventSchema, runFinishedEventSchema, workspaceChangedEventSchema]);
256
+ const studioEventSchema = z.discriminatedUnion('type', [traceLineEventSchema, recordEventSchema, recordRejectedEventSchema, runFinishedEventSchema, workspaceChangedEventSchema]);
212
257
 
213
258
  /** One problem found on a recipe file, with the JSON path it lives at. */
214
259
  const recipeIssueSchema = z.object({
@@ -256,6 +301,20 @@ const startPageViewSchema = z.object({
256
301
  html: z.string()
257
302
  });
258
303
 
304
+ /** What `explain-why` answers with: the sentence (`why-sentence.mapper.ts`'s `buildWhySentence`), plus the structured facts a click can act on — a link back to the step that bound the value, the policy that applied. */
305
+ const whyViewSchema = z.object({
306
+ sentence: z.string(),
307
+ field: z.string(),
308
+ recipeId: z.string(),
309
+ outcome: z.enum(['missing', 'rejected']),
310
+ /** The JSON path (`steps.2.steps.0`) of the step that bound the value's source id, when one was found in the recipe. */
311
+ stepPath: z.string().optional(),
312
+ /** The missing-value policy that applied (`nullable`, `default`, `null`, `fail`, `skip-record`), when one could be resolved. */
313
+ policy: z.string().optional(),
314
+ /** The engine's own rejection reason, for a rejected record. */
315
+ reason: z.string().optional()
316
+ });
317
+
259
318
  /** The cookie set on the page's own document response, so its own asset and API requests carry the token too. */
260
319
  const TOKEN_COOKIE = 'opencraw_token';
261
320
  /** A random token, generated once at server startup and required on every request. */
@@ -298,6 +357,457 @@ function cookieToken(header) {
298
357
  return undefined;
299
358
  }
300
359
 
360
+ /**
361
+ * Finds and parses one input recipe of a workspace folder, and the output
362
+ * recipe it names.
363
+ *
364
+ * @param folder - The workspace folder.
365
+ * @param recipeId - The input recipe's id.
366
+ * @returns The parsed pair.
367
+ * @throws RecipeValidationError when either recipe does not parse.
368
+ * @throws Error when the input or its output is not in the folder.
369
+ */
370
+ async function loadRecipePair(folder, recipeId) {
371
+ const documents = await readRecipeSource(folder);
372
+ const inputDocument = documents.find(document => kindOf(document.content) === 'input' && idOf(document.content) === recipeId);
373
+ if (inputDocument === undefined) throw new Error(`no input recipe "${recipeId}" in this workspace`);
374
+ const input = parseInputRecipe(inputDocument.content, inputDocument.source);
375
+ const outputDocument = documents.find(document => kindOf(document.content) === 'output' && idOf(document.content) === input.output);
376
+ if (outputDocument === undefined) throw new Error(`no output recipe "${input.output}" in this workspace`);
377
+ return {
378
+ input,
379
+ output: parseOutputRecipe(outputDocument.content, outputDocument.source)
380
+ };
381
+ }
382
+ function kindOf(content) {
383
+ return content?.kind;
384
+ }
385
+ function idOf(content) {
386
+ return content?.id;
387
+ }
388
+
389
+ /**
390
+ * Runs one input recipe of a workspace folder as a sample: `debug: true` (so
391
+ * each `record:emit` carries its scope snapshot and mapping trace, and each
392
+ * `record:reject` its scope), an in-memory sink, and `budget` as the
393
+ * crawler's `sample` (`@opencraw/core`'s `CrawlOptions.sample`, #89). Every
394
+ * engine event becomes a trace line with the same `traceLine` formatter the
395
+ * cli's `--trace` uses; every emitted record reaches `callbacks.onRecord` as
396
+ * it happens, not only in the final result. Step events (`step:start`,
397
+ * `step:skip`, `step:retry`) are aggregated by path into `result.steps`, so
398
+ * `explain-why` (#92) can later say whether the step that bound a mapping's
399
+ * source id ran, was skipped, or retried — without a second run.
400
+ *
401
+ * @param folder - The workspace folder the recipe lives in.
402
+ * @param recipeId - The input recipe to run.
403
+ * @param budget - The sample budget; unset runs the recipe to completion (still capped by its own `limits`).
404
+ * @param callbacks - Where trace lines and records go as the run proceeds.
405
+ * @param browser - Browser launch settings for a web-mode recipe (an executable path override, headless…); the server-wide setting `studio-http.use-case.ts`'s `StudioServerOptions.browser` carries in, e.g. `OPENCRAW_CHROMIUM` in a sandbox with no full Playwright install.
406
+ * @returns A handle: `stop` closes the crawler cleanly, `result` resolves when the run ends.
407
+ * @throws RecipeValidationError, RecipeBindingError, Error when the recipe or its output cannot be loaded or bound.
408
+ */
409
+ async function runSample(folder, recipeId, budget, callbacks, browser) {
410
+ const {
411
+ input,
412
+ output
413
+ } = await loadRecipePair(folder, recipeId);
414
+ const set = bindRecipeSet(output, [input]);
415
+ const sink = memorySink();
416
+ const records = [];
417
+ const rejectedRecords = [];
418
+ const steps = new Map();
419
+ const crawler = createCrawler({
420
+ sink,
421
+ debug: true,
422
+ sample: budget,
423
+ browser,
424
+ onEvent: event => {
425
+ const line = traceLine(event);
426
+ if (line !== undefined) callbacks.onTraceLine(line);
427
+ trackStep(steps, event);
428
+ if (event.type === 'record:emit') {
429
+ const record = {
430
+ key: event.key,
431
+ data: event.data,
432
+ scope: event.scope,
433
+ mapping: event.mapping
434
+ };
435
+ records.push(record);
436
+ callbacks.onRecord(record);
437
+ } else if (event.type === 'record:reject') {
438
+ const rejected = {
439
+ field: event.field,
440
+ reason: event.reason,
441
+ url: event.url,
442
+ scope: event.scope
443
+ };
444
+ rejectedRecords.push(rejected);
445
+ callbacks.onRejected?.(rejected);
446
+ }
447
+ }
448
+ });
449
+ const result = runToResult(crawler, set, records, rejectedRecords, steps);
450
+ return {
451
+ stop: () => crawler.close(),
452
+ result
453
+ };
454
+ }
455
+ /**
456
+ * Folds one `step:*` event into `steps`, by path: `skip` always wins over an
457
+ * earlier `ran`/`retried` outcome for the same path (a step skipped even
458
+ * once is worth flagging), `retry` wins over a plain `ran`, and a path seen
459
+ * only as `finish`/`start` (no skip, no retry) settles on `ran`. A `forEach`
460
+ * runs its body once per item, so the same path folds repeatedly; this is
461
+ * deliberately a summary across the whole sample, not a per-record trace.
462
+ */
463
+ function trackStep(steps, event) {
464
+ if (event.type === 'step:start') {
465
+ if (!steps.has(event.path)) steps.set(event.path, {
466
+ path: event.path,
467
+ stepType: event.stepType,
468
+ stepId: event.stepId,
469
+ outcome: 'ran'
470
+ });
471
+ return;
472
+ }
473
+ if (event.type === 'step:retry') {
474
+ steps.set(event.path, {
475
+ path: event.path,
476
+ stepType: event.stepType,
477
+ stepId: event.stepId,
478
+ outcome: 'retried',
479
+ error: event.error
480
+ });
481
+ return;
482
+ }
483
+ if (event.type === 'step:skip') {
484
+ steps.set(event.path, {
485
+ path: event.path,
486
+ stepType: event.stepType,
487
+ stepId: event.stepId,
488
+ outcome: 'skipped',
489
+ error: event.error
490
+ });
491
+ }
492
+ }
493
+ async function runToResult(crawler, set, records, rejectedRecords, steps) {
494
+ try {
495
+ const report = await crawler.run(set);
496
+ const recipe = report.recipes[0];
497
+ return {
498
+ recipeId: recipe.recipeId,
499
+ emitted: recipe.emitted,
500
+ rejected: recipe.rejected,
501
+ duplicates: recipe.duplicates,
502
+ durationMs: recipe.durationMs,
503
+ error: recipe.error,
504
+ stoppedBy: recipe.stoppedBy,
505
+ records,
506
+ rejectedRecords,
507
+ // eslint-disable-next-line unicorn/prefer-iterator-to-array -- `.toArray()` needs a `lib` newer than this repo's `es2022` (tsconfig.base.json)
508
+ steps: [...steps.values()]
509
+ };
510
+ } finally {
511
+ await crawler.close();
512
+ }
513
+ }
514
+
515
+ /** @returns A fresh, empty cache. */
516
+ function createLastRunCache() {
517
+ return {
518
+ entries: new Map()
519
+ };
520
+ }
521
+ /** Records a finished sample run, replacing whatever this recipe's cache entry held before. */
522
+ function recordLastRun(cache, recipeId, result) {
523
+ cache.entries.set(recipeId, {
524
+ recipeId,
525
+ result
526
+ });
527
+ }
528
+ /** @returns The recipe's last completed sample run, or `undefined` when none has finished yet. */
529
+ function lastRunOf(cache, recipeId) {
530
+ return cache.entries.get(recipeId);
531
+ }
532
+
533
+ /**
534
+ * A best-effort hint about what a missing value's bound id actually held in
535
+ * the scope snapshot: the keys of an object, or the length of a list — the
536
+ * fallback when there is no per-item DOM snapshot to compare a selector
537
+ * against (see `why-sentence.mapper.ts`'s doc comment for why the richer,
538
+ * "the item has `.price_new`" hint is out of this phase's scope).
539
+ *
540
+ * @param scope - The record's scope snapshot (`SampleRunRecord.scope`/`SampleRunRejected.scope`).
541
+ * @param boundId - The leading id the rule's `from` reads.
542
+ * @returns A short clause (no leading punctuation), or `undefined` when the id is not in scope or nothing useful can be said about it.
543
+ */
544
+ function hintFor(scope, boundId) {
545
+ if (scope === undefined || boundId === undefined) return undefined;
546
+ const value = scope[boundId];
547
+ if (value === undefined) return undefined;
548
+ if (Array.isArray(value)) return value.length === 0 ? undefined : `"${boundId}" is a list of ${value.length}, not a single value`;
549
+ if (typeof value === 'object' && value !== null) {
550
+ const keys = Object.keys(value);
551
+ return keys.length === 0 ? undefined : `"${boundId}" has: ${keys.slice(0, 5).join(', ')}`;
552
+ }
553
+ return undefined;
554
+ }
555
+
556
+ /**
557
+ * Builds the Why? tab's sentence (studio plan §4.4, issue #92) from the
558
+ * facts `why.use-case.ts` gathers: the value the mapping read (and each
559
+ * transform's effect on it), the step that bound its source id and whether
560
+ * it ran, was skipped, or bound nothing this recipe knows of, the
561
+ * missing-value policy that applied, and a hint about what was actually
562
+ * there instead.
563
+ *
564
+ * This is the scope-snapshot-only half of the design: phase 2's per-item DOM
565
+ * snapshot (the CSS-selector "the item has `.price_new`" hint the issue's
566
+ * example shows) is not wired in here — `sample-run` keeps the mapping's
567
+ * scope snapshot (bound ids and their values), not a per-`forEach`-item DOM
568
+ * capture, and building that link is a bigger integration than this phase's
569
+ * budget covers (see this package's `page-snapshot` slice, which captures a
570
+ * step's page for the content pane, not per-iteration). `hint`, when given,
571
+ * is instead built from the scope snapshot itself (`item-hint.algorithm.ts`).
572
+ *
573
+ * @param facts - What is known about the value.
574
+ * @returns One sentence, always ending in a period.
575
+ */
576
+ function buildWhySentence(facts) {
577
+ return facts.outcome === 'rejected' ? rejectedSentence(facts) : missingSentence(facts);
578
+ }
579
+ function rejectedSentence(facts) {
580
+ const parts = [`"${facts.field}" was rejected: ${facts.reason ?? 'the engine gave no reason'}`];
581
+ if (facts.policy !== undefined) parts.push(` (policy: ${facts.policy})`);
582
+ return `${parts.join('')}.`;
583
+ }
584
+ function missingSentence(facts) {
585
+ const parts = [`"${facts.field}" is missing`, stepClause(facts)];
586
+ if (facts.policy !== undefined) parts.push(`; left ${facts.policy === 'null' ? 'null' : facts.policy} by policy`);
587
+ if (facts.hint !== undefined) parts.push(`; ${facts.hint}`);
588
+ return `${parts.join('')}.`;
589
+ }
590
+ function stepClause(facts) {
591
+ const boundId = facts.boundId ?? facts.field;
592
+ const step = facts.step;
593
+ if (step === undefined) return ': no step in this recipe binds its source';
594
+ const label = step.stepId === undefined ? describeStepType(step.stepType) : `${describeStepType(step.stepType)} "${step.stepId}"`;
595
+ if (step.outcome === 'not-found') return `: no step in this recipe binds "${boundId}"`;
596
+ if (step.outcome === 'skipped') return `: ${label} (${step.path}), which binds "${boundId}", was skipped${step.error === undefined ? '' : ` (${step.error})`}`;
597
+ if (step.outcome === 'retried') return `: ${label} (${step.path}), which binds "${boundId}", had to retry${step.error === undefined ? '' : ` (${step.error})`} and still found nothing for "${facts.field}"`;
598
+ return `: ${label} (${step.path}) ran and bound "${boundId}", but "${facts.field}" reads ${sourceOf(facts)} from it, which was empty`;
599
+ }
600
+ function sourceOf(facts) {
601
+ return facts.trace?.from === undefined ? 'a path' : JSON.stringify(facts.trace.from);
602
+ }
603
+ /** A step's type read as a short phrase, matching the Steps outline's own vocabulary (studio plan §4.1: "Go to…", "Read… from…"). */
604
+ function describeStepType(stepType) {
605
+ switch (stepType) {
606
+ case 'extract':
607
+ {
608
+ return 'the Read step';
609
+ }
610
+ case 'request':
611
+ {
612
+ return 'the request step';
613
+ }
614
+ case 'forEach':
615
+ {
616
+ return 'the loop';
617
+ }
618
+ case 'paginate':
619
+ {
620
+ return 'the pagination';
621
+ }
622
+ case 'goto':
623
+ {
624
+ return 'the Go to step';
625
+ }
626
+ default:
627
+ {
628
+ return `the ${stepType} step`;
629
+ }
630
+ }
631
+ }
632
+
633
+ /**
634
+ * Explains one missing or rejected value from a recipe's last sample run
635
+ * (issue #92's Why? tab, studio plan §4.4): the mapping trace and scope
636
+ * snapshot `run-sample` kept, the recipe's own rule and field for the
637
+ * missing-value policy, and the step that bound the value's source id — did
638
+ * it run, was it skipped, or does no step in this recipe bind it at all.
639
+ *
640
+ * @param folder - The workspace folder (to re-read the recipe pair: the last
641
+ * run only kept the run's own data, not the recipe text, and a save may
642
+ * have happened since).
643
+ * @param cache - The server's last-run cache (`sample-run`'s `LastRunCache`).
644
+ * @param loadPair - Loads the input/output recipe pair; injected so this
645
+ * stays testable without a real workspace folder (`sample-run`'s
646
+ * `loadRecipePair` in production).
647
+ * @param target - What was clicked.
648
+ * @returns The explanation.
649
+ * @throws Error when no sample run has finished for this recipe yet, or the target does not name a real record/field.
650
+ */
651
+ async function explainWhy(folder, cache, loadPair, target) {
652
+ const entry = lastRunOf(cache, target.recipeId);
653
+ if (entry === undefined) throw new Error(`no finished sample run for "${target.recipeId}" yet — run a sample first`);
654
+ const {
655
+ input,
656
+ output
657
+ } = await loadPair(folder, target.recipeId);
658
+ return target.kind === 'rejected' ? explainRejected(target.recipeId, input, output, entry.result.rejectedRecords, entry.result.steps, target.rejectedIndex) : explainMissing(target.recipeId, input, output, entry.result.records, entry.result.steps, target.recordIndex, target.field);
659
+ }
660
+ function explainRejected(recipeId, input, output, rejectedRecords, steps, index) {
661
+ const rejected = rejectedRecords[index];
662
+ if (rejected === undefined) throw new Error(`no rejected record at index ${index}`);
663
+ const rule = ruleFor(input.mapping, rejected.field);
664
+ const field = fieldFor(output.fields, rejected.field);
665
+ const policy = field === undefined ? undefined : resolveMissingPolicy(field, output, rule);
666
+ const boundId = boundIdOf(rule);
667
+ const step = boundId === undefined ? undefined : findStep(input.steps, boundId, steps);
668
+ const hint = hintFor(rejected.scope, boundId);
669
+ const sentence = buildWhySentence({
670
+ field: rejected.field,
671
+ outcome: 'rejected',
672
+ reason: rejected.reason,
673
+ policy,
674
+ boundId,
675
+ step,
676
+ hint
677
+ });
678
+ return {
679
+ sentence,
680
+ field: rejected.field,
681
+ recipeId,
682
+ outcome: 'rejected',
683
+ reason: rejected.reason,
684
+ policy,
685
+ stepPath: step?.path
686
+ };
687
+ }
688
+ function explainMissing(recipeId, input, output, records, steps, index, field) {
689
+ const record = records[index];
690
+ if (record === undefined) throw new Error(`no record at index ${index}`);
691
+ const trace = record.mapping?.[field];
692
+ const rule = ruleFor(input.mapping, field);
693
+ const fieldSpec = fieldFor(output.fields, field);
694
+ const policy = fieldSpec === undefined ? undefined : resolveMissingPolicy(fieldSpec, output, rule);
695
+ const boundId = boundIdOf(rule);
696
+ const step = boundId === undefined ? undefined : findStep(input.steps, boundId, steps);
697
+ const hint = hintFor(record.scope, boundId);
698
+ const sentence = buildWhySentence({
699
+ field,
700
+ outcome: 'missing',
701
+ policy,
702
+ trace,
703
+ boundId,
704
+ step,
705
+ hint
706
+ });
707
+ return {
708
+ sentence,
709
+ field,
710
+ recipeId,
711
+ outcome: 'missing',
712
+ policy,
713
+ stepPath: step?.path
714
+ };
715
+ }
716
+ /**
717
+ * The rule bound to one output field path: a direct match on `input.mapping`
718
+ * (its keys are themselves dotted target paths — `stock.inStock`, `tax` —
719
+ * `map-record.use-case.ts` writes each with `setPath`), or, for a path
720
+ * inside an `each` list's items (`variants[1].size`), the item's own rule
721
+ * inside the list rule's `fields`. Only one level of `each` nesting is
722
+ * resolved; a field inside a nested `each` (an `each` of `each`) is not —
723
+ * out of this phase's scope.
724
+ */
725
+ function ruleFor(mapping, path) {
726
+ const direct = mapping[path];
727
+ if (direct !== undefined) return direct;
728
+ const match = /^([^[]+)\[\d+\]\.(.+)$/.exec(path);
729
+ if (match === null) return undefined;
730
+ const parent = mapping[match[1]];
731
+ return parent !== undefined && 'each' in parent ? parent.fields[match[2]] : undefined;
732
+ }
733
+ /** The output field spec at one dotted path (`stock.inStock`, `variants[1].size`), walking `object`/`array` members the same way `map-record.use-case.ts` builds them. */
734
+ function fieldFor(fields, path) {
735
+ let current = fields;
736
+ let field;
737
+ for (const token of path.split('.')) {
738
+ const match = /^([^[]+)(\[\d+\])?$/.exec(token);
739
+ if (match === null || current === undefined) return undefined;
740
+ field = current[match[1]];
741
+ if (field === undefined) return undefined;
742
+ current = match[2] === undefined ? field.fields : field.items?.fields;
743
+ }
744
+ return field;
745
+ }
746
+ /** The leading id a `from` rule reads (`item` of `item.description`, `price` of `price`); `undefined` for an `each` rule, a `.` (self), or a rule this run found nothing to say about. */
747
+ function boundIdOf(rule) {
748
+ if (rule === undefined || !('from' in rule)) return undefined;
749
+ const first = Array.isArray(rule.from) ? rule.from[0] : rule.from;
750
+ const match = /^([A-Z_][\w-]*)/i.exec(first);
751
+ return match?.[1];
752
+ }
753
+ /**
754
+ * Finds the step of the recipe whose `id` or `as` (a `forEach`'s loop
755
+ * variable) is `boundId`, walking into `forEach`/`paginate`/`if` bodies the
756
+ * same way `scope-outline`'s outline does, and reports how it went over the
757
+ * whole sample run from `stepSummaries` (`sample-run`'s per-path summary).
758
+ *
759
+ * @returns `undefined` only when nothing in `steps` looks like a step at all; a `boundId` that names no real step still gets a fact, with `outcome: 'not-found'`.
760
+ */
761
+ function findStep(steps, boundId, stepSummaries) {
762
+ const found = search(steps, boundId, 'steps');
763
+ if (found === undefined) return {
764
+ path: '',
765
+ stepType: '',
766
+ outcome: 'not-found'
767
+ };
768
+ const summary = stepSummaries.find(entry => entry.path === found.path);
769
+ return {
770
+ path: found.path,
771
+ stepType: found.step.type ?? 'unknown',
772
+ stepId: found.step.id,
773
+ outcome: summary?.outcome ?? 'ran',
774
+ error: summary?.error
775
+ };
776
+ }
777
+ function search(steps, boundId, prefix) {
778
+ for (const [index, step] of steps.entries()) {
779
+ const path = `${prefix}.${index}`;
780
+ if (step.id === boundId || step.as === boundId) return {
781
+ path,
782
+ step
783
+ };
784
+ if (step.steps !== undefined) {
785
+ const nested = search(step.steps, boundId, `${path}.steps`);
786
+ if (nested !== undefined) return nested;
787
+ }
788
+ if (step.else !== undefined) {
789
+ const nested = search(step.else, boundId, `${path}.else`);
790
+ if (nested !== undefined) return nested;
791
+ }
792
+ }
793
+ return undefined;
794
+ }
795
+
796
+ /**
797
+ * Handles `explain-why` (issue #92's Why? tab): builds the sentence for a
798
+ * missing or rejected value from the recipe's last sample run, kept in
799
+ * `state.lastRuns` by `handleRunSample`.
800
+ *
801
+ * @param state - The server state: `folder` must already be set (`open-workspace` first).
802
+ * @param command - The command.
803
+ * @returns The explanation.
804
+ * @throws Error when no workspace is open, no sample run has finished for the target's recipe, or the target does not name a real record/field.
805
+ */
806
+ async function handleExplainWhy(state, command) {
807
+ if (state.folder === undefined) throw new Error('open a workspace first');
808
+ return explainWhy(state.folder, state.lastRuns, loadRecipePair, command.target);
809
+ }
810
+
301
811
  /**
302
812
  * Fetches an input recipe's start page as the engine would see it: a fresh
303
813
  * Playwright page in web mode, core's HTTP client in api mode. Returns plain
@@ -558,100 +1068,6 @@ function keyOf(recipeId, path) {
558
1068
  return `${recipeId}:${path}`;
559
1069
  }
560
1070
 
561
- /**
562
- * Finds and parses one input recipe of a workspace folder, and the output
563
- * recipe it names.
564
- *
565
- * @param folder - The workspace folder.
566
- * @param recipeId - The input recipe's id.
567
- * @returns The parsed pair.
568
- * @throws RecipeValidationError when either recipe does not parse.
569
- * @throws Error when the input or its output is not in the folder.
570
- */
571
- async function loadRecipePair(folder, recipeId) {
572
- const documents = await readRecipeSource(folder);
573
- const inputDocument = documents.find(document => kindOf(document.content) === 'input' && idOf(document.content) === recipeId);
574
- if (inputDocument === undefined) throw new Error(`no input recipe "${recipeId}" in this workspace`);
575
- const input = parseInputRecipe(inputDocument.content, inputDocument.source);
576
- const outputDocument = documents.find(document => kindOf(document.content) === 'output' && idOf(document.content) === input.output);
577
- if (outputDocument === undefined) throw new Error(`no output recipe "${input.output}" in this workspace`);
578
- return {
579
- input,
580
- output: parseOutputRecipe(outputDocument.content, outputDocument.source)
581
- };
582
- }
583
- function kindOf(content) {
584
- return content?.kind;
585
- }
586
- function idOf(content) {
587
- return content?.id;
588
- }
589
-
590
- /**
591
- * Runs one input recipe of a workspace folder as a sample: `debug: true` (so
592
- * each `record:emit` carries its data), an in-memory sink, and `budget` as
593
- * the crawler's `sample` (`@opencraw/core`'s `CrawlOptions.sample`, #89).
594
- * Every engine event becomes a trace line with the same `traceLine`
595
- * formatter the cli's `--trace` uses; every emitted record reaches
596
- * `callbacks.onRecord` as it happens, not only in the final result.
597
- *
598
- * @param folder - The workspace folder the recipe lives in.
599
- * @param recipeId - The input recipe to run.
600
- * @param budget - The sample budget; unset runs the recipe to completion (still capped by its own `limits`).
601
- * @param callbacks - Where trace lines and records go as the run proceeds.
602
- * @param browser - Browser launch settings for a web-mode recipe (an executable path override, headless…); the server-wide setting `studio-http.use-case.ts`'s `StudioServerOptions.browser` carries in, e.g. `OPENCRAW_CHROMIUM` in a sandbox with no full Playwright install.
603
- * @returns A handle: `stop` closes the crawler cleanly, `result` resolves when the run ends.
604
- * @throws RecipeValidationError, RecipeBindingError, Error when the recipe or its output cannot be loaded or bound.
605
- */
606
- async function runSample(folder, recipeId, budget, callbacks, browser) {
607
- const {
608
- input,
609
- output
610
- } = await loadRecipePair(folder, recipeId);
611
- const set = bindRecipeSet(output, [input]);
612
- const sink = memorySink();
613
- const crawler = createCrawler({
614
- sink,
615
- debug: true,
616
- sample: budget,
617
- browser,
618
- onEvent: event => {
619
- const line = traceLine(event);
620
- if (line !== undefined) callbacks.onTraceLine(line);
621
- if (event.type === 'record:emit') callbacks.onRecord({
622
- key: event.key,
623
- data: event.data
624
- });
625
- }
626
- });
627
- const result = runToResult(crawler, set, sink);
628
- return {
629
- stop: () => crawler.close(),
630
- result
631
- };
632
- }
633
- async function runToResult(crawler, set, sink) {
634
- try {
635
- const report = await crawler.run(set);
636
- const recipe = report.recipes[0];
637
- return {
638
- recipeId: recipe.recipeId,
639
- emitted: recipe.emitted,
640
- rejected: recipe.rejected,
641
- duplicates: recipe.duplicates,
642
- durationMs: recipe.durationMs,
643
- error: recipe.error,
644
- stoppedBy: recipe.stoppedBy,
645
- records: sink.records.map(record => ({
646
- key: record.key,
647
- data: record.data
648
- }))
649
- };
650
- } finally {
651
- await crawler.close();
652
- }
653
- }
654
-
655
1071
  /**
656
1072
  * Handles `fetch-start-page`: loads the named input recipe from the current
657
1073
  * workspace and fetches its start point (see `page-snapshot`'s own caveats:
@@ -1546,11 +1962,12 @@ async function saveRecipe(path, recipe) {
1546
1962
  await writeFile(path, `${JSON.stringify(recipe, null, 2)}\n`, 'utf8');
1547
1963
  }
1548
1964
 
1549
- /** @returns Fresh state for a new server instance: no workspace open, no run, no sockets, an empty snapshot cache, Playwright's own default browser. */
1965
+ /** @returns Fresh state for a new server instance: no workspace open, no run, no sockets, an empty snapshot cache, no last runs, Playwright's own default browser. */
1550
1966
  function createStudioState() {
1551
1967
  return {
1552
1968
  sockets: new Set(),
1553
- snapshots: createSnapshotCache()
1969
+ snapshots: createSnapshotCache(),
1970
+ lastRuns: createLastRunCache()
1554
1971
  };
1555
1972
  }
1556
1973
  /**
@@ -1616,13 +2033,25 @@ async function handleRunSample(state, command) {
1616
2033
  type: 'record',
1617
2034
  recipeId: command.recipeId,
1618
2035
  key: record.key,
1619
- data: record.data
2036
+ data: record.data,
2037
+ scope: record.scope,
2038
+ mapping: record.mapping
2039
+ });
2040
+ },
2041
+ onRejected: rejected => {
2042
+ broadcast(state, {
2043
+ type: 'record-rejected',
2044
+ recipeId: command.recipeId,
2045
+ field: rejected.field,
2046
+ reason: rejected.reason,
2047
+ scope: rejected.scope
1620
2048
  });
1621
2049
  }
1622
2050
  }, state.browser);
1623
2051
  state.activeRun = handle;
1624
2052
  void handle.result.then(result => {
1625
2053
  if (state.activeRun === handle) state.activeRun = undefined;
2054
+ recordLastRun(state.lastRuns, command.recipeId, result);
1626
2055
  broadcast(state, {
1627
2056
  type: 'run-finished',
1628
2057
  recipeId: result.recipeId,
@@ -2033,6 +2462,10 @@ function dispatch(command, state) {
2033
2462
  {
2034
2463
  return Promise.resolve(handleInferSelector(state, command));
2035
2464
  }
2465
+ case 'explain-why':
2466
+ {
2467
+ return handleExplainWhy(state, command);
2468
+ }
2036
2469
  }
2037
2470
  }
2038
2471
  function respondJson(response, status, body) {
@@ -2089,5 +2522,5 @@ async function main(argv) {
2089
2522
  process.on('SIGTERM', shutdown);
2090
2523
  }
2091
2524
 
2092
- export { fetchStartPage, fetchStartPageCommandSchema, fieldPickSchema, inferSelectorCommandSchema, inferSelectorViewSchema, loadRecipePair, main, openWorkspace, openWorkspaceCommandSchema, outlineBracketSchema, outlineCardSchema, outlineNodeSchema, outlineViewSchema, recipeIssueSchema, recipeListingSchema, recordEventSchema, runFinishedEventSchema, runSample, runSampleCommandSchema, sampleBudgetSchema, sampleRunRecordSchema, sampleRunViewSchema, saveOutlineCommandSchema, saveRecipe, saveRecipeCommandSchema, sentencePartSchema, snapshotViewSchema, startPageViewSchema, startStudioServer, stopRunCommandSchema, stoppedBySchema, studioCommandSchema, studioEventSchema, takeSnapshotCommandSchema, traceLineEventSchema, verifySelectorCommandSchema, verifySelectorViewSchema, workspaceChangedEventSchema, workspaceViewSchema };
2525
+ export { explainWhyCommandSchema, fetchStartPage, fetchStartPageCommandSchema, fieldPickSchema, fieldTraceSchema, inferSelectorCommandSchema, inferSelectorViewSchema, loadRecipePair, main, missingWhyTargetSchema, openWorkspace, openWorkspaceCommandSchema, outlineBracketSchema, outlineCardSchema, outlineNodeSchema, outlineViewSchema, recipeIssueSchema, recipeListingSchema, recordEventSchema, recordRejectedEventSchema, rejectedWhyTargetSchema, runFinishedEventSchema, runSample, runSampleCommandSchema, sampleBudgetSchema, sampleRunRecordSchema, sampleRunViewSchema, saveOutlineCommandSchema, saveRecipe, saveRecipeCommandSchema, sentencePartSchema, snapshotViewSchema, startPageViewSchema, startStudioServer, stopRunCommandSchema, stoppedBySchema, studioCommandSchema, studioEventSchema, takeSnapshotCommandSchema, traceLineEventSchema, verifySelectorCommandSchema, verifySelectorViewSchema, whyTargetSchema, whyViewSchema, workspaceChangedEventSchema, workspaceViewSchema };
2093
2526
  //# sourceMappingURL=index.esm.js.map