artifact-chain-assistant 0.8.2 → 0.8.3

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 (80) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +13 -0
  4. package/INSTALL.md +10 -10
  5. package/README.md +4 -4
  6. package/README.zh-CN.md +4 -4
  7. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  8. package/adapters/claude/INSTALL.md +10 -10
  9. package/adapters/claude/agent-methods/catalog.yaml +1 -1
  10. package/adapters/claude/compatibility.json +4 -4
  11. package/adapters/claude/families-src/prd-feature/implementation.yaml +39 -0
  12. package/adapters/claude/families-src/scenario-script/implementation.yaml +39 -0
  13. package/adapters/claude/family-apis/catalog.json +16 -0
  14. package/adapters/claude/family-apis/e2e-test/api.json +205 -0
  15. package/adapters/claude/family-apis/schema/family-api.schema.json +235 -0
  16. package/adapters/claude/schemas/project-facts.schema.json +80 -0
  17. package/adapters/claude/scripts/check-family-api.mjs +84 -0
  18. package/adapters/claude/scripts/export-family-api.mjs +184 -0
  19. package/adapters/claude/scripts/family-compile.mjs +309 -0
  20. package/adapters/claude/scripts/family-help-render.mjs +214 -0
  21. package/adapters/claude/scripts/lib/family-api-validator.mjs +413 -0
  22. package/adapters/claude/scripts/lib/generated/canonical-json-v1-manifest.json +7 -0
  23. package/adapters/claude/scripts/lib/generated/canonical-json-v1-vectors.generated.json +50 -0
  24. package/adapters/claude/scripts/lib/generated/canonical-json-v1.generated.mjs +130 -0
  25. package/adapters/claude/scripts/lib/generated/family-api-validator.generated.mjs +1756 -0
  26. package/adapters/claude/scripts/lib/generated/project-facts-validator.generated.mjs +638 -0
  27. package/adapters/claude/scripts/lib/generated/schema-validator-manifest.json +20 -0
  28. package/adapters/claude/scripts/lib/method-query.mjs +177 -0
  29. package/adapters/claude/scripts/method-query.mjs +67 -0
  30. package/adapters/claude/skills/setup/SKILL.md +2 -1
  31. package/adapters/codex/.codex-plugin/plugin.json +1 -1
  32. package/adapters/codex/INSTALL.md +10 -10
  33. package/adapters/codex/agent-methods/catalog.yaml +1 -1
  34. package/adapters/codex/compatibility.json +4 -4
  35. package/adapters/codex/families-src/prd-feature/implementation.yaml +39 -0
  36. package/adapters/codex/families-src/scenario-script/implementation.yaml +39 -0
  37. package/adapters/codex/family-apis/catalog.json +16 -0
  38. package/adapters/codex/family-apis/e2e-test/api.json +205 -0
  39. package/adapters/codex/family-apis/schema/family-api.schema.json +235 -0
  40. package/adapters/codex/schemas/project-facts.schema.json +80 -0
  41. package/adapters/codex/scripts/check-family-api.mjs +84 -0
  42. package/adapters/codex/scripts/export-family-api.mjs +184 -0
  43. package/adapters/codex/scripts/family-compile.mjs +309 -0
  44. package/adapters/codex/scripts/family-help-render.mjs +214 -0
  45. package/adapters/codex/scripts/lib/family-api-validator.mjs +413 -0
  46. package/adapters/codex/scripts/lib/generated/canonical-json-v1-manifest.json +7 -0
  47. package/adapters/codex/scripts/lib/generated/canonical-json-v1-vectors.generated.json +50 -0
  48. package/adapters/codex/scripts/lib/generated/canonical-json-v1.generated.mjs +130 -0
  49. package/adapters/codex/scripts/lib/generated/family-api-validator.generated.mjs +1756 -0
  50. package/adapters/codex/scripts/lib/generated/project-facts-validator.generated.mjs +638 -0
  51. package/adapters/codex/scripts/lib/generated/schema-validator-manifest.json +20 -0
  52. package/adapters/codex/scripts/lib/method-query.mjs +177 -0
  53. package/adapters/codex/scripts/method-query.mjs +67 -0
  54. package/adapters/codex/skills/setup/SKILL.md +2 -1
  55. package/agent-methods/catalog.yaml +1 -1
  56. package/compatibility.json +4 -4
  57. package/families-src/prd-feature/implementation.yaml +39 -0
  58. package/families-src/scenario-script/implementation.yaml +39 -0
  59. package/family-apis/catalog.json +16 -0
  60. package/family-apis/e2e-test/api.json +205 -0
  61. package/family-apis/schema/family-api.schema.json +235 -0
  62. package/package.json +2 -2
  63. package/schemas/project-facts.schema.json +80 -0
  64. package/scripts/check-family-api.mjs +84 -0
  65. package/scripts/export-family-api.mjs +184 -0
  66. package/scripts/family-compile.mjs +309 -0
  67. package/scripts/family-help-render.mjs +214 -0
  68. package/scripts/generate-canonical-json-protocol.mjs +75 -0
  69. package/scripts/generate-schema-validators.mjs +141 -0
  70. package/scripts/lib/family-api-validator.mjs +413 -0
  71. package/scripts/lib/generated/canonical-json-v1-manifest.json +7 -0
  72. package/scripts/lib/generated/canonical-json-v1-vectors.generated.json +50 -0
  73. package/scripts/lib/generated/canonical-json-v1.generated.mjs +130 -0
  74. package/scripts/lib/generated/family-api-validator.generated.mjs +1756 -0
  75. package/scripts/lib/generated/project-facts-validator.generated.mjs +638 -0
  76. package/scripts/lib/generated/schema-validator-manifest.json +20 -0
  77. package/scripts/lib/method-query.mjs +177 -0
  78. package/scripts/method-query.mjs +67 -0
  79. package/skills/setup/SKILL.md +2 -1
  80. package/skills-src/setup/SKILL.md +2 -1
