mhproto 0.8.0-preview.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/CONTRIBUTING.md +62 -0
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/bin/mhproto.mjs +248 -0
- package/doc/context-design.md +43 -0
- package/doc/context-measurements.json +28 -0
- package/doc/guide.md +138 -0
- package/doc/publishing.md +93 -0
- package/doc/release-notes.md +23 -0
- package/doc/release-review-2026-10-02.md +127 -0
- package/doc/release-review.md +68 -0
- package/doc/viewer-design.md +80 -0
- package/package.json +69 -0
- package/skills/mhproto-discover/SKILL.md +14 -0
- package/skills/mhproto-implement/SKILL.md +14 -0
- package/skills/mhproto-reconcile/SKILL.md +14 -0
- package/skills/mhproto-specify/SKILL.md +16 -0
- package/skills/mhproto-specify/references/format.md +20 -0
- package/skills/mhproto-verify/SKILL.md +14 -0
- package/src/config.mjs +202 -0
- package/src/context.mjs +228 -0
- package/src/core.mjs +471 -0
- package/src/node-reporter.mjs +15 -0
- package/src/paths.mjs +51 -0
- package/src/server.mjs +290 -0
- package/src/verify.mjs +149 -0
- package/src/visuals.mjs +189 -0
- package/viewer/app.js +1463 -0
- package/viewer/diff.js +245 -0
- package/viewer/index.html +51 -0
- package/viewer/style.css +1003 -0
- package/viewer/vendor/LICENSE +21 -0
- package/viewer/vendor/NOTICE.txt +3372 -0
- package/viewer/vendor/README.md +75 -0
- package/viewer/vendor/build-evidence.json +2775 -0
- package/viewer/vendor/bundled-audit.json +88 -0
- package/viewer/vendor/bundled-packages.json +84 -0
- package/viewer/vendor/embedded-notices.txt +33 -0
- package/viewer/vendor/license-inventory.json +1456 -0
- package/viewer/vendor/licenses/braintree__sanitize-url-7.1.2.txt +21 -0
- package/viewer/vendor/licenses/chevrotain-13.2.0.txt +202 -0
- package/viewer/vendor/licenses/chevrotain-allstar-0.5.0.txt +16 -0
- package/viewer/vendor/licenses/chevrotain__cst-dts-gen-13.2.0.txt +202 -0
- package/viewer/vendor/licenses/chevrotain__gast-13.2.0.txt +202 -0
- package/viewer/vendor/licenses/chevrotain__regexp-to-ast-13.2.0.txt +202 -0
- package/viewer/vendor/licenses/chevrotain__utils-13.2.0.txt +202 -0
- package/viewer/vendor/licenses/cose-base-1.0.3.txt +21 -0
- package/viewer/vendor/licenses/cose-base-2.2.0.txt +21 -0
- package/viewer/vendor/licenses/cytoscape-3.34.3.txt +19 -0
- package/viewer/vendor/licenses/cytoscape-cose-bilkent-4.1.0.txt +21 -0
- package/viewer/vendor/licenses/cytoscape-fcose-2.2.0.txt +19 -0
- package/viewer/vendor/licenses/d3-7.9.0.txt +13 -0
- package/viewer/vendor/licenses/d3-array-2.12.1.txt +27 -0
- package/viewer/vendor/licenses/d3-array-3.2.4.txt +13 -0
- package/viewer/vendor/licenses/d3-axis-3.0.0.txt +13 -0
- package/viewer/vendor/licenses/d3-brush-3.0.0.txt +13 -0
- package/viewer/vendor/licenses/d3-chord-3.0.1.txt +13 -0
- package/viewer/vendor/licenses/d3-color-3.1.0.txt +13 -0
- package/viewer/vendor/licenses/d3-contour-4.0.2.txt +13 -0
- package/viewer/vendor/licenses/d3-delaunay-6.0.4.txt +14 -0
- package/viewer/vendor/licenses/d3-dispatch-3.0.1.txt +13 -0
- package/viewer/vendor/licenses/d3-drag-3.0.0.txt +13 -0
- package/viewer/vendor/licenses/d3-dsv-3.0.1.txt +13 -0
- package/viewer/vendor/licenses/d3-ease-3.0.1.txt +28 -0
- package/viewer/vendor/licenses/d3-fetch-3.0.1.txt +13 -0
- package/viewer/vendor/licenses/d3-force-3.0.0.txt +13 -0
- package/viewer/vendor/licenses/d3-format-3.1.2.txt +13 -0
- package/viewer/vendor/licenses/d3-geo-3.1.1.txt +34 -0
- package/viewer/vendor/licenses/d3-hierarchy-3.1.2.txt +13 -0
- package/viewer/vendor/licenses/d3-interpolate-3.0.1.txt +13 -0
- package/viewer/vendor/licenses/d3-path-1.0.9.txt +27 -0
- package/viewer/vendor/licenses/d3-path-3.1.0.txt +13 -0
- package/viewer/vendor/licenses/d3-polygon-3.0.1.txt +13 -0
- package/viewer/vendor/licenses/d3-quadtree-3.0.1.txt +13 -0
- package/viewer/vendor/licenses/d3-random-3.0.1.txt +13 -0
- package/viewer/vendor/licenses/d3-sankey-0.12.3.txt +27 -0
- package/viewer/vendor/licenses/d3-scale-4.0.2.txt +13 -0
- package/viewer/vendor/licenses/d3-scale-chromatic-3.1.0.txt +28 -0
- package/viewer/vendor/licenses/d3-selection-3.0.0.txt +13 -0
- package/viewer/vendor/licenses/d3-shape-1.3.7.txt +27 -0
- package/viewer/vendor/licenses/d3-shape-3.2.0.txt +13 -0
- package/viewer/vendor/licenses/d3-time-3.1.0.txt +13 -0
- package/viewer/vendor/licenses/d3-time-format-4.1.0.txt +13 -0
- package/viewer/vendor/licenses/d3-timer-3.0.1.txt +13 -0
- package/viewer/vendor/licenses/d3-transition-3.0.1.txt +13 -0
- package/viewer/vendor/licenses/d3-zoom-3.0.0.txt +13 -0
- package/viewer/vendor/licenses/dagre-d3-es-7.0.14.txt +23 -0
- package/viewer/vendor/licenses/dayjs-1.11.23.txt +21 -0
- package/viewer/vendor/licenses/delaunator-5.1.0.txt +15 -0
- package/viewer/vendor/licenses/dompurify-3.4.16.txt +202 -0
- package/viewer/vendor/licenses/elk-source-notice.txt +11 -0
- package/viewer/vendor/licenses/elkjs-0.9.3.txt +264 -0
- package/viewer/vendor/licenses/embedded-and-node-notices.txt +45 -0
- package/viewer/vendor/licenses/es-toolkit-1.52.0-1.txt +39 -0
- package/viewer/vendor/licenses/es-toolkit-1.52.0.txt +21 -0
- package/viewer/vendor/licenses/fastdom-1.0.12.txt +221 -0
- package/viewer/vendor/licenses/iconify__utils-3.1.7.txt +21 -0
- package/viewer/vendor/licenses/internmap-1.0.1.txt +13 -0
- package/viewer/vendor/licenses/internmap-2.0.3.txt +13 -0
- package/viewer/vendor/licenses/js-yaml-4.3.2.txt +21 -0
- package/viewer/vendor/licenses/katex-0.16.47.txt +21 -0
- package/viewer/vendor/licenses/khroma-2.1.0.txt +21 -0
- package/viewer/vendor/licenses/langium-4.4.0.txt +16 -0
- package/viewer/vendor/licenses/layout-base-1.0.2.txt +21 -0
- package/viewer/vendor/licenses/layout-base-2.0.1.txt +21 -0
- package/viewer/vendor/licenses/lodash-es-4.18.1.txt +47 -0
- package/viewer/vendor/licenses/marked-16.4.2.txt +44 -0
- package/viewer/vendor/licenses/mermaid-12.1.0.txt +21 -0
- package/viewer/vendor/licenses/mermaid-js__parser-2.0.1.txt +21 -0
- package/viewer/vendor/licenses/path-browserify-1.0.1.txt +20 -0
- package/viewer/vendor/licenses/robust-predicates-3.0.3.txt +24 -0
- package/viewer/vendor/licenses/roughjs-4.6.6.txt +21 -0
- package/viewer/vendor/licenses/stylis-4.4.0.txt +21 -0
- package/viewer/vendor/licenses/ts-dedent-2.3.0.txt +21 -0
- package/viewer/vendor/licenses/upsetjs__venn.js-2.0.0.txt +22 -0
- package/viewer/vendor/licenses/uuid-14.0.2.txt +9 -0
- package/viewer/vendor/licenses/vscode-jsonrpc-9.0.3.txt +11 -0
- package/viewer/vendor/licenses/vscode-languageserver-protocol-3.18.4.txt +11 -0
- package/viewer/vendor/licenses/vscode-languageserver-textdocument-1.0.15.txt +11 -0
- package/viewer/vendor/licenses/vscode-languageserver-types-3.18.4.txt +11 -0
- package/viewer/vendor/licenses/vscode-uri-3.1.0.txt +9 -0
- package/viewer/vendor/manifest.json +21 -0
- package/viewer/vendor/mermaid.min.js +7729 -0
package/src/server.mjs
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readFile, mkdir, realpath } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { loadProject, validateProject, packageRoot, projectPath } from './core.mjs';
|
|
5
|
+
import { addVisual, mediaMime, maxMediaBytes } from './visuals.mjs';
|
|
6
|
+
import { atomicWrite, writePath } from './paths.mjs';
|
|
7
|
+
import { compareModels, contractSnapshot, snapshotProject } from '../viewer/diff.js';
|
|
8
|
+
export { compareModels } from '../viewer/diff.js';
|
|
9
|
+
|
|
10
|
+
const assets = {
|
|
11
|
+
'/': ['index.html', 'text/html'],
|
|
12
|
+
'/app.js': ['app.js', 'text/javascript'],
|
|
13
|
+
'/diff.js': ['diff.js', 'text/javascript'],
|
|
14
|
+
'/style.css': ['style.css', 'text/css'],
|
|
15
|
+
'/vendor/mermaid.min.js': ['vendor/mermaid.min.js', 'text/javascript'],
|
|
16
|
+
};
|
|
17
|
+
export async function model(root) {
|
|
18
|
+
const project = await loadProject(root);
|
|
19
|
+
return { ...project, issues: await validateProject(project) };
|
|
20
|
+
}
|
|
21
|
+
async function readBaseline(file, required = false) {
|
|
22
|
+
try {
|
|
23
|
+
const value = JSON.parse(await readFile(file, 'utf8'));
|
|
24
|
+
snapshotProject(value);
|
|
25
|
+
return contractSnapshot(value, {
|
|
26
|
+
label: value.label ?? 'Saved baseline',
|
|
27
|
+
createdAt: value.createdAt ?? null,
|
|
28
|
+
});
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error.code === 'ENOENT' && !required) return null;
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createHandler(root, { against } = {}) {
|
|
36
|
+
const baselineFile = path.resolve(root, against ?? '.mhproto/baseline.json');
|
|
37
|
+
return async (request, response) => {
|
|
38
|
+
response.setHeader(
|
|
39
|
+
'content-security-policy',
|
|
40
|
+
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'",
|
|
41
|
+
);
|
|
42
|
+
response.setHeader('x-content-type-options', 'nosniff');
|
|
43
|
+
response.setHeader('cache-control', 'no-store');
|
|
44
|
+
if (!/^(127\.0\.0\.1|localhost)(:\d+)?$/.test(request.headers.host ?? '')) {
|
|
45
|
+
response.writeHead(403).end();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
50
|
+
if (request.method === 'POST' && url.pathname === '/api/baseline') {
|
|
51
|
+
if (against) {
|
|
52
|
+
response.writeHead(409, { 'content-type': 'application/json' }).end(
|
|
53
|
+
JSON.stringify({
|
|
54
|
+
error:
|
|
55
|
+
'This comparison uses --against. Start a new viewer without it to save an iteration baseline.',
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (request.headers.origin !== 'http://' + request.headers.host) {
|
|
61
|
+
response.writeHead(403).end();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!(request.headers['content-type'] ?? '').startsWith('application/json')) {
|
|
65
|
+
response.writeHead(415).end();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
let bytes = 0;
|
|
69
|
+
for await (const chunk of request) {
|
|
70
|
+
bytes += chunk.length;
|
|
71
|
+
if (bytes > 1024) {
|
|
72
|
+
response.writeHead(413).end();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const baseline = contractSnapshot(await model(root));
|
|
77
|
+
await atomicWrite(root, '.mhproto/baseline.json', JSON.stringify(baseline, null, 2) + '\n');
|
|
78
|
+
response
|
|
79
|
+
.writeHead(201, { 'content-type': 'application/json' })
|
|
80
|
+
.end(JSON.stringify(baseline));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (request.method === 'POST' && url.pathname === '/api/visuals') {
|
|
84
|
+
if (request.headers.origin !== 'http://' + request.headers.host) {
|
|
85
|
+
response.writeHead(403).end();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!(request.headers['content-type'] ?? '').startsWith('application/json')) {
|
|
89
|
+
response.writeHead(415).end();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const chunks = [];
|
|
93
|
+
let bytes = 0;
|
|
94
|
+
for await (const chunk of request) {
|
|
95
|
+
bytes += chunk.length;
|
|
96
|
+
if (bytes > Math.ceil((maxMediaBytes * 4) / 3) + 8192) {
|
|
97
|
+
response.writeHead(413).end();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
chunks.push(chunk);
|
|
101
|
+
}
|
|
102
|
+
let visual;
|
|
103
|
+
try {
|
|
104
|
+
visual = await addVisual(
|
|
105
|
+
root,
|
|
106
|
+
await loadProject(root),
|
|
107
|
+
JSON.parse(Buffer.concat(chunks).toString('utf8')),
|
|
108
|
+
);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
response
|
|
111
|
+
.writeHead(400, { 'content-type': 'application/json' })
|
|
112
|
+
.end(JSON.stringify({ error: error.message }));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
response.writeHead(201, { 'content-type': 'application/json' }).end(JSON.stringify(visual));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (!['GET', 'HEAD'].includes(request.method)) {
|
|
119
|
+
response.writeHead(405).end();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (url.pathname.startsWith('/api/visuals/')) {
|
|
123
|
+
const project = await loadProject(root),
|
|
124
|
+
id = url.pathname.slice('/api/visuals/'.length);
|
|
125
|
+
const visual = project.visuals.find((v) => v.id === id && v.file);
|
|
126
|
+
if (!visual) {
|
|
127
|
+
response.writeHead(404).end();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
response.setHeader('content-type', mediaMime(visual.file));
|
|
131
|
+
const bytes = await readFile(await projectPath(root, visual.file));
|
|
132
|
+
response.end(request.method === 'HEAD' ? undefined : bytes);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (url.pathname === '/api/model') {
|
|
136
|
+
response.setHeader('content-type', 'application/json');
|
|
137
|
+
response.end(JSON.stringify(await model(root)));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (url.pathname === '/api/diff') {
|
|
141
|
+
const previous = await readBaseline(baselineFile, Boolean(against));
|
|
142
|
+
response.setHeader('content-type', 'application/json');
|
|
143
|
+
response.end(
|
|
144
|
+
JSON.stringify({
|
|
145
|
+
available: Boolean(previous),
|
|
146
|
+
changes: previous ? compareModels(previous, await model(root)) : [],
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (url.pathname === '/api/baseline') {
|
|
152
|
+
response.setHeader('x-mhproto-baseline-readonly', String(Boolean(against)));
|
|
153
|
+
response.setHeader('content-type', 'application/json');
|
|
154
|
+
response.end(JSON.stringify(await readBaseline(baselineFile, Boolean(against))));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const asset = assets[url.pathname];
|
|
158
|
+
if (!asset) {
|
|
159
|
+
response.writeHead(404).end();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
response.setHeader('content-type', asset[1]);
|
|
163
|
+
response.end(await readFile(path.join(packageRoot, 'viewer', asset[0])));
|
|
164
|
+
} catch (error) {
|
|
165
|
+
response
|
|
166
|
+
.writeHead(500, { 'content-type': 'application/json' })
|
|
167
|
+
.end(JSON.stringify({ error: error.message }));
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
export async function serve(root, port = 4317, options = {}) {
|
|
172
|
+
const server = createServer(createHandler(root, options));
|
|
173
|
+
await new Promise((resolve, reject) => {
|
|
174
|
+
server.once('error', reject);
|
|
175
|
+
server.listen(port, '127.0.0.1', resolve);
|
|
176
|
+
});
|
|
177
|
+
return server;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function exportViewer(root, destination, { against } = {}) {
|
|
181
|
+
const actualRoot = await realpath(root);
|
|
182
|
+
const actualDestination = await realpath(destination).catch((error) => {
|
|
183
|
+
if (error.code !== 'ENOENT') throw error;
|
|
184
|
+
return path.resolve(destination);
|
|
185
|
+
});
|
|
186
|
+
if (actualDestination === actualRoot)
|
|
187
|
+
throw new Error('Export into a separate directory, not the project root');
|
|
188
|
+
const relative = path.relative(path.resolve(root), path.resolve(destination));
|
|
189
|
+
if (!relative.startsWith('..' + path.sep) && !path.isAbsolute(relative))
|
|
190
|
+
await writePath(root, path.join(relative, 'viewer.html'));
|
|
191
|
+
const loaded = await model(root);
|
|
192
|
+
if (loaded.issues.some((issue) => issue.level === 'error'))
|
|
193
|
+
throw new Error('Fix contract validation errors before exporting');
|
|
194
|
+
// Portable exports retain evidence summaries, not captured process output or
|
|
195
|
+
// machine paths. Authored contract data still needs review before sharing.
|
|
196
|
+
const { root: localRoot, ...data } = loaded;
|
|
197
|
+
const keep = (value, keys) =>
|
|
198
|
+
Object.fromEntries(
|
|
199
|
+
keys.filter((key) => value[key] !== undefined).map((key) => [key, value[key]]),
|
|
200
|
+
);
|
|
201
|
+
const project = {
|
|
202
|
+
...data,
|
|
203
|
+
capabilities: loaded.capabilities.map((cap) => ({
|
|
204
|
+
...cap,
|
|
205
|
+
evidence: cap.evidence
|
|
206
|
+
? {
|
|
207
|
+
...keep(cap.evidence, [
|
|
208
|
+
'version',
|
|
209
|
+
'capability',
|
|
210
|
+
'digest',
|
|
211
|
+
'finishedAt',
|
|
212
|
+
'changedDuringRun',
|
|
213
|
+
'stale',
|
|
214
|
+
]),
|
|
215
|
+
results: cap.evidence.results.map((result) => ({
|
|
216
|
+
...keep(result, [
|
|
217
|
+
'id',
|
|
218
|
+
'status',
|
|
219
|
+
'startedAt',
|
|
220
|
+
'durationMs',
|
|
221
|
+
'exitCode',
|
|
222
|
+
'timedOut',
|
|
223
|
+
'missing',
|
|
224
|
+
'skipped',
|
|
225
|
+
'todo',
|
|
226
|
+
'truncated',
|
|
227
|
+
'rules',
|
|
228
|
+
'examples',
|
|
229
|
+
]),
|
|
230
|
+
tests: (result.tests ?? []).map((test) =>
|
|
231
|
+
keep(test, ['type', 'name', 'skip', 'todo']),
|
|
232
|
+
),
|
|
233
|
+
})),
|
|
234
|
+
}
|
|
235
|
+
: null,
|
|
236
|
+
})),
|
|
237
|
+
};
|
|
238
|
+
const media = {};
|
|
239
|
+
for (const visual of project.visuals)
|
|
240
|
+
if (visual.file) {
|
|
241
|
+
const mime = mediaMime(visual.file);
|
|
242
|
+
if (!mime) throw new Error(`Unsupported visual file: ${visual.file}`);
|
|
243
|
+
media[visual.id] =
|
|
244
|
+
`data:${mime};base64,${(await readFile(await projectPath(root, visual.file))).toString('base64')}`;
|
|
245
|
+
}
|
|
246
|
+
const baseline = await readBaseline(
|
|
247
|
+
path.resolve(root, against ?? '.mhproto/baseline.json'),
|
|
248
|
+
Boolean(against),
|
|
249
|
+
);
|
|
250
|
+
const [html, css, js, mermaid, diff] = await Promise.all(
|
|
251
|
+
['index.html', 'style.css', 'app.js', 'vendor/mermaid.min.js', 'diff.js'].map((name) =>
|
|
252
|
+
readFile(path.join(packageRoot, 'viewer', name), 'utf8'),
|
|
253
|
+
),
|
|
254
|
+
);
|
|
255
|
+
const bundled = js.replace(/import\s*\{[^}]+\}\s*from\s*['"]\.\/diff\.js['"];?/, () =>
|
|
256
|
+
diff.replace(/^export /gm, ''),
|
|
257
|
+
);
|
|
258
|
+
const safeJson = (value) => JSON.stringify(value).replaceAll('<', '\\u003c');
|
|
259
|
+
const standalone = html
|
|
260
|
+
.replace(/<link\s+rel="stylesheet"\s+href="\/style\.css"\s*\/?>/, () => `<style>${css}</style>`)
|
|
261
|
+
.replace(
|
|
262
|
+
/<script\s+src="\/vendor\/mermaid\.min\.js"\s*>\s*<\/script>/,
|
|
263
|
+
() => `<script>${mermaid.replace(/<\/script/gi, '<\\/script')}</script>`,
|
|
264
|
+
)
|
|
265
|
+
.replace(
|
|
266
|
+
/<script\s+type="module"\s+src="\/app\.js"\s*>\s*<\/script>/,
|
|
267
|
+
() =>
|
|
268
|
+
`<script id="mhproto-model" type="application/json">${safeJson(project)}</script><script id="mhproto-media" type="application/json">${safeJson(media)}</script><script id="mhproto-baseline" type="application/json">${safeJson(baseline)}</script><script type="module">${bundled.replace(/<\/script/gi, '<\\/script')}</script>`,
|
|
269
|
+
);
|
|
270
|
+
if (
|
|
271
|
+
!standalone.includes('id="mhproto-model"') ||
|
|
272
|
+
/<script[^>]+src=/.test(standalone) ||
|
|
273
|
+
/<link[^>]+href="\/style\.css"/.test(standalone)
|
|
274
|
+
)
|
|
275
|
+
throw new Error('Viewer template could not be bundled into a standalone export');
|
|
276
|
+
const output = new Map([
|
|
277
|
+
['index.html', html],
|
|
278
|
+
['style.css', css],
|
|
279
|
+
['app.js', js],
|
|
280
|
+
['vendor/mermaid.min.js', mermaid],
|
|
281
|
+
['diff.js', diff],
|
|
282
|
+
['model.json', JSON.stringify(project, null, 2)],
|
|
283
|
+
['baseline.json', JSON.stringify(baseline)],
|
|
284
|
+
['viewer.html', standalone],
|
|
285
|
+
]);
|
|
286
|
+
await mkdir(destination, { recursive: true });
|
|
287
|
+
// Resolve every output before replacing any existing file.
|
|
288
|
+
for (const name of output.keys()) await writePath(destination, name);
|
|
289
|
+
for (const [name, contents] of output) await atomicWrite(destination, name, contents);
|
|
290
|
+
}
|
package/src/verify.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { loadProject, packageRoot } from './core.mjs';
|
|
5
|
+
import { atomicWrite } from './paths.mjs';
|
|
6
|
+
|
|
7
|
+
export async function runCheck(check, root) {
|
|
8
|
+
const command = check.command.map((arg) =>
|
|
9
|
+
arg === '{mhprotoNodeReporter}' ? path.join(packageRoot, 'src/node-reporter.mjs') : arg,
|
|
10
|
+
);
|
|
11
|
+
const startedAt = new Date().toISOString();
|
|
12
|
+
const started = Date.now();
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const env = { ...process.env, ...check.env };
|
|
15
|
+
// Each command is an independent run even when MHProto itself is called from node --test.
|
|
16
|
+
delete env.NODE_TEST_CONTEXT;
|
|
17
|
+
const child = spawn(command[0], command.slice(1), {
|
|
18
|
+
cwd: root,
|
|
19
|
+
shell: false,
|
|
20
|
+
env,
|
|
21
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
22
|
+
detached: process.platform !== 'win32',
|
|
23
|
+
});
|
|
24
|
+
let stdout = '',
|
|
25
|
+
stderr = '',
|
|
26
|
+
truncated = false,
|
|
27
|
+
settled = false,
|
|
28
|
+
timedOut = false;
|
|
29
|
+
const tests = [];
|
|
30
|
+
const decoder = new StringDecoder('utf8');
|
|
31
|
+
let pending = '',
|
|
32
|
+
eventBytes = 0,
|
|
33
|
+
malformed = false;
|
|
34
|
+
const limit = 1024 * 1024;
|
|
35
|
+
const stop = () => {
|
|
36
|
+
try {
|
|
37
|
+
if (process.platform === 'win32') child.kill('SIGKILL');
|
|
38
|
+
else process.kill(-child.pid, 'SIGKILL');
|
|
39
|
+
} catch {}
|
|
40
|
+
};
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
timedOut = true;
|
|
43
|
+
stop();
|
|
44
|
+
}, check.timeoutMs ?? 60000);
|
|
45
|
+
child.stdout.on('data', (data) => {
|
|
46
|
+
const text = decoder.write(data);
|
|
47
|
+
if (stdout.length + text.length > limit) truncated = true;
|
|
48
|
+
stdout += text.slice(0, Math.max(0, limit - stdout.length));
|
|
49
|
+
if (check.runner === 'node-test') {
|
|
50
|
+
eventBytes += data.length;
|
|
51
|
+
if (eventBytes > limit) {
|
|
52
|
+
malformed = true;
|
|
53
|
+
stop();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const lines = (pending + text).split('\n');
|
|
57
|
+
pending = lines.pop();
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
try {
|
|
60
|
+
const item = JSON.parse(line);
|
|
61
|
+
if (!['test:pass', 'test:fail'].includes(item.type) || typeof item.name !== 'string')
|
|
62
|
+
malformed = true;
|
|
63
|
+
else tests.push(item);
|
|
64
|
+
} catch {
|
|
65
|
+
if (line.trim()) malformed = true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
child.stderr.on('data', (data) => {
|
|
71
|
+
const text = data.toString();
|
|
72
|
+
if (stderr.length + text.length > limit) truncated = true;
|
|
73
|
+
stderr += text.slice(0, Math.max(0, limit - stderr.length));
|
|
74
|
+
});
|
|
75
|
+
function finish(exitCode, error) {
|
|
76
|
+
if (settled) return;
|
|
77
|
+
settled = true;
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
const missing = (check.testNames ?? []).filter(
|
|
80
|
+
(name) =>
|
|
81
|
+
!tests.some((t) => t.name === name && ['test:pass', 'test:fail'].includes(t.type)),
|
|
82
|
+
);
|
|
83
|
+
const skipped = (check.testNames ?? []).filter((name) =>
|
|
84
|
+
tests.some((t) => t.name === name && t.skip),
|
|
85
|
+
);
|
|
86
|
+
const todo = (check.testNames ?? []).filter((name) =>
|
|
87
|
+
tests.some((t) => t.name === name && t.todo),
|
|
88
|
+
);
|
|
89
|
+
const failed = tests.filter((t) => t.type === 'test:fail');
|
|
90
|
+
if (pending.trim()) malformed = true;
|
|
91
|
+
const passed =
|
|
92
|
+
exitCode === 0 &&
|
|
93
|
+
!error &&
|
|
94
|
+
!timedOut &&
|
|
95
|
+
!malformed &&
|
|
96
|
+
!missing.length &&
|
|
97
|
+
!skipped.length &&
|
|
98
|
+
!todo.length &&
|
|
99
|
+
!failed.length;
|
|
100
|
+
resolve({
|
|
101
|
+
id: check.id,
|
|
102
|
+
status: passed ? 'passing' : 'failing',
|
|
103
|
+
startedAt,
|
|
104
|
+
durationMs: Date.now() - started,
|
|
105
|
+
command,
|
|
106
|
+
exitCode,
|
|
107
|
+
timedOut,
|
|
108
|
+
error:
|
|
109
|
+
error?.message ?? (malformed ? 'Invalid or oversized node-test reporter output' : null),
|
|
110
|
+
missing,
|
|
111
|
+
skipped,
|
|
112
|
+
todo,
|
|
113
|
+
tests,
|
|
114
|
+
stdout,
|
|
115
|
+
stderr,
|
|
116
|
+
truncated,
|
|
117
|
+
rules: check.rules ?? [],
|
|
118
|
+
examples: check.examples ?? [],
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
child.on('error', (error) => finish(null, error));
|
|
122
|
+
child.on('close', (code) => finish(code));
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function verifyCapability(project, cap, onResult = () => {}) {
|
|
127
|
+
const results = [];
|
|
128
|
+
// Commands are deliberately sequential. Test fixtures can share resources.
|
|
129
|
+
for (const check of cap.checks) {
|
|
130
|
+
const result = await runCheck(check, project.root);
|
|
131
|
+
results.push(result);
|
|
132
|
+
onResult(result);
|
|
133
|
+
}
|
|
134
|
+
const current = (await loadProject(project.root)).capabilities.find((c) => c.id === cap.id);
|
|
135
|
+
const report = {
|
|
136
|
+
version: 1,
|
|
137
|
+
capability: cap.id,
|
|
138
|
+
digest: cap.digest,
|
|
139
|
+
finishedAt: new Date().toISOString(),
|
|
140
|
+
changedDuringRun: current?.digest !== cap.digest,
|
|
141
|
+
results,
|
|
142
|
+
};
|
|
143
|
+
await atomicWrite(
|
|
144
|
+
project.root,
|
|
145
|
+
`.mhproto/evidence/${cap.id}.json`,
|
|
146
|
+
JSON.stringify(report, null, 2) + '\n',
|
|
147
|
+
);
|
|
148
|
+
return report;
|
|
149
|
+
}
|
package/src/visuals.mjs
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
4
|
+
import { parse, stringify } from 'yaml';
|
|
5
|
+
import { atomicWrite, writePath } from './paths.mjs';
|
|
6
|
+
|
|
7
|
+
export const visualsFile = 'mhproto/visuals.yaml';
|
|
8
|
+
const queues = new Map();
|
|
9
|
+
export const mediaTypes = {
|
|
10
|
+
'image/png': 'png',
|
|
11
|
+
'image/jpeg': 'jpg',
|
|
12
|
+
'image/webp': 'webp',
|
|
13
|
+
'image/gif': 'gif',
|
|
14
|
+
'application/pdf': 'pdf',
|
|
15
|
+
};
|
|
16
|
+
export const maxMediaBytes = 8 * 1024 * 1024;
|
|
17
|
+
export function mediaMime(file) {
|
|
18
|
+
return Object.entries(mediaTypes).find(
|
|
19
|
+
([, ext]) =>
|
|
20
|
+
path.extname(file).slice(1).toLowerCase() === ext ||
|
|
21
|
+
(ext === 'jpg' && path.extname(file).toLowerCase() === '.jpeg'),
|
|
22
|
+
)?.[0];
|
|
23
|
+
}
|
|
24
|
+
export function safeDesignUrl(value) {
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(value);
|
|
27
|
+
return url.protocol === 'https:' && !url.username && !url.password;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function resolve(cap, schema) {
|
|
33
|
+
let current = schema,
|
|
34
|
+
seen = new Set();
|
|
35
|
+
while (current?.$ref) {
|
|
36
|
+
if (seen.has(current.$ref)) return {};
|
|
37
|
+
seen.add(current.$ref);
|
|
38
|
+
let value = cap.openapi;
|
|
39
|
+
for (const part of current.$ref.slice(2).split('/'))
|
|
40
|
+
value = value?.[part.replaceAll('~1', '/').replaceAll('~0', '~')];
|
|
41
|
+
current = value;
|
|
42
|
+
}
|
|
43
|
+
return current ?? {};
|
|
44
|
+
}
|
|
45
|
+
function propertyAt(cap, schema, parts) {
|
|
46
|
+
const s = resolve(cap, schema);
|
|
47
|
+
if (!parts.length) return s;
|
|
48
|
+
if (s.anyOf || s.oneOf || s.allOf)
|
|
49
|
+
return (s.anyOf ?? s.oneOf ?? s.allOf)
|
|
50
|
+
.map((item) => propertyAt(cap, item, parts))
|
|
51
|
+
.find((schema) => schema != null);
|
|
52
|
+
const [part, ...rest] = parts;
|
|
53
|
+
const child = part === '*' ? s.items : s.properties?.[part];
|
|
54
|
+
return child !== undefined ? propertyAt(cap, child, rest) : null;
|
|
55
|
+
}
|
|
56
|
+
export function targetError(project, target) {
|
|
57
|
+
if (!target || typeof target !== 'object') return 'Visual requires a target';
|
|
58
|
+
const cap = project.capabilities.find((c) => c.id === target.capability);
|
|
59
|
+
if (!cap) return 'Visual references an unknown feature';
|
|
60
|
+
const op = cap.operations.find((o) => o.operationId === target.id);
|
|
61
|
+
if (target.kind === 'feature') return null;
|
|
62
|
+
if (['operation', 'request', 'response', 'field'].includes(target.kind)) {
|
|
63
|
+
if (!op) return 'Visual references an unknown operation';
|
|
64
|
+
if (target.kind === 'response' && target.status && !op.responses?.[target.status])
|
|
65
|
+
return 'Visual references an unknown response';
|
|
66
|
+
if (target.kind === 'field') {
|
|
67
|
+
if (
|
|
68
|
+
!['request', 'response'].includes(target.scope) ||
|
|
69
|
+
typeof target.path !== 'string' ||
|
|
70
|
+
!target.path.startsWith('/')
|
|
71
|
+
)
|
|
72
|
+
return 'Field visual requires request/response scope and a JSON pointer';
|
|
73
|
+
let schema;
|
|
74
|
+
if (target.scope === 'response')
|
|
75
|
+
schema =
|
|
76
|
+
op.responses?.[target.status ?? Object.keys(op.responses).find((s) => /^2/.test(s))]
|
|
77
|
+
?.content?.['application/json']?.schema;
|
|
78
|
+
else
|
|
79
|
+
schema = {
|
|
80
|
+
type: 'object',
|
|
81
|
+
properties: {
|
|
82
|
+
body: op.requestBody?.content?.['application/json']?.schema,
|
|
83
|
+
...Object.fromEntries(
|
|
84
|
+
['path', 'query', 'header', 'cookie'].map((location) => [
|
|
85
|
+
location,
|
|
86
|
+
{
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: Object.fromEntries(
|
|
89
|
+
(op.parameters ?? [])
|
|
90
|
+
.filter((p) => p.in === location)
|
|
91
|
+
.map((p) => [p.name, p.schema]),
|
|
92
|
+
),
|
|
93
|
+
},
|
|
94
|
+
]),
|
|
95
|
+
),
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
if (
|
|
99
|
+
propertyAt(
|
|
100
|
+
cap,
|
|
101
|
+
schema,
|
|
102
|
+
target.path
|
|
103
|
+
.slice(1)
|
|
104
|
+
.split('/')
|
|
105
|
+
.map((p) => p.replaceAll('~1', '/').replaceAll('~0', '~')),
|
|
106
|
+
) == null
|
|
107
|
+
)
|
|
108
|
+
return 'Visual references an unknown field';
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const items = {
|
|
113
|
+
schema: Object.keys(cap.openapi.components?.schemas ?? {}),
|
|
114
|
+
example: cap.examples.map((e) => e.id),
|
|
115
|
+
rule: cap.rules.map((r) => r.id),
|
|
116
|
+
check: cap.checks.map((c) => c.id),
|
|
117
|
+
};
|
|
118
|
+
if (!items[target.kind]) return 'Unsupported visual target';
|
|
119
|
+
return items[target.kind].includes(target.id)
|
|
120
|
+
? null
|
|
121
|
+
: 'Visual references an unknown ' + target.kind;
|
|
122
|
+
}
|
|
123
|
+
export function validMedia(bytes, mime) {
|
|
124
|
+
if (mime === 'image/png')
|
|
125
|
+
return bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
|
|
126
|
+
if (mime === 'image/jpeg') return bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255;
|
|
127
|
+
if (mime === 'image/webp')
|
|
128
|
+
return bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP';
|
|
129
|
+
if (mime === 'image/gif') return /^GIF8[79]a$/.test(bytes.toString('ascii', 0, 6));
|
|
130
|
+
return mime === 'application/pdf' && bytes.toString('ascii', 0, 5) === '%PDF-';
|
|
131
|
+
}
|
|
132
|
+
export async function addVisual(root, project, input) {
|
|
133
|
+
const operation = async () => {
|
|
134
|
+
if (!input || typeof input !== 'object') throw new Error('Visual input must be an object');
|
|
135
|
+
if (Boolean(input.url) === Boolean(input.data))
|
|
136
|
+
throw new Error('Choose exactly one file or design link');
|
|
137
|
+
const error = targetError(project, input.target);
|
|
138
|
+
if (error) throw new Error(error);
|
|
139
|
+
if (typeof input.title !== 'string' || !input.title.trim() || input.title.length > 200)
|
|
140
|
+
throw new Error('Visual requires a title (up to 200 characters)');
|
|
141
|
+
const visual = {
|
|
142
|
+
id: randomUUID(),
|
|
143
|
+
title: input.title.trim(),
|
|
144
|
+
kind: input.url ? 'design' : 'screenshot',
|
|
145
|
+
target: input.target,
|
|
146
|
+
};
|
|
147
|
+
if (input.caption) {
|
|
148
|
+
if (typeof input.caption !== 'string' || input.caption.length > 1000)
|
|
149
|
+
throw new Error('Caption is too long');
|
|
150
|
+
visual.caption = input.caption;
|
|
151
|
+
}
|
|
152
|
+
let bytes;
|
|
153
|
+
if (input.url) {
|
|
154
|
+
if (!safeDesignUrl(input.url)) throw new Error('Design links must use HTTPS');
|
|
155
|
+
visual.url = input.url;
|
|
156
|
+
} else {
|
|
157
|
+
const match = /^data:([^;]+);base64,([A-Za-z0-9+/]*={0,2})$/.exec(input.data ?? '');
|
|
158
|
+
if (!match || !mediaTypes[match[1]]) throw new Error('Use a PNG, JPEG, WebP, GIF or PDF');
|
|
159
|
+
bytes = Buffer.from(match[2], 'base64');
|
|
160
|
+
if (!bytes.length || bytes.length > maxMediaBytes)
|
|
161
|
+
throw new Error('Visual files must be between 1 byte and 8 MB');
|
|
162
|
+
if (!validMedia(bytes, match[1]))
|
|
163
|
+
throw new Error('Visual content does not match its media type');
|
|
164
|
+
visual.mime = match[1];
|
|
165
|
+
if (visual.mime === 'application/pdf') visual.kind = 'design';
|
|
166
|
+
visual.file = `mhproto/assets/${visual.id}.${mediaTypes[visual.mime]}`;
|
|
167
|
+
visual.sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
168
|
+
}
|
|
169
|
+
const metadata = await writePath(root, visualsFile);
|
|
170
|
+
let entries = [];
|
|
171
|
+
try {
|
|
172
|
+
entries = parse(await readFile(metadata, 'utf8'))?.visuals;
|
|
173
|
+
if (!Array.isArray(entries)) throw new Error('visuals.yaml requires a visuals array');
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error.code !== 'ENOENT') throw error;
|
|
176
|
+
}
|
|
177
|
+
if (bytes) await writeFile(await writePath(root, visual.file), bytes, { flag: 'wx' });
|
|
178
|
+
await atomicWrite(root, visualsFile, stringify({ visuals: [...entries, visual] }));
|
|
179
|
+
return visual;
|
|
180
|
+
};
|
|
181
|
+
const previous = queues.get(root) ?? Promise.resolve();
|
|
182
|
+
const task = previous.catch(() => {}).then(operation);
|
|
183
|
+
queues.set(root, task);
|
|
184
|
+
try {
|
|
185
|
+
return await task;
|
|
186
|
+
} finally {
|
|
187
|
+
if (queues.get(root) === task) queues.delete(root);
|
|
188
|
+
}
|
|
189
|
+
}
|