@volter/twin-standard 1.0.30 → 1.0.32

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.
Files changed (149) hide show
  1. package/dist/src/archive-source.d.ts +13 -0
  2. package/dist/src/archive-source.js +176 -0
  3. package/dist/src/check-sources.d.ts +19 -2
  4. package/dist/src/check-sources.js +95 -37
  5. package/dist/src/cli.js +52 -9
  6. package/dist/src/clients/ai-gateway.d.ts +1 -0
  7. package/dist/src/clients/ai-gateway.js +3 -0
  8. package/dist/src/clients/anthropic-messages.d.ts +10 -0
  9. package/dist/src/clients/anthropic-messages.js +52 -0
  10. package/dist/src/clients/anthropic.d.ts +1 -0
  11. package/dist/src/clients/anthropic.js +3 -0
  12. package/dist/src/clients/aws.d.ts +2 -0
  13. package/dist/src/clients/aws.js +137 -0
  14. package/dist/src/clients/cerebras.d.ts +1 -0
  15. package/dist/src/clients/cerebras.js +3 -0
  16. package/dist/src/clients/clerk.d.ts +2 -0
  17. package/dist/src/clients/clerk.js +99 -0
  18. package/dist/src/clients/cloudflare.d.ts +2 -0
  19. package/dist/src/clients/cloudflare.js +86 -0
  20. package/dist/src/clients/elevenlabs.d.ts +2 -0
  21. package/dist/src/clients/elevenlabs.js +39 -0
  22. package/dist/src/clients/gemini.d.ts +2 -0
  23. package/dist/src/clients/gemini.js +92 -0
  24. package/dist/src/clients/github.d.ts +2 -0
  25. package/dist/src/clients/github.js +71 -0
  26. package/dist/src/clients/groq.d.ts +1 -0
  27. package/dist/src/clients/groq.js +3 -0
  28. package/dist/src/clients/harness.d.ts +36 -0
  29. package/dist/src/clients/harness.js +74 -0
  30. package/dist/src/clients/merge.d.ts +1 -0
  31. package/dist/src/clients/merge.js +8 -0
  32. package/dist/src/clients/openai-compatible.d.ts +9 -0
  33. package/dist/src/clients/openai-compatible.js +51 -0
  34. package/dist/src/clients/openai.d.ts +2 -0
  35. package/dist/src/clients/openai.js +65 -0
  36. package/dist/src/clients/openrouter.d.ts +1 -0
  37. package/dist/src/clients/openrouter.js +3 -0
  38. package/dist/src/clients/perplexity.d.ts +1 -0
  39. package/dist/src/clients/perplexity.js +3 -0
  40. package/dist/src/clients/planetscale.d.ts +2 -0
  41. package/dist/src/clients/planetscale.js +109 -0
  42. package/dist/src/clients/posthog.d.ts +2 -0
  43. package/dist/src/clients/posthog.js +145 -0
  44. package/dist/src/clients/run.d.ts +1 -0
  45. package/dist/src/clients/run.js +25 -0
  46. package/dist/src/clients/sentry.d.ts +2 -0
  47. package/dist/src/clients/sentry.js +167 -0
  48. package/dist/src/clients/slack.d.ts +2 -0
  49. package/dist/src/clients/slack.js +107 -0
  50. package/dist/src/clients/stripe.d.ts +2 -0
  51. package/dist/src/clients/stripe.js +161 -0
  52. package/dist/src/clients/tinybird.d.ts +2 -0
  53. package/dist/src/clients/tinybird.js +71 -0
  54. package/dist/src/clients/tunnel.d.ts +2 -0
  55. package/dist/src/clients/tunnel.js +99 -0
  56. package/dist/src/clients/upstash.d.ts +2 -0
  57. package/dist/src/clients/upstash.js +143 -0
  58. package/dist/src/clients/volteridentity.d.ts +2 -0
  59. package/dist/src/clients/volteridentity.js +128 -0
  60. package/dist/src/clients/xai.d.ts +2 -0
  61. package/dist/src/clients/xai.js +23 -0
  62. package/dist/src/conformance.d.ts +33 -0
  63. package/dist/src/conformance.js +520 -0
  64. package/dist/src/derive.js +113 -22
  65. package/dist/src/gate.js +22 -16
  66. package/dist/src/index.d.ts +5 -3
  67. package/dist/src/index.js +5 -3
  68. package/dist/src/lanes.d.ts +2 -0
  69. package/dist/src/lanes.js +7 -0
  70. package/dist/src/p3-rules.js +0 -2
  71. package/dist/src/protocol-3.d.ts +1 -0
  72. package/dist/src/protocol-3.js +76 -30
  73. package/dist/src/published.d.ts +6 -0
  74. package/dist/src/published.js +65 -11
  75. package/dist/src/spec-documents.js +2 -1
  76. package/dist/src/spec-ir-client.d.ts +5 -1
  77. package/dist/src/spec-ir-client.js +12 -2
  78. package/dist/src/spec-ir.d.ts +7 -2
  79. package/dist/src/spec-ir.js +42 -10
  80. package/dist/src/verdict.d.ts +14 -0
  81. package/dist/src/verdict.js +30 -0
  82. package/dist/src/walk/behavior-journey.d.ts +47 -0
  83. package/dist/src/walk/behavior-journey.js +1138 -0
  84. package/dist/src/walk/git-client.d.ts +26 -0
  85. package/dist/src/walk/git-client.js +139 -0
  86. package/dist/src/walk/journey-kit.d.ts +445 -0
  87. package/dist/src/walk/journey-kit.js +743 -0
  88. package/dist/src/walk/shape.d.ts +54 -0
  89. package/dist/src/walk/shape.js +210 -0
  90. package/dist/src/walk/sse-example.d.ts +2 -0
  91. package/dist/src/walk/sse-example.js +32 -0
  92. package/dist/src/walk/vendor-fetch.d.ts +20 -0
  93. package/dist/src/walk/vendor-fetch.js +92 -0
  94. package/dist/src/walk/walk-database.d.ts +12 -0
  95. package/dist/src/walk/walk-database.js +96 -0
  96. package/dist/src/walk/xml-view.d.ts +2 -0
  97. package/dist/src/walk/xml-view.js +55 -0
  98. package/package.json +39 -3
  99. package/src/archive-source.ts +141 -0
  100. package/src/check-sources.ts +83 -33
  101. package/src/cli.ts +40 -8
  102. package/src/clients/ai-gateway.ts +4 -0
  103. package/src/clients/anthropic-messages.ts +60 -0
  104. package/src/clients/anthropic.ts +4 -0
  105. package/src/clients/aws.ts +119 -0
  106. package/src/clients/cerebras.ts +4 -0
  107. package/src/clients/clerk.ts +97 -0
  108. package/src/clients/cloudflare.ts +80 -0
  109. package/src/clients/elevenlabs.ts +38 -0
  110. package/src/clients/gemini.ts +89 -0
  111. package/src/clients/github.ts +67 -0
  112. package/src/clients/groq.ts +4 -0
  113. package/src/clients/harness.ts +101 -0
  114. package/src/clients/merge.ts +9 -0
  115. package/src/clients/openai-compatible.ts +59 -0
  116. package/src/clients/openai.ts +66 -0
  117. package/src/clients/openrouter.ts +4 -0
  118. package/src/clients/perplexity.ts +4 -0
  119. package/src/clients/planetscale.ts +98 -0
  120. package/src/clients/posthog.ts +132 -0
  121. package/src/clients/run.ts +18 -0
  122. package/src/clients/sentry.ts +157 -0
  123. package/src/clients/slack.ts +98 -0
  124. package/src/clients/stripe.ts +129 -0
  125. package/src/clients/tinybird.ts +69 -0
  126. package/src/clients/tunnel.ts +79 -0
  127. package/src/clients/upstash.ts +137 -0
  128. package/src/clients/volteridentity.ts +108 -0
  129. package/src/clients/xai.ts +25 -0
  130. package/src/conformance.ts +446 -0
  131. package/src/derive.ts +87 -16
  132. package/src/gate.ts +20 -14
  133. package/src/index.ts +5 -3
  134. package/src/lanes.ts +7 -0
  135. package/src/p3-rules.ts +0 -2
  136. package/src/protocol-3.ts +71 -27
  137. package/src/published.ts +59 -11
  138. package/src/spec-documents.ts +2 -1
  139. package/src/spec-ir-client.ts +15 -4
  140. package/src/spec-ir.ts +43 -5
  141. package/src/verdict.ts +28 -0
  142. package/src/walk/behavior-journey.ts +806 -0
  143. package/src/walk/git-client.ts +138 -0
  144. package/src/walk/journey-kit.ts +858 -0
  145. package/src/walk/shape.ts +208 -0
  146. package/src/walk/sse-example.ts +25 -0
  147. package/src/walk/vendor-fetch.ts +80 -0
  148. package/src/walk/walk-database.ts +97 -0
  149. package/src/walk/xml-view.ts +44 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-standard",
