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.
Files changed (124) hide show
  1. package/CONTRIBUTING.md +62 -0
  2. package/LICENSE +21 -0
  3. package/README.md +53 -0
  4. package/THIRD_PARTY_NOTICES.md +40 -0
  5. package/bin/mhproto.mjs +248 -0
  6. package/doc/context-design.md +43 -0
  7. package/doc/context-measurements.json +28 -0
  8. package/doc/guide.md +138 -0
  9. package/doc/publishing.md +93 -0
  10. package/doc/release-notes.md +23 -0
  11. package/doc/release-review-2026-10-02.md +127 -0
  12. package/doc/release-review.md +68 -0
  13. package/doc/viewer-design.md +80 -0
  14. package/package.json +69 -0
  15. package/skills/mhproto-discover/SKILL.md +14 -0
  16. package/skills/mhproto-implement/SKILL.md +14 -0
  17. package/skills/mhproto-reconcile/SKILL.md +14 -0
  18. package/skills/mhproto-specify/SKILL.md +16 -0
  19. package/skills/mhproto-specify/references/format.md +20 -0
  20. package/skills/mhproto-verify/SKILL.md +14 -0
  21. package/src/config.mjs +202 -0
  22. package/src/context.mjs +228 -0
  23. package/src/core.mjs +471 -0
  24. package/src/node-reporter.mjs +15 -0
  25. package/src/paths.mjs +51 -0
  26. package/src/server.mjs +290 -0
  27. package/src/verify.mjs +149 -0
  28. package/src/visuals.mjs +189 -0
  29. package/viewer/app.js +1463 -0
  30. package/viewer/diff.js +245 -0
  31. package/viewer/index.html +51 -0
  32. package/viewer/style.css +1003 -0
  33. package/viewer/vendor/LICENSE +21 -0
  34. package/viewer/vendor/NOTICE.txt +3372 -0
  35. package/viewer/vendor/README.md +75 -0
  36. package/viewer/vendor/build-evidence.json +2775 -0
  37. package/viewer/vendor/bundled-audit.json +88 -0
  38. package/viewer/vendor/bundled-packages.json +84 -0
  39. package/viewer/vendor/embedded-notices.txt +33 -0
  40. package/viewer/vendor/license-inventory.json +1456 -0
  41. package/viewer/vendor/licenses/braintree__sanitize-url-7.1.2.txt +21 -0
  42. package/viewer/vendor/licenses/chevrotain-13.2.0.txt +202 -0
  43. package/viewer/vendor/licenses/chevrotain-allstar-0.5.0.txt +16 -0
  44. package/viewer/vendor/licenses/chevrotain__cst-dts-gen-13.2.0.txt +202 -0
  45. package/viewer/vendor/licenses/chevrotain__gast-13.2.0.txt +202 -0
  46. package/viewer/vendor/licenses/chevrotain__regexp-to-ast-13.2.0.txt +202 -0
  47. package/viewer/vendor/licenses/chevrotain__utils-13.2.0.txt +202 -0
  48. package/viewer/vendor/licenses/cose-base-1.0.3.txt +21 -0
  49. package/viewer/vendor/licenses/cose-base-2.2.0.txt +21 -0
  50. package/viewer/vendor/licenses/cytoscape-3.34.3.txt +19 -0
  51. package/viewer/vendor/licenses/cytoscape-cose-bilkent-4.1.0.txt +21 -0
  52. package/viewer/vendor/licenses/cytoscape-fcose-2.2.0.txt +19 -0
  53. package/viewer/vendor/licenses/d3-7.9.0.txt +13 -0
  54. package/viewer/vendor/licenses/d3-array-2.12.1.txt +27 -0
  55. package/viewer/vendor/licenses/d3-array-3.2.4.txt +13 -0
  56. package/viewer/vendor/licenses/d3-axis-3.0.0.txt +13 -0
  57. package/viewer/vendor/licenses/d3-brush-3.0.0.txt +13 -0
  58. package/viewer/vendor/licenses/d3-chord-3.0.1.txt +13 -0
  59. package/viewer/vendor/licenses/d3-color-3.1.0.txt +13 -0
  60. package/viewer/vendor/licenses/d3-contour-4.0.2.txt +13 -0
  61. package/viewer/vendor/licenses/d3-delaunay-6.0.4.txt +14 -0
  62. package/viewer/vendor/licenses/d3-dispatch-3.0.1.txt +13 -0
  63. package/viewer/vendor/licenses/d3-drag-3.0.0.txt +13 -0
  64. package/viewer/vendor/licenses/d3-dsv-3.0.1.txt +13 -0
  65. package/viewer/vendor/licenses/d3-ease-3.0.1.txt +28 -0
  66. package/viewer/vendor/licenses/d3-fetch-3.0.1.txt +13 -0
  67. package/viewer/vendor/licenses/d3-force-3.0.0.txt +13 -0
  68. package/viewer/vendor/licenses/d3-format-3.1.2.txt +13 -0
  69. package/viewer/vendor/licenses/d3-geo-3.1.1.txt +34 -0
  70. package/viewer/vendor/licenses/d3-hierarchy-3.1.2.txt +13 -0
  71. package/viewer/vendor/licenses/d3-interpolate-3.0.1.txt +13 -0
  72. package/viewer/vendor/licenses/d3-path-1.0.9.txt +27 -0
  73. package/viewer/vendor/licenses/d3-path-3.1.0.txt +13 -0
  74. package/viewer/vendor/licenses/d3-polygon-3.0.1.txt +13 -0
  75. package/viewer/vendor/licenses/d3-quadtree-3.0.1.txt +13 -0
  76. package/viewer/vendor/licenses/d3-random-3.0.1.txt +13 -0
  77. package/viewer/vendor/licenses/d3-sankey-0.12.3.txt +27 -0
  78. package/viewer/vendor/licenses/d3-scale-4.0.2.txt +13 -0
  79. package/viewer/vendor/licenses/d3-scale-chromatic-3.1.0.txt +28 -0
  80. package/viewer/vendor/licenses/d3-selection-3.0.0.txt +13 -0
  81. package/viewer/vendor/licenses/d3-shape-1.3.7.txt +27 -0
  82. package/viewer/vendor/licenses/d3-shape-3.2.0.txt +13 -0
  83. package/viewer/vendor/licenses/d3-time-3.1.0.txt +13 -0
  84. package/viewer/vendor/licenses/d3-time-format-4.1.0.txt +13 -0
  85. package/viewer/vendor/licenses/d3-timer-3.0.1.txt +13 -0
  86. package/viewer/vendor/licenses/d3-transition-3.0.1.txt +13 -0
  87. package/viewer/vendor/licenses/d3-zoom-3.0.0.txt +13 -0
  88. package/viewer/vendor/licenses/dagre-d3-es-7.0.14.txt +23 -0
  89. package/viewer/vendor/licenses/dayjs-1.11.23.txt +21 -0
  90. package/viewer/vendor/licenses/delaunator-5.1.0.txt +15 -0
  91. package/viewer/vendor/licenses/dompurify-3.4.16.txt +202 -0
  92. package/viewer/vendor/licenses/elk-source-notice.txt +11 -0
  93. package/viewer/vendor/licenses/elkjs-0.9.3.txt +264 -0
  94. package/viewer/vendor/licenses/embedded-and-node-notices.txt +45 -0
  95. package/viewer/vendor/licenses/es-toolkit-1.52.0-1.txt +39 -0
  96. package/viewer/vendor/licenses/es-toolkit-1.52.0.txt +21 -0
  97. package/viewer/vendor/licenses/fastdom-1.0.12.txt +221 -0
  98. package/viewer/vendor/licenses/iconify__utils-3.1.7.txt +21 -0
  99. package/viewer/vendor/licenses/internmap-1.0.1.txt +13 -0
  100. package/viewer/vendor/licenses/internmap-2.0.3.txt +13 -0
  101. package/viewer/vendor/licenses/js-yaml-4.3.2.txt +21 -0
  102. package/viewer/vendor/licenses/katex-0.16.47.txt +21 -0
  103. package/viewer/vendor/licenses/khroma-2.1.0.txt +21 -0
  104. package/viewer/vendor/licenses/langium-4.4.0.txt +16 -0
  105. package/viewer/vendor/licenses/layout-base-1.0.2.txt +21 -0
  106. package/viewer/vendor/licenses/layout-base-2.0.1.txt +21 -0
  107. package/viewer/vendor/licenses/lodash-es-4.18.1.txt +47 -0
  108. package/viewer/vendor/licenses/marked-16.4.2.txt +44 -0
  109. package/viewer/vendor/licenses/mermaid-12.1.0.txt +21 -0
  110. package/viewer/vendor/licenses/mermaid-js__parser-2.0.1.txt +21 -0
  111. package/viewer/vendor/licenses/path-browserify-1.0.1.txt +20 -0
  112. package/viewer/vendor/licenses/robust-predicates-3.0.3.txt +24 -0
  113. package/viewer/vendor/licenses/roughjs-4.6.6.txt +21 -0
  114. package/viewer/vendor/licenses/stylis-4.4.0.txt +21 -0
  115. package/viewer/vendor/licenses/ts-dedent-2.3.0.txt +21 -0
  116. package/viewer/vendor/licenses/upsetjs__venn.js-2.0.0.txt +22 -0
  117. package/viewer/vendor/licenses/uuid-14.0.2.txt +9 -0
  118. package/viewer/vendor/licenses/vscode-jsonrpc-9.0.3.txt +11 -0
  119. package/viewer/vendor/licenses/vscode-languageserver-protocol-3.18.4.txt +11 -0
  120. package/viewer/vendor/licenses/vscode-languageserver-textdocument-1.0.15.txt +11 -0
  121. package/viewer/vendor/licenses/vscode-languageserver-types-3.18.4.txt +11 -0
  122. package/viewer/vendor/licenses/vscode-uri-3.1.0.txt +9 -0
  123. package/viewer/vendor/manifest.json +21 -0
  124. package/viewer/vendor/mermaid.min.js +7729 -0
