stylegallery 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +156 -0
- package/CATALOG.md +69 -0
- package/DOMAINS.md +65 -0
- package/GOVERNANCE.md +222 -0
- package/GUIDE.md +59 -0
- package/README.md +313 -0
- package/consumer-reference/adapters/style-dictionary.config.mjs +36 -0
- package/consumer-reference/agent-native/README.md +157 -0
- package/consumer-reference/agent-native/registry.json +640 -0
- package/consumer-reference/agent-native/schema/agent-native.schema.json +848 -0
- package/consumer-reference/agent-native/schema/epistemic.schema.json +14 -0
- package/consumer-reference/agent-native/schema/execution.schema.json +12 -0
- package/consumer-reference/agent-native/schema/identity.schema.json +10 -0
- package/consumer-reference/agent-native/schema/learning.schema.json +12 -0
- package/consumer-reference/agent-native/schema/manifest.schema.json +6 -0
- package/consumer-reference/agent-native/schema/operation.schema.json +12 -0
- package/consumer-reference/agent-native/schema/protocol-binding.schema.json +9 -0
- package/consumer-reference/agent-native/schema/retrieval.schema.json +10 -0
- package/consumer-reference/baselines/calibration.json +260 -0
- package/consumer-reference/baselines/manifest.json +21 -0
- package/consumer-reference/contract.md +83 -0
- package/consumer-reference/fixtures/consumer-conformance/e2e-fixture.mjs +282 -0
- package/consumer-reference/fixtures/consumer-conformance/manifest.json +102 -0
- package/consumer-reference/fixtures/consumer-conformance/runtime-proof.mjs +32 -0
- package/consumer-reference/fixtures/consumer-conformance/valid-migration-result.json +18 -0
- package/consumer-reference/fixtures/consumer-conformance/valid-migration.json +142 -0
- package/consumer-reference/fixtures/invalid-stable-ended-support.json +19 -0
- package/consumer-reference/fixtures/promotion/invalid-blocking-evidence.json +26 -0
- package/consumer-reference/fixtures/promotion/invalid-count-only.json +29 -0
- package/consumer-reference/fixtures/promotion/invalid-missing-owner-migration.json +25 -0
- package/consumer-reference/fixtures/promotion/invalid-normative-bypass-no-regression.json +25 -0
- package/consumer-reference/fixtures/promotion/invalid-related-counted-independent.json +45 -0
- package/consumer-reference/fixtures/promotion/invalid-stable-ended-support.json +26 -0
- package/consumer-reference/fixtures/promotion/manifest.json +15 -0
- package/consumer-reference/fixtures/promotion/valid-deferred-example.json +67 -0
- package/consumer-reference/fixtures/promotion/valid-normative-bypass.json +67 -0
- package/consumer-reference/fixtures/token-portability/invalid-cycle.json +7 -0
- package/consumer-reference/fixtures/token-portability/invalid-dangling-alias.json +6 -0
- package/consumer-reference/fixtures/token-portability/invalid-extends.json +4 -0
- package/consumer-reference/fixtures/token-portability/invalid-json-pointer.json +7 -0
- package/consumer-reference/fixtures/token-portability/invalid-resolver.json +9 -0
- package/consumer-reference/fixtures/token-portability/invalid-type-mismatch.json +10 -0
- package/consumer-reference/fixtures/token-portability/invalid-unknown-reserved.json +6 -0
- package/consumer-reference/fixtures/token-portability/invalid-untested-type.json +6 -0
- package/consumer-reference/fixtures/token-portability/valid-border.json +14 -0
- package/consumer-reference/fixtures/token-portability/valid-reference.json +32 -0
- package/consumer-reference/fixtures/token-portability/valid-whole-alias.json +7 -0
- package/consumer-reference/fixtures/valid-deprecated.json +21 -0
- package/consumer-reference/fixtures/valid-experimental.json +19 -0
- package/consumer-reference/generated/manifest.json +40 -0
- package/consumer-reference/generated/tokens.css +12 -0
- package/consumer-reference/index.md +32 -0
- package/consumer-reference/policies/shared-experimental.json +37 -0
- package/consumer-reference/schema/ax-evidence.schema.json +30 -0
- package/consumer-reference/schema/baseline-manifest.schema.json +55 -0
- package/consumer-reference/schema/calibration-record.schema.json +190 -0
- package/consumer-reference/schema/capture-session.schema.json +161 -0
- package/consumer-reference/schema/component-state.schema.json +150 -0
- package/consumer-reference/schema/consumer-conformance-record.schema.json +365 -0
- package/consumer-reference/schema/dom-evidence.schema.json +38 -0
- package/consumer-reference/schema/evidence-record.schema.json +81 -0
- package/consumer-reference/schema/fixture-manifest.schema.json +32 -0
- package/consumer-reference/schema/item.schema.json +188 -0
- package/consumer-reference/schema/page-evidence-manifest.schema.json +216 -0
- package/consumer-reference/schema/page-evidence-session.schema.json +119 -0
- package/consumer-reference/schema/portable-tokens.schema.json +104 -0
- package/consumer-reference/schema/promotion-rfc.schema.json +219 -0
- package/consumer-reference/schema/runtime-evidence-manifest.schema.json +33 -0
- package/consumer-reference/schema/visual-evidence.schema.json +31 -0
- package/design-engineering/consumer-migration-readiness.md +124 -0
- package/design-engineering/index.md +28 -0
- package/design-engineering/interface-craft.md +107 -0
- package/design-engineering/reference-profiles/external-adaptation/index.md +12 -0
- package/design-engineering/reference-profiles/governed-local/editorial/components/button.component.json +125 -0
- package/design-engineering/reference-profiles/governed-local/editorial/evidence/button.evidence.json +3504 -0
- package/design-engineering/reference-profiles/governed-local/editorial/fixtures/button.fixture.json +65 -0
- package/design-engineering/reference-profiles/governed-local/editorial/generated/evidence-coverage.md +23 -0
- package/design-engineering/reference-profiles/governed-local/editorial/generated/keyboard-matrix.md +19 -0
- package/design-engineering/reference-profiles/governed-local/editorial/generated/state-matrix.md +21 -0
- package/design-engineering/reference-profiles/governed-local/editorial/local-foundations.json +12 -0
- package/design-engineering/reference-profiles/governed-local/editorial/profile.json +43 -0
- package/design-engineering/reference-profiles/governed-local/editorial/states/button.states.json +195 -0
- package/design-engineering/reference-profiles/governed-local/editorial/tokens.dtcg.json +17 -0
- package/design-engineering/reference-profiles/governed-local/index.md +26 -0
- package/design-engineering/reference-profiles/governed-local/terminal/components/button.component.json +125 -0
- package/design-engineering/reference-profiles/governed-local/terminal/evidence/button.evidence.json +3504 -0
- package/design-engineering/reference-profiles/governed-local/terminal/fixtures/button.fixture.json +65 -0
- package/design-engineering/reference-profiles/governed-local/terminal/generated/evidence-coverage.md +23 -0
- package/design-engineering/reference-profiles/governed-local/terminal/generated/keyboard-matrix.md +19 -0
- package/design-engineering/reference-profiles/governed-local/terminal/generated/state-matrix.md +21 -0
- package/design-engineering/reference-profiles/governed-local/terminal/local-foundations.json +12 -0
- package/design-engineering/reference-profiles/governed-local/terminal/profile.json +43 -0
- package/design-engineering/reference-profiles/governed-local/terminal/states/button.states.json +195 -0
- package/design-engineering/reference-profiles/governed-local/terminal/tokens.dtcg.json +17 -0
- package/design-engineering/reference-profiles/index.md +19 -0
- package/game-ui/classification.md +82 -0
- package/game-ui/index.md +35 -0
- package/game-ui/reference-record.md +62 -0
- package/game-ui/screen-hierarchy.md +107 -0
- package/game-ui/unity/architecture.md +166 -0
- package/game-ui/unity/cli-loop.md +84 -0
- package/game-ui/unity/data/unity-technologies-public-repositories.json +26654 -0
- package/game-ui/unity/org-wiki.md +146 -0
- package/game-ui/unity/repository-map.md +102 -0
- package/game-ui/unity/ui-systems.md +85 -0
- package/guides/decision-tree.md +149 -0
- package/guides/documentation-mode-taxonomy.md +157 -0
- package/guides/layout-brief.md +220 -0
- package/guides/vocabulary.md +148 -0
- package/guides/webpage-generation-workflow.md +185 -0
- package/index.md +36 -0
- package/layout/index.md +26 -0
- package/motion/index.md +24 -0
- package/motion/practice-reference.md +145 -0
- package/motion/review-workflow.md +86 -0
- package/motion/vocabulary.md +82 -0
- package/package.json +78 -0
- package/patterns/centering/center.md +87 -0
- package/patterns/centering/index.md +6 -0
- package/patterns/centering/super-center.md +89 -0
- package/patterns/containment/box.md +87 -0
- package/patterns/containment/clamped-card.md +86 -0
- package/patterns/containment/content-limiter.md +87 -0
- package/patterns/containment/fluid-styles.md +86 -0
- package/patterns/containment/index.md +8 -0
- package/patterns/grid-repetition/card-grid.md +88 -0
- package/patterns/grid-repetition/columns.md +87 -0
- package/patterns/grid-repetition/dense-grid.md +88 -0
- package/patterns/grid-repetition/grid-wrapper.md +89 -0
- package/patterns/grid-repetition/index.md +12 -0
- package/patterns/grid-repetition/masonry-approx.md +87 -0
- package/patterns/grid-repetition/page-grid.md +90 -0
- package/patterns/grid-repetition/ram-grid.md +88 -0
- package/patterns/grid-repetition/twelve-span-grid.md +92 -0
- package/patterns/in-line-grouping/badge-list.md +94 -0
- package/patterns/in-line-grouping/breadcrumb.md +89 -0
- package/patterns/in-line-grouping/cluster.md +89 -0
- package/patterns/in-line-grouping/deconstructed-pancake.md +92 -0
- package/patterns/in-line-grouping/index.md +13 -0
- package/patterns/in-line-grouping/pagination.md +89 -0
- package/patterns/in-line-grouping/reel.md +90 -0
- package/patterns/in-line-grouping/split-nav.md +97 -0
- package/patterns/in-line-grouping/tab-strip.md +88 -0
- package/patterns/in-line-grouping/wrap-row.md +89 -0
- package/patterns/index.md +13 -0
- package/patterns/media-fit/frame.md +95 -0
- package/patterns/media-fit/icon-frame.md +87 -0
- package/patterns/media-fit/index.md +6 -0
- package/patterns/overlay-exception/imposter.md +95 -0
- package/patterns/overlay-exception/index.md +6 -0
- package/patterns/overlay-exception/overlay-stack.md +89 -0
- package/patterns/split-sidebar/index.md +12 -0
- package/patterns/split-sidebar/list-detail.md +95 -0
- package/patterns/split-sidebar/main-with-rail.md +95 -0
- package/patterns/split-sidebar/media-object.md +102 -0
- package/patterns/split-sidebar/sidebar.md +101 -0
- package/patterns/split-sidebar/split-screen.md +92 -0
- package/patterns/split-sidebar/sticky-aside.md +97 -0
- package/patterns/split-sidebar/supporting-pane.md +95 -0
- package/patterns/split-sidebar/switcher.md +93 -0
- package/patterns/stacking/feed.md +88 -0
- package/patterns/stacking/index.md +8 -0
- package/patterns/stacking/line-up.md +95 -0
- package/patterns/stacking/stack.md +87 -0
- package/patterns/stacking/step-nav.md +88 -0
- package/patterns/viewport-shell/cover.md +90 -0
- package/patterns/viewport-shell/fixed-sidenav-shell.md +97 -0
- package/patterns/viewport-shell/holy-grail.md +112 -0
- package/patterns/viewport-shell/index.md +11 -0
- package/patterns/viewport-shell/panel-layout.md +95 -0
- package/patterns/viewport-shell/scroll-body-shell.md +93 -0
- package/patterns/viewport-shell/sticky-footer.md +92 -0
- package/patterns/viewport-shell/sticky-header.md +86 -0
- package/platform-guides/apple-interaction.md +89 -0
- package/platform-guides/index.md +26 -0
- package/quality/claim-records/index.md +3 -0
- package/quality/claim-records/samples.md +92 -0
- package/quality/claim-records/stylegallery-multidomain-scope.md +42 -0
- package/quality/claims.md +77 -0
- package/quality/evidence/accessibility.md +58 -0
- package/quality/evidence/consumer-migration.md +42 -0
- package/quality/evidence/design-rationale.md +34 -0
- package/quality/evidence/executable-evidence.md +79 -0
- package/quality/evidence/families.md +56 -0
- package/quality/evidence/hci-evaluation.md +36 -0
- package/quality/evidence/index.md +16 -0
- package/quality/evidence/pattern-contract-audit.md +96 -0
- package/quality/evidence/search-metadata-polyhierarchy.md +86 -0
- package/quality/evidence/sources.md +43 -0
- package/quality/evidence/tokens.md +33 -0
- package/quality/evidence/visual-qa.md +41 -0
- package/quality/gates/accessibility-evidence.md +60 -0
- package/quality/gates/consumer-migration-evidence.md +53 -0
- package/quality/gates/design-claim.md +56 -0
- package/quality/gates/harmony-evaluation.md +78 -0
- package/quality/gates/index.md +13 -0
- package/quality/gates/layout.md +52 -0
- package/quality/gates/rationale.md +53 -0
- package/quality/gates/visual-evidence.md +47 -0
- package/quality/index.md +55 -0
- package/quality/principles.md +58 -0
- package/recipes/article-page.md +52 -0
- package/recipes/command-surface.md +55 -0
- package/recipes/dashboard.md +56 -0
- package/recipes/form-flow.md +55 -0
- package/recipes/homepage.md +89 -0
- package/recipes/index.md +13 -0
- package/recipes/list-detail.md +55 -0
- package/recipes/primitive-to-recipe-matrix.md +83 -0
- package/recipes/saas-settings.md +52 -0
- package/scripts/agent-native/a2a-projection.mjs +248 -0
- package/scripts/agent-native/agui-projection.mjs +178 -0
- package/scripts/agent-native/canonical-json.mjs +109 -0
- package/scripts/agent-native/cli-adapter.mjs +68 -0
- package/scripts/agent-native/events.mjs +113 -0
- package/scripts/agent-native/execution.mjs +227 -0
- package/scripts/agent-native/fixture.mjs +159 -0
- package/scripts/agent-native/identity.mjs +184 -0
- package/scripts/agent-native/immutable-store.mjs +102 -0
- package/scripts/agent-native/knowledge.mjs +168 -0
- package/scripts/agent-native/learning-records.mjs +244 -0
- package/scripts/agent-native/learning.mjs +247 -0
- package/scripts/agent-native/mcp-adapter.mjs +173 -0
- package/scripts/agent-native/queries.mjs +173 -0
- package/scripts/agent-native/registry.mjs +154 -0
- package/scripts/agent-native/retrieval.mjs +171 -0
- package/scripts/agent-native/self-description.mjs +160 -0
- package/scripts/artifact-metadata.mjs +20 -0
- package/scripts/audit-evidence-freshness.mjs +308 -0
- package/scripts/baseline-contract.mjs +189 -0
- package/scripts/baseline-schema-parity.mjs +40 -0
- package/scripts/build-reference-artifacts.mjs +127 -0
- package/scripts/calibration-raw-contract.mjs +172 -0
- package/scripts/capture-session-contract.mjs +187 -0
- package/scripts/component-state-artifact-fixture.mjs +44 -0
- package/scripts/component-state-contract.mjs +99 -0
- package/scripts/component-state-semantics.mjs +128 -0
- package/scripts/component-state-workflow-contract.mjs +67 -0
- package/scripts/component-state-workflow-negative-cases.mjs +64 -0
- package/scripts/consumer-conformance-contract.mjs +198 -0
- package/scripts/consumer-evidence-governance-contract.mjs +210 -0
- package/scripts/consumer-reference-case-runner.mjs +93 -0
- package/scripts/consumer-reference-schema.mjs +155 -0
- package/scripts/create-component-state-session.mjs +100 -0
- package/scripts/create-page-evidence-session.mjs +139 -0
- package/scripts/domain-document-boundaries.mjs +40 -0
- package/scripts/domain-validation-checks.mjs +141 -0
- package/scripts/evidence-artifact-contract.mjs +191 -0
- package/scripts/finalize-component-state-evidence.mjs +165 -0
- package/scripts/finalize-page-evidence.mjs +197 -0
- package/scripts/generate-consumer-reference-evidence.mjs +154 -0
- package/scripts/generate-patterns.mjs +218 -0
- package/scripts/governance-matrix-contract.mjs +73 -0
- package/scripts/governance-matrix-negative-cases.mjs +114 -0
- package/scripts/governance-policy-contract.mjs +49 -0
- package/scripts/governance-test-fixture.mjs +321 -0
- package/scripts/governance-workflow-negative-cases.mjs +241 -0
- package/scripts/governed-profile-registry.mjs +125 -0
- package/scripts/json-schema-formats.mjs +31 -0
- package/scripts/markdown-structure.mjs +45 -0
- package/scripts/page-artifact-metadata.mjs +124 -0
- package/scripts/page-evidence-contract.mjs +297 -0
- package/scripts/page-evidence-fixture.mjs +271 -0
- package/scripts/pattern-data.mjs +325 -0
- package/scripts/profile-record-contract.mjs +140 -0
- package/scripts/promotion-attestation-contract.mjs +91 -0
- package/scripts/promotion-boundary-test-contract.mjs +92 -0
- package/scripts/promotion-fixture-inventory.mjs +109 -0
- package/scripts/promotion-governance-contract.mjs +28 -0
- package/scripts/reference-artifact-path-contract.mjs +28 -0
- package/scripts/reference-profile-contract.mjs +114 -0
- package/scripts/reference-token-contract.mjs +156 -0
- package/scripts/run-consumer-page-evidence-ci.mjs +170 -0
- package/scripts/sg-mcp.mjs +16 -0
- package/scripts/sg.mjs +27 -0
- package/scripts/strict-json.mjs +72 -0
- package/scripts/summarize-sentinel-calibration.mjs +98 -0
- package/scripts/test-agent-adapter-conformance.mjs +250 -0
- package/scripts/test-agent-conformance-receipt.mjs +106 -0
- package/scripts/test-agent-events.mjs +81 -0
- package/scripts/test-agent-execution-effects.mjs +114 -0
- package/scripts/test-agent-execution-fixtures.mjs +140 -0
- package/scripts/test-agent-execution-kernel.mjs +39 -0
- package/scripts/test-agent-execution-learning.mjs +130 -0
- package/scripts/test-agent-execution-lifecycle.mjs +143 -0
- package/scripts/test-agent-execution-safety.mjs +158 -0
- package/scripts/test-agent-execution-views.mjs +117 -0
- package/scripts/test-agent-identity-integrity-cases.mjs +160 -0
- package/scripts/test-agent-knowledge-kernel.mjs +183 -0
- package/scripts/test-agent-learning.mjs +246 -0
- package/scripts/test-agent-mcp.mjs +246 -0
- package/scripts/test-agent-native-schemas.mjs +215 -0
- package/scripts/test-agent-projections.mjs +249 -0
- package/scripts/test-agent-registry-mcp-integrity.mjs +215 -0
- package/scripts/test-agent-registry-mutations.mjs +110 -0
- package/scripts/test-audit-evidence-freshness.mjs +177 -0
- package/scripts/test-component-state-sentinel.mjs +35 -0
- package/scripts/test-component-state-source-contract.mjs +83 -0
- package/scripts/test-consumer-conformance-sentinel.mjs +165 -0
- package/scripts/test-consumer-reference-sentinel.mjs +31 -0
- package/scripts/test-generate-consumer-reference-evidence.mjs +124 -0
- package/scripts/test-json-cli-output.mjs +151 -0
- package/scripts/test-json-schema-formats.mjs +40 -0
- package/scripts/test-reference-adapters.mjs +199 -0
- package/scripts/test-renderer-purity.mjs +34 -0
- package/scripts/test-sg-cli.mjs +129 -0
- package/scripts/test-summarize-sentinel-calibration.mjs +226 -0
- package/scripts/test-validate-baseline-manifest.mjs +234 -0
- package/scripts/test-validate-component-state-artifacts.mjs +243 -0
- package/scripts/test-validate-component-state.mjs +106 -0
- package/scripts/test-validate-consumer-conformance.mjs +427 -0
- package/scripts/test-validate-consumer-reference-cli.mjs +69 -0
- package/scripts/test-validate-consumer-reference.mjs +263 -0
- package/scripts/test-validate-domains.mjs +357 -0
- package/scripts/test-validate-governance.mjs +292 -0
- package/scripts/test-validate-ia.mjs +262 -0
- package/scripts/test-validate-okf.mjs +73 -0
- package/scripts/test-validate-page-evidence.mjs +302 -0
- package/scripts/test-validate-patterns.mjs +157 -0
- package/scripts/test-validate-promotion-rfc.mjs +250 -0
- package/scripts/test-validate-reference-profiles.mjs +170 -0
- package/scripts/test-validate-unity-org-wiki.mjs +57 -0
- package/scripts/test-validate-unity-source-contracts.mjs +67 -0
- package/scripts/test-validate-vocabulary.mjs +174 -0
- package/scripts/test-validate-webpage-workflow.mjs +293 -0
- package/scripts/validate-baseline-manifest.mjs +44 -0
- package/scripts/validate-catalog.mjs +65 -0
- package/scripts/validate-component-state.mjs +141 -0
- package/scripts/validate-consumer-conformance.mjs +477 -0
- package/scripts/validate-consumer-reference.mjs +288 -0
- package/scripts/validate-domains.mjs +237 -0
- package/scripts/validate-governance.mjs +248 -0
- package/scripts/validate-ia.mjs +136 -0
- package/scripts/validate-links.mjs +66 -0
- package/scripts/validate-okf.mjs +104 -0
- package/scripts/validate-page-evidence.mjs +191 -0
- package/scripts/validate-patterns.mjs +246 -0
- package/scripts/validate-promotion-rfc.mjs +224 -0
- package/scripts/validate-reference-artifacts.mjs +125 -0
- package/scripts/validate-renderer-purity.mjs +56 -0
- package/scripts/validate-unity-org-wiki.mjs +146 -0
- package/scripts/validate-unity-source-contracts.mjs +107 -0
- package/scripts/validate-vocabulary.mjs +115 -0
- package/scripts/validate-webpage-workflow.mjs +254 -0
- package/scripts/visual-expectation-contract.mjs +20 -0
- package/scripts/workflow-action-contract.mjs +163 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import Ajv2020 from "ajv/dist/2020.js";
|
|
7
|
+
import { addDateTimeFormat, isRfc3339DateTime } from "./json-schema-formats.mjs";
|
|
8
|
+
import { parseStrictJson } from "./strict-json.mjs";
|
|
9
|
+
import { validateCalibration } from "./baseline-contract.mjs";
|
|
10
|
+
|
|
11
|
+
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
|
+
const calibrationSchemaPath = path.join(repositoryRoot, "consumer-reference", "schema", "calibration-record.schema.json");
|
|
13
|
+
const defaultPageSchemaPath = path.join(repositoryRoot, "consumer-reference", "schema", "page-evidence-manifest.schema.json");
|
|
14
|
+
const PAGE_RECORD_KIND = "page_evidence_manifest";
|
|
15
|
+
// This is an advisory warning window only. It never changes an explicit expiry or adds a TTL.
|
|
16
|
+
const DEFAULT_EXPIRING_WINDOW_DAYS = 7;
|
|
17
|
+
|
|
18
|
+
function failure(code, sourcePath, message, details = undefined) {
|
|
19
|
+
return details === undefined ? { code, message, path: sourcePath } : { code, message, path: sourcePath, details };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function warning(code, sourcePath, status, thresholdDate, asOf, message) {
|
|
23
|
+
return { asOf, code, message, source_path: sourcePath, status, threshold_date: thresholdDate };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isRecord(value) {
|
|
27
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseDateTime(value) {
|
|
31
|
+
return isRfc3339DateTime(value) ? Date.parse(value) : NaN;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isInside(base, target) {
|
|
35
|
+
const relative = path.relative(path.resolve(base), path.resolve(target));
|
|
36
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function displayPath(file, root) {
|
|
40
|
+
const absolute = path.resolve(file);
|
|
41
|
+
if (isInside(root, absolute)) return path.relative(root, absolute).split(path.sep).join("/") || ".";
|
|
42
|
+
return absolute;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readRecord(file, root, failures) {
|
|
46
|
+
const sourcePath = displayPath(file, root);
|
|
47
|
+
let stat;
|
|
48
|
+
try {
|
|
49
|
+
stat = fs.lstatSync(file);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
failures.push(failure("freshness_record_unreadable", sourcePath, `record cannot be read: ${error instanceof Error ? error.message : String(error)}`));
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
55
|
+
failures.push(failure("freshness_record_unreadable", sourcePath, "record must be a regular non-symlink file"));
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
if (stat.size > 1024 * 1024) {
|
|
59
|
+
failures.push(failure("freshness_record_unreadable", sourcePath, "record must not exceed 1 MiB"));
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
return { sourcePath, value: parseStrictJson(fs.readFileSync(file, "utf8")) };
|
|
64
|
+
} catch (error) {
|
|
65
|
+
failures.push(failure("freshness_record_invalid", sourcePath, error instanceof Error ? error.message : String(error)));
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readSchema(schemaPath, sourcePath, failures) {
|
|
71
|
+
let stat;
|
|
72
|
+
try {
|
|
73
|
+
stat = fs.lstatSync(schemaPath);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
failures.push(failure("freshness_schema_missing", sourcePath, `schema cannot be read: ${error instanceof Error ? error.message : String(error)}`));
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
79
|
+
failures.push(failure("freshness_schema_invalid", sourcePath, "schema must be a regular non-symlink file"));
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
return parseStrictJson(fs.readFileSync(schemaPath, "utf8"));
|
|
84
|
+
} catch (error) {
|
|
85
|
+
failures.push(failure("freshness_schema_invalid", sourcePath, error instanceof Error ? error.message : String(error)));
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function compileSchema(schema, schemaPath, sourcePath, failures, { strict = true } = {}) {
|
|
91
|
+
if (!isRecord(schema)) {
|
|
92
|
+
failures.push(failure("freshness_schema_invalid", sourcePath, "schema must be a JSON object"));
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const ajv = new Ajv2020({ allErrors: true, strict });
|
|
97
|
+
addDateTimeFormat(ajv);
|
|
98
|
+
const schemaDirectory = path.dirname(defaultPageSchemaPath);
|
|
99
|
+
if (path.resolve(schemaPath) === path.resolve(defaultPageSchemaPath) && fs.existsSync(schemaDirectory)) {
|
|
100
|
+
for (const entry of fs.readdirSync(schemaDirectory, { withFileTypes: true }).filter((item) => item.isFile() && item.name.endsWith(".schema.json"))) {
|
|
101
|
+
const dependencyPath = path.join(schemaDirectory, entry.name);
|
|
102
|
+
if (path.resolve(dependencyPath) === path.resolve(schemaPath)) continue;
|
|
103
|
+
try {
|
|
104
|
+
const dependency = parseStrictJson(fs.readFileSync(dependencyPath, "utf8"));
|
|
105
|
+
if (dependency?.$id) ajv.addSchema(dependency, dependency.$id);
|
|
106
|
+
} catch {
|
|
107
|
+
// Only schemas reachable from the selected page schema affect this compile.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (schema.$id) ajv.addSchema(schema, schema.$id);
|
|
112
|
+
else ajv.addSchema(schema, schemaPath);
|
|
113
|
+
return ajv.getSchema(schema.$id ?? schemaPath) ?? ajv.compile(schema);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
failures.push(failure("freshness_schema_invalid", sourcePath, error instanceof Error ? error.message : String(error)));
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function schemaFailures(validate, value, sourcePath) {
|
|
121
|
+
if (validate(value)) return [];
|
|
122
|
+
return (validate.errors ?? []).map((error) => failure(
|
|
123
|
+
"freshness_record_invalid",
|
|
124
|
+
sourcePath,
|
|
125
|
+
`${error.instancePath || "/"} ${error.message}`,
|
|
126
|
+
));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function validateCalibrationRecord(value, sourcePath, failures) {
|
|
130
|
+
const schema = readSchema(calibrationSchemaPath, sourcePath, failures);
|
|
131
|
+
const validate = schema === undefined ? undefined : compileSchema(schema, calibrationSchemaPath, sourcePath, failures, { strict: false });
|
|
132
|
+
if (validate) failures.push(...schemaFailures(validate, value, sourcePath));
|
|
133
|
+
for (const finding of validateCalibration(value, sourcePath)) {
|
|
134
|
+
failures.push(failure("freshness_record_invalid", sourcePath, finding.message, { code: finding.code }));
|
|
135
|
+
}
|
|
136
|
+
if (!isRecord(value)) return undefined;
|
|
137
|
+
const external = value.committed_ci?.external_verification;
|
|
138
|
+
if (external === null || external === undefined) return { thresholdDate: null, verifiedAt: null };
|
|
139
|
+
if (!isRecord(external) || !isRecord(external.artifact)) {
|
|
140
|
+
failures.push(failure("freshness_record_invalid", sourcePath, "calibration external verification must contain artifact metadata"));
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
const thresholdDate = external.artifact.expires_at;
|
|
144
|
+
const verifiedAt = external.verified_at;
|
|
145
|
+
if (!Number.isFinite(parseDateTime(thresholdDate)) || !Number.isFinite(parseDateTime(verifiedAt))) {
|
|
146
|
+
failures.push(failure("freshness_record_invalid", sourcePath, "calibration expiry and verification dates must be RFC 3339 date-time values"));
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
return { thresholdDate, verifiedAt };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function validatePageRecord(value, sourcePath, pageSchemaPath, failures) {
|
|
153
|
+
const schema = readSchema(pageSchemaPath, sourcePath, failures);
|
|
154
|
+
if (schema === undefined) return undefined;
|
|
155
|
+
const validate = compileSchema(schema, pageSchemaPath, sourcePath, failures, { strict: true });
|
|
156
|
+
if (validate) failures.push(...schemaFailures(validate, value, sourcePath));
|
|
157
|
+
if (!isRecord(value) || value.record_kind !== PAGE_RECORD_KIND) return undefined;
|
|
158
|
+
const thresholdDate = value.review_by;
|
|
159
|
+
if (!Number.isFinite(parseDateTime(thresholdDate))) {
|
|
160
|
+
failures.push(failure("freshness_record_invalid", sourcePath, "page evidence review_by must be an RFC 3339 date-time value"));
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
return { thresholdDate, verifiedAt: null };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function classify(entry, asOfMs, windowMs) {
|
|
167
|
+
if (entry.thresholdDate === null) return "fresh";
|
|
168
|
+
const thresholdMs = Date.parse(entry.thresholdDate);
|
|
169
|
+
if (entry.kind === PAGE_RECORD_KIND) {
|
|
170
|
+
if (asOfMs >= thresholdMs) return "review_due";
|
|
171
|
+
return thresholdMs - asOfMs <= windowMs ? "expiring" : "fresh";
|
|
172
|
+
}
|
|
173
|
+
if (asOfMs >= thresholdMs) return "expired";
|
|
174
|
+
return thresholdMs - asOfMs <= windowMs ? "expiring" : "fresh";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isBlockingStatus(status) {
|
|
178
|
+
return status === "expired" || status === "review_due";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function auditOne({ file, asOf, asOfMs, mode, root, pageSchemaPath, expiringWindowMs }) {
|
|
182
|
+
const failures = [];
|
|
183
|
+
const sourcePath = displayPath(file, root);
|
|
184
|
+
const loaded = readRecord(file, root, failures);
|
|
185
|
+
if (!loaded) return { failures, warnings: [], record: null };
|
|
186
|
+
const { value } = loaded;
|
|
187
|
+
if (!isRecord(value)) {
|
|
188
|
+
failures.push(failure("freshness_record_invalid", sourcePath, "record must be a JSON object"));
|
|
189
|
+
return { failures, warnings: [], record: null };
|
|
190
|
+
}
|
|
191
|
+
let kind;
|
|
192
|
+
let dates;
|
|
193
|
+
if (value.record_kind === PAGE_RECORD_KIND) {
|
|
194
|
+
kind = PAGE_RECORD_KIND;
|
|
195
|
+
dates = validatePageRecord(value, sourcePath, pageSchemaPath, failures);
|
|
196
|
+
} else if (value.committed_ci !== undefined || value.required_runs !== undefined || value.runs !== undefined) {
|
|
197
|
+
kind = "calibration_record";
|
|
198
|
+
dates = validateCalibrationRecord(value, sourcePath, failures);
|
|
199
|
+
} else {
|
|
200
|
+
failures.push(failure("freshness_record_invalid", sourcePath, "record_kind is not a supported evidence record"));
|
|
201
|
+
return { failures, warnings: [], record: null };
|
|
202
|
+
}
|
|
203
|
+
if (dates === undefined || failures.length > 0) return { failures, warnings: [], record: null };
|
|
204
|
+
const entry = { asOf, kind, source_path: sourcePath, threshold_date: dates.thresholdDate, verified_at: dates.verifiedAt };
|
|
205
|
+
const status = classify({ kind, thresholdDate: dates.thresholdDate }, asOfMs, expiringWindowMs);
|
|
206
|
+
const result = { ...entry, status };
|
|
207
|
+
const warnings = [];
|
|
208
|
+
if (status !== "fresh") {
|
|
209
|
+
const code = status === "expiring" ? "evidence_expiring" : status === "expired" ? "evidence_expired" : "evidence_review_due";
|
|
210
|
+
const message = status === "expiring"
|
|
211
|
+
? `evidence expiry is within the advisory window (${dates.thresholdDate})`
|
|
212
|
+
: status === "expired"
|
|
213
|
+
? `evidence expired at ${dates.thresholdDate}`
|
|
214
|
+
: `evidence review is due at ${dates.thresholdDate}`;
|
|
215
|
+
warnings.push(warning(code, sourcePath, status, dates.thresholdDate, asOf, message));
|
|
216
|
+
if (mode === "blocking" && isBlockingStatus(status)) failures.push(failure(code, sourcePath, message, { asOf, threshold_date: dates.thresholdDate }));
|
|
217
|
+
}
|
|
218
|
+
return { failures, record: result, warnings };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function parseArguments(argv = process.argv.slice(2)) {
|
|
222
|
+
const options = { asOf: null, expiringWindowDays: DEFAULT_EXPIRING_WINDOW_DAYS, json: false, mode: "advisory", pageSchema: defaultPageSchemaPath, records: [] };
|
|
223
|
+
const failures = [];
|
|
224
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
225
|
+
const argument = argv[index];
|
|
226
|
+
if (argument === "--json") {
|
|
227
|
+
options.json = true;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (["--record", "--as-of", "--mode", "--page-schema", "--expiring-within-days"].includes(argument)) {
|
|
231
|
+
const value = argv[index + 1];
|
|
232
|
+
if (!value || value.startsWith("--")) {
|
|
233
|
+
failures.push(failure("argument_value_required", "<cli>", `${argument} requires a value`));
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
index += 1;
|
|
237
|
+
if (argument === "--record") options.records.push(value);
|
|
238
|
+
else if (argument === "--as-of") options.asOf = value;
|
|
239
|
+
else if (argument === "--mode") options.mode = value;
|
|
240
|
+
else if (argument === "--page-schema") options.pageSchema = path.resolve(process.cwd(), value);
|
|
241
|
+
else options.expiringWindowDays = Number(value);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
failures.push(failure("argument_unknown", "<cli>", `unsupported argument ${argument}`));
|
|
245
|
+
}
|
|
246
|
+
if (options.records.length === 0) failures.push(failure("record_required", "<cli>", "at least one --record is required"));
|
|
247
|
+
if (options.asOf === null) failures.push(failure("as_of_required", "<cli>", "--as-of is required for deterministic freshness auditing"));
|
|
248
|
+
else if (!Number.isFinite(parseDateTime(options.asOf))) failures.push(failure("as_of_invalid", "<cli>", "--as-of must be an RFC 3339 date-time value"));
|
|
249
|
+
if (!["advisory", "blocking"].includes(options.mode)) failures.push(failure("mode_invalid", "<cli>", "--mode must be advisory or blocking"));
|
|
250
|
+
if (!Number.isFinite(options.expiringWindowDays) || options.expiringWindowDays < 0 || options.expiringWindowDays > 3650) failures.push(failure("expiring_window_invalid", "<cli>", "--expiring-within-days must be a number from 0 through 3650"));
|
|
251
|
+
return { failures, options };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function auditEvidenceFreshness({ records, asOf, mode = "advisory", root = repositoryRoot, pageSchema = defaultPageSchemaPath, expiringWindowDays = DEFAULT_EXPIRING_WINDOW_DAYS }) {
|
|
255
|
+
const asOfMs = parseDateTime(asOf);
|
|
256
|
+
const failures = [];
|
|
257
|
+
const warnings = [];
|
|
258
|
+
const entries = [];
|
|
259
|
+
if (!Number.isFinite(asOfMs)) failures.push(failure("as_of_invalid", "<cli>", "asOf must be an RFC 3339 date-time value"));
|
|
260
|
+
for (const record of records) {
|
|
261
|
+
const file = path.resolve(root, record);
|
|
262
|
+
const result = auditOne({ asOf, asOfMs, expiringWindowMs: expiringWindowDays * 24 * 60 * 60 * 1000, file, mode, pageSchemaPath: pageSchema, root });
|
|
263
|
+
failures.push(...result.failures);
|
|
264
|
+
warnings.push(...result.warnings);
|
|
265
|
+
if (result.record) entries.push(result.record);
|
|
266
|
+
}
|
|
267
|
+
const uniqueFailures = [...new Map(failures.map((item) => [`${item.code}:${item.path}:${item.message}`, item])).values()];
|
|
268
|
+
const uniqueWarnings = [...new Map(warnings.map((item) => [`${item.code}:${item.source_path}:${item.threshold_date}`, item])).values()];
|
|
269
|
+
return {
|
|
270
|
+
asOf,
|
|
271
|
+
expiring_window_days: expiringWindowDays,
|
|
272
|
+
failures: uniqueFailures,
|
|
273
|
+
mode,
|
|
274
|
+
ok: uniqueFailures.length === 0,
|
|
275
|
+
records: entries,
|
|
276
|
+
warnings: uniqueWarnings,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function runCli(argv = process.argv.slice(2), root = repositoryRoot) {
|
|
281
|
+
const parsed = parseArguments(argv);
|
|
282
|
+
if (parsed.failures.length > 0) return { asOf: parsed.options.asOf, failures: parsed.failures, mode: parsed.options.mode, ok: false, records: [], warnings: [] };
|
|
283
|
+
return auditEvidenceFreshness({
|
|
284
|
+
asOf: parsed.options.asOf,
|
|
285
|
+
expiringWindowDays: parsed.options.expiringWindowDays,
|
|
286
|
+
mode: parsed.options.mode,
|
|
287
|
+
pageSchema: parsed.options.pageSchema,
|
|
288
|
+
records: parsed.options.records,
|
|
289
|
+
root,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
294
|
+
try {
|
|
295
|
+
const parsed = parseArguments();
|
|
296
|
+
const report = parsed.failures.length > 0
|
|
297
|
+
? { asOf: parsed.options.asOf, failures: parsed.failures, mode: parsed.options.mode, ok: false, records: [], warnings: [] }
|
|
298
|
+
: runCli();
|
|
299
|
+
if (parsed.options.json || parsed.failures.length > 0) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
300
|
+
else if (report.ok) process.stdout.write(`ok: audited ${report.records.length} evidence record(s)\n`);
|
|
301
|
+
else process.stderr.write(`${report.failures.map((item) => `${item.code}: ${item.message}`).join("\n")}\n`);
|
|
302
|
+
process.exitCode = report.ok ? 0 : 1;
|
|
303
|
+
} catch (error) {
|
|
304
|
+
const report = { failures: [failure("freshness_tool_error", "<tool>", error instanceof Error ? error.message : String(error))], ok: false, records: [], warnings: [] };
|
|
305
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
306
|
+
process.exitCode = 1;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import { parseStrictJson } from "./strict-json.mjs";
|
|
6
|
+
|
|
7
|
+
export const BASELINE_ENVIRONMENT = {
|
|
8
|
+
architecture: "amd64",
|
|
9
|
+
browser_revision: "1228",
|
|
10
|
+
browser_version: "149.0.7827.55",
|
|
11
|
+
container_image: "mcr.microsoft.com/playwright:v1.61.0-noble@sha256:57b65fdc9ceabe0ef613124c7bbe2babcf9362c4d85e382fe3b03604e84b428a",
|
|
12
|
+
node: "22",
|
|
13
|
+
os: "Ubuntu 24.04",
|
|
14
|
+
platform: "linux/amd64",
|
|
15
|
+
playwright: "1.61.0",
|
|
16
|
+
viewport: { height: 768, width: 1024 },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const BASELINE_REFERENCE = {
|
|
20
|
+
baseline: { path: "tests/snapshots/consumer-reference-card-grid.png", sha256: "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837" },
|
|
21
|
+
source: { path: "tests/helpers/render-consumer-reference.mjs", sha256: "88802a948909d5e40470be6b5481766ce2de498e59c053ac68af370b46e72ca9" },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const BASELINE_METADATA_SHA256 = sha256(JSON.stringify({ environment: BASELINE_ENVIRONMENT, reference: BASELINE_REFERENCE }));
|
|
25
|
+
export const CALIBRATION_CANONICAL_REPOSITORY = "changeroa/StyleGallery";
|
|
26
|
+
export const CALIBRATION_EXECUTION_REPOSITORIES = Object.freeze(["ark-jo/StyleGallery", CALIBRATION_CANONICAL_REPOSITORY]);
|
|
27
|
+
export const CALIBRATION_EXTERNAL_VERIFICATION = {
|
|
28
|
+
artifact: {
|
|
29
|
+
api_digest: "sha256:3f11a517b447e1b5a1da17d9ee66ba2dd947fc8a0a482c447556ef00652e6074",
|
|
30
|
+
expires_at: "2026-07-27T15:01:36Z",
|
|
31
|
+
id: "8283099324",
|
|
32
|
+
name: "chromium-sentinel-calibration-29260372260-18229be570766d3b42f5600955120bfcba690b76",
|
|
33
|
+
size_in_bytes: 70310,
|
|
34
|
+
},
|
|
35
|
+
repository_relationship: {
|
|
36
|
+
execution_is_fork: true,
|
|
37
|
+
parent: CALIBRATION_CANONICAL_REPOSITORY,
|
|
38
|
+
source: CALIBRATION_CANONICAL_REPOSITORY,
|
|
39
|
+
},
|
|
40
|
+
source: "https://github.com/ark-jo/StyleGallery/actions/runs/29260372260",
|
|
41
|
+
verified_at: "2026-07-13T15:04:41Z",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function finding(code, file, message) {
|
|
45
|
+
return { code, message, path: file };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function sha256(value) {
|
|
49
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function readJson(file, failures) {
|
|
53
|
+
if (!fs.existsSync(file)) {
|
|
54
|
+
failures.push(finding("baseline_file_missing", file, "required baseline record is missing"));
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const stat = fs.lstatSync(file);
|
|
59
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size > 1024 * 1024) throw new Error("JSON input must be a regular file no larger than 1 MiB");
|
|
60
|
+
return parseStrictJson(fs.readFileSync(file, "utf8"));
|
|
61
|
+
} catch (error) {
|
|
62
|
+
failures.push(finding("baseline_json_invalid", file, error instanceof Error ? error.message : String(error)));
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sameValue(left, right) {
|
|
68
|
+
return isDeepStrictEqual(left, right);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isRecord(value) {
|
|
72
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function rejectUnknown(value, allowed, code, file, failures) {
|
|
76
|
+
if (!isRecord(value)) return;
|
|
77
|
+
for (const key of Object.keys(value).filter((key) => !allowed.has(key))) {
|
|
78
|
+
failures.push(finding(code, file, `unsupported property ${key}`));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isHash(value) {
|
|
83
|
+
return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function validateEnvironment(environment, file) {
|
|
87
|
+
return sameValue(environment, BASELINE_ENVIRONMENT)
|
|
88
|
+
? []
|
|
89
|
+
: [finding("calibration_environment_mismatch", file, "environment must match the immutable linux/amd64 calibration image")];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function validateCalibration(record, file) {
|
|
93
|
+
const failures = [];
|
|
94
|
+
if (!isRecord(record)) return [finding("calibration_record_invalid", file, "calibration record must be an object")];
|
|
95
|
+
const allowed = new Set(["baseline_owner_approval", "committed_ci", "environment", "reference", "required_runs", "runs", "schema_version", "status"]);
|
|
96
|
+
rejectUnknown(record, allowed, "calibration_property_unknown", file, failures);
|
|
97
|
+
if (record.schema_version !== "1.0") failures.push(finding("calibration_schema_version", file, "schema_version must be 1.0"));
|
|
98
|
+
if (record.baseline_owner_approval !== "pending") failures.push(finding("baseline_owner_approval_invalid", file, "baseline owner approval must remain pending"));
|
|
99
|
+
if (record.required_runs !== 20) failures.push(finding("calibration_required_runs", file, "required_runs must equal 20"));
|
|
100
|
+
failures.push(...validateEnvironment(record.environment, file));
|
|
101
|
+
if (!sameValue(record.reference, BASELINE_REFERENCE)) failures.push(finding("calibration_reference_mismatch", file, "source and baseline hashes must match the proposed reference"));
|
|
102
|
+
if (!Array.isArray(record.runs)) return [...failures, finding("calibration_runs_invalid", file, "runs must be an array")];
|
|
103
|
+
if (record.status === "awaiting_committed_ci") {
|
|
104
|
+
if (record.runs.length !== 0 || record.committed_ci !== null) failures.push(finding("calibration_pending_evidence_forbidden", file, "pending calibration cannot claim runs or committed CI"));
|
|
105
|
+
return failures;
|
|
106
|
+
}
|
|
107
|
+
if (!["awaiting_external_verification", "completed"].includes(record.status)) return [...failures, finding("calibration_status_invalid", file, "status must be awaiting_committed_ci, awaiting_external_verification, or completed")];
|
|
108
|
+
if (!isRecord(record.committed_ci)) failures.push(finding("calibration_committed_ci_missing", file, "calibrated record requires committed CI metadata"));
|
|
109
|
+
else {
|
|
110
|
+
rejectUnknown(record.committed_ci, new Set(["artifact_name", "checkout_sha", "execution_repository", "external_verification", "head_sha", "raw_evidence_sha256", "repository", "run_attempt", "run_id", "sha", "workflow"]), "calibration_committed_ci_property_unknown", file, failures);
|
|
111
|
+
const expectedArtifact = `chromium-sentinel-calibration-${record.committed_ci.run_id}-${record.committed_ci.sha}`;
|
|
112
|
+
if (!/^[0-9]+$/.test(record.committed_ci.run_id ?? "")
|
|
113
|
+
|| !/^[0-9]+$/.test(record.committed_ci.run_attempt ?? "")
|
|
114
|
+
|| !/^[0-9a-f]{40}$/.test(record.committed_ci.sha ?? "")
|
|
115
|
+
|| !/^[0-9a-f]{40}$/.test(record.committed_ci.head_sha ?? "")
|
|
116
|
+
|| record.committed_ci.checkout_sha !== record.committed_ci.sha
|
|
117
|
+
|| !CALIBRATION_EXECUTION_REPOSITORIES.includes(record.committed_ci.execution_repository)
|
|
118
|
+
|| !/^[0-9a-f]{64}$/.test(record.committed_ci.raw_evidence_sha256 ?? "")
|
|
119
|
+
|| record.committed_ci.repository !== CALIBRATION_CANONICAL_REPOSITORY
|
|
120
|
+
|| record.committed_ci.workflow !== ".github/workflows/validate.yml"
|
|
121
|
+
|| record.committed_ci.artifact_name !== expectedArtifact) {
|
|
122
|
+
failures.push(finding("calibration_committed_ci_invalid", file, "canonical and execution repositories, workflow, run, attempt, SHAs, raw evidence, and artifact name must have the canonical identity"));
|
|
123
|
+
}
|
|
124
|
+
if (record.status === "awaiting_external_verification" && record.committed_ci.external_verification !== null) {
|
|
125
|
+
failures.push(finding("calibration_external_verification_preclaim", file, "awaiting calibration must not claim uploaded artifact verification"));
|
|
126
|
+
}
|
|
127
|
+
if (record.status === "completed") {
|
|
128
|
+
if (!("external_verification" in record.committed_ci) || record.committed_ci.external_verification === null) {
|
|
129
|
+
failures.push(finding("calibration_external_verification_missing", file, "completed calibration requires independently checked run and artifact metadata"));
|
|
130
|
+
} else {
|
|
131
|
+
const external = record.committed_ci.external_verification;
|
|
132
|
+
const expectedSource = `https://github.com/${record.committed_ci.execution_repository}/actions/runs/${record.committed_ci.run_id}`;
|
|
133
|
+
if (!sameValue(external, CALIBRATION_EXTERNAL_VERIFICATION)
|
|
134
|
+
|| external?.source !== expectedSource
|
|
135
|
+
|| external?.artifact?.name !== record.committed_ci.artifact_name
|
|
136
|
+
|| external?.artifact?.api_digest === `sha256:${record.committed_ci.raw_evidence_sha256}`) {
|
|
137
|
+
failures.push(finding("calibration_external_verification_invalid", file, "external verification must equal the independently checked GitHub Actions run, fork relationship, and artifact API identity"));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (record.runs.length !== 20) failures.push(finding("calibration_run_count", file, "calibrated record requires exactly 20 runs"));
|
|
143
|
+
const runAllowed = new Set(["architecture", "ax_sha256", "completed", "dom_sha256", "metadata_sha256", "png_sha256", "run", "screenshot_diff_pixels"]);
|
|
144
|
+
for (const run of record.runs) {
|
|
145
|
+
if (!isRecord(run)) {
|
|
146
|
+
failures.push(finding("calibration_run_invalid", file, "every run must be an object"));
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
rejectUnknown(run, runAllowed, "calibration_run_property_unknown", file, failures);
|
|
150
|
+
if (!Number.isInteger(run.run) || run.run < 1 || run.run > 20) failures.push(finding("calibration_run_number_invalid", file, "run must be an integer from 1 through 20"));
|
|
151
|
+
if (run.completed !== true) failures.push(finding("calibration_run_incomplete", file, "every calibration run must complete"));
|
|
152
|
+
if (run.architecture !== "amd64") failures.push(finding("calibration_architecture_variance", file, "every run must record architecture amd64"));
|
|
153
|
+
if (![run.ax_sha256, run.dom_sha256, run.metadata_sha256, run.png_sha256].every(isHash)) failures.push(finding("calibration_hash_invalid", file, "every artifact hash must be 64 lowercase hexadecimal characters"));
|
|
154
|
+
if (run.png_sha256 !== BASELINE_REFERENCE.baseline.sha256) failures.push(finding("calibration_baseline_hash_mismatch", file, "every completed PNG must equal the proposed baseline hash"));
|
|
155
|
+
if (run.metadata_sha256 !== BASELINE_METADATA_SHA256) failures.push(finding("calibration_metadata_hash_mismatch", file, "every completed metadata hash must equal the canonical environment and reference hash"));
|
|
156
|
+
if (run.screenshot_diff_pixels !== 0) failures.push(finding("calibration_diff_nonzero", file, "every screenshot diff must equal zero"));
|
|
157
|
+
}
|
|
158
|
+
const runNumbers = record.runs.map((run) => run?.run);
|
|
159
|
+
if (new Set(runNumbers).size !== runNumbers.length) failures.push(finding("calibration_run_duplicate", file, "calibration run numbers must be unique"));
|
|
160
|
+
if (!sameValue([...runNumbers].sort((left, right) => left - right), Array.from({ length: 20 }, (_, index) => index + 1))) failures.push(finding("calibration_run_set", file, "calibration runs must be the complete range 1 through 20"));
|
|
161
|
+
const architectures = new Set(record.runs.map((run) => run?.architecture));
|
|
162
|
+
if (architectures.size !== 1 || !architectures.has("amd64")) failures.push(finding("calibration_architecture_variance", file, "every run must record architecture amd64"));
|
|
163
|
+
if (["png_sha256", "dom_sha256", "ax_sha256"].some((field) => new Set(record.runs.map((run) => run?.[field])).size !== 1)) failures.push(finding("calibration_hash_variance", file, "PNG, DOM, and AX hashes must each be identical across runs"));
|
|
164
|
+
if (new Set(record.runs.map((run) => run?.metadata_sha256)).size !== 1) failures.push(finding("calibration_metadata_variance", file, "stable environment metadata must be identical across runs"));
|
|
165
|
+
return failures;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function validateManifest(manifest, file, root) {
|
|
169
|
+
const failures = [];
|
|
170
|
+
if (!isRecord(manifest)) return [finding("baseline_manifest_invalid", file, "baseline manifest must be an object")];
|
|
171
|
+
const allowed = new Set(["architecture", "baseline", "baseline_owner_approval", "browser_revision", "browser_version", "calibration", "container_image", "node", "os", "platform", "playwright", "schema_version", "source", "status", "viewport"]);
|
|
172
|
+
rejectUnknown(manifest, allowed, "baseline_manifest_property_unknown", file, failures);
|
|
173
|
+
if (manifest.schema_version !== "1.0" || manifest.status !== "proposed") failures.push(finding("baseline_manifest_state", file, "manifest must be proposed schema 1.0"));
|
|
174
|
+
if (manifest.baseline_owner_approval !== "pending") failures.push(finding("baseline_owner_approval_invalid", file, "baseline owner approval must remain pending"));
|
|
175
|
+
for (const [key, value] of Object.entries(BASELINE_ENVIRONMENT)) if (!sameValue(manifest[key], value)) failures.push(finding("baseline_environment_mismatch", file, `${key} differs from the immutable environment`));
|
|
176
|
+
rejectUnknown(manifest.calibration, new Set(["record", "required_runs", "workflow_artifact_name"]), "baseline_calibration_property_unknown", file, failures);
|
|
177
|
+
if (!isRecord(manifest.calibration) || manifest.calibration.required_runs !== 20 || manifest.calibration.record !== "consumer-reference/baselines/calibration.json") failures.push(finding("baseline_calibration_route", file, "manifest must route to the 20-run calibration record"));
|
|
178
|
+
if (manifest.calibration?.workflow_artifact_name !== "chromium-sentinel-calibration-${run_id}-${sha}") failures.push(finding("baseline_artifact_name", file, "workflow artifact name template is immutable"));
|
|
179
|
+
for (const [kind, entry] of [["source", manifest.source], ["baseline", manifest.baseline]]) {
|
|
180
|
+
rejectUnknown(entry, new Set(["path", "sha256"]), `baseline_${kind}_property_unknown`, file, failures);
|
|
181
|
+
if (!sameValue(entry, BASELINE_REFERENCE[kind])) failures.push(finding(`baseline_${kind}_reference_mismatch`, file, `${kind} identity must equal the proposed reference`));
|
|
182
|
+
const target = typeof entry?.path === "string" ? path.resolve(root, entry.path) : "";
|
|
183
|
+
if (!isRecord(entry) || !isHash(entry.sha256)) failures.push(finding(`baseline_${kind}_invalid`, file, `${kind} requires an exact path and 64-character hash`));
|
|
184
|
+
if (!target || !target.startsWith(`${root}${path.sep}`) || !fs.existsSync(target)) failures.push(finding(`baseline_${kind}_missing`, file, `${kind} path must resolve inside the repository`));
|
|
185
|
+
else if (fs.lstatSync(target).isSymbolicLink() || !fs.lstatSync(target).isFile() || !fs.realpathSync(target).startsWith(`${fs.realpathSync(root)}${path.sep}`)) failures.push(finding(`baseline_${kind}_file_invalid`, file, `${kind} must be a regular repository file, not a directory or symlink`));
|
|
186
|
+
else if (entry.sha256 !== sha256(fs.readFileSync(target))) failures.push(finding(`baseline_${kind}_hash_mismatch`, file, `${kind} hash differs from repository bytes`));
|
|
187
|
+
}
|
|
188
|
+
return failures;
|
|
189
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { BASELINE_METADATA_SHA256, BASELINE_REFERENCE } from "./baseline-contract.mjs";
|
|
2
|
+
|
|
3
|
+
const executionRepositories = ["ark-jo/StyleGallery", "changeroa/StyleGallery"];
|
|
4
|
+
|
|
5
|
+
export function baselineSchemaParity(schema, canonical, completedEvidence) {
|
|
6
|
+
const committedSchema = schema.properties.committed_ci.oneOf.find((entry) => entry.type === "object");
|
|
7
|
+
const externalSchema = committedSchema.properties.external_verification?.oneOf?.find((entry) => entry.type === "object") ?? {};
|
|
8
|
+
const artifactSchema = externalSchema.properties?.artifact ?? {};
|
|
9
|
+
const relationshipSchema = externalSchema.properties?.repository_relationship ?? {};
|
|
10
|
+
const runSchema = schema.properties.runs.items;
|
|
11
|
+
const runContains = schema.allOf[0].else.properties.runs.allOf;
|
|
12
|
+
return schema.additionalProperties === false
|
|
13
|
+
&& committedSchema.additionalProperties === false
|
|
14
|
+
&& schema.properties.environment.additionalProperties === false
|
|
15
|
+
&& Object.entries(canonical.environment).every(([key, value]) => key === "viewport"
|
|
16
|
+
? JSON.stringify(schema.properties.environment.properties.viewport.properties) === JSON.stringify({ width: { const: value.width }, height: { const: value.height } })
|
|
17
|
+
: schema.properties.environment.properties[key].const === value)
|
|
18
|
+
&& schema.properties.reference.additionalProperties === false
|
|
19
|
+
&& schema.properties.reference.properties.source.additionalProperties === false
|
|
20
|
+
&& schema.properties.reference.properties.source.properties.sha256.const === BASELINE_REFERENCE.source.sha256
|
|
21
|
+
&& schema.properties.reference.properties.baseline.properties.sha256.const === BASELINE_REFERENCE.baseline.sha256
|
|
22
|
+
&& runSchema.additionalProperties === false
|
|
23
|
+
&& runSchema.properties.png_sha256.const === BASELINE_REFERENCE.baseline.sha256
|
|
24
|
+
&& runSchema.properties.metadata_sha256.const === BASELINE_METADATA_SHA256
|
|
25
|
+
&& committedSchema.properties.repository.const === "changeroa/StyleGallery"
|
|
26
|
+
&& JSON.stringify(committedSchema.properties.execution_repository?.enum ?? []) === JSON.stringify(executionRepositories)
|
|
27
|
+
&& externalSchema.additionalProperties === false
|
|
28
|
+
&& artifactSchema.additionalProperties === false
|
|
29
|
+
&& relationshipSchema.additionalProperties === false
|
|
30
|
+
&& artifactSchema.properties?.api_digest?.const === completedEvidence.artifactApiDigest
|
|
31
|
+
&& artifactSchema.properties?.id?.const === completedEvidence.artifactId
|
|
32
|
+
&& artifactSchema.properties?.name?.const === completedEvidence.artifactName
|
|
33
|
+
&& artifactSchema.properties?.size_in_bytes?.const === completedEvidence.artifactSize
|
|
34
|
+
&& artifactSchema.properties?.expires_at?.const === completedEvidence.artifactExpiresAt
|
|
35
|
+
&& externalSchema.properties?.source?.const === completedEvidence.source
|
|
36
|
+
&& externalSchema.properties?.verified_at?.const === completedEvidence.verifiedAt
|
|
37
|
+
&& JSON.stringify(runContains.map((entry) => entry.contains.properties.run.const).sort((left, right) => left - right)) === JSON.stringify(Array.from({ length: 20 }, (_, index) => index + 1))
|
|
38
|
+
&& runContains.every((entry) => entry.minContains === 1 && entry.maxContains === 1)
|
|
39
|
+
&& JSON.stringify([...committedSchema.required].sort()) === JSON.stringify(["artifact_name", "checkout_sha", "execution_repository", "external_verification", "head_sha", "raw_evidence_sha256", "repository", "run_attempt", "run_id", "sha", "workflow"].sort());
|
|
40
|
+
}
|