3
- "version": "1.0.30",
3
+ "version": "1.0.32",
4
4
  "description": "The Protocol 3 standard for Volter twin packs: the criteria a pack is graded by, the grader, the gate a pack repository and the twin catalog run on every change and submission, and the spec derivation the grade checks against. Pin a version: every place a pack is judged judges by that standard.",
5
5
  "keywords": [
6
6
  "twin",
@@ -45,6 +45,14 @@
45
45
  "./spec": {
46
46
  "types": "./dist/src/spec.d.ts",
47
47
  "default": "./dist/src/spec.js"
48
+ },
49
+ "./walk": {
50
+ "types": "./dist/src/walk/behavior-journey.d.ts",
51
+ "default": "./dist/src/walk/behavior-journey.js"
52
+ },
53
+ "./journey": {
54
+ "types": "./dist/src/walk/journey-kit.d.ts",
55
+ "default": "./dist/src/walk/journey-kit.js"
48
56
  }
49
57
  },
50
58
  "bin": {
@@ -61,11 +69,39 @@
61
69
  "typescript": "^5.9.0",
62
70
  "yaml": "2.9.0"
63
71
  },
72
+ "peerDependencies": {
73
+ "@volter/world-core": "*",
74
+ "@volter/world-runtime": "*"
75
+ },
76
+ "peerDependenciesMeta": {
77
+ "@volter/world-runtime": {
78
+ "optional": true
79
+ }
80
+ },
64
81
  "devDependencies": {
82
+ "@anthropic-ai/sdk": "^0.131.0",
83
+ "@aws-sdk/client-s3": "^3.1081.0",
84
+ "@aws-sdk/client-secrets-manager": "^3.1081.0",
85
+ "@clerk/backend": "2.33.3",
86
+ "@elevenlabs/elevenlabs-js": "^2.70.0",
87
+ "@planetscale/database": "1.19.0",
88
+ "@slack/socket-mode": "2.0.7",
89
+ "@slack/web-api": "7.19.0",
65
90
  "@types/bun": "^1.2.20",
66
- "@types/node": "^24.0.0"
91
+ "@types/node": "^24.0.0",
92
+ "@upstash/qstash": "2.11.0",
93
+ "@upstash/ratelimit": "2.0.6",
94
+ "@upstash/redis": "1.38.2",
95
+ "@volter/identity": "0.4.1",
96
+ "@volter/tunnel": "2.0.5",
97
+ "aws4fetch": "^1.0.20",
98
+ "cloudflare": "^7.2.0",
99
+ "openai": "^7.25.0",
100
+ "stripe": "^23.0.0",
101
+ "@google/genai": "2.13.0",
102
+ "@google/generative-ai": "0.24.1"
67
103
  },
68
104
  "engines": {
69
- "node": ">=22.3"
105
+ "node": ">=22.6"
70
106
  }
71
107
  }
