@open-agent-toolkit/cli 0.2.25 → 0.2.27

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 (87) hide show
  1. package/assets/NOTICES.md +156 -0
  2. package/assets/docs/cli-utilities/configuration.md +42 -11
  3. package/assets/docs/contributing/explainer-kit-verification.md +125 -0
  4. package/assets/docs/contributing/index.md +1 -0
  5. package/assets/docs/reference/troubleshooting.md +47 -0
  6. package/assets/docs/workflows/projects/artifacts.md +24 -6
  7. package/assets/docs/workflows/projects/dispatch-ceiling.md +67 -16
  8. package/assets/docs/workflows/skills/explainer-kit-providers.md +144 -0
  9. package/assets/docs/workflows/skills/explainer-kit.md +121 -69
  10. package/assets/docs/workflows/skills/index.md +1 -0
  11. package/assets/public-package-versions.json +4 -4
  12. package/assets/skills/explainer-kit/SKILL.md +18 -3
  13. package/assets/skills/explainer-kit/recipes/project-recap.json +43 -16
  14. package/assets/skills/explainer-kit/references/contracts.md +167 -20
  15. package/assets/skills/explainer-kit/references/golden-conformance.md +80 -0
  16. package/assets/skills/explainer-kit/references/visual-authoring.md +92 -0
  17. package/assets/skills/explainer-kit/references/visual-review.md +57 -0
  18. package/assets/skills/explainer-kit/schemas/author-request.v2.schema.json +172 -1
  19. package/assets/skills/explainer-kit/schemas/build-record.schema.json +7 -1
  20. package/assets/skills/explainer-kit/schemas/fact-base.schema.json +38 -2
  21. package/assets/skills/explainer-kit/schemas/manifest.schema.json +25 -1
  22. package/assets/skills/explainer-kit/schemas/run-request.schema.json +4 -0
  23. package/assets/skills/explainer-kit/schemas/set-plan.v1.schema.json +149 -0
  24. package/assets/skills/explainer-kit/schemas/visual-review-request.v1.schema.json +117 -0
  25. package/assets/skills/explainer-kit/schemas/visual-review-result.v1.schema.json +80 -0
  26. package/assets/skills/explainer-kit/scripts/lib/browser-runtime.mjs +148 -4
  27. package/assets/skills/explainer-kit/scripts/lib/catalog.mjs +243 -0
  28. package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +586 -8
  29. package/assets/skills/explainer-kit/scripts/lib/diagram.mjs +285 -8
  30. package/assets/skills/explainer-kit/scripts/lib/durability.mjs +35 -0
  31. package/assets/skills/explainer-kit/scripts/lib/fact-base.mjs +144 -8
  32. package/assets/skills/explainer-kit/scripts/lib/package-coverage.mjs +379 -0
  33. package/assets/skills/explainer-kit/scripts/lib/png.mjs +287 -0
  34. package/assets/skills/explainer-kit/scripts/lib/qa.mjs +280 -8
  35. package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +132 -3
  36. package/assets/skills/explainer-kit/scripts/lib/records.mjs +513 -21
  37. package/assets/skills/explainer-kit/scripts/lib/render.mjs +67 -3
  38. package/assets/skills/explainer-kit/scripts/lib/s3-static.mjs +43 -1
  39. package/assets/skills/explainer-kit/scripts/lib/set-plan.mjs +208 -0
  40. package/assets/skills/explainer-kit/scripts/lib/source-backlinks.mjs +218 -0
  41. package/assets/skills/explainer-kit/scripts/lib/visual-review.mjs +380 -0
  42. package/assets/skills/explainer-kit/scripts/render-qa.mjs +48 -10
  43. package/assets/skills/explainer-kit/scripts/run.mjs +859 -134
  44. package/assets/skills/oat-explainer-kit/SKILL.md +40 -12
  45. package/assets/skills/oat-explainer-kit/references/author-callback.md +12 -10
  46. package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +40 -2
  47. package/assets/skills/oat-explainer-kit/references/visual-review-callback.md +72 -0
  48. package/assets/skills/oat-explainer-kit/scripts/bind-project-sources.mjs +167 -5
  49. package/assets/skills/oat-explainer-kit/scripts/finalize-tracked-run.mjs +92 -5
  50. package/assets/skills/oat-explainer-kit/scripts/run.mjs +324 -2
  51. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +1 -1
  52. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +1 -1
  53. package/assets/skills/oat-project-implement/SKILL.md +9 -11
  54. package/assets/skills/oat-project-implement/references/dispatch-and-dry-run.md +18 -9
  55. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +1 -1
  56. package/assets/skills/oat-project-implement/references/phase-execution.md +13 -4
  57. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +1 -1
  58. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +1 -1
  59. package/dist/commands/config/index.d.ts.map +1 -1
  60. package/dist/commands/config/index.js +27 -3
  61. package/dist/commands/project/archive/archive-utils.d.ts +1 -0
  62. package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
  63. package/dist/commands/project/archive/archive-utils.js +109 -42
  64. package/dist/commands/project/archive/explainer-package-coverage.d.ts +14 -0
  65. package/dist/commands/project/archive/explainer-package-coverage.d.ts.map +1 -0
  66. package/dist/commands/project/archive/explainer-package-coverage.js +27 -0
  67. package/dist/commands/project/archive/explainer-source-backlinks.d.ts +18 -0
  68. package/dist/commands/project/archive/explainer-source-backlinks.d.ts.map +1 -0
  69. package/dist/commands/project/archive/explainer-source-backlinks.js +27 -0
  70. package/dist/commands/project/archive/push-runner.d.ts +2 -1
  71. package/dist/commands/project/archive/push-runner.d.ts.map +1 -1
  72. package/dist/commands/project/archive/push-runner.js +5 -1
  73. package/dist/commands/project/dispatch-ceiling/index.d.ts.map +1 -1
  74. package/dist/commands/project/dispatch-ceiling/index.js +90 -0
  75. package/dist/config/dispatch-notices.d.ts +8 -0
  76. package/dist/config/dispatch-notices.d.ts.map +1 -0
  77. package/dist/config/dispatch-notices.js +79 -0
  78. package/dist/config/dispatch-policy-options.d.ts +2 -0
  79. package/dist/config/dispatch-policy-options.d.ts.map +1 -1
  80. package/dist/config/dispatch-policy-options.js +14 -2
  81. package/dist/providers/identity/dispatch-report.d.ts +17 -0
  82. package/dist/providers/identity/dispatch-report.d.ts.map +1 -1
  83. package/dist/providers/identity/dispatch-report.js +30 -0
  84. package/dist/release/public-package-contract.d.ts +6 -0
  85. package/dist/release/public-package-contract.d.ts.map +1 -1
  86. package/dist/release/public-package-contract.js +75 -0
  87. package/package.json +2 -2
