@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
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// A pack's corrections to its vendor's spec (spec/patches.json): RFC 6902 operations (add, replace, remove, move, copy),
|
|
2
|
+
// applied to the parsed spec so the vendored file stays the vendor's own bytes. derive-pack.ts applies them before the
|
|
3
|
+
// IR; vendor-examples.ts reads the patched schemas, which are what the twin is derived from.
|
|
4
|
+
/** Apply one patch to `doc` in place. */
|
|
5
|
+
export function applySpecPatch(doc, p) {
|
|
6
|
+
const at = (pointer) => {
|
|
7
|
+
const keys = pointer.split('/').slice(1).map((k) => k.replace(/~1/g, '/').replace(/~0/g, '~'));
|
|
8
|
+
const key = keys.pop();
|
|
9
|
+
return { parent: keys.reduce((node, k) => node[k], doc), key };
|
|
10
|
+
};
|
|
11
|
+
const read = (pointer) => { const { parent, key } = at(pointer); return parent[key]; };
|
|
12
|
+
const value = p.op === 'move' || p.op === 'copy' ? structuredClone(read(p.from)) : p.value;
|
|
13
|
+
const drop = ({ parent, key }) => (Array.isArray(parent) ? parent.splice(Number(key), 1) : delete parent[key]);
|
|
14
|
+
if (p.op === 'move')
|
|
15
|
+
drop(at(p.from));
|
|
16
|
+
const { parent, key } = at(p.path);
|
|
17
|
+
if (p.op === 'remove')
|
|
18
|
+
drop({ parent, key });
|
|
19
|
+
// into an array, `add` inserts at the index (`-` appends) and `replace` overwrites it
|
|
20
|
+
else if (Array.isArray(parent) && p.op !== 'replace')
|
|
21
|
+
parent.splice(key === '-' ? parent.length : Number(key), 0, value);
|
|
22
|
+
else
|
|
23
|
+
parent[key] = value;
|
|
24
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { CRUD_CLASSES, fromOpenAPI, fromSmithy, fromSwagger2, slug, type SpecIR } from './spec-ir.js';
|
|
2
|
+
export { discoveryToOpenAPI, fromDiscovery, isDiscovery } from './spec-ir-discovery.js';
|
|
3
|
+
export type { ClientCall, ClientOpsSpec } from './spec-ir-client.js';
|
|
4
|
+
export { mergeDocuments, readSpecDocuments, type SpecDocuments } from './spec-documents.js';
|
|
5
|
+
export { applySpecPatch, type SpecPatch } from './spec-patches.js';
|
package/dist/src/spec.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// The spec side of the standard, for a tool that reads a vendor's spec as the derivation does (a vendor's published
|
|
2
|
+
// examples replayed, a client's calls extracted): the spec IR, a Discovery document as OpenAPI, several documents as one
|
|
3
|
+
// API, and a recorded patch applied.
|
|
4
|
+
export { CRUD_CLASSES, fromOpenAPI, fromSmithy, fromSwagger2, slug } from "./spec-ir.js";
|
|
5
|
+
export { discoveryToOpenAPI, fromDiscovery, isDiscovery } from "./spec-ir-discovery.js";
|
|
6
|
+
export { mergeDocuments, readSpecDocuments } from "./spec-documents.js";
|
|
7
|
+
export { applySpecPatch } from "./spec-patches.js";
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** What a criterion is checked against: one unit (a pack, or one lane of a multi-API pack), read from the tree. */
|
|
2
|
+
export interface GradeUnit {
|
|
3
|
+
/** `stripe`, or a lane `aws/secretsmanager` */
|
|
4
|
+
name: string;
|
|
5
|
+
/** the vendor (the pack directory's name) */
|
|
6
|
+
vendor: string;
|
|
7
|
+
/** absolute path of the unit's directory */
|
|
8
|
+
dir: string;
|
|
9
|
+
/** score-pack's report for the unit, recomputed now (null when the unit has no customer life to walk) */
|
|
10
|
+
report: ScoreReport | null;
|
|
11
|
+
/** why the walk gave no report although the unit has a life (a crash, a timeout), else undefined */
|
|
12
|
+
walkError?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ScoreReport {
|
|
15
|
+
vendor: string;
|
|
16
|
+
life: {
|
|
17
|
+
steps: number;
|
|
18
|
+
verdict: string;
|
|
19
|
+
judge: string | null;
|
|
20
|
+
authors: string[];
|
|
21
|
+
};
|
|
22
|
+
failures: Array<{
|
|
23
|
+
step: string;
|
|
24
|
+
beat: string;
|
|
25
|
+
detail: string;
|
|
26
|
+
}>;
|
|
27
|
+
flags: Array<{
|
|
28
|
+
pattern: string;
|
|
29
|
+
step: string;
|
|
30
|
+
detail: string;
|
|
31
|
+
}>;
|
|
32
|
+
coverage: {
|
|
33
|
+
missing: number;
|
|
34
|
+
code: {
|
|
35
|
+
lines: number;
|
|
36
|
+
run: number;
|
|
37
|
+
recorded: number;
|
|
38
|
+
missing: number;
|
|
39
|
+
};
|
|
40
|
+
state?: unknown;
|
|
41
|
+
} | null;
|
|
42
|
+
examples: {
|
|
43
|
+
done: boolean;
|
|
44
|
+
replayed?: boolean;
|
|
45
|
+
verdict?: string;
|
|
46
|
+
line: string;
|
|
47
|
+
};
|
|
48
|
+
breadth: {
|
|
49
|
+
operations: number;
|
|
50
|
+
[servedBy: string]: number;
|
|
51
|
+
};
|
|
52
|
+
missingForDone: string[];
|
|
53
|
+
}
|
|
54
|
+
export interface CriterionResult {
|
|
55
|
+
pass: boolean;
|
|
56
|
+
/** why it failed, or what it measured when it passed */
|
|
57
|
+
reason: string;
|
|
58
|
+
}
|
|
59
|
+
export interface Criterion {
|
|
60
|
+
id: string;
|
|
61
|
+
section: 'form' | 'proof' | 'soundness';
|
|
62
|
+
/** it runs the pack (typecheck, tests), so its result can vary with the machine; the rest read the tree */
|
|
63
|
+
executed?: boolean;
|
|
64
|
+
/** what it asks, in one line, for the catalog */
|
|
65
|
+
asks: string;
|
|
66
|
+
check(unit: GradeUnit): CriterionResult | Promise<CriterionResult>;
|
|
67
|
+
}
|
|
68
|
+
export interface Standard {
|
|
69
|
+
id: string;
|
|
70
|
+
version: number;
|
|
71
|
+
title: string;
|
|
72
|
+
criteria: Criterion[];
|
|
73
|
+
/** criteria the standard names that no tool decides yet: shown, counted in no grade */
|
|
74
|
+
planned: string[];
|
|
75
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// A standard the grader grades against (docs/contributing/architecture.md, "The grade"): its id and version, and
|
|
2
|
+
// its criteria, unweighted (a grade is criteria met over criteria). The grader is one engine (./grade.ts); a new
|
|
3
|
+
// protocol, or a revision of one, is another module beside this, released as a version of this package.
|
|
4
|
+
export {};
|
package/dist/src/unit.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// A unit the standard grades, named from its directory (docs/contributing/architecture.md, "The catalog: where twins come
|
|
2
|
+
// from": the standard takes a pack's directory, in any repository, or an unpacked published tarball): a pack is its
|
|
3
|
+
// directory's name, and a lane keeps its vendor (`cloudflare/r2`).
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { basename, join, resolve } from 'node:path';
|
|
6
|
+
/** The unit at `path`: its absolute directory and the name it is reported by. */
|
|
7
|
+
export function unitAt(path) {
|
|
8
|
+
const dir = resolve(path.replace(/^~/, process.env.HOME ?? '~'));
|
|
9
|
+
// a lane is a unit under its vendor's package: it has no package.json of its own, its vendor's directory does (a pack
|
|
10
|
+
// has its own, whatever its parent holds: a pack repository's root is a workspace)
|
|
11
|
+
const parent = resolve(dir, '..');
|
|
12
|
+
const lane = !existsSync(join(dir, 'package.json')) && existsSync(join(parent, 'package.json')) && existsSync(join(dir, 'spec'));
|
|
13
|
+
return { dir, name: lane ? `${basename(parent)}/${basename(dir)}` : basename(dir) };
|
|
14
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@volter/twin-standard",
|
|
3
|
+
"version": "1.0.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"twin",
|
|
7
|
+
"protocol-3",
|
|
8
|
+
"standard",
|
|
9
|
+
"grade",
|
|
10
|
+
"catalog",
|
|
11
|
+
"openapi"
|
|
12
|
+
],
|
|
13
|
+
"author": "Volter (https://github.com/volter-ai)",
|
|
14
|
+
"license": "Apache-2.0",
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"!**/*.test.ts",
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/volter-ai/twin-world.git",
|
|
28
|
+
"directory": "packages/twin-standard"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/volter-ai/twin-world/tree/main/packages/twin-standard#readme",
|
|
31
|
+
"type": "module",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"types": "./dist/src/index.d.ts",
|
|
35
|
+
"default": "./dist/src/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./cli": {
|
|
38
|
+
"types": "./dist/src/cli.d.ts",
|
|
39
|
+
"default": "./dist/src/cli.js"
|
|
40
|
+
},
|
|
41
|
+
"./check-sources": {
|
|
42
|
+
"types": "./dist/src/check-sources.d.ts",
|
|
43
|
+
"default": "./dist/src/check-sources.js"
|
|
44
|
+
},
|
|
45
|
+
"./spec": {
|
|
46
|
+
"types": "./dist/src/spec.d.ts",
|
|
47
|
+
"default": "./dist/src/spec.js"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"bin": {
|
|
51
|
+
"twin-standard": "dist/src/cli.js"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"build": "node ../../scripts/publish/build.mjs",
|
|
56
|
+
"prepack": "node ../../scripts/publish/prepare-publish.mjs prepack",
|
|
57
|
+
"postpack": "node ../../scripts/publish/prepare-publish.mjs postpack"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"graphql": "^16.13.2",
|
|
61
|
+
"typescript": "^5.9.0",
|
|
62
|
+
"yaml": "2.9.0"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@types/bun": "^1.2.20",
|
|
66
|
+
"@types/node": "^24.0.0"
|
|
67
|
+
},
|
|
68
|
+
"engines": {
|
|
69
|
+
"node": ">=22.3"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// check-sources — the evidence a derived pack cites, fetched (docs/contributing/architecture.md,
|
|
3
|
+
// "Protocol 3", Correcting the spec and The semantics layer). Every URL a state transition's `source`,
|
|
4
|
+
// a handler's `// source:` line or a spec patch's `why` names is fetched once and its answer recorded in `spec/sources.json`; a patch's
|
|
5
|
+
// `quote` is looked for in the text of the page its `source` names. derive-pack reads the record offline
|
|
6
|
+
// and refuses a dead source, one never checked, and a quote its page does not hold.
|
|
7
|
+
//
|
|
8
|
+
// Documentation pages, never a vendor's API: each is fetched at most once per run, sixteen at a time,
|
|
9
|
+
// and a page already recorded as found is not fetched again (--recheck fetches everything).
|
|
10
|
+
//
|
|
11
|
+
// Usage: twin-standard check-sources <pack dir> [--recheck] (twin-world: bun scripts/check-sources.ts <vendor>)
|
|
12
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { join, resolve } from 'node:path';
|
|
14
|
+
import { unitAt } from './unit.ts';
|
|
15
|
+
|
|
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 } };
|
|
18
|
+
|
|
19
|
+
/** 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
|
+
|
|
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). */
|
|
24
|
+
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
|
|
30
|
+
const m = /(?:^\s*(?:\/\/|\/?\*+)|[\s;,)}\]]\/\/)\s*source:\s*(.+?)\s*(?:\*\/)?\s*$/i.exec(line);
|
|
31
|
+
return m ? [{ where: `${f}:${i + 1}`, source: m[1]! }] : [];
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A handler citation's page and the words it says the page holds (`https://… "quote"`), when it quotes. */
|
|
36
|
+
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;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** What a pack cites: each URL, and the quotes a patch says the URL's page holds. */
|
|
42
|
+
export async function citations(pkg: string): Promise<Map<string, Set<string>>> {
|
|
43
|
+
const cited = new Map<string, Set<string>>();
|
|
44
|
+
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);
|
|
47
|
+
for (const c of handlerCitations(pkg)) {
|
|
48
|
+
const q = quotedPage(c.source);
|
|
49
|
+
if (q) cite(q.url, q.quote);
|
|
50
|
+
else for (const u of urlsIn(c.source)) cite(u);
|
|
51
|
+
}
|
|
52
|
+
const patchPath = join(pkg, 'spec', 'patches.json');
|
|
53
|
+
if (existsSync(patchPath)) {
|
|
54
|
+
for (const p of (JSON.parse(readFileSync(patchPath, 'utf8')) as { patches: Patch[] }).patches) {
|
|
55
|
+
for (const u of urlsIn(p.why)) cite(u);
|
|
56
|
+
if (p.source?.url) cite(p.source.url, p.source.quote);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return cited;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// a page's characters with its markup, entities and whitespace gone, so a quote from the rendered page is
|
|
63
|
+
// found in it however the page highlights or wraps it (a code example is a span per token, and an external link
|
|
64
|
+
// carries an invisible U+2060 word joiner beside its icon: clerk.com/docs/webhooks/overview, "Clerk uses Svix")
|
|
65
|
+
const textOf = (html: string): string => bare(html.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '').replace(/<[^>]+>/g, '')
|
|
66
|
+
.replace(/"/g, '"').replace(/'|'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/ /g, ' ').replace(/&/g, '&'));
|
|
67
|
+
const bare = (s: string): string => s.replace(/[\s\p{Cf}]+/gu, '');
|
|
68
|
+
|
|
69
|
+
/** Fetch and record the sources the unit at `args[0]` cites (`--recheck`: every one, not only those due). */
|
|
70
|
+
export async function checkSources(args: string[]): Promise<void> {
|
|
71
|
+
const vendor = args[0];
|
|
72
|
+
if (!vendor) { console.error('usage: twin-standard check-sources <pack dir> [--recheck]'); process.exit(1); }
|
|
73
|
+
const pkg = unitAt(vendor).dir;
|
|
74
|
+
const recordPath = join(pkg, 'spec', 'sources.json');
|
|
75
|
+
const record: Record<string, SourceRecord> = existsSync(recordPath) ? JSON.parse(readFileSync(recordPath, 'utf8')) : {};
|
|
76
|
+
const cited = await citations(pkg);
|
|
77
|
+
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));
|
|
79
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
80
|
+
let next = 0;
|
|
81
|
+
await Promise.all(Array.from({ length: 16 }, async () => {
|
|
82
|
+
while (next < due.length) {
|
|
83
|
+
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; }
|
|
96
|
+
const found = Object.fromEntries([...quotes].map((q) => [q, status === 200 && text.includes(bare(q))]));
|
|
97
|
+
record[url] = { status, checked: today, ...(quotes.size ? { quotes: found } : {}) };
|
|
98
|
+
}
|
|
99
|
+
}));
|
|
100
|
+
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);
|
|
103
|
+
const unquoted = Object.entries(sorted).flatMap(([url, r]) => Object.entries(r.quotes ?? {}).filter(([, ok]) => !ok).map(([q]) => `${url}: "${q}"`));
|
|
104
|
+
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}`);
|
|
106
|
+
for (const u of unquoted) console.log(` quote missing: ${u}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (import.meta.main) await checkSources(process.argv.slice(2));
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// twin-standard — the Protocol 3 standard's commands, the same wherever they run (a pack repository's hook and workflow,
|
|
3
|
+
// a release, the catalog's check of a submission, an author's machine):
|
|
4
|
+
//
|
|
5
|
+
// twin-standard gate <pack repository> [<pack or lane dir>…] every unit passes the form section, or exit 1
|
|
6
|
+
// twin-standard gate-published <name>@<version>… [--registry <url>]
|
|
7
|
+
// each published version passes the gate, as the catalog
|
|
8
|
+
// grades a submission (its tarball, unpacked)
|
|
9
|
+
// twin-standard grade <pack or lane dir>… [--static] [--form] each criterion's result
|
|
10
|
+
// twin-standard derive <pack or lane dir> [--out <dir>] write the unit's src/generated/ from its spec
|
|
11
|
+
// twin-standard check-sources <pack or lane dir> [--recheck] fetch and record the sources the unit cites
|
|
12
|
+
// twin-standard --version the package's version and each standard's
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { relative, resolve } from 'node:path';
|
|
15
|
+
import { unitsOf, p3Gate } from './gate.ts';
|
|
16
|
+
import { grade, STANDARDS } from './grade.ts';
|
|
17
|
+
import { gatePublished } from './published.ts';
|
|
18
|
+
|
|
19
|
+
const [command, ...args] = process.argv.slice(2);
|
|
20
|
+
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');
|
|
22
|
+
process.exit(2);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
if (command === '--version') {
|
|
26
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string };
|
|
27
|
+
console.log(`@volter/twin-standard ${pkg.version} · ${STANDARDS.map((s) => `${s.id}@${s.version}`).join(' ')}`);
|
|
28
|
+
} else if (command === 'gate') {
|
|
29
|
+
const [repoArg, ...only] = args;
|
|
30
|
+
if (!repoArg) usage();
|
|
31
|
+
const repo = resolve(repoArg!);
|
|
32
|
+
const results = await p3Gate(only.length ? only.map((u) => resolve(u)) : unitsOf(repo));
|
|
33
|
+
let blocked = 0;
|
|
34
|
+
for (const r of results) {
|
|
35
|
+
const name = relative(repo, r.unit) || r.unit;
|
|
36
|
+
if (r.failures.length) { blocked++; console.error(`✗ ${name}`); for (const f of r.failures) console.error(` ${f}`); }
|
|
37
|
+
else console.log(`✓ ${name}`);
|
|
38
|
+
}
|
|
39
|
+
console.log(blocked ? `gate: ${blocked} of ${results.length} units are not Protocol 3; they may not be in ${repo}` : `gate: all ${results.length} units are Protocol 3`);
|
|
40
|
+
process.exit(blocked ? 1 : 0);
|
|
41
|
+
} else if (command === 'gate-published') {
|
|
42
|
+
const at = args.indexOf('--registry');
|
|
43
|
+
const registry = at >= 0 ? args[at + 1] : undefined;
|
|
44
|
+
const specs = args.filter((a, i) => !a.startsWith('--') && (at < 0 || i !== at + 1));
|
|
45
|
+
if (!specs.length) usage();
|
|
46
|
+
let blocked = 0;
|
|
47
|
+
for (const spec of specs) {
|
|
48
|
+
let r;
|
|
49
|
+
try { r = await gatePublished(spec, registry ? { registry } : {}); } catch (e) { blocked++; console.error(`✗ ${spec}\n ${(e as Error).message}`); continue; }
|
|
50
|
+
if (r.failures.length) { blocked++; console.error(`✗ ${spec}`); for (const f of r.failures) for (const x of f.failures) console.error(` ${f.unit}: ${x}`); }
|
|
51
|
+
else console.log(`✓ ${spec}`);
|
|
52
|
+
}
|
|
53
|
+
console.log(blocked ? `gate: ${blocked} of ${specs.length} versions are not Protocol 3` : `gate: all ${specs.length} versions are Protocol 3`);
|
|
54
|
+
process.exit(blocked ? 1 : 0);
|
|
55
|
+
} else if (command === 'grade') {
|
|
56
|
+
const units = args.filter((a) => !a.startsWith('--'));
|
|
57
|
+
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
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} else if (command === 'derive') {
|
|
66
|
+
if (!args[0]) usage();
|
|
67
|
+
// the derivation is a script over process.argv: it reads the unit from its first argument
|
|
68
|
+
process.argv = [process.argv[0]!, process.argv[1]!, ...args];
|
|
69
|
+
await import('./derive.ts');
|
|
70
|
+
} else if (command === 'check-sources') {
|
|
71
|
+
if (!args[0]) usage();
|
|
72
|
+
await (await import('./check-sources.ts')).checkSources(args);
|
|
73
|
+
} else {
|
|
74
|
+
usage();
|
|
75
|
+
}
|