@volter/twin-standard 1.0.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/LICENSE +202 -0
- package/README.md +20 -0
- package/dist/src/check-sources.d.ts +33 -0
- package/dist/src/check-sources.js +128 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +105 -0
- package/dist/src/derive.d.ts +2 -0
- package/dist/src/derive.js +349 -0
- package/dist/src/gate.d.ts +9 -0
- package/dist/src/gate.js +109 -0
- package/dist/src/grade.d.ts +30 -0
- package/dist/src/grade.js +36 -0
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.js +11 -0
- package/dist/src/lanes.d.ts +2 -0
- package/dist/src/lanes.js +33 -0
- package/dist/src/p3-rules.d.ts +8 -0
- package/dist/src/p3-rules.js +43 -0
- package/dist/src/protocol-3.d.ts +5 -0
- package/dist/src/protocol-3.js +721 -0
- package/dist/src/published.d.ts +13 -0
- package/dist/src/published.js +36 -0
- package/dist/src/spec-documents.d.ts +12 -0
- package/dist/src/spec-documents.js +61 -0
- package/dist/src/spec-ir-client.d.ts +20 -0
- package/dist/src/spec-ir-client.js +77 -0
- package/dist/src/spec-ir-commands.d.ts +38 -0
- package/dist/src/spec-ir-commands.js +38 -0
- package/dist/src/spec-ir-discovery.d.ts +11 -0
- package/dist/src/spec-ir-discovery.js +106 -0
- package/dist/src/spec-ir-graphql.d.ts +25 -0
- package/dist/src/spec-ir-graphql.js +46 -0
- package/dist/src/spec-ir-lines.d.ts +19 -0
- package/dist/src/spec-ir-lines.js +76 -0
- package/dist/src/spec-ir-proto.d.ts +80 -0
- package/dist/src/spec-ir-proto.js +339 -0
- package/dist/src/spec-ir.d.ts +104 -0
- package/dist/src/spec-ir.js +691 -0
- package/dist/src/spec-patches.d.ts +8 -0
- package/dist/src/spec-patches.js +24 -0
- package/dist/src/spec.d.ts +5 -0
- package/dist/src/spec.js +7 -0
- package/dist/src/types.d.ts +75 -0
- package/dist/src/types.js +4 -0
- package/dist/src/unit.d.ts +5 -0
- package/dist/src/unit.js +14 -0
- package/package.json +71 -0
- package/src/check-sources.ts +109 -0
- package/src/cli.ts +75 -0
- package/src/derive.ts +316 -0
- package/src/gate.ts +95 -0
- package/src/grade.ts +47 -0
- package/src/index.ts +12 -0
- package/src/lanes.ts +29 -0
- package/src/p3-rules.ts +44 -0
- package/src/protocol-3.ts +617 -0
- package/src/published.ts +37 -0
- package/src/spec-documents.ts +58 -0
- package/src/spec-ir-client.ts +86 -0
- package/src/spec-ir-commands.ts +50 -0
- package/src/spec-ir-discovery.ts +104 -0
- package/src/spec-ir-graphql.ts +64 -0
- package/src/spec-ir-lines.ts +76 -0
- package/src/spec-ir-proto.ts +289 -0
- package/src/spec-ir.ts +689 -0
- package/src/spec-patches.ts +23 -0
- package/src/spec.ts +8 -0
- package/src/types.ts +53 -0
- package/src/unit.ts +15 -0
package/src/derive.ts
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// derive — the derived-pack generator's build step (docs/contributing/architecture.md, "The derived pack"), the
|
|
3
|
+
// standard's own: the grade's `derived-reproducible` runs it, so every grader derives the same way. Reads a pack's
|
|
4
|
+
// vendored spec (`<pack>/spec/openapi.json[.gz]`), builds the spec IR (./spec-ir.ts) and writes the pack's
|
|
5
|
+
// `src/generated/`. Deterministic: a second run over the same spec writes the same bytes. Never hand-edit what it writes.
|
|
6
|
+
//
|
|
7
|
+
// Usage: twin-standard derive <pack dir> [--out <dir>] (twin-world: bun scripts/derive-pack.ts <vendor>)
|
|
8
|
+
|
|
9
|
+
import { parse as parseYaml } from 'yaml';
|
|
10
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { gunzipSync } from 'node:zlib';
|
|
13
|
+
import { CRUD_CLASSES, fromOpenAPI, fromSmithy, fromSwagger2, slug, type SpecIR } from './spec-ir.ts';
|
|
14
|
+
import { fromCommandReplies, type LineSpec } from './spec-ir-lines.ts';
|
|
15
|
+
import { fromProto, protoSchema, readProtoFiles } from './spec-ir-proto.ts';
|
|
16
|
+
import { fromRedisCommands, type CommandTableSpec } from './spec-ir-commands.ts';
|
|
17
|
+
import { fromClientCalls, type ClientOpsSpec } from './spec-ir-client.ts';
|
|
18
|
+
import { discoveryMethods, fromDiscovery, isDiscovery } from './spec-ir-discovery.ts';
|
|
19
|
+
import { handlerCitations, quotedPage, type Patch, type SourceRecord, urlsIn } from './check-sources.ts';
|
|
20
|
+
import { applySpecPatch } from './spec-patches.ts';
|
|
21
|
+
import { unitAt } from './unit.ts';
|
|
22
|
+
import { mergeDocuments, readSpecDocuments } from './spec-documents.ts';
|
|
23
|
+
import { p3RuleHits } from './p3-rules.ts';
|
|
24
|
+
|
|
25
|
+
const vendor = process.argv[2];
|
|
26
|
+
if (!vendor) {
|
|
27
|
+
console.error('usage: twin-standard derive <pack dir> [--out <dir>]');
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
const PKG = unitAt(vendor).dir;
|
|
31
|
+
// an OpenAPI document, an AWS Smithy JSON model (smithy.json), the RFC a line protocol is (rfcNNNN.txt), or the
|
|
32
|
+
// Protocol Buffers files an RPC service is published as (spec/proto/, every file the service imports), or the calls a
|
|
33
|
+
// vendor's own client makes where the vendor publishes no spec (client-ops.json, spec-ir-client.ts), or one API's several
|
|
34
|
+
// OpenAPI documents (spec/openapi/, read as their union: spec-documents.ts), or a Google API Discovery document
|
|
35
|
+
// (discovery.json, spec-ir-discovery.ts)
|
|
36
|
+
const rfc = existsSync(join(PKG, 'spec')) ? readdirSync(join(PKG, 'spec')).find((f) => /^rfc\d+\.txt$/.test(f)) : undefined;
|
|
37
|
+
const specPath = ['openapi.json.gz', 'openapi.json', 'openapi.yaml.gz', 'openapi.yaml', 'smithy.json.gz', 'smithy.json', 'discovery.json.gz', 'discovery.json', ...(rfc ? [rfc] : []), 'openapi', 'proto', 'commands', 'client-ops.json'].map((f) => join(PKG, 'spec', f)).find((p) => existsSync(p));
|
|
38
|
+
if (!specPath) {
|
|
39
|
+
console.error(`derive-pack: no spec at ${join(PKG, 'spec')}/openapi.{json,yaml}[.gz], smithy.json[.gz], rfcNNNN.txt, proto/, commands/ or client-ops.json`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
const rpcService = specPath.endsWith('/proto');
|
|
43
|
+
const severalDocuments = specPath.endsWith('/openapi');
|
|
44
|
+
// a command language's table, one vendored file per command (Redis's src/commands/*.json), with its version in SOURCE.md
|
|
45
|
+
const commandTable = specPath.endsWith('/commands');
|
|
46
|
+
const protoTree = (dir: string, at = ''): Record<string, string> => Object.fromEntries(readdirSync(join(dir, at), { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? Object.entries(protoTree(dir, join(at, e.name))) : e.name.endsWith('.proto') ? [[join(at, e.name), readFileSync(join(dir, at, e.name), 'utf8')]] : [])));
|
|
47
|
+
const bytes = rpcService || commandTable || severalDocuments ? Buffer.from('') : readFileSync(specPath);
|
|
48
|
+
const text = (specPath.endsWith('.gz') ? gunzipSync(bytes) : bytes).toString('utf8');
|
|
49
|
+
// an RFC's command-reply table and a service's .proto files are read into data first, so a patch corrects them as it
|
|
50
|
+
// corrects a JSON spec
|
|
51
|
+
const lineProtocol = specPath.endsWith('.txt');
|
|
52
|
+
const commandVersion = commandTable ? (/\*\*Version:\*\* `([^`]+)`/.exec(existsSync(join(PKG, 'spec', 'SOURCE.md')) ? readFileSync(join(PKG, 'spec', 'SOURCE.md'), 'utf8') : '')?.[1] ?? '') : '';
|
|
53
|
+
let doc: any = severalDocuments ? readSpecDocuments(join(PKG, 'spec')) : commandTable ? fromRedisCommands(Object.fromEntries(readdirSync(specPath).filter((f) => f.endsWith('.json')).sort().map((f) => [f, JSON.parse(readFileSync(join(specPath, f), 'utf8'))])), commandVersion) : rpcService ? readProtoFiles(protoTree(specPath)) : lineProtocol ? fromCommandReplies(text) : /\.ya?ml(\.gz)?$/.test(specPath) ? parseYaml(text) : JSON.parse(text);
|
|
54
|
+
|
|
55
|
+
// ── the evidence the pack cites ──────────────────────────────────────────────────────────────
|
|
56
|
+
// A source is a page check-sources (./check-sources.ts) fetched and recorded found (spec/sources.json), a quote
|
|
57
|
+
// that page holds, or `spec:<operationId or /json/pointer> "quote"`: words of the vendored spec itself
|
|
58
|
+
// (read before any patch, so a patch never vouches for itself).
|
|
59
|
+
const vendored = lineProtocol || commandTable ? structuredClone(doc) : JSON.parse(text.startsWith('{') ? text : JSON.stringify(doc));
|
|
60
|
+
/** An RFC's section by its number (`4.1.4`): its heading's line to the next heading's. */
|
|
61
|
+
const sectionOf = (n: string): string | undefined => {
|
|
62
|
+
const at = text.search(new RegExp(`^${n.replace(/\./g, '\\.')}\\. `, 'm'));
|
|
63
|
+
if (at < 0) return undefined;
|
|
64
|
+
const next = text.slice(at + 1).search(/^\d+(\.\d+)*\. {2}/m);
|
|
65
|
+
return next < 0 ? text.slice(at) : text.slice(at, at + 1 + next);
|
|
66
|
+
};
|
|
67
|
+
const sourcesPath = join(PKG, 'spec', 'sources.json');
|
|
68
|
+
const recorded: Record<string, SourceRecord> = existsSync(sourcesPath) ? JSON.parse(readFileSync(sourcesPath, 'utf8')) : {};
|
|
69
|
+
const HTTP_METHODS = new Set(['get', 'put', 'post', 'delete', 'patch', 'head', 'options', 'trace']);
|
|
70
|
+
const specOps = new Map<string, unknown>();
|
|
71
|
+
// an operation by its id: the spec's operationId, or the `method_path` slug the IR mints where the spec names none (QStash)
|
|
72
|
+
for (const d of severalDocuments ? Object.values(vendored.documents) as any[] : [vendored]) {
|
|
73
|
+
for (const [path, item] of Object.entries(d.paths ?? {})) {
|
|
74
|
+
for (const [method, o] of Object.entries(item as Record<string, unknown>)) {
|
|
75
|
+
if (!o || typeof o !== 'object' || !HTTP_METHODS.has(method)) continue;
|
|
76
|
+
const id = (o as { operationId?: unknown }).operationId;
|
|
77
|
+
specOps.set(typeof id === 'string' && id ? id : slug(method, path), o);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// a proto spec's rpc, message or enum is its declaring file's text, comments and all (Google documents each rpc and field
|
|
82
|
+
// in the comments above it): `spec:SearchUris "…"` checks the words there
|
|
83
|
+
// (the names are read from the file with its comments taken out: a comment's "the message of" names nothing)
|
|
84
|
+
if (rpcService) for (const text of Object.values(protoTree(specPath))) for (const m of text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '').matchAll(/\b(?:rpc|message|enum)\s+(\w+)/g)) if (!specOps.has(m[1]!)) specOps.set(m[1]!, text);
|
|
85
|
+
// a JSON pointer into several documents reads their union (a schema only one of them has)
|
|
86
|
+
const pointed = severalDocuments ? mergeDocuments(vendored).doc : vendored;
|
|
87
|
+
// a Discovery document's methods are its operations, by their ids
|
|
88
|
+
if (isDiscovery(vendored)) for (const m of discoveryMethods(vendored.resources)) specOps.set(String(m.id), m);
|
|
89
|
+
// a spec node's words: its strings and its keys (a schema's property names are what it says)
|
|
90
|
+
const wordsIn = (node: unknown): string => (typeof node === 'string' ? node : node && typeof node === 'object' ? Object.entries(node).map(([k, v]) => `${Array.isArray(node) ? '' : k} ${wordsIn(v)}`).join(' ') : '');
|
|
91
|
+
const bare = (s: string): string => s.replace(/\s+/g, '');
|
|
92
|
+
// a JSON pointer into the spec, an operation by id, or (an RFC's) a section by number
|
|
93
|
+
const specNode = (ref: string): unknown => (ref.startsWith('/') ? ref.split('/').slice(1).map((k) => k.replace(/~1/g, '/').replace(/~0/g, '~')).reduce((n: any, k) => n?.[k], severalDocuments && !ref.startsWith('/documents/') ? pointed : vendored) : lineProtocol && /^\d+(\.\d+)*$/.test(ref) ? sectionOf(ref) : specOps.get(ref));
|
|
94
|
+
/** Why a citation does not hold, or undefined when it does. */
|
|
95
|
+
function unheld(source: string): string | undefined {
|
|
96
|
+
const spec = /^spec:(\S+)\s+"([\s\S]*)"$/.exec(source.trim());
|
|
97
|
+
if (spec && !spec[2]!.trim()) return `spec:${spec[1]} quotes nothing`;
|
|
98
|
+
if (spec) {
|
|
99
|
+
const node = specNode(spec[1]!);
|
|
100
|
+
if (node === undefined) return `the spec has nothing at ${spec[1]}`;
|
|
101
|
+
return bare(wordsIn(node)).includes(bare(spec[2]!.replace(/\\"/g, '"'))) ? undefined : `the spec at ${spec[1]} does not say "${spec[2]}"`;
|
|
102
|
+
}
|
|
103
|
+
const urls = urlsIn(source);
|
|
104
|
+
if (!urls.length) return `"${source.slice(0, 60)}" names no page and no spec words`;
|
|
105
|
+
const dead = urls.filter((u) => recorded[u]?.status !== 200);
|
|
106
|
+
return dead.length ? dead.map((u) => `${u} is ${recorded[u] ? `answering ${recorded[u]!.status || 'nothing'}` : 'not checked'} (twin-standard check-sources ${vendor})`).join('; ') : undefined;
|
|
107
|
+
}
|
|
108
|
+
const evidence: string[] = [];
|
|
109
|
+
// every rule a handler writes carries its citation beside it (`// source:`), held as a transition's is
|
|
110
|
+
for (const c of handlerCitations(PKG)) {
|
|
111
|
+
const q = quotedPage(c.source);
|
|
112
|
+
// a quote is checked only in the two forms that say where it is; any other quote is refused, never passed unread
|
|
113
|
+
const trimmed = c.source.trim();
|
|
114
|
+
const loose = !q && (/^spec:/.test(trimmed) ? !/^spec:\S+\s+"[\s\S]*"$/.test(trimmed) : trimmed.includes('"'));
|
|
115
|
+
const held = loose ? `"${c.source.slice(0, 60)}" quotes words in no form a check reads (\`<url> "<quote>"\` or \`spec:<ref> "<quote>"\`)` : q ? (recorded[q.url]?.quotes?.[q.quote] === true ? undefined : `${q.url} does not hold "${q.quote}" as recorded (twin-standard check-sources ${vendor})`) : unheld(c.source);
|
|
116
|
+
if (held) evidence.push(`semantics/${c.where}: ${held}`);
|
|
117
|
+
}
|
|
118
|
+
// Corrections to the vendor's spec, as data: RFC 6902 operations (add, replace, remove, move, copy)
|
|
119
|
+
// in spec/patches.json, applied before the IR so the vendored file stays the vendor's own bytes. Each says
|
|
120
|
+
// `why`, citing what shows the vendor differs from its spec (its docs, the spec's own example, a recording).
|
|
121
|
+
const patchPath = join(PKG, 'spec', 'patches.json');
|
|
122
|
+
if (existsSync(patchPath)) {
|
|
123
|
+
for (const p of JSON.parse(readFileSync(patchPath, 'utf8')).patches as Array<{ op: string; path: string; from?: string; value?: unknown; why: string }>) {
|
|
124
|
+
if (!p.why?.trim()) { console.error(`${vendor}: spec patch ${p.op} ${p.path} gives no why`); process.exit(1); }
|
|
125
|
+
{
|
|
126
|
+
const s = (p as Patch).source;
|
|
127
|
+
const held = s?.spec ? unheld(`spec:${s.spec} "${s.quote}"`)
|
|
128
|
+
: s?.url ? (recorded[s.url]?.quotes?.[s.quote] === true ? undefined : `${s.url} does not hold "${s.quote}" as recorded`)
|
|
129
|
+
: urlsIn(p.why).length ? unheld(p.why) : 'it cites no page and quotes nothing';
|
|
130
|
+
if (held) evidence.push(`spec patch ${p.op} ${p.path}: ${held}`);
|
|
131
|
+
}
|
|
132
|
+
applySpecPatch(doc, p);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// several documents of one API are read as their union once the patches have ruled every contradiction
|
|
136
|
+
if (severalDocuments) {
|
|
137
|
+
const { doc: merged, contradictions } = mergeDocuments(doc);
|
|
138
|
+
if (contradictions.length) {
|
|
139
|
+
console.error(`${vendor}: the documents contradict each other; a patch (citing what shows which holds) rules each:\n ${contradictions.join('\n ')}`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
doc = merged;
|
|
143
|
+
}
|
|
144
|
+
// --out <dir> writes the core elsewhere, touching nothing in the pack (the grader's reproducibility check)
|
|
145
|
+
const outAt = process.argv.indexOf('--out');
|
|
146
|
+
const out = outAt >= 0 && process.argv[outAt + 1] ? process.argv[outAt + 1]! : join(PKG, 'src', 'generated');
|
|
147
|
+
mkdirSync(out, { recursive: true });
|
|
148
|
+
// A line protocol's surface is its commands and the replies each may answer; its manifest's transitions
|
|
149
|
+
// name commands and cite the RFC.
|
|
150
|
+
if (lineProtocol || commandTable) {
|
|
151
|
+
const spec = doc as LineSpec | CommandTableSpec;
|
|
152
|
+
writeFileSync(join(out, 'surface.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', spec: specPath.split('/').pop(), ...spec })}\n`);
|
|
153
|
+
const manifestPath = join(PKG, 'src', 'manifest.ts');
|
|
154
|
+
if (existsSync(manifestPath)) {
|
|
155
|
+
const { manifest } = (await import(manifestPath)) as { manifest: { resources: Record<string, { state?: Record<string, { transitions: Array<{ operation?: string; actor?: string; to?: string; source?: string }> }> }> } };
|
|
156
|
+
for (const [name, decl] of Object.entries(manifest.resources)) {
|
|
157
|
+
for (const [field, m] of Object.entries(decl.state ?? {})) {
|
|
158
|
+
for (const tr of m.transitions) {
|
|
159
|
+
const held = unheld(String(tr.source ?? ''));
|
|
160
|
+
if (held) evidence.push(`${name}.${field} ${tr.operation ?? tr.actor ?? ''} → ${tr.to ?? '(stays)'}: ${held}`);
|
|
161
|
+
if (tr.operation && !spec.commands.some((c) => c.id === tr.operation)) evidence.push(`${name}.${field}: a transition names ${tr.operation}, which the spec does not have`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (evidence.length) {
|
|
167
|
+
console.error(`${vendor}: ${evidence.length} citation(s) do not hold:\n ${[...new Set(evidence)].join('\n ')}`);
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
console.log(`${vendor}: spec ${spec.version}, ${spec.commands.length} commands${'anyCommand' in spec ? `; any command may answer ${spec.anyCommand.join(', ')}` : ''}`);
|
|
171
|
+
process.exit(0);
|
|
172
|
+
}
|
|
173
|
+
const ir: SpecIR = isDiscovery(doc) ? fromDiscovery(doc) : doc?.format === 'client' ? fromClientCalls(doc as ClientOpsSpec) : rpcService ? fromProto(doc) : /smithy\.json(\.gz)?$/.test(specPath) ? fromSmithy(doc) : doc?.swagger === '2.0' ? fromSwagger2(doc) : fromOpenAPI(doc);
|
|
174
|
+
// A spec that groups its operations by tag (Cloudflare's: nearly every path begins /accounts/{account_id}/, so the
|
|
175
|
+
// path's first segment names one family for thousands of operations) says so in spec/grouping.json, `{ "by": "tag" }`:
|
|
176
|
+
// each operation's family is then its first tag, as a file name (`Worker Script` is `worker-script`).
|
|
177
|
+
const grouping = existsSync(join(PKG, 'spec', 'grouping.json')) ? (JSON.parse(readFileSync(join(PKG, 'spec', 'grouping.json'), 'utf8')) as { by?: string }) : {};
|
|
178
|
+
if (grouping.by === 'tag') {
|
|
179
|
+
// an operation the spec names no operationId for is found by its method and path, in the spec as corrected (an
|
|
180
|
+
// operation a patch adds carries its own tags: PostHog's person delete)
|
|
181
|
+
const byRoute = new Map<string, { tags?: string[] }>();
|
|
182
|
+
for (const [path, item] of Object.entries((doc as { paths?: Record<string, Record<string, unknown>> }).paths ?? {})) {
|
|
183
|
+
for (const [method, op] of Object.entries(item ?? {})) if (op && typeof op === 'object') byRoute.set(`${method.toLowerCase()} ${path}`, op as { tags?: string[] });
|
|
184
|
+
}
|
|
185
|
+
for (const o of ir.operations) {
|
|
186
|
+
const op = (specOps.get(o.id) ?? byRoute.get(`${o.method.toLowerCase()} ${o.path}`)) as { tags?: string[] } | undefined;
|
|
187
|
+
const tag = op?.tags?.[0];
|
|
188
|
+
if (tag) o.family = tag.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A GraphQL API beside the REST one (spec/schema.graphql[.gz]) is a second wire over the same state.
|
|
193
|
+
const sdlPath = ['schema.graphql.gz', 'schema.graphql'].map((f) => join(PKG, 'spec', f)).find((p) => existsSync(p));
|
|
194
|
+
// the GraphQL wire's root fields (`mutation.issueArchive`), which a transition may name as a REST operation is named
|
|
195
|
+
const graphqlOperations = new Set<string>();
|
|
196
|
+
if (sdlPath) {
|
|
197
|
+
const { fromGraphQL } = await import('./spec-ir-graphql.ts');
|
|
198
|
+
const sdl = (sdlPath.endsWith('.gz') ? gunzipSync(readFileSync(sdlPath)) : readFileSync(sdlPath)).toString('utf8');
|
|
199
|
+
const gql = fromGraphQL(sdl);
|
|
200
|
+
for (const o of gql.operations) graphqlOperations.add(o.id);
|
|
201
|
+
mkdirSync(out, { recursive: true });
|
|
202
|
+
writeFileSync(join(out, 'graphql.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', spec: sdlPath.split('/').pop(), ...gql })}\n`);
|
|
203
|
+
// the schema itself, for the pack's GraphQL wire to validate and execute against (imported lazily)
|
|
204
|
+
writeFileSync(join(out, 'graphql-sdl.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', sdl })}\n`);
|
|
205
|
+
const g = new Map<string, number>();
|
|
206
|
+
for (const o of gql.operations) g.set(o.class, (g.get(o.class) ?? 0) + 1);
|
|
207
|
+
console.log(` graphql: ${gql.operations.length} root fields, ${gql.resources.length} resources; ` + [...g].sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}=${v}`).join(' '));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// a proto unit's schema, for the kernel's protobuf codec and gRPC wire: fields by number, enums by number, rpcs by path
|
|
211
|
+
if (rpcService) writeFileSync(join(out, 'proto.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', ...protoSchema(doc) })}\n`);
|
|
212
|
+
// Data, not code: the surface is JSON so it adds nothing to a typecheck and ships as an asset.
|
|
213
|
+
writeFileSync(join(out, 'surface.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', spec: specPath.split('/').pop(), ...ir })}\n`);
|
|
214
|
+
|
|
215
|
+
// The event types a vendor delivers, when its spec enumerates them where a webhook subscription names the events it
|
|
216
|
+
// wants (a request body's `enabled_events`): the pack records an event only under a type the vendor sends.
|
|
217
|
+
const eventTypes = new Set<string>();
|
|
218
|
+
for (const item of Object.values((doc?.paths ?? {}) as Record<string, Record<string, any>>)) {
|
|
219
|
+
for (const op of Object.values(item ?? {})) {
|
|
220
|
+
for (const media of Object.values((op?.requestBody?.content ?? {}) as Record<string, any>)) {
|
|
221
|
+
for (const type of (media?.schema?.properties?.enabled_events?.items?.enum ?? []) as unknown[]) if (typeof type === 'string' && type !== '*') eventTypes.add(type);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (eventTypes.size) writeFileSync(join(out, 'events.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', spec: specPath.split('/').pop(), types: [...eventTypes].sort() })}\n`);
|
|
226
|
+
|
|
227
|
+
// What the pack's UI needs to know about each resource its manifest declares: where it is listed,
|
|
228
|
+
// read, created and moved, which values its state fields take and which moves are legal from which,
|
|
229
|
+
// and which fields name another resource. Data the browser bundle reads; it never imports the manifest.
|
|
230
|
+
const manifestPath = join(PKG, 'src', 'manifest.ts');
|
|
231
|
+
if (existsSync(manifestPath)) {
|
|
232
|
+
const { manifest } = (await import(manifestPath)) as { manifest: { resources: Record<string, { state?: Record<string, { initial: unknown; transitions: Array<{ operation?: string; actor?: string; from: string[] | '*'; to?: string; source?: string }> }>; embeds?: Record<string, string> }>; unmodeled?: string[] } };
|
|
233
|
+
const base = ir.basePath ?? '';
|
|
234
|
+
const ops = ir.operations.filter((o) => !(manifest.unmodeled ?? []).includes(o.id));
|
|
235
|
+
const ui: Record<string, unknown> = {};
|
|
236
|
+
for (const [name, decl] of Object.entries(manifest.resources)) {
|
|
237
|
+
const mine = ops.filter((o) => o.resource === name);
|
|
238
|
+
const pick = (cls: string) => mine.filter((o) => o.class === cls).sort((a, b) => a.path.length - b.path.length)[0];
|
|
239
|
+
const [list, retrieve, create] = [pick('list'), pick('retrieve'), pick('create')];
|
|
240
|
+
const moves = Object.entries(decl.state ?? {}).flatMap(([field, m]) =>
|
|
241
|
+
m.transitions.filter((t) => t.operation).map((t) => {
|
|
242
|
+
const o = mine.find((x) => x.id === t.operation);
|
|
243
|
+
return o ? { operation: o.id, method: o.method.toUpperCase(), path: (o.basePath ?? base) + o.path, field, from: t.from, ...(t.to !== undefined ? { to: t.to } : {}) } : undefined;
|
|
244
|
+
}).filter(Boolean),
|
|
245
|
+
);
|
|
246
|
+
ui[name] = {
|
|
247
|
+
...(list ? { list: (list.basePath ?? base) + list.path } : {}),
|
|
248
|
+
...(retrieve ? { retrieve: (retrieve.basePath ?? base) + retrieve.path } : {}),
|
|
249
|
+
...(create ? { create: { path: (create.basePath ?? base) + create.path, encoding: create.bodyEncoding, body: create.body } } : {}),
|
|
250
|
+
actions: moves,
|
|
251
|
+
states: Object.fromEntries(Object.entries(decl.state ?? {}).map(([f, m]) => [f, [...new Set([String(m.initial), ...m.transitions.flatMap((t) => [...(t.from === '*' ? [] : t.from), ...(t.to !== undefined ? [t.to] : [])])])].sort()])),
|
|
252
|
+
embeds: decl.embeds ?? {},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
writeFileSync(join(out, 'ui.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', resources: ui })}\n`);
|
|
256
|
+
// every transition cites what shows the vendor moves that way, and the citation holds
|
|
257
|
+
for (const [name, decl] of Object.entries(manifest.resources)) {
|
|
258
|
+
for (const [field, m] of Object.entries(decl.state ?? {})) {
|
|
259
|
+
for (const tr of m.transitions) {
|
|
260
|
+
const held = unheld(String(tr.source ?? ''));
|
|
261
|
+
if (held) evidence.push(`${name}.${field} ${tr.operation ?? tr.actor ?? ''} → ${tr.to ?? '(stays)'}: ${held}`);
|
|
262
|
+
if (tr.operation && !ir.operations.some((o) => o.id === tr.operation) && !graphqlOperations.has(tr.operation)) evidence.push(`${name}.${field}: a transition names ${tr.operation}, which the spec does not have`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (evidence.length) {
|
|
267
|
+
console.error(`${vendor}: ${evidence.length} citation(s) do not hold:\n ${[...new Set(evidence)].join('\n ')}`);
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
// every state candidate of a resource the pack models is ruled on: a state field, or named not one
|
|
271
|
+
const unruled = ir.resources.flatMap((r) => {
|
|
272
|
+
const decl = manifest.resources[r.name] as { state?: Record<string, unknown>; notState?: string[] } | undefined;
|
|
273
|
+
return decl ? r.stateCandidates.filter((c) => !(c.field in (decl.state ?? {})) && !(decl.notState ?? []).includes(c.field)).map((c) => `${r.name}.${c.field}`) : [];
|
|
274
|
+
});
|
|
275
|
+
if (unruled.length) {
|
|
276
|
+
console.error(`${vendor}: the manifest does not rule on ${unruled.length} state candidate(s): ${unruled.join(', ')} (declare each a state field or list it in notState)`);
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
279
|
+
// a resource's refresh scope ("The real-system adapters", What a pack declares) names the list that enumerates it or
|
|
280
|
+
// its retrieve, or says why the vendor offers no read-back; whether every stored resource declares one is
|
|
281
|
+
// pack-standing's report, not this build's
|
|
282
|
+
// (a resource the spec names is read by its own list or retrieve; one the manifest names apart from the spec's, an RPC
|
|
283
|
+
// vendor's, by the list or read the scope names, with `items` where the answer holds them when the spec does not say)
|
|
284
|
+
const specNamed = new Set(ir.resources.map((r) => r.name));
|
|
285
|
+
const scopes = Object.entries(manifest.resources as Record<string, { refresh?: { list?: string; get?: string; none?: string; items?: string } }>).flatMap(([name, decl]) => {
|
|
286
|
+
const r = decl.refresh;
|
|
287
|
+
if (!r) return [];
|
|
288
|
+
const op = (id: string | undefined) => ir.operations.find((o) => o.id === id);
|
|
289
|
+
const ours = (o: { resource?: string }): boolean => !specNamed.has(name) || o.resource === name;
|
|
290
|
+
if (r.list !== undefined) {
|
|
291
|
+
const o = op(r.list);
|
|
292
|
+
if (!(o?.class === 'list' && ours(o))) return [`${name}: refresh.list ${r.list} is ${o ? `a ${o.class} of ${o.resource ?? 'no resource'}` : 'no operation of the spec'}, not a list of ${name}`];
|
|
293
|
+
return o.answers?.list || r.items ? [] : [`${name}: refresh.list ${r.list} answers no list the spec names; say where its items are (items)`];
|
|
294
|
+
}
|
|
295
|
+
if (r.get !== undefined) { const o = op(r.get); return o?.class === 'retrieve' && ours(o) ? [] : [`${name}: refresh.get ${r.get} is ${o ? `a ${o.class} of ${o.resource ?? 'no resource'}` : 'no operation of the spec'}, not the retrieve of ${name}`]; }
|
|
296
|
+
return typeof r.none === 'string' && r.none.trim() ? [] : [`${name}: refresh names no list, no get and no reason`];
|
|
297
|
+
});
|
|
298
|
+
if (scopes.length) {
|
|
299
|
+
console.error(`${vendor}: ${scopes.length} refresh scope(s) do not hold:\n ${scopes.join('\n ')}`);
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
|
+
// the architecture's mechanical rules over the pack's own code (./p3-rules.ts): a line breaking one records why, or
|
|
303
|
+
// the build stops
|
|
304
|
+
const hits = p3RuleHits(PKG);
|
|
305
|
+
if (hits.length) {
|
|
306
|
+
console.error(`${vendor}: ${hits.length} line(s) break a Protocol 3 rule (fix it, or record why on the line: // p3: <reason>):\n ${hits.map((h) => `${h.file}:${h.line}: ${h.rule}\n ${h.text}`).join('\n ')}`);
|
|
307
|
+
process.exit(1);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const counts = new Map<string, number>();
|
|
312
|
+
for (const o of ir.operations) counts.set(o.class, (counts.get(o.class) ?? 0) + 1);
|
|
313
|
+
const crud = ir.operations.filter((o) => CRUD_CLASSES.has(o.class)).length;
|
|
314
|
+
const candidates = ir.resources.reduce((n, r) => n + r.stateCandidates.length, 0);
|
|
315
|
+
console.log(`${vendor}: spec ${ir.version}, ${ir.operations.length} operations (${crud} crud), ${ir.resources.length} resources, ${candidates} state candidates`);
|
|
316
|
+
console.log(' ' + [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}=${v}`).join(' '));
|
package/src/gate.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// The gate — what may be in a Protocol 3 pack repository or catalog: every pack and lane passes the form
|
|
2
|
+
// section of the grade (docs/contributing/architecture.md, "The grade"), so nothing that is not Protocol 3 enters it:
|
|
3
|
+
// nothing outside the pack layout, no handler past its contract.
|
|
4
|
+
// The repository was made so that this holds (the owner, 2026-09-29: "this was WHY we created p3 so this can't
|
|
5
|
+
// happen"); this is what makes it hold. It runs before a commit (the pack repository's hook, on the units it touches),
|
|
6
|
+
// before a release (pack-release, on each pack it releases), and on every version submitted to the catalog
|
|
7
|
+
// (twin-catalog's check, on the published tarball) — each at the version of this package it pins, so all of them judge by
|
|
8
|
+
// one standard.
|
|
9
|
+
//
|
|
10
|
+
// twin-standard gate <pack repository> [<pack or lane dir>…]
|
|
11
|
+
//
|
|
12
|
+
// It blocks on every criterion of the form section; what a pack's walked life proves (no dead code among them) is the
|
|
13
|
+
// proof section's, which the gate does not read.
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import ts from 'typescript';
|
|
18
|
+
import { grade } from './grade.ts';
|
|
19
|
+
|
|
20
|
+
/** Every unit of a pack repository: each pack (a directory with a package.json) and each lane in it (a directory with
|
|
21
|
+
* its own src/manifest.ts and no package.json). */
|
|
22
|
+
export function unitsOf(repo: string): string[] {
|
|
23
|
+
const units: string[] = [];
|
|
24
|
+
for (const name of readdirSync(repo).sort()) {
|
|
25
|
+
const dir = join(repo, name);
|
|
26
|
+
if (name.startsWith('.') || name === 'node_modules' || !statSync(dir).isDirectory() || !existsSync(join(dir, 'package.json'))) continue;
|
|
27
|
+
units.push(dir);
|
|
28
|
+
for (const sub of readdirSync(dir).sort()) {
|
|
29
|
+
const lane = join(dir, sub);
|
|
30
|
+
if (statSync(lane).isDirectory() && !existsSync(join(lane, 'package.json')) && existsSync(join(lane, 'src', 'manifest.ts'))) units.push(lane);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return units;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What a vendor whose every API is a lane may hold at its root: the package files, the vendor's life (journeys/), its
|
|
37
|
+
* generated facts, its lanes, and in src/ only its manifest (its lanes' routes, discovery and descriptor, as data), the
|
|
38
|
+
* fetch the kernel serves them by (createVendorFetch, generated) and the fixed serve factory, index and bin. Nothing
|
|
39
|
+
* routes by hand: only the fetch reaches a lane. A directory with no manifest and no lane is no Protocol 3 pack. */
|
|
40
|
+
const ROOT_ENTRIES = new Set(['README.md', 'package.json', 'LICENSE', 'CHANGELOG.md', 'tsconfig.json', 'tsconfig.build.json', 'gate.ts', 'census.json', 'vendor', 'journeys', 'generated', 'src', 'node_modules']);
|
|
41
|
+
const ROOT_SRC = new Set(['manifest.ts', 'fetch.ts', 'index.ts', 'cli.ts', 'server.ts']);
|
|
42
|
+
/** What the repository ignores (a build's dist/, a World's state) is no file of the pack, as protocol-3's layout reads it. */
|
|
43
|
+
function ignored(dir: string, names: string[]): Set<string> {
|
|
44
|
+
if (!names.length) return new Set();
|
|
45
|
+
const run = spawnSync('git', ['check-ignore', '--', ...names], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
46
|
+
return new Set(String(run.stdout ?? '').split('\n').filter(Boolean));
|
|
47
|
+
}
|
|
48
|
+
export function laneRootFailures(dir: string): string[] {
|
|
49
|
+
const lanes = readdirSync(dir).filter((n) => existsSync(join(dir, n, 'src', 'manifest.ts')));
|
|
50
|
+
if (!lanes.length) return ['layout: no src/manifest.ts and no lane: not a Protocol 3 pack'];
|
|
51
|
+
const stray = readdirSync(dir).filter((n) => !n.startsWith('.') && !ROOT_ENTRIES.has(n) && !lanes.includes(n));
|
|
52
|
+
const skip = ignored(dir, stray);
|
|
53
|
+
const outside = [
|
|
54
|
+
...stray.filter((n) => !skip.has(n)),
|
|
55
|
+
...(existsSync(join(dir, 'src')) ? readdirSync(join(dir, 'src')).filter((n) => !ROOT_SRC.has(n) && !n.endsWith('.d.ts') && n !== 'semantics').map((n) => `src/${n}`) : []),
|
|
56
|
+
// what the lanes share, and nothing else (architecture, "Pack layout": a vendor whose every API is a lane)
|
|
57
|
+
...(existsSync(join(dir, 'src', 'semantics')) ? readdirSync(join(dir, 'src', 'semantics')).filter((n) => n !== 'shared.ts').map((n) => `src/semantics/${n}`) : []),
|
|
58
|
+
];
|
|
59
|
+
const failures = outside.length ? [`layout: ${outside.length} outside the vendor's root (its lanes hold the rest): ${outside.slice(0, 8).join(', ')}${outside.length > 8 ? ` and ${outside.length - 8} more` : ''}`] : [];
|
|
60
|
+
// the vendor's front is declared and the kernel serves it: a manifest, and a fetch that is createVendorFetch over it
|
|
61
|
+
const src = (n: string): string => (existsSync(join(dir, 'src', n)) ? readFileSync(join(dir, 'src', n), 'utf8') : '');
|
|
62
|
+
if (!src('manifest.ts')) failures.push("layout: no src/manifest.ts: a vendor of lanes declares its lanes' routes, discovery and descriptor");
|
|
63
|
+
if (!/\bcreateVendorFetch\(/.test(src('fetch.ts'))) failures.push('layout: src/fetch.ts builds no createVendorFetch: the kernel routes a vendor\'s lanes from its manifest, nothing by hand');
|
|
64
|
+
// the root's own files hold to the pack contract as a unit's do (the grade's kernel-contract)
|
|
65
|
+
const beyond: string[] = [];
|
|
66
|
+
for (const n of existsSync(join(dir, 'src')) ? readdirSync(join(dir, 'src')).filter((x) => /\.tsx?$/.test(x) && !x.endsWith('.d.ts')) : []) {
|
|
67
|
+
// the file's imports as TypeScript reads them, as the grade's kernel-contract does
|
|
68
|
+
for (const from of ts.preProcessFile(readFileSync(join(dir, 'src', n), 'utf8'), true, true).importedFiles.map((i) => i.fileName)) {
|
|
69
|
+
// only the fetch reaches a lane (the kernel's routing), and the index each lane's manifest and generated surface
|
|
70
|
+
// (the kernel derives the vendor-backed half from them); the manifest is data, importing the contract's types alone
|
|
71
|
+
const laneData = n === 'index.ts' && /^\.\.\/[^/]+\/src\/(manifest\.ts|generated\/surface\.gen\.json)$/.test(from);
|
|
72
|
+
if (/^\.\.\/[^/]+\/src\//.test(from) && n !== 'fetch.ts' && !laneData) { beyond.push(`src/${n} imports ${from}: only src/fetch.ts reaches a lane`); continue; }
|
|
73
|
+
if (n === 'manifest.ts' && from !== '@volter/world-core') { beyond.push(`src/manifest.ts imports ${from}: the manifest is data`); continue; }
|
|
74
|
+
if (from.startsWith('.') || from === '@volter/world-core' || from === '@volter/world-ui') continue;
|
|
75
|
+
if (n === 'cli.ts' && (from === '@volter/world-core/args' || from === '@volter/world-core/lifecycle')) continue;
|
|
76
|
+
beyond.push(`src/${n} imports ${from}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return beyond.length ? [...failures, `kernel-contract: ${beyond.join('; ')}`] : failures;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Each unit's blocking form failures (empty when it may be in the repository). */
|
|
83
|
+
export async function p3Gate(units: string[]): Promise<Array<{ unit: string; failures: string[] }>> {
|
|
84
|
+
const out: Array<{ unit: string; failures: string[] }> = [];
|
|
85
|
+
for (const unit of units) {
|
|
86
|
+
// a vendor whose every API is a lane has no spec of its own: its lanes are graded, and its root is only the vendor's
|
|
87
|
+
// front (architecture, "Pack layout", "Other wires: lanes")
|
|
88
|
+
const lanesOnly = !existsSync(join(unit, 'spec')) && readdirSync(unit).some((n) => existsSync(join(unit, n, 'src', 'manifest.ts')));
|
|
89
|
+
if (lanesOnly || !existsSync(join(unit, 'src', 'manifest.ts'))) { out.push({ unit, failures: laneRootFailures(unit) }); continue; }
|
|
90
|
+
const grades = await grade(unit, { static: true, sections: ['form'] });
|
|
91
|
+
const form = Object.values(grades).flatMap((g) => g.criteria).filter((c) => !c.pass);
|
|
92
|
+
out.push({ unit, failures: form.map((c) => `${c.id}: ${c.reason}`) });
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
package/src/grade.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The grader (docs/contributing/architecture.md, "The grade"): one engine that grades a unit — a pack, or a lane of one,
|
|
2
|
+
// in any repository or an unpacked published tarball — against the latest version of every standard this package
|
|
3
|
+
// holds. It trusts only stamped evidence; a committed score, count or index row is never an input. The proof section
|
|
4
|
+
// reads the customer life's report, which the caller recomputes and hands in (twin-world walks it on an in-memory World);
|
|
5
|
+
// without one, the proof criteria fail as unproven, never pass.
|
|
6
|
+
import { protocol3 } from './protocol-3.ts';
|
|
7
|
+
import type { GradeUnit, ScoreReport, Standard } from './types.ts';
|
|
8
|
+
import { unitAt } from './unit.ts';
|
|
9
|
+
|
|
10
|
+
/** The latest version of each standard. A new protocol, or a revision, replaces or joins this list. */
|
|
11
|
+
export const STANDARDS: Standard[] = [protocol3];
|
|
12
|
+
|
|
13
|
+
export interface CriterionGrade { id: string; section: string; asks: string; pass: boolean; reason: string }
|
|
14
|
+
export interface StandardGrade { grade: number; met: number; total: number; criteria: CriterionGrade[]; planned: string[] }
|
|
15
|
+
|
|
16
|
+
export type GradeOptions = {
|
|
17
|
+
/** only the criteria that read the tree (the executed ones are the pack's typecheck and tests) */
|
|
18
|
+
static?: boolean;
|
|
19
|
+
/** only these sections (the catalog grades a submission's `form`) */
|
|
20
|
+
sections?: ReadonlyArray<'form' | 'proof' | 'soundness'>;
|
|
21
|
+
/** the unit's name when its directory does not say it (an unpacked tarball's `package/`) */
|
|
22
|
+
name?: string;
|
|
23
|
+
/** the customer life's report, recomputed by the caller, and why a walk gave none */
|
|
24
|
+
report?: ScoreReport | null;
|
|
25
|
+
walkError?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Each standard's grade of the unit at `dir`, by `<standard>@<version>`. */
|
|
29
|
+
export async function grade(dir: string, opts: GradeOptions = {}): Promise<Record<string, StandardGrade>> {
|
|
30
|
+
const at = unitAt(dir);
|
|
31
|
+
const name = opts.name ?? at.name;
|
|
32
|
+
const unit: GradeUnit = { name, vendor: name.split('/')[0]!, dir: at.dir, report: opts.report ?? null, ...(opts.walkError ? { walkError: opts.walkError } : {}) };
|
|
33
|
+
const grades: Record<string, StandardGrade> = {};
|
|
34
|
+
for (const standard of STANDARDS) {
|
|
35
|
+
const criteria: CriterionGrade[] = [];
|
|
36
|
+
for (const c of standard.criteria) {
|
|
37
|
+
if (opts.static && c.executed) continue;
|
|
38
|
+
if (opts.sections && !opts.sections.includes(c.section)) continue;
|
|
39
|
+
let result;
|
|
40
|
+
try { result = await c.check(unit); } catch (e) { result = { pass: false, reason: `the check threw: ${(e as Error).message.slice(0, 160)}` }; }
|
|
41
|
+
criteria.push({ id: c.id, section: c.section, asks: c.asks, ...result });
|
|
42
|
+
}
|
|
43
|
+
const met = criteria.filter((c) => c.pass).length;
|
|
44
|
+
grades[`${standard.id}@${standard.version}`] = { grade: criteria.length ? Math.floor((met / criteria.length) * 100) : 0, met, total: criteria.length, criteria, planned: standard.planned };
|
|
45
|
+
}
|
|
46
|
+
return grades;
|
|
47
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// @volter/twin-standard — the Protocol 3 standard (docs/contributing/architecture.md, "The grade"): the criteria a twin
|
|
2
|
+
// pack is judged by, the grader, the gate a pack repository and the catalog run, and the derivation the grade checks
|
|
3
|
+
// against. One package, versioned, so every place a pack is judged — its repository, its release, the catalog's review
|
|
4
|
+
// of a submission — judges by the same standard at the version it pins. Changing a criterion is a release of this
|
|
5
|
+
// package: a criterion's id never changes meaning, a changed criterion takes a new id and a new standard version.
|
|
6
|
+
export { grade, STANDARDS, type CriterionGrade, type GradeOptions, type StandardGrade } from './grade.ts';
|
|
7
|
+
export { laneRootFailures, p3Gate, unitsOf } from './gate.ts';
|
|
8
|
+
export { gatePublished, vendorOfPackage, type PublishedGate } from './published.ts';
|
|
9
|
+
export { handlerName, protocol3, PLANNED } from './protocol-3.ts';
|
|
10
|
+
export { sharingLanesIn } from './lanes.ts';
|
|
11
|
+
export { unitAt } from './unit.ts';
|
|
12
|
+
export type { Criterion, CriterionResult, GradeUnit, ScoreReport, Standard } from './types.ts';
|
package/src/lanes.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The lanes over a vendor's state (docs/contributing/architecture.md, "Pack layout"): a lane whose manifest names the
|
|
2
|
+
// vendor's service and that the vendor's serve factory serves (its server.ts, or its fixed fetch.ts when its manifest routes to the lane, imports the lane's src/). Such a lane is
|
|
3
|
+
// walked, measured and graded through the vendor's life; every other lane keeps its own.
|
|
4
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
/** The service a manifest names: the top-level `service:` of its exported manifest object. */
|
|
8
|
+
function serviceOf(manifestPath: string): string | undefined {
|
|
9
|
+
const text = readFileSync(manifestPath, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
|
|
10
|
+
const body = /export\s+const\s+manifest\b[^=]*=\s*\{([\s\S]*)$/.exec(text)?.[1] ?? '';
|
|
11
|
+
// only the object's own first level: a nested object's `service` is not the manifest's
|
|
12
|
+
let depth = 0;
|
|
13
|
+
for (const m of body.matchAll(/[{}]|\bservice:\s*['"]([^'"]+)['"]/g)) {
|
|
14
|
+
if (m[0] === '{') depth += 1;
|
|
15
|
+
else if (m[0] === '}') { if (depth === 0) break; depth -= 1; }
|
|
16
|
+
else if (depth === 0) return m[1];
|
|
17
|
+
}
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The lanes over a vendor's state in the vendor's directory `pkg`, wherever its repository is. */
|
|
22
|
+
export function sharingLanesIn(pkg: string, vendor: string): string[] {
|
|
23
|
+
// the vendor serves a lane from its serve factory, or from its fixed fetch when its manifest routes to it
|
|
24
|
+
const serverText = ['server.ts', 'fetch.ts'].map((f) => join(pkg, 'src', f)).filter(existsSync).map((f) => readFileSync(f, 'utf8')).join('\n');
|
|
25
|
+
return (existsSync(pkg) ? readdirSync(pkg) : []).filter((l) => {
|
|
26
|
+
const manifest = join(pkg, l, 'src', 'manifest.ts');
|
|
27
|
+
return existsSync(manifest) && serviceOf(manifest) === vendor && new RegExp(`from ['"]\\.\\./${l}/src/`).test(serverText);
|
|
28
|
+
}).sort();
|
|
29
|
+
}
|
package/src/p3-rules.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The architecture's mechanical rules for a derived pack's own code (docs/contributing/architecture.md, "What an author
|
|
2
|
+
// writes, and how"), checked by the derive over `src/` (generated files aside). Each was broken by copying: the rules the
|
|
3
|
+
// tools did not check spread from one pack to thirty (the P3 drift pass, 2026-09-30). A line may record why it is the
|
|
4
|
+
// exception, in a `// p3: <reason>` comment on it or on the line before, and the check passes it.
|
|
5
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
6
|
+
import { join, relative } from 'node:path';
|
|
7
|
+
|
|
8
|
+
export type RuleHit = { file: string; line: number; rule: string; text: string };
|
|
9
|
+
|
|
10
|
+
const RULES: Array<{ rule: string; test: (line: string) => boolean }> = [
|
|
11
|
+
// ids come from ctx.mint: never a row count made into an id, a number or a label's nonce
|
|
12
|
+
{ rule: 'an id or number from a row count (use ctx.mint)', test: (l) => /\b(rowsRaw|rows|rowsOf)\([^;]*?\)(\s*\.filter\([^;]*?\))?\.length\s*\+\s*1\b/.test(l) || /\b1\s*\+\s*[\w.]*\b(rowsRaw|rows|rowsOf)\([^;]*?\)(\s*\.filter\([^;]*?\))?\.length\b/.test(l) },
|
|
13
|
+
// the twin's own URL is ctx.publicBase
|
|
14
|
+
{ rule: "the twin's URL from the request (use ctx.publicBase)", test: (l) => /request\.url\)\.origin/.test(l) },
|
|
15
|
+
// signing, digests and randomness are the kernel's (ctx.crypto, world-core's exports)
|
|
16
|
+
{ rule: 'node:crypto in a pack (use ctx.crypto or the kernel\'s digest)', test: (l) => /from ['"](node:)?crypto['"]|require\(['"](node:)?crypto['"]\)/.test(l) },
|
|
17
|
+
// a webhook is the manifest's events; ctx.deliver is for a message that is no declared event (a push notification)
|
|
18
|
+
{ rule: 'ctx.deliver where the manifest\'s events belong (or record why)', test: (l) => /ctx\.deliver\(/.test(l) },
|
|
19
|
+
// an object's bytes are ctx.blobs, never a row's field
|
|
20
|
+
{ rule: "bytes kept in a row (use ctx.blobs)", test: (l) => /ctx\.(write|record)\([^;]*toString\(['"]base64['"]\)/.test(l) || /\b_bytes\s*:/.test(l) },
|
|
21
|
+
// a secret the vendor issues is drawn from ctx.secret, never computed from ids
|
|
22
|
+
{ rule: 'a secret computed from ids (draw it from ctx.secret)', test: (l) => /(\b(const|let)\s+[a-zA-Z]*(secret|Secret|token|Token|password|Password|hmac|Hmac)\b\s*=|[{,]\s*(secret|token|password|hmac|api_key|client_secret|access_token|refresh_token)\s*:(?!\s*(string|number|unknown)\b))[^;,]*\b(digest|uuidFrom|lettersFrom|sha256)\(/.test(l) && !/secret\(|Secret\(|keyValue\(|tokenValue\(|credential\(/.test(l) },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const files = (dir: string): string[] => readdirSync(dir).flatMap((n) => {
|
|
26
|
+
const p = join(dir, n);
|
|
27
|
+
if (statSync(p).isDirectory()) return n === 'generated' || n === 'node_modules' ? [] : files(p);
|
|
28
|
+
return /\.(ts|tsx)$/.test(n) && !n.endsWith('.test.ts') ? [p] : [];
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** Every line of a unit's `src/` that breaks a rule and does not record why. */
|
|
32
|
+
export function p3RuleHits(unit: string): RuleHit[] {
|
|
33
|
+
const src = join(unit, 'src');
|
|
34
|
+
const out: RuleHit[] = [];
|
|
35
|
+
for (const f of files(src)) {
|
|
36
|
+
const lines = readFileSync(f, 'utf8').split('\n');
|
|
37
|
+
lines.forEach((text, i) => {
|
|
38
|
+
if (/^\s*(\/\/|\*|\/\*)/.test(text)) return;
|
|
39
|
+
const excused = /\/\/ p3: \S/.test(text) || /\/\/ p3: \S/.test(lines[i - 1] ?? '');
|
|
40
|
+
for (const r of RULES) if (!excused && r.test(text)) out.push({ file: relative(unit, f), line: i + 1, rule: r.rule, text: text.trim().slice(0, 140) });
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|