@danypops/vehicle-conformance 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -26,5 +26,50 @@ expanded immutability, schema-sensitive call rendering, 40/80/120-column
26
26
  physical-line safety, partial output, and projector exception policy. Pi-specific
27
27
  component construction stays in the adapter fixture rather than this package.
28
28
 
29
+ ## The five boundaries
30
+
31
+ Every conformant Tool Shell provider keeps five things independent:
32
+
33
+ 1. **Application DTO** -- the domain's own real output shape, transport-neutral,
34
+ untouched by any presentation concern.
35
+ 2. **Model content** -- what the LLM reads: independently bounded, ANSI-free,
36
+ semantic. Never derived from or coupled to what a human sees.
37
+ 3. **Persisted presentation details** -- a *projected*, versioned, discriminated-union
38
+ DTO, independently bounded, with explicit `{total, returned, omitted}`
39
+ completeness metadata. Projected once, before persistence -- never inferred
40
+ from raw output at render time.
41
+ 4. **Interactive component** -- the rendered view of #3. Expanded mode may only
42
+ reveal rows already inside the bounded DTO, never bypass the bound by
43
+ reaching back into raw application output.
44
+ 5. **CLI presenters** -- a separate, JSON/human-text presentation path outside
45
+ the interactive TUI entirely; out of scope for this suite.
46
+
47
+ The fail-closed rule that matters most: a parser for #3 must reject a
48
+ malformed/unknown-version/oversized/cyclic details object and fall back to
49
+ **content** (#2) -- never render raw, unbounded application output as a human
50
+ view.
51
+
52
+ ## Declared-value coverage
53
+
54
+ A fixture's `ToolShellDualChannelSubject` can optionally supply
55
+ `declaredValueCases` (one `{ value, rawPayload }` per value of a discriminator
56
+ field the provider's own schema declares -- a `format`/`kind`/`action`/...) plus
57
+ a matching `renderDeclaredValue(value, rawPayload, options)`. When present, the
58
+ suite renders one result per declared value and fails if fewer than
59
+ `min(2, cases.length)` of them escape being textually indistinguishable from a
60
+ raw `JSON.stringify(rawPayload, null, 2)` dump.
61
+
62
+ This is the generic version of a `never`-typed exhaustiveness guard on a
63
+ discriminated switch -- it catches the same bug class (most declared values
64
+ silently falling through to an undifferentiated raw-JSON view) in bespoke
65
+ non-switch code too (an `if`-chain, a plain-string `action` switch with no
66
+ compile-time exhaustiveness), which a TypeScript-only lint rule would miss
67
+ entirely. `evaluateDeclaredValueCoverage(cases, renderDeclaredValue, options)`
68
+ is exported separately for direct unit testing of the classifier against a
69
+ known-bad fixture shape, independent of the wrapping `bun:test` assertion.
70
+
71
+ Omit `declaredValueCases` entirely for a subject with no such discriminator --
72
+ the check then no-ops.
73
+
29
74
  See the [workspace README](https://github.com/DanyPops/vehicle#readme) for
30
75
  the full Vehicle package layout.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/vehicle-conformance",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Host-neutral conformance suite for any VehicleClient implementation: one shared bun:test assertion set that LocalVehicleClient, RemoteVehicleClient, and any future transport must satisfy identically. A Bun-only devDependency for testing, not a runtime library -- ships raw TypeScript, never precompiled.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,11 +14,11 @@
14
14
  "typecheck": "tsc --noEmit"
15
15
  },
16
16
  "dependencies": {
17
- "@danypops/vehicle-core": "^0.13.0",
18
- "@danypops/vehicle-server": "^0.18.3"
17
+ "@danypops/vehicle-core": "^0.19.0",
18
+ "@danypops/vehicle-server": "^0.27.0"
19
19
  },
20
20
  "devDependencies": {
21
- "@danypops/vehicle-client": "^0.7.0",
21
+ "@danypops/vehicle-client": "^0.10.6",
22
22
  "@types/node": "^22.0.0",
23
23
  "typescript": "^5.9.3"
24
24
  },
@@ -442,6 +442,76 @@ export interface ToolShellDualChannelSubject {
442
442
  replay(details: unknown, fallbackContent: string, options: ToolShellRenderOptions): readonly string[];
443
443
  renderCall(args: unknown, width: 40 | 80 | 120): readonly string[];
444
444
  invalidProjection(): Promise<unknown>;
445
+ /**
446
+ * Optional -- the discriminator values (a `format`/`kind`/`action`/... field) this provider's
447
+ * own presentation-details schema declares, each paired with a representative raw application
448
+ * payload for that value. Supplying this (together with renderDeclaredValue) enables the
449
+ * declared-value coverage check below, which generically catches the pi-web-spider bug class
450
+ * (see doc 4e9e08c1, Finding 1/4): most declared values falling through to an undifferentiated
451
+ * JSON.stringify dump of their own payload instead of a real projected view. Omit entirely for
452
+ * a subject with no such discriminator -- the check then no-ops.
453
+ */
454
+ readonly declaredValueCases?: readonly ToolShellDeclaredValueCase[];
455
+ /** Required alongside declaredValueCases: renders the expanded view for one declared value's
456
+ * own raw payload, through exactly the same projection+render pipeline the real handler uses. */
457
+ renderDeclaredValue?(value: string, rawPayload: unknown, options: ToolShellRenderOptions): readonly string[];
458
+ }
459
+
460
+ export interface ToolShellDeclaredValueCase {
461
+ /** e.g. a WebFormat value ('search'/'lean'/...), a PackageToolDetails['kind'], a tickets action name. */
462
+ readonly value: string;
463
+ /** The real, untransformed application output this declared value would carry. */
464
+ readonly rawPayload: unknown;
465
+ }
466
+
467
+ export interface DeclaredValueCoverageResult {
468
+ /** Declared values whose rendered output is NOT indistinguishable from a raw JSON.stringify dump of their own payload. */
469
+ readonly nonRawValues: readonly string[];
470
+ /** Declared values whose rendered output IS indistinguishable from a raw JSON.stringify dump of their own payload. */
471
+ readonly rawValues: readonly string[];
472
+ }
473
+
474
+ function normalizeForComparison(text: string): string {
475
+ return text.replace(/\s+/g, "");
476
+ }
477
+
478
+ /**
479
+ * True when `renderedLines` is textually indistinguishable (ignoring ANSI styling and whitespace)
480
+ * from `JSON.stringify(rawPayload, null, 2)` -- the exact shape pi-web-spider's `primaryLines()`
481
+ * fell back to for every non-"markdown" format. Whitespace-insensitive so a renderer that reflows
482
+ * the same JSON text to a narrower width still counts as "raw", matching the real bug (a Text
483
+ * component wrapping the identical JSON.stringify output).
484
+ */
485
+ function looksLikeRawJsonDump(renderedLines: readonly string[], rawPayload: unknown): boolean {
486
+ let rawJson: string;
487
+ try {
488
+ rawJson = JSON.stringify(rawPayload, null, 2) ?? "";
489
+ } catch {
490
+ return false;
491
+ }
492
+ if (rawJson.length === 0) return false;
493
+ const renderedText = renderedLines.join("\n").replace(ANSI_CSI_PATTERN, "");
494
+ return normalizeForComparison(renderedText) === normalizeForComparison(rawJson);
495
+ }
496
+
497
+ /**
498
+ * Pure, independently unit-testable core of the declared-value coverage check -- separated from
499
+ * the bun:test `it()` wiring below so a fixture reproducing a known-bad shape (e.g.
500
+ * pi-web-spider's own pre-fix behavior) can be asserted against directly, proving the classifier
501
+ * itself actually detects that bug class rather than trusting the wrapping `it()` alone.
502
+ */
503
+ export function evaluateDeclaredValueCoverage(
504
+ cases: readonly ToolShellDeclaredValueCase[],
505
+ renderDeclaredValue: (value: string, rawPayload: unknown, options: ToolShellRenderOptions) => readonly string[],
506
+ options: ToolShellRenderOptions = { width: 80, expanded: true },
507
+ ): DeclaredValueCoverageResult {
508
+ const nonRawValues: string[] = [];
509
+ const rawValues: string[] = [];
510
+ for (const { value, rawPayload } of cases) {
511
+ const lines = renderDeclaredValue(value, rawPayload, options);
512
+ (looksLikeRawJsonDump(lines, rawPayload) ? rawValues : nonRawValues).push(value);
513
+ }
514
+ return { nonRawValues, rawValues };
445
515
  }
446
516
 
447
517
  export interface ToolShellDualChannelFixture {
@@ -529,5 +599,29 @@ export function runToolShellDualChannelConformance(fixture: ToolShellDualChannel
529
599
  await cleanup();
530
600
  }
531
601
  });
602
+
603
+ it("renders most of its own declared discriminator values as more than a raw JSON dump of their own payload", async () => {
604
+ const { subject, cleanup } = await fixture.create();
605
+ try {
606
+ const cases = subject.declaredValueCases;
607
+ if (!cases || cases.length === 0) return; // opt-in: no discriminator declared, nothing to check
608
+ if (!subject.renderDeclaredValue) {
609
+ throw new Error("declaredValueCases supplied without a matching renderDeclaredValue implementation");
610
+ }
611
+ const renderDeclaredValue = subject.renderDeclaredValue.bind(subject);
612
+ const options: ToolShellRenderOptions = { width: 80, expanded: true };
613
+ for (const { value, rawPayload } of cases) {
614
+ assertPhysicalLines(renderDeclaredValue(value, rawPayload, options), options.width);
615
+ }
616
+ const { nonRawValues, rawValues } = evaluateDeclaredValueCoverage(cases, renderDeclaredValue, options);
617
+ expect(
618
+ nonRawValues.length,
619
+ `declared values [${cases.map((c) => c.value).join(", ")}] mostly render as an undifferentiated JSON.stringify dump of ` +
620
+ `their own payload -- only [${nonRawValues.join(", ") || "none"}] escape it, [${rawValues.join(", ")}] don't`,
621
+ ).toBeGreaterThanOrEqual(Math.min(2, cases.length));
622
+ } finally {
623
+ await cleanup();
624
+ }
625
+ });
532
626
  });
533
627
  }