@@ -0,0 +1,141 @@
1
+ // Pinned vendor archive evidence. Members are read in memory, never extracted to the filesystem.
2
+ import { createHash } from 'node:crypto';
3
+ import { gunzipSync, inflateRawSync } from 'node:zlib';
4
+
5
+ export type ArchiveSource = { url: string; sha256: string; member: string };
6
+ export function archiveSource(locator: string): ArchiveSource | undefined {
7
+ const m = /^archive:(https?:\/\/\S+)#sha256=([a-fA-F0-9]{64})!\/(\S+)$/.exec(locator);
8
+ if (!m || m[3]!.startsWith('/') || m[3]!.includes('\\') || m[3]!.split('/').some(p => p === '..' || p === '.' || !p)) return undefined;
9
+ return { url: m[1]!, sha256: m[2]!.toLowerCase(), member: m[3]! };
10
+ }
11
+ const hashOf = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex');
12
+ const stringAt = (bytes: Buffer, start: number, size: number): string => bytes.subarray(start, start + size).toString('utf8').replace(/\0.*$/s, '');
13
+
14
+ function zipMember(bytes: Buffer, member: string): Buffer {
15
+ // The central directory supplies sizes even when local headers use data descriptors.
16
+ let end = bytes.length - 22;
17
+ while (end >= Math.max(0, bytes.length - 65557) && bytes.readUInt32LE(end) !== 0x06054b50) end--;
18
+ if (end < Math.max(0, bytes.length - 65557)) throw new Error('ZIP has no end directory');
19
+ if (bytes.readUInt16LE(end + 4) || bytes.readUInt16LE(end + 6)) throw new Error('multi-disk ZIP is not supported');
20
+ let count = bytes.readUInt16LE(end + 10), offset = bytes.readUInt32LE(end + 16);
21
+ if (count === 0xffff || offset === 0xffffffff) {
22
+ const loc = end - 20;
23
+ if (loc < 0 || bytes.readUInt32LE(loc) !== 0x07064b50) throw new Error('ZIP64 has no directory locator');
24
+ const pos = Number(bytes.readBigUInt64LE(loc + 8));
25
+ if (!Number.isSafeInteger(pos) || bytes.readUInt32LE(pos) !== 0x06064b50) throw new Error('invalid ZIP64 directory');
26
+ count = Number(bytes.readBigUInt64LE(pos + 32)); offset = Number(bytes.readBigUInt64LE(pos + 48));
27
+ }
28
+ for (let i = 0; i < count; i++) {
29
+ if (bytes.readUInt32LE(offset) !== 0x02014b50) throw new Error('invalid ZIP directory member');
30
+ const flags = bytes.readUInt16LE(offset + 8), method = bytes.readUInt16LE(offset + 10);
31
+ let size = bytes.readUInt32LE(offset + 20), plain = bytes.readUInt32LE(offset + 24), local = bytes.readUInt32LE(offset + 42);
32
+ const names = bytes.readUInt16LE(offset + 28), extras = bytes.readUInt16LE(offset + 30), comments = bytes.readUInt16LE(offset + 32);
33
+ const name = stringAt(bytes, offset + 46, names);
34
+ if (name === member) {
35
+ if (flags & 1) throw new Error('encrypted ZIP member');
36
+ // ZIP64 values appear only for fields whose ordinary value is the sentinel.
37
+ if (size === 0xffffffff || plain === 0xffffffff || local === 0xffffffff) {
38
+ let at = offset + 46 + names, stop = at + extras;
39
+ while (at < stop) {
40
+ const id = bytes.readUInt16LE(at), len = bytes.readUInt16LE(at + 2);
41
+ if (id === 1) {
42
+ let v = at + 4;
43
+ if (plain === 0xffffffff) { plain = Number(bytes.readBigUInt64LE(v)); v += 8; }
44
+ if (size === 0xffffffff) { size = Number(bytes.readBigUInt64LE(v)); v += 8; }
45
+ if (local === 0xffffffff) local = Number(bytes.readBigUInt64LE(v));
46
+ break;
47
+ }
48
+ at += 4 + len;
49
+ }
50
+ }
51
+ if (![size, plain, local].every(Number.isSafeInteger) || bytes.readUInt32LE(local) !== 0x04034b50) throw new Error('invalid ZIP local member');
52
+ const mode = bytes.readUInt32LE(offset + 38) >>> 16;
53
+ if ((mode & 0xf000) === 0xa000 || name.endsWith('/')) throw new Error('ZIP member is not a regular file');
54
+ const start = local + 30 + bytes.readUInt16LE(local + 26) + bytes.readUInt16LE(local + 28);
55
+ if (start + size > bytes.length) throw new Error('truncated ZIP member');
56
+ const data = bytes.subarray(start, start + size);
57
+ const result = method === 0 ? data : method === 8 ? inflateRawSync(data) : undefined;
58
+ if (!result) throw new Error(`unsupported ZIP compression ${method}`);
59
+ if (result.length !== plain) throw new Error('ZIP member size differs from its directory');
60
+ return result;
61
+ }
62
+ offset += 46 + names + extras + comments;
63
+ }
64
+ throw new Error(`archive has no member ${member}`);
65
+ }
66
+ function tarNumber(bytes: Buffer): number {
67
+ if (bytes[0]! & 0x80) {
68
+ let n = BigInt(bytes[0]! & 0x7f);
69
+ for (const b of bytes.subarray(1)) n = (n << 8n) | BigInt(b);
70
+ const value = Number(n);
71
+ if (!Number.isSafeInteger(value)) throw new Error('tar size is too large');
72
+ return value;
73
+ }
74
+ const s = bytes.toString('ascii').replace(/\0.*$/s, '').trim();
75
+ if (s && !/^[0-7]+$/.test(s)) throw new Error('invalid tar number');
76
+ return s ? parseInt(s, 8) : 0;
77
+ }
78
+ function paxFields(bytes: Buffer): Record<string, string> {
79
+ const out: Record<string, string> = {};
80
+ let at = 0;
81
+ while (at < bytes.length) {
82
+ const space = bytes.indexOf(32, at), n = Number(bytes.subarray(at, space).toString());
83
+ if (space < at || !Number.isInteger(n) || n <= space - at + 1 || at + n > bytes.length) throw new Error('invalid tar PAX record');
84
+ const entry = bytes.subarray(space + 1, at + n - 1).toString('utf8'), eq = entry.indexOf('=');
85
+ if (eq >= 0) out[entry.slice(0, eq)] = entry.slice(eq + 1);
86
+ at += n;
87
+ }
88
+ return out;
89
+ }
90
+ function tarMember(bytes: Buffer, member: string): Buffer {
91
+ let at = 0, longName: string | undefined, local: Record<string, string> = {}, global: Record<string, string> = {};
92
+ while (at + 512 <= bytes.length) {
93
+ const header = bytes.subarray(at, at + 512);
94
+ if (header.every(b => b === 0)) break;
95
+ const sum = header.reduce((n, b, i) => n + (i >= 148 && i < 156 ? 32 : b), 0);
96
+ if (sum !== tarNumber(header.subarray(148, 156))) throw new Error('invalid tar header checksum');
97
+ const kind = stringAt(header, 156, 1), baseSize = tarNumber(header.subarray(124, 136));
98
+ const meta = kind === 'x' || kind === 'g' || kind === 'L' || kind === 'K';
99
+ const attributes = { ...global, ...local };
100
+ const size = !meta && attributes.size !== undefined ? Number(attributes.size) : baseSize;
101
+ if (!Number.isSafeInteger(size) || size < 0 || at + 512 + size > bytes.length) throw new Error('invalid tar member size');
102
+ const data = bytes.subarray(at + 512, at + 512 + size);
103
+ if (kind === 'x') local = paxFields(data);
104
+ else if (kind === 'g') global = { ...global, ...paxFields(data) };
105
+ else if (kind === 'L') longName = data.toString('utf8').replace(/\0.*$/s, '');
106
+ else if (kind !== 'K') {
107
+ const prefix = stringAt(header, 345, 155);
108
+ const name = attributes.path ?? longName ?? `${prefix ? `${prefix}/` : ''}${stringAt(header, 0, 100)}`;
109
+ if (name.replace(/^\.\//, '') === member) {
110
+ if (kind !== '' && kind !== '0' && kind !== '7') throw new Error('tar member is not a regular file');
111
+ return data;
112
+ }
113
+ local = {}; longName = undefined;
114
+ }
115
+ at += 512 + Math.ceil(size / 512) * 512;
116
+ }
117
+ throw new Error(`archive has no member ${member}`);
118
+ }
119
+
120
+ /** One cache per source-check run; multiple pinned members share the archive's one download. */
121
+ export function archiveReader(): (source: ArchiveSource) => Promise<{ status: number; raw: string; sha256?: string; error?: string }> {
122
+ const downloads = new Map<string, Promise<{ status: number; bytes: Buffer }>>();
123
+ return async source => {
124
+ try {
125
+ let pending = downloads.get(source.url);
126
+ if (!pending) {
127
+ pending = (async () => {
128
+ const res = await fetch(source.url, { redirect: 'follow', signal: AbortSignal.timeout(30000), headers: { 'user-agent': 'volter-source-check/1.0 (+https://volter.ai)' } });
129
+ return { status: res.status, bytes: Buffer.from(await res.arrayBuffer()) };
130
+ })();
131
+ downloads.set(source.url, pending);
132
+ }
133
+ const { status, bytes } = await pending;
134
+ if (status !== 200) return { status, raw: '' };
135
+ const sha256 = hashOf(bytes);
136
+ if (sha256 !== source.sha256) return { status: 0, raw: '', sha256, error: `SHA256 differs: expected ${source.sha256}, received ${sha256}` };
137
+ const member = bytes.readUInt32LE(0) === 0x04034b50 ? zipMember(bytes, source.member) : tarMember(bytes[0] === 0x1f && bytes[1] === 0x8b ? gunzipSync(bytes) : bytes, source.member);
138
+ return { status, raw: member.toString('utf8'), sha256 };
139
+ } catch (err) { return { status: 0, raw: '', error: err instanceof Error ? err.message : String(err) }; }
140
+ };
141
+ }
@@ -10,44 +10,69 @@
10
10
  //
11
11
  // Usage: twin-standard check-sources <pack dir> [--recheck] (twin-world: bun scripts/check-sources.ts <vendor>)
12
12
  import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
13
- import { join, resolve } from 'node:path';
13
+ import { dirname, join, resolve } from 'node:path';
14
14
  import { unitAt } from './unit.ts';
15
+ import { archiveReader, archiveSource } from './archive-source.ts';
15
16
 
16
- export type SourceRecord = { status: number; checked: string; quotes?: Record<string, boolean> };
17
- export type Patch = { op: string; path: string; why: string; source?: { url?: string; spec?: string; quote: string } };
17
+ export type SourceRecord = { status: number; checked: string; quotes?: Record<string, boolean>; sha256?: string; error?: string };
18
+ export type Patch = { op: string; path: string; why: string; source?: { url?: string; spec?: string; recording?: string; quote: string } };
18
19
 
19
20
  /** Every URL in a text, without trailing punctuation. */
20
- export const urlsIn = (text: string): string[] => (text.match(/https?:\/\/[^\s"'<>]+/g) ?? []).map((u) => u.replace(/[),.;:\]]+$/, ''));
21
+ export const urlsIn = (text: string): string[] => (text.match(/(?:archive:)?https?:\/\/[^\s"'<>]+/g) ?? []).map((u) => u.replace(/[),.;:\]]+$/, ''));
21
22
 
22
- /** The citations a pack's handlers write: each comment line of `src/semantics/` reading `source: <citation>`, where it
23
- * stands (`<file>:<line>`) and what it cites ("What an author writes, and how": a rule carries its citation beside it). */
23
+ /** The citations beside vendor rules in a pack's handwritten source, including hosted screens and engines.
24
+ * Generated source and test files do not supply author evidence. */
24
25
  export function handlerCitations(pkg: string): Array<{ where: string; source: string }> {
25
- const dir = join(pkg, 'src', 'semantics');
26
- if (!existsSync(dir)) return [];
27
- return readdirSync(dir).filter((f) => /\.tsx?$/.test(f) && !/\.test\.tsx?$/.test(f)).sort().flatMap((f) =>
28
- readFileSync(join(dir, f), 'utf8').split('\n').flatMap((line, i) => {
29
- // a line of its own (`// source:`, `* source:` in a doc comment) or a comment ending a statement
26
+ const files: Array<{ name: string; at: string }> = [];
27
+ const walk = (dir: string, prefix: string): void => {
28
+ if (!existsSync(dir)) return;
29
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
30
+ const name = `${prefix}${entry.name}`, at = join(dir, entry.name);
31
+ if (entry.isDirectory()) {
32
+ if (entry.name !== 'generated') walk(at, `${name}/`);
33
+ } else if (/\.tsx?$/.test(entry.name) && !/\.(?:test|uitest|spec)\.tsx?$/.test(entry.name)) files.push({ name, at });
34
+ }
35
+ };
36
+ walk(join(pkg, 'src'), '');
37
+ const comments = files.flatMap(({ name: f, at }) =>
38
+ readFileSync(at, 'utf8').split('\n').flatMap((line, i) => {
30
39
  const m = /(?:^\s*(?:\/\/|\/?\*+)|[\s;,)}\]]\/\/)\s*source:\s*(.+?)\s*(?:\*\/)?\s*$/i.exec(line);
31
40
  return m ? [{ where: `${f}:${i + 1}`, source: m[1]! }] : [];
32
41
  }));
42
+ const examplesPath = join(pkg, 'spec', 'doc-examples.json');
43
+ const examples: Array<{ source?: string; quote?: string }> = existsSync(examplesPath) ? JSON.parse(readFileSync(examplesPath, 'utf8')) : [];
44
+ return [...comments, ...examples.flatMap((e, i) => e.quote !== undefined && (e.source?.startsWith('archive:') || /raw\.githubusercontent\.com\/.*\/tests?\//.test(e.source ?? '')) ? [{ where: `spec/doc-examples.json[${i}]`, source: `${e.source} "${e.quote.replace(/"/g, '\\"')}"` }] : [])];
33
45
  }
34
46
 
35
47
  /** A handler citation's page and the words it says the page holds (`https://… "quote"`), when it quotes. */
36
48
  export const quotedPage = (source: string): { url: string; quote: string } | undefined => {
37
- const m = /^(https?:\/\/\S+)\s+"([\s\S]+)"$/.exec(source.trim());
38
- return m && m[2]!.trim() ? { url: m[1]!.replace(/[),.;:\]]+$/, ''), quote: m[2]!.replace(/\\"/g, '"') } : undefined;
49
+ const m = /^((?:archive:)?https?:\/\/\S+)\s+"([\s\S]+)"$/.exec(source.trim());
50
+ return m && m[2]!.trim() && (!m[1]!.startsWith('archive:') || archiveSource(m[1]!)) ? { url: m[1]!.replace(/[),.;:\]]+$/, ''), quote: m[2]!.replace(/\\"/g, '"') } : undefined;
39
51
  };
40
52
 
53
+ /** Evidence is usable offline only when the page answered or the archive digest matched its pin. */
54
+ export function sourceHeld(url: string, record: SourceRecord | undefined): boolean {
55
+ if (record?.status !== 200) return false;
56
+ if (!url.startsWith('archive:')) return true;
57
+ const archive = archiveSource(url);
58
+ return !!archive && record.sha256 === archive.sha256;
59
+ }
60
+
41
61
  /** What a pack cites: each URL, and the quotes a patch says the URL's page holds. */
42
62
  export async function citations(pkg: string): Promise<Map<string, Set<string>>> {
43
63
  const cited = new Map<string, Set<string>>();
44
64
  const cite = (url: string, quote?: string) => { if (!cited.has(url)) cited.set(url, new Set()); if (quote) cited.get(url)!.add(quote); };
45
- const { manifest } = (await import(join(pkg, 'src', 'manifest.ts'))) as { manifest: { resources: Record<string, { state?: Record<string, { transitions: Array<{ source?: string }> }> }> } };
46
- for (const r of Object.values(manifest.resources)) for (const f of Object.values(r.state ?? {})) for (const t of f.transitions) for (const u of urlsIn(String(t.source ?? ''))) cite(u);
65
+ const { manifest } = (await import(join(pkg, 'src', 'manifest.ts'))) as { manifest: { resources?: Record<string, { state?: Record<string, { transitions: Array<{ source?: string }> }> }> } };
66
+ // a vendor of lanes' root declares no resources of its own (its lanes do): only its handlers' and manifest's citations
67
+ for (const r of Object.values(manifest.resources ?? {})) for (const f of Object.values(r.state ?? {})) for (const t of f.transitions) {
68
+ const source = String(t.source ?? ''), q = quotedPage(source);
69
+ if (q?.url.startsWith('archive:')) cite(q.url, q.quote); else for (const u of urlsIn(source)) cite(u);
70
+ }
47
71
  for (const c of handlerCitations(pkg)) {
48
72
  const q = quotedPage(c.source);
49
73
  if (q) cite(q.url, q.quote);
50
- else for (const u of urlsIn(c.source)) cite(u);
74
+ // a URL inside the quoted words (`spec:<ref> "… https://…"`) is what the spec says, not a page cited
75
+ else for (const u of urlsIn(c.source.replace(/"[^"]*"/g, ''))) cite(u);
51
76
  }
52
77
  const patchPath = join(pkg, 'spec', 'patches.json');
53
78
  if (existsSync(patchPath)) {
@@ -65,8 +90,37 @@ export async function citations(pkg: string): Promise<Map<string, Set<string>>>
65
90
  const textOf = (html: string): string => bare(html.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '').replace(/<[^>]+>/g, '')
66
91
  .replace(/&quot;/g, '"').replace(/&#x27;|&#39;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&'));
67
92
  const bare = (s: string): string => s.replace(/[\s\p{Cf}]+/gu, '');
93
+ /** A page's markup taken out and its words kept apart, as a person reads it: block ends as line breaks, runs of space as one. */
94
+ const readable = (html: string): string => html.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '')
95
+ .replace(/<\/(p|div|li|h[1-6]|pre|tr|section|article|br)\s*>|<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, ' ')
96
+ .replace(/&quot;/g, '"').replace(/&#x27;|&#39;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&')
97
+ .replace(/[ \t]+/g, ' ').replace(/\n\s*\n\s*/g, '\n\n').trim();
68
98
 
69
99
  /** Fetch and record the sources the unit at `args[0]` cites (`--recheck`: every one, not only those due). */
100
+ /** A documentation page as the citation check reads it: fetched as a browser-like agent (asked again as this tool where
101
+ * that is refused), an HTML page's text without its markup, a plain page as it is. `twin-standard read-page <url>` prints
102
+ * it, so an author reads a page through the same path its citations are checked on; `--raw` prints the page's own bytes
103
+ * (a spec writer that reads the page's tables). */
104
+ export async function readPage(url: string): Promise<{ status: number; text: string; readable: string; raw: string; sha256?: string; error?: string }> {
105
+ if (url.startsWith('archive:')) {
106
+ const archive = archiveSource(url);
107
+ if (!archive) return { status: 0, text: '', readable: '', raw: '', error: 'malformed archive locator' };
108
+ const result = await archiveReader()(archive);
109
+ return { ...result, text: bare(result.raw), readable: result.raw };
110
+ }
111
+ try {
112
+ const get = (agent: string): Promise<Response> => fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(8000), headers: { 'user-agent': agent } });
113
+ let res = await get('Mozilla/5.0 (volter twin source check)');
114
+ // a site that refuses a browser-like agent it cannot verify (dev.mysql.com) is asked again as the tool this is
115
+ if (res.status === 403) res = await get('volter-source-check/1.0 (+https://volter.ai)');
116
+ // an HTML page's text without its markup; a plain page (a raw source file, a markdown document) as it is: its
117
+ // `<name>` placeholders and `=>` arrows are not tags
118
+ const body = await res.text();
119
+ const html = (res.headers.get('content-type') ?? '').includes('html');
120
+ return { status: res.status, text: html ? textOf(body) : bare(body), readable: html ? readable(body) : body, raw: body };
121
+ } catch { return { status: 0, text: '', readable: '', raw: '' }; }
122
+ }
123
+
70
124
  export async function checkSources(args: string[]): Promise<void> {
71
125
  const vendor = args[0];
72
126
  if (!vendor) { console.error('usage: twin-standard check-sources <pack dir> [--recheck]'); process.exit(1); }
@@ -75,35 +129,31 @@ export async function checkSources(args: string[]): Promise<void> {
75
129
  const record: Record<string, SourceRecord> = existsSync(recordPath) ? JSON.parse(readFileSync(recordPath, 'utf8')) : {};
76
130
  const cited = await citations(pkg);
77
131
  const recheck = args.includes('--recheck');
78
- const due = [...cited].filter(([url, quotes]) => recheck || record[url]?.status !== 200 || [...quotes].some((q) => record[url]?.quotes?.[q] !== true));
132
+ const due = [...cited].filter(([url, quotes]) => recheck || !sourceHeld(url, record[url]) || [...quotes].some((q) => record[url]?.quotes?.[q] !== true));
79
133
  const today = new Date().toISOString().slice(0, 10);
134
+ const readArchive = archiveReader();
80
135
  let next = 0;
81
136
  await Promise.all(Array.from({ length: 16 }, async () => {
82
137
  while (next < due.length) {
83
138
  const [url, quotes] = due[next++]!;
84
- let status = 0;
85
- let text = '';
86
- try {
87
- const get = (agent: string): Promise<Response> => fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(8000), headers: { 'user-agent': agent } });
88
- let res = await get('Mozilla/5.0 (volter twin source check)');
89
- // a site that refuses a browser-like agent it cannot verify (dev.mysql.com) is asked again as the tool this is
90
- if (res.status === 403) res = await get('volter-source-check/1.0 (+https://volter.ai)');
91
- status = res.status;
92
- // an HTML page's text without its markup; a plain page (a raw source file, a markdown document) as it is: its
93
- // `<name>` placeholders and `=>` arrows are not tags
94
- text = (res.headers.get('content-type') ?? '').includes('html') ? textOf(await res.text()) : bare(await res.text());
95
- } catch { status = 0; }
139
+ const archive = archiveSource(url);
140
+ const result = archive ? await readArchive(archive) : await readPage(url);
141
+ const { status, sha256, error } = result;
142
+ const text = 'text' in result ? String(result.text) : bare(result.raw);
96
143
  const found = Object.fromEntries([...quotes].map((q) => [q, status === 200 && text.includes(bare(q))]));
97
- record[url] = { status, checked: today, ...(quotes.size ? { quotes: found } : {}) };
144
+ record[url] = { status, checked: today, ...(sha256 ? { sha256 } : {}), ...(error ? { error } : {}), ...(quotes.size ? { quotes: found } : {}) };
98
145
  }
99
146
  }));
100
147
  const sorted = Object.fromEntries(Object.entries(record).filter(([url]) => cited.has(url)).sort(([a], [b]) => a.localeCompare(b)));
101
- writeFileSync(recordPath, `${JSON.stringify(sorted, null, 2)}\n`);
102
- const bad = Object.entries(sorted).filter(([, r]) => r.status !== 200);
148
+ // a vendor of lanes' root holds no spec/ (its lanes do) and no derive reads it: its citations are checked, not recorded
149
+ if (existsSync(dirname(recordPath))) writeFileSync(recordPath, `${JSON.stringify(sorted, null, 2)}\n`);
150
+ const bad = Object.entries(sorted).filter(([url, r]) => !sourceHeld(url, r));
103
151
  const unquoted = Object.entries(sorted).flatMap(([url, r]) => Object.entries(r.quotes ?? {}).filter(([, ok]) => !ok).map(([q]) => `${url}: "${q}"`));
104
152
  console.log(`${vendor}: ${cited.size} cited pages, ${due.length} fetched; ${bad.length} not found or not fetchable, ${unquoted.length} quotes not on their page`);
105
- for (const [url, r] of bad) console.log(` ${r.status || 'no answer'} ${url}`);
153
+ for (const [url, r] of bad) console.log(` ${r.status || 'no answer'} ${url}${r.error ? `: ${r.error}` : ''}`);
106
154
  for (const u of unquoted) console.log(` quote missing: ${u}`);
155
+ // a citation that does not hold fails the command, so a caller reads the verdict from the exit code, not the output
156
+ if (bad.length || unquoted.length) process.exitCode = 1;
107
157
  }
108
158
 
109
159
  if (import.meta.main) await checkSources(process.argv.slice(2));
package/src/cli.ts CHANGED
@@ -9,16 +9,21 @@
9
9
  // twin-standard grade <pack or lane dir>… [--static] [--form] each criterion's result
10
10
  // twin-standard derive <pack or lane dir> [--out <dir>] write the unit's src/generated/ from its spec
11
11
  // twin-standard check-sources <pack or lane dir> [--recheck] fetch and record the sources the unit cites
12
+ // twin-standard conform <pack dir> | <name>@<version> [--registry <url>]
13
+ // the pack served and read as an application and the
14
+ // catalog meet it (conformance.ts): a checkout, or a
15
+ // published version as the catalog admits it; or exit 1
12
16
  // twin-standard --version the package's version and each standard's
13
- import { readFileSync } from 'node:fs';
17
+ import { existsSync, readFileSync } from 'node:fs';
14
18
  import { relative, resolve } from 'node:path';
15
19
  import { unitsOf, p3Gate } from './gate.ts';
16
20
  import { grade, STANDARDS } from './grade.ts';
21
+ import { withCheckoutFiles } from './protocol-3.ts';
17
22
  import { gatePublished } from './published.ts';
18
23
 
19
24
  const [command, ...args] = process.argv.slice(2);
20
25
  const usage = (): never => {
21
- console.error('usage: twin-standard gate <repo> [units…] | gate-published <name>@<version>… [--registry <url>] | grade <unit>… [--static] [--form] | derive <unit> [--out <dir>] | check-sources <unit> [--recheck] | --version');
26
+ console.error('usage: twin-standard gate <repo> [units…] | gate-published <name>@<version>… [--registry <url>] | grade <unit>… [--static] [--form] | derive <unit> [--out <dir>] | read-page <url> [--raw] | check-sources <unit> [--recheck] | conform <pack dir> | <name>@<version> [--registry <url>] | --version');
22
27
  process.exit(2);
23
28
  };
24
29
 
@@ -55,13 +60,15 @@ if (command === '--version') {
55
60
  } else if (command === 'grade') {
56
61
  const units = args.filter((a) => !a.startsWith('--'));
57
62
  if (!units.length) usage();
58
- for (const unit of units) {
59
- const grades = await grade(unit, { static: args.includes('--static'), ...(args.includes('--form') ? { sections: ['form'] as const } : {}) });
60
- for (const [key, s] of Object.entries(grades)) {
61
- console.log(`${unit} ${key} ${s.met}/${s.total}`);
62
- for (const c of s.criteria) console.log(` ${c.pass ? 'ok ' : 'NO '} ${c.section.padEnd(9)} ${c.id.padEnd(22)} ${c.reason}`);
63
+ await withCheckoutFiles(async () => {
64
+ for (const unit of units) {
65
+ const grades = await grade(unit, { static: args.includes('--static'), ...(args.includes('--form') ? { sections: ['form'] as const } : {}) });
66
+ for (const [key, s] of Object.entries(grades)) {
67
+ console.log(`${unit} ${key} ${s.met}/${s.total}`);
68
+ for (const c of s.criteria) console.log(` ${c.pass ? 'ok ' : 'NO '} ${c.section.padEnd(9)} ${c.id.padEnd(22)} ${c.reason}`);
69
+ }
63
70
  }
64
- }
71
+ });
65
72
  } else if (command === 'derive') {
66
73
  if (!args[0]) usage();
67
74
  // the derivation is a script over process.argv: it reads the unit from its first argument
@@ -70,6 +77,31 @@ if (command === '--version') {
70
77
  } else if (command === 'check-sources') {
71
78
  if (!args[0]) usage();
72
79
  await (await import('./check-sources.ts')).checkSources(args);
80
+ } else if (command === 'read-page') {
81
+ // a documentation page's text, read as the citation check reads it: where an author finds the vendor's examples and words
82
+ if (!args[0]) usage();
83
+ const { status, readable, raw, error } = await (await import('./check-sources.ts')).readPage(args[0]);
84
+ if (status !== 200) { console.error(`read-page: ${args[0]} answered ${status || 'nothing'}${error ? `: ${error}` : ''}`); process.exit(1); }
85
+ // --raw: the page's own bytes, as fetched (a spec writer reads its tables, which the readable text drops)
86
+ if (args.includes('--raw')) process.stdout.write(raw); else console.log(readable);
87
+ } else if (command === 'conform') {
88
+ if (!args[0]) usage();
89
+ const at = args.indexOf('--registry');
90
+ const target = args.find((a, i) => !a.startsWith('--') && (at < 0 || i !== at + 1))!;
91
+ // a directory is a checkout; `<name>@<version>` (a scope's leading @ aside) is a version on the registry, and a
92
+ // `.tgz` the tarball `npm pack` makes of one, the bytes a publish would send
93
+ const published = target.endsWith('.tgz') || (!existsSync(target) && /^@?[^@]+@[^@]+$/.test(target));
94
+ const results = published
95
+ ? await (await import('./published.ts')).conformPublished(target, at >= 0 ? { registry: args[at + 1]! } : {})
96
+ : await (await import('./conformance.ts')).conformance(target);
97
+ for (const r of results) {
98
+ console.log(`${r.failures.length ? '✗' : '✓'} ${r.id.padEnd(15)} ${r.asks}${r.note ? ` (${r.note})` : ''}`);
99
+ for (const f of r.failures) console.log(` ${f}`);
100
+ }
101
+ const failing = results.filter((r) => r.failures.length).length;
102
+ console.log(failing ? `conform: ${failing} of ${results.length} checks failing` : `conform: all ${results.length} checks hold`);
103
+ // a served pack may leave handles open (a socket's timers): the verdict is the exit code
104
+ process.exit(failing ? 1 : 0);
73
105
  } else {
74
106
  usage();
75
107
  }
@@ -0,0 +1,4 @@
1
+ // Vercel AI Gateway's chat through openai-node (its OpenAI-compatible API), the shared chat cases.
2
+ import { chatCases } from './openai-compatible.ts';
3
+
4
+ export const cases = chatCases({ key: 'AI_GATEWAY_API_KEY', base: (host) => `https://${host}/v1`, model: 'anthropic/claude-sonnet-5' });
@@ -0,0 +1,60 @@
1
+ // The Messages cases every vendor on Anthropic's Messages wire holds, driven by Anthropic's own SDK pointed at the vendor
2
+ // (an application using @anthropic-ai/sdk with that vendor's base URL): a turn answered with the World's scenario
3
+ // loaded, `tool_choice: { type: 'none' }` answered without a tool_use block, the scenario rewritten between two turns
4
+ // deciding the second.
5
+ import type { ClientCase, ClientContext } from './harness.ts';
6
+
7
+ const MESSAGES = 'https://platform.claude.com/docs/en/api/messages';
8
+ const TOOLS = [{ name: 'lookup', input_schema: { type: 'object' as const, properties: {} } }];
9
+ /** A vendor's Messages as the SDK reaches it: the variable its key is issued in, the base URL on its host, a model it
10
+ * serves, and a turn that calls the tool `lookup` in the World's scenario grammar for the vendor (the pack's GET /twin). */
11
+ export type MessagesVendor = { key: string; base: (host: string) => string; model: string; toolTurn: Record<string, unknown> };
12
+
13
+ export function messagesCases(v: MessagesVendor): ClientCase[] {
14
+ const SCRIPT = { handlers: [
15
+ { id: 'calls', on: { hasTool: 'lookup' }, respond: v.toolTurn },
16
+ { id: 'words', on: { userTextIncludes: 'hello' }, respond: { text: 'scripted words' } },
17
+ ] };
18
+ const ask = async (c: ClientContext, extra: Record<string, unknown> = {}) => {
19
+ const { default: Anthropic } = await import('@anthropic-ai/sdk');
20
+ const client = new Anthropic({ apiKey: c.env[v.key] ?? '', baseURL: v.base(c.host), fetch: c.fetch, maxRetries: 0, timeout: 10_000 });
21
+ return client.messages.create({ model: v.model, max_tokens: 256, messages: [{ role: 'user', content: 'hello' }], ...extra } as never) as Promise<{ content?: Array<{ type: string; text?: string }> }>;
22
+ };
23
+ const text = (m: { content?: Array<{ type: string; text?: string }> }): string | undefined => m.content?.find((b) => b.type === 'text')?.text;
24
+ return [
25
+ {
26
+ id: 'messages-answers',
27
+ asks: "a message is answered, the World's scenario loaded",
28
+ source: `${MESSAGES} "Send a structured list of input messages with text and/or image content, and the model will generate the next message in the conversation."`,
29
+ async run(c) {
30
+ c.scenario(SCRIPT);
31
+ const answer = await ask(c).catch((e: Error) => e);
32
+ if (answer instanceof Error) return [`the client's messages.create failed: ${answer.message}`];
33
+ return text(answer) === 'scripted words' ? [] : [`the scripted turn was not answered: ${JSON.stringify(answer.content).slice(0, 120)}`];
34
+ },
35
+ },
36
+ {
37
+ id: 'messages-tool-choice-none',
38
+ asks: "tool_choice { type: 'none' } is answered without a tool_use block",
39
+ source: `${MESSAGES} "The model will not be allowed to use tools."`,
40
+ async run(c) {
41
+ c.scenario(SCRIPT);
42
+ const none = await ask(c, { tools: TOOLS, tool_choice: { type: 'none' } }).catch((e: Error) => e);
43
+ if (none instanceof Error) return [`the client's messages.create failed: ${none.message}`];
44
+ return none.content?.some((b) => b.type === 'tool_use') ? ["tool_choice none was answered with a tool_use block"] : [];
45
+ },
46
+ },
47
+ {
48
+ id: 'messages-scenario-each-turn',
49
+ asks: "the World's scenario rewritten between two turns decides the second (no reboot)",
50
+ source: 'architecture "The scenario is read from the World\'s store at every turn"',
51
+ async run(c) {
52
+ c.scenario({ handlers: [{ id: 'one', on: { userTextIncludes: 'hello' }, respond: { text: 'first script' } }] });
53
+ const first = await ask(c).then(text, (e: Error) => `error ${e.message}`);
54
+ c.scenario({ handlers: [{ id: 'two', on: { userTextIncludes: 'hello' }, respond: { text: 'second script' } }] });
55
+ const second = await ask(c).then(text, (e: Error) => `error ${e.message}`);
56
+ return first === 'first script' && second === 'second script' ? [] : [`the turns answered "${first}" then "${second}"`];
57
+ },
58
+ },
59
+ ];
60
+ }
@@ -0,0 +1,4 @@
1
+ // Anthropic's Messages through its own SDK (@anthropic-ai/sdk), the shared Messages cases.
2
+ import { messagesCases } from './anthropic-messages.ts';
3
+
4
+ export const cases = messagesCases({ key: 'ANTHROPIC_API_KEY', base: (host) => `https://${host}`, model: 'claude-sonnet-4-5', toolTurn: { toolUses: [{ name: 'lookup', input: {} }] } });