@nextcommerce/campaigns-os 1.33.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 +204 -0
- package/CHANGELOG.md +5002 -0
- package/CONTEXT.md +685 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +368 -0
- package/agents/claude/CLAUDE.md +32 -0
- package/agents/codex/AGENTS.md +27 -0
- package/agents/copilot/copilot-instructions.md +14 -0
- package/agents/cursor/campaigns-os.mdc +13 -0
- package/bin/campaigns-os.mjs +38 -0
- package/campaign-spec/README.md +138 -0
- package/campaign-spec/dist/analytics-vocabulary.d.ts +47 -0
- package/campaign-spec/dist/analytics-vocabulary.js +74 -0
- package/campaign-spec/dist/index.d.ts +40 -0
- package/campaign-spec/dist/index.js +77 -0
- package/campaign-spec/dist/normalize.d.ts +22 -0
- package/campaign-spec/dist/normalize.js +41 -0
- package/campaign-spec/dist/routing.d.ts +190 -0
- package/campaign-spec/dist/routing.js +263 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +303 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.js +191 -0
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +14 -0
- package/campaign-spec/dist/rules/campaign-metadata.js +40 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.js +66 -0
- package/campaign-spec/dist/rules/cycle-detection.d.ts +12 -0
- package/campaign-spec/dist/rules/cycle-detection.js +141 -0
- package/campaign-spec/dist/rules/design-source-shape.d.ts +29 -0
- package/campaign-spec/dist/rules/design-source-shape.js +142 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.d.ts +11 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.js +40 -0
- package/campaign-spec/dist/rules/exit-intent-validation.d.ts +23 -0
- package/campaign-spec/dist/rules/exit-intent-validation.js +147 -0
- package/campaign-spec/dist/rules/funnel-count.d.ts +9 -0
- package/campaign-spec/dist/rules/funnel-count.js +37 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.d.ts +22 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.js +60 -0
- package/campaign-spec/dist/rules/funnel-identity.d.ts +15 -0
- package/campaign-spec/dist/rules/funnel-identity.js +57 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.d.ts +20 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.js +66 -0
- package/campaign-spec/dist/rules/index.d.ts +69 -0
- package/campaign-spec/dist/rules/index.js +131 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.d.ts +13 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.js +59 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.d.ts +12 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.js +42 -0
- package/campaign-spec/dist/rules/page-count.d.ts +11 -0
- package/campaign-spec/dist/rules/page-count.js +31 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.d.ts +14 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.js +47 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.d.ts +8 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.js +126 -0
- package/campaign-spec/dist/rules/promo-codes-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/promo-codes-shape.js +187 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.d.ts +33 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.js +81 -0
- package/campaign-spec/dist/rules/route-target-resolves.d.ts +32 -0
- package/campaign-spec/dist/rules/route-target-resolves.js +112 -0
- package/campaign-spec/dist/rules/schema-version.d.ts +21 -0
- package/campaign-spec/dist/rules/schema-version.js +53 -0
- package/campaign-spec/dist/rules/sdk-version.d.ts +26 -0
- package/campaign-spec/dist/rules/sdk-version.js +96 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.js +30 -0
- package/campaign-spec/dist/rules/shipping-methods-present.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-methods-present.js +26 -0
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/store-profile-shape.js +127 -0
- package/campaign-spec/dist/rules/thank-you-requirement.d.ts +16 -0
- package/campaign-spec/dist/rules/thank-you-requirement.js +50 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.d.ts +28 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.js +114 -0
- package/campaign-spec/dist/rules/upsell-has-packages.d.ts +9 -0
- package/campaign-spec/dist/rules/upsell-has-packages.js +35 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.d.ts +10 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.js +45 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.d.ts +11 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.js +44 -0
- package/campaign-spec/dist/rules/variant-labels-shape.d.ts +28 -0
- package/campaign-spec/dist/rules/variant-labels-shape.js +86 -0
- package/campaign-spec/dist/sdk-version-parse.d.ts +43 -0
- package/campaign-spec/dist/sdk-version-parse.js +62 -0
- package/campaign-spec/dist/types.d.ts +674 -0
- package/campaign-spec/dist/types.js +40 -0
- package/campaign-spec/package.json +12 -0
- package/compatibility.json +25 -0
- package/contracts/agent-relevant-change-policy.v1.json +111 -0
- package/contracts/brand-theme-source-defaults.figma-sections-export.v0.json +32 -0
- package/contracts/brand-theme-target-tokens.next-core.v0.json +65 -0
- package/contracts/campaign-cart-checkout-field-contract.v0.json +45 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +11 -0
- package/contracts/commerce-surface-catalog.json +2452 -0
- package/contracts/fixtures/orientation/canonicalization/v1.json +34 -0
- package/contracts/fixtures/orientation/envelope/current.json +95 -0
- package/contracts/fixtures/orientation/envelope/freshness_unknown.json +97 -0
- package/contracts/fixtures/orientation/envelope/legacy_baseline.json +95 -0
- package/contracts/fixtures/orientation/envelope/orientation_available.json +136 -0
- package/contracts/fixtures/orientation/envelope/recovered_interrupted_update.json +136 -0
- package/contracts/fixtures/orientation/envelope/refused.json +100 -0
- package/contracts/fixtures/orientation/envelope/restart_required.json +137 -0
- package/contracts/fixtures/orientation/envelope/updated.json +135 -0
- package/contracts/fixtures/orientation/hostile-target/README.md +61 -0
- package/contracts/fixtures/orientation/hostile-target/manifest.json +82 -0
- package/contracts/fixtures/orientation/hostile-target/repo/CHANGELOG.md +18 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/intended.mjs +12 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/tripwire.mjs +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/release-ledger.json +48 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/supported-surface.json +17 -0
- package/contracts/fixtures/orientation/hostile-target/repo/docs/example-contract.md +13 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-checkout +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-merge +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/pre-commit +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/package.json +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/tripwire.mjs +9 -0
- package/contracts/fixtures/orientation/hostile-target/repo/package.json +20 -0
- package/contracts/fixtures/orientation/hostile-target/repo/schemas/example.v0.schema.json +15 -0
- package/contracts/fixtures/orientation/release-gate/cases.json +1073 -0
- package/contracts/fixtures/runtime-recipe/accept/current.json +299 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +294 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +51 -0
- package/contracts/fixtures/runtime-recipe/manifest.json +85 -0
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +297 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +251 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +310 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +299 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json +180 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json +115 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json +29 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json +27 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json +141 -0
- package/contracts/migration-sidecar-bundle.v0.json +149 -0
- package/contracts/orientation-limits.v1.json +41 -0
- package/contracts/orientation-reason-codes.v1.json +196 -0
- package/contracts/private-template-sources.json +8 -0
- package/contracts/release-ledger.json +4598 -0
- package/contracts/reserved-skill-names.json +13 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +299 -0
- package/contracts/supported-surface.json +180 -0
- package/contracts/template-brand-contract.apollo-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.apollo.v0.json +27 -0
- package/contracts/template-brand-contract.demeter.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-two-step.v0.json +28 -0
- package/contracts/template-brand-contract.olympus.v0.json +27 -0
- package/contracts/template-brand-contract.shared-commerce.v0.json +190 -0
- package/contracts/template-brand-contract.shop-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.shop-three-step.v0.json +29 -0
- package/contracts/template-slot-manifest.apollo-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.apollo.v0.json +12 -0
- package/contracts/template-slot-manifest.demeter.v0.json +30 -0
- package/contracts/template-slot-manifest.olympus-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.olympus-mv-two-step.v0.json +15 -0
- package/contracts/template-slot-manifest.olympus.v0.json +12 -0
- package/contracts/template-slot-manifest.shared-content-core.v0.json +4254 -0
- package/contracts/template-slot-manifest.shop-single-step.v0.json +47 -0
- package/contracts/template-slot-manifest.shop-three-step.v0.json +33 -0
- package/docs/brand-theme-bridge.md +159 -0
- package/docs/build-packet.md +1300 -0
- package/docs/campaign-build-brief.md +145 -0
- package/docs/campaign-standardization-report.md +329 -0
- package/docs/campaigns-os-build-flow.md +117 -0
- package/docs/design-source-package.md +784 -0
- package/docs/legacy-migration.md +58 -0
- package/docs/migration-sidecar-bundle.md +139 -0
- package/docs/orientation-contract-reference.md +1220 -0
- package/docs/polish-evidence.md +502 -0
- package/docs/qa-and-test-orders.md +1691 -0
- package/docs/release-ledger-authoring-guide.md +274 -0
- package/docs/runtime-readiness.md +211 -0
- package/docs/supported-surface.md +83 -0
- package/docs/versioning.md +55 -0
- package/docs/workflow-findings-sidecar.md +588 -0
- package/package.json +135 -0
- package/prompts/first-build.md +27 -0
- package/prompts/friction-log.md +30 -0
- package/schemas/campaign-build-brief.v1.schema.json +149 -0
- package/schemas/campaign-design-source-package.v0.schema.json +697 -0
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +390 -0
- package/schemas/campaign-runtime-build-context.v0.schema.json +339 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +452 -0
- package/schemas/campaign-spec.v4.schema.json +582 -0
- package/schemas/campaigns-os-doctor-output.v0.schema.json +43 -0
- package/schemas/campaigns-os-legacy-migration-inventory.v0.schema.json +112 -0
- package/schemas/campaigns-os-legacy-provisioning-plan.v0.schema.json +38 -0
- package/schemas/campaigns-os-legacy-provisioning-receipt.v0.schema.json +57 -0
- package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +150 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +438 -0
- package/schemas/campaigns-os-release-ledger.v1.schema.json +162 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +343 -0
- package/schemas/campaigns-os-runtime-recipe.v1.schema.json +313 -0
- package/schemas/campaigns-os-sidecar-bundle-conformance.v0.schema.json +78 -0
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +381 -0
- package/schemas/campaigns-os-workflow-finding.v0.schema.json +134 -0
- package/schemas/source-html-manifest.v0.schema.json +252 -0
- package/skills/next-campaigns-build/SKILL.md +73 -0
- package/skills/next-campaigns-os/SKILL.md +101 -0
- package/skills/next-campaigns-os/references/session-intake.md +160 -0
- package/skills/next-campaigns-os-setup/SKILL.md +22 -0
- package/skills/next-campaigns-polish/SKILL.md +145 -0
- package/skills/next-campaigns-qa/SKILL.md +92 -0
- package/skills.json +56 -0
- package/skills.sh +64 -0
- package/src/adapter-decision-contract.mjs +333 -0
- package/src/brand-theme.mjs +1151 -0
- package/src/browser-launch.mjs +79 -0
- package/src/build-brief.mjs +781 -0
- package/src/built-site-scope.mjs +312 -0
- package/src/campaign-ecosystem.mjs +734 -0
- package/src/campaign-identity.mjs +405 -0
- package/src/campaign-workspace.mjs +127 -0
- package/src/checkpoint-waiver.mjs +302 -0
- package/src/cli.mjs +13442 -0
- package/src/commercial-journey.mjs +1119 -0
- package/src/commercial-parity.mjs +965 -0
- package/src/consent.mjs +347 -0
- package/src/content-residue.mjs +322 -0
- package/src/deadline.mjs +81 -0
- package/src/design-source-package.mjs +2604 -0
- package/src/deviation.mjs +107 -0
- package/src/doctor-check-registry.mjs +49 -0
- package/src/doctor-sidecar.mjs +106 -0
- package/src/finding-cause.mjs +557 -0
- package/src/findings.mjs +326 -0
- package/src/fs-identity.mjs +68 -0
- package/src/gate-actions.mjs +105 -0
- package/src/html-scan.mjs +53 -0
- package/src/install-mode.mjs +272 -0
- package/src/legacy-migration.d.ts +128 -0
- package/src/legacy-migration.mjs +510 -0
- package/src/lifecycle.mjs +338 -0
- package/src/local-proof.mjs +401 -0
- package/src/map-pin-writeback.mjs +210 -0
- package/src/orchestration-stage-contract.mjs +81 -0
- package/src/package-install-fixture.mjs +33 -0
- package/src/page-kit-build-summary.mjs +175 -0
- package/src/page-kit-campaign-config.mjs +57 -0
- package/src/page-kit-sdk-version.mjs +392 -0
- package/src/page-kit-store-profile.mjs +369 -0
- package/src/page-kit-sync.mjs +162 -0
- package/src/polish-browser.mjs +867 -0
- package/src/polish-capture.mjs +1094 -0
- package/src/polish-deadline.mjs +51 -0
- package/src/polish-gate.mjs +739 -0
- package/src/polish-node.mjs +639 -0
- package/src/polish-page-load.mjs +1100 -0
- package/src/private-template-source.mjs +237 -0
- package/src/proof-policy.mjs +82 -0
- package/src/qa-analytics-correctness.mjs +307 -0
- package/src/qa-analytics-errors.mjs +38 -0
- package/src/qa-analytics-parity.mjs +699 -0
- package/src/qa-binding-evidence.mjs +140 -0
- package/src/qa-browser.mjs +6608 -0
- package/src/qa-cart-entry.mjs +406 -0
- package/src/qa-commercial-parity.mjs +641 -0
- package/src/qa-node.mjs +3620 -0
- package/src/qa-order-bump.mjs +381 -0
- package/src/qa-parity-capture.mjs +428 -0
- package/src/qa-parity-fixture.mjs +359 -0
- package/src/qa-publish.mjs +362 -0
- package/src/qa-purchase-data-layer.mjs +263 -0
- package/src/qa-route-probe.mjs +272 -0
- package/src/qa-sidecar.mjs +188 -0
- package/src/qa-test-order-topology.mjs +207 -0
- package/src/qa-url-privacy.mjs +13 -0
- package/src/qa-verdict-discovery.mjs +192 -0
- package/src/qa-verdict-publish.mjs +105 -0
- package/src/qa-verdict.mjs +287 -0
- package/src/remit.mjs +388 -0
- package/src/repo-scan.mjs +83 -0
- package/src/route-identity.mjs +133 -0
- package/src/run-record-closeout.mjs +229 -0
- package/src/run-record.mjs +839 -0
- package/src/run-session.mjs +226 -0
- package/src/runtime-state-ignore.mjs +113 -0
- package/src/sdk-attribute-index.mjs +212 -0
- package/src/sdk-markup.mjs +358 -0
- package/src/sdk-meta-tags.mjs +52 -0
- package/src/shell-token.mjs +7 -0
- package/src/sidecar-bundle.mjs +399 -0
- package/src/source-asset-crawl.mjs +469 -0
- package/src/source-html-intake.mjs +627 -0
- package/src/source-html-manifest.mjs +276 -0
- package/src/source-prep.mjs +284 -0
- package/src/spec-derive-store.mjs +431 -0
- package/src/spec-derive.mjs +514 -0
- package/src/spec-fetch.mjs +66 -0
- package/src/spec-hash.mjs +48 -0
- package/src/spec-identity.mjs +27 -0
- package/src/stage-ledger.mjs +509 -0
- package/src/standardization-report.mjs +1297 -0
- package/src/template-brand-contract.mjs +473 -0
- package/src/template-freshness.mjs +196 -0
- package/src/template-reference.mjs +81 -0
- package/src/template-slot-manifest.mjs +150 -0
- package/src/text-safety.mjs +62 -0
- package/src/theme-gate.mjs +185 -0
- package/src/upsell-selector-scope.mjs +299 -0
package/src/findings.mjs
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// Workflow Findings — the human/agent findings channel within Run Telemetry
|
|
2
|
+
// (the "Findings Sidecar" lane). See docs/workflow-findings-sidecar.md (Run
|
|
3
|
+
// Telemetry); the Run Record embeds a per-run snapshot of these findings.
|
|
4
|
+
//
|
|
5
|
+
// This module owns local finding capture only: validate, append, list, and
|
|
6
|
+
// export Workflow Findings. It deliberately does NOT cluster, route, create
|
|
7
|
+
// Linear issues, or remit — remit lives behind consent in remit.mjs /
|
|
8
|
+
// run-record.mjs, and aggregation is internal tooling's job. Capturing a
|
|
9
|
+
// finding must never require Linear access or NEXT internal context, so this
|
|
10
|
+
// module has no network or credential dependencies.
|
|
11
|
+
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
|
|
16
|
+
export const WORKFLOW_FINDING_SCHEMA = "campaigns-os-workflow-finding/v0";
|
|
17
|
+
export const FINDINGS_JOURNAL_REL_PATH = ".campaign-runtime/workflow-findings.jsonl";
|
|
18
|
+
|
|
19
|
+
export const FINDING_STAGES = [
|
|
20
|
+
"overall",
|
|
21
|
+
"intake",
|
|
22
|
+
"start",
|
|
23
|
+
"doctor",
|
|
24
|
+
"setup",
|
|
25
|
+
"build",
|
|
26
|
+
"polish",
|
|
27
|
+
"deploy",
|
|
28
|
+
"qa",
|
|
29
|
+
"test-order",
|
|
30
|
+
"next",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
export const FINDING_KINDS = [
|
|
34
|
+
"positive_signal",
|
|
35
|
+
"friction",
|
|
36
|
+
"missing_prompt",
|
|
37
|
+
"blocker",
|
|
38
|
+
"docs_gap",
|
|
39
|
+
"automation_gap",
|
|
40
|
+
"idea",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export const FINDING_AUTHOR_TYPES = ["operator", "agent", "system"];
|
|
44
|
+
|
|
45
|
+
export const FINDING_EVIDENCE_QUALITY = [
|
|
46
|
+
"operator_report",
|
|
47
|
+
"artifact_referenced",
|
|
48
|
+
"artifact_attached",
|
|
49
|
+
"system_observed",
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
// Required core fields. Strict here; permissive about optional context.
|
|
53
|
+
const REQUIRED_FIELDS = ["schema_version", "id", "created_at", "stage", "kind", "summary"];
|
|
54
|
+
|
|
55
|
+
function isNonEmptyString(value) {
|
|
56
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Hand-rolled validator matching the repo convention (no AJV). Checks the
|
|
61
|
+
* required core and the closed enums; leaves optional context fields
|
|
62
|
+
* permissive. Returns `{ ok, errors }` where each error is `{ code, message }`.
|
|
63
|
+
*/
|
|
64
|
+
export function validateWorkflowFinding(finding) {
|
|
65
|
+
const errors = [];
|
|
66
|
+
const add = (code, message) => errors.push({ code, message });
|
|
67
|
+
|
|
68
|
+
if (!finding || typeof finding !== "object" || Array.isArray(finding)) {
|
|
69
|
+
add("finding.type", "Workflow Finding must be a JSON object.");
|
|
70
|
+
return { ok: false, errors };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const field of REQUIRED_FIELDS) {
|
|
74
|
+
if (!isNonEmptyString(finding[field])) {
|
|
75
|
+
add(`finding.${field}`, `Missing or empty required field "${field}".`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (finding.schema_version != null && finding.schema_version !== WORKFLOW_FINDING_SCHEMA) {
|
|
80
|
+
add("finding.schema_version", `Expected schema_version "${WORKFLOW_FINDING_SCHEMA}".`);
|
|
81
|
+
}
|
|
82
|
+
if (isNonEmptyString(finding.stage) && !FINDING_STAGES.includes(finding.stage)) {
|
|
83
|
+
add("finding.stage", `Unknown stage "${finding.stage}". Allowed: ${FINDING_STAGES.join(", ")}.`);
|
|
84
|
+
}
|
|
85
|
+
if (isNonEmptyString(finding.kind) && !FINDING_KINDS.includes(finding.kind)) {
|
|
86
|
+
add("finding.kind", `Unknown kind "${finding.kind}". Allowed: ${FINDING_KINDS.join(", ")}.`);
|
|
87
|
+
}
|
|
88
|
+
if (finding.author_type != null && !FINDING_AUTHOR_TYPES.includes(finding.author_type)) {
|
|
89
|
+
add("finding.author_type", `Unknown author_type "${finding.author_type}". Allowed: ${FINDING_AUTHOR_TYPES.join(", ")}.`);
|
|
90
|
+
}
|
|
91
|
+
if (finding.evidence_quality != null && !FINDING_EVIDENCE_QUALITY.includes(finding.evidence_quality)) {
|
|
92
|
+
add("finding.evidence_quality", `Unknown evidence_quality "${finding.evidence_quality}". Allowed: ${FINDING_EVIDENCE_QUALITY.join(", ")}.`);
|
|
93
|
+
}
|
|
94
|
+
if (finding.artifact_paths != null && !Array.isArray(finding.artifact_paths)) {
|
|
95
|
+
add("finding.artifact_paths", "artifact_paths must be an array of strings when present.");
|
|
96
|
+
}
|
|
97
|
+
if (finding.command_exit_status != null && !Number.isInteger(finding.command_exit_status)) {
|
|
98
|
+
add("finding.command_exit_status", "command_exit_status must be an integer when present.");
|
|
99
|
+
}
|
|
100
|
+
if (finding.safe_to_share != null && typeof finding.safe_to_share !== "boolean") {
|
|
101
|
+
add("finding.safe_to_share", "safe_to_share must be a boolean when present.");
|
|
102
|
+
}
|
|
103
|
+
if (finding.run_id != null && typeof finding.run_id !== "string") {
|
|
104
|
+
add("finding.run_id", "run_id must be a string or null when present.");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { ok: errors.length === 0, errors };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolve the journal path. Precedence:
|
|
112
|
+
* 1. explicit `--journal <path>`
|
|
113
|
+
* 2. packet-adjacent target repo when `--packet <path>` is supplied
|
|
114
|
+
* 3. current working directory otherwise
|
|
115
|
+
*/
|
|
116
|
+
export function resolveJournalPath(args, cwd = process.cwd()) {
|
|
117
|
+
if (isNonEmptyString(args.journal)) return resolve(args.journal);
|
|
118
|
+
if (isNonEmptyString(args.packet)) {
|
|
119
|
+
return join(dirname(resolve(args.packet)), FINDINGS_JOURNAL_REL_PATH);
|
|
120
|
+
}
|
|
121
|
+
return join(resolve(cwd), FINDINGS_JOURNAL_REL_PATH);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function generateFindingId(now) {
|
|
125
|
+
return `wf_${now.getTime()}_${randomBytes(4).toString("hex")}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function coerceBoolean(value) {
|
|
129
|
+
if (value === true || value === "true") return true;
|
|
130
|
+
if (value === false || value === "false") return false;
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build a Workflow Finding object from parsed CLI flags. Generated values
|
|
136
|
+
* (id, created_at, schema_version) and sensible defaults (author_type,
|
|
137
|
+
* evidence_quality) are filled here so the JSONL line is self-describing.
|
|
138
|
+
*/
|
|
139
|
+
export function buildFinding(input, { now = new Date() } = {}) {
|
|
140
|
+
const finding = {
|
|
141
|
+
schema_version: WORKFLOW_FINDING_SCHEMA,
|
|
142
|
+
id: isNonEmptyString(input.id) ? input.id : generateFindingId(now),
|
|
143
|
+
created_at: isNonEmptyString(input.created_at) ? input.created_at : now.toISOString(),
|
|
144
|
+
stage: isNonEmptyString(input.stage) ? input.stage.trim() : undefined,
|
|
145
|
+
kind: isNonEmptyString(input.kind) ? input.kind.trim() : undefined,
|
|
146
|
+
summary: isNonEmptyString(input.summary) ? input.summary.trim() : undefined,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const artifactPaths = parseArtifactPaths(input.artifact_paths);
|
|
150
|
+
const optional = {
|
|
151
|
+
details: input.details,
|
|
152
|
+
expected: input.expected,
|
|
153
|
+
actual: input.actual,
|
|
154
|
+
severity: input.severity,
|
|
155
|
+
command: input.command,
|
|
156
|
+
source_type: input.source_type,
|
|
157
|
+
template_family: input.template_family,
|
|
158
|
+
map_id: input.map_id,
|
|
159
|
+
campaign_slug: input.campaign_slug,
|
|
160
|
+
target_repo: input.target_repo,
|
|
161
|
+
packet_path: input.packet_path,
|
|
162
|
+
assembly_report_path: input.assembly_report_path,
|
|
163
|
+
qa_run_id: input.qa_run_id,
|
|
164
|
+
run_id: input.run_id,
|
|
165
|
+
suggested_owner: input.suggested_owner,
|
|
166
|
+
};
|
|
167
|
+
for (const [key, value] of Object.entries(optional)) {
|
|
168
|
+
if (isNonEmptyString(value)) finding[key] = value.trim();
|
|
169
|
+
}
|
|
170
|
+
if (artifactPaths.length) finding.artifact_paths = artifactPaths;
|
|
171
|
+
if (Number.isInteger(input.command_exit_status)) finding.command_exit_status = input.command_exit_status;
|
|
172
|
+
|
|
173
|
+
const safeToShare = coerceBoolean(input.safe_to_share);
|
|
174
|
+
if (safeToShare !== null) finding.safe_to_share = safeToShare;
|
|
175
|
+
|
|
176
|
+
// author_type: default operator for manual CLI adds; explicit flag wins.
|
|
177
|
+
finding.author_type = FINDING_AUTHOR_TYPES.includes(input.author_type) ? input.author_type : "operator";
|
|
178
|
+
|
|
179
|
+
// evidence_quality: explicit flag wins; otherwise infer artifact_referenced
|
|
180
|
+
// when artifact paths were supplied, else operator_report.
|
|
181
|
+
if (FINDING_EVIDENCE_QUALITY.includes(input.evidence_quality)) {
|
|
182
|
+
finding.evidence_quality = input.evidence_quality;
|
|
183
|
+
} else {
|
|
184
|
+
finding.evidence_quality = artifactPaths.length ? "artifact_referenced" : "operator_report";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return finding;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function parseArtifactPaths(value) {
|
|
191
|
+
if (Array.isArray(value)) return value.filter(isNonEmptyString).map((entry) => entry.trim());
|
|
192
|
+
if (isNonEmptyString(value)) {
|
|
193
|
+
return value
|
|
194
|
+
.split(",")
|
|
195
|
+
.map((entry) => entry.trim())
|
|
196
|
+
.filter(Boolean);
|
|
197
|
+
}
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Append exactly one validated finding as one JSONL line. Never rewrites
|
|
203
|
+
* existing entries — the journal is append-only.
|
|
204
|
+
*/
|
|
205
|
+
export function appendFinding(journalPath, finding) {
|
|
206
|
+
const validation = validateWorkflowFinding(finding);
|
|
207
|
+
if (!validation.ok) {
|
|
208
|
+
const detail = validation.errors.map((error) => `[${error.code}] ${error.message}`).join("; ");
|
|
209
|
+
throw new Error(`Workflow Finding failed validation: ${detail}`);
|
|
210
|
+
}
|
|
211
|
+
mkdirSync(dirname(resolve(journalPath)), { recursive: true });
|
|
212
|
+
appendFileSync(resolve(journalPath), `${JSON.stringify(finding)}\n`);
|
|
213
|
+
return finding;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Read the journal. Returns `{ findings, malformed }`. Malformed lines are
|
|
218
|
+
* preserved as `{ line, raw, error }` rather than throwing, so one bad line
|
|
219
|
+
* never blocks listing or export of the rest of the Learning Trail.
|
|
220
|
+
*/
|
|
221
|
+
export function readJournal(journalPath) {
|
|
222
|
+
const resolved = resolve(journalPath);
|
|
223
|
+
if (!existsSync(resolved)) return { findings: [], malformed: [] };
|
|
224
|
+
const text = readFileSync(resolved, "utf8");
|
|
225
|
+
const findings = [];
|
|
226
|
+
const malformed = [];
|
|
227
|
+
const lines = text.split("\n");
|
|
228
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
229
|
+
const raw = lines[index];
|
|
230
|
+
if (!raw.trim()) continue;
|
|
231
|
+
try {
|
|
232
|
+
findings.push(JSON.parse(raw));
|
|
233
|
+
} catch (error) {
|
|
234
|
+
malformed.push({ line: index + 1, raw, error: error.message });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return { findings, malformed };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function groupByStageThenKind(findings) {
|
|
241
|
+
// Internal aggregation groups by Observation Stage first, Finding Kind
|
|
242
|
+
// second. The local export mirrors that ordering so a pasted summary reads
|
|
243
|
+
// the same way the dashboard will later.
|
|
244
|
+
const byStage = new Map();
|
|
245
|
+
for (const finding of findings) {
|
|
246
|
+
const stage = isNonEmptyString(finding.stage) ? finding.stage : "(unknown)";
|
|
247
|
+
const kind = isNonEmptyString(finding.kind) ? finding.kind : "(unknown)";
|
|
248
|
+
if (!byStage.has(stage)) byStage.set(stage, new Map());
|
|
249
|
+
const byKind = byStage.get(stage);
|
|
250
|
+
if (!byKind.has(kind)) byKind.set(kind, []);
|
|
251
|
+
byKind.get(kind).push(finding);
|
|
252
|
+
}
|
|
253
|
+
const stageOrder = (stage) => {
|
|
254
|
+
const index = FINDING_STAGES.indexOf(stage);
|
|
255
|
+
return index === -1 ? FINDING_STAGES.length : index;
|
|
256
|
+
};
|
|
257
|
+
const kindOrder = (kind) => {
|
|
258
|
+
const index = FINDING_KINDS.indexOf(kind);
|
|
259
|
+
return index === -1 ? FINDING_KINDS.length : index;
|
|
260
|
+
};
|
|
261
|
+
return [...byStage.entries()]
|
|
262
|
+
.sort((a, b) => stageOrder(a[0]) - stageOrder(b[0]) || a[0].localeCompare(b[0]))
|
|
263
|
+
.map(([stage, byKind]) => ({
|
|
264
|
+
stage,
|
|
265
|
+
kinds: [...byKind.entries()]
|
|
266
|
+
.sort((a, b) => kindOrder(a[0]) - kindOrder(b[0]) || a[0].localeCompare(b[0]))
|
|
267
|
+
.map(([kind, items]) => ({ kind, items })),
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Markdown summary grouped by Observation Stage then Finding Kind. Includes
|
|
273
|
+
* counts and short summaries and references (artifact paths, run IDs) only —
|
|
274
|
+
* never artifact contents. Pasteable into Linear/GitHub/Slack.
|
|
275
|
+
*/
|
|
276
|
+
export function exportSummaryMarkdown(findings) {
|
|
277
|
+
const lines = ["# Campaigns OS Workflow Findings", ""];
|
|
278
|
+
lines.push(`Total findings: ${findings.length}`, "");
|
|
279
|
+
if (!findings.length) {
|
|
280
|
+
lines.push("_No findings recorded yet._");
|
|
281
|
+
return `${lines.join("\n")}\n`;
|
|
282
|
+
}
|
|
283
|
+
for (const { stage, kinds } of groupByStageThenKind(findings)) {
|
|
284
|
+
const stageCount = kinds.reduce((sum, group) => sum + group.items.length, 0);
|
|
285
|
+
lines.push(`## ${stage} (${stageCount})`, "");
|
|
286
|
+
for (const { kind, items } of kinds) {
|
|
287
|
+
lines.push(`### ${kind} (${items.length})`);
|
|
288
|
+
for (const finding of items) {
|
|
289
|
+
const refs = [];
|
|
290
|
+
if (Array.isArray(finding.artifact_paths) && finding.artifact_paths.length) {
|
|
291
|
+
refs.push(`artifacts: ${finding.artifact_paths.join(", ")}`);
|
|
292
|
+
}
|
|
293
|
+
if (isNonEmptyString(finding.qa_run_id)) refs.push(`qa_run: ${finding.qa_run_id}`);
|
|
294
|
+
if (isNonEmptyString(finding.map_id)) refs.push(`map: ${finding.map_id}`);
|
|
295
|
+
const refSuffix = refs.length ? ` — _${refs.join("; ")}_` : "";
|
|
296
|
+
lines.push(`- ${finding.summary || "(no summary)"}${refSuffix}`);
|
|
297
|
+
}
|
|
298
|
+
lines.push("");
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return `${lines.join("\n").replace(/\n+$/, "")}\n`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Structured JSON export for internal ingestion. Validates each entry and
|
|
306
|
+
* strips nothing but also adds nothing — artifact contents are never present
|
|
307
|
+
* because findings only ever store references.
|
|
308
|
+
*/
|
|
309
|
+
export function exportJson(findings) {
|
|
310
|
+
const invalid = [];
|
|
311
|
+
for (const finding of findings) {
|
|
312
|
+
const validation = validateWorkflowFinding(finding);
|
|
313
|
+
if (!validation.ok) invalid.push({ id: finding?.id || null, errors: validation.errors });
|
|
314
|
+
}
|
|
315
|
+
if (invalid.length) {
|
|
316
|
+
const detail = invalid
|
|
317
|
+
.map((entry) => `${entry.id || "(no id)"}: ${entry.errors.map((error) => error.code).join(", ")}`)
|
|
318
|
+
.join(" | ");
|
|
319
|
+
throw new Error(`Findings Journal has invalid entries; refusing to export: ${detail}`);
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
schema_version: WORKFLOW_FINDING_SCHEMA,
|
|
323
|
+
count: findings.length,
|
|
324
|
+
findings,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Path identity, answered once for every module that asks "is this the same
|
|
2
|
+
// file" or "which spelling of this path do I record".
|
|
3
|
+
//
|
|
4
|
+
// A path reached through a symlinked checkout and the same file reached
|
|
5
|
+
// directly are one file; two lexically different spellings of a path that
|
|
6
|
+
// does not exist cannot be shown to be one file. Every caller used to pick a
|
|
7
|
+
// policy for the second case on its own — one compared missing paths by
|
|
8
|
+
// spelling, another refused to treat them as equal — so the two decisions
|
|
9
|
+
// that key on packet identity (session join/refuse, context-to-report
|
|
10
|
+
// binding) could disagree on the same checkout. The policy is now an
|
|
11
|
+
// argument. A leaf: node built-ins only.
|
|
12
|
+
|
|
13
|
+
import { realpathSync } from "node:fs";
|
|
14
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
15
|
+
|
|
16
|
+
// The path as the filesystem knows it: the real path when it exists, else
|
|
17
|
+
// the real path of its nearest existing ancestor with the missing tail
|
|
18
|
+
// re-appended. What run sessions, Run Record artifact refs and portable
|
|
19
|
+
// (`--strip-paths`) output record. Canonicalising only the side that exists
|
|
20
|
+
// is worse than canonicalising neither — a real base and a lexical target
|
|
21
|
+
// relativize to `../<link>/…` — so a path that is not there yet still
|
|
22
|
+
// resolves through the directory links above it.
|
|
23
|
+
export function canonicalPath(path) {
|
|
24
|
+
const resolved = resolve(path);
|
|
25
|
+
const missing = [];
|
|
26
|
+
let cursor = resolved;
|
|
27
|
+
for (;;) {
|
|
28
|
+
try {
|
|
29
|
+
return join(realpathSync(cursor), ...missing);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (!absentOrMalformed(error)) throw error;
|
|
32
|
+
const parent = dirname(cursor);
|
|
33
|
+
if (parent === cursor) return resolved;
|
|
34
|
+
missing.unshift(basename(cursor));
|
|
35
|
+
cursor = parent;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The same file. Equal once resolved, or one file behind any symlinks. With
|
|
41
|
+
// `requireExisting`, a side that is not on disk is never the same file as
|
|
42
|
+
// anything spelled differently; without it, a missing path compares by its
|
|
43
|
+
// canonical spelling (the directory links above it resolved).
|
|
44
|
+
export function sameFile(left, right, { requireExisting = false } = {}) {
|
|
45
|
+
const resolvedLeft = resolve(left);
|
|
46
|
+
const resolvedRight = resolve(right);
|
|
47
|
+
if (resolvedLeft === resolvedRight) return true;
|
|
48
|
+
if (!requireExisting) return canonicalPath(resolvedLeft) === canonicalPath(resolvedRight);
|
|
49
|
+
const real = (path) => {
|
|
50
|
+
try {
|
|
51
|
+
return realpathSync(path);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (absentOrMalformed(error)) return null;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const realLeft = real(resolvedLeft);
|
|
58
|
+
return realLeft !== null && realLeft === real(resolvedRight);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The errors a best-effort read may treat as "nothing there": the path is
|
|
62
|
+
// absent (ENOENT), a component of it is not a directory (ENOTDIR), or the
|
|
63
|
+
// bytes are not JSON (SyntaxError). A permission failure, EISDIR, EIO and
|
|
64
|
+
|
|
65
|
+
// every other error are not absence and must reach the caller.
|
|
66
|
+
export function absentOrMalformed(error) {
|
|
67
|
+
return error?.code === "ENOENT" || error?.code === "ENOTDIR" || error instanceof SyntaxError;
|
|
68
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Gate actions: the vocabulary a checkpoint gate publishes as
|
|
2
|
+
// `required_actions[]` and the one rule that turns an action into the text an
|
|
3
|
+
// operator reads.
|
|
4
|
+
//
|
|
5
|
+
// A gate's action is `{ id, kind, command, description }`: a runnable command
|
|
6
|
+
// carrying the `--packet <packet>` placeholder, or a manual step with no
|
|
7
|
+
// command. Doctor, `next`, the QA runner's resolve printer and its theme-gate
|
|
8
|
+
// printer each rendered that shape for themselves — four spellings of the
|
|
9
|
+
// placeholder substitution — and the polish checkpoint's actions were declared
|
|
10
|
+
// once in polish-node and copied byte for byte into polish-gate, which
|
|
11
|
+
// polish-node imports and so could not import from. A leaf: shell-token only.
|
|
12
|
+
|
|
13
|
+
import { shellToken } from "./shell-token.mjs";
|
|
14
|
+
import { localProofRebuildText } from "./local-proof.mjs";
|
|
15
|
+
import { invocationPrefixFor } from "./install-mode.mjs";
|
|
16
|
+
import { dirname as installModeDirname, resolve as installModeResolve } from "node:path";
|
|
17
|
+
import { fileURLToPath as installModeFileUrl } from "node:url";
|
|
18
|
+
const PACKAGE_ROOT = installModeResolve(installModeDirname(installModeFileUrl(import.meta.url)), "..");
|
|
19
|
+
|
|
20
|
+
// The recorded actions of the hidden eager-media checkpoint, keyed by the
|
|
21
|
+
// short name each producer reaches for. The gate evaluators publish these
|
|
22
|
+
// objects unchanged; renderers never re-spell their text.
|
|
23
|
+
export const HIDDEN_EAGER_MEDIA_ACTIONS = Object.freeze({
|
|
24
|
+
capture: Object.freeze({
|
|
25
|
+
id: "polish.hidden_eager_media.capture",
|
|
26
|
+
kind: "command",
|
|
27
|
+
command: "campaigns-os polish capture --packet <packet> --base-url <url>",
|
|
28
|
+
description: "Capture package-owned page-load evidence for every mapped route and fixed viewport.",
|
|
29
|
+
}),
|
|
30
|
+
install_browser: Object.freeze({
|
|
31
|
+
id: "polish.hidden_eager_media.install_browser",
|
|
32
|
+
kind: "command",
|
|
33
|
+
command: "campaigns-os qa install-browser",
|
|
34
|
+
description: "Install the package-owned Playwright Chromium runtime before rerunning polish capture (npm run qa:install-browser from a checkout).",
|
|
35
|
+
}),
|
|
36
|
+
waive: Object.freeze({
|
|
37
|
+
id: "polish.hidden_eager_media.waive",
|
|
38
|
+
kind: "command",
|
|
39
|
+
command: "campaigns-os checkpoint waive --packet <packet> --gate polish.hidden_eager_media --reason \"<why>\" --waived-by \"<named human>\" --review-condition \"<trigger>\"",
|
|
40
|
+
description: "Record an exact named-human waiver for the current hidden eager-media findings.",
|
|
41
|
+
}),
|
|
42
|
+
repair: Object.freeze({
|
|
43
|
+
id: "polish.hidden_eager_media.repair",
|
|
44
|
+
kind: "manual",
|
|
45
|
+
command: null,
|
|
46
|
+
description: "Make each reported hidden media element visible, defer it with exact preload=none/metadata, or reduce its aggregate transferred bytes to at most 1,048,576; then recapture.",
|
|
47
|
+
}),
|
|
48
|
+
repair_authority: Object.freeze({
|
|
49
|
+
id: "polish.hidden_eager_media.repair_authority",
|
|
50
|
+
kind: "manual",
|
|
51
|
+
command: null,
|
|
52
|
+
description: "Repair the packet or Assembly Report campaign identity, build fingerprint, and mapped route plan before capture.",
|
|
53
|
+
}),
|
|
54
|
+
// A capture over plain HTTP whose ledger shows a failed cross-origin http:
|
|
55
|
+
// dependency is a production build served locally: its protocol-relative
|
|
56
|
+
// vendor loaders resolved to http:// and failed. The action is to rebuild in
|
|
57
|
+
// the development environment (local proof mode), never to edit the
|
|
58
|
+
// generated include that emits the loader.
|
|
59
|
+
local_proof_rebuild: Object.freeze({
|
|
60
|
+
id: "polish.hidden_eager_media.local_proof_rebuild",
|
|
61
|
+
kind: "manual",
|
|
62
|
+
command: null,
|
|
63
|
+
description: localProofRebuildText(),
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const PACKET_PLACEHOLDER = "--packet <packet>";
|
|
68
|
+
|
|
69
|
+
// The placeholder becomes the packet this run read, shell-quoted, so the
|
|
70
|
+
// command is copy-pasteable. A function replacement, so `$&`, `$$` or `$1`
|
|
71
|
+
// inside the path are inserted literally instead of being read as
|
|
72
|
+
// replacement patterns. No packet, or no command: returned as given.
|
|
73
|
+
export function substitutePacket(command, packetPath) {
|
|
74
|
+
if (typeof command !== "string" || typeof packetPath !== "string" || !packetPath) return command;
|
|
75
|
+
return command.replace(PACKET_PLACEHOLDER, () => `--packet ${shellToken(packetPath)}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Whole-token match against the declared TEMPLATE, never the substituted
|
|
79
|
+
// string: a packet path that happens to contain "--report" must not be
|
|
80
|
+
// mistaken for an option the action declared, and a flag that merely shares
|
|
81
|
+
// the prefix (say --report-format) is not the option itself.
|
|
82
|
+
function templateDeclares(template, flag) {
|
|
83
|
+
return typeof template === "string" && template.split(/\s+/).includes(flag);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The text an operator acts on: the runnable command with the packet
|
|
87
|
+
// substituted, else the manual description, else nothing. With `reportPath`,
|
|
88
|
+
// a packet-scoped command that does not already name a report gains
|
|
89
|
+
// `--report <path>`, so a remediation acts on the report the inspection read
|
|
90
|
+
// rather than on the default sidecar it would otherwise resolve.
|
|
91
|
+
export function requiredActionText(action, { packetPath = null, reportPath = null } = {}) {
|
|
92
|
+
const template = typeof action?.command === "string" ? action.command : null;
|
|
93
|
+
let command = substitutePacket(template, packetPath);
|
|
94
|
+
if (command && reportPath && templateDeclares(template, "--packet") && !templateDeclares(template, "--report")) {
|
|
95
|
+
command = `${command} --report ${shellToken(reportPath)}`;
|
|
96
|
+
}
|
|
97
|
+
// Registry commands are stored bare; the printed text is spelled for the
|
|
98
|
+
// install this package runs from (bare from a checkout, `npx campaigns-os`
|
|
99
|
+
// from a campaign folder), once, here.
|
|
100
|
+
if (command && command.startsWith("campaigns-os ")) {
|
|
101
|
+
return `${invocationPrefixFor(PACKAGE_ROOT)} ${command.slice("campaigns-os ".length)}`;
|
|
102
|
+
}
|
|
103
|
+
if (command) return command;
|
|
104
|
+
return typeof action?.description === "string" && action.description ? action.description : null;
|
|
105
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
// A synchronous doctor's snapshot ends with the invocation. No file contents
|
|
7
|
+
// survive into another command, even when a host imports the CLI once and reuses it.
|
|
8
|
+
const snapshots = new AsyncLocalStorage();
|
|
9
|
+
const MAX_RETAINED_BYTES = 16 * 1024 * 1024;
|
|
10
|
+
const MAX_RETAINED_FILES = 4096;
|
|
11
|
+
export function withHtmlScanSnapshot(operation, { reuse = false } = {}) {
|
|
12
|
+
if (reuse && snapshots.getStore()?.active) return operation();
|
|
13
|
+
const snapshot = { files: new Map(), retainedBytes: 0, active: true };
|
|
14
|
+
return snapshots.run(snapshot, () => {
|
|
15
|
+
try {
|
|
16
|
+
return operation();
|
|
17
|
+
} finally {
|
|
18
|
+
// AsyncLocalStorage propagates to queued callbacks too. Expire the store
|
|
19
|
+
// at synchronous return, even if a future caller returns a Promise.
|
|
20
|
+
snapshot.active = false;
|
|
21
|
+
snapshot.files.clear();
|
|
22
|
+
snapshot.retainedBytes = 0;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function file(path) {
|
|
28
|
+
const snapshot = snapshots.getStore();
|
|
29
|
+
if (!snapshot?.active) return { bytes: readFileSync(path) };
|
|
30
|
+
const key = resolve(path);
|
|
31
|
+
if (snapshot.files.has(key)) return snapshot.files.get(key);
|
|
32
|
+
const entry = { bytes: readFileSync(path) };
|
|
33
|
+
// Budget buffer + worst-case decoded UTF-16 text together. Oversize files
|
|
34
|
+
// retain the original read-per-use behavior; tiny files are count-bounded.
|
|
35
|
+
const cost = entry.bytes.length * 3;
|
|
36
|
+
if (snapshot.files.size < MAX_RETAINED_FILES && snapshot.retainedBytes + cost <= MAX_RETAINED_BYTES) {
|
|
37
|
+
snapshot.files.set(key, entry);
|
|
38
|
+
snapshot.retainedBytes += cost;
|
|
39
|
+
}
|
|
40
|
+
return entry;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function readHtmlScanText(path) {
|
|
44
|
+
const entry = file(path);
|
|
45
|
+
return entry.text ??= entry.bytes.toString('utf8');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Byte-preserving generic digest; only an active read-only scan scope caches it.
|
|
49
|
+
// Callers that write artifacts must hash outside that scope to observe new bytes.
|
|
50
|
+
export function htmlScanDigest(path) {
|
|
51
|
+
const entry = file(path);
|
|
52
|
+
return entry.digest ??= createHash('sha256').update(entry.bytes).digest('hex');
|
|
53
|
+
}
|