package/src/core.mjs ADDED
@@ -0,0 +1,471 @@
1
+ import { readFile, readdir, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { parse } from 'yaml';
5
+ import Ajv from 'ajv/dist/2020.js';
6
+ import addFormats from 'ajv-formats';
7
+ import { visualsFile, targetError, safeDesignUrl, mediaMime } from './visuals.mjs';
8
+ import { projectPath } from './paths.mjs';
9
+ import { assertShape } from './config.mjs';
10
+ export { projectPath } from './paths.mjs';
11
+
12
+ const methods = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace']);
13
+ export const packageRoot = path.resolve(import.meta.dirname, '..');
14
+
15
+ export async function readProject(root, relative) {
16
+ return readFile(await projectPath(root, relative), 'utf8');
17
+ }
18
+
19
+ export function extractRules(markdown) {
20
+ const rules = [];
21
+ let current, fence;
22
+ for (const line of markdown.split('\n')) {
23
+ const marker = /^\s{0,3}(`{3,}|~{3,})/.exec(line)?.[1];
24
+ if (marker) {
25
+ if (!fence) fence = marker;
26
+ else if (marker[0] === fence[0] && marker.length >= fence.length) fence = null;
27
+ current = undefined;
28
+ continue;
29
+ }
30
+ if (fence) continue;
31
+ const match = /^\s*-\s+\*\*([A-Z][A-Z0-9-]*-\d+)\*\*\s*(.*)$/.exec(line);
32
+ if (match) {
33
+ current = { id: match[1], text: match[2] };
34
+ rules.push(current);
35
+ } else if (current && /^\s{2,}\S/.test(line)) {
36
+ current.text += '\n' + line.trim();
37
+ } else current = undefined;
38
+ }
39
+ return rules;
40
+ }
41
+
42
+ async function fingerprint(root, inputs) {
43
+ const hash = createHash('sha256');
44
+ const files = new Set();
45
+ const directories = new Set();
46
+ async function visit(relative) {
47
+ const absolute = await projectPath(root, relative);
48
+ if ((await stat(absolute)).isDirectory()) {
49
+ if (directories.has(absolute)) return;
50
+ directories.add(absolute);
51
+ for (const name of (await readdir(absolute)).sort()) {
52
+ if (
53
+ ['node_modules', '.git', 'dist', '.mhproto', '.env'].includes(name) ||
54
+ name.startsWith('.env.')
55
+ )
56
+ continue;
57
+ await visit(path.join(relative, name));
58
+ }
59
+ } else files.add(relative);
60
+ }
61
+ for (const input of inputs) await visit(input);
62
+ for (const file of [...files].sort())
63
+ hash
64
+ .update(file + '\0')
65
+ .update(await readFile(await projectPath(root, file)))
66
+ .update('\0');
67
+ return hash.digest('hex');
68
+ }
69
+
70
+ export function operationList(doc) {
71
+ return Object.entries(doc.paths ?? {}).flatMap(([url, rawItem]) => {
72
+ const item = resolveObject(doc, rawItem);
73
+ return Object.entries(item)
74
+ .filter(([method]) => methods.has(method))
75
+ .map(([method, op]) => {
76
+ const parameters = new Map();
77
+ for (const list of [item.parameters ?? [], op.parameters ?? []]) {
78
+ const seen = new Set();
79
+ for (const raw of list) {
80
+ const param = resolveObject(doc, raw),
81
+ key = JSON.stringify([param.in, param.name]);
82
+ if (seen.has(key))
83
+ throw new Error(
84
+ `Duplicate parameter ${param.name} at ${method.toUpperCase()} ${url}`,
85
+ );
86
+ seen.add(key);
87
+ parameters.set(key, param);
88
+ }
89
+ }
90
+ return {
91
+ ...op,
92
+ method: method.toUpperCase(),
93
+ path: url,
94
+ parameters: [...parameters.values()],
95
+ requestBody: op.requestBody ? resolveObject(doc, op.requestBody) : undefined,
96
+ responses: Object.fromEntries(
97
+ Object.entries(op.responses ?? {}).map(([status, response]) => [
98
+ status,
99
+ resolveObject(doc, response),
100
+ ]),
101
+ ),
102
+ rules: op['x-mhproto-rules'] ?? op['x-clauses'] ?? [],
103
+ };
104
+ });
105
+ });
106
+ }
107
+
108
+ export function localRef(doc, ref) {
109
+ if (typeof ref !== 'string' || !ref.startsWith('#/'))
110
+ throw new Error(`External reference unsupported in V0: ${ref}`);
111
+ let node = doc;
112
+ for (const part of decodeURIComponent(ref.slice(2)).split('/')) {
113
+ const key = part.replaceAll('~1', '/').replaceAll('~0', '~');
114
+ node = node && Object.hasOwn(node, key) ? node[key] : undefined;
115
+ }
116
+ if (node === undefined) throw new Error(`Unresolved reference: ${ref}`);
117
+ return node;
118
+ }
119
+
120
+ export function resolveObject(doc, object, seen = new Set()) {
121
+ if (!object?.$ref) return object;
122
+ if (seen.has(object.$ref)) throw new Error(`Cyclic OpenAPI object reference: ${object.$ref}`);
123
+ const next = new Set(seen).add(object.$ref);
124
+ const resolved = resolveObject(doc, localRef(doc, object.$ref), next);
125
+ if (!resolved || typeof resolved !== 'object' || Array.isArray(resolved))
126
+ throw new Error(`OpenAPI object reference must resolve to an object: ${object.$ref}`);
127
+ return {
128
+ ...resolved,
129
+ ...Object.fromEntries(
130
+ ['summary', 'description'].filter((k) => Object.hasOwn(object, k)).map((k) => [k, object[k]]),
131
+ ),
132
+ };
133
+ }
134
+
135
+ export function schemaValidator(doc) {
136
+ const ajv = new Ajv({ strict: false, allErrors: true, validateFormats: true });
137
+ addFormats(ajv);
138
+ const compiled = new Map();
139
+ return (schema, data) => {
140
+ if (!compiled.has(schema)) {
141
+ const wrapper =
142
+ typeof schema === 'boolean' ? schema : { ...schema, components: doc.components ?? {} };
143
+ compiled.set(schema, ajv.compile(wrapper));
144
+ }
145
+ const validate = compiled.get(schema);
146
+ return validate(data)
147
+ ? []
148
+ : validate.errors.map((e) => `${e.instancePath || '/'} ${e.message}`);
149
+ };
150
+ }
151
+
152
+ async function loadCapability(root, definition, system) {
153
+ const prose = await readProject(root, definition.spec);
154
+ const doc = assertShape(
155
+ 'openapi',
156
+ parse(await readProject(root, definition.interface)),
157
+ definition.interface,
158
+ );
159
+ const examples = assertShape(
160
+ 'examples',
161
+ parse(await readProject(root, definition.examples)),
162
+ definition.examples,
163
+ ).examples;
164
+ const checks = assertShape(
165
+ 'checks',
166
+ parse(await readProject(root, definition.checks)),
167
+ definition.checks,
168
+ ).checks;
169
+ const digest = await fingerprint(root, [
170
+ 'mhproto.yaml',
171
+ ...(system ? [system] : []),
172
+ definition.spec,
173
+ definition.interface,
174
+ definition.examples,
175
+ definition.checks,
176
+ ...(definition.sources ?? []),
177
+ ...checks.flatMap((c) => c.files ?? []),
178
+ ]);
179
+ let evidence;
180
+ try {
181
+ const file = `.mhproto/evidence/${definition.id}.json`;
182
+ evidence = assertShape('evidence', JSON.parse(await readProject(root, file)), file);
183
+ if (evidence.capability !== definition.id)
184
+ throw new Error(`${file}: evidence belongs to another capability`);
185
+ } catch (error) {
186
+ if (error.code !== 'ENOENT') throw error;
187
+ }
188
+ return {
189
+ ...definition,
190
+ prose,
191
+ rules: extractRules(prose),
192
+ openapi: doc,
193
+ operations: operationList(doc),
194
+ files: {
195
+ spec: definition.spec,
196
+ interface: definition.interface,
197
+ examples: definition.examples,
198
+ checks: definition.checks,
199
+ },
200
+ transitions: doc['x-phase-transitions'] ?? [],
201
+ nonTransitions: doc['x-phase-unchanged-by'] ?? [],
202
+ examples,
203
+ checks,
204
+ digest,
205
+ evidence: evidence ? { ...evidence, stale: evidence.digest !== digest } : null,
206
+ };
207
+ }
208
+
209
+ export async function loadProject(root = process.cwd()) {
210
+ root = path.resolve(root);
211
+ const config = assertShape(
212
+ 'config',
213
+ parse(await readProject(root, 'mhproto.yaml')),
214
+ 'mhproto.yaml',
215
+ );
216
+ const ids = new Set();
217
+ for (const cap of config.capabilities) {
218
+ if (!/^[a-z][a-z0-9-]*$/.test(cap.id) || ids.has(cap.id))
219
+ throw new Error(`Invalid or duplicate capability id: ${cap.id}`);
220
+ ids.add(cap.id);
221
+ }
222
+ let visuals = [];
223
+ try {
224
+ visuals = parse(await readProject(root, visualsFile))?.visuals;
225
+ if (!Array.isArray(visuals)) throw new Error('visuals.yaml requires a visuals array');
226
+ } catch (error) {
227
+ if (error.code !== 'ENOENT') throw error;
228
+ }
229
+ return {
230
+ root,
231
+ name: config.name ?? path.basename(root),
232
+ visuals,
233
+ system: config.system ? await readProject(root, config.system) : '',
234
+ capabilities: await Promise.all(
235
+ config.capabilities.map((c) => loadCapability(root, c, config.system)),
236
+ ),
237
+ };
238
+ }
239
+
240
+ export async function validateProject(project) {
241
+ const issues = [];
242
+ const issue = (cap, level, message) => issues.push({ capability: cap.id, level, message });
243
+ const visualIds = new Set();
244
+ for (const visual of project.visuals ?? []) {
245
+ if (!visual || typeof visual !== 'object') {
246
+ issue({ id: 'visuals' }, 'error', 'Visual entry must be an object');
247
+ continue;
248
+ }
249
+ const cap = { id: visual.target?.capability ?? 'visuals' };
250
+ if (
251
+ typeof visual.id !== 'string' ||
252
+ !/^[A-Za-z0-9._-]+$/.test(visual.id) ||
253
+ visualIds.has(visual.id)
254
+ )
255
+ issue(cap, 'error', 'Invalid or duplicate visual id');
256
+ visualIds.add(visual.id);
257
+ const problem = targetError(project, visual.target);
258
+ if (problem) issue(cap, 'error', problem);
259
+ if (typeof visual.title !== 'string' || !visual.title.trim() || visual.title.length > 200)
260
+ issue(cap, 'error', 'Visual requires a title (up to 200 characters)');
261
+ if (Boolean(visual.file) === Boolean(visual.url))
262
+ issue(cap, 'error', 'Visual requires exactly one file or design URL');
263
+ if (visual.url && !safeDesignUrl(visual.url)) issue(cap, 'error', 'Design link must use HTTPS');
264
+ if (visual.file) {
265
+ try {
266
+ await projectPath(project.root, visual.file);
267
+ const mime = mediaMime(visual.file);
268
+ if (!mime) issue(cap, 'error', 'Unsupported visual file');
269
+ else if (visual.mime && visual.mime !== mime)
270
+ issue(cap, 'error', 'Visual MIME disagrees with file extension');
271
+ } catch (error) {
272
+ issue(cap, 'error', error.message);
273
+ }
274
+ }
275
+ }
276
+ for (const cap of project.capabilities) {
277
+ const rules = new Set(),
278
+ operations = new Set(),
279
+ examples = new Set(),
280
+ checkIds = new Set();
281
+ for (const rule of cap.rules) {
282
+ if (rules.has(rule.id)) issue(cap, 'error', `Duplicate rule ${rule.id}`);
283
+ rules.add(rule.id);
284
+ }
285
+ if (!rules.size) issue(cap, 'error', 'No rules found: use - **CAP-RULE-1** text');
286
+ if (
287
+ cap.openapi?.openapi !== '3.1.0' &&
288
+ cap.openapi?.openapi !== '3.1.1' &&
289
+ cap.openapi?.openapi !== '3.1.2'
290
+ )
291
+ issue(cap, 'error', 'V0 supports OpenAPI 3.1 JSON Schema contracts');
292
+ if (!cap.openapi?.info?.title || !cap.openapi?.info?.version)
293
+ issue(cap, 'error', 'OpenAPI info.title and info.version are required');
294
+ let validates;
295
+ try {
296
+ validates = schemaValidator(cap.openapi);
297
+ } catch (error) {
298
+ issue(cap, 'error', error.message);
299
+ }
300
+ const cite = (id, context) => {
301
+ if (!rules.has(id)) issue(cap, 'error', `${context} references missing rule ${id}`);
302
+ };
303
+ const walk = (node) => {
304
+ if (!node || typeof node !== 'object') return;
305
+ if (typeof node.$ref === 'string') {
306
+ try {
307
+ localRef(cap.openapi, node.$ref);
308
+ } catch (e) {
309
+ issue(cap, 'error', e.message);
310
+ }
311
+ }
312
+ for (const value of Object.values(node)) walk(value);
313
+ };
314
+ walk(cap.openapi);
315
+ const validateData = (schema, data, context) => {
316
+ if (schema === undefined || !validates) return;
317
+ try {
318
+ for (const error of validates(schema, data)) issue(cap, 'error', `${context}: ${error}`);
319
+ } catch (error) {
320
+ issue(cap, 'error', `${context}: ${error.message}`);
321
+ }
322
+ };
323
+ const validateMedia = (media, context) => {
324
+ if (media.schema !== undefined) {
325
+ try {
326
+ validates?.(media.schema, undefined);
327
+ } catch (error) {
328
+ issue(cap, 'error', `${context} schema: ${error.message}`);
329
+ }
330
+ }
331
+ if ('example' in media) validateData(media.schema, media.example, context);
332
+ for (const [name, raw] of Object.entries(media.examples ?? {})) {
333
+ try {
334
+ const example = resolveObject(cap.openapi, raw);
335
+ if (Object.hasOwn(example, 'value'))
336
+ validateData(media.schema, example.value, `${context} example ${name}`);
337
+ } catch (error) {
338
+ issue(cap, 'error', `${context} example ${name}: ${error.message}`);
339
+ }
340
+ }
341
+ };
342
+ for (const [name, schema] of Object.entries(cap.openapi.components?.schemas ?? {})) {
343
+ try {
344
+ validates?.(schema, undefined);
345
+ } catch (e) {
346
+ issue(cap, 'error', `Schema ${name}: ${e.message}`);
347
+ }
348
+ }
349
+ for (const op of cap.operations) {
350
+ if (!op.operationId || operations.has(op.operationId))
351
+ issue(cap, 'error', `Missing or duplicate operationId at ${op.method} ${op.path}`);
352
+ operations.add(op.operationId);
353
+ if (!op.responses || !Object.keys(op.responses).length)
354
+ issue(cap, 'error', `${op.operationId} has no responses`);
355
+ for (const id of op.rules) cite(id, op.operationId);
356
+ if (!op.rules.length) issue(cap, 'warning', `${op.operationId} has no behaviour links`);
357
+ for (const param of op.path.matchAll(/\{([^}]+)\}/g)) {
358
+ if (
359
+ !op.parameters.some((p) => p.in === 'path' && p.name === param[1] && p.required === true)
360
+ )
361
+ issue(cap, 'error', `${op.operationId} is missing required path parameter ${param[1]}`);
362
+ }
363
+ for (const condition of op['x-preconditions'] ?? [])
364
+ if (condition.fails?.clause) cite(condition.fails.clause, op.operationId);
365
+ for (const param of op.parameters)
366
+ validateMedia(param, `${op.operationId} ${param.in} parameter ${param.name}`);
367
+ for (const [status, response] of Object.entries(op.responses ?? {})) {
368
+ const item = resolveObject(cap.openapi, response);
369
+ for (const e of item?.['x-error-codes'] ?? [])
370
+ if (e.clause) cite(e.clause, `${op.operationId}/${status}`);
371
+ for (const media of Object.values(item?.content ?? {}))
372
+ validateMedia(media, `${op.operationId} response ${status}`);
373
+ }
374
+ for (const media of Object.values(op.requestBody?.content ?? {}))
375
+ validateMedia(media, `${op.operationId} request`);
376
+ }
377
+ for (const transition of [...cap.transitions, ...cap.nonTransitions])
378
+ if (transition.clause) cite(transition.clause, 'Transition');
379
+ for (const id of cap.presentation?.operationOrder ?? [])
380
+ if (!operations.has(id))
381
+ issue(cap, 'error', `Presentation references missing operation ${id}`);
382
+ for (const [id, presentation] of Object.entries(cap.presentation?.operations ?? {})) {
383
+ if (!operations.has(id))
384
+ issue(cap, 'error', `Presentation references missing operation ${id}`);
385
+ for (const rule of [
386
+ ...(presentation.rules ?? []),
387
+ ...(presentation.ruleGroups ?? []).flatMap((g) => g.rules ?? []),
388
+ ])
389
+ cite(rule, `Presentation ${id}`);
390
+ for (const diagram of presentation.diagrams ?? [])
391
+ if (typeof diagram.title !== 'string' || typeof diagram.source !== 'string')
392
+ issue(cap, 'error', `Presentation ${id}: diagrams require title and source strings`);
393
+ }
394
+ for (const example of cap.examples) {
395
+ if (!example.id || examples.has(example.id))
396
+ issue(cap, 'error', `Missing or duplicate example id ${example.id}`);
397
+ examples.add(example.id);
398
+ if (!example.given || !example.when || !example.then)
399
+ issue(cap, 'error', `${example.id} requires given, when and then`);
400
+ for (const id of example.rules ?? []) cite(id, example.id);
401
+ for (const id of example.operations ?? [])
402
+ if (!operations.has(id))
403
+ issue(cap, 'error', `${example.id} references missing operation ${id}`);
404
+ if (example.request) {
405
+ const op = cap.operations.find((o) => o.operationId === example.request.operation);
406
+ if (!op) issue(cap, 'error', `${example.id} request references missing operation`);
407
+ else {
408
+ const schema = resolveObject(cap.openapi, op.requestBody)?.content?.['application/json']
409
+ ?.schema;
410
+ if (schema !== undefined)
411
+ validateData(schema, example.request.body, example.id + ' request');
412
+ if (example.response) {
413
+ const code = String(example.response.status);
414
+ const response =
415
+ op.responses?.[code] ?? op.responses?.[code[0] + 'XX'] ?? op.responses?.default;
416
+ if (!response) issue(cap, 'error', `${example.id} has undeclared response status`);
417
+ else
418
+ validateData(
419
+ response.content?.['application/json']?.schema,
420
+ example.response.body,
421
+ example.id + ' response',
422
+ );
423
+ }
424
+ }
425
+ }
426
+ }
427
+ for (const check of cap.checks) {
428
+ if (!check.id || checkIds.has(check.id))
429
+ issue(cap, 'error', `Missing or duplicate check id ${check.id}`);
430
+ checkIds.add(check.id);
431
+ if (
432
+ !Array.isArray(check.command) ||
433
+ !check.command.length ||
434
+ check.command.some((v) => typeof v !== 'string')
435
+ )
436
+ issue(cap, 'error', `${check.id}: command must be an argv array`);
437
+ else if (!check.command[0])
438
+ issue(cap, 'error', `${check.id}: command executable cannot be empty`);
439
+ if (check.runner && !['command', 'node-test'].includes(check.runner))
440
+ issue(cap, 'error', `${check.id}: unsupported runner`);
441
+ if (
442
+ check.runner === 'node-test' &&
443
+ (!Array.isArray(check.testNames) || !check.testNames.length)
444
+ )
445
+ issue(cap, 'error', `${check.id}: node-test requires testNames (exact test names)`);
446
+ for (const id of check.rules ?? []) cite(id, check.id);
447
+ for (const id of check.examples ?? [])
448
+ if (!examples.has(id)) issue(cap, 'error', `${check.id} references missing example ${id}`);
449
+ for (const file of check.files ?? []) {
450
+ try {
451
+ await projectPath(project.root, file);
452
+ } catch (e) {
453
+ issue(cap, 'error', `${check.id}: ${e.message}`);
454
+ }
455
+ }
456
+ }
457
+ for (const rule of cap.rules)
458
+ if (!cap.checks.some((c) => c.rules?.includes(rule.id)))
459
+ issue(cap, 'warning', `${rule.id} has no linked executable check`);
460
+ for (const example of cap.examples)
461
+ if (!cap.checks.some((c) => c.examples?.includes(example.id)))
462
+ issue(cap, 'warning', `${example.id} has no linked executable check`);
463
+ if (cap.evidence?.stale)
464
+ issue(
465
+ cap,
466
+ 'warning',
467
+ 'Verification evidence is stale; spec or tracked implementation changed',
468
+ );
469
+ }
470
+ return issues;
471
+ }
@@ -0,0 +1,15 @@
1
+ // Node's reporter receives structured events, so skipped/missing tests cannot count as passes.
2
+ export default async function* reporter(events) {
3
+ for await (const event of events) {
4
+ if (['test:pass', 'test:fail'].includes(event.type)) {
5
+ const { name, skip, todo, details } = event.data;
6
+ yield JSON.stringify({
7
+ type: event.type,
8
+ name,
9
+ skip: Boolean(skip),
10
+ todo: Boolean(todo),
11
+ message: details?.error?.message ?? null,
12
+ }) + '\n';
13
+ }
14
+ }
15
+ }
package/src/paths.mjs ADDED
@@ -0,0 +1,51 @@
1
+ import { mkdir, realpath, rename, writeFile, rm } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ const inside = (base, candidate) => candidate === base || candidate.startsWith(base + path.sep);
6
+
7
+ export async function projectPath(root, relative) {
8
+ if (typeof relative !== 'string' || path.isAbsolute(relative))
9
+ throw new Error(`Expected project-relative path: ${relative}`);
10
+ const base = await realpath(root);
11
+ const actual = await realpath(path.resolve(base, relative));
12
+ if (!inside(base, actual)) throw new Error(`Path escapes project: ${relative}`);
13
+ return actual;
14
+ }
15
+
16
+ export async function writePath(root, relative) {
17
+ if (typeof relative !== 'string' || path.isAbsolute(relative))
18
+ throw new Error(`Expected project-relative path: ${relative}`);
19
+ const base = await realpath(root),
20
+ candidate = path.resolve(base, relative);
21
+ if (candidate === base || !inside(base, candidate))
22
+ throw new Error(`Path escapes project: ${relative}`);
23
+ let ancestor = candidate;
24
+ while (true) {
25
+ try {
26
+ if (!inside(base, await realpath(ancestor)))
27
+ throw new Error(`Path escapes project through symlink: ${relative}`);
28
+ break;
29
+ } catch (error) {
30
+ if (error.code !== 'ENOENT') throw error;
31
+ ancestor = path.dirname(ancestor);
32
+ }
33
+ }
34
+ await mkdir(path.dirname(candidate), { recursive: true });
35
+ return candidate;
36
+ }
37
+
38
+ export async function atomicWrite(root, relative, contents) {
39
+ const destination = await writePath(root, relative);
40
+ const temporary = path.join(
41
+ path.dirname(destination),
42
+ '.' + path.basename(destination) + '-' + randomUUID() + '.tmp',
43
+ );
44
+ try {
45
+ await writeFile(temporary, contents, { flag: 'wx' });
46
+ await rename(temporary, destination);
47
+ } finally {
48
+ await rm(temporary, { force: true });
49
+ }
50
+ return destination;
51
+ }