@open-agent-toolkit/cli 0.2.30 → 0.2.31
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/assets/bundle-metadata.json +1 -1
- package/assets/docs/cli-utilities/configuration.md +41 -0
- package/assets/docs/contributing/code.md +19 -7
- package/assets/docs/contributing/explainer-kit-verification.md +9 -1
- package/assets/docs/contributing/skills.md +9 -0
- package/assets/docs/workflows/skills/explainer-kit-providers.md +16 -5
- package/assets/docs/workflows/skills/explainer-kit.md +98 -38
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/explainer-kit/SKILL.md +33 -8
- package/assets/skills/explainer-kit/briefs/project-recap.md +25 -7
- package/assets/skills/explainer-kit/recipes/project-recap.v2.json +72 -0
- package/assets/skills/explainer-kit/references/contracts.md +49 -17
- package/assets/skills/explainer-kit/references/destination-contract.md +141 -25
- package/assets/skills/explainer-kit/references/extension-contract.md +19 -10
- package/assets/skills/explainer-kit/references/visual-authoring.md +24 -0
- package/assets/skills/explainer-kit/references/visual-review.md +19 -5
- package/assets/skills/explainer-kit/schemas/author-request.v3.schema.json +241 -0
- package/assets/skills/explainer-kit/schemas/publish-receipt.v2.schema.json +215 -0
- package/assets/skills/explainer-kit/schemas/publish-request.v2.schema.json +34 -0
- package/assets/skills/explainer-kit/schemas/run-request.schema.json +4 -1
- package/assets/skills/explainer-kit/schemas/terminal-evidence.v1.schema.json +81 -0
- package/assets/skills/explainer-kit/schemas/visual-review-evidence.v1.schema.json +66 -0
- package/assets/skills/explainer-kit/scripts/lib/catalog.mjs +109 -3
- package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +550 -17
- package/assets/skills/explainer-kit/scripts/lib/durability.mjs +90 -8
- package/assets/skills/explainer-kit/scripts/lib/fs-safe.mjs +5 -0
- package/assets/skills/explainer-kit/scripts/lib/internal-references.mjs +538 -0
- package/assets/skills/explainer-kit/scripts/lib/package-coverage.mjs +129 -11
- package/assets/skills/explainer-kit/scripts/lib/publication-policy.mjs +54 -0
- package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +2 -1
- package/assets/skills/explainer-kit/scripts/lib/records.mjs +139 -22
- package/assets/skills/explainer-kit/scripts/lib/s3-roots.mjs +353 -0
- package/assets/skills/explainer-kit/scripts/lib/s3-static.mjs +237 -107
- package/assets/skills/explainer-kit/scripts/lib/set-plan.mjs +1 -0
- package/assets/skills/explainer-kit/scripts/lib/terminal-evidence.mjs +157 -0
- package/assets/skills/explainer-kit/scripts/lib/visual-review.mjs +26 -6
- package/assets/skills/explainer-kit/scripts/run.mjs +1006 -144
- package/assets/skills/oat-explainer-kit/SKILL.md +16 -3
- package/assets/skills/oat-explainer-kit/references/config-contract.md +13 -8
- package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +50 -6
- package/assets/skills/oat-explainer-kit/references/migration.md +2 -1
- package/assets/skills/oat-explainer-kit/references/visual-review-callback.md +11 -0
- package/assets/skills/oat-explainer-kit/scripts/bind-project-sources.mjs +37 -15
- package/assets/skills/oat-explainer-kit/scripts/check-terminal-outcome.mjs +83 -0
- package/assets/skills/oat-explainer-kit/scripts/derive-destination.mjs +91 -0
- package/assets/skills/oat-explainer-kit/scripts/finalize-tracked-run.mjs +66 -10
- package/assets/skills/oat-explainer-kit/scripts/resolve-config.mjs +60 -21
- package/assets/skills/oat-explainer-kit/scripts/resolve-paths.mjs +52 -8
- package/assets/skills/oat-explainer-kit/scripts/run.mjs +271 -36
- package/assets/skills/oat-project-autonomous/references/gate-inventory.md +2 -2
- package/assets/skills/oat-project-complete/SKILL.md +19 -3
- package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +2 -2
- package/assets/skills/oat-project-implement/references/completion-and-closeout.md +8 -0
- package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +2 -2
- package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +2 -2
- package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +2 -2
- package/dist/commands/config/index.d.ts.map +1 -1
- package/dist/commands/config/index.js +18 -0
- package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
- package/dist/commands/project/archive/archive-utils.js +57 -7
- package/dist/commands/project/archive/explainer-terminal-evidence.d.ts +29 -0
- package/dist/commands/project/archive/explainer-terminal-evidence.d.ts.map +1 -0
- package/dist/commands/project/archive/explainer-terminal-evidence.js +37 -0
- package/dist/config/oat-config.d.ts +2 -0
- package/dist/config/oat-config.d.ts.map +1 -1
- package/dist/config/oat-config.js +4 -0
- package/dist/config/resolve.d.ts.map +1 -1
- package/dist/config/resolve.js +1 -0
- package/package.json +2 -2
- /package/assets/skills/explainer-kit/recipes/{project-recap.json → project-recap.v1.json} +0 -0
- /package/assets/skills/explainer-kit/schemas/{publish-receipt.schema.json → publish-receipt.v1.schema.json} +0 -0
- /package/assets/skills/explainer-kit/schemas/{publish-request.schema.json → publish-request.v1.schema.json} +0 -0
|
@@ -4,7 +4,17 @@ import { readFile, realpath } from 'node:fs/promises';
|
|
|
4
4
|
import { dirname, relative, resolve, sep } from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
catalogFromManifest,
|
|
9
|
+
initiativeCatalogPath,
|
|
10
|
+
serializeInitiativeCatalog,
|
|
11
|
+
validateInitiativeCatalog,
|
|
12
|
+
} from './catalog.mjs';
|
|
13
|
+
import {
|
|
14
|
+
canonicalHash,
|
|
15
|
+
isVerifiablePublishReceipt,
|
|
16
|
+
validateContract,
|
|
17
|
+
} from './contracts.mjs';
|
|
8
18
|
import { writeFileAtomic, writeJsonAtomic } from './fs-safe.mjs';
|
|
9
19
|
import { validateSafeRelativePath } from './safe-paths.mjs';
|
|
10
20
|
|
|
@@ -347,7 +357,14 @@ async function verifyCommitEvidence(evidence, { runRoot, manifest }) {
|
|
|
347
357
|
};
|
|
348
358
|
}
|
|
349
359
|
|
|
350
|
-
async function verifyPublishEvidence(
|
|
360
|
+
async function verifyPublishEvidence(
|
|
361
|
+
evidence,
|
|
362
|
+
{ runRoot, manifest, options = {} },
|
|
363
|
+
) {
|
|
364
|
+
// Injectable for the rejection-branch test below, exactly as `fetchImpl` is
|
|
365
|
+
// on `defaultHttpGet`: the branch fires only when the builder and validator
|
|
366
|
+
// disagree, which correct production code never does on purpose.
|
|
367
|
+
const buildCatalog = options.buildCatalog ?? catalogFromManifest;
|
|
351
368
|
const expectedPath = joinWithin(runRoot, 'publish-receipt.json');
|
|
352
369
|
if (resolve(evidence.receiptPath) !== expectedPath) {
|
|
353
370
|
return {
|
|
@@ -370,15 +387,61 @@ async function verifyPublishEvidence(evidence, { runRoot, manifest }) {
|
|
|
370
387
|
errors: [error('publish-receipt', errorMessage(caught))],
|
|
371
388
|
};
|
|
372
389
|
}
|
|
373
|
-
|
|
390
|
+
let catalogArtifact;
|
|
391
|
+
if (isVerifiablePublishReceipt(receipt)) {
|
|
392
|
+
try {
|
|
393
|
+
// Must rebuild the catalog under the policy the receipt declares. The
|
|
394
|
+
// connector embedded that policy in the catalog it hashed, so verifying a
|
|
395
|
+
// `protected` receipt against a `public`-shaped rebuild yields a
|
|
396
|
+
// different hash and rejects every protected publication as
|
|
397
|
+
// `cross-record-mismatch`.
|
|
398
|
+
const catalog = buildCatalog(manifest, receipt.roots?.publicBaseUrl, {
|
|
399
|
+
publicAccess: receipt.publicAccess,
|
|
400
|
+
});
|
|
401
|
+
// This is a builder/validator cross-check on a catalog rebuilt three
|
|
402
|
+
// lines above from the same inputs — durability never consumes catalog
|
|
403
|
+
// bytes from outside, only a recorded hash, so no externally-sourced
|
|
404
|
+
// catalog object exists here to validate. The value of the check is that
|
|
405
|
+
// `catalogFromManifest` and `validateInitiativeCatalog` are independent
|
|
406
|
+
// implementations of the same contract: if they ever diverge, this
|
|
407
|
+
// reports the specific contract violation instead of letting the
|
|
408
|
+
// divergence surface as an opaque `cross-record-mismatch`.
|
|
409
|
+
const catalogValidation = validateInitiativeCatalog(
|
|
410
|
+
catalog,
|
|
411
|
+
manifest,
|
|
412
|
+
receipt.roots?.publicBaseUrl,
|
|
413
|
+
{ publicAccess: receipt.publicAccess },
|
|
414
|
+
);
|
|
415
|
+
if (!catalogValidation.valid) {
|
|
416
|
+
return {
|
|
417
|
+
verified: false,
|
|
418
|
+
errors: [
|
|
419
|
+
error(
|
|
420
|
+
'publish-receipt',
|
|
421
|
+
`Rebuilt initiative catalog is invalid: ${catalogValidation.errors[0].message}`,
|
|
422
|
+
),
|
|
423
|
+
],
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
catalogArtifact = {
|
|
427
|
+
relativePath: initiativeCatalogPath(manifest.slug),
|
|
428
|
+
hash: bufferHash(Buffer.from(serializeInitiativeCatalog(catalog))),
|
|
429
|
+
};
|
|
430
|
+
} catch (caught) {
|
|
431
|
+
return {
|
|
432
|
+
verified: false,
|
|
433
|
+
errors: [error('publish-receipt', errorMessage(caught))],
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const validation = validateContract('publish-receipt', receipt, {
|
|
438
|
+
manifest,
|
|
439
|
+
...(catalogArtifact && { catalogArtifact }),
|
|
440
|
+
});
|
|
374
441
|
const errors = validation.errors.map(({ code, message }) =>
|
|
375
442
|
error(code, message),
|
|
376
443
|
);
|
|
377
|
-
if (
|
|
378
|
-
receipt.sentinel?.uploadVerified !== true ||
|
|
379
|
-
receipt.sentinel?.publicVerified !== true ||
|
|
380
|
-
receipt.sentinel?.deleted !== true
|
|
381
|
-
) {
|
|
444
|
+
if (!sentinelVerificationComplete(receipt)) {
|
|
382
445
|
errors.push(
|
|
383
446
|
error(
|
|
384
447
|
'sentinel-verification',
|
|
@@ -421,6 +484,25 @@ async function verifyPublishEvidence(evidence, { runRoot, manifest }) {
|
|
|
421
484
|
};
|
|
422
485
|
}
|
|
423
486
|
|
|
487
|
+
function sentinelVerificationComplete(receipt) {
|
|
488
|
+
if (receipt.sentinel?.deleted !== true) return false;
|
|
489
|
+
if (receipt.schemaVersion === 'explainer-kit.publish-receipt/v1') {
|
|
490
|
+
return (
|
|
491
|
+
receipt.sentinel.uploadVerified === true &&
|
|
492
|
+
receipt.sentinel.publicVerified === true
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
if (receipt.schemaVersion !== 'explainer-kit.publish-receipt/v2') {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
return (
|
|
499
|
+
receipt.sentinel.objectVerification?.status === 'verified' &&
|
|
500
|
+
(receipt.publicAccess === 'public'
|
|
501
|
+
? receipt.sentinel.publicVerification?.status === 'verified'
|
|
502
|
+
: receipt.sentinel.publicVerification?.status === 'skipped-protected')
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
424
506
|
function appendEvidence(artifact, evidence) {
|
|
425
507
|
artifact.durableEvidence ??= [];
|
|
426
508
|
if (hasEvidence(artifact, evidence)) {
|
|
@@ -41,6 +41,11 @@ export async function createConfinedRunRoot(outputRoot, slug) {
|
|
|
41
41
|
|
|
42
42
|
const normalizedSlug = normalizeSlug(slug);
|
|
43
43
|
const requestedRoot = resolve(checkedRoot.normalizedPath);
|
|
44
|
+
if (basename(requestedRoot) === normalizedSlug) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Output root already ends in run slug "${normalizedSlug}"; supply the parent output root to avoid double-nesting.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
44
49
|
await mkdir(requestedRoot, { recursive: true });
|
|
45
50
|
const canonicalRoot = await realpath(requestedRoot);
|
|
46
51
|
const runRoot = join(canonicalRoot, normalizedSlug);
|
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
const MAX_HTML_BYTES = 1_000_000;
|
|
2
|
+
const MAX_ELEMENTS = 10_000;
|
|
3
|
+
const MAX_REFERENCES = 20_000;
|
|
4
|
+
const MAX_ERRORS = 100;
|
|
5
|
+
const RAW_TEXT_ELEMENTS = new Set(['script', 'style']);
|
|
6
|
+
const REFERENCE_ATTRIBUTES = new Set([
|
|
7
|
+
'href',
|
|
8
|
+
'poster',
|
|
9
|
+
'src',
|
|
10
|
+
'srcset',
|
|
11
|
+
'xlink:href',
|
|
12
|
+
]);
|
|
13
|
+
const SAFE_DATA_REFERENCE =
|
|
14
|
+
/^data:image\/(?:gif|jpeg|png|webp);base64,[a-z0-9+/]+={0,2}$/i;
|
|
15
|
+
|
|
16
|
+
export function validateInternalReferences({ artifacts, manifestPaths } = {}) {
|
|
17
|
+
const errors = [];
|
|
18
|
+
const add = (artifactId, code, message, detail = {}) => {
|
|
19
|
+
if (errors.length >= MAX_ERRORS) return;
|
|
20
|
+
errors.push({ artifactId, code, message, ...detail });
|
|
21
|
+
};
|
|
22
|
+
if (!Array.isArray(artifacts) || !Array.isArray(manifestPaths)) {
|
|
23
|
+
add(
|
|
24
|
+
null,
|
|
25
|
+
'invalid-site-tree',
|
|
26
|
+
'Internal-reference validation requires artifacts and manifest paths.',
|
|
27
|
+
);
|
|
28
|
+
return { valid: false, errors };
|
|
29
|
+
}
|
|
30
|
+
const declaredPaths = new Set(manifestPaths);
|
|
31
|
+
if (
|
|
32
|
+
declaredPaths.size !== manifestPaths.length ||
|
|
33
|
+
manifestPaths.some((path) => !isCanonicalSitePath(path))
|
|
34
|
+
) {
|
|
35
|
+
add(
|
|
36
|
+
null,
|
|
37
|
+
'invalid-site-tree',
|
|
38
|
+
'Manifest paths must be unique canonical site-relative files.',
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const idsByPath = new Map();
|
|
43
|
+
const tokenized = [];
|
|
44
|
+
const boundPaths = new Set();
|
|
45
|
+
let referenceCount = 0;
|
|
46
|
+
for (const artifact of artifacts) {
|
|
47
|
+
if (
|
|
48
|
+
!isObject(artifact) ||
|
|
49
|
+
typeof artifact.artifactId !== 'string' ||
|
|
50
|
+
typeof artifact.renderedPath !== 'string' ||
|
|
51
|
+
typeof artifact.html !== 'string' ||
|
|
52
|
+
!declaredPaths.has(artifact.renderedPath)
|
|
53
|
+
) {
|
|
54
|
+
add(
|
|
55
|
+
artifact?.artifactId ?? null,
|
|
56
|
+
'invalid-site-tree',
|
|
57
|
+
'Every rendered artifact must bind to one manifest-declared site path.',
|
|
58
|
+
);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (boundPaths.has(artifact.renderedPath)) {
|
|
62
|
+
add(
|
|
63
|
+
artifact.artifactId,
|
|
64
|
+
'invalid-site-tree',
|
|
65
|
+
'Rendered artifact paths must be unique.',
|
|
66
|
+
{ renderedPath: artifact.renderedPath },
|
|
67
|
+
);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
boundPaths.add(artifact.renderedPath);
|
|
71
|
+
if (Buffer.byteLength(artifact.html, 'utf8') > MAX_HTML_BYTES) {
|
|
72
|
+
add(
|
|
73
|
+
artifact.artifactId,
|
|
74
|
+
'validation-bound',
|
|
75
|
+
`Rendered artifact exceeds the ${MAX_HTML_BYTES}-byte validation bound.`,
|
|
76
|
+
);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const parsed = tokenizeBoundedHtml(artifact.html);
|
|
80
|
+
if (!parsed.valid) {
|
|
81
|
+
add(artifact.artifactId, parsed.code, parsed.message, {
|
|
82
|
+
renderedPath: artifact.renderedPath,
|
|
83
|
+
});
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const idCounts = new Map();
|
|
87
|
+
for (const element of parsed.elements) {
|
|
88
|
+
for (const attribute of element.attributes) {
|
|
89
|
+
if (attribute.name === 'id') {
|
|
90
|
+
idCounts.set(
|
|
91
|
+
attribute.value,
|
|
92
|
+
(idCounts.get(attribute.value) ?? 0) + 1,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (REFERENCE_ATTRIBUTES.has(attribute.name)) {
|
|
96
|
+
referenceCount += 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
idsByPath.set(artifact.renderedPath, idCounts);
|
|
101
|
+
tokenized.push({ artifact, elements: parsed.elements });
|
|
102
|
+
}
|
|
103
|
+
if (referenceCount > MAX_REFERENCES) {
|
|
104
|
+
add(
|
|
105
|
+
null,
|
|
106
|
+
'validation-bound',
|
|
107
|
+
`Rendered site exceeds the ${MAX_REFERENCES}-reference validation bound.`,
|
|
108
|
+
);
|
|
109
|
+
return { valid: false, errors };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const { artifact, elements } of tokenized) {
|
|
113
|
+
for (const element of elements) {
|
|
114
|
+
for (const attribute of element.attributes) {
|
|
115
|
+
if (!REFERENCE_ATTRIBUTES.has(attribute.name)) continue;
|
|
116
|
+
const references =
|
|
117
|
+
attribute.name === 'srcset'
|
|
118
|
+
? parseSrcset(attribute.value)
|
|
119
|
+
: { valid: true, values: [attribute.value] };
|
|
120
|
+
if (!references.valid) {
|
|
121
|
+
add(
|
|
122
|
+
artifact.artifactId,
|
|
123
|
+
'malformed-srcset',
|
|
124
|
+
'srcset must contain bounded URL candidates with valid descriptors.',
|
|
125
|
+
referenceDetail(artifact, element, attribute, attribute.value),
|
|
126
|
+
);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
for (const reference of references.values) {
|
|
130
|
+
validateReference({
|
|
131
|
+
artifact,
|
|
132
|
+
element,
|
|
133
|
+
attribute,
|
|
134
|
+
reference,
|
|
135
|
+
declaredPaths,
|
|
136
|
+
idsByPath,
|
|
137
|
+
add,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { valid: errors.length === 0, errors };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function validateReference({
|
|
147
|
+
artifact,
|
|
148
|
+
element,
|
|
149
|
+
attribute,
|
|
150
|
+
reference,
|
|
151
|
+
declaredPaths,
|
|
152
|
+
idsByPath,
|
|
153
|
+
add,
|
|
154
|
+
}) {
|
|
155
|
+
const detail = referenceDetail(artifact, element, attribute, reference);
|
|
156
|
+
if (
|
|
157
|
+
typeof reference !== 'string' ||
|
|
158
|
+
reference.length === 0 ||
|
|
159
|
+
hasUnsafeRawReferenceBytes(reference)
|
|
160
|
+
) {
|
|
161
|
+
add(
|
|
162
|
+
artifact.artifactId,
|
|
163
|
+
'unsafe-reference',
|
|
164
|
+
'References must use a non-empty canonical raw form.',
|
|
165
|
+
detail,
|
|
166
|
+
);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const scheme = reference.match(/^([a-z][a-z0-9+.-]*):/i)?.[1]?.toLowerCase();
|
|
170
|
+
if (scheme === 'data') {
|
|
171
|
+
if (
|
|
172
|
+
!isResourceAttribute(element.name, attribute.name) ||
|
|
173
|
+
!SAFE_DATA_REFERENCE.test(reference)
|
|
174
|
+
) {
|
|
175
|
+
add(
|
|
176
|
+
artifact.artifactId,
|
|
177
|
+
'unsafe-scheme',
|
|
178
|
+
'Only bounded base64 image data references are allowed inline.',
|
|
179
|
+
detail,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (scheme) {
|
|
185
|
+
let external;
|
|
186
|
+
try {
|
|
187
|
+
external = new URL(reference);
|
|
188
|
+
} catch {
|
|
189
|
+
add(
|
|
190
|
+
artifact.artifactId,
|
|
191
|
+
'malformed-reference',
|
|
192
|
+
'Absolute reference is malformed.',
|
|
193
|
+
detail,
|
|
194
|
+
);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (
|
|
198
|
+
scheme !== 'https' ||
|
|
199
|
+
external.username ||
|
|
200
|
+
external.password ||
|
|
201
|
+
(element.name !== 'a' && element.name !== 'area') ||
|
|
202
|
+
attribute.name !== 'href'
|
|
203
|
+
) {
|
|
204
|
+
add(
|
|
205
|
+
artifact.artifactId,
|
|
206
|
+
scheme === 'https' && isResourceAttribute(element.name, attribute.name)
|
|
207
|
+
? 'external-resource'
|
|
208
|
+
: 'unsafe-scheme',
|
|
209
|
+
'External resources and unsafe navigation schemes are not allowed.',
|
|
210
|
+
detail,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (reference.startsWith('//')) {
|
|
216
|
+
add(
|
|
217
|
+
artifact.artifactId,
|
|
218
|
+
'unsafe-reference',
|
|
219
|
+
'Protocol-relative references are ambiguous.',
|
|
220
|
+
detail,
|
|
221
|
+
);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (reference.includes('?')) {
|
|
225
|
+
add(
|
|
226
|
+
artifact.artifactId,
|
|
227
|
+
'query-reference',
|
|
228
|
+
'Internal references cannot contain queries.',
|
|
229
|
+
detail,
|
|
230
|
+
);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const [pathPart, rawFragment, ...extraFragments] = reference.split('#');
|
|
234
|
+
if (extraFragments.length > 0) {
|
|
235
|
+
add(
|
|
236
|
+
artifact.artifactId,
|
|
237
|
+
'malformed-reference',
|
|
238
|
+
'Internal references can contain at most one fragment delimiter.',
|
|
239
|
+
detail,
|
|
240
|
+
);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (pathPart.endsWith('/')) {
|
|
244
|
+
add(
|
|
245
|
+
artifact.artifactId,
|
|
246
|
+
'directory-reference',
|
|
247
|
+
'Internal links must name an explicit file such as index.html.',
|
|
248
|
+
detail,
|
|
249
|
+
);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (pathPart.startsWith('/')) {
|
|
253
|
+
add(
|
|
254
|
+
artifact.artifactId,
|
|
255
|
+
'site-root-escape',
|
|
256
|
+
'Internal references must be relative to the current artifact.',
|
|
257
|
+
detail,
|
|
258
|
+
);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const base = new URL(artifact.renderedPath, 'https://explainer.invalid/');
|
|
262
|
+
let resolved;
|
|
263
|
+
try {
|
|
264
|
+
resolved = new URL(reference, base);
|
|
265
|
+
} catch {
|
|
266
|
+
add(
|
|
267
|
+
artifact.artifactId,
|
|
268
|
+
'malformed-reference',
|
|
269
|
+
'Relative reference is malformed.',
|
|
270
|
+
detail,
|
|
271
|
+
);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (
|
|
275
|
+
resolved.origin !== base.origin ||
|
|
276
|
+
resolved.protocol !== 'https:' ||
|
|
277
|
+
!resolved.pathname.startsWith('/site/')
|
|
278
|
+
) {
|
|
279
|
+
add(
|
|
280
|
+
artifact.artifactId,
|
|
281
|
+
'site-root-escape',
|
|
282
|
+
'Internal reference resolves outside the generated site root.',
|
|
283
|
+
detail,
|
|
284
|
+
);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const targetPath = resolved.pathname.slice(1);
|
|
288
|
+
if (!declaredPaths.has(targetPath)) {
|
|
289
|
+
add(
|
|
290
|
+
artifact.artifactId,
|
|
291
|
+
'missing-target',
|
|
292
|
+
'Internal reference does not resolve to a manifest-declared file.',
|
|
293
|
+
{ ...detail, targetPath },
|
|
294
|
+
);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (rawFragment !== undefined) {
|
|
298
|
+
let fragment;
|
|
299
|
+
try {
|
|
300
|
+
fragment = decodeURIComponent(rawFragment);
|
|
301
|
+
} catch {
|
|
302
|
+
add(
|
|
303
|
+
artifact.artifactId,
|
|
304
|
+
'malformed-reference',
|
|
305
|
+
'Reference fragment is not valid percent-encoded text.',
|
|
306
|
+
detail,
|
|
307
|
+
);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const targetCount = idsByPath.get(targetPath)?.get(fragment) ?? 0;
|
|
311
|
+
if (!fragment || targetCount === 0) {
|
|
312
|
+
add(
|
|
313
|
+
artifact.artifactId,
|
|
314
|
+
'missing-fragment',
|
|
315
|
+
'Internal reference fragment does not exist in the target document.',
|
|
316
|
+
{ ...detail, targetPath, fragment },
|
|
317
|
+
);
|
|
318
|
+
} else if (targetCount > 1) {
|
|
319
|
+
add(
|
|
320
|
+
artifact.artifactId,
|
|
321
|
+
'ambiguous-fragment',
|
|
322
|
+
'Internal reference fragment resolves to multiple targets in the target document.',
|
|
323
|
+
{ ...detail, targetPath, fragment },
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function tokenizeBoundedHtml(html) {
|
|
330
|
+
const elements = [];
|
|
331
|
+
let cursor = 0;
|
|
332
|
+
while (cursor < html.length) {
|
|
333
|
+
const open = html.indexOf('<', cursor);
|
|
334
|
+
if (open < 0) break;
|
|
335
|
+
if (html.startsWith('<!--', open)) {
|
|
336
|
+
const close = html.indexOf('-->', open + 4);
|
|
337
|
+
if (close < 0) return malformedHtml();
|
|
338
|
+
cursor = close + 3;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const close = findTagEnd(html, open + 1);
|
|
342
|
+
if (close < 0) return malformedHtml();
|
|
343
|
+
const source = html.slice(open + 1, close);
|
|
344
|
+
if (/^\s*[!/]/.test(source)) {
|
|
345
|
+
cursor = close + 1;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const parsed = parseStartTag(source);
|
|
349
|
+
if (!parsed.valid) return malformedHtml();
|
|
350
|
+
elements.push(parsed.element);
|
|
351
|
+
if (elements.length > MAX_ELEMENTS) {
|
|
352
|
+
return {
|
|
353
|
+
valid: false,
|
|
354
|
+
code: 'validation-bound',
|
|
355
|
+
message: `Rendered artifact exceeds the ${MAX_ELEMENTS}-element validation bound.`,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
cursor = close + 1;
|
|
359
|
+
if (RAW_TEXT_ELEMENTS.has(parsed.element.name)) {
|
|
360
|
+
const closing = indexOfCaseInsensitive(
|
|
361
|
+
html,
|
|
362
|
+
`</${parsed.element.name}`,
|
|
363
|
+
cursor,
|
|
364
|
+
);
|
|
365
|
+
if (closing < 0) return malformedHtml();
|
|
366
|
+
const closingEnd = findTagEnd(html, closing + 2);
|
|
367
|
+
if (closingEnd < 0) return malformedHtml();
|
|
368
|
+
cursor = closingEnd + 1;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return { valid: true, elements };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function findTagEnd(html, start) {
|
|
375
|
+
let quote = null;
|
|
376
|
+
for (let index = start; index < html.length; index += 1) {
|
|
377
|
+
const char = html[index];
|
|
378
|
+
if (quote) {
|
|
379
|
+
if (char === quote) quote = null;
|
|
380
|
+
} else if (char === '"' || char === "'") {
|
|
381
|
+
quote = char;
|
|
382
|
+
} else if (char === '>') {
|
|
383
|
+
return index;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return -1;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function parseStartTag(source) {
|
|
390
|
+
const nameMatch = source.match(/^\s*([a-z][a-z0-9:-]*)/i);
|
|
391
|
+
if (!nameMatch) return { valid: false };
|
|
392
|
+
const name = nameMatch[1].toLowerCase();
|
|
393
|
+
const attributes = [];
|
|
394
|
+
let cursor = nameMatch[0].length;
|
|
395
|
+
while (cursor < source.length) {
|
|
396
|
+
while (/\s/.test(source[cursor] ?? '')) cursor += 1;
|
|
397
|
+
if (cursor >= source.length || source[cursor] === '/') break;
|
|
398
|
+
const attributeMatch = source
|
|
399
|
+
.slice(cursor)
|
|
400
|
+
.match(/^([^\s"'<>/=]+)(?:\s*=\s*)?/);
|
|
401
|
+
if (!attributeMatch) return { valid: false };
|
|
402
|
+
const attributeName = attributeMatch[1].toLowerCase();
|
|
403
|
+
cursor += attributeMatch[0].length;
|
|
404
|
+
const hasValue = attributeMatch[0].includes('=');
|
|
405
|
+
let value = '';
|
|
406
|
+
if (hasValue) {
|
|
407
|
+
const quote = source[cursor];
|
|
408
|
+
if (quote !== '"' && quote !== "'") return { valid: false };
|
|
409
|
+
const end = source.indexOf(quote, cursor + 1);
|
|
410
|
+
if (end < 0) return { valid: false };
|
|
411
|
+
value = decodeHtmlReferences(source.slice(cursor + 1, end));
|
|
412
|
+
cursor = end + 1;
|
|
413
|
+
}
|
|
414
|
+
attributes.push({ name: attributeName, value });
|
|
415
|
+
}
|
|
416
|
+
return { valid: true, element: { name, attributes } };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function parseSrcset(value) {
|
|
420
|
+
const values = [];
|
|
421
|
+
let cursor = 0;
|
|
422
|
+
while (cursor < value.length) {
|
|
423
|
+
while (/\s/.test(value[cursor] ?? '')) cursor += 1;
|
|
424
|
+
if (cursor >= value.length || value[cursor] === ',') {
|
|
425
|
+
return { valid: false, values: [] };
|
|
426
|
+
}
|
|
427
|
+
const dataReference =
|
|
428
|
+
value.slice(cursor, cursor + 'data:'.length).toLowerCase() === 'data:';
|
|
429
|
+
const urlStart = cursor;
|
|
430
|
+
if (dataReference) {
|
|
431
|
+
while (cursor < value.length && !/\s/.test(value[cursor])) {
|
|
432
|
+
cursor += 1;
|
|
433
|
+
}
|
|
434
|
+
} else {
|
|
435
|
+
while (cursor < value.length && !/[\s,]/.test(value[cursor])) {
|
|
436
|
+
cursor += 1;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const url = value.slice(urlStart, cursor);
|
|
440
|
+
if (!url) return { valid: false, values: [] };
|
|
441
|
+
values.push(url);
|
|
442
|
+
if (value[cursor] === ',') {
|
|
443
|
+
cursor += 1;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
while (/\s/.test(value[cursor] ?? '')) cursor += 1;
|
|
447
|
+
const descriptorStart = cursor;
|
|
448
|
+
while (cursor < value.length && !/[\s,]/.test(value[cursor])) {
|
|
449
|
+
cursor += 1;
|
|
450
|
+
}
|
|
451
|
+
const descriptor = value.slice(descriptorStart, cursor);
|
|
452
|
+
if (descriptor && !/^(?:\d+w|(?:\d+(?:\.\d+)?|\.\d+)x)$/.test(descriptor)) {
|
|
453
|
+
return { valid: false, values: [] };
|
|
454
|
+
}
|
|
455
|
+
while (/\s/.test(value[cursor] ?? '')) cursor += 1;
|
|
456
|
+
if (cursor < value.length) {
|
|
457
|
+
if (value[cursor] !== ',') return { valid: false, values: [] };
|
|
458
|
+
cursor += 1;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
valid: values.length > 0,
|
|
463
|
+
values,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function hasUnsafeRawReferenceBytes(value) {
|
|
468
|
+
if (
|
|
469
|
+
value !== value.trim() ||
|
|
470
|
+
value.includes('\\') ||
|
|
471
|
+
value.includes('\0') ||
|
|
472
|
+
/%(?:2e|2f|5c)/i.test(value)
|
|
473
|
+
) {
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
for (const char of value) {
|
|
477
|
+
const code = char.codePointAt(0);
|
|
478
|
+
if (code < 0x20 || code === 0x7f) return true;
|
|
479
|
+
}
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function isResourceAttribute(elementName, attributeName) {
|
|
484
|
+
return (
|
|
485
|
+
['poster', 'src', 'srcset'].includes(attributeName) ||
|
|
486
|
+
(['href', 'xlink:href'].includes(attributeName) &&
|
|
487
|
+
!['a', 'area'].includes(elementName))
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function isCanonicalSitePath(value) {
|
|
492
|
+
if (
|
|
493
|
+
typeof value !== 'string' ||
|
|
494
|
+
!/^site\/[a-z0-9._-]+(?:\/[a-z0-9._-]+)*$/i.test(value)
|
|
495
|
+
) {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
return value.split('/').every((segment) => !['.', '..'].includes(segment));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function referenceDetail(artifact, element, attribute, reference) {
|
|
502
|
+
return {
|
|
503
|
+
renderedPath: artifact.renderedPath,
|
|
504
|
+
element: element.name,
|
|
505
|
+
attribute: attribute.name,
|
|
506
|
+
reference,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function malformedHtml() {
|
|
511
|
+
return {
|
|
512
|
+
valid: false,
|
|
513
|
+
code: 'malformed-html',
|
|
514
|
+
message: 'Rendered HTML cannot be tokenized safely.',
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function indexOfCaseInsensitive(source, search, fromIndex) {
|
|
519
|
+
return source.toLowerCase().indexOf(search.toLowerCase(), fromIndex);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function decodeHtmlReferences(value) {
|
|
523
|
+
return value.replace(
|
|
524
|
+
/&(?:amp|quot|apos|#39|#x27);/gi,
|
|
525
|
+
(entity) =>
|
|
526
|
+
({
|
|
527
|
+
'&': '&',
|
|
528
|
+
'"': '"',
|
|
529
|
+
''': "'",
|
|
530
|
+
''': "'",
|
|
531
|
+
''': "'",
|
|
532
|
+
})[entity.toLowerCase()] ?? entity,
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function isObject(value) {
|
|
537
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
538
|
+
}
|