@open-agent-toolkit/cli 0.2.26 → 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.
- package/assets/NOTICES.md +156 -0
- package/assets/docs/contributing/explainer-kit-verification.md +125 -0
- package/assets/docs/contributing/index.md +1 -0
- package/assets/docs/reference/troubleshooting.md +47 -0
- package/assets/docs/workflows/projects/artifacts.md +24 -6
- package/assets/docs/workflows/skills/explainer-kit-providers.md +144 -0
- package/assets/docs/workflows/skills/explainer-kit.md +121 -69
- package/assets/docs/workflows/skills/index.md +1 -0
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/explainer-kit/SKILL.md +18 -3
- package/assets/skills/explainer-kit/recipes/project-recap.json +43 -16
- package/assets/skills/explainer-kit/references/contracts.md +167 -20
- package/assets/skills/explainer-kit/references/golden-conformance.md +80 -0
- package/assets/skills/explainer-kit/references/visual-authoring.md +92 -0
- package/assets/skills/explainer-kit/references/visual-review.md +57 -0
- package/assets/skills/explainer-kit/schemas/author-request.v2.schema.json +172 -1
- package/assets/skills/explainer-kit/schemas/build-record.schema.json +7 -1
- package/assets/skills/explainer-kit/schemas/fact-base.schema.json +38 -2
- package/assets/skills/explainer-kit/schemas/manifest.schema.json +25 -1
- package/assets/skills/explainer-kit/schemas/run-request.schema.json +4 -0
- package/assets/skills/explainer-kit/schemas/set-plan.v1.schema.json +149 -0
- package/assets/skills/explainer-kit/schemas/visual-review-request.v1.schema.json +117 -0
- package/assets/skills/explainer-kit/schemas/visual-review-result.v1.schema.json +80 -0
- package/assets/skills/explainer-kit/scripts/lib/browser-runtime.mjs +148 -4
- package/assets/skills/explainer-kit/scripts/lib/catalog.mjs +243 -0
- package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +586 -8
- package/assets/skills/explainer-kit/scripts/lib/diagram.mjs +285 -8
- package/assets/skills/explainer-kit/scripts/lib/durability.mjs +35 -0
- package/assets/skills/explainer-kit/scripts/lib/fact-base.mjs +144 -8
- package/assets/skills/explainer-kit/scripts/lib/package-coverage.mjs +379 -0
- package/assets/skills/explainer-kit/scripts/lib/png.mjs +287 -0
- package/assets/skills/explainer-kit/scripts/lib/qa.mjs +280 -8
- package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +132 -3
- package/assets/skills/explainer-kit/scripts/lib/records.mjs +513 -21
- package/assets/skills/explainer-kit/scripts/lib/render.mjs +67 -3
- package/assets/skills/explainer-kit/scripts/lib/s3-static.mjs +43 -1
- package/assets/skills/explainer-kit/scripts/lib/set-plan.mjs +208 -0
- package/assets/skills/explainer-kit/scripts/lib/source-backlinks.mjs +218 -0
- package/assets/skills/explainer-kit/scripts/lib/visual-review.mjs +380 -0
- package/assets/skills/explainer-kit/scripts/render-qa.mjs +48 -10
- package/assets/skills/explainer-kit/scripts/run.mjs +859 -134
- package/assets/skills/oat-explainer-kit/SKILL.md +40 -12
- package/assets/skills/oat-explainer-kit/references/author-callback.md +12 -10
- package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +40 -2
- package/assets/skills/oat-explainer-kit/references/visual-review-callback.md +72 -0
- package/assets/skills/oat-explainer-kit/scripts/bind-project-sources.mjs +167 -5
- package/assets/skills/oat-explainer-kit/scripts/finalize-tracked-run.mjs +92 -5
- package/assets/skills/oat-explainer-kit/scripts/run.mjs +324 -2
- package/dist/commands/project/archive/archive-utils.d.ts +1 -0
- package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
- package/dist/commands/project/archive/archive-utils.js +109 -42
- package/dist/commands/project/archive/explainer-package-coverage.d.ts +14 -0
- package/dist/commands/project/archive/explainer-package-coverage.d.ts.map +1 -0
- package/dist/commands/project/archive/explainer-package-coverage.js +27 -0
- package/dist/commands/project/archive/explainer-source-backlinks.d.ts +18 -0
- package/dist/commands/project/archive/explainer-source-backlinks.d.ts.map +1 -0
- package/dist/commands/project/archive/explainer-source-backlinks.js +27 -0
- package/dist/commands/project/archive/push-runner.d.ts +2 -1
- package/dist/commands/project/archive/push-runner.d.ts.map +1 -1
- package/dist/commands/project/archive/push-runner.js +5 -1
- package/dist/release/public-package-contract.d.ts +6 -0
- package/dist/release/public-package-contract.d.ts.map +1 -1
- package/dist/release/public-package-contract.js +75 -0
- package/package.json +2 -2
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { assertBrowserProbeSession } from './browser-runtime.mjs';
|
|
5
|
+
import { writeJsonAtomic } from './fs-safe.mjs';
|
|
1
6
|
import { findUnpinnedResourceRefs } from './html-safety.mjs';
|
|
7
|
+
import { decodeBrowserPng } from './png.mjs';
|
|
2
8
|
import { recipeFloor, recipeRequiredNarrative } from './recipes.mjs';
|
|
9
|
+
import { cohesionEvidenceFromLedger } from './visual-review.mjs';
|
|
3
10
|
|
|
4
11
|
const VOID_ELEMENTS = new Set([
|
|
5
12
|
'area',
|
|
@@ -21,6 +28,7 @@ const INLINE_ASSET_VIOLATION_PATTERN =
|
|
|
21
28
|
/<link\b|@import\b|url\(\s*["']?(?!data:|#)/i;
|
|
22
29
|
const TOKEN_PATTERN = /{{\s*[A-Z][A-Z0-9_]*\s*}}/g;
|
|
23
30
|
const ARROW_KEYS = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'];
|
|
31
|
+
const MAX_SCREENSHOT_BYTES = 20 * 1024 * 1024;
|
|
24
32
|
const INLINE_DIAGRAM_PATTERN =
|
|
25
33
|
/<svg\b(?=[^>]*(?:\bclass\s*=\s*["'][^"']*\bdiagram\b|\baria-label\s*=\s*["'][^"']*(?:architecture|diagram)))[^>]*>/i;
|
|
26
34
|
const STRUCTURED_BLOCK_PATTERNS = [
|
|
@@ -420,12 +428,33 @@ export function checkHtmlStructure({
|
|
|
420
428
|
return { valid: issues.length === 0, issues };
|
|
421
429
|
}
|
|
422
430
|
|
|
423
|
-
export function checkArtifactCohesion(artifacts) {
|
|
431
|
+
export function checkArtifactCohesion(artifacts, { ledger = null } = {}) {
|
|
424
432
|
if (!Array.isArray(artifacts)) {
|
|
425
433
|
throw new TypeError('Artifact cohesion input must be an array.');
|
|
426
434
|
}
|
|
427
435
|
const issues = [];
|
|
428
436
|
const groups = ['terminology', 'numericClaims', 'statuses'];
|
|
437
|
+
const expected = ledger
|
|
438
|
+
? {
|
|
439
|
+
terminology: new Map(
|
|
440
|
+
(ledger.terminology ?? []).map(({ term }) => [term, term]),
|
|
441
|
+
),
|
|
442
|
+
numericClaims: new Map(
|
|
443
|
+
(ledger.numbers ?? []).map(({ subject, value }) => [subject, value]),
|
|
444
|
+
),
|
|
445
|
+
statuses: new Map(
|
|
446
|
+
(ledger.statuses ?? []).map(({ subject, value }) => [subject, value]),
|
|
447
|
+
),
|
|
448
|
+
}
|
|
449
|
+
: null;
|
|
450
|
+
|
|
451
|
+
if (expected && groups.some((group) => expected[group].size === 0)) {
|
|
452
|
+
issues.push({
|
|
453
|
+
code: 'cohesion-ledger-empty',
|
|
454
|
+
message:
|
|
455
|
+
'Adaptive recap cohesion requires non-empty terminology, numeric, and status ledger entries.',
|
|
456
|
+
});
|
|
457
|
+
}
|
|
429
458
|
|
|
430
459
|
for (const group of groups) {
|
|
431
460
|
const claims = new Map();
|
|
@@ -458,6 +487,38 @@ export function checkArtifactCohesion(artifacts) {
|
|
|
458
487
|
}
|
|
459
488
|
}
|
|
460
489
|
}
|
|
490
|
+
if (expected) {
|
|
491
|
+
for (const [claim, expectedValue] of expected[group]) {
|
|
492
|
+
const observed = claims.get(claim);
|
|
493
|
+
if (
|
|
494
|
+
!observed ||
|
|
495
|
+
observed.normalized !== normalizeClaim(expectedValue)
|
|
496
|
+
) {
|
|
497
|
+
issues.push({
|
|
498
|
+
code: 'cohesion-claim-unobserved',
|
|
499
|
+
message: `Rendered artifacts do not observably support ${group}.${claim}.`,
|
|
500
|
+
claim,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (expected) {
|
|
508
|
+
for (const artifact of artifacts) {
|
|
509
|
+
const count = groups.reduce(
|
|
510
|
+
(total, group) =>
|
|
511
|
+
total + Object.keys(artifact?.cohesion?.[group] ?? {}).length,
|
|
512
|
+
0,
|
|
513
|
+
);
|
|
514
|
+
if (count === 0) {
|
|
515
|
+
issues.push({
|
|
516
|
+
code: 'cohesion-observations-empty',
|
|
517
|
+
message: `Artifact ${String(artifact?.id)} has no observed shared-ledger evidence.`,
|
|
518
|
+
artifactId: artifact?.id,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
461
522
|
}
|
|
462
523
|
|
|
463
524
|
return { valid: issues.length === 0, issues };
|
|
@@ -466,14 +527,36 @@ export function checkArtifactCohesion(artifacts) {
|
|
|
466
527
|
export async function runBrowserProbes({
|
|
467
528
|
artifacts,
|
|
468
529
|
probe,
|
|
530
|
+
browserSession,
|
|
469
531
|
widths = REPRESENTATIVE_WIDTHS,
|
|
532
|
+
evidenceRoot,
|
|
533
|
+
requireEvidence = false,
|
|
534
|
+
onProbeResult,
|
|
470
535
|
}) {
|
|
471
536
|
if (!Array.isArray(artifacts) || artifacts.length === 0) {
|
|
472
537
|
throw new TypeError('Browser QA requires at least one artifact.');
|
|
473
538
|
}
|
|
474
|
-
if (
|
|
539
|
+
if (browserSession !== undefined && probe !== undefined) {
|
|
540
|
+
throw new TypeError(
|
|
541
|
+
'Browser QA accepts either a trusted browser session or a bare non-retaining probe, not both.',
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
const session =
|
|
545
|
+
browserSession === undefined
|
|
546
|
+
? null
|
|
547
|
+
: assertBrowserProbeSession(browserSession, { allowFixture: true });
|
|
548
|
+
const resolvedProbe = session?.probe ?? probe;
|
|
549
|
+
if (typeof resolvedProbe !== 'function') {
|
|
475
550
|
throw new TypeError('Browser QA requires a probe callback.');
|
|
476
551
|
}
|
|
552
|
+
if (evidenceRoot && !session) {
|
|
553
|
+
throw new TypeError(
|
|
554
|
+
'A trusted browser session is required for retained evidence.',
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
if (onProbeResult !== undefined && typeof onProbeResult !== 'function') {
|
|
558
|
+
throw new TypeError('Browser QA probe observer must be a callback.');
|
|
559
|
+
}
|
|
477
560
|
if (
|
|
478
561
|
!Array.isArray(widths) ||
|
|
479
562
|
widths.length === 0 ||
|
|
@@ -483,10 +566,21 @@ export async function runBrowserProbes({
|
|
|
483
566
|
}
|
|
484
567
|
|
|
485
568
|
const issues = [];
|
|
569
|
+
const evidence = [];
|
|
486
570
|
let probes = 0;
|
|
487
571
|
for (const artifact of artifacts) {
|
|
572
|
+
const evidenceId = browserEvidenceId(artifact.id);
|
|
488
573
|
for (const width of widths) {
|
|
489
574
|
for (const scenario of browserScenarios(artifact)) {
|
|
575
|
+
const viewport = viewportName(width);
|
|
576
|
+
const screenshotPath =
|
|
577
|
+
evidenceRoot && scenario === 'default'
|
|
578
|
+
? `qa/browser/${evidenceId}/${viewport}.png`
|
|
579
|
+
: undefined;
|
|
580
|
+
const metricsPath =
|
|
581
|
+
evidenceRoot && scenario === 'default'
|
|
582
|
+
? `qa/browser/${evidenceId}/${viewport}.json`
|
|
583
|
+
: undefined;
|
|
490
584
|
const request = {
|
|
491
585
|
artifact,
|
|
492
586
|
scenario,
|
|
@@ -500,6 +594,9 @@ export async function runBrowserProbes({
|
|
|
500
594
|
artifact.type === 'deck' && scenario === 'default'
|
|
501
595
|
? { tab: true, arrows: [...ARROW_KEYS] }
|
|
502
596
|
: { tab: true },
|
|
597
|
+
...(screenshotPath && {
|
|
598
|
+
screenshotPath: join(evidenceRoot, screenshotPath),
|
|
599
|
+
}),
|
|
503
600
|
...(scenario === 'no-js' && { javascriptEnabled: false }),
|
|
504
601
|
...(scenario === 'print' && { media: 'print' }),
|
|
505
602
|
...(artifact.type === 'deck' &&
|
|
@@ -520,11 +617,62 @@ export async function runBrowserProbes({
|
|
|
520
617
|
},
|
|
521
618
|
}),
|
|
522
619
|
};
|
|
523
|
-
|
|
620
|
+
let result;
|
|
621
|
+
try {
|
|
622
|
+
result = await resolvedProbe(request);
|
|
623
|
+
} catch (cause) {
|
|
624
|
+
const error = new Error(
|
|
625
|
+
`Browser evidence callback failed: ${cause?.message ?? String(cause)}`,
|
|
626
|
+
{ cause },
|
|
627
|
+
);
|
|
628
|
+
error.code = 'E_VISUAL_REVIEW';
|
|
629
|
+
throw error;
|
|
630
|
+
}
|
|
524
631
|
probes += 1;
|
|
525
632
|
validateProbeResult(result, artifact.id, width, request);
|
|
633
|
+
if (onProbeResult) {
|
|
634
|
+
try {
|
|
635
|
+
await onProbeResult(
|
|
636
|
+
structuredClone({
|
|
637
|
+
artifactId: artifact.id,
|
|
638
|
+
artifactType: artifact.type,
|
|
639
|
+
scenario,
|
|
640
|
+
viewport: request.viewport,
|
|
641
|
+
result,
|
|
642
|
+
}),
|
|
643
|
+
);
|
|
644
|
+
} catch (cause) {
|
|
645
|
+
const error = new Error(
|
|
646
|
+
`Browser evidence observer failed: ${cause?.message ?? String(cause)}`,
|
|
647
|
+
{ cause },
|
|
648
|
+
);
|
|
649
|
+
error.code = 'E_VISUAL_REVIEW';
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
526
653
|
|
|
527
654
|
const context = { artifactId: artifact.id, width, scenario };
|
|
655
|
+
if (screenshotPath && metricsPath) {
|
|
656
|
+
const retained = await retainBrowserEvidence({
|
|
657
|
+
evidenceRoot,
|
|
658
|
+
artifactId: artifact.id,
|
|
659
|
+
viewport,
|
|
660
|
+
viewportSize: request.viewport,
|
|
661
|
+
screenshotPath,
|
|
662
|
+
metricsPath,
|
|
663
|
+
result,
|
|
664
|
+
browserSession: session,
|
|
665
|
+
});
|
|
666
|
+
if (retained.valid) {
|
|
667
|
+
evidence.push(retained.evidence);
|
|
668
|
+
} else if (requireEvidence) {
|
|
669
|
+
issues.push({
|
|
670
|
+
...context,
|
|
671
|
+
code: 'browser-evidence-missing',
|
|
672
|
+
message: retained.message,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
}
|
|
528
676
|
if (result.pageOverflowX) {
|
|
529
677
|
issues.push({
|
|
530
678
|
...context,
|
|
@@ -625,29 +773,51 @@ export async function runBrowserProbes({
|
|
|
625
773
|
}
|
|
626
774
|
}
|
|
627
775
|
|
|
628
|
-
return {
|
|
776
|
+
return {
|
|
777
|
+
valid: issues.length === 0,
|
|
778
|
+
issues,
|
|
779
|
+
probes,
|
|
780
|
+
...(evidenceRoot && { evidence }),
|
|
781
|
+
};
|
|
629
782
|
}
|
|
630
783
|
|
|
631
784
|
export async function auditArtifactSet({
|
|
632
785
|
artifacts,
|
|
633
786
|
denylist = [],
|
|
634
787
|
browserProbe,
|
|
788
|
+
browserSession,
|
|
635
789
|
widths,
|
|
790
|
+
evidenceRoot,
|
|
791
|
+
requireBrowserEvidence = false,
|
|
792
|
+
onProbeResult,
|
|
793
|
+
setPlan,
|
|
636
794
|
}) {
|
|
637
795
|
if (!Array.isArray(artifacts) || artifacts.length === 0) {
|
|
638
796
|
throw new TypeError('Render QA requires at least one artifact.');
|
|
639
797
|
}
|
|
640
798
|
|
|
641
|
-
const
|
|
799
|
+
const artifactsWithCohesion = cohesionEvidenceFromLedger(artifacts, setPlan);
|
|
800
|
+
const structural = artifactsWithCohesion.map((artifact) => ({
|
|
642
801
|
id: artifact.id,
|
|
643
802
|
...checkHtmlStructure({ ...artifact, denylist }),
|
|
644
803
|
}));
|
|
645
|
-
const cohesion = checkArtifactCohesion(
|
|
646
|
-
|
|
804
|
+
const cohesion = checkArtifactCohesion(artifactsWithCohesion, {
|
|
805
|
+
...(setPlan?.recipe?.id === 'project-recap' &&
|
|
806
|
+
Object.values(setPlan.ledger ?? {}).some(
|
|
807
|
+
(entries) => Array.isArray(entries) && entries.length > 0,
|
|
808
|
+
) && {
|
|
809
|
+
ledger: setPlan.ledger,
|
|
810
|
+
}),
|
|
811
|
+
});
|
|
812
|
+
const browserProvider = browserSession ?? browserProbe;
|
|
813
|
+
const browser = browserProvider
|
|
647
814
|
? await runBrowserProbes({
|
|
648
815
|
artifacts,
|
|
649
|
-
probe: browserProbe,
|
|
816
|
+
...(browserSession ? { browserSession } : { probe: browserProbe }),
|
|
650
817
|
...(widths && { widths }),
|
|
818
|
+
...(evidenceRoot && { evidenceRoot }),
|
|
819
|
+
...(requireBrowserEvidence && { requireEvidence: true }),
|
|
820
|
+
...(onProbeResult !== undefined && { onProbeResult }),
|
|
651
821
|
})
|
|
652
822
|
: null;
|
|
653
823
|
const issues = [
|
|
@@ -665,6 +835,108 @@ export async function auditArtifactSet({
|
|
|
665
835
|
};
|
|
666
836
|
}
|
|
667
837
|
|
|
838
|
+
async function retainBrowserEvidence({
|
|
839
|
+
evidenceRoot,
|
|
840
|
+
artifactId: artifactIdentifier,
|
|
841
|
+
viewport,
|
|
842
|
+
viewportSize,
|
|
843
|
+
screenshotPath,
|
|
844
|
+
metricsPath,
|
|
845
|
+
result,
|
|
846
|
+
browserSession,
|
|
847
|
+
}) {
|
|
848
|
+
let screenshot;
|
|
849
|
+
try {
|
|
850
|
+
screenshot = await stat(join(evidenceRoot, screenshotPath));
|
|
851
|
+
} catch {
|
|
852
|
+
return {
|
|
853
|
+
valid: false,
|
|
854
|
+
message: `Browser screenshot evidence is missing for ${artifactIdentifier} at ${viewportSize.width}px.`,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
if (
|
|
858
|
+
!screenshot.isFile() ||
|
|
859
|
+
screenshot.size === 0 ||
|
|
860
|
+
screenshot.size > MAX_SCREENSHOT_BYTES
|
|
861
|
+
) {
|
|
862
|
+
return {
|
|
863
|
+
valid: false,
|
|
864
|
+
message: `Browser screenshot evidence for ${artifactIdentifier} at ${viewportSize.width}px is empty or exceeds ${MAX_SCREENSHOT_BYTES} bytes.`,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
const screenshotBytes = await readFile(join(evidenceRoot, screenshotPath));
|
|
868
|
+
const decoded = decodedPng(screenshotBytes);
|
|
869
|
+
if (
|
|
870
|
+
!decoded ||
|
|
871
|
+
decoded.width !== viewportSize.width ||
|
|
872
|
+
decoded.height !== viewportSize.height
|
|
873
|
+
) {
|
|
874
|
+
return {
|
|
875
|
+
valid: false,
|
|
876
|
+
message: `Browser screenshot evidence for ${artifactIdentifier} at ${viewportSize.width}px must be a viewport-matched PNG.`,
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
await writeJsonAtomic(evidenceRoot, metricsPath, {
|
|
880
|
+
schemaVersion: 'explainer-kit.browser-evidence/v2',
|
|
881
|
+
artifactId: artifactIdentifier,
|
|
882
|
+
viewport,
|
|
883
|
+
viewportSize,
|
|
884
|
+
scenario: 'default',
|
|
885
|
+
runtime: structuredClone(browserSession.runtime),
|
|
886
|
+
capture: structuredClone(browserSession.capture),
|
|
887
|
+
captureIdentity: browserSession.captureIdentity,
|
|
888
|
+
screenshotPath,
|
|
889
|
+
metrics: structuredClone(result),
|
|
890
|
+
});
|
|
891
|
+
return {
|
|
892
|
+
valid: true,
|
|
893
|
+
evidence: {
|
|
894
|
+
artifactId: artifactIdentifier,
|
|
895
|
+
viewport,
|
|
896
|
+
width: viewportSize.width,
|
|
897
|
+
height: viewportSize.height,
|
|
898
|
+
screenshotPath,
|
|
899
|
+
decodedScreenshotHash: decoded.decodedHash,
|
|
900
|
+
metricsPath,
|
|
901
|
+
runtime: structuredClone(browserSession.runtime),
|
|
902
|
+
captureIdentity: browserSession.captureIdentity,
|
|
903
|
+
},
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
export function pngDimensions(bytes) {
|
|
908
|
+
const decoded = decodedPng(bytes);
|
|
909
|
+
return decoded ? { width: decoded.width, height: decoded.height } : null;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function decodedPng(bytes) {
|
|
913
|
+
try {
|
|
914
|
+
return decodeBrowserPng(bytes);
|
|
915
|
+
} catch {
|
|
916
|
+
return null;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function browserEvidenceId(value) {
|
|
921
|
+
if (
|
|
922
|
+
typeof value !== 'string' ||
|
|
923
|
+
!/^[a-z0-9]+(?:[._-][a-z0-9]+)*$/.test(value)
|
|
924
|
+
) {
|
|
925
|
+
throw new TypeError('Browser evidence requires a safe artifact id.');
|
|
926
|
+
}
|
|
927
|
+
return value;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function viewportName(width) {
|
|
931
|
+
return (
|
|
932
|
+
{
|
|
933
|
+
320: 'mobile',
|
|
934
|
+
768: 'tablet',
|
|
935
|
+
1440: 'desktop',
|
|
936
|
+
}[width] ?? `viewport-${width}`
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
|
|
668
940
|
function checkHeadings(html, add) {
|
|
669
941
|
const headings = [
|
|
670
942
|
...html.matchAll(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1\s*>/gi),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
|
|
3
3
|
const RECIPE_SCHEMA_V2 = 'explainer-kit.recipe/v2';
|
|
4
|
-
const
|
|
4
|
+
const RECIPE_V2_REQUIRED_ROOT_KEYS = [
|
|
5
5
|
'discoveryLimits',
|
|
6
6
|
'expansion',
|
|
7
7
|
'floor',
|
|
@@ -10,6 +10,7 @@ const RECIPE_V2_ROOT_KEYS = [
|
|
|
10
10
|
'sourceRoles',
|
|
11
11
|
'version',
|
|
12
12
|
];
|
|
13
|
+
const RECIPE_V2_ROOT_KEYS = [...RECIPE_V2_REQUIRED_ROOT_KEYS, 'fallback'];
|
|
13
14
|
const SOURCE_ROLE_KEYS = [
|
|
14
15
|
'accepts',
|
|
15
16
|
'maxBindings',
|
|
@@ -34,9 +35,14 @@ const PROFILE_REQUIRED_KEYS = [
|
|
|
34
35
|
'profileId',
|
|
35
36
|
'type',
|
|
36
37
|
];
|
|
37
|
-
const PROFILE_KEYS = [
|
|
38
|
+
const PROFILE_KEYS = [
|
|
39
|
+
...PROFILE_REQUIRED_KEYS,
|
|
40
|
+
'allowedJustificationKinds',
|
|
41
|
+
'shell',
|
|
42
|
+
];
|
|
38
43
|
const EXPANSION_LIMIT_KEYS = ['maxArtifacts', 'maxPerType'];
|
|
39
44
|
const DISCOVERY_LIMIT_KEYS = ['consecutiveNoNewFindingsRounds', 'maxRounds'];
|
|
45
|
+
const FALLBACK_KEYS = ['authoring', 'mode', 'scope', 'selection'];
|
|
40
46
|
const SOURCE_KINDS = new Set([
|
|
41
47
|
'file',
|
|
42
48
|
'directory',
|
|
@@ -81,6 +87,54 @@ export function loadRecipe(id, version) {
|
|
|
81
87
|
return structuredClone(recipe);
|
|
82
88
|
}
|
|
83
89
|
|
|
90
|
+
export function selectRecipeAuthoring(recipe, mode = 'artistic') {
|
|
91
|
+
const selected = structuredClone(recipe);
|
|
92
|
+
if (mode === 'artistic') {
|
|
93
|
+
return selected;
|
|
94
|
+
}
|
|
95
|
+
if (selected.fallback?.mode !== mode) {
|
|
96
|
+
const error = new Error(
|
|
97
|
+
`Recipe ${selected.id}@${selected.version} does not support recap mode ${mode}`,
|
|
98
|
+
);
|
|
99
|
+
error.code = 'E_RECIPE_UNSUPPORTED';
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
selected.floor = selected.floor.map((artifact) => ({
|
|
104
|
+
...artifact,
|
|
105
|
+
authoring: selected.fallback.authoring,
|
|
106
|
+
}));
|
|
107
|
+
selected.expansion.profiles = selected.expansion.profiles.map(
|
|
108
|
+
({ shell: _shell, ...profile }) => ({
|
|
109
|
+
...profile,
|
|
110
|
+
authoring: selected.fallback.authoring,
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
return selected;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function resolveDiagramRenderingRoute(recipe, artifact, diagrams) {
|
|
117
|
+
if (
|
|
118
|
+
!recipe ||
|
|
119
|
+
typeof recipe !== 'object' ||
|
|
120
|
+
!artifact ||
|
|
121
|
+
typeof artifact !== 'object' ||
|
|
122
|
+
!Array.isArray(diagrams)
|
|
123
|
+
) {
|
|
124
|
+
throw new TypeError(
|
|
125
|
+
'Diagram routing requires a recipe, artifact, and diagram analyses.',
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const supported = diagrams.filter(({ valid }) => valid === true);
|
|
129
|
+
if (
|
|
130
|
+
supported.length === 0 ||
|
|
131
|
+
supported.every(({ inlineSupported }) => inlineSupported === true)
|
|
132
|
+
) {
|
|
133
|
+
return 'inline';
|
|
134
|
+
}
|
|
135
|
+
return artifact.authoring === 'html' ? 'artistic' : 'reject';
|
|
136
|
+
}
|
|
137
|
+
|
|
84
138
|
export function recipeFloor(recipe) {
|
|
85
139
|
if (recipe?.schemaVersion === RECIPE_SCHEMA_V2) {
|
|
86
140
|
return structuredClone(recipe.floor);
|
|
@@ -304,6 +358,54 @@ export function validateContentModel(recipe, contentModel) {
|
|
|
304
358
|
return { valid: errors.length === 0, errors };
|
|
305
359
|
}
|
|
306
360
|
|
|
361
|
+
export function validatePlannedPortfolio(recipe, portfolio) {
|
|
362
|
+
const errors = [];
|
|
363
|
+
if (!Array.isArray(portfolio)) {
|
|
364
|
+
return { valid: false, errors: ['Planned portfolio must be an array'] };
|
|
365
|
+
}
|
|
366
|
+
const floorIds = new Set(recipeFloor(recipe).map(({ id }) => id));
|
|
367
|
+
const expansion = recipeExpansion(recipe);
|
|
368
|
+
const profiles = new Map(
|
|
369
|
+
expansion.profiles.map((profile) => [profile.profileId, profile]),
|
|
370
|
+
);
|
|
371
|
+
const counts = new Map();
|
|
372
|
+
let optionalCount = 0;
|
|
373
|
+
|
|
374
|
+
for (const artifact of portfolio) {
|
|
375
|
+
if (floorIds.has(artifact?.artifactId)) continue;
|
|
376
|
+
optionalCount += 1;
|
|
377
|
+
const profile = profiles.get(artifact?.profileId);
|
|
378
|
+
if (!profile) {
|
|
379
|
+
errors.push(`Unknown planned profile: ${artifact?.profileId}`);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
counts.set(profile.profileId, (counts.get(profile.profileId) ?? 0) + 1);
|
|
383
|
+
if (!isObject(artifact.justification)) {
|
|
384
|
+
errors.push(
|
|
385
|
+
`Optional artifact ${artifact.artifactId} requires a source-backed justification`,
|
|
386
|
+
);
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (
|
|
390
|
+
profile.allowedJustificationKinds !== undefined &&
|
|
391
|
+
!profile.allowedJustificationKinds.includes(artifact.justification.kind)
|
|
392
|
+
) {
|
|
393
|
+
errors.push(
|
|
394
|
+
`Optional artifact ${artifact.artifactId} justification ${artifact.justification.kind} is not allowed for ${profile.profileId}`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (optionalCount > expansion.limits.maxArtifacts) {
|
|
399
|
+
errors.push('Planned portfolio exceeds the recipe optional-artifact limit');
|
|
400
|
+
}
|
|
401
|
+
for (const [profileId, count] of counts) {
|
|
402
|
+
if (count > profiles.get(profileId).maxCount) {
|
|
403
|
+
errors.push(`Planned portfolio exceeds the ${profileId} profile limit`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return { valid: errors.length === 0, errors };
|
|
407
|
+
}
|
|
408
|
+
|
|
307
409
|
export function shouldStopDiscovery(recipe, findingsByRound) {
|
|
308
410
|
if (
|
|
309
411
|
!Array.isArray(findingsByRound) ||
|
|
@@ -329,7 +431,12 @@ export function shouldStopDiscovery(recipe, findingsByRound) {
|
|
|
329
431
|
export function validateRecipe(recipe, file = 'recipe') {
|
|
330
432
|
assertObject(recipe, `${file} recipe`);
|
|
331
433
|
if (recipe.schemaVersion === RECIPE_SCHEMA_V2) {
|
|
332
|
-
|
|
434
|
+
assertAllowedKeys(
|
|
435
|
+
recipe,
|
|
436
|
+
RECIPE_V2_ROOT_KEYS,
|
|
437
|
+
RECIPE_V2_REQUIRED_ROOT_KEYS,
|
|
438
|
+
`${file} recipe`,
|
|
439
|
+
);
|
|
333
440
|
validateV2Shape(recipe, file);
|
|
334
441
|
} else {
|
|
335
442
|
assert(false, `${file} has unsupported schemaVersion`);
|
|
@@ -368,6 +475,22 @@ export function validateRecipe(recipe, file = 'recipe') {
|
|
|
368
475
|
}
|
|
369
476
|
assertUnique(roleNames, `${file} source role names`);
|
|
370
477
|
|
|
478
|
+
if ('fallback' in recipe) {
|
|
479
|
+
assert(
|
|
480
|
+
recipe.id === 'project-recap',
|
|
481
|
+
`${file} fallback is allowed only for project-recap`,
|
|
482
|
+
);
|
|
483
|
+
assertObject(recipe.fallback, `${file} fallback`);
|
|
484
|
+
assertExactKeys(recipe.fallback, FALLBACK_KEYS, `${file} fallback`);
|
|
485
|
+
assert(
|
|
486
|
+
recipe.fallback.mode === 'deterministic-markdown' &&
|
|
487
|
+
recipe.fallback.selection === 'explicit' &&
|
|
488
|
+
recipe.fallback.authoring === 'markdown' &&
|
|
489
|
+
recipe.fallback.scope === 'portfolio',
|
|
490
|
+
`${file} has an unsupported fallback policy`,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
371
494
|
validateDiscoveryLimits(recipe.discoveryLimits, file);
|
|
372
495
|
return recipe;
|
|
373
496
|
}
|
|
@@ -435,6 +558,12 @@ function validateV2Shape(recipe, file) {
|
|
|
435
558
|
`${file} expansion profile briefRef`,
|
|
436
559
|
);
|
|
437
560
|
assertFiniteCount(profile.maxCount, `${file} expansion profile maxCount`);
|
|
561
|
+
if ('allowedJustificationKinds' in profile) {
|
|
562
|
+
assertUniqueNonEmptyStrings(
|
|
563
|
+
profile.allowedJustificationKinds,
|
|
564
|
+
`${file} expansion profile allowedJustificationKinds`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
438
567
|
if (profile.authoring === 'html' || 'shell' in profile) {
|
|
439
568
|
assertNonEmptyString(
|
|
440
569
|
profile.shell,
|