@volter/twin-standard 1.0.30 → 1.0.31

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