@danypops/vehicle-conformance 0.2.1 → 0.4.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/README.md +59 -1
- package/package.json +5 -5
- package/src/vehicle-conformance.ts +207 -0
package/README.md
CHANGED
|
@@ -10,8 +10,66 @@ bun add -d @danypops/vehicle-conformance
|
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
```ts
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
registerConformanceOperations,
|
|
15
|
+
runVehicleClientConformance,
|
|
16
|
+
runToolShellDualChannelConformance,
|
|
17
|
+
} from "@danypops/vehicle-conformance";
|
|
14
18
|
```
|
|
15
19
|
|
|
20
|
+
`runToolShellDualChannelConformance(fixture)` is the host-neutral Tool Shell
|
|
21
|
+
matrix. A fixture adapts one provider's real projection/rendering boundary via
|
|
22
|
+
`execute`, `render`, `replay`, `renderCall`, and `invalidProjection`; the shared
|
|
23
|
+
suite checks independent model/presentation sentinels and named bounds,
|
|
24
|
+
JSON-safe secret-free details, malformed/unknown replay fallback, collapsed vs.
|
|
25
|
+
expanded immutability, schema-sensitive call rendering, 40/80/120-column
|
|
26
|
+
physical-line safety, partial output, and projector exception policy. Pi-specific
|
|
27
|
+
component construction stays in the adapter fixture rather than this package.
|
|
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
|
+
|
|
16
74
|
See the [workspace README](https://github.com/DanyPops/vehicle#readme) for
|
|
17
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
|
+
"version": "0.4.0",
|
|
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,13 +14,13 @@
|
|
|
14
14
|
"typecheck": "tsc --noEmit"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@danypops/vehicle-core": "^0.
|
|
18
|
-
"@danypops/vehicle-server": "^0.
|
|
17
|
+
"@danypops/vehicle-core": "^0.17.1",
|
|
18
|
+
"@danypops/vehicle-server": "^0.25.2"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
|
-
"@danypops/vehicle-client": "^0.
|
|
21
|
+
"@danypops/vehicle-client": "^0.10.3",
|
|
22
22
|
"@types/node": "^22.0.0",
|
|
23
|
-
"typescript": "^5.9.
|
|
23
|
+
"typescript": "^5.9.3"
|
|
24
24
|
},
|
|
25
25
|
"repository": {
|
|
26
26
|
"type": "git",
|
|
@@ -418,3 +418,210 @@ export function runVehicleClientConformance(fixture: VehicleConformanceFixture):
|
|
|
418
418
|
});
|
|
419
419
|
});
|
|
420
420
|
}
|
|
421
|
+
|
|
422
|
+
export interface ToolShellConformanceSnapshot {
|
|
423
|
+
readonly content: string;
|
|
424
|
+
readonly details: unknown;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export interface ToolShellRenderOptions {
|
|
428
|
+
readonly width: 40 | 80 | 120;
|
|
429
|
+
readonly expanded: boolean;
|
|
430
|
+
readonly partial?: boolean;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Host adapter for the Tool Shell's two independent persisted channels. The
|
|
435
|
+
* conformance package stays Pi-free: a Pi adapter supplies component output,
|
|
436
|
+
* while a CLI or another host can supply its own renderer through this same API.
|
|
437
|
+
*/
|
|
438
|
+
export interface ToolShellDualChannelSubject {
|
|
439
|
+
readonly bounds: { readonly modelContentBytes: number; readonly presentationDetailsBytes: number };
|
|
440
|
+
execute(): Promise<ToolShellConformanceSnapshot>;
|
|
441
|
+
render(snapshot: ToolShellConformanceSnapshot, options: ToolShellRenderOptions): readonly string[];
|
|
442
|
+
replay(details: unknown, fallbackContent: string, options: ToolShellRenderOptions): readonly string[];
|
|
443
|
+
renderCall(args: unknown, width: 40 | 80 | 120): readonly string[];
|
|
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 };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export interface ToolShellDualChannelFixture {
|
|
518
|
+
readonly label: string;
|
|
519
|
+
create(): Promise<{ readonly subject: ToolShellDualChannelSubject; readonly cleanup: () => Promise<void> }>;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function utf8Length(value: string): number {
|
|
523
|
+
return new TextEncoder().encode(value).byteLength;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// biome-ignore lint/complexity/useRegexLiterals: a constructor avoids control-character lint on the equivalent literal.
|
|
527
|
+
const ANSI_CSI_PATTERN = new RegExp("\\u001B\\[[0-?]*[ -/]*[@-~]", "g");
|
|
528
|
+
|
|
529
|
+
function assertPhysicalLines(lines: readonly string[], width: number): void {
|
|
530
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
531
|
+
for (const line of lines) {
|
|
532
|
+
expect(line).not.toContain("\n");
|
|
533
|
+
// ANSI is forbidden in model content, but permitted in host rendering.
|
|
534
|
+
const visible = line.replace(ANSI_CSI_PATTERN, "");
|
|
535
|
+
expect([...visible].length).toBeLessThanOrEqual(width);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Reusable provider-facing dual-channel contract matrix. */
|
|
540
|
+
export function runToolShellDualChannelConformance(fixture: ToolShellDualChannelFixture): void {
|
|
541
|
+
describe(`Vehicle Tool Shell dual-channel conformance: ${fixture.label}`, () => {
|
|
542
|
+
it("keeps model and persisted-presentation sentinels isolated under independent named bounds", async () => {
|
|
543
|
+
const { subject, cleanup } = await fixture.create();
|
|
544
|
+
try {
|
|
545
|
+
const snapshot = await subject.execute();
|
|
546
|
+
expect(snapshot.content).toContain("MODEL_ONLY");
|
|
547
|
+
expect(snapshot.content).not.toContain("PRESENTATION_ONLY");
|
|
548
|
+
const details = JSON.stringify(snapshot.details);
|
|
549
|
+
expect(details).toContain("PRESENTATION_ONLY");
|
|
550
|
+
expect(details).not.toContain("MODEL_ONLY");
|
|
551
|
+
expect(details).not.toContain("RAW_SECRET");
|
|
552
|
+
expect(utf8Length(snapshot.content)).toBeLessThanOrEqual(subject.bounds.modelContentBytes);
|
|
553
|
+
expect(utf8Length(details)).toBeLessThanOrEqual(subject.bounds.presentationDetailsBytes);
|
|
554
|
+
} finally {
|
|
555
|
+
await cleanup();
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("keeps model content semantic, ANSI-free, and useful when replay details reject", async () => {
|
|
560
|
+
const { subject, cleanup } = await fixture.create();
|
|
561
|
+
try {
|
|
562
|
+
const snapshot = await subject.execute();
|
|
563
|
+
expect(snapshot.content).not.toContain("\u001b[");
|
|
564
|
+
for (const details of [{ schema: "unknown/v99" }, { malformed: true }, { output: { legacy: true } }, undefined]) {
|
|
565
|
+
const lines = subject.replay(details, snapshot.content, { width: 80, expanded: false });
|
|
566
|
+
expect(lines.join("\n")).toContain("MODEL_ONLY");
|
|
567
|
+
}
|
|
568
|
+
} finally {
|
|
569
|
+
await cleanup();
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it("changes only human rendering across collapsed/expanded and 40/80/120 layouts", async () => {
|
|
574
|
+
const { subject, cleanup } = await fixture.create();
|
|
575
|
+
try {
|
|
576
|
+
const snapshot = await subject.execute();
|
|
577
|
+
const before = JSON.stringify(snapshot);
|
|
578
|
+
for (const width of [40, 80, 120] as const) {
|
|
579
|
+
assertPhysicalLines(subject.render(snapshot, { width, expanded: false }), width);
|
|
580
|
+
assertPhysicalLines(subject.render(snapshot, { width, expanded: true }), width);
|
|
581
|
+
assertPhysicalLines(subject.render(snapshot, { width, expanded: false, partial: true }), width);
|
|
582
|
+
}
|
|
583
|
+
expect(JSON.stringify(snapshot)).toBe(before);
|
|
584
|
+
} finally {
|
|
585
|
+
await cleanup();
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it("never echoes schema-sensitive call input and follows the documented projector exception policy", async () => {
|
|
590
|
+
const { subject, cleanup } = await fixture.create();
|
|
591
|
+
try {
|
|
592
|
+
for (const width of [40, 80, 120] as const) {
|
|
593
|
+
const call = subject.renderCall({ name: "safe-task", token: "RAW_SECRET" }, width).join("\n");
|
|
594
|
+
expect(call).toContain("safe-task");
|
|
595
|
+
expect(call).not.toContain("RAW_SECRET");
|
|
596
|
+
}
|
|
597
|
+
await expect(subject.invalidProjection()).rejects.toBeTruthy();
|
|
598
|
+
} finally {
|
|
599
|
+
await cleanup();
|
|
600
|
+
}
|
|
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
|
+
});
|
|
626
|
+
});
|
|
627
|
+
}
|