@@ -0,0 +1,413 @@
1
+ // @feature ACA18 @scenario S-65 @decision D-ACA-18
2
+ // Family API validator: deterministic, fail-closed validation of Skill Family API definitions.
3
+ // Executes the dependency-free validator compiled from the authoritative
4
+ // family-api.schema.json. The generated module is checked for drift at build/test.
5
+ import { createHash } from 'node:crypto';
6
+ import { readFile } from 'node:fs/promises';
7
+ import validateFamilyApiSchema, { schemaDigest as familyApiSchemaDigest } from './generated/family-api-validator.generated.mjs';
8
+ import { canonicalStringify } from './generated/canonical-json-v1.generated.mjs';
9
+
10
+ export { familyApiSchemaDigest, canonicalStringify };
11
+
12
+ /**
13
+ * Load and parse a JSON file with fail-closed semantics.
14
+ * @param {string} filePath
15
+ * @returns {Promise<object>}
16
+ */
17
+ export async function loadJson(filePath) {
18
+ const content = await readFile(filePath, 'utf8');
19
+ return JSON.parse(content);
20
+ }
21
+
22
+ /**
23
+ * Parse a JSON string safely.
24
+ * @param {string} text
25
+ * @returns {object}
26
+ */
27
+ export function parseJson(text) {
28
+ return JSON.parse(text);
29
+ }
30
+
31
+ /**
32
+ * Compute the immutable revision digest of a Family API object.
33
+ * Removes the revisionDigest field, serializes with sorted keys, and hashes SHA-256.
34
+ * @param {object} apiObject - Parsed API YAML object
35
+ * @returns {string} - "sha256:<hex>"
36
+ */
37
+ export function computeRevisionDigest(apiObject) {
38
+ const clone = structuredClone(apiObject);
39
+ if (clone.api && typeof clone.api === 'object') {
40
+ delete clone.api.revisionDigest;
41
+ }
42
+ const canonical = canonicalStringify(clone);
43
+ const hash = createHash('sha256').update(canonical).digest('hex');
44
+ return `sha256:${hash}`;
45
+ }
46
+
47
+ /**
48
+ * Deterministic JSON serialization with sorted keys at every level.
49
+ * Regular arrays preserve input order; set fields are sorted and deduplicated.
50
+ * @param {*} value
51
+ * @returns {string}
52
+ */
53
+ export function stableStringify(value) {
54
+ return canonicalStringify(value);
55
+ }
56
+
57
+ /**
58
+ * Compute canonical hash compatible with Registry's computeContentHash.
59
+ * @param {*} value
60
+ * @returns {string} "sha256:<hex>"
61
+ */
62
+ export function computeCanonicalHash(value) {
63
+ const canonical = canonicalStringify(value);
64
+ const hash = createHash('sha256').update(canonical).digest('hex');
65
+ return `sha256:${hash}`;
66
+ }
67
+
68
+ // Forbidden fields that must not appear in a Family API service definition
69
+ const FORBIDDEN_SERVICE_FIELDS = new Set([
70
+ 'stage', 'worker', 'prompt', 'template', 'provider', 'providers',
71
+ 'installation', 'enablement', 'trust', 'resolution',
72
+ 'locked', 'compatibility', 'selectionSource',
73
+ 'bindings', 'sources',
74
+ ]);
75
+
76
+ // Fields that must not appear at the top level of the API
77
+ const FORBIDDEN_API_FIELDS = new Set([
78
+ 'stage', 'worker', 'prompt', 'template', 'provider',
79
+ 'installation', 'enablement', 'trust', 'resolution',
80
+ 'locked', 'compatibility', 'selectionSource',
81
+ 'providers', 'bindings', 'sources',
82
+ ]);
83
+
84
+ // Canonical namespace pattern
85
+ const CANONICAL_NAMESPACE_RE = /^artifact\.[a-z][a-z0-9-]*-family$/;
86
+ const SERVICE_ID_RE = /^artifact\.[a-z][a-z0-9-]+\.[a-z][a-z0-9-]+$/;
87
+ const CONTRACT_REF_RE = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*@[0-9]+$/;
88
+
89
+ /**
90
+ * Validate a Family API definition against the authoritative JSON Schema and semantic constraints.
91
+ * @param {object} apiObj - Parsed API object
92
+ * @param {object} [opts] - Options
93
+ * @param {string} [opts.sourcePath] - Source file path for error messages
94
+ * @returns {Promise<{ ok: boolean, errors: Array<{code: string, path: string, message: string}>, warnings: string[] }>}
95
+ */
96
+ export async function validateFamilyApi(apiObj, opts = {}) {
97
+ const errors = [];
98
+ const warnings = [];
99
+ const prefix = opts.sourcePath ? `${opts.sourcePath}: ` : '';
100
+
101
+ // 1. Run authoritative JSON Schema validation (includes additionalProperties: false)
102
+ try {
103
+ const schemaValid = validateFamilyApiSchema(apiObj);
104
+ if (!schemaValid) {
105
+ for (const err of validateFamilyApiSchema.errors || []) {
106
+ errors.push({
107
+ code: 'SCHEMA_VALIDATION',
108
+ path: err.instancePath || '/',
109
+ message: `${prefix}${err.message}${err.params ? ` (${JSON.stringify(err.params)})` : ''}`,
110
+ });
111
+ }
112
+ }
113
+ } catch (err) {
114
+ errors.push({
115
+ code: 'SCHEMA_LOAD_ERROR',
116
+ path: '/schema',
117
+ message: `Failed to load or compile JSON Schema: ${err.message}`,
118
+ });
119
+ return { ok: false, errors, warnings };
120
+ }
121
+
122
+ // 2. Semantic validation beyond JSON Schema capabilities
123
+ if (!apiObj.api || typeof apiObj.api !== 'object') {
124
+ return { ok: false, errors, warnings };
125
+ }
126
+
127
+ const api = apiObj.api;
128
+
129
+ // 3. Canonical namespace
130
+ if (api.id && !CANONICAL_NAMESPACE_RE.test(api.id)) {
131
+ errors.push({
132
+ code: 'CANONICAL_NAMESPACE',
133
+ path: '/api/id',
134
+ message: `${prefix}api.id "${api.id}" does not match canonical namespace pattern artifact.<family>-family`,
135
+ });
136
+ }
137
+
138
+ // 4. Revision digest verification
139
+ if (api.revisionDigest && /^sha256:[a-f0-9]{64}$/.test(api.revisionDigest)) {
140
+ const computed = computeRevisionDigest(apiObj);
141
+ if (computed !== api.revisionDigest) {
142
+ errors.push({
143
+ code: 'DIGEST_MISMATCH',
144
+ path: '/api/revisionDigest',
145
+ message: `${prefix}api.revisionDigest mismatch: declared ${api.revisionDigest}, computed ${computed}`,
146
+ });
147
+ }
148
+ }
149
+
150
+ // 5. Forbidden fields (explicit semantic blocklist beyond schema)
151
+ for (const field of FORBIDDEN_API_FIELDS) {
152
+ if (field in apiObj) {
153
+ errors.push({
154
+ code: 'FORBIDDEN_FIELD',
155
+ path: `/${field}`,
156
+ message: `${prefix}forbidden top-level field "${field}" in Family API`,
157
+ });
158
+ }
159
+ if (field in api) {
160
+ errors.push({
161
+ code: 'FORBIDDEN_FIELD',
162
+ path: `/api/${field}`,
163
+ message: `${prefix}forbidden field "${field}" in api object`,
164
+ });
165
+ }
166
+ }
167
+
168
+ // 6. Services semantic validation
169
+ if (Array.isArray(apiObj.services)) {
170
+ const serviceIds = new Set();
171
+ for (let i = 0; i < apiObj.services.length; i++) {
172
+ const svc = apiObj.services[i];
173
+ const svcPath = `/services/${i}`;
174
+
175
+ // Service ID must belong to this API's family
176
+ if (api.id && svc.id) {
177
+ const familyPrefix = api.id.replace(/-family$/, '');
178
+ if (!svc.id.startsWith(familyPrefix + '.')) {
179
+ errors.push({
180
+ code: 'SERVICE_FAMILY_MISMATCH',
181
+ path: `${svcPath}/id`,
182
+ message: `${prefix}services[${i}] id "${svc.id}" does not belong to family "${api.id}"`,
183
+ });
184
+ }
185
+ }
186
+
187
+ // Uniqueness
188
+ if (serviceIds.has(svc.id)) {
189
+ errors.push({
190
+ code: 'DUPLICATE_SERVICE',
191
+ path: `${svcPath}/id`,
192
+ message: `${prefix}services[${i}] duplicate service id "${svc.id}"`,
193
+ });
194
+ }
195
+ serviceIds.add(svc.id);
196
+
197
+ // Forbidden service fields
198
+ for (const field of FORBIDDEN_SERVICE_FIELDS) {
199
+ if (field in svc) {
200
+ errors.push({
201
+ code: 'FORBIDDEN_SERVICE_FIELD',
202
+ path: `${svcPath}/${field}`,
203
+ message: `${prefix}services[${i}] forbidden field "${field}" in service definition`,
204
+ });
205
+ }
206
+ }
207
+
208
+ // accepts/produces references
209
+ validateRefs(svc.accepts, `${prefix}services[${i}]`, 'accepts', errors);
210
+ validateRefs(svc.produces, `${prefix}services[${i}]`, 'produces', errors);
211
+ }
212
+ }
213
+
214
+ // 7. At least one discoverable service (default or help)
215
+ if (Array.isArray(apiObj.services)) {
216
+ const hasDefault = apiObj.services.some(s => s.discoverable?.default);
217
+ const hasHelp = apiObj.services.some(s => s.discoverable?.help);
218
+ if (!hasDefault) {
219
+ warnings.push(`${prefix}no service declared as discoverable.default`);
220
+ }
221
+ if (!hasHelp) {
222
+ warnings.push(`${prefix}no service declared as discoverable.help`);
223
+ }
224
+ }
225
+
226
+ return { ok: errors.length === 0, errors, warnings };
227
+ }
228
+
229
+ /**
230
+ * Validate an array of contract/protocol references.
231
+ * @param {Array} refs
232
+ * @param {string} svcPrefix
233
+ * @param {string} field
234
+ * @param {Array} errors
235
+ */
236
+ function validateRefs(refs, svcPrefix, field, errors) {
237
+ if (refs === undefined) return;
238
+ if (!Array.isArray(refs)) {
239
+ errors.push({
240
+ code: 'INVALID_REF_TYPE',
241
+ path: `${svcPrefix}/${field}`,
242
+ message: `${svcPrefix} ${field} must be an array`,
243
+ });
244
+ return;
245
+ }
246
+ for (let j = 0; j < refs.length; j++) {
247
+ const ref = refs[j];
248
+ const refPath = `${svcPrefix}/${field}[${j}]`;
249
+ if (typeof ref === 'string') {
250
+ if (ref.includes('@') && !CONTRACT_REF_RE.test(ref)) {
251
+ errors.push({
252
+ code: 'INVALID_CONTRACT_REF',
253
+ path: refPath,
254
+ message: `${refPath} "${ref}" looks like a contract reference but does not match pattern <authority>.<type>@<major>`,
255
+ });
256
+ }
257
+ } else if (typeof ref === 'object' && ref !== null) {
258
+ if (!ref.protocol || typeof ref.protocol !== 'string') {
259
+ errors.push({
260
+ code: 'MISSING_PROTOCOL',
261
+ path: refPath,
262
+ message: `${refPath} protocol reference must have a string "protocol" field`,
263
+ });
264
+ }
265
+ if (!ref.version || typeof ref.version !== 'string') {
266
+ errors.push({
267
+ code: 'MISSING_VERSION',
268
+ path: refPath,
269
+ message: `${refPath} protocol reference must have a string "version" field`,
270
+ });
271
+ }
272
+ } else {
273
+ errors.push({
274
+ code: 'INVALID_REF_TYPE',
275
+ path: refPath,
276
+ message: `${refPath} must be a string or protocol reference object`,
277
+ });
278
+ }
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Validate that a catalog is consistent with its referenced API files.
284
+ * @param {object} catalog - Parsed catalog.yaml
285
+ * @param {Array<{id: string, major: number, content: object}>} apiFiles
286
+ * @returns {{ ok: boolean, errors: string[] }}
287
+ */
288
+ export function validateCatalogConsistency(catalog, apiFiles) {
289
+ const errors = [];
290
+
291
+ if (!catalog || !Array.isArray(catalog.families)) {
292
+ errors.push('catalog.families must be an array');
293
+ return { ok: false, errors };
294
+ }
295
+
296
+ for (const entry of catalog.families) {
297
+ if (!entry.id || !entry.major) {
298
+ errors.push(`catalog family entry missing id or major`);
299
+ continue;
300
+ }
301
+
302
+ const apiFile = apiFiles.find(f => f.id === entry.id && f.major === entry.major);
303
+ if (!apiFile) {
304
+ errors.push(`catalog references ${entry.id}@${entry.major} but no matching API file found`);
305
+ continue;
306
+ }
307
+
308
+ if (entry.revisionDigest !== apiFile.content.api.revisionDigest) {
309
+ errors.push(
310
+ `catalog revisionDigest for ${entry.id}@${entry.major} (${entry.revisionDigest}) ` +
311
+ `does not match API file (${apiFile.content.api.revisionDigest})`
312
+ );
313
+ }
314
+ }
315
+
316
+ for (const apiFile of apiFiles) {
317
+ const inCatalog = catalog.families.some(
318
+ f => f.id === apiFile.id && f.major === apiFile.major
319
+ );
320
+ if (!inCatalog) {
321
+ errors.push(`API file ${apiFile.id}@${apiFile.major} exists but is not in catalog`);
322
+ }
323
+ }
324
+
325
+ return { ok: errors.length === 0, errors };
326
+ }
327
+
328
+ /**
329
+ * Side-effect ceiling ranking for comparison.
330
+ * Siblings (same rank) are NOT interchangeable — swapping between them is breaking.
331
+ */
332
+ const CEILING_RANK = {
333
+ 'none': 0,
334
+ 'read-only': 1,
335
+ 'write-authorized-artifacts': 2,
336
+ 'write-review-result': 2,
337
+ 'write-project-artifacts': 3,
338
+ };
339
+
340
+ /**
341
+ * Check API compatibility: adding optional service is compatible,
342
+ * removing required, changing required/optional flag, changing accepts/produces,
343
+ * swapping sibling side-effects, or expanding side effect is breaking.
344
+ * @param {object} previousApi - Previous API object
345
+ * @param {object} currentApi - Current API object
346
+ * @returns {{ compatible: boolean, breaking: string[] }}
347
+ */
348
+ export function checkApiCompatibility(previousApi, currentApi) {
349
+ const breaking = [];
350
+ const prevServices = new Map((previousApi.services || []).map(s => [s.id, s]));
351
+ const currServices = new Map((currentApi.services || []).map(s => [s.id, s]));
352
+
353
+ // Check removed services
354
+ for (const [id, prev] of prevServices) {
355
+ if (!currServices.has(id)) {
356
+ if (prev.required) {
357
+ breaking.push(`required service "${id}" removed`);
358
+ }
359
+ // Removing optional service is compatible
360
+ }
361
+ }
362
+
363
+ // Check changed and new services
364
+ for (const [id, curr] of currServices) {
365
+ const prev = prevServices.get(id);
366
+ if (!prev) {
367
+ // New required service is breaking
368
+ if (curr.required) {
369
+ breaking.push(`new required service "${id}" added`);
370
+ }
371
+ // New optional service is compatible
372
+ continue;
373
+ }
374
+
375
+ // Check required flag changed
376
+ if (prev.required !== curr.required) {
377
+ breaking.push(`service "${id}" required flag changed from ${prev.required} to ${curr.required}`);
378
+ }
379
+
380
+ // Check accepts changed
381
+ const prevAccepts = stableStringify(prev.accepts || []);
382
+ const currAccepts = stableStringify(curr.accepts || []);
383
+ if (prevAccepts !== currAccepts) {
384
+ breaking.push(`service "${id}" accepts changed`);
385
+ }
386
+
387
+ // Check produces changed
388
+ const prevProduces = stableStringify(prev.produces || []);
389
+ const currProduces = stableStringify(curr.produces || []);
390
+ if (prevProduces !== currProduces) {
391
+ breaking.push(`service "${id}" produces changed`);
392
+ }
393
+
394
+ // Check side effect: expansion OR sibling swap is breaking
395
+ const prevCeiling = prev.sideEffectCeiling;
396
+ const currCeiling = curr.sideEffectCeiling;
397
+ if (prevCeiling !== currCeiling) {
398
+ const prevRank = CEILING_RANK[prevCeiling] ?? 0;
399
+ const currRank = CEILING_RANK[currCeiling] ?? 0;
400
+ // Expansion (higher rank) is breaking
401
+ if (currRank > prevRank) {
402
+ breaking.push(`service "${id}" sideEffectCeiling expanded from "${prevCeiling}" to "${currCeiling}"`);
403
+ }
404
+ // Sibling swap (same rank, different ceiling) is also breaking
405
+ // e.g., write-review-result ↔ write-authorized-artifacts
406
+ else if (currRank === prevRank) {
407
+ breaking.push(`service "${id}" sideEffectCeiling changed from "${prevCeiling}" to "${currCeiling}" (sibling swap)`);
408
+ }
409
+ }
410
+ }
411
+
412
+ return { compatible: breaking.length === 0, breaking };
413
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "protocol": "canonical-json-v1",
4
+ "authorityDigest": "sha256:5f7d38cc2646d9b149f384f439870b7d71b9e6d8b5abfc0f2fee6f9f3b395e25",
5
+ "outputDigest": "sha256:5937d28fa9862399b654637ca130a51d0bad24350943e4e48ee7cab4c4c51350",
6
+ "vectorsDigest": "sha256:7b6ee3d871269e72cea6b79daac07bcd3ef57aae65c3cccd548e64dfa1298eef"
7
+ }
@@ -0,0 +1,50 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "protocol": "canonical-json-v1",
4
+ "legal": [
5
+ {
6
+ "name": "object-unicode-null",
7
+ "input": { "b": 1, "a": 2, "unicode": "你好", "nullValue": null },
8
+ "canonical": "{\"a\":2,\"b\":1,\"nullValue\":null,\"unicode\":\"你好\"}",
9
+ "digest": "sha256:e9da8640b06cbb0970d8072eff5b57498381aed127343d9f552916ea4564bd9f"
10
+ },
11
+ {
12
+ "name": "ordinary-array-order",
13
+ "input": { "items": [3, 1, 2] },
14
+ "canonical": "{\"items\":[3,1,2]}",
15
+ "digest": "sha256:de21aef9c6d6b65cfe6dc3c9159d9969659d1f5b854c6249d871036c57a26966"
16
+ },
17
+ {
18
+ "name": "set-like-names-remain-ordinary",
19
+ "input": {
20
+ "contextTargets": ["c", "a", "b"],
21
+ "domains": ["web", "api"],
22
+ "intents": ["build", "deploy"],
23
+ "artifactTypes": ["feature", "bugfix"]
24
+ },
25
+ "canonical": "{\"artifactTypes\":[\"feature\",\"bugfix\"],\"contextTargets\":[\"c\",\"a\",\"b\"],\"domains\":[\"web\",\"api\"],\"intents\":[\"build\",\"deploy\"]}",
26
+ "digest": "sha256:6cb73e462de926a9249a0d3fd92565d83096e6701383c4f5f33f809a013c2047"
27
+ },
28
+ {
29
+ "name": "canonical-project-facts-targets",
30
+ "input": {
31
+ "artifactGraphSummary": {
32
+ "artifactCount": 2,
33
+ "edgeCount": 3,
34
+ "contextTargets": ["ACA12", "ACA18"]
35
+ }
36
+ },
37
+ "canonical": "{\"artifactGraphSummary\":{\"artifactCount\":2,\"contextTargets\":[\"ACA12\",\"ACA18\"],\"edgeCount\":3}}",
38
+ "digest": "sha256:9e608eb56d99ccf1dc70eb2dba46efc9538f30b3d5481061cce02112692e8055"
39
+ }
40
+ ],
41
+ "orderDifferences": [
42
+ { "name": "ordinary-array", "left": { "items": [1, 2, 3] }, "right": { "items": [3, 2, 1] } },
43
+ { "name": "contextTargets-name-is-not-global-set", "left": { "contextTargets": ["a", "b"] }, "right": { "contextTargets": ["b", "a"] } }
44
+ ],
45
+ "invalidKinds": [
46
+ "undefined", "nan", "positive-infinity", "negative-infinity", "bigint", "symbol", "function",
47
+ "date", "regexp", "map", "set", "promise", "error", "custom-prototype", "circular",
48
+ "sparse-array", "hidden-field", "symbol-key", "accessor", "proxy"
49
+ ]
50
+ }
@@ -0,0 +1,130 @@
1
+ // Generated by scripts/generate-canonical-json-protocol.mjs.
2
+ // authority digest: sha256:5f7d38cc2646d9b149f384f439870b7d71b9e6d8b5abfc0f2fee6f9f3b395e25
3
+ // Canonical JSON v1 — authoritative, dependency-free protocol implementation.
4
+ // Consumers may import this public subpath or ship an exact generated snapshot.
5
+ import { runInNewContext } from 'node:vm';
6
+ import { types as utilTypes } from 'node:util';
7
+
8
+ const primordials = runInNewContext(`({
9
+ getOwnPropertyDescriptor: Object.getOwnPropertyDescriptor,
10
+ getPrototypeOf: Object.getPrototypeOf,
11
+ ownKeys: Reflect.ownKeys,
12
+ isArray: Array.isArray,
13
+ isFinite: Number.isFinite,
14
+ isInteger: Number.isInteger
15
+ })`);
16
+ const objectPrototype = primordials.getPrototypeOf({});
17
+ const arrayPrototype = primordials.getPrototypeOf([]);
18
+ const bannedPrototypes = new Map([
19
+ [Date.prototype, 'Date'],
20
+ [RegExp.prototype, 'RegExp'],
21
+ [Map.prototype, 'Map'],
22
+ [Set.prototype, 'Set'],
23
+ [WeakMap.prototype, 'WeakMap'],
24
+ [WeakSet.prototype, 'WeakSet'],
25
+ [Promise.prototype, 'Promise'],
26
+ [Error.prototype, 'Error'],
27
+ ]);
28
+
29
+ function nextAncestors(ancestors, value) {
30
+ const next = new Array(ancestors.length + 1);
31
+ for (let index = 0; index < ancestors.length; index += 1) next[index] = ancestors[index];
32
+ next[ancestors.length] = value;
33
+ return next;
34
+ }
35
+
36
+ function isArrayIndex(key, length) {
37
+ if (key === '') return false;
38
+ const numeric = Number(key);
39
+ return primordials.isInteger(numeric) && numeric >= 0 && numeric < length && String(numeric) === key;
40
+ }
41
+
42
+ export function cloneStrictJsonData(value, ancestors = []) {
43
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
44
+ if (typeof value === 'number') {
45
+ if (!primordials.isFinite(value)) throw new TypeError(`Canonical JSON rejects ${String(value)}`);
46
+ return value;
47
+ }
48
+ if (typeof value !== 'object') {
49
+ const label = typeof value === 'bigint' ? 'BigInt'
50
+ : typeof value === 'symbol' ? 'Symbol'
51
+ : typeof value === 'function' ? 'function'
52
+ : 'undefined';
53
+ throw new TypeError(`Canonical JSON rejects ${label}`);
54
+ }
55
+
56
+ if (utilTypes.isProxy(value)) throw new TypeError('Proxy value rejected');
57
+ for (let index = 0; index < ancestors.length; index += 1) {
58
+ if (ancestors[index] === value) throw new TypeError('circular reference rejected');
59
+ }
60
+
61
+ const isArray = primordials.isArray(value);
62
+ const prototype = primordials.getPrototypeOf(value);
63
+ if (prototype !== (isArray ? arrayPrototype : objectPrototype) && !(prototype === null && !isArray)) {
64
+ const banned = bannedPrototypes.get(prototype);
65
+ if (banned) throw new TypeError(`Canonical JSON rejects ${banned}`);
66
+ throw new TypeError('custom prototype rejected');
67
+ }
68
+
69
+ const keys = primordials.ownKeys(value);
70
+ for (const key of keys) {
71
+ if (typeof key === 'symbol') throw new TypeError('symbol-keyed property rejected');
72
+ }
73
+
74
+ const next = nextAncestors(ancestors, value);
75
+ if (isArray) {
76
+ const lengthDescriptor = primordials.getOwnPropertyDescriptor(value, 'length');
77
+ if (!lengthDescriptor || !('value' in lengthDescriptor) || !primordials.isInteger(lengthDescriptor.value)) {
78
+ throw new TypeError('invalid array length rejected');
79
+ }
80
+ const length = lengthDescriptor.value;
81
+ const output = new Array(length);
82
+ for (let index = 0; index < length; index += 1) {
83
+ const descriptor = primordials.getOwnPropertyDescriptor(value, String(index));
84
+ if (!descriptor || !('value' in descriptor) || descriptor.enumerable !== true) {
85
+ throw new TypeError('sparse array or accessor element rejected');
86
+ }
87
+ output[index] = cloneStrictJsonData(descriptor.value, next);
88
+ }
89
+ for (const key of keys) {
90
+ if (key !== 'length' && !isArrayIndex(key, length)) {
91
+ throw new TypeError('extra array property rejected');
92
+ }
93
+ }
94
+ return output;
95
+ }
96
+
97
+ const output = Object.create(null);
98
+ for (const key of keys) {
99
+ const descriptor = primordials.getOwnPropertyDescriptor(value, key);
100
+ if (!descriptor) throw new TypeError(`missing property descriptor "${key}" rejected`);
101
+ if (!('value' in descriptor)) {
102
+ throw new TypeError(`accessor property "${key}" rejected`);
103
+ }
104
+ if (descriptor.enumerable !== true) {
105
+ throw new TypeError(`non-enumerable field "${key}" rejected`);
106
+ }
107
+ output[key] = cloneStrictJsonData(descriptor.value, next);
108
+ }
109
+ return output;
110
+ }
111
+
112
+ export function validateJsonSafe(value) {
113
+ cloneStrictJsonData(value);
114
+ }
115
+
116
+ function sortObjectKeys(value) {
117
+ if (value === null || typeof value !== 'object') return value;
118
+ if (Array.isArray(value)) return value.map(sortObjectKeys);
119
+ const output = Object.create(null);
120
+ for (const key of Object.keys(value).sort()) output[key] = sortObjectKeys(value[key]);
121
+ return output;
122
+ }
123
+
124
+ export function canonicalizeJson(value) {
125
+ return sortObjectKeys(cloneStrictJsonData(value));
126
+ }
127
+
128
+ export function canonicalStringify(value) {
129
+ return JSON.stringify(canonicalizeJson(value));
130
+ }