@@ -0,0 +1,379 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { browserCaptureIdentity } from './browser-runtime.mjs';
4
+ import { canonicalHash, validateContract } from './contracts.mjs';
5
+
6
+ export const PACKAGE_COVERAGE_VERSION = 'explainer-kit.package-coverage/v2';
7
+ export const SET_PLAN_RECORD_PATHS = Object.freeze([
8
+ 'source/set-plan/request.json',
9
+ 'source/set-plan/result.json',
10
+ 'source/set-plan/ledger.json',
11
+ 'source/set-plan/portfolio.json',
12
+ 'source/set-plan/drafts.json',
13
+ ]);
14
+ export const VISUAL_REVISION_PATH = 'qa/visual-review/revision.json';
15
+
16
+ const REVIEW_VIEWPORTS = Object.freeze(['mobile', 'tablet', 'desktop']);
17
+ const SUCCESSFUL_OUTCOMES = new Set(['built-not-durable', 'built-durable']);
18
+ const PARTIAL_REVIEW_OUTCOME = 'built-needs-review';
19
+
20
+ export function requiredImmutablePackagePaths(manifest, { runMode } = {}) {
21
+ if (!isObject(manifest)) {
22
+ throw new TypeError(
23
+ 'Manifest package coverage requires a manifest object.',
24
+ );
25
+ }
26
+ if (
27
+ runMode !== undefined &&
28
+ runMode !== 'interactive' &&
29
+ runMode !== 'unattended'
30
+ ) {
31
+ throw new TypeError(
32
+ 'Manifest package coverage run mode must be interactive or unattended.',
33
+ );
34
+ }
35
+
36
+ const required = new Set([
37
+ 'run-request.json',
38
+ 'source/content-approval.json',
39
+ manifest.source?.factBasePath,
40
+ 'source/fact-base.md',
41
+ ...(manifest.source?.authorResultPaths ?? []),
42
+ manifest.theme?.path,
43
+ ...(manifest.artifacts ?? []).flatMap((artifact) => [
44
+ artifact.contentPath,
45
+ ...(artifact.status === 'built' &&
46
+ typeof artifact.renderedPath === 'string'
47
+ ? [artifact.renderedPath]
48
+ : []),
49
+ ]),
50
+ ]);
51
+ required.delete(undefined);
52
+
53
+ if (manifest.recipe?.id !== 'project-recap') return [...required];
54
+
55
+ const successful = SUCCESSFUL_OUTCOMES.has(manifest.outcome);
56
+ if (successful) {
57
+ for (const path of SET_PLAN_RECORD_PATHS) required.add(path);
58
+ }
59
+
60
+ const recorded = Object.keys(manifest.immutableHashes ?? {});
61
+ if (manifest.outcome === PARTIAL_REVIEW_OUTCOME) return [...required];
62
+
63
+ const retainsReviewMaterial = recorded.some(isReviewMaterial);
64
+ const requireAttemptOne =
65
+ (successful && runMode === 'unattended') ||
66
+ (successful && runMode === 'interactive' && retainsReviewMaterial) ||
67
+ (!successful && retainsReviewMaterial);
68
+ const retainsAttemptTwo = recorded.some(isAttemptTwoMaterial);
69
+
70
+ if (requireAttemptOne || retainsAttemptTwo) {
71
+ addVisualReviewAttemptPaths(required, manifest, 1);
72
+ }
73
+ if (retainsAttemptTwo) {
74
+ required.add(VISUAL_REVISION_PATH);
75
+ addVisualReviewAttemptPaths(required, manifest, 2);
76
+ }
77
+ return [...required];
78
+ }
79
+
80
+ export async function validateImmutablePackageEvidence(
81
+ manifest,
82
+ { runMode, read } = {},
83
+ ) {
84
+ const requiredPaths = requiredImmutablePackagePaths(manifest, { runMode });
85
+ const missing = requiredPaths.filter(
86
+ (path) => !(path in (manifest?.immutableHashes ?? {})),
87
+ );
88
+ if (missing.length > 0) {
89
+ throw new Error(
90
+ `Manifest immutable hashes do not cover the canonical package: ${missing.join(', ')}.`,
91
+ );
92
+ }
93
+ if (manifest?.recipe?.id !== 'project-recap') return;
94
+
95
+ const recorded = Object.keys(manifest.immutableHashes ?? {});
96
+ const successful = SUCCESSFUL_OUTCOMES.has(manifest.outcome);
97
+ const retainsReviewMaterial = recorded.some(isReviewMaterial);
98
+ if (manifest.outcome === PARTIAL_REVIEW_OUTCOME) return;
99
+ const requireAttemptOne =
100
+ (successful && runMode === 'unattended') ||
101
+ (successful && runMode === 'interactive' && retainsReviewMaterial) ||
102
+ (!successful &&
103
+ manifest.outcome !== PARTIAL_REVIEW_OUTCOME &&
104
+ retainsReviewMaterial);
105
+ const retainsAttemptTwo = recorded.some(isAttemptTwoMaterial);
106
+ if (!requireAttemptOne && !retainsAttemptTwo) return;
107
+ if (typeof read !== 'function') {
108
+ throw new TypeError(
109
+ 'Canonical browser evidence validation requires an immutable package reader.',
110
+ );
111
+ }
112
+
113
+ const readVerified = async (path) => {
114
+ const value = await read(path);
115
+ const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value);
116
+ const expected = manifest.immutableHashes[path];
117
+ const actual = `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
118
+ if (actual !== expected) {
119
+ throw new Error(`Immutable package hash mismatch for ${path}.`);
120
+ }
121
+ return bytes;
122
+ };
123
+ const builtArtifacts = (manifest.artifacts ?? []).filter(
124
+ ({ status }) => status === 'built',
125
+ );
126
+ const browserRecords = new Map();
127
+ let expectedRuntimeHash;
128
+ let expectedCaptureIdentity;
129
+ for (const artifact of builtArtifacts) {
130
+ for (const viewport of REVIEW_VIEWPORTS) {
131
+ const metricsPath = `qa/browser/${artifact.id}/${viewport}.json`;
132
+ const bytes = await readVerified(metricsPath);
133
+ const screenshotPath = `qa/browser/${artifact.id}/${viewport}.png`;
134
+ const screenshotBytes = await readVerified(screenshotPath);
135
+ if (!isPng(screenshotBytes)) {
136
+ throw new Error(
137
+ `Browser screenshot evidence ${screenshotPath} is not a PNG.`,
138
+ );
139
+ }
140
+ const record = parseJson(bytes, metricsPath);
141
+ validateBrowserEvidenceRecord(record, {
142
+ artifactId: artifact.id,
143
+ viewport,
144
+ metricsPath,
145
+ requireLaunched: successful && runMode === 'unattended',
146
+ });
147
+ const runtimeHash = canonicalHash(record.runtime);
148
+ expectedRuntimeHash ??= runtimeHash;
149
+ expectedCaptureIdentity ??= record.captureIdentity;
150
+ if (
151
+ runtimeHash !== expectedRuntimeHash ||
152
+ record.captureIdentity !== expectedCaptureIdentity
153
+ ) {
154
+ throw new Error(
155
+ `Browser runtime or capture identity mismatch in ${metricsPath}.`,
156
+ );
157
+ }
158
+ browserRecords.set(metricsPath, { bytes, record, screenshotBytes });
159
+ }
160
+ }
161
+
162
+ const attempts = retainsAttemptTwo ? [1, 2] : [1];
163
+ let terminalResult;
164
+ for (const attempt of attempts) {
165
+ const root = `qa/visual-review/attempt-${attempt}`;
166
+ const requestPath = `${root}/request.json`;
167
+ const request = parseJson(await readVerified(requestPath), requestPath);
168
+ const requestValidation = validateContract(
169
+ 'visual-review-request',
170
+ request,
171
+ );
172
+ if (!requestValidation.valid) {
173
+ throw new Error(
174
+ `Immutable visual-review request ${requestPath} is invalid: ${requestValidation.errors
175
+ .map(({ code }) => code)
176
+ .join(', ')}.`,
177
+ );
178
+ }
179
+ if (
180
+ canonicalHash(request.browserRuntime) !== expectedRuntimeHash ||
181
+ request.captureIdentity !== expectedCaptureIdentity
182
+ ) {
183
+ throw new Error(
184
+ `Visual-review browser runtime or capture identity mismatch in ${requestPath}.`,
185
+ );
186
+ }
187
+ const requestEvidence = new Map(
188
+ request.renderedArtifacts.flatMap((artifact) =>
189
+ artifact.evidence.map((evidence) => [
190
+ `${artifact.artifactId}:${evidence.viewport}`,
191
+ evidence,
192
+ ]),
193
+ ),
194
+ );
195
+ for (const artifact of builtArtifacts) {
196
+ for (const viewport of REVIEW_VIEWPORTS) {
197
+ const key = `${artifact.id}:${viewport}`;
198
+ const evidence = requestEvidence.get(key);
199
+ const sourceMetricsPath = `qa/browser/${artifact.id}/${viewport}.json`;
200
+ const sourceScreenshotPath = `qa/browser/${artifact.id}/${viewport}.png`;
201
+ if (
202
+ !evidence ||
203
+ evidence.metricsPath !== sourceMetricsPath ||
204
+ evidence.screenshotPath !== sourceScreenshotPath ||
205
+ evidence.captureIdentity !== expectedCaptureIdentity
206
+ ) {
207
+ throw new Error(
208
+ `Visual-review evidence binding mismatch for ${key} in ${requestPath}.`,
209
+ );
210
+ }
211
+ const copiedMetricsPath = `${root}/evidence/${artifact.id}/${viewport}.json`;
212
+ const copiedMetrics = await readVerified(copiedMetricsPath);
213
+ const copiedScreenshotPath = `${root}/evidence/${artifact.id}/${viewport}.png`;
214
+ const copiedScreenshot = await readVerified(copiedScreenshotPath);
215
+ if (!isPng(copiedScreenshot)) {
216
+ throw new Error(
217
+ `Retained visual-review screenshot ${copiedScreenshotPath} is not a PNG.`,
218
+ );
219
+ }
220
+ const copiedRecord = parseJson(copiedMetrics, copiedMetricsPath);
221
+ validateBrowserEvidenceRecord(copiedRecord, {
222
+ artifactId: artifact.id,
223
+ viewport,
224
+ metricsPath: copiedMetricsPath,
225
+ requireLaunched: successful && runMode === 'unattended',
226
+ });
227
+ if (
228
+ canonicalHash(copiedRecord.runtime) !== expectedRuntimeHash ||
229
+ copiedRecord.captureIdentity !== expectedCaptureIdentity
230
+ ) {
231
+ throw new Error(
232
+ `Retained visual-review browser runtime or capture identity mismatch in ${copiedMetricsPath}.`,
233
+ );
234
+ }
235
+ if (
236
+ evidence.metricsHash !==
237
+ `sha256:${createHash('sha256').update(copiedMetrics).digest('hex')}`
238
+ ) {
239
+ throw new Error(
240
+ `Visual-review metrics hash mismatch for ${key} in ${requestPath}.`,
241
+ );
242
+ }
243
+ if (
244
+ evidence.screenshotHash !==
245
+ `sha256:${createHash('sha256')
246
+ .update(copiedScreenshot)
247
+ .digest('hex')}`
248
+ ) {
249
+ throw new Error(
250
+ `Visual-review screenshot hash mismatch for ${key} in ${requestPath}.`,
251
+ );
252
+ }
253
+ if (attempt === attempts.at(-1)) {
254
+ const sourceMetrics = browserRecords.get(sourceMetricsPath);
255
+ if (
256
+ !copiedMetrics.equals(sourceMetrics.bytes) ||
257
+ !copiedScreenshot.equals(sourceMetrics.screenshotBytes)
258
+ ) {
259
+ throw new Error(
260
+ `Terminal visual-review evidence differs from ${sourceMetricsPath}.`,
261
+ );
262
+ }
263
+ }
264
+ }
265
+ }
266
+ if (
267
+ requestEvidence.size !==
268
+ builtArtifacts.length * REVIEW_VIEWPORTS.length
269
+ ) {
270
+ throw new Error(
271
+ `Visual-review evidence set in ${requestPath} does not match the complete browser record set.`,
272
+ );
273
+ }
274
+
275
+ const resultPath = `${root}/result.json`;
276
+ const result = parseJson(await readVerified(resultPath), resultPath);
277
+ const resultValidation = validateContract('visual-review-result', result, {
278
+ visualReviewRequest: request,
279
+ });
280
+ if (!resultValidation.valid) {
281
+ throw new Error(
282
+ `Immutable visual-review result ${resultPath} is invalid: ${resultValidation.errors
283
+ .map(({ code }) => code)
284
+ .join(', ')}.`,
285
+ );
286
+ }
287
+ terminalResult = result;
288
+ }
289
+ if (successful && terminalResult?.disposition !== 'pass') {
290
+ throw new Error(
291
+ 'Successful recap package requires a terminal passing visual review.',
292
+ );
293
+ }
294
+ }
295
+
296
+ function addVisualReviewAttemptPaths(required, manifest, attempt) {
297
+ const root = `qa/visual-review/attempt-${attempt}`;
298
+ required.add(`${root}/request.json`);
299
+ required.add(`${root}/result.json`);
300
+ for (const artifact of manifest.artifacts ?? []) {
301
+ if (artifact.status !== 'built') continue;
302
+ for (const viewport of REVIEW_VIEWPORTS) {
303
+ required.add(`qa/browser/${artifact.id}/${viewport}.png`);
304
+ required.add(`qa/browser/${artifact.id}/${viewport}.json`);
305
+ required.add(`${root}/evidence/${artifact.id}/${viewport}.png`);
306
+ required.add(`${root}/evidence/${artifact.id}/${viewport}.json`);
307
+ }
308
+ }
309
+ }
310
+
311
+ function isReviewMaterial(path) {
312
+ return (
313
+ path.startsWith('qa/browser/') ||
314
+ path.startsWith('qa/visual-review/') ||
315
+ path.startsWith('qa/review-gate/')
316
+ );
317
+ }
318
+
319
+ function isAttemptTwoMaterial(path) {
320
+ return (
321
+ path === VISUAL_REVISION_PATH ||
322
+ path.startsWith('qa/visual-review/attempt-2/') ||
323
+ path.startsWith('qa/review-gate/attempt-2')
324
+ );
325
+ }
326
+
327
+ function isObject(value) {
328
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
329
+ }
330
+
331
+ function isPng(bytes) {
332
+ return (
333
+ bytes.length >= 8 &&
334
+ bytes
335
+ .subarray(0, 8)
336
+ .equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
337
+ );
338
+ }
339
+
340
+ function validateBrowserEvidenceRecord(
341
+ record,
342
+ { artifactId, viewport, metricsPath, requireLaunched },
343
+ ) {
344
+ if (
345
+ !isObject(record) ||
346
+ record.schemaVersion !== 'explainer-kit.browser-evidence/v2' ||
347
+ record.artifactId !== artifactId ||
348
+ record.viewport !== viewport ||
349
+ record.scenario !== 'default' ||
350
+ !isObject(record.runtime) ||
351
+ !['launched', 'fixture'].includes(record.runtime.kind) ||
352
+ record.runtime.name !== 'chromium' ||
353
+ typeof record.runtime.version !== 'string' ||
354
+ record.runtime.version.length === 0 ||
355
+ !isObject(record.capture) ||
356
+ !/^sha256:[a-f0-9]{64}$/.test(record.captureIdentity ?? '') ||
357
+ record.captureIdentity !==
358
+ browserCaptureIdentity(record.runtime, record.capture)
359
+ ) {
360
+ throw new Error(
361
+ `Browser evidence ${metricsPath} does not satisfy explainer-kit.browser-evidence/v2.`,
362
+ );
363
+ }
364
+ if (requireLaunched && record.runtime.kind !== 'launched') {
365
+ throw new Error(
366
+ `Production browser evidence ${metricsPath} must identify launched Chromium, not a fixture session.`,
367
+ );
368
+ }
369
+ }
370
+
371
+ function parseJson(bytes, path) {
372
+ try {
373
+ return JSON.parse(bytes.toString('utf8'));
374
+ } catch {
375
+ throw new Error(
376
+ `Immutable package record ${path} must contain valid JSON.`,
377
+ );
378
+ }
379
+ }
@@ -0,0 +1,287 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { crc32, inflateSync } from 'node:zlib';
3
+
4
+ const PNG_SIGNATURE = Buffer.from('89504e470d0a1a0a', 'hex');
5
+ const SUPPORTED_COLOR_TYPES = new Map([
6
+ [2, 3],
7
+ [6, 4],
8
+ ]);
9
+ const KNOWN_CRITICAL_CHUNKS = new Set(['IHDR', 'IDAT', 'IEND']);
10
+
11
+ export const MAX_PNG_INPUT_BYTES = 20 * 1024 * 1024;
12
+ export const MAX_PNG_DECODED_BYTES = 64 * 1024 * 1024;
13
+ export const MAX_PNG_DIMENSION = 16_384;
14
+
15
+ export function decodeBrowserPng(bytes) {
16
+ if (!Buffer.isBuffer(bytes)) {
17
+ throw pngError('PNG input must be a Buffer.');
18
+ }
19
+ if (
20
+ bytes.length < PNG_SIGNATURE.length + 12 ||
21
+ bytes.length > MAX_PNG_INPUT_BYTES
22
+ ) {
23
+ throw pngError(
24
+ `PNG input must be between ${PNG_SIGNATURE.length + 12} and ${MAX_PNG_INPUT_BYTES} bytes.`,
25
+ );
26
+ }
27
+ if (!bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
28
+ throw pngError('PNG signature is invalid.');
29
+ }
30
+
31
+ let offset = PNG_SIGNATURE.length;
32
+ let ihdr = null;
33
+ let sawIdat = false;
34
+ let idatEnded = false;
35
+ let sawIend = false;
36
+ const idatParts = [];
37
+ let idatLength = 0;
38
+
39
+ while (offset < bytes.length) {
40
+ if (bytes.length - offset < 12) {
41
+ throw pngError('PNG chunk boundary is truncated.');
42
+ }
43
+ const length = bytes.readUInt32BE(offset);
44
+ const dataStart = offset + 8;
45
+ const dataEnd = dataStart + length;
46
+ const chunkEnd = dataEnd + 4;
47
+ if (chunkEnd > bytes.length) {
48
+ throw pngError('PNG chunk length exceeds the input boundary.');
49
+ }
50
+
51
+ const typeBytes = bytes.subarray(offset + 4, offset + 8);
52
+ if (
53
+ typeBytes.some(
54
+ (byte) => !((byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122)),
55
+ ) ||
56
+ (typeBytes[2] & 0x20) !== 0
57
+ ) {
58
+ throw pngError('PNG chunk type is invalid.');
59
+ }
60
+ const type = typeBytes.toString('ascii');
61
+ const expectedCrc = bytes.readUInt32BE(dataEnd);
62
+ const actualCrc = crc32(bytes.subarray(offset + 4, dataEnd));
63
+ if (actualCrc !== expectedCrc) {
64
+ throw pngError(`PNG ${type} chunk CRC is invalid.`);
65
+ }
66
+ if (!KNOWN_CRITICAL_CHUNKS.has(type) && (typeBytes[0] & 0x20) === 0) {
67
+ throw pngError(`PNG critical chunk ${type} is unsupported.`);
68
+ }
69
+
70
+ const data = bytes.subarray(dataStart, dataEnd);
71
+ if (ihdr === null && type !== 'IHDR') {
72
+ throw pngError('PNG IHDR must be the first chunk.');
73
+ }
74
+ if (type === 'IHDR') {
75
+ if (ihdr !== null || offset !== PNG_SIGNATURE.length || length !== 13) {
76
+ throw pngError('PNG must contain exactly one 13-byte IHDR first.');
77
+ }
78
+ ihdr = parseIhdr(data);
79
+ } else if (type === 'IDAT') {
80
+ if (sawIend || idatEnded) {
81
+ throw pngError('PNG IDAT chunks must be contiguous.');
82
+ }
83
+ sawIdat = true;
84
+ idatLength = boundedAdd(idatLength, length, MAX_PNG_INPUT_BYTES);
85
+ idatParts.push(data);
86
+ } else if (type === 'IEND') {
87
+ if (!sawIdat || sawIend || length !== 0) {
88
+ throw pngError('PNG must contain one empty IEND after IDAT.');
89
+ }
90
+ sawIend = true;
91
+ if (chunkEnd !== bytes.length) {
92
+ throw pngError('PNG contains trailing bytes after IEND.');
93
+ }
94
+ } else if (sawIdat) {
95
+ idatEnded = true;
96
+ }
97
+
98
+ offset = chunkEnd;
99
+ }
100
+
101
+ if (ihdr === null || !sawIdat || idatLength === 0 || !sawIend) {
102
+ throw pngError(
103
+ 'PNG requires IHDR, non-empty contiguous IDAT, and IEND chunks.',
104
+ );
105
+ }
106
+
107
+ const { width, height, bitDepth, channels, colorType } = ihdr;
108
+ const rowBytes = boundedMultiply(width, channels, MAX_PNG_DECODED_BYTES);
109
+ const scanlineBytes = boundedMultiply(
110
+ height,
111
+ boundedAdd(rowBytes, 1, MAX_PNG_DECODED_BYTES),
112
+ MAX_PNG_DECODED_BYTES,
113
+ );
114
+ const compressed = Buffer.concat(idatParts, idatLength);
115
+ let inflated;
116
+ let consumed;
117
+ try {
118
+ const result = inflateSync(compressed, {
119
+ info: true,
120
+ maxOutputLength: scanlineBytes,
121
+ });
122
+ inflated = result.buffer;
123
+ consumed = result.engine.bytesWritten;
124
+ } catch (error) {
125
+ throw pngError(`PNG IDAT zlib stream is invalid: ${error.message}`);
126
+ }
127
+ if (consumed !== compressed.length) {
128
+ throw pngError('PNG IDAT contains trailing zlib data.');
129
+ }
130
+ if (inflated.length !== scanlineBytes) {
131
+ throw pngError('PNG inflated scanline length is invalid.');
132
+ }
133
+
134
+ const pixels = reconstructScanlines(inflated, {
135
+ width,
136
+ height,
137
+ channels,
138
+ rowBytes,
139
+ });
140
+ const pixelHash = hashBytes(pixels);
141
+ const decodedHash = hashDecodedIdentity({
142
+ width,
143
+ height,
144
+ bitDepth,
145
+ colorType,
146
+ channels,
147
+ pixels,
148
+ });
149
+ return {
150
+ width,
151
+ height,
152
+ bitDepth,
153
+ colorType,
154
+ channels,
155
+ pixels,
156
+ pixelHash,
157
+ decodedHash,
158
+ };
159
+ }
160
+
161
+ function parseIhdr(data) {
162
+ const width = data.readUInt32BE(0);
163
+ const height = data.readUInt32BE(4);
164
+ const bitDepth = data[8];
165
+ const colorType = data[9];
166
+ const channels = SUPPORTED_COLOR_TYPES.get(colorType);
167
+ if (
168
+ width === 0 ||
169
+ height === 0 ||
170
+ width > MAX_PNG_DIMENSION ||
171
+ height > MAX_PNG_DIMENSION
172
+ ) {
173
+ throw pngError(`PNG dimensions must be within 1..${MAX_PNG_DIMENSION}.`);
174
+ }
175
+ if (
176
+ bitDepth !== 8 ||
177
+ channels === undefined ||
178
+ data[10] !== 0 ||
179
+ data[11] !== 0 ||
180
+ data[12] !== 0
181
+ ) {
182
+ throw pngError(
183
+ 'PNG browser profile must be non-interlaced 8-bit RGB or RGBA.',
184
+ );
185
+ }
186
+ return { width, height, bitDepth, channels, colorType };
187
+ }
188
+
189
+ function hashDecodedIdentity({
190
+ width,
191
+ height,
192
+ bitDepth,
193
+ colorType,
194
+ channels,
195
+ pixels,
196
+ }) {
197
+ const descriptor = Buffer.alloc(11);
198
+ descriptor.writeUInt32BE(width, 0);
199
+ descriptor.writeUInt32BE(height, 4);
200
+ descriptor[8] = bitDepth;
201
+ descriptor[9] = colorType;
202
+ descriptor[10] = channels;
203
+ return `sha256:${createHash('sha256')
204
+ .update('explainer-kit.decoded-png/v1\0', 'utf8')
205
+ .update(descriptor)
206
+ .update(pixels)
207
+ .digest('hex')}`;
208
+ }
209
+
210
+ function hashBytes(bytes) {
211
+ return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
212
+ }
213
+
214
+ function reconstructScanlines(inflated, { height, channels, rowBytes }) {
215
+ const pixels = Buffer.allocUnsafe(rowBytes * height);
216
+ let inputOffset = 0;
217
+ for (let row = 0; row < height; row += 1) {
218
+ const filter = inflated[inputOffset];
219
+ inputOffset += 1;
220
+ if (filter > 4) {
221
+ throw pngError(`PNG scanline filter ${filter} is unsupported.`);
222
+ }
223
+ const rowOffset = row * rowBytes;
224
+ for (let column = 0; column < rowBytes; column += 1) {
225
+ const encoded = inflated[inputOffset + column];
226
+ const left =
227
+ column >= channels ? pixels[rowOffset + column - channels] : 0;
228
+ const up = row > 0 ? pixels[rowOffset - rowBytes + column] : 0;
229
+ const upLeft =
230
+ row > 0 && column >= channels
231
+ ? pixels[rowOffset - rowBytes + column - channels]
232
+ : 0;
233
+ pixels[rowOffset + column] =
234
+ (encoded + filterPrediction(filter, left, up, upLeft)) & 0xff;
235
+ }
236
+ inputOffset += rowBytes;
237
+ }
238
+ return pixels;
239
+ }
240
+
241
+ function filterPrediction(filter, left, up, upLeft) {
242
+ switch (filter) {
243
+ case 0:
244
+ return 0;
245
+ case 1:
246
+ return left;
247
+ case 2:
248
+ return up;
249
+ case 3:
250
+ return Math.floor((left + up) / 2);
251
+ case 4:
252
+ return paeth(left, up, upLeft);
253
+ default:
254
+ throw pngError(`PNG scanline filter ${filter} is unsupported.`);
255
+ }
256
+ }
257
+
258
+ function paeth(left, up, upLeft) {
259
+ const estimate = left + up - upLeft;
260
+ const leftDistance = Math.abs(estimate - left);
261
+ const upDistance = Math.abs(estimate - up);
262
+ const upLeftDistance = Math.abs(estimate - upLeft);
263
+ if (leftDistance <= upDistance && leftDistance <= upLeftDistance) return left;
264
+ return upDistance <= upLeftDistance ? up : upLeft;
265
+ }
266
+
267
+ function boundedAdd(left, right, maximum) {
268
+ const result = left + right;
269
+ if (!Number.isSafeInteger(result) || result > maximum) {
270
+ throw pngError('PNG size exceeds the configured bounds.');
271
+ }
272
+ return result;
273
+ }
274
+
275
+ function boundedMultiply(left, right, maximum) {
276
+ const result = left * right;
277
+ if (!Number.isSafeInteger(result) || result > maximum) {
278
+ throw pngError('PNG decoded size exceeds the configured bounds.');
279
+ }
280
+ return result;
281
+ }
282
+
283
+ function pngError(message) {
284
+ const error = new Error(message);
285
+ error.code = 'E_PNG';
286
+ return error;
287
+ }