From b6b5e579228706fabb2070f933e7abc5c69a532c Mon Sep 17 00:00:00 2001 From: Root At Skic Date: Mon, 19 Oct 2026 11:42:06 +0300 Subject: [PATCH] Add archify-vscode-ext skill: Archify diagrams and models for the Archify Diagram Viewer extension Model tasks (validate, list, scaffold, resolve), diagram tasks (validate, render), the bundled Archify 3.0.1 runtime and yaml parser under scripts/vendor, references for authoring, formats and folders, and the online-shop example model. --- LICENSE | 21 + README.md | 20 + SKILL.md | 178 + .../0100-context/online-shop.archify | 26 + .../0200-containers/online-shop.archify | 39 + .../online-shop/0300-components/api.archify | 42 + examples/online-shop/main.archify | 24 + .../registry/containers.archify-registry | 37 + .../registry/people.archify-registry | 12 + .../registry/systems.archify-registry | 19 + references/authoring.md | 169 + references/folders.md | 99 + references/formats.md | 102 + scripts/.gitignore | 19 + scripts/.scripts/diagram/Taskfile.yml | 18 + .../.scripts/diagram/api/diagram--render.sh | 8 + .../.scripts/diagram/api/diagram--validate.sh | 8 + .../.scripts/diagram/lib/--env-vars-reader.sh | 6 + .../diagram/lib/--env-vars-validator.sh | 6 + scripts/.scripts/diagram/lib/--index-api.sh | 8 + scripts/.scripts/diagram/lib/--index.sh | 17 + .../.scripts/diagram/lib/-diagram--render.sh | 9 + .../diagram/lib/-diagram--validate.sh | 9 + scripts/.scripts/diagram/lib/-node--run.sh | 9 + .../.scripts/loggers/lib/--env-vars-reader.sh | 7 + scripts/.scripts/loggers/lib/--index-api.sh | 7 + scripts/.scripts/loggers/lib/--index.sh | 7 + scripts/.scripts/loggers/lib/-error.sh | 10 + scripts/.scripts/loggers/lib/-info.sh | 11 + scripts/.scripts/model/Taskfile.yml | 32 + scripts/.scripts/model/api/model--list.sh | 8 + scripts/.scripts/model/api/model--resolve.sh | 8 + scripts/.scripts/model/api/model--scaffold.sh | 8 + scripts/.scripts/model/api/model--validate.sh | 8 + .../.scripts/model/lib/--env-vars-reader.sh | 6 + .../model/lib/--env-vars-validator.sh | 6 + scripts/.scripts/model/lib/--index-api.sh | 10 + scripts/.scripts/model/lib/--index.sh | 17 + scripts/.scripts/model/lib/-model--list.sh | 9 + scripts/.scripts/model/lib/-model--resolve.sh | 9 + .../.scripts/model/lib/-model--scaffold.sh | 9 + .../.scripts/model/lib/-model--validate.sh | 9 + scripts/.scripts/model/lib/-node--run.sh | 9 + scripts/.scripts/runtime/Taskfile.yml | 11 + scripts/.scripts/runtime/api/archify--sync.sh | 8 + .../.scripts/runtime/lib/--env-vars-reader.sh | 6 + .../runtime/lib/--env-vars-validator.sh | 6 + scripts/.scripts/runtime/lib/--index-api.sh | 6 + scripts/.scripts/runtime/lib/--index.sh | 8 + .../.scripts/runtime/lib/-archify--sync.sh | 11 + scripts/.scripts/suite/Taskfile.yml | 11 + scripts/.scripts/suite/api/tests--run.sh | 8 + .../.scripts/suite/lib/--env-vars-reader.sh | 6 + .../suite/lib/--env-vars-validator.sh | 6 + scripts/.scripts/suite/lib/--index-api.sh | 6 + scripts/.scripts/suite/lib/--index.sh | 8 + scripts/.scripts/suite/lib/-tests--run.sh | 9 + scripts/Taskfile.yml | 31 + scripts/package.json | 7 + scripts/src/actions/diagram-render/action.mjs | 45 + .../src/actions/diagram-validate/action.mjs | 34 + scripts/src/actions/model-list/action.mjs | 63 + scripts/src/actions/model-resolve/action.mjs | 50 + scripts/src/actions/model-scaffold/action.mjs | 211 + scripts/src/actions/model-validate/action.mjs | 67 + scripts/src/cli/commands.mjs | 52 + scripts/src/services/archify.mjs | 123 + scripts/src/services/model.mjs | 283 + scripts/src/services/source.mjs | 42 + scripts/src/services/yaml.mjs | 46 + scripts/src/test/run.mjs | 177 + scripts/src/tools/sync-archify.mjs | 95 + scripts/vendor/archify.json | 6 + scripts/vendor/archify/LICENSE | 22 + scripts/vendor/archify/THIRD_PARTY_NOTICES.md | 69 + .../archify/assets/JetBrainsMono-OFL.txt | 93 + scripts/vendor/archify/assets/template.html | 13709 ++++++++++++++++ scripts/vendor/archify/bin/archify.mjs | 6936 ++++++++ .../vendor/archify/bin/delivery-update.mjs | 91 + scripts/vendor/archify/bin/finalize.mjs | 895 + scripts/vendor/archify/bin/open-artifact.mjs | 146 + scripts/vendor/archify/bin/preview.mjs | 969 ++ scripts/vendor/archify/bin/recover-output.mjs | 41 + scripts/vendor/archify/bin/visual-check.mjs | 2667 +++ scripts/vendor/archify/brand-marks/README.md | 31 + .../vendor/archify/brand-marks/catalog.json | 131 + .../archify/delta/architecture-delta.mjs | 1313 ++ .../vendor/archify/migrations/workflow-v2.mjs | 280 + scripts/vendor/archify/package.json | 42 + scripts/vendor/archify/recipes/scenarios.mjs | 430 + .../references/architecture-layout-repair.md | 18 + .../archify/references/authoring-contract.md | 419 + .../archify/references/authoring-defaults.md | 41 + .../vendor/archify/references/brand-marks.md | 82 + .../archify/references/delivery-contract.md | 588 + .../references/repository-authoring.md | 105 + .../archify/references/update-awareness.md | 12 + .../archify/references/viewer-runtime.md | 35 + .../archify/renderers/architecture/grid.mjs | 62 + .../archify/renderers/architecture/labels.mjs | 215 + .../architecture/render-architecture.mjs | 1001 ++ .../renderers/architecture/routing.mjs | 1157 ++ .../archify/renderers/dataflow/README.md | 105 + .../renderers/dataflow/render-dataflow.mjs | 544 + .../archify/renderers/lifecycle/README.md | 171 + .../renderers/lifecycle/grid-routing.mjs | 429 + .../renderers/lifecycle/render-lifecycle.mjs | 938 ++ .../archify/renderers/sequence/README.md | 129 + .../renderers/sequence/render-sequence.mjs | 526 + .../renderers/shared/atomic-output.mjs | 1982 +++ .../archify/renderers/shared/brand-marks.mjs | 670 + .../vendor/archify/renderers/shared/cli.mjs | 451 + .../renderers/shared/desktop-readability.mjs | 132 + .../archify/renderers/shared/diagnostics.mjs | 173 + .../renderers/shared/engineering-profiles.mjs | 157 + .../shared/generated-brand-marks.mjs | 2003 +++ .../renderers/shared/generated-validators.mjs | 134 + .../archify/renderers/shared/geometry.mjs | 1941 +++ .../vendor/archify/renderers/shared/i18n.mjs | 603 + .../renderers/shared/layout-report.mjs | 42 + .../archify/renderers/shared/legend.mjs | 217 + .../archify/renderers/shared/output-path.mjs | 440 + .../renderers/shared/path-semantics.mjs | 1017 ++ .../renderers/shared/portable-path.mjs | 408 + .../renderers/shared/repository-evidence.mjs | 352 + .../renderers/shared/repository-location.mjs | 59 + .../renderers/shared/route-quality.mjs | 614 + .../archify/renderers/shared/sidecar-path.mjs | 38 + .../archify/renderers/shared/spatial-grid.mjs | 66 + .../archify/renderers/shared/text-fit.mjs | 105 + .../vendor/archify/renderers/shared/utils.mjs | 254 + .../archify/renderers/shared/validator.mjs | 86 + .../archify/renderers/workflow/README.md | 301 + .../renderers/workflow/render-workflow.mjs | 37 + .../renderers/workflow/workflow-compiler.mjs | 4913 ++++++ .../workflow/workflow-migration-geometry.mjs | 144 + scripts/vendor/archify/schemas/README.md | 256 + .../archify/schemas/architecture.schema.json | 152 + .../vendor/archify/schemas/common.schema.json | 181 + .../archify/schemas/dataflow.schema.json | 254 + .../archify/schemas/lifecycle.schema.json | 280 + .../archify/schemas/sequence.schema.json | 234 + .../archify/schemas/workflow.schema.json | 439 + .../archify/scripts/check-render-output.mjs | 1388 ++ .../vendor/archify/scripts/check-update.mjs | 1710 ++ .../archify/scripts/delivery-update-child.mjs | 23 + .../archify/scripts/generate-brand-marks.mjs | 141 + .../archify/scripts/generate-validators.mjs | 98 + .../archify/scripts/render-examples.mjs | 26 + .../archify/scripts/update-contract.mjs | 182 + scripts/vendor/archify/skill-release.json | 10 + scripts/vendor/yaml/LICENSE | 13 + .../vendor/yaml/compose/compose-collection.js | 88 + scripts/vendor/yaml/compose/compose-doc.js | 43 + scripts/vendor/yaml/compose/compose-node.js | 109 + scripts/vendor/yaml/compose/compose-scalar.js | 86 + scripts/vendor/yaml/compose/composer.js | 219 + .../vendor/yaml/compose/resolve-block-map.js | 115 + .../yaml/compose/resolve-block-scalar.js | 198 + .../vendor/yaml/compose/resolve-block-seq.js | 49 + scripts/vendor/yaml/compose/resolve-end.js | 37 + .../yaml/compose/resolve-flow-collection.js | 207 + .../yaml/compose/resolve-flow-scalar.js | 226 + scripts/vendor/yaml/compose/resolve-props.js | 146 + .../yaml/compose/util-contains-newline.js | 34 + .../compose/util-empty-scalar-position.js | 26 + .../yaml/compose/util-flow-indent-check.js | 15 + .../vendor/yaml/compose/util-map-includes.js | 13 + scripts/vendor/yaml/doc/Document.js | 335 + scripts/vendor/yaml/doc/anchors.js | 71 + scripts/vendor/yaml/doc/applyReviver.js | 55 + scripts/vendor/yaml/doc/createNode.js | 88 + scripts/vendor/yaml/doc/directives.js | 176 + scripts/vendor/yaml/errors.js | 57 + scripts/vendor/yaml/index.js | 17 + scripts/vendor/yaml/log.js | 11 + scripts/vendor/yaml/nodes/Alias.js | 118 + scripts/vendor/yaml/nodes/Collection.js | 147 + scripts/vendor/yaml/nodes/Node.js | 38 + scripts/vendor/yaml/nodes/Pair.js | 36 + scripts/vendor/yaml/nodes/Scalar.js | 24 + scripts/vendor/yaml/nodes/YAMLMap.js | 144 + scripts/vendor/yaml/nodes/YAMLSeq.js | 113 + scripts/vendor/yaml/nodes/addPairToJSMap.js | 63 + scripts/vendor/yaml/nodes/identity.js | 36 + scripts/vendor/yaml/nodes/toJS.js | 37 + scripts/vendor/yaml/package.json | 9 + scripts/vendor/yaml/parse/cst-scalar.js | 214 + scripts/vendor/yaml/parse/cst-stringify.js | 61 + scripts/vendor/yaml/parse/cst-visit.js | 97 + scripts/vendor/yaml/parse/cst.js | 98 + scripts/vendor/yaml/parse/lexer.js | 721 + scripts/vendor/yaml/parse/line-counter.js | 39 + scripts/vendor/yaml/parse/parser.js | 975 ++ scripts/vendor/yaml/public-api.js | 102 + scripts/vendor/yaml/schema/Schema.js | 37 + scripts/vendor/yaml/schema/common/map.js | 17 + scripts/vendor/yaml/schema/common/null.js | 15 + scripts/vendor/yaml/schema/common/seq.js | 17 + scripts/vendor/yaml/schema/common/string.js | 14 + scripts/vendor/yaml/schema/core/bool.js | 19 + scripts/vendor/yaml/schema/core/float.js | 43 + scripts/vendor/yaml/schema/core/int.js | 38 + scripts/vendor/yaml/schema/core/schema.js | 23 + scripts/vendor/yaml/schema/json/schema.js | 62 + scripts/vendor/yaml/schema/tags.js | 96 + scripts/vendor/yaml/schema/yaml-1.1/binary.js | 58 + scripts/vendor/yaml/schema/yaml-1.1/bool.js | 26 + scripts/vendor/yaml/schema/yaml-1.1/float.js | 46 + scripts/vendor/yaml/schema/yaml-1.1/int.js | 71 + scripts/vendor/yaml/schema/yaml-1.1/merge.js | 67 + scripts/vendor/yaml/schema/yaml-1.1/omap.js | 74 + scripts/vendor/yaml/schema/yaml-1.1/pairs.js | 78 + scripts/vendor/yaml/schema/yaml-1.1/schema.js | 39 + scripts/vendor/yaml/schema/yaml-1.1/set.js | 93 + .../vendor/yaml/schema/yaml-1.1/timestamp.js | 101 + .../vendor/yaml/stringify/foldFlowLines.js | 146 + scripts/vendor/yaml/stringify/stringify.js | 129 + .../yaml/stringify/stringifyCollection.js | 153 + .../vendor/yaml/stringify/stringifyComment.js | 20 + .../yaml/stringify/stringifyDocument.js | 85 + .../vendor/yaml/stringify/stringifyNumber.js | 25 + .../vendor/yaml/stringify/stringifyPair.js | 150 + .../vendor/yaml/stringify/stringifyString.js | 336 + scripts/vendor/yaml/util.js | 11 + scripts/vendor/yaml/visit.js | 233 + 226 files changed, 67520 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SKILL.md create mode 100644 examples/online-shop/0100-context/online-shop.archify create mode 100644 examples/online-shop/0200-containers/online-shop.archify create mode 100644 examples/online-shop/0300-components/api.archify create mode 100644 examples/online-shop/main.archify create mode 100644 examples/online-shop/registry/containers.archify-registry create mode 100644 examples/online-shop/registry/people.archify-registry create mode 100644 examples/online-shop/registry/systems.archify-registry create mode 100644 references/authoring.md create mode 100644 references/folders.md create mode 100644 references/formats.md create mode 100644 scripts/.gitignore create mode 100644 scripts/.scripts/diagram/Taskfile.yml create mode 100755 scripts/.scripts/diagram/api/diagram--render.sh create mode 100755 scripts/.scripts/diagram/api/diagram--validate.sh create mode 100755 scripts/.scripts/diagram/lib/--env-vars-reader.sh create mode 100755 scripts/.scripts/diagram/lib/--env-vars-validator.sh create mode 100755 scripts/.scripts/diagram/lib/--index-api.sh create mode 100755 scripts/.scripts/diagram/lib/--index.sh create mode 100755 scripts/.scripts/diagram/lib/-diagram--render.sh create mode 100755 scripts/.scripts/diagram/lib/-diagram--validate.sh create mode 100755 scripts/.scripts/diagram/lib/-node--run.sh create mode 100755 scripts/.scripts/loggers/lib/--env-vars-reader.sh create mode 100755 scripts/.scripts/loggers/lib/--index-api.sh create mode 100755 scripts/.scripts/loggers/lib/--index.sh create mode 100755 scripts/.scripts/loggers/lib/-error.sh create mode 100755 scripts/.scripts/loggers/lib/-info.sh create mode 100644 scripts/.scripts/model/Taskfile.yml create mode 100755 scripts/.scripts/model/api/model--list.sh create mode 100755 scripts/.scripts/model/api/model--resolve.sh create mode 100755 scripts/.scripts/model/api/model--scaffold.sh create mode 100755 scripts/.scripts/model/api/model--validate.sh create mode 100755 scripts/.scripts/model/lib/--env-vars-reader.sh create mode 100755 scripts/.scripts/model/lib/--env-vars-validator.sh create mode 100755 scripts/.scripts/model/lib/--index-api.sh create mode 100755 scripts/.scripts/model/lib/--index.sh create mode 100755 scripts/.scripts/model/lib/-model--list.sh create mode 100755 scripts/.scripts/model/lib/-model--resolve.sh create mode 100755 scripts/.scripts/model/lib/-model--scaffold.sh create mode 100755 scripts/.scripts/model/lib/-model--validate.sh create mode 100755 scripts/.scripts/model/lib/-node--run.sh create mode 100644 scripts/.scripts/runtime/Taskfile.yml create mode 100755 scripts/.scripts/runtime/api/archify--sync.sh create mode 100755 scripts/.scripts/runtime/lib/--env-vars-reader.sh create mode 100755 scripts/.scripts/runtime/lib/--env-vars-validator.sh create mode 100755 scripts/.scripts/runtime/lib/--index-api.sh create mode 100755 scripts/.scripts/runtime/lib/--index.sh create mode 100755 scripts/.scripts/runtime/lib/-archify--sync.sh create mode 100644 scripts/.scripts/suite/Taskfile.yml create mode 100755 scripts/.scripts/suite/api/tests--run.sh create mode 100755 scripts/.scripts/suite/lib/--env-vars-reader.sh create mode 100755 scripts/.scripts/suite/lib/--env-vars-validator.sh create mode 100755 scripts/.scripts/suite/lib/--index-api.sh create mode 100755 scripts/.scripts/suite/lib/--index.sh create mode 100755 scripts/.scripts/suite/lib/-tests--run.sh create mode 100644 scripts/Taskfile.yml create mode 100644 scripts/package.json create mode 100644 scripts/src/actions/diagram-render/action.mjs create mode 100644 scripts/src/actions/diagram-validate/action.mjs create mode 100644 scripts/src/actions/model-list/action.mjs create mode 100644 scripts/src/actions/model-resolve/action.mjs create mode 100644 scripts/src/actions/model-scaffold/action.mjs create mode 100644 scripts/src/actions/model-validate/action.mjs create mode 100644 scripts/src/cli/commands.mjs create mode 100644 scripts/src/services/archify.mjs create mode 100644 scripts/src/services/model.mjs create mode 100644 scripts/src/services/source.mjs create mode 100644 scripts/src/services/yaml.mjs create mode 100644 scripts/src/test/run.mjs create mode 100644 scripts/src/tools/sync-archify.mjs create mode 100644 scripts/vendor/archify.json create mode 100644 scripts/vendor/archify/LICENSE create mode 100644 scripts/vendor/archify/THIRD_PARTY_NOTICES.md create mode 100644 scripts/vendor/archify/assets/JetBrainsMono-OFL.txt create mode 100644 scripts/vendor/archify/assets/template.html create mode 100755 scripts/vendor/archify/bin/archify.mjs create mode 100644 scripts/vendor/archify/bin/delivery-update.mjs create mode 100644 scripts/vendor/archify/bin/finalize.mjs create mode 100644 scripts/vendor/archify/bin/open-artifact.mjs create mode 100644 scripts/vendor/archify/bin/preview.mjs create mode 100644 scripts/vendor/archify/bin/recover-output.mjs create mode 100644 scripts/vendor/archify/bin/visual-check.mjs create mode 100644 scripts/vendor/archify/brand-marks/README.md create mode 100644 scripts/vendor/archify/brand-marks/catalog.json create mode 100644 scripts/vendor/archify/delta/architecture-delta.mjs create mode 100644 scripts/vendor/archify/migrations/workflow-v2.mjs create mode 100644 scripts/vendor/archify/package.json create mode 100644 scripts/vendor/archify/recipes/scenarios.mjs create mode 100644 scripts/vendor/archify/references/architecture-layout-repair.md create mode 100644 scripts/vendor/archify/references/authoring-contract.md create mode 100644 scripts/vendor/archify/references/authoring-defaults.md create mode 100644 scripts/vendor/archify/references/brand-marks.md create mode 100644 scripts/vendor/archify/references/delivery-contract.md create mode 100644 scripts/vendor/archify/references/repository-authoring.md create mode 100644 scripts/vendor/archify/references/update-awareness.md create mode 100644 scripts/vendor/archify/references/viewer-runtime.md create mode 100644 scripts/vendor/archify/renderers/architecture/grid.mjs create mode 100644 scripts/vendor/archify/renderers/architecture/labels.mjs create mode 100644 scripts/vendor/archify/renderers/architecture/render-architecture.mjs create mode 100644 scripts/vendor/archify/renderers/architecture/routing.mjs create mode 100644 scripts/vendor/archify/renderers/dataflow/README.md create mode 100644 scripts/vendor/archify/renderers/dataflow/render-dataflow.mjs create mode 100644 scripts/vendor/archify/renderers/lifecycle/README.md create mode 100644 scripts/vendor/archify/renderers/lifecycle/grid-routing.mjs create mode 100644 scripts/vendor/archify/renderers/lifecycle/render-lifecycle.mjs create mode 100644 scripts/vendor/archify/renderers/sequence/README.md create mode 100644 scripts/vendor/archify/renderers/sequence/render-sequence.mjs create mode 100644 scripts/vendor/archify/renderers/shared/atomic-output.mjs create mode 100644 scripts/vendor/archify/renderers/shared/brand-marks.mjs create mode 100644 scripts/vendor/archify/renderers/shared/cli.mjs create mode 100644 scripts/vendor/archify/renderers/shared/desktop-readability.mjs create mode 100644 scripts/vendor/archify/renderers/shared/diagnostics.mjs create mode 100644 scripts/vendor/archify/renderers/shared/engineering-profiles.mjs create mode 100644 scripts/vendor/archify/renderers/shared/generated-brand-marks.mjs create mode 100644 scripts/vendor/archify/renderers/shared/generated-validators.mjs create mode 100644 scripts/vendor/archify/renderers/shared/geometry.mjs create mode 100644 scripts/vendor/archify/renderers/shared/i18n.mjs create mode 100644 scripts/vendor/archify/renderers/shared/layout-report.mjs create mode 100644 scripts/vendor/archify/renderers/shared/legend.mjs create mode 100644 scripts/vendor/archify/renderers/shared/output-path.mjs create mode 100644 scripts/vendor/archify/renderers/shared/path-semantics.mjs create mode 100644 scripts/vendor/archify/renderers/shared/portable-path.mjs create mode 100644 scripts/vendor/archify/renderers/shared/repository-evidence.mjs create mode 100644 scripts/vendor/archify/renderers/shared/repository-location.mjs create mode 100644 scripts/vendor/archify/renderers/shared/route-quality.mjs create mode 100644 scripts/vendor/archify/renderers/shared/sidecar-path.mjs create mode 100644 scripts/vendor/archify/renderers/shared/spatial-grid.mjs create mode 100644 scripts/vendor/archify/renderers/shared/text-fit.mjs create mode 100644 scripts/vendor/archify/renderers/shared/utils.mjs create mode 100644 scripts/vendor/archify/renderers/shared/validator.mjs create mode 100644 scripts/vendor/archify/renderers/workflow/README.md create mode 100644 scripts/vendor/archify/renderers/workflow/render-workflow.mjs create mode 100644 scripts/vendor/archify/renderers/workflow/workflow-compiler.mjs create mode 100644 scripts/vendor/archify/renderers/workflow/workflow-migration-geometry.mjs create mode 100644 scripts/vendor/archify/schemas/README.md create mode 100644 scripts/vendor/archify/schemas/architecture.schema.json create mode 100644 scripts/vendor/archify/schemas/common.schema.json create mode 100644 scripts/vendor/archify/schemas/dataflow.schema.json create mode 100644 scripts/vendor/archify/schemas/lifecycle.schema.json create mode 100644 scripts/vendor/archify/schemas/sequence.schema.json create mode 100644 scripts/vendor/archify/schemas/workflow.schema.json create mode 100644 scripts/vendor/archify/scripts/check-render-output.mjs create mode 100644 scripts/vendor/archify/scripts/check-update.mjs create mode 100644 scripts/vendor/archify/scripts/delivery-update-child.mjs create mode 100644 scripts/vendor/archify/scripts/generate-brand-marks.mjs create mode 100644 scripts/vendor/archify/scripts/generate-validators.mjs create mode 100644 scripts/vendor/archify/scripts/render-examples.mjs create mode 100644 scripts/vendor/archify/scripts/update-contract.mjs create mode 100644 scripts/vendor/archify/skill-release.json create mode 100644 scripts/vendor/yaml/LICENSE create mode 100644 scripts/vendor/yaml/compose/compose-collection.js create mode 100644 scripts/vendor/yaml/compose/compose-doc.js create mode 100644 scripts/vendor/yaml/compose/compose-node.js create mode 100644 scripts/vendor/yaml/compose/compose-scalar.js create mode 100644 scripts/vendor/yaml/compose/composer.js create mode 100644 scripts/vendor/yaml/compose/resolve-block-map.js create mode 100644 scripts/vendor/yaml/compose/resolve-block-scalar.js create mode 100644 scripts/vendor/yaml/compose/resolve-block-seq.js create mode 100644 scripts/vendor/yaml/compose/resolve-end.js create mode 100644 scripts/vendor/yaml/compose/resolve-flow-collection.js create mode 100644 scripts/vendor/yaml/compose/resolve-flow-scalar.js create mode 100644 scripts/vendor/yaml/compose/resolve-props.js create mode 100644 scripts/vendor/yaml/compose/util-contains-newline.js create mode 100644 scripts/vendor/yaml/compose/util-empty-scalar-position.js create mode 100644 scripts/vendor/yaml/compose/util-flow-indent-check.js create mode 100644 scripts/vendor/yaml/compose/util-map-includes.js create mode 100644 scripts/vendor/yaml/doc/Document.js create mode 100644 scripts/vendor/yaml/doc/anchors.js create mode 100644 scripts/vendor/yaml/doc/applyReviver.js create mode 100644 scripts/vendor/yaml/doc/createNode.js create mode 100644 scripts/vendor/yaml/doc/directives.js create mode 100644 scripts/vendor/yaml/errors.js create mode 100644 scripts/vendor/yaml/index.js create mode 100644 scripts/vendor/yaml/log.js create mode 100644 scripts/vendor/yaml/nodes/Alias.js create mode 100644 scripts/vendor/yaml/nodes/Collection.js create mode 100644 scripts/vendor/yaml/nodes/Node.js create mode 100644 scripts/vendor/yaml/nodes/Pair.js create mode 100644 scripts/vendor/yaml/nodes/Scalar.js create mode 100644 scripts/vendor/yaml/nodes/YAMLMap.js create mode 100644 scripts/vendor/yaml/nodes/YAMLSeq.js create mode 100644 scripts/vendor/yaml/nodes/addPairToJSMap.js create mode 100644 scripts/vendor/yaml/nodes/identity.js create mode 100644 scripts/vendor/yaml/nodes/toJS.js create mode 100644 scripts/vendor/yaml/package.json create mode 100644 scripts/vendor/yaml/parse/cst-scalar.js create mode 100644 scripts/vendor/yaml/parse/cst-stringify.js create mode 100644 scripts/vendor/yaml/parse/cst-visit.js create mode 100644 scripts/vendor/yaml/parse/cst.js create mode 100644 scripts/vendor/yaml/parse/lexer.js create mode 100644 scripts/vendor/yaml/parse/line-counter.js create mode 100644 scripts/vendor/yaml/parse/parser.js create mode 100644 scripts/vendor/yaml/public-api.js create mode 100644 scripts/vendor/yaml/schema/Schema.js create mode 100644 scripts/vendor/yaml/schema/common/map.js create mode 100644 scripts/vendor/yaml/schema/common/null.js create mode 100644 scripts/vendor/yaml/schema/common/seq.js create mode 100644 scripts/vendor/yaml/schema/common/string.js create mode 100644 scripts/vendor/yaml/schema/core/bool.js create mode 100644 scripts/vendor/yaml/schema/core/float.js create mode 100644 scripts/vendor/yaml/schema/core/int.js create mode 100644 scripts/vendor/yaml/schema/core/schema.js create mode 100644 scripts/vendor/yaml/schema/json/schema.js create mode 100644 scripts/vendor/yaml/schema/tags.js create mode 100644 scripts/vendor/yaml/schema/yaml-1.1/binary.js create mode 100644 scripts/vendor/yaml/schema/yaml-1.1/bool.js create mode 100644 scripts/vendor/yaml/schema/yaml-1.1/float.js create mode 100644 scripts/vendor/yaml/schema/yaml-1.1/int.js create mode 100644 scripts/vendor/yaml/schema/yaml-1.1/merge.js create mode 100644 scripts/vendor/yaml/schema/yaml-1.1/omap.js create mode 100644 scripts/vendor/yaml/schema/yaml-1.1/pairs.js create mode 100644 scripts/vendor/yaml/schema/yaml-1.1/schema.js create mode 100644 scripts/vendor/yaml/schema/yaml-1.1/set.js create mode 100644 scripts/vendor/yaml/schema/yaml-1.1/timestamp.js create mode 100644 scripts/vendor/yaml/stringify/foldFlowLines.js create mode 100644 scripts/vendor/yaml/stringify/stringify.js create mode 100644 scripts/vendor/yaml/stringify/stringifyCollection.js create mode 100644 scripts/vendor/yaml/stringify/stringifyComment.js create mode 100644 scripts/vendor/yaml/stringify/stringifyDocument.js create mode 100644 scripts/vendor/yaml/stringify/stringifyNumber.js create mode 100644 scripts/vendor/yaml/stringify/stringifyPair.js create mode 100644 scripts/vendor/yaml/stringify/stringifyString.js create mode 100644 scripts/vendor/yaml/util.js create mode 100644 scripts/vendor/yaml/visit.js diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0516bbd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Root At Skic + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..38a9fae --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# archify-vscode-ext + +Agent Skill for Archify diagrams and models as read by the [Archify Diagram Viewer](https://marketplace.visualstudio.com/items?itemName=root-at-skic.archify-vscode-ext) VS Code extension: how to write `.archify` diagrams (architecture, workflow, sequence, dataflow, lifecycle), how to put them together as a model (`main.archify`, shared elements in `*.archify-registry` files, `ref:` components, `diagram:` links, C4-style folders), and tasks that validate, list, scaffold, resolve and render them. The Archify validator and renderer (MIT, by tt-a1i) are bundled under `scripts/vendor`, so nothing else needs to be installed. + +Quick start: + +```bash +cd scripts +task model:scaffold -- --dir="/path/to/online-shop" --id="online-shop" --name="Online Shop" --c4 +task model:validate -- --dir="/path/to/online-shop" +task diagram:render -- --file="/path/to/online-shop/0200-containers/online-shop.archify" --out="/tmp/containers.html" +``` + +See [SKILL.md](SKILL.md) for the workflow, the task table and the gotchas; `examples/online-shop/` is a complete model that validates. + +Requires Node.js 18+, bash and [go-task](https://taskfile.dev). The skill targets the Archify Diagram Viewer extension; a diagram that validates here opens the same way in VS Code. + +## Support + +If this saves you time, you can buy me a coffee: https://buymeacoffee.com/root.at.skic diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..10f8425 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,178 @@ +--- +name: archify-vscode-ext +description: > + Author, validate, render and manage Archify diagrams (.archify YAML files of the kinds + architecture, workflow, sequence, dataflow and lifecycle) and Archify models: a folder + with main.archify on top, shared elements in *.archify-registry files, ref: components, + diagram: links and C4-style folders, as read by the Archify Diagram Viewer VS Code + extension. Bundles the Archify validator and renderer, so diagrams are checked and + rendered to HTML without VS Code. Use this skill when asked to create, edit, fix, + validate, render or organise .archify files, build a C4 or architecture model as code, + add a shared element or a registry, scaffold a model folder, or explain why a diagram + does not validate, even if the user only says "draw the architecture", "make a diagram + of this system", "put the diagrams together" or "the diagram shows an error". +license: MIT +compatibility: Requires Node.js 18+, bash and go-task (task). No network access; Archify is bundled under scripts/vendor. +metadata: + author: root-at-skic + version: "1.0" + origin-repository: https://gitea.lego-cloud.eu/vscode-extensions-skills/archify-vscode-ext.git + targets: Archify Diagram Viewer (root-at-skic.archify-vscode-ext) +--- + +# Archify diagrams and models + +Write diagrams as text next to the code, in the form the Archify Diagram Viewer extension opens as interactive pictures. One `.archify` file is one diagram. A folder with `main.archify` on top is a model: diagrams that belong together, shared elements defined once in a registry, and links between diagrams. + +## Overview + +Archify (MIT, by tt-a1i) is the renderer and validator. The extension bundles it; so does this skill (`scripts/vendor/archify`), so every task here runs on plain Node and needs nothing installed. The model layer (registry, `ref`, `diagram:` links) is the extension's; this skill resolves it the same way the extension does before Archify sees a diagram. Every task prints YAML on stdout (`success: true|false` first) and logs to stderr. + +All tasks run from this skill's `scripts/` folder: + +```bash +cd /scripts +task --list-all +``` + +| Task | What it does | +|---|---| +| `task model:validate -- --dir=""` | Registry, every diagram (refs resolved, Archify validator), every `diagram:` link. Exit 1 on any error. Works on a single diagram outside a model too. | +| `task model:list -- --dir=""` | What the model page shows: metadata, diagrams by folder, registry elements with "used in N diagrams". | +| `task model:scaffold -- --dir="" --id="" --name="" [--description="…"] [--c4]` | Creates `main.archify`; with `--c4` also `registry/` and the `0100-context`, `0200-containers`, `0300-components` folders with starter diagrams. Never overwrites. | +| `task model:resolve -- --file="" [--out=""]` | The Archify JSON the extension hands to Archify, for the plain Archify CLI or CI. | +| `task diagram:validate -- --file=""` | One diagram through Archify (refs resolved when inside a model). | +| `task diagram:render -- --file="" --out=""` | Standalone HTML page for people without VS Code. | +| `task test` | The skill's own checks against `examples/online-shop` and a scaffolded model. | +| `task sync` | Refresh `scripts/vendor/archify` from an Archify checkout (maintainers only). | + +Options for validate and render: `--quality=showcase` (stricter composition checks), `--repo-root=` for diagrams with `meta.repository` and `sources`. + +## Workflow + +### 1. Diagram or model? + +- One picture, no shared things: a single `.archify` file anywhere. Skip to step 4 and write inline elements (`id`, `type`, `label`). +- Several pictures of one system, the same elements in more than one of them, levels (context, containers, components), "click the API to see inside": a model. Start with step 2. + +A diagram inside a model is still a plain diagram: it opens, renders and exports on its own. + +### 2. Start a model + +New folder: + +```bash +task model:scaffold -- --dir="/path/to/online-shop" --id="online-shop" --name="Online Shop" --description="Web shop, API, workers and stores." --c4 +``` + +Existing folder of diagrams: add `main.archify` with the `model` block (see `references/formats.md`); every `.archify` below it is now part of the model. `--c4` is a convention, not a requirement: any folders, any depth. + +The model's root is the nearest folder above a file that holds `main.archify`. Nothing else marks it. `main.archify` may also hold a diagram of its own (the overview), below the `model` block. + +### 3. Add a shared element + +Put it in a `*.archify-registry` file under the root (one file or many; `registry/people`, `registry/systems`, `registry/containers` is the C4 split). The key is the id, unique across all registry files: + +```yaml +schema_version: 1 +elements: + api: + type: backend # Archify component type, fixed for the element + label: API + sublabel: Node.js + brand: node-js # optional, an Archify brand id + description: > + Owns authentication, orders and the catalogue. + diagram: 0300-components/api.archify # the diagram that explains it, relative to the root +``` + +Fields that reach Archify: `type`, `label`, `sublabel`, `tag`, `icon`, `brand`, `sources`. `description` and `diagram` belong to the model only. Write `description` with `>` (a folded block): a plain scalar with `: ` in it is a YAML error. + +### 4. Write a diagram + +```yaml +schema_version: 1 +diagram_type: architecture +meta: + title: Containers + output: containers.html # required by Archify; relative .html path +components: + - ref: api # from the registry; id defaults to "api" + pos: [460, 160] + size: [130, 60] + - ref: db + label: Orders DB # label, sublabel, tag may be overridden + pos: [670, 260] + size: [130, 60] + - id: cron # unshared, inline, as in any diagram + type: backend + label: Nightly job + pos: [670, 460] + size: [130, 60] +connections: + - { from: api, to: db, label: SQL } +``` + +- `ref` plus the diagram's own geometry (`pos`, `size`, or `row`/`col`). `id: db-replica` with `ref: db` draws the same element under another id. +- Override only `label`, `sublabel`, `tag`. `type`, `brand`, `icon`, `sources` come from the registry; if they differ, it is a different element. +- A component may add `diagram: ` to link to another diagram; it wins over the registry's link. +- Connections, boundaries, lanes and everything else are plain Archify and use the ids. + +Read `references/authoring.md` before writing a diagram of a kind you have not written in this session: the five kinds, fields, the position grid, labels, boundaries and the validator's error codes with their fixes. + +### 5. Validate after every change + +```bash +task model:validate -- --dir="/path/to/online-shop" +``` + +Read the output top down: + +- `summary.errors` and `summary.warnings`; `diagramsOk` of `diagrams`. +- `problems`: model-level (duplicate ids across registries, a nested `main.archify`, bad links in registries). +- `diagrams[].problems`: per file, each with `file`, `line`, `pointer` (JSON pointer into the file, `/components/3/ref`), `code` for Archify's own diagnostics, and the message. Archify messages often end with a suggested fix (`move "db" pos to [178, 70]`, `labelAt`); apply that value rather than guessing. + +Fix in this order: YAML syntax, registry problems, unknown refs and forbidden overrides, schema errors (`schema/…`), node overlap and out-of-range placement, edges through nodes, crossings and corridors, label clearance. Run again until `success: true`. For one file, `task diagram:validate -- --file=…` is faster. `--quality=showcase` adds composition warnings; use it before a diagram is shown to people. + +### 6. Render for people without VS Code + +```bash +task diagram:render -- --file="/path/to/online-shop/0200-containers/online-shop.archify" --out="/tmp/containers.html" +``` + +A standalone HTML page with Archify's viewer (focus, search, export). Links to other diagrams are not followed outside VS Code. `task model:resolve -- --file=… --out=x.json` gives the resolved JSON when someone wants to run the plain Archify CLI (`node scripts/vendor/archify/bin/archify.mjs validate architecture x.json --json`). + +## Folder conventions + +``` +online-shop/ + main.archify model block (+ optional overview diagram) + registry/*.archify-registry shared elements, split any way + 0100-context/… groups are folders; numbers only sort them + 0200-containers/… + 0300-components/… +``` + +Diagrams next to `main.archify` are listed first, ungrouped; deeper ones by folder path. Read `references/folders.md` for the layouts (single diagram, smallest model, grouped, C4 with 0400-code) and how levels hang together through `diagram:` links. + +## Gotchas + +- **Colons in plain scalars.** `description: Orders: the source of truth` is a YAML error (nested mapping). Use `description: >` and indent the text, or quote it. The same for labels with `: `. +- **The `model` block must not reach Archify.** Archify's schemas set `additionalProperties: false` at every level; any unknown key (`model`, `ref`, `description`, `diagram`, a typo like `colour`) fails as `schema/additionalProperties`. The tasks strip the model keys; everything else must be a schema field. +- **`type` and `brand` cannot be overridden** on a `ref` component. The validator reports `"type" of shared element "db" cannot be overridden`. Change the registry or use a different element. +- **Ids are unique per diagram.** Two `ref: db` entries in one diagram clash; give the second `id: db-replica`. +- **`ref` outside a model is an error.** Without `main.archify` above the file there is no registry; the extension would show the component as `external` with the ref name. Define it inline or create the model. +- **Overlapping labels need `labelAt`** (or `labelDx`/`labelDy`). Archify reports label clearance with a suggested point; set exactly that. Shortening the label is the last resort, never deleting it. +- **`meta.output` is required** and must be a relative POSIX `.html` path (`containers.html`); it is not where the render task writes, `--out` is. +- **Boxes need clear gaps.** Components closer than 8px fail; labels wider than the box fail (`6.5px` per character plus 13). The grid in `references/authoring.md` keeps a standard layout clear. +- **Links are relative to the model root**, never to the file, and must end in `.archify` and exist. `..` is refused. +- **A second `main.archify` deeper down** is reported, not nested. One model per tree. +- **Diagram kinds other than architecture** take `ref` too (workflow and dataflow `nodes`, sequence `participants`, lifecycle `states`), but the registry `type` must fit that kind: lifecycle states use their own type enum, and sequence participants have no `tag`. +- **No em dashes or smart quotes in ids**; ids match `^[a-zA-Z][a-zA-Z0-9_-]*$`. + +## References + +- Read `references/authoring.md` when writing or repairing a diagram: kinds, fields, grid, labels, boundaries, brands, validator codes and fixes. +- Read `references/formats.md` when writing `main.archify`, a registry, a `ref` or a link, or when unsure what the tasks strip before Archify runs. +- Read `references/folders.md` when laying out a model folder or explaining how the extension finds the root and groups diagrams. +- `examples/online-shop/` is a complete C4-style model that validates with 0 problems; copy its shape. diff --git a/examples/online-shop/0100-context/online-shop.archify b/examples/online-shop/0100-context/online-shop.archify new file mode 100644 index 0000000..746dec8 --- /dev/null +++ b/examples/online-shop/0100-context/online-shop.archify @@ -0,0 +1,26 @@ +schema_version: 1 +diagram_type: architecture +meta: + title: System context + output: context.html +components: + - ref: customer + pos: [40, 60] + size: [130, 60] + - ref: support + pos: [40, 260] + size: [130, 60] + - ref: shop + pos: [280, 160] + size: [150, 60] + - ref: psp + pos: [520, 60] + size: [150, 60] + - ref: email + pos: [520, 260] + size: [150, 60] +connections: + - { from: customer, to: shop, label: buys, variant: emphasis } + - { from: support, to: shop, label: manages orders } + - { from: shop, to: psp, label: charges, variant: security } + - { from: shop, to: email, label: sends mail } diff --git a/examples/online-shop/0200-containers/online-shop.archify b/examples/online-shop/0200-containers/online-shop.archify new file mode 100644 index 0000000..8d960d8 --- /dev/null +++ b/examples/online-shop/0200-containers/online-shop.archify @@ -0,0 +1,39 @@ +schema_version: 1 +diagram_type: architecture +meta: + title: Containers + output: containers.html +components: + - ref: customer + pos: [40, 160] + size: [130, 60] + - ref: web + pos: [250, 160] + size: [130, 60] + - ref: api + pos: [460, 160] + size: [130, 60] + - ref: cache + pos: [670, 60] + size: [130, 60] + - ref: db + pos: [670, 260] + size: [130, 60] + - ref: queue + pos: [460, 360] + size: [130, 60] + - ref: worker + pos: [670, 460] + size: [130, 60] +boundaries: + - kind: region + label: Online Shop + wraps: [web, api, cache, db, queue, worker] +connections: + - { from: customer, to: web, label: HTTPS, variant: emphasis } + - { from: web, to: api, label: JSON } + - { from: api, to: cache, label: read } + - { from: api, to: db, label: SQL } + - { from: api, to: queue, label: enqueue, labelAt: [560, 290] } + - { from: queue, to: worker, label: poll } + - { from: worker, to: db, label: SQL, labelAt: [735, 420] } diff --git a/examples/online-shop/0300-components/api.archify b/examples/online-shop/0300-components/api.archify new file mode 100644 index 0000000..183a998 --- /dev/null +++ b/examples/online-shop/0300-components/api.archify @@ -0,0 +1,42 @@ +schema_version: 1 +diagram_type: architecture +meta: + title: API components + output: api.html +components: + - ref: web + pos: [40, 160] + size: [130, 60] + - id: auth + type: security + label: Auth + sublabel: sessions, OIDC + pos: [250, 60] + size: [130, 60] + - id: orders + type: backend + label: Orders + pos: [250, 160] + size: [130, 60] + - id: catalogue + type: backend + label: Catalogue + pos: [250, 260] + size: [130, 60] + - ref: db + pos: [460, 160] + size: [130, 60] + - ref: queue + pos: [460, 310] + size: [130, 60] +boundaries: + - kind: region + label: API + wraps: [auth, orders, catalogue] +connections: + - { from: web, to: auth, label: sign in, variant: security } + - { from: web, to: orders, label: place order } + - { from: web, to: catalogue, label: browse } + - { from: orders, to: db, label: SQL } + - { from: catalogue, to: db, label: SQL } + - { from: orders, to: queue, label: enqueue } diff --git a/examples/online-shop/main.archify b/examples/online-shop/main.archify new file mode 100644 index 0000000..fc4be8a --- /dev/null +++ b/examples/online-shop/main.archify @@ -0,0 +1,24 @@ +schema_version: 1 +model: + id: online-shop + name: Online Shop + description: > + The web shop, its API and workers, and the stores behind them. + Context, containers and components, C4 style. +diagram_type: architecture +meta: + title: Online Shop — overview + output: main.html +components: + - ref: customer + pos: [40, 160] + size: [130, 60] + - ref: shop + pos: [250, 160] + size: [150, 60] + - ref: psp + pos: [480, 160] + size: [150, 60] +connections: + - { from: customer, to: shop, label: buys, variant: emphasis } + - { from: shop, to: psp, label: charges } diff --git a/examples/online-shop/registry/containers.archify-registry b/examples/online-shop/registry/containers.archify-registry new file mode 100644 index 0000000..236e997 --- /dev/null +++ b/examples/online-shop/registry/containers.archify-registry @@ -0,0 +1,37 @@ +schema_version: 1 +elements: + web: + type: frontend + label: Web App + sublabel: React + brand: react + description: The shop as customers see it; talks only to the API. + api: + type: backend + label: API + sublabel: Node.js + brand: node-js + description: Owns authentication, orders and the catalogue. + diagram: 0300-components/api.archify + worker: + type: backend + label: Worker + sublabel: async jobs + description: Picks jobs from the queue; emails, invoices, exports. + queue: + type: messagebus + label: Queue + sublabel: SQS + description: Job queue between the API and the workers. + db: + type: database + label: Database + sublabel: PostgreSQL + brand: postgresql + description: Orders, customers and catalogue; the source of truth. + cache: + type: database + label: Cache + sublabel: Redis + brand: redis + description: Sessions and catalogue cache; nothing here is the source of truth. diff --git a/examples/online-shop/registry/people.archify-registry b/examples/online-shop/registry/people.archify-registry new file mode 100644 index 0000000..ab5e4d5 --- /dev/null +++ b/examples/online-shop/registry/people.archify-registry @@ -0,0 +1,12 @@ +schema_version: 1 +elements: + customer: + type: external + label: Customer + sublabel: web and mobile + description: Buys products; signs in with email or a social account. + support: + type: external + label: Support agent + sublabel: back office + description: Handles refunds and order problems in the admin console. diff --git a/examples/online-shop/registry/systems.archify-registry b/examples/online-shop/registry/systems.archify-registry new file mode 100644 index 0000000..1afa79a --- /dev/null +++ b/examples/online-shop/registry/systems.archify-registry @@ -0,0 +1,19 @@ +schema_version: 1 +elements: + shop: + type: cloud + label: Online Shop + sublabel: the system + description: Everything we build and run; web app, API, workers and stores. + diagram: 0200-containers/online-shop.archify + psp: + type: external + label: Payment provider + sublabel: Stripe + brand: stripe + description: Charges cards and pays out; we never see card numbers. + email: + type: external + label: Email service + sublabel: transactional mail + description: Sends order confirmations and password resets. diff --git a/references/authoring.md b/references/authoring.md new file mode 100644 index 0000000..6ed2fb3 --- /dev/null +++ b/references/authoring.md @@ -0,0 +1,169 @@ +# Authoring Archify diagrams + +What an agent needs to write a diagram that validates and reads well. The schemas under `scripts/vendor/archify/schemas/` are the authority; read the kind's schema and `common.schema.json` before using a field not listed here. Archify rejects unknown keys everywhere (`additionalProperties: false`). + +## Every diagram + +```yaml +schema_version: 1 # 1 for architecture, sequence, dataflow; 2 for new workflow and lifecycle +diagram_type: architecture # architecture | workflow | sequence | dataflow | lifecycle +meta: + title: Containers # required + output: containers.html # required: relative POSIX path ending in .html, no "..", no drive letters +``` + +Optional `meta` keys: `subtitle` (omit unless asked), `viewBox: [w, h]` (omit for architecture; Archify measures), `legend` (`mode: auto|all|hidden`, `entries: { backend: { label: Service } }` to rename), `locale` (`en`, `zh-CN`; others need `translations`), `visual_preset` (`classic` default, `signal-flow`, `blueprint`, `editorial`; only on request), `quality_profile` (`standard`, `showcase`), `animation: trace`, `repository` plus node `sources` for source-backed diagrams, `cards` (summary blocks under the diagram: `dot`, `title`, `items`). + +Ids match `^[a-zA-Z][a-zA-Z0-9_-]*$` and are unique in their collection. Relationship `id`s are optional (they give stable `#relation=` links). + +## Node fields (all kinds) + +| Field | Values | +|---|---| +| `type` | `frontend`, `backend`, `database`, `cloud`, `security`, `messagebus`, `external` (lifecycle states: `start`, `active`, `waiting`, `decision`, `success`, `failure`, `neutral`, `external`) | +| `label` | required; keep it short, the box is small | +| `sublabel` | one fact: technology, protocol, role | +| `tag` | small badge text (not in sequence participants) | +| `icon` | corner symbol: `calendar`, `clock`, `person`, `briefcase`, `flag`, `moon`, a type name, or `none` | +| `brand` | an Archify brand id (see Brands) | +| `sources` | 1 to 3 `{ path, line, end_line, label }` repository references, with `meta.repository` | + +`type` sets colour and legend entry. Pick by role: people and outside systems `external`; the own system as one box `cloud`; UIs `frontend`; services and jobs `backend`; stores and caches `database`; queues and topics `messagebus`; auth, gateways, secrets `security`. + +## Architecture + +Collections: `components` (required), `connections`, `boundaries`, `cards`, `layout`. + +Placement is yours; Archify routes the lines but never moves boxes. Use free positions on this grid, which is what the templates and `examples/online-shop` use: + +- box `size: [130, 60]` (`[150, 60]` for a system-level box with a longer label) +- columns at x = 40, 250, 460, 670, 880 (step 210: an 80px clear gap between boxes) +- rows at y = 60, 160, 260, 360, 460 (step 100: a 40px gap) + +Rules of thumb that keep the validator quiet: + +- Place by connections first: main path left to right, neighbours adjacent; a store or branch directly above or below its owner, centred, so the edge is one straight segment; a return edge on the side without branches. +- A horizontal label on the 80px gap fits about 9 ASCII characters (`6.5px per character + 21px`). Longer labels: widen the step (250), or set `labelAt` when the validator asks. +- Label width inside a box: `6.5px per character + 13px` must be under the box width; a 130px box takes about 18 characters. Sublabel: `5.4px per character + 8px`. +- A side with k connections needs `32 + 14 * (k - 1)` px; spread a hub's partners over two or three sides. +- External actors stay outside the boundary, including its padding. +- Omit `meta.viewBox`; Archify sizes the canvas. + +Connections: `from`, `to`, `label`, `variant` (`default`, `emphasis` for the main path, `security`, `dashed`), `fromSide`/`toSide` (`left|right|top|bottom`, pin only when needed), `route` (`auto`, `straight`, `orthogonal-h`, `orthogonal-v`), `via: [[x, y], ...]` (explicit waypoints, must leave the source outward on the pinned side), `labelAt: [x, y]`, `labelDx`, `labelDy`, `labelSegment`, `width`. + +Boundaries: `kind: region | security-group`, `label`, `wraps: [ids]`, `pad`. A boundary states a real ownership, trust or deployment fact; it does not replace connections. + +Alternative to `pos`: `row`/`col` per component with `layout: { mode: grid, origin, cols, cellW, cellH, gapX, gapY }`. Do not mix `pos` and `row`/`col` in one diagram: Archify stops with `internal/unclassified` instead of a readable error. + +## Workflow (schema_version 2) + +```yaml +schema_version: 2 +diagram_type: workflow +meta: { title: Change approval, output: workflow.html } +lanes: + - { id: author, label: Author } + - { id: review, label: Reviewers } +mainPath: [draft, review, approved, released] +nodes: + - { id: draft, lane: author, col: 0, type: frontend, label: Draft change } + - { id: review, lane: review, col: 1, type: security, label: Review, sublabel: two approvals } +edges: + - { id: submit, from: draft, to: review, label: submit } + - { from: review, to: draft, label: changes requested, role: branch, variant: dashed } +``` + +`col` is 0..5, the logical step. `mainPath` lists the happy path in order; keep it monotonic. `edges.role`: `main`, `branch`, `async`, `return`, `error`. Optional `phases` and `groups` (`fromCol`, `toCol`) and `yOffset` per node (symmetric values like -90/0/90 inside one lane). `meta.legend.entries` renames types for non-software plans (see `everyday-plan` in the extension's templates: `backend` as "Time off", icons `calendar`, `briefcase`). + +## Sequence + +```yaml +schema_version: 1 +diagram_type: sequence +meta: { title: Login, output: sequence.html, viewBox: [960, 520] } +participants: + - { id: client, type: external, label: Client } + - { id: api, type: backend, label: API } +messages: + - { from: client, to: api, y: 160, label: POST /login, variant: emphasis } + - { from: api, to: client, y: 210, label: 200 OK, variant: return } +``` + +Participants are columns in order; messages run top to bottom by `y` (50px apart is comfortable). `variant` adds `return`. Optional `note` per message, `segments` (`from`, `to`, `label`) and `activations` (`participant`, `from`, `to`). `meta.column_fit: spread` uses a wide viewBox for column distance. + +## Dataflow + +```yaml +schema_version: 1 +diagram_type: dataflow +meta: { title: Sales reporting, output: dataflow.html, viewBox: [960, 420] } +stages: [{ label: Sources }, { label: Processing }, { label: Storage }] +nodes: + - { id: shop, type: frontend, label: Web shop, stage: 0, row: 0 } + - { id: etl, type: backend, label: Nightly ETL, stage: 1, row: 0 } +flows: + - { from: shop, to: etl, label: orders } + - { from: crm, to: etl, label: customers, classification: PII, variant: security } +``` + +Stages are columns (index into `stages`), rows separate parallel streams. Label only data contracts, classifications and cross-boundary movement. + +## Lifecycle (schema_version 2) + +```yaml +schema_version: 2 +diagram_type: lifecycle +meta: { title: Order status, output: lifecycle.html } +lanes: + - { id: main, label: Normal path } + - { id: exits, label: Exits } +states: + - { id: placed, type: start, label: Placed, lane: main, col: 0 } + - { id: paid, type: active, label: Paid, lane: main, col: 1 } + - { id: cancelled, type: failure, label: Cancelled, lane: exits, col: 0 } +transitions: + - { id: pay, from: placed, to: paid } + - { id: cancel, from: placed, to: cancelled, variant: security } +``` + +Each lane is a row (`main` first, `terminal` last); `col` 0..4 is one shared x grid, so put an exit in the column of the state it leaves. Every transition is authored, including the main path. A recoverable failure needs a real transition back. Omit `viewBox` in v2. + +## Brands + +`brand` puts a product logo on a node. Use the canonical id from the bundled catalogue: + +```bash +node scripts/vendor/archify/bin/archify.mjs brands "kafka" --json # id apache-kafka +node scripts/vendor/archify/bin/archify.mjs brands --json # all 107 +``` + +Ids include `postgresql`, `redis`, `mysql`, `mongodb`, `apache-kafka`, `rabbitmq`, `docker`, `kubernetes`, `terraform`, `node-js`, `react`, `vue`, `python`, `go`, `rust`, `typescript`, `spring`, `django`, `fastapi`, `github`, `gitlab`, `jenkins`, `grafana`, `prometheus`, `stripe`, `paypal`, `openai`, `anthropic`, `claude`, `google-cloud`, `cloudflare`, `vercel`, `supabase`, `snowflake`, `databricks`. An unknown brand is a validation error; omit `brand` rather than guess. A URL brand needs `archify brands capture ` first (network) and the returned digest. + +## Validator output and fixes + +`task model:validate` and `task diagram:validate` map Archify's diagnostics to the file, line and JSON pointer. Fix in this order, one coherent change at a time, validating after each: + +| Code or message | Cause | Fix | +|---|---|---| +| `YAML: ...` | syntax error, usually `: ` inside a plain scalar | `>` block scalar or quotes | +| `schema/additionalProperties` | an unknown key | remove or rename it; model keys only in registries and `ref` nodes | +| `schema/...` other | wrong enum, missing `meta.output`, bad id, `schema_version` not allowed for the kind | read the message; the pointer names the node | +| `Label "..." is wider than component` | label too long for `size` | widen `size[0]` or shorten | +| `Components "a" and "b" are less than 8px apart` | overlap | use the suggested `pos` | +| `clean-flow/edge-through-node` | an edge crosses an unrelated box | move the box off the corridor, or `fromSide`/`toSide`, or `via` | +| `clean-flow/endpoint-side-direction` | `via` leaves the box inward | first waypoint must be outward on the pinned side | +| `composition/label-gap`, `label_route_clearance` | labels collide with routes, boxes or each other | enlarge the gap or set the suggested `labelAt`; never delete the label | +| `layout/boundary-out-of-bounds`, `layout/route-out-of-bounds` | negative coordinates or an authored `viewBox` too small | move inward, or remove/enlarge `viewBox` | +| `composition/excessive-route-detour` | an explicit `via` is far longer than needed | remove the `via` | +| `Component ids must be unique` | duplicate id | rename, or `id:` next to `ref:` | +| `brand/unknown` | brand id not in the catalogue; the message lists the closest ids | use one of them, or omit `brand` | + +`--quality=showcase` turns composition warnings (crossings, corridors, border runs, route rhythm) into failures; use it for diagrams that will be presented. `--layout-json` on the plain Archify CLI (`archify validate architecture x.json --layout-json`) prints the resolved boxes, routes and label positions when a repair needs measurements; get `x.json` from `task model:resolve`. + +## Composition + +- One obvious main path, `variant: emphasis` on it. Branches beside their owner. Stores below their writer. +- Labels carry meaning (protocol, action, direction, async). Omit one only when both endpoints imply it. +- Group only real boundaries. A boundary around one box needs a real isolation fact. +- A C4 level answers one question; put details in the next level and link it with `diagram:`. +- Title only; no subtitle, no preset, no legend overrides unless asked. diff --git a/references/folders.md b/references/folders.md new file mode 100644 index 0000000..28d4b90 --- /dev/null +++ b/references/folders.md @@ -0,0 +1,99 @@ +# Folder layouts + +Diagrams are `.archify` files; a registry is an `.archify-registry` file. The extension knows a file by its extension; the names in front are yours, with one reserved name: `main.archify`, the entry point of a model. + +## A single diagram + +``` +anywhere/ + payment-flow.archify +``` + +Nothing else. Works as a plain diagram, also when it sits inside a model folder. + +## A model, smallest form + +``` +online-shop/ + main.archify entry point: id, name, description, optionally a diagram + online-shop.archify-registry one name for one thing: what several diagrams use + containers.archify + checkout-flow.archify + order-lifecycle.archify +``` + +The folder that contains `main.archify` is the root; everything below belongs to the model. Any number of diagrams of any kind. A diagram that uses a thing from the registry refers to it by name; a diagram that does not is still a plain diagram. + +## A larger model, grouped + +``` +online-shop/ + main.archify + registry/ + people.archify-registry + services.archify-registry + stores.archify-registry + services/ + containers.archify + api-components.archify + processes/ + checkout-flow.archify + refund-sequence.archify + order-lifecycle.archify +``` + +Groups are folders you choose, shown in folder order. The registry may be one file or a folder of files split any way (by kind, by team). + +## C4 + +The four C4 levels are four groups of diagrams. The registry holds the things C4 talks about: people, software systems, containers, components. Each level's diagram zooms into one thing from the level above, and the registry entry of that thing points at its diagram. + +``` +online-shop/ + main.archify id, name, description; may hold an overview diagram + registry/ + people.archify-registry customer, support agent + systems.archify-registry online shop, payment provider, email service + containers.archify-registry web app, api, worker, database, queue + components.archify-registry api: auth, orders, catalogue; worker: mailer, invoicing + 0100-context/ + online-shop.archify people and systems around the shop + 0200-containers/ + online-shop.archify inside the shop: web app, api, worker, database, queue + 0300-components/ + api.archify inside the api: auth, orders, catalogue + worker.archify inside the worker: mailer, invoicing + 0400-code/ optional; usually left out + orders.archify classes inside the orders component +``` + +How the levels hang together: + +- The system "online shop" in `systems.archify-registry` points at `0200-containers/online-shop.archify`. Click the shop in the context diagram and the containers diagram opens. +- The container "api" in `containers.archify-registry` points at `0300-components/api.archify`. Click the api, see its components. +- The same api, drawn in the containers diagram and in several component diagrams, is one entry with one description. + +The numbered folders are only for sorting; nothing depends on the names. A team that wants only context and containers has `main.archify`, two folders and two registry files. `task model:scaffold --c4` creates this shape with three levels and empty registries; `examples/online-shop/` in this skill is the filled-in version. + +Component types that fit C4: people are `external`; the system itself and other systems are `cloud` (own) or `external` (others); containers are `frontend`, `backend`, `database`, `messagebus`, `security`; components inside a container use the same set, wrapped in a `region` boundary named after the container. + +## How the extension knows it is in a model + +The nearest folder above a file that contains `main.archify` is the root. Under that root, at any depth: `*.archify` are member diagrams, `*.archify-registry` make up the registry, subfolders are groups. `node_modules` and `.git` are skipped. + +- No `main.archify` anywhere above: a single diagram. +- A diagram inside a model still opens, renders and exports on its own. +- A second `main.archify` deeper down would start a nested model; not supported, reported. + +## What you see in VS Code + +Any `.archify` diagram: click it, the diagram opens, with Show Source, live updates, Problems, export and 3D. + +`main.archify`: the model page instead of a diagram: + +1. the metadata: id, name, description; +2. the file's own diagram, if it has one, drawn like any other diagram; +3. the list of diagrams, grouped by folder, each with its title and kind; diagrams next to `main.archify` first, ungrouped, then deeper ones by folder path; +4. the registry: every shared thing with its description and "used in N diagrams". + +`task model:list` prints the same information. diff --git a/references/formats.md b/references/formats.md new file mode 100644 index 0000000..bb0d8a4 --- /dev/null +++ b/references/formats.md @@ -0,0 +1,102 @@ +# File formats of a model + +Three file kinds, all YAML, all read by the extension. Archify itself only ever sees plain diagrams; the model keys are resolved first (by the extension, and by this skill's tasks in the same way). + +## `main.archify` + +The entry point of a model. The folder that contains it is the model root. + +```yaml +schema_version: 1 +model: + id: online-shop # ^[a-zA-Z][a-zA-Z0-9_-]*$ + name: Online Shop + description: > + Web shop, its API, workers and stores. +``` + +Optionally, below the `model` block, a diagram exactly as in any other `.archify` file (`diagram_type`, `meta`, `components`, ...). The extension shows it on the model page as the overview. Archify never sees the `model` block. + +A `main.archify` without a diagram is valid (metadata only). A `model` block in any other file is ignored and reported as a warning. + +## `name.archify-registry` + +Shared things of the model. One file or many, anywhere under the root, split any way (by kind, by team). + +```yaml +schema_version: 1 +elements: + api: + type: backend + label: API + sublabel: REST service + brand: node-js + description: > + Node.js service behind the load balancer. + diagram: 0300-components/api.archify + db: + type: database + label: Database + sublabel: PostgreSQL + brand: postgresql + description: Orders, customers and catalogue. +``` + +- The key is the element id, unique across all registry files of the model. A duplicate is an error. +- `type`, `label`, `sublabel`, `tag`, `icon`, `brand`, `sources` are Archify's component fields and are copied into every diagram that refers to the element. `type` and `label` are required. +- `description` is the one description of the thing; shown on hover and on the model page, never sent to Archify. +- `diagram` is the link: the diagram that explains the element, relative to the model root, ending in `.archify`, must exist. Clicking the element in any diagram opens it. +- Any other key is reported as a warning and never reaches a diagram. + +## A diagram that refers to the registry + +```yaml +components: + - ref: api + pos: [250, 160] + size: [130, 60] + - ref: db + label: Orders DB # a diagram may override label, sublabel, tag + pos: [460, 260] + size: [130, 60] + - id: cron # an unshared element, inline, as today + type: backend + label: Nightly job + pos: [460, 420] + size: [130, 60] +``` + +- `ref` names a registry element; `id` defaults to the same name. `id: db-replica` with `ref: db` draws the same element twice or under another id. +- A diagram may override `label`, `sublabel` and `tag`; never `type`, `brand`, `icon` or `sources` (if those differ, it is a different element). The validator reports the attempt. +- Before Archify runs, each `ref` component is replaced by the registry element plus the diagram's own fields; `description` and `diagram` are dropped; what remains is an ordinary component. +- Connections, boundaries and everything else stay as they are and use the ids as before. +- The same works in the node collection of every kind: `components` (architecture), `nodes` (workflow, dataflow), `participants` (sequence), `states` (lifecycle). Only fields the kind's schema accepts are copied (sequence participants have no `tag`; lifecycle states need a lifecycle `type`). + +## Links between diagrams + +A diagram may point at another diagram from a component, whether shared or inline: + +```yaml + - ref: api + pos: [250, 160] + size: [130, 60] + diagram: 0300-components/api.archify # overrides the registry's link for this diagram +``` + +Registry `diagram` links and component `diagram` links are the same mechanism; the component's wins when both exist. Paths are relative to the model root. Levels, zoom-in and "see also" are this one mechanism. + +## Outside a model + +A diagram with `ref` components outside any model (no `main.archify` above it) has no registry. The extension reports "no registry found" and shows the component by its ref name with type `external`, so the picture still renders. The validator treats it as an error: define the element inline or create the model. A `diagram:` link outside a model is dropped with a warning. + +## What the resolver does, exactly + +For each diagram file under the root (and a single file outside a model): + +1. Parse the YAML; a syntax error is reported with its line and stops that file. +2. Remove `model` (main.archify only). +3. For each node in the kind's collection: expand `ref`, apply the diagram's own keys, drop `ref` and `diagram`, keep `id` (own `id` or the ref name). +4. Check ids are unique in the diagram, links exist, overrides are allowed. +5. Hand the result to `archify validate --json` (or `render`). + +`task model:resolve -- --file=… --out=x.json` writes step 4's result. diff --git a/scripts/.gitignore b/scripts/.gitignore new file mode 100644 index 0000000..9257d51 --- /dev/null +++ b/scripts/.gitignore @@ -0,0 +1,19 @@ +# Node / pnpm +node_modules/ +dist/ +.pnpm-store/ + +# Build artefacts +*.js.map +*.d.ts.map + +# Environment +.env +.env.local +.env.*.local + +# Test output +.test-output/ + +# OS +.DS_Store diff --git a/scripts/.scripts/diagram/Taskfile.yml b/scripts/.scripts/diagram/Taskfile.yml new file mode 100644 index 0000000..a65bf51 --- /dev/null +++ b/scripts/.scripts/diagram/Taskfile.yml @@ -0,0 +1,18 @@ +# https://taskfile.dev + +version: "3" + +tasks: + validate: + desc: 'Validate one diagram -- --file="" [--quality=standard|showcase]' + cmds: + - | + ./.scripts/diagram/api/diagram--validate.sh {{ .CLI_ARGS }} + silent: true + + render: + desc: 'Render one diagram to HTML -- --file="" --out="" [--quality=standard|showcase]' + cmds: + - | + ./.scripts/diagram/api/diagram--render.sh {{ .CLI_ARGS }} + silent: true diff --git a/scripts/.scripts/diagram/api/diagram--render.sh b/scripts/.scripts/diagram/api/diagram--render.sh new file mode 100755 index 0000000..467bcc6 --- /dev/null +++ b/scripts/.scripts/diagram/api/diagram--render.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +. ./.scripts/diagram/lib/--index.sh + +_diagram__diagram__render "$@" diff --git a/scripts/.scripts/diagram/api/diagram--validate.sh b/scripts/.scripts/diagram/api/diagram--validate.sh new file mode 100755 index 0000000..93a93cd --- /dev/null +++ b/scripts/.scripts/diagram/api/diagram--validate.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +. ./.scripts/diagram/lib/--index.sh + +_diagram__diagram__validate "$@" diff --git a/scripts/.scripts/diagram/lib/--env-vars-reader.sh b/scripts/.scripts/diagram/lib/--env-vars-reader.sh new file mode 100755 index 0000000..4f0bb0f --- /dev/null +++ b/scripts/.scripts/diagram/lib/--env-vars-reader.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +# CLI-only module: all input comes from flags. No environment variables are read. diff --git a/scripts/.scripts/diagram/lib/--env-vars-validator.sh b/scripts/.scripts/diagram/lib/--env-vars-validator.sh new file mode 100755 index 0000000..242a9d8 --- /dev/null +++ b/scripts/.scripts/diagram/lib/--env-vars-validator.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +# CLI-only module: nothing to validate. diff --git a/scripts/.scripts/diagram/lib/--index-api.sh b/scripts/.scripts/diagram/lib/--index-api.sh new file mode 100755 index 0000000..7d0224f --- /dev/null +++ b/scripts/.scripts/diagram/lib/--index-api.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +. ./.scripts/diagram/lib/-node--run.sh +. ./.scripts/diagram/lib/-diagram--validate.sh +. ./.scripts/diagram/lib/-diagram--render.sh diff --git a/scripts/.scripts/diagram/lib/--index.sh b/scripts/.scripts/diagram/lib/--index.sh new file mode 100755 index 0000000..45b335c --- /dev/null +++ b/scripts/.scripts/diagram/lib/--index.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- +. ./.scripts/loggers/lib/--index.sh +# +# --> passed parameters are read & exported environment variables +# +. ./.scripts/diagram/lib/--env-vars-reader.sh +# +# --> required environment variables are validated for existence +# +. ./.scripts/diagram/lib/--env-vars-validator.sh +# +# --> available functions are imported/exported +# +. ./.scripts/diagram/lib/--index-api.sh diff --git a/scripts/.scripts/diagram/lib/-diagram--render.sh b/scripts/.scripts/diagram/lib/-diagram--render.sh new file mode 100755 index 0000000..989c714 --- /dev/null +++ b/scripts/.scripts/diagram/lib/-diagram--render.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +# Runs the "diagram-render" action of the Node CLI. Flags are forwarded unchanged. +_diagram__diagram__render() { + _diagram__node__run --action diagram-render "$@" +} diff --git a/scripts/.scripts/diagram/lib/-diagram--validate.sh b/scripts/.scripts/diagram/lib/-diagram--validate.sh new file mode 100755 index 0000000..84c33d2 --- /dev/null +++ b/scripts/.scripts/diagram/lib/-diagram--validate.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +# Runs the "diagram-validate" action of the Node CLI. Flags are forwarded unchanged. +_diagram__diagram__validate() { + _diagram__node__run --action diagram-validate "$@" +} diff --git a/scripts/.scripts/diagram/lib/-node--run.sh b/scripts/.scripts/diagram/lib/-node--run.sh new file mode 100755 index 0000000..b9f0a1e --- /dev/null +++ b/scripts/.scripts/diagram/lib/-node--run.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +# Runs the Node action dispatcher (YAML to stdout, exit code = success). +_diagram__node__run() { + node ./src/cli/commands.mjs "$@" +} diff --git a/scripts/.scripts/loggers/lib/--env-vars-reader.sh b/scripts/.scripts/loggers/lib/--env-vars-reader.sh new file mode 100755 index 0000000..91600cb --- /dev/null +++ b/scripts/.scripts/loggers/lib/--env-vars-reader.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +export LOGGER_IS_ENABLED_INFO="${LOGGER_IS_ENABLED_INFO:-true}" +export LOGGER_IS_ENABLED_ERROR="${LOGGER_IS_ENABLED_ERROR:-true}" diff --git a/scripts/.scripts/loggers/lib/--index-api.sh b/scripts/.scripts/loggers/lib/--index-api.sh new file mode 100755 index 0000000..cd1d2d2 --- /dev/null +++ b/scripts/.scripts/loggers/lib/--index-api.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +. ./.scripts/loggers/lib/-info.sh +. ./.scripts/loggers/lib/-error.sh diff --git a/scripts/.scripts/loggers/lib/--index.sh b/scripts/.scripts/loggers/lib/--index.sh new file mode 100755 index 0000000..f014831 --- /dev/null +++ b/scripts/.scripts/loggers/lib/--index.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +. ./.scripts/loggers/lib/--env-vars-reader.sh +. ./.scripts/loggers/lib/--index-api.sh diff --git a/scripts/.scripts/loggers/lib/-error.sh b/scripts/.scripts/loggers/lib/-error.sh new file mode 100755 index 0000000..07def3e --- /dev/null +++ b/scripts/.scripts/loggers/lib/-error.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +_loggers_error() { + if [ "${LOGGER_IS_ENABLED_ERROR}" = true ]; then + printf '# ERROR # %s\n' "$*" >&2 + fi +} diff --git a/scripts/.scripts/loggers/lib/-info.sh b/scripts/.scripts/loggers/lib/-info.sh new file mode 100755 index 0000000..93bc30a --- /dev/null +++ b/scripts/.scripts/loggers/lib/-info.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +# Logs go to stderr; stdout is reserved for the YAML result. +_loggers_info() { + if [ "${LOGGER_IS_ENABLED_INFO}" = true ]; then + printf '# INFO # %s\n' "$*" >&2 + fi +} diff --git a/scripts/.scripts/model/Taskfile.yml b/scripts/.scripts/model/Taskfile.yml new file mode 100644 index 0000000..a60bdbd --- /dev/null +++ b/scripts/.scripts/model/Taskfile.yml @@ -0,0 +1,32 @@ +# https://taskfile.dev + +version: "3" + +tasks: + validate: + desc: 'Validate a whole model -- --dir="" [--quality=standard|showcase]' + cmds: + - | + ./.scripts/model/api/model--validate.sh {{ .CLI_ARGS }} + silent: true + + list: + desc: 'What the model page shows -- --dir=""' + cmds: + - | + ./.scripts/model/api/model--list.sh {{ .CLI_ARGS }} + silent: true + + scaffold: + desc: 'Create a model -- --dir="" --id="" --name="" [--description=""] [--c4]' + cmds: + - | + ./.scripts/model/api/model--scaffold.sh {{ .CLI_ARGS }} + silent: true + + resolve: + desc: 'Resolved Archify JSON of one diagram -- --file="" [--out=""]' + cmds: + - | + ./.scripts/model/api/model--resolve.sh {{ .CLI_ARGS }} + silent: true diff --git a/scripts/.scripts/model/api/model--list.sh b/scripts/.scripts/model/api/model--list.sh new file mode 100755 index 0000000..6238da7 --- /dev/null +++ b/scripts/.scripts/model/api/model--list.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +. ./.scripts/model/lib/--index.sh + +_model__model__list "$@" diff --git a/scripts/.scripts/model/api/model--resolve.sh b/scripts/.scripts/model/api/model--resolve.sh new file mode 100755 index 0000000..ba5257c --- /dev/null +++ b/scripts/.scripts/model/api/model--resolve.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +. ./.scripts/model/lib/--index.sh + +_model__model__resolve "$@" diff --git a/scripts/.scripts/model/api/model--scaffold.sh b/scripts/.scripts/model/api/model--scaffold.sh new file mode 100755 index 0000000..b7999fc --- /dev/null +++ b/scripts/.scripts/model/api/model--scaffold.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +. ./.scripts/model/lib/--index.sh + +_model__model__scaffold "$@" diff --git a/scripts/.scripts/model/api/model--validate.sh b/scripts/.scripts/model/api/model--validate.sh new file mode 100755 index 0000000..a0f063d --- /dev/null +++ b/scripts/.scripts/model/api/model--validate.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +. ./.scripts/model/lib/--index.sh + +_model__model__validate "$@" diff --git a/scripts/.scripts/model/lib/--env-vars-reader.sh b/scripts/.scripts/model/lib/--env-vars-reader.sh new file mode 100755 index 0000000..4f0bb0f --- /dev/null +++ b/scripts/.scripts/model/lib/--env-vars-reader.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +# CLI-only module: all input comes from flags. No environment variables are read. diff --git a/scripts/.scripts/model/lib/--env-vars-validator.sh b/scripts/.scripts/model/lib/--env-vars-validator.sh new file mode 100755 index 0000000..242a9d8 --- /dev/null +++ b/scripts/.scripts/model/lib/--env-vars-validator.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +# CLI-only module: nothing to validate. diff --git a/scripts/.scripts/model/lib/--index-api.sh b/scripts/.scripts/model/lib/--index-api.sh new file mode 100755 index 0000000..7ab21f9 --- /dev/null +++ b/scripts/.scripts/model/lib/--index-api.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +. ./.scripts/model/lib/-node--run.sh +. ./.scripts/model/lib/-model--validate.sh +. ./.scripts/model/lib/-model--list.sh +. ./.scripts/model/lib/-model--scaffold.sh +. ./.scripts/model/lib/-model--resolve.sh diff --git a/scripts/.scripts/model/lib/--index.sh b/scripts/.scripts/model/lib/--index.sh new file mode 100755 index 0000000..1d8daf9 --- /dev/null +++ b/scripts/.scripts/model/lib/--index.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- +. ./.scripts/loggers/lib/--index.sh +# +# --> passed parameters are read & exported environment variables +# +. ./.scripts/model/lib/--env-vars-reader.sh +# +# --> required environment variables are validated for existence +# +. ./.scripts/model/lib/--env-vars-validator.sh +# +# --> available functions are imported/exported +# +. ./.scripts/model/lib/--index-api.sh diff --git a/scripts/.scripts/model/lib/-model--list.sh b/scripts/.scripts/model/lib/-model--list.sh new file mode 100755 index 0000000..af298d7 --- /dev/null +++ b/scripts/.scripts/model/lib/-model--list.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +# Runs the "model-list" action of the Node CLI. Flags are forwarded unchanged. +_model__model__list() { + _model__node__run --action model-list "$@" +} diff --git a/scripts/.scripts/model/lib/-model--resolve.sh b/scripts/.scripts/model/lib/-model--resolve.sh new file mode 100755 index 0000000..b0a98be --- /dev/null +++ b/scripts/.scripts/model/lib/-model--resolve.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +# Runs the "model-resolve" action of the Node CLI. Flags are forwarded unchanged. +_model__model__resolve() { + _model__node__run --action model-resolve "$@" +} diff --git a/scripts/.scripts/model/lib/-model--scaffold.sh b/scripts/.scripts/model/lib/-model--scaffold.sh new file mode 100755 index 0000000..c852a35 --- /dev/null +++ b/scripts/.scripts/model/lib/-model--scaffold.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +# Runs the "model-scaffold" action of the Node CLI. Flags are forwarded unchanged. +_model__model__scaffold() { + _model__node__run --action model-scaffold "$@" +} diff --git a/scripts/.scripts/model/lib/-model--validate.sh b/scripts/.scripts/model/lib/-model--validate.sh new file mode 100755 index 0000000..1840e6a --- /dev/null +++ b/scripts/.scripts/model/lib/-model--validate.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +# Runs the "model-validate" action of the Node CLI. Flags are forwarded unchanged. +_model__model__validate() { + _model__node__run --action model-validate "$@" +} diff --git a/scripts/.scripts/model/lib/-node--run.sh b/scripts/.scripts/model/lib/-node--run.sh new file mode 100755 index 0000000..93c9e27 --- /dev/null +++ b/scripts/.scripts/model/lib/-node--run.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +# Runs the Node action dispatcher (YAML to stdout, exit code = success). +_model__node__run() { + node ./src/cli/commands.mjs "$@" +} diff --git a/scripts/.scripts/runtime/Taskfile.yml b/scripts/.scripts/runtime/Taskfile.yml new file mode 100644 index 0000000..53e17b7 --- /dev/null +++ b/scripts/.scripts/runtime/Taskfile.yml @@ -0,0 +1,11 @@ +# https://taskfile.dev + +version: "3" + +tasks: + sync: + desc: 'Refresh vendor/archify and vendor/yaml from local checkouts -- [] [--yaml-src ]' + cmds: + - | + ./.scripts/runtime/api/archify--sync.sh {{ .CLI_ARGS }} + silent: true diff --git a/scripts/.scripts/runtime/api/archify--sync.sh b/scripts/.scripts/runtime/api/archify--sync.sh new file mode 100755 index 0000000..66a1d45 --- /dev/null +++ b/scripts/.scripts/runtime/api/archify--sync.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +. ./.scripts/runtime/lib/--index.sh + +_runtime__archify__sync "$@" diff --git a/scripts/.scripts/runtime/lib/--env-vars-reader.sh b/scripts/.scripts/runtime/lib/--env-vars-reader.sh new file mode 100755 index 0000000..4f0bb0f --- /dev/null +++ b/scripts/.scripts/runtime/lib/--env-vars-reader.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +# CLI-only module: all input comes from flags. No environment variables are read. diff --git a/scripts/.scripts/runtime/lib/--env-vars-validator.sh b/scripts/.scripts/runtime/lib/--env-vars-validator.sh new file mode 100755 index 0000000..242a9d8 --- /dev/null +++ b/scripts/.scripts/runtime/lib/--env-vars-validator.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +# CLI-only module: nothing to validate. diff --git a/scripts/.scripts/runtime/lib/--index-api.sh b/scripts/.scripts/runtime/lib/--index-api.sh new file mode 100755 index 0000000..db5f73c --- /dev/null +++ b/scripts/.scripts/runtime/lib/--index-api.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +. ./.scripts/runtime/lib/-archify--sync.sh diff --git a/scripts/.scripts/runtime/lib/--index.sh b/scripts/.scripts/runtime/lib/--index.sh new file mode 100755 index 0000000..5952bb5 --- /dev/null +++ b/scripts/.scripts/runtime/lib/--index.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- +. ./.scripts/loggers/lib/--index.sh +. ./.scripts/runtime/lib/--env-vars-reader.sh +. ./.scripts/runtime/lib/--env-vars-validator.sh +. ./.scripts/runtime/lib/--index-api.sh diff --git a/scripts/.scripts/runtime/lib/-archify--sync.sh b/scripts/.scripts/runtime/lib/-archify--sync.sh new file mode 100755 index 0000000..0c20b6c --- /dev/null +++ b/scripts/.scripts/runtime/lib/-archify--sync.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +# Refreshes vendor/archify and vendor/yaml from local checkouts. +# Flags: [] [--yaml-src ]; see src/tools/sync-archify.mjs. +_runtime__archify__sync() { + _loggers_info "Syncing the Archify runtime into vendor/" + node ./src/tools/sync-archify.mjs "$@" +} diff --git a/scripts/.scripts/suite/Taskfile.yml b/scripts/.scripts/suite/Taskfile.yml new file mode 100644 index 0000000..e02af63 --- /dev/null +++ b/scripts/.scripts/suite/Taskfile.yml @@ -0,0 +1,11 @@ +# https://taskfile.dev + +version: "3" + +tasks: + run: + desc: "Run the skill's own checks against examples/online-shop and a scaffolded model" + cmds: + - | + ./.scripts/suite/api/tests--run.sh {{ .CLI_ARGS }} + silent: true diff --git a/scripts/.scripts/suite/api/tests--run.sh b/scripts/.scripts/suite/api/tests--run.sh new file mode 100755 index 0000000..428ef15 --- /dev/null +++ b/scripts/.scripts/suite/api/tests--run.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +. ./.scripts/suite/lib/--index.sh + +_suite__tests__run "$@" diff --git a/scripts/.scripts/suite/lib/--env-vars-reader.sh b/scripts/.scripts/suite/lib/--env-vars-reader.sh new file mode 100755 index 0000000..4f0bb0f --- /dev/null +++ b/scripts/.scripts/suite/lib/--env-vars-reader.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +# CLI-only module: all input comes from flags. No environment variables are read. diff --git a/scripts/.scripts/suite/lib/--env-vars-validator.sh b/scripts/.scripts/suite/lib/--env-vars-validator.sh new file mode 100755 index 0000000..242a9d8 --- /dev/null +++ b/scripts/.scripts/suite/lib/--env-vars-validator.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +# CLI-only module: nothing to validate. diff --git a/scripts/.scripts/suite/lib/--index-api.sh b/scripts/.scripts/suite/lib/--index-api.sh new file mode 100755 index 0000000..b816557 --- /dev/null +++ b/scripts/.scripts/suite/lib/--index-api.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +. ./.scripts/suite/lib/-tests--run.sh diff --git a/scripts/.scripts/suite/lib/--index.sh b/scripts/.scripts/suite/lib/--index.sh new file mode 100755 index 0000000..f095646 --- /dev/null +++ b/scripts/.scripts/suite/lib/--index.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- +. ./.scripts/loggers/lib/--index.sh +. ./.scripts/suite/lib/--env-vars-reader.sh +. ./.scripts/suite/lib/--env-vars-validator.sh +. ./.scripts/suite/lib/--index-api.sh diff --git a/scripts/.scripts/suite/lib/-tests--run.sh b/scripts/.scripts/suite/lib/-tests--run.sh new file mode 100755 index 0000000..481f670 --- /dev/null +++ b/scripts/.scripts/suite/lib/-tests--run.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +# Runs every task against examples/online-shop and a scaffolded temp model. +_suite__tests__run() { + node ./src/test/run.mjs "$@" +} diff --git a/scripts/Taskfile.yml b/scripts/Taskfile.yml new file mode 100644 index 0000000..91720ba --- /dev/null +++ b/scripts/Taskfile.yml @@ -0,0 +1,31 @@ +# https://taskfile.dev + +version: "3" + +includes: + model: ./.scripts/model/Taskfile.yml + diagram: ./.scripts/diagram/Taskfile.yml + runtime: ./.scripts/runtime/Taskfile.yml + suite: ./.scripts/suite/Taskfile.yml + +tasks: + default: + cmds: + - task --list-all + silent: true + + test: + desc: "Run the skill's own checks (same as suite:run)" + cmds: + - task: suite:run + vars: + CLI_ARGS: "{{ .CLI_ARGS }}" + silent: true + + sync: + desc: "Refresh the vendored Archify runtime (same as runtime:sync)" + cmds: + - task: runtime:sync + vars: + CLI_ARGS: "{{ .CLI_ARGS }}" + silent: true diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..a6c2551 --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,7 @@ +{ + "name": "archify-vscode-ext-skill-tools", + "private": true, + "type": "module", + "description": "Model and diagram tools for the archify-vscode-ext skill. No dependencies; Node 18+. Archify (MIT) and yaml (ISC) are vendored under vendor/.", + "engines": { "node": ">=18" } +} diff --git a/scripts/src/actions/diagram-render/action.mjs b/scripts/src/actions/diagram-render/action.mjs new file mode 100644 index 0000000..1a5cd29 --- /dev/null +++ b/scripts/src/actions/diagram-render/action.mjs @@ -0,0 +1,45 @@ +// Renders one diagram to a standalone Archify HTML page, resolving refs when +// the file sits inside a model. + +import fs from 'node:fs'; +import path from 'node:path'; +import { archifyVersion, renderJson, withTempJson } from '../../services/archify.mjs'; +import { DIAGRAM_EXT, presentProblem, summarize } from '../../services/model.mjs'; +import { resolveOne } from '../model-resolve/action.mjs'; + +export const help = `diagram-render — render one diagram to HTML (refs resolved when inside a model) + --file the .archify file (required) + --out the .html file to write (required) + --quality Archify quality profile: standard (default) or showcase + --repo-root Git checkout for diagrams with meta.repository and sources`; + +export async function run(input) { + if (!input.file) return { success: false, error: '--file is required: the .archify file to render.', help }; + if (!input.out) return { success: false, error: '--out is required: where to write the HTML.', help }; + const file = path.resolve(String(input.file)); + const out = path.resolve(String(input.out)); + if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) return { success: false, error: `${file} is not a file.` }; + const { root, registry, entry } = resolveOne(file); + const problems = [...(registry?.problems ?? []), ...entry.problems]; + let summary = summarize(problems); + if (summary.errors > 0 || !entry.resolved) { + return { success: false, root: root ?? null, file: entry.file, error: entry.resolved ? 'Resolve problems first.' : 'The file has no diagram to render.', summary, problems: problems.map(presentProblem) }; + } + fs.mkdirSync(path.dirname(out), { recursive: true }); + const name = path.basename(file, DIAGRAM_EXT) || 'diagram'; + const render = withTempJson(entry.resolved, `${name}.${entry.diagramType}`, (json) => renderJson(entry.diagramType, json, out, { quality: input.quality || undefined, repoRoot: input['repo-root'] || undefined })); + if (!render.ok) problems.push({ severity: 'error', file: entry.file, message: render.error }); + summary = summarize(problems); + return { + success: render.ok, + root: root ?? null, + file: entry.file, + kind: entry.diagramType, + title: entry.resolved.meta?.title, + archify: archifyVersion(), + out: render.ok ? out : undefined, + bytes: render.ok && fs.existsSync(out) ? fs.statSync(out).size : undefined, + summary, + problems: problems.map(presentProblem), + }; +} diff --git a/scripts/src/actions/diagram-validate/action.mjs b/scripts/src/actions/diagram-validate/action.mjs new file mode 100644 index 0000000..77b66d9 --- /dev/null +++ b/scripts/src/actions/diagram-validate/action.mjs @@ -0,0 +1,34 @@ +// Validates one diagram through the bundled Archify, resolving refs when the +// file sits inside a model. + +import fs from 'node:fs'; +import path from 'node:path'; +import { archifyVersion } from '../../services/archify.mjs'; +import { presentProblem, summarize, validateResolved } from '../../services/model.mjs'; +import { resolveOne } from '../model-resolve/action.mjs'; + +export const help = `diagram-validate — validate one diagram (refs resolved when inside a model) + --file the .archify file (required) + --quality Archify quality profile: standard (default) or showcase + --repo-root Git checkout for diagrams with meta.repository and sources`; + +export async function run(input) { + if (!input.file) return { success: false, error: '--file is required: the .archify file to validate.', help }; + const file = path.resolve(String(input.file)); + if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) return { success: false, error: `${file} is not a file.` }; + const { root, registry, entry } = resolveOne(file); + const receipt = validateResolved(entry, { quality: input.quality || undefined, repoRoot: input['repo-root'] || undefined }); + const problems = [...(registry?.problems ?? []), ...entry.problems]; + const summary = summarize(problems); + return { + success: summary.errors === 0 && (entry.hasDiagram || entry.isMain), + root: root ?? null, + file: entry.file, + kind: entry.diagramType, + title: entry.resolved?.meta?.title, + archify: archifyVersion(), + archifyOk: receipt?.ok, + summary, + problems: problems.map(presentProblem), + }; +} diff --git a/scripts/src/actions/model-list/action.mjs b/scripts/src/actions/model-list/action.mjs new file mode 100644 index 0000000..e1cc92d --- /dev/null +++ b/scripts/src/actions/model-list/action.mjs @@ -0,0 +1,63 @@ +// What the extension's model page shows: metadata, diagrams grouped by +// folder, and the registry with "used in N diagrams". + +import fs from 'node:fs'; +import path from 'node:path'; +import { MAIN, findRoot, loadModel, presentProblem, relative, summarize } from '../../services/model.mjs'; + +export const help = `model-list — metadata, diagrams by folder and registry elements of a model + --dir model folder, or any file/folder inside it (required)`; + +export async function run(input) { + if (!input.dir) return { success: false, error: '--dir is required: the model folder or any file inside it.', help }; + const target = path.resolve(String(input.dir)); + if (!fs.existsSync(target)) return { success: false, error: `${target} does not exist.` }; + const root = findRoot(target); + if (!root) return { success: false, error: `No ${MAIN} in ${target} or any folder above it.` }; + + const model = loadModel(root); + const groups = new Map(); + for (const entry of model.diagrams) { + const folder = relative(root, path.dirname(entry.path)); + if (!groups.has(folder)) groups.set(folder, []); + groups.get(folder).push({ + file: entry.file, + title: entry.resolved?.meta?.title ?? (entry.isMain ? `${entry.model?.name ?? MAIN} (model, no diagram)` : undefined), + kind: entry.diagramType ?? (entry.isMain ? 'model' : 'unknown'), + refs: [...new Set(entry.refs)], + links: linksOf(entry), + }); + } + const rootDiagrams = groups.get('') ?? []; + groups.delete(''); + + const elements = Object.values(model.registry.elements).map((element) => ({ + id: element.id, + type: element.type, + label: element.label, + sublabel: element.sublabel, + brand: element.brand, + description: element.description, + diagram: element.diagram, + file: element.file, + usedIn: model.usage[element.id] ?? 0, + })); + const all = [...model.problems, ...model.diagrams.flatMap((entry) => entry.problems)]; + return { + success: summarize(all).errors === 0, + root, + model: model.main?.model ? { id: model.main.model.id, name: model.main.model.name, description: model.main.model.description, hasDiagram: model.main.hasDiagram } : undefined, + summary: { diagrams: model.diagrams.length, groups: groups.size, registryFiles: model.registry.files.length, elements: elements.length, ...summarize(all) }, + diagrams: rootDiagrams, + groups: [...groups.entries()].map(([folder, diagrams]) => ({ folder, diagrams })), + registry: elements, + problems: all.map(presentProblem), + }; +} + +function linksOf(entry) { + const data = entry.source.data; + if (!data || !entry.diagramType) return []; + const collection = Object.values(data).find((value) => Array.isArray(value) && value.some((item) => item && typeof item === 'object' && item.diagram)); + return (collection ?? []).filter((item) => item && typeof item === 'object' && item.diagram).map((item) => ({ from: item.id ?? item.ref, to: item.diagram })); +} diff --git a/scripts/src/actions/model-resolve/action.mjs b/scripts/src/actions/model-resolve/action.mjs new file mode 100644 index 0000000..ab22638 --- /dev/null +++ b/scripts/src/actions/model-resolve/action.mjs @@ -0,0 +1,50 @@ +// Prints or writes the resolved Archify JSON of one diagram: what the +// extension hands to Archify after refs are expanded and model-only keys +// are removed. Use the result with the plain Archify CLI or in CI. + +import fs from 'node:fs'; +import path from 'node:path'; +import { MAIN, findRoot, loadRegistry, presentProblem, resolveDiagram, scanModel, summarize } from '../../services/model.mjs'; + +export const help = `model-resolve — resolved Archify JSON for one diagram + --file the .archify file (required) + --out write the JSON there instead of including it in the output`; + +export function resolveOne(file) { + const root = findRoot(path.dirname(file)); + const registry = root ? loadRegistry(root, scanModel(root).registries) : undefined; + const entry = resolveDiagram(file, root ? { root, elements: registry.elements } : {}); + return { root, registry, entry }; +} + +export async function run(input) { + if (!input.file) return { success: false, error: '--file is required: the .archify file to resolve.', help }; + const file = path.resolve(String(input.file)); + if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) return { success: false, error: `${file} is not a file.` }; + const { root, registry, entry } = resolveOne(file); + const problems = [...(registry?.problems ?? []), ...entry.problems]; + const summary = summarize(problems); + const result = { + success: summary.errors === 0 && Boolean(entry.resolved), + root: root ?? null, + note: root ? undefined : `No ${MAIN} above the file: nothing to resolve, refs stay unresolved.`, + file: entry.file, + kind: entry.diagramType, + summary, + problems: problems.map(presentProblem), + }; + if (!entry.resolved) { + if (!entry.hasDiagram && entry.isMain) result.error = `${MAIN} has no diagram part (no diagram_type).`; + return result; + } + if (input.out) { + const out = path.resolve(String(input.out)); + fs.mkdirSync(path.dirname(out), { recursive: true }); + fs.writeFileSync(out, `${JSON.stringify(entry.resolved, null, 2)}\n`); + result.out = out; + result.archifyCommand = `node vendor/archify/bin/archify.mjs validate ${entry.diagramType} "${out}" --json`; + } else { + result.resolved = entry.resolved; + } + return result; +} diff --git a/scripts/src/actions/model-scaffold/action.mjs b/scripts/src/actions/model-scaffold/action.mjs new file mode 100644 index 0000000..fd9f3e5 --- /dev/null +++ b/scripts/src/actions/model-scaffold/action.mjs @@ -0,0 +1,211 @@ +// Creates a model: main.archify with the model block, and with --c4 an +// overview diagram, empty registries and the C4 folders with starter +// diagrams that validate. Never overwrites existing files. + +import fs from 'node:fs'; +import path from 'node:path'; +import { ID, MAIN } from '../../services/model.mjs'; + +export const help = `model-scaffold — create main.archify (and with --c4 the C4 folders) + --dir folder of the model; created when missing (required) + --id model id, letters/digits/-/_ (required) + --name model name (required) + --description model description (optional) + --c4 also create registry/, 0100-context, 0200-containers, 0300-components`; + +function block(text) { + // YAML folded scalar: safe for colons and long sentences. + return `>\n${String(text).trim().split('\n').map((line) => ` ${line}`).join('\n')}`; +} + +function mainFile(id, name, description, withDiagram) { + const head = `schema_version: 1\nmodel:\n id: ${id}\n name: ${JSON.stringify(name)}\n description: ${block(description)}\n`; + if (!withDiagram) return head; + return `${head}# The model's own diagram: the overview shown on the model page. +# Move the system and its users into registry/ and replace "id:" by "ref:" once they are shared. +diagram_type: architecture +meta: + title: ${JSON.stringify(`${name}: overview`)} + output: main.html +components: + - id: user + type: external + label: User + pos: [40, 160] + size: [130, 60] + - id: system + type: cloud + label: ${JSON.stringify(name)} + sublabel: the system + pos: [250, 160] + size: [150, 60] + diagram: 0200-containers/${id}.archify +connections: + - { from: user, to: system, label: uses, variant: emphasis } +`; +} + +function registryFile(what, example) { + return `# ${what}. One name for one thing, defined once; diagrams refer to it with "ref: ". +schema_version: 1 +elements: {} +# Example: +${example.split('\n').map((line) => `# ${line}`).join('\n')} +`; +} + +const REGISTRIES = { + 'people.archify-registry': registryFile('People and roles that use the system', 'customer:\n type: external\n label: Customer\n sublabel: web and mobile\n description: Buys products.'), + 'systems.archify-registry': registryFile('Software systems: the one we build and the ones around it', 'shop:\n type: cloud\n label: Online Shop\n description: Everything we build and run.\n diagram: 0200-containers/online-shop.archify'), + 'containers.archify-registry': registryFile('Containers: deployable units inside the system', 'api:\n type: backend\n label: API\n sublabel: Node.js\n brand: node-js\n description: Owns orders and the catalogue.\n diagram: 0300-components/api.archify'), +}; + +function contextDiagram(id, name) { + return `# C4 level 1: the system and the people and systems around it. +schema_version: 1 +diagram_type: architecture +meta: + title: System context + output: context.html +components: + - id: user + type: external + label: User + pos: [40, 160] + size: [130, 60] + - id: system + type: cloud + label: ${JSON.stringify(name)} + sublabel: the system + pos: [250, 160] + size: [150, 60] + diagram: 0200-containers/${id}.archify + - id: external + type: external + label: External service + pos: [480, 160] + size: [150, 60] +connections: + - { from: user, to: system, label: uses, variant: emphasis } + - { from: system, to: external, label: calls } +`; +} + +function containersDiagram(name) { + return `# C4 level 2: the containers inside the system. +schema_version: 1 +diagram_type: architecture +meta: + title: Containers + output: containers.html +components: + - id: user + type: external + label: User + pos: [40, 160] + size: [130, 60] + - id: web + type: frontend + label: Web App + pos: [250, 160] + size: [130, 60] + - id: api + type: backend + label: API + pos: [460, 160] + size: [130, 60] + diagram: 0300-components/api.archify + - id: db + type: database + label: Database + pos: [670, 160] + size: [130, 60] +boundaries: + - kind: region + label: ${JSON.stringify(name)} + wraps: [web, api, db] +connections: + - { from: user, to: web, label: HTTPS, variant: emphasis } + - { from: web, to: api, label: JSON } + - { from: api, to: db, label: SQL } +`; +} + +const COMPONENTS_DIAGRAM = `# C4 level 3: the components inside one container. +schema_version: 1 +diagram_type: architecture +meta: + title: API components + output: api.html +components: + - id: web + type: frontend + label: Web App + pos: [40, 160] + size: [130, 60] + - id: auth + type: security + label: Auth + pos: [250, 60] + size: [130, 60] + - id: orders + type: backend + label: Orders + pos: [250, 260] + size: [130, 60] + - id: db + type: database + label: Database + pos: [460, 160] + size: [130, 60] +boundaries: + - kind: region + label: API + wraps: [auth, orders] +connections: + - { from: web, to: auth, label: sign in, variant: security } + - { from: web, to: orders, label: place order } + - { from: orders, to: db, label: SQL } +`; + +export async function run(input) { + const missing = ['dir', 'id', 'name'].filter((key) => !input[key] || input[key] === true); + if (missing.length) return { success: false, error: `Missing ${missing.map((key) => `--${key}`).join(', ')}.`, help }; + const id = String(input.id); + if (!ID.test(id)) return { success: false, error: `--id "${id}" must match ${ID} (letters, digits, - and _; starts with a letter).` }; + const name = String(input.name); + const description = input.description && input.description !== true ? String(input.description) : `${name}: diagrams of the system.`; + const dir = path.resolve(String(input.dir)); + const c4 = Boolean(input.c4); + + const files = { [MAIN]: mainFile(id, name, description, c4) }; + if (c4) { + for (const [file, text] of Object.entries(REGISTRIES)) files[`registry/${file}`] = text; + files[`0100-context/${id}.archify`] = contextDiagram(id, name); + files[`0200-containers/${id}.archify`] = containersDiagram(name); + files['0300-components/api.archify'] = COMPONENTS_DIAGRAM; + } + + const existing = Object.keys(files).filter((file) => fs.existsSync(path.join(dir, file))); + if (existing.length) return { success: false, error: `Refusing to overwrite existing files in ${dir}.`, existing }; + if (fs.existsSync(dir) && !fs.statSync(dir).isDirectory()) return { success: false, error: `${dir} is not a folder.` }; + + const created = []; + for (const [file, text] of Object.entries(files)) { + const full = path.join(dir, file); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, text); + created.push(file); + } + return { + success: true, + root: dir, + model: { id, name }, + created, + nextSteps: [ + `task model:validate -- --dir="${dir}"`, + c4 ? 'Move shared things (people, systems, containers) into registry/*.archify-registry and replace "id:" by "ref:" in the diagrams.' : 'Add diagrams (*.archify) and registries (*.archify-registry) below the folder.', + `Open ${path.join(dir, MAIN)} in VS Code with the Archify Diagram Viewer extension to see the model page.`, + ], + }; +} diff --git a/scripts/src/actions/model-validate/action.mjs b/scripts/src/actions/model-validate/action.mjs new file mode 100644 index 0000000..3b50a2d --- /dev/null +++ b/scripts/src/actions/model-validate/action.mjs @@ -0,0 +1,67 @@ +// Validates a whole model: registry, every diagram (refs resolved, Archify +// validator run), and every diagram: link. Also works on a single diagram +// outside a model (Archify validation only). + +import fs from 'node:fs'; +import path from 'node:path'; +import { archifyVersion } from '../../services/archify.mjs'; +import { MAIN, findRoot, loadModel, presentProblem, resolveDiagram, summarize, validateResolved } from '../../services/model.mjs'; + +export const help = `model-validate — validate a model (or a single diagram outside a model) + --dir model folder, or any file/folder inside it (required) + --quality Archify quality profile: standard (default) or showcase + --repo-root Git checkout for diagrams with meta.repository and sources`; + +function options(input) { + return { quality: input.quality || undefined, repoRoot: input['repo-root'] || undefined }; +} + +export function diagramReport(entry) { + return { + file: entry.file, + kind: entry.diagramType ?? (entry.isMain ? 'model' : undefined), + title: entry.resolved?.meta?.title ?? (entry.isMain ? entry.model?.name : undefined), + ok: summarize(entry.problems).errors === 0, + problems: entry.problems.map(presentProblem), + }; +} + +export async function run(input) { + if (!input.dir) return { success: false, error: '--dir is required: the model folder or any file inside it.', help }; + const target = path.resolve(String(input.dir)); + if (!fs.existsSync(target)) return { success: false, error: `${target} does not exist.` }; + const root = findRoot(target); + + if (!root) { + if (fs.statSync(target).isDirectory()) { + return { success: false, error: `No ${MAIN} in ${target} or any folder above it. Scaffold one with model-scaffold, or pass a single .archify file.` }; + } + const entry = resolveDiagram(target); + validateResolved(entry, options(input)); + const report = diagramReport(entry); + return { + success: report.ok, + mode: 'single-diagram', + note: `No ${MAIN} above ${target}: Archify validation only, refs cannot be resolved.`, + archify: archifyVersion(), + summary: summarize(entry.problems), + diagrams: [report], + }; + } + + const model = loadModel(root, options(input)); + for (const entry of model.diagrams) validateResolved(entry, options(input)); + const diagrams = model.diagrams.map(diagramReport); + const all = [...model.problems, ...model.diagrams.flatMap((entry) => entry.problems)]; + const summary = summarize(all); + return { + success: summary.errors === 0, + mode: 'model', + root, + model: model.main?.model ? { id: model.main.model.id, name: model.main.model.name } : undefined, + archify: archifyVersion(), + summary: { ...summary, diagrams: diagrams.length, diagramsOk: diagrams.filter((item) => item.ok).length, registryFiles: model.registry.files.length, elements: Object.keys(model.registry.elements).length }, + problems: model.problems.map(presentProblem), + diagrams, + }; +} diff --git a/scripts/src/cli/commands.mjs b/scripts/src/cli/commands.mjs new file mode 100644 index 0000000..79bd442 --- /dev/null +++ b/scripts/src/cli/commands.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +// Action dispatcher: node src/cli/commands.mjs --action [--flag value]... +// Prints YAML to stdout; exit code 0 when the result has success: true. + +import * as modelValidate from '../actions/model-validate/action.mjs'; +import * as modelList from '../actions/model-list/action.mjs'; +import * as modelScaffold from '../actions/model-scaffold/action.mjs'; +import * as modelResolve from '../actions/model-resolve/action.mjs'; +import * as diagramValidate from '../actions/diagram-validate/action.mjs'; +import * as diagramRender from '../actions/diagram-render/action.mjs'; +import { toYaml } from '../services/yaml.mjs'; + +const ACTIONS = { + 'model-validate': modelValidate, + 'model-list': modelList, + 'model-scaffold': modelScaffold, + 'model-resolve': modelResolve, + 'diagram-validate': diagramValidate, + 'diagram-render': diagramRender, +}; + +function parseArgs(argv) { + const options = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith('--')) continue; + const eq = arg.indexOf('='); + if (eq > 0) options[arg.slice(2, eq)] = arg.slice(eq + 1); + else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) options[arg.slice(2)] = argv[++i]; + else options[arg.slice(2)] = true; + } + return options; +} + +const options = parseArgs(process.argv.slice(2)); +const action = ACTIONS[options.action]; + +if (!action || options.help) { + const list = Object.values(ACTIONS).map((module) => module.help).join('\n\n'); + const unknown = options.action && !action ? `Unknown action ${options.action}\n\n` : ''; + process.stdout.write(`${unknown}Usage: node src/cli/commands.mjs --action [options]\n\nActions:\n\n${list}\n\nExit code: 0 when success: true, 1 otherwise, 2 for an unknown action.\n`); + process.exit(action || !options.action ? 0 : 2); +} + +try { + const result = await action.run(options); + process.stdout.write(toYaml(result)); + process.exit(result.success ? 0 : 1); +} catch (error) { + process.stdout.write(toYaml({ success: false, error: error.message })); + process.exit(1); +} diff --git a/scripts/src/services/archify.mjs b/scripts/src/services/archify.mjs new file mode 100644 index 0000000..e762fe8 --- /dev/null +++ b/scripts/src/services/archify.mjs @@ -0,0 +1,123 @@ +// Runs the bundled Archify CLI (vendor/archify) on a resolved JSON file. +// Node itself (process.execPath) runs the CLI, so no global install is needed. + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const scripts = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +export const ARCHIFY_BIN = path.join(scripts, 'vendor', 'archify', 'bin', 'archify.mjs'); +export const ARCHIFY_INFO = path.join(scripts, 'vendor', 'archify.json'); + +export function archifyVersion() { + try { + return JSON.parse(fs.readFileSync(ARCHIFY_INFO, 'utf8')).version; + } catch { + return 'unknown'; + } +} + +function run(args) { + const result = spawnSync(process.execPath, [ARCHIFY_BIN, ...args], { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + env: { ...process.env, ARCHIFY_SKIP_UPDATE_CHECK: '1', NO_COLOR: '1' }, + }); + return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '', error: result.error }; +} + +// Writes the resolved diagram to a temp file, runs the callback with its +// path, then removes the temp folder. +export function withTempJson(diagram, name, callback) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-skill-')); + try { + const file = path.join(dir, `${name}.json`); + fs.writeFileSync(file, `${JSON.stringify(diagram, null, 2)}\n`); + return callback(file); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// archify validate --json → the receipt object. +export function validateJson(type, file, options = {}) { + const args = ['validate', type, file, '--json']; + if (options.quality) args.push('--quality', options.quality); + if (options.repoRoot) args.push('--repo-root', options.repoRoot); + const result = run(args); + if (result.error) return { ok: false, error: `Could not run Archify: ${result.error.message}` }; + const text = result.stdout.trim(); + const start = text.indexOf('{'); + if (start < 0) { + return { ok: false, error: `Archify returned no JSON receipt (exit ${result.status}). ${lastLines(result.stderr || result.stdout)}` }; + } + try { + return JSON.parse(text.slice(start)); + } catch (error) { + return { ok: false, error: `Archify receipt is not JSON: ${error.message}. ${lastLines(result.stderr)}` }; + } +} + +// archify render +export function renderJson(type, file, output, options = {}) { + const args = ['render', type, file, output]; + if (options.quality) args.push('--quality', options.quality); + if (options.repoRoot) args.push('--repo-root', options.repoRoot); + const result = run(args); + if (result.error) return { ok: false, error: `Could not run Archify: ${result.error.message}` }; + if (result.status !== 0) return { ok: false, error: lastLines(result.stderr || result.stdout) || `Archify exited with ${result.status}` }; + return { ok: true, output }; +} + +function lastLines(text, count = 6) { + return (text ?? '') + .trim() + .split('\n') + .filter((line) => line.trim()) + .slice(-count) + .join('\n'); +} + +// Turns a validate receipt into problems: { severity, code, pointer, message }. +// Pointers come from the diagnostic subject when Archify gives one, else from +// an id quoted in the message that matches a node of the diagram. +export function receiptProblems(receipt, nodeIndex = {}, collection = 'components') { + const problems = []; + const diagnostics = Array.isArray(receipt.diagnostics) ? receipt.diagnostics : []; + for (const diagnostic of diagnostics) { + problems.push({ + severity: diagnostic.severity === 'warning' ? 'warning' : 'error', + code: diagnostic.code, + pointer: pointerOf(diagnostic, nodeIndex, collection), + message: diagnostic.message, + }); + } + if (!receipt.ok && diagnostics.length === 0) { + problems.push({ severity: 'error', code: receipt.stage ? `${receipt.stage}/failed` : 'archify/failed', pointer: '/', message: receipt.error ?? 'Archify validation failed.' }); + } + for (const issue of receipt.composition?.issues ?? []) { + if (diagnostics.some((diagnostic) => diagnostic.message === issue.message)) continue; + problems.push({ + severity: issue.severity === 'error' ? 'error' : 'warning', + code: issue.code ?? 'composition/issue', + pointer: pointerOf(issue, nodeIndex, collection), + message: issue.message ?? JSON.stringify(issue), + }); + } + return problems; +} + +function pointerOf(diagnostic, nodeIndex, collection) { + const subject = diagnostic.subject ?? {}; + if (typeof subject.path === 'string' && subject.path !== '/') return subject.path; + if (subject.collection && Number.isInteger(subject.index)) return `/${subject.collection}/${subject.index}`; + const quoted = String(diagnostic.message ?? '').match(/"([A-Za-z][A-Za-z0-9_-]*)"/g) ?? []; + for (const token of quoted) { + const id = token.slice(1, -1); + if (Number.isInteger(nodeIndex[id])) return `/${collection}/${nodeIndex[id]}`; + } + if (typeof subject.path === 'string') return subject.path; + return undefined; +} diff --git a/scripts/src/services/model.mjs b/scripts/src/services/model.mjs new file mode 100644 index 0000000..fbe2fe5 --- /dev/null +++ b/scripts/src/services/model.mjs @@ -0,0 +1,283 @@ +// The model: a folder with main.archify on top, diagrams (*.archify) and +// registries (*.archify-registry) below it. This module finds the root, loads +// the registry, resolves `ref` components and checks `diagram:` links. +// Formats follow FORMAT.md of the Archify Diagram Viewer design. + +import fs from 'node:fs'; +import path from 'node:path'; +import { readSource } from './source.mjs'; +import { receiptProblems, validateJson, withTempJson } from './archify.mjs'; + +export const MAIN = 'main.archify'; +export const DIAGRAM_EXT = '.archify'; +export const REGISTRY_EXT = '.archify-registry'; +export const TYPES = ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']; +export const COMPONENT_TYPES = ['frontend', 'backend', 'database', 'cloud', 'security', 'messagebus', 'external']; +export const STATE_TYPES = ['start', 'active', 'waiting', 'decision', 'success', 'failure', 'neutral', 'external']; +export const ID = /^[a-zA-Z][a-zA-Z0-9_-]*$/; + +// Node collection of each diagram kind, and the registry fields that kind accepts. +export const NODE_COLLECTION = { architecture: 'components', workflow: 'nodes', dataflow: 'nodes', sequence: 'participants', lifecycle: 'states' }; +const COPY_ALL = ['type', 'label', 'sublabel', 'tag', 'icon', 'brand', 'sources']; +const COPY = { + architecture: COPY_ALL, + workflow: COPY_ALL, + dataflow: COPY_ALL, + sequence: ['type', 'label', 'sublabel', 'icon', 'brand', 'sources'], + lifecycle: COPY_ALL, +}; +// What a diagram may override on a shared element. Everything else in COPY is fixed. +export const OVERRIDE = ['label', 'sublabel', 'tag']; +// Registry-only fields, never sent to Archify. +const REGISTRY_ONLY = ['description', 'diagram']; +const SKIP_DIRS = new Set(['node_modules', '.git']); + +export function problem(severity, file, message, extra = {}) { + return { severity, file, ...extra, message }; +} + +// Nearest folder at or above `target` (file or folder) that holds main.archify. +export function findRoot(target) { + const start = path.resolve(target); + let dir = fs.existsSync(start) && fs.statSync(start).isDirectory() ? start : path.dirname(start); + for (;;) { + if (fs.existsSync(path.join(dir, MAIN))) return dir; + const parent = path.dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} + +export function relative(root, file) { + return path.relative(root, file).split(path.sep).join('/'); +} + +// Every diagram and registry under the root, in folder order. A second +// main.archify deeper down is reported, not nested. +export function scanModel(root) { + const diagrams = []; + const registries = []; + const nestedMains = []; + const walk = (dir) => { + const entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) walk(full); + } else if (entry.name === MAIN && dir !== root) nestedMains.push(full); + else if (entry.name.endsWith(REGISTRY_EXT)) registries.push(full); + else if (entry.name.endsWith(DIAGRAM_EXT)) diagrams.push(full); + } + }; + walk(root); + // Root-level diagrams first (main.archify at the very top), then by folder path. + diagrams.sort((a, b) => { + const da = relative(root, path.dirname(a)); + const db = relative(root, path.dirname(b)); + if (da !== db) return da === '' ? -1 : db === '' ? 1 : da.localeCompare(db); + if (path.basename(a) === MAIN) return -1; + if (path.basename(b) === MAIN) return 1; + return a.localeCompare(b); + }); + return { diagrams, registries, nestedMains }; +} + +function linkTarget(root, link) { + if (typeof link !== 'string' || !link.trim()) return { ok: false, reason: 'is not a path' }; + if (path.isAbsolute(link) || link.split('/').includes('..')) return { ok: false, reason: 'must be relative to the model root, without ".."' }; + if (!link.endsWith(DIAGRAM_EXT)) return { ok: false, reason: `must point at a ${DIAGRAM_EXT} file` }; + const target = path.join(root, ...link.split('/')); + if (!fs.existsSync(target)) return { ok: false, reason: `does not exist under ${root}` }; + return { ok: true, target }; +} + +// Loads every registry file under the root. Element ids are unique across +// all registry files; each element must have a type and a label. +export function loadRegistry(root, registries) { + const elements = {}; + const problems = []; + const files = []; + for (const file of registries) { + const rel = relative(root, file); + const source = readSource(file); + files.push({ file: rel, elements: 0 }); + for (const error of source.errors) problems.push(problem('error', rel, error.message, { line: error.line })); + if (!source.data) continue; + const doc = source.data; + if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) { + problems.push(problem('error', rel, 'A registry file is a YAML mapping with an "elements" key.', { line: 1 })); + continue; + } + if (doc.elements === undefined) { + problems.push(problem('error', rel, 'Missing "elements" mapping.', { line: 1 })); + continue; + } + if (typeof doc.elements !== 'object' || doc.elements === null || Array.isArray(doc.elements)) { + problems.push(problem('error', rel, '"elements" must be a mapping of id to element.', { line: source.lineOf('/elements'), pointer: '/elements' })); + continue; + } + for (const [id, element] of Object.entries(doc.elements)) { + const pointer = `/elements/${id}`; + const line = source.lineOf(pointer); + files[files.length - 1].elements += 1; + if (!ID.test(id)) problems.push(problem('error', rel, `Element id "${id}" must match ${ID}.`, { line, pointer })); + if (elements[id]) { + problems.push(problem('error', rel, `Element "${id}" is defined twice (also in ${elements[id].file}, line ${elements[id].line}).`, { line, pointer })); + continue; + } + if (typeof element !== 'object' || element === null) { + problems.push(problem('error', rel, `Element "${id}" must be a mapping.`, { line, pointer })); + continue; + } + if (!element.type) problems.push(problem('error', rel, `Element "${id}" has no type.`, { line, pointer })); + else if (!COMPONENT_TYPES.includes(element.type) && !STATE_TYPES.includes(element.type)) { + problems.push(problem('error', rel, `Element "${id}" has type "${element.type}"; Archify knows ${COMPONENT_TYPES.join(', ')} (and lifecycle state types).`, { line: source.lineOf(`${pointer}/type`), pointer: `${pointer}/type` })); + } + if (!element.label) problems.push(problem('error', rel, `Element "${id}" has no label.`, { line, pointer })); + for (const key of Object.keys(element)) { + if (!COPY_ALL.includes(key) && !REGISTRY_ONLY.includes(key)) { + problems.push(problem('warning', rel, `Element "${id}": "${key}" is not a registry field and will not reach any diagram.`, { line: source.lineOf(`${pointer}/${key}`), pointer: `${pointer}/${key}` })); + } + } + if (element.diagram !== undefined) { + const link = linkTarget(root, element.diagram); + if (!link.ok) problems.push(problem('error', rel, `Element "${id}": diagram link "${element.diagram}" ${link.reason}.`, { line: source.lineOf(`${pointer}/diagram`), pointer: `${pointer}/diagram` })); + } + elements[id] = { ...element, id, file: rel, line }; + } + } + return { elements, problems, files }; +} + +// Reads one diagram file and expands its ref nodes from the registry. +// `root` and `elements` are undefined outside a model. +export function resolveDiagram(file, { root, elements } = {}) { + const rel = root ? relative(root, file) : path.basename(file); + const isMain = path.basename(file) === MAIN; + const source = readSource(file); + const problems = []; + const refs = []; + const result = { file: rel, path: file, isMain, source, problems, refs, model: undefined, diagramType: undefined, hasDiagram: false, resolved: undefined, nodeIndex: {} }; + for (const error of source.errors) problems.push(problem('error', rel, error.message, { line: error.line })); + if (!source.data) return result; + const data = source.data; + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + problems.push(problem('error', rel, 'A diagram file is a YAML mapping.', { line: 1 })); + return result; + } + + if (data.model !== undefined) { + if (!isMain) problems.push(problem('warning', rel, `Only ${MAIN} carries a "model" block; it is ignored here.`, { line: source.lineOf('/model'), pointer: '/model' })); + else if (typeof data.model !== 'object' || data.model === null) problems.push(problem('error', rel, '"model" must be a mapping with id, name and description.', { line: source.lineOf('/model'), pointer: '/model' })); + else { + result.model = data.model; + if (!data.model.id) problems.push(problem('error', rel, 'model.id is missing.', { line: source.lineOf('/model'), pointer: '/model/id' })); + else if (!ID.test(String(data.model.id))) problems.push(problem('error', rel, `model.id "${data.model.id}" must match ${ID}.`, { line: source.lineOf('/model/id'), pointer: '/model/id' })); + if (!data.model.name) problems.push(problem('error', rel, 'model.name is missing.', { line: source.lineOf('/model'), pointer: '/model/name' })); + } + } else if (isMain) { + problems.push(problem('error', rel, `${MAIN} needs a "model" block (id, name, description).`, { line: 1, pointer: '/model' })); + } + + const diagram = { ...data }; + delete diagram.model; + const type = diagram.diagram_type; + if (type === undefined) { + if (!isMain) problems.push(problem('error', rel, `diagram_type is missing; one of ${TYPES.join(', ')}.`, { line: 1, pointer: '/diagram_type' })); + return result; + } + result.hasDiagram = true; + if (!TYPES.includes(type)) { + problems.push(problem('error', rel, `diagram_type "${type}" is unknown; one of ${TYPES.join(', ')}.`, { line: source.lineOf('/diagram_type'), pointer: '/diagram_type' })); + return result; + } + result.diagramType = type; + const collection = NODE_COLLECTION[type]; + const copyKeys = COPY[type]; + const nodes = Array.isArray(diagram[collection]) ? diagram[collection] : []; + const seen = new Map(); + + diagram[collection] = nodes.map((node, index) => { + const pointer = `/${collection}/${index}`; + if (typeof node !== 'object' || node === null) return node; + if (node.diagram !== undefined) { + if (root) { + const link = linkTarget(root, node.diagram); + if (!link.ok) problems.push(problem('error', rel, `diagram link "${node.diagram}" ${link.reason}.`, { line: source.lineOf(`${pointer}/diagram`), pointer: `${pointer}/diagram` })); + } else { + problems.push(problem('warning', rel, `diagram link "${node.diagram}" is ignored: links need a model (no ${MAIN} above this file).`, { line: source.lineOf(`${pointer}/diagram`), pointer: `${pointer}/diagram` })); + } + } + let merged; + if (node.ref === undefined) { + const { diagram: _link, ...plain } = node; + merged = plain; + } else { + const ref = String(node.ref); + const { ref: _ref, diagram: _link, ...own } = node; + refs.push(ref); + const element = elements?.[ref]; + if (!element) { + const message = root + ? `Unknown element "${ref}": no registry under ${root} defines it.` + : `"${ref}" cannot be resolved: no ${MAIN} above this file, so there is no registry. Define the element inline (id, type, label) instead.`; + problems.push(problem('error', rel, message, { line: source.lineOf(`${pointer}/ref`), pointer: `${pointer}/ref` })); + merged = { id: ref, type: 'external', label: ref, ...own }; + } else { + for (const key of Object.keys(own)) { + if (copyKeys.includes(key) && !OVERRIDE.includes(key)) { + problems.push(problem('error', rel, `"${key}" of shared element "${ref}" cannot be overridden in a diagram; change it in ${element.file} or use another element.`, { line: source.lineOf(`${pointer}/${key}`), pointer: `${pointer}/${key}` })); + } + } + merged = { id: own.id ?? ref }; + for (const key of copyKeys) if (element[key] !== undefined) merged[key] = element[key]; + for (const [key, value] of Object.entries(own)) if (key !== 'id' && (!copyKeys.includes(key) || OVERRIDE.includes(key))) merged[key] = value; + } + } + if (merged.id !== undefined) { + if (seen.has(merged.id)) problems.push(problem('error', rel, `Duplicate id "${merged.id}" (also at line ${seen.get(merged.id)}). Use "id:" next to "ref:" to draw an element twice.`, { line: source.lineOf(pointer), pointer: `${pointer}/id` })); + else seen.set(merged.id, source.lineOf(pointer)); + result.nodeIndex[merged.id] = index; + } + return merged; + }); + result.resolved = diagram; + return result; +} + +// Runs the Archify validator on a resolved diagram and appends its problems. +export function validateResolved(entry, options = {}) { + if (!entry.resolved || !entry.diagramType) return undefined; + const name = path.basename(entry.path, DIAGRAM_EXT) || 'diagram'; + const receipt = withTempJson(entry.resolved, `${name}.${entry.diagramType}`, (file) => validateJson(entry.diagramType, file, options)); + for (const item of receiptProblems(receipt, entry.nodeIndex, NODE_COLLECTION[entry.diagramType])) { + entry.problems.push(problem(item.severity, entry.file, item.message, { line: item.pointer ? entry.source.lineOf(item.pointer) : undefined, pointer: item.pointer, code: item.code })); + } + return receipt; +} + +// Loads a whole model: registry, every diagram resolved, model-level problems. +export function loadModel(root, options = {}) { + const scan = scanModel(root); + const registry = loadRegistry(root, scan.registries); + const problems = [...registry.problems]; + for (const nested of scan.nestedMains) { + problems.push(problem('error', relative(root, nested), `A second ${MAIN} inside a model would start a nested model; nested models are not supported. Move it out or remove it.`, { line: 1 })); + } + const diagrams = scan.diagrams.map((file) => resolveDiagram(file, { root, elements: registry.elements })); + const main = diagrams.find((entry) => entry.isMain); + const usage = {}; + for (const entry of diagrams) for (const ref of new Set(entry.refs)) usage[ref] = (usage[ref] ?? 0) + 1; + return { root, scan, registry, diagrams, main, usage, problems, options }; +} + +export function summarize(problems) { + const errors = problems.filter((item) => item.severity === 'error').length; + const warnings = problems.filter((item) => item.severity === 'warning').length; + return { errors, warnings }; +} + +export function presentProblem(item) { + const where = [item.file, item.line !== undefined ? `line ${item.line}` : undefined, item.pointer].filter(Boolean).join(' '); + return { severity: item.severity, file: item.file, line: item.line, pointer: item.pointer, code: item.code, message: item.message, where }; +} diff --git a/scripts/src/services/source.mjs b/scripts/src/services/source.mjs new file mode 100644 index 0000000..5d36ac4 --- /dev/null +++ b/scripts/src/services/source.mjs @@ -0,0 +1,42 @@ +// Reads an .archify or .archify-registry file as YAML and keeps the parsed +// document so problems can be reported with the line of the offending node. + +import fs from 'node:fs'; +import { parseDocument, LineCounter } from '../../vendor/yaml/index.js'; + +export class Source { + constructor(file) { + this.file = file; + this.text = fs.readFileSync(file, 'utf8'); + this.lineCounter = new LineCounter(); + this.doc = parseDocument(this.text, { lineCounter: this.lineCounter, prettyErrors: true }); + this.errors = this.doc.errors.map((error) => ({ + line: error.linePos?.[0]?.line, + message: `YAML: ${error.message.split('\n')[0]}`, + })); + this.data = this.errors.length ? undefined : this.doc.toJS({ maxAliasCount: -1 }); + } + + // Line (1-based) of the node at a JSON pointer such as /components/2/ref. + lineOf(pointer) { + if (!pointer || pointer === '/') return 1; + const path = pointer + .split('/') + .slice(1) + .map((segment) => (/^\d+$/.test(segment) ? Number(segment) : segment.replace(/~1/g, '/').replace(/~0/g, '~'))); + for (let depth = path.length; depth > 0; depth -= 1) { + let node; + try { + node = this.doc.getIn(path.slice(0, depth), true); + } catch { + node = undefined; + } + if (node?.range) return this.lineCounter.linePos(node.range[0]).line; + } + return undefined; + } +} + +export function readSource(file) { + return new Source(file); +} diff --git a/scripts/src/services/yaml.mjs b/scripts/src/services/yaml.mjs new file mode 100644 index 0000000..6f2f514 --- /dev/null +++ b/scripts/src/services/yaml.mjs @@ -0,0 +1,46 @@ +// Minimal YAML emitter for action results (objects, arrays, scalars). +// Output only; parsing is done by the vendored yaml package (see source.mjs). + +const PLAIN = /^[A-Za-z0-9_./@+-][A-Za-z0-9_ ./@+:-]*$/; +const RESERVED = /^(true|false|null|yes|no|on|off|~|-?\d+(\.\d+)?)$/i; + +function scalar(value) { + if (value === null || value === undefined) return 'null'; + if (typeof value === 'boolean' || typeof value === 'number') return String(value); + const text = String(value); + if (text === '' || !PLAIN.test(text) || RESERVED.test(text) || text.endsWith(':') || text.includes(': ') || text.includes(' #')) { + return JSON.stringify(text); + } + return text; +} + +function isEmpty(value) { + return (Array.isArray(value) && value.length === 0) || (value && typeof value === 'object' && Object.keys(value).length === 0); +} + +function emit(value, indent) { + const pad = ' '.repeat(indent); + if (Array.isArray(value)) { + return value + .map((item) => { + if (item && typeof item === 'object' && !isEmpty(item)) { + const body = emit(item, indent + 2); + return `${pad}- ${body.slice(indent + 2)}`; + } + return `${pad}- ${isEmpty(item) ? (Array.isArray(item) ? '[]' : '{}') : scalar(item)}`; + }) + .join('\n'); + } + return Object.entries(value) + .filter(([, item]) => item !== undefined) + .map(([key, item]) => { + if (item && typeof item === 'object' && !isEmpty(item)) return `${pad}${key}:\n${emit(item, indent + 2)}`; + if (isEmpty(item)) return `${pad}${key}: ${Array.isArray(item) ? '[]' : '{}'}`; + return `${pad}${key}: ${scalar(item)}`; + }) + .join('\n'); +} + +export function toYaml(value) { + return `${emit(value, 0)}\n`; +} diff --git a/scripts/src/test/run.mjs b/scripts/src/test/run.mjs new file mode 100644 index 0000000..5ae5c6e --- /dev/null +++ b/scripts/src/test/run.mjs @@ -0,0 +1,177 @@ +#!/usr/bin/env node +// The skill's own checks. Runs every action against examples/online-shop and +// a scaffolded temp model. Prints YAML (success first); exit 1 on any failure. + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { toYaml } from '../services/yaml.mjs'; + +const scripts = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const skill = path.resolve(scripts, '..'); +const example = path.join(skill, 'examples', 'online-shop'); +const cli = path.join(scripts, 'src', 'cli', 'commands.mjs'); +const checks = []; + +function cliRun(args) { + const result = spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8', cwd: scripts, maxBuffer: 64 * 1024 * 1024 }); + return { status: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +function yamlValue(stdout, key) { + const match = new RegExp(`^${key}: (.*)$`, 'm').exec(stdout); + return match ? match[1].trim() : undefined; +} + +function check(name, command, expect) { + const result = cliRun(command); + let ok = true; + const notes = []; + try { + expect(result, notes); + } catch (error) { + ok = false; + notes.push(error.message); + } + checks.push({ name, command: `node src/cli/commands.mjs ${command.join(' ')}`, ok, notes, stdout: ok ? undefined : result.stdout.slice(0, 4000) }); +} + +const assert = (condition, message) => { + if (!condition) throw new Error(message); +}; + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-skill-test-')); +try { + check('help prints usage', ['--help'], (r) => { + assert(r.status === 0, `exit ${r.status}`); + assert(/model-validate/.test(r.stdout) && /diagram-render/.test(r.stdout), 'actions missing from --help'); + }); + + check('model-validate example: 4 diagrams, 0 problems', ['--action', 'model-validate', '--dir', example], (r, notes) => { + assert(r.status === 0, `exit ${r.status}`); + assert(yamlValue(r.stdout, ' errors') === '0', 'errors are not 0'); + assert(yamlValue(r.stdout, ' warnings') === '0', 'warnings are not 0'); + assert(yamlValue(r.stdout, ' diagrams') === '4', 'expected 4 diagrams'); + assert(yamlValue(r.stdout, ' diagramsOk') === '4', 'expected 4 diagrams ok'); + notes.push('4 diagrams validated with 0 problems'); + }); + + check('model-validate from a file inside the model', ['--action', 'model-validate', '--dir', path.join(example, '0300-components', 'api.archify')], (r) => { + assert(r.status === 0, `exit ${r.status}`); + assert(yamlValue(r.stdout, 'mode') === 'model', 'root was not found from a file inside'); + }); + + check('model-list example: 3 groups plus root-level main.archify', ['--action', 'model-list', '--dir', example], (r, notes) => { + assert(r.status === 0, `exit ${r.status}`); + assert(yamlValue(r.stdout, ' groups') === '3', 'expected 3 groups'); + assert(/- file: main\.archify/.test(r.stdout), 'main.archify not listed at root level'); + assert(/folder: 0100-context/.test(r.stdout) && /folder: 0200-containers/.test(r.stdout) && /folder: 0300-components/.test(r.stdout), 'group folders missing'); + assert(/id: api\n(?:.*\n)*?\s+usedIn: 2/.test(r.stdout), 'api should be used in 2 diagrams'); + assert(yamlValue(r.stdout, ' elements') === '11', 'expected 11 registry elements'); + notes.push('3 groups, 11 elements with usage counts'); + }); + + const resolved = path.join(tmp, 'containers.architecture.json'); + check('model-resolve writes Archify JSON', ['--action', 'model-resolve', '--file', path.join(example, '0200-containers', 'online-shop.archify'), '--out', resolved], (r, notes) => { + assert(r.status === 0, `exit ${r.status}`); + const json = JSON.parse(fs.readFileSync(resolved, 'utf8')); + assert(json.model === undefined, 'model block leaked into the resolved JSON'); + const api = json.components.find((c) => c.id === 'api'); + assert(api && api.type === 'backend' && api.brand === 'node-js' && api.description === undefined && api.diagram === undefined, 'api was not resolved from the registry'); + notes.push(`${json.components.length} components, refs expanded`); + }); + + check('model-resolve prints the JSON inline without --out', ['--action', 'model-resolve', '--file', path.join(example, 'main.archify')], (r) => { + assert(r.status === 0, `exit ${r.status}`); + assert(/^resolved:/m.test(r.stdout) && /label: Online Shop/.test(r.stdout), 'resolved block missing'); + }); + + check('diagram-validate inside the model', ['--action', 'diagram-validate', '--file', path.join(example, '0100-context', 'online-shop.archify')], (r) => { + assert(r.status === 0, `exit ${r.status}`); + assert(yamlValue(r.stdout, 'archifyOk') === 'true', 'Archify did not accept the diagram'); + }); + + const html = path.join(tmp, 'context.html'); + check('diagram-render writes HTML', ['--action', 'diagram-render', '--file', path.join(example, '0100-context', 'online-shop.archify'), '--out', html], (r, notes) => { + assert(r.status === 0, `exit ${r.status}`); + assert(fs.existsSync(html) && fs.statSync(html).size > 10000, 'HTML missing or too small'); + assert(/ { + assert(r.status === 1, `exit ${r.status}, expected 1`); + const text = r.stdout.replace(/\\"/g, '"'); + for (const expected of ['defined twice', 'Unknown element "nobody"', '"type" of shared element "db"', 'does not exist under', 'Duplicate id "a"', 'less than 8px apart']) { + assert(text.includes(expected), `missing problem: ${expected}`); + } + assert(/pointer: \/components\/0\/ref/.test(text) && /line: 7/.test(text), 'pointer/line of the unknown ref missing'); + assert(/file: 0300-components\/overlap\.archify\n(?:.*\n)*?\s+line: 7\n\s+pointer: \/components\/0\n\s+code: layout\/constraint/.test(text), 'Archify layout problem not mapped to a component line'); + notes.push('all expected problems reported with file, line and pointer'); + }); + + const single = path.join(tmp, 'single.archify'); + fs.writeFileSync(single, 'schema_version: 1\ndiagram_type: architecture\nmeta:\n title: Single\n output: single.html\ncomponents:\n - { id: a, type: backend, label: A, pos: [40, 60], size: [130, 60] }\n - { id: b, type: database, label: B, pos: [300, 60], size: [130, 60] }\nconnections:\n - { from: a, to: b, label: SQL }\n'); + check('diagram outside a model validates with Archify only', ['--action', 'model-validate', '--dir', single], (r) => { + assert(r.status === 0, `exit ${r.status}`); + assert(yamlValue(r.stdout, 'mode') === 'single-diagram', 'expected single-diagram mode'); + }); + + const outside = path.join(tmp, 'outside.archify'); + fs.writeFileSync(outside, 'schema_version: 1\ndiagram_type: architecture\nmeta:\n title: Outside\n output: outside.html\ncomponents:\n - { ref: api, pos: [40, 60], size: [130, 60] }\n'); + check('ref outside a model is an error', ['--action', 'diagram-validate', '--file', outside], (r) => { + assert(r.status === 1, `exit ${r.status}, expected 1`); + assert(/cannot be resolved: no main\.archify above/.test(r.stdout), 'missing "no main.archify" problem'); + }); + + const scaffold = path.join(tmp, 'scaffolded'); + check('model-scaffold --c4 creates a model', ['--action', 'model-scaffold', '--dir', scaffold, '--id', 'demo-shop', '--name', 'Demo Shop', '--description', 'A demo: scaffolded model', '--c4'], (r, notes) => { + assert(r.status === 0, `exit ${r.status}`); + for (const file of ['main.archify', 'registry/people.archify-registry', 'registry/systems.archify-registry', 'registry/containers.archify-registry', '0100-context/demo-shop.archify', '0200-containers/demo-shop.archify', '0300-components/api.archify']) { + assert(fs.existsSync(path.join(scaffold, file)), `${file} not created`); + } + notes.push('7 files created'); + }); + check('scaffolded model validates', ['--action', 'model-validate', '--dir', scaffold], (r) => { + assert(r.status === 0, `exit ${r.status}`); + assert(yamlValue(r.stdout, ' errors') === '0' && yamlValue(r.stdout, ' warnings') === '0', 'scaffolded model has problems'); + assert(yamlValue(r.stdout, ' diagrams') === '4', 'expected 4 diagrams in the scaffolded model'); + }); + check('scaffolded model lists 3 groups', ['--action', 'model-list', '--dir', scaffold], (r) => { + assert(r.status === 0, `exit ${r.status}`); + assert(yamlValue(r.stdout, ' groups') === '3', 'expected 3 groups'); + }); + check('model-scaffold refuses to overwrite', ['--action', 'model-scaffold', '--dir', scaffold, '--id', 'demo-shop', '--name', 'Demo Shop', '--c4'], (r) => { + assert(r.status === 1, `exit ${r.status}, expected 1`); + assert(/Refusing to overwrite/.test(r.stdout), 'no refusal message'); + assert(fs.readFileSync(path.join(scaffold, 'main.archify'), 'utf8').includes('Demo Shop'), 'main.archify was touched'); + }); + const plain = path.join(tmp, 'plain'); + check('model-scaffold without --c4 creates main.archify only', ['--action', 'model-scaffold', '--dir', plain, '--id', 'plain', '--name', 'Plain'], (r) => { + assert(r.status === 0, `exit ${r.status}`); + assert(fs.readdirSync(plain).join(',') === 'main.archify', `unexpected files: ${fs.readdirSync(plain).join(',')}`); + }); + check('plain model validates (model block only)', ['--action', 'model-validate', '--dir', plain], (r) => { + assert(r.status === 0, `exit ${r.status}`); + }); +} finally { + fs.rmSync(tmp, { recursive: true, force: true }); +} + +const failed = checks.filter((item) => !item.ok).length; +process.stdout.write(toYaml({ success: failed === 0, summary: { checks: checks.length, passed: checks.length - failed, failed }, checks })); +process.exit(failed ? 1 : 0); diff --git a/scripts/src/tools/sync-archify.mjs b/scripts/src/tools/sync-archify.mjs new file mode 100644 index 0000000..883c8a3 --- /dev/null +++ b/scripts/src/tools/sync-archify.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +// Copies the Archify renderer runtime into scripts/vendor/archify and the +// yaml parser (ESM build) into scripts/vendor/yaml, so the skill's tasks run +// on plain Node 18+ without an npm install. +// +// Usage: node src/tools/sync-archify.mjs [path-to-archify-checkout] [--yaml-src ] +// Defaults: $ARCHIFY_SRC or ../../../ws-storm/archify (relative to scripts/) +// $YAML_SRC or ../../../ws-main/archify-vscode-ext/node_modules/yaml + +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const scripts = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const args = process.argv.slice(2); +const yamlIndex = args.indexOf('--yaml-src'); +const yamlArg = yamlIndex >= 0 ? args.splice(yamlIndex, 2)[1] : undefined; +const source = path.resolve(args[0] || process.env.ARCHIFY_SRC || path.join(scripts, '..', '..', '..', 'ws-storm', 'archify')); +const yamlSource = path.resolve( + yamlArg || process.env.YAML_SRC || path.join(scripts, '..', '..', '..', 'ws-main', 'archify-vscode-ext', 'node_modules', 'yaml'), +); +const skill = path.join(source, 'archify'); + +// Runtime pieces the CLI reads at render/validate time. test/ and examples/ +// are development-only and large, so they are left out. +const RUNTIME = [ + 'LICENSE', + 'THIRD_PARTY_NOTICES.md', + 'package.json', + 'skill-release.json', + 'assets', + 'bin', + 'brand-marks', + 'delta', + 'migrations', + 'recipes', + 'references', + 'renderers', + 'schemas', + 'scripts', +]; + +if (!fs.existsSync(path.join(skill, 'bin', 'archify.mjs'))) { + console.error(`Archify checkout not found at ${source} (expected ${path.join(skill, 'bin', 'archify.mjs')})`); + process.exit(1); +} +if (!fs.existsSync(path.join(yamlSource, 'browser', 'dist', 'index.js'))) { + console.error(`yaml package not found at ${yamlSource} (expected browser/dist/index.js)`); + process.exit(1); +} + +const vendor = path.join(scripts, 'vendor'); +const archifyOut = path.join(vendor, 'archify'); +fs.rmSync(archifyOut, { recursive: true, force: true }); +fs.mkdirSync(archifyOut, { recursive: true }); +for (const entry of RUNTIME) { + fs.cpSync(path.join(skill, entry), path.join(archifyOut, entry), { recursive: true }); +} + +let revision = 'unknown'; +try { + revision = execFileSync('git', ['-C', source, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); +} catch {} +const { version } = JSON.parse(fs.readFileSync(path.join(skill, 'package.json'), 'utf8')); +fs.writeFileSync( + path.join(vendor, 'archify.json'), + `${JSON.stringify({ upstream: 'https://github.com/tt-a1i/archify', license: 'MIT', version, revision }, null, 2)}\n`, +); + +// yaml (ISC): the browser build is plain ESM and runs on Node as well. +const yamlOut = path.join(vendor, 'yaml'); +fs.rmSync(yamlOut, { recursive: true, force: true }); +fs.cpSync(path.join(yamlSource, 'browser', 'dist'), yamlOut, { recursive: true }); +fs.copyFileSync(path.join(yamlSource, 'LICENSE'), path.join(yamlOut, 'LICENSE')); +const yamlPackage = JSON.parse(fs.readFileSync(path.join(yamlSource, 'package.json'), 'utf8')); +fs.writeFileSync( + path.join(yamlOut, 'package.json'), + `${JSON.stringify( + { + name: 'yaml', + version: yamlPackage.version, + license: yamlPackage.license, + author: yamlPackage.author, + homepage: yamlPackage.homepage, + type: 'module', + note: 'ESM build of the yaml package, copied by src/tools/sync-archify.mjs. Not an npm install.', + }, + null, + 2, + )}\n`, +); + +console.log(`Synced archify ${version} (${revision.slice(0, 12)}) from ${source}`); +console.log(`Synced yaml ${yamlPackage.version} from ${yamlSource}`); diff --git a/scripts/vendor/archify.json b/scripts/vendor/archify.json new file mode 100644 index 0000000..449c129 --- /dev/null +++ b/scripts/vendor/archify.json @@ -0,0 +1,6 @@ +{ + "upstream": "https://github.com/tt-a1i/archify", + "license": "MIT", + "version": "3.0.1", + "revision": "7158026e852f3aa6578c741e673b46d7878c92c1" +} diff --git a/scripts/vendor/archify/LICENSE b/scripts/vendor/archify/LICENSE new file mode 100644 index 0000000..0f9f939 --- /dev/null +++ b/scripts/vendor/archify/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 tt-a1i (Archify) +Copyright (c) 2025 Cocoon AI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/scripts/vendor/archify/THIRD_PARTY_NOTICES.md b/scripts/vendor/archify/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..3c15e20 --- /dev/null +++ b/scripts/vendor/archify/THIRD_PARTY_NOTICES.md @@ -0,0 +1,69 @@ +# Third-party notices + +Archify includes optional vector data for third-party brand marks. These marks +are provided only to identify technologies and services in user-authored +diagrams. Their inclusion does not imply sponsorship, endorsement, partnership, +or affiliation with Archify. + +The Archify MIT license applies to Archify's own code and content. It does not +replace the copyright licenses, trademark policies, or brand guidelines that +apply to third-party marks. Users are responsible for confirming that their +particular use is permitted. + +## Simple Icons + +Most of the built-in vector paths and their metadata were generated from +[Simple Icons 16.28.0](https://github.com/simple-icons/simple-icons/tree/16.28.0). +Simple Icons makes its collection work available under +[CC0 1.0 Universal](https://github.com/simple-icons/simple-icons/blob/16.28.0/LICENSE.md). + +As Simple Icons explains in its +[disclaimer](https://github.com/simple-icons/simple-icons/blob/16.28.0/DISCLAIMER.md), +CC0 for the collection does not mean that every underlying icon is CC0. License +and brand-guideline metadata may be incomplete or change over time. The absence +of an individual license entry is not a grant of permission. + +Archify embeds the selected icons as vector-path data and may render them in a +user-selected color. The following individual licenses were recorded in the +pinned Simple Icons 16.28.0 metadata: + +| Mark | Recorded source | Recorded license | Archify treatment | +|---|---|---|---| +| Angular | [Angular press kit](https://angular.dev/press-kit) | [`CC-BY-4.0`](https://creativecommons.org/licenses/by/4.0/) | Embedded as vector-path data; color may be changed by the authored diagram. | +| Apache Airflow | [Apache logos](https://apache.org/logos) | [`Apache-2.0`](https://www.apache.org/licenses/LICENSE-2.0) | Embedded as vector-path data; Apache trademarks remain subject to the [ASF trademark policy](https://www.apache.org/foundation/marks/). | +| Apache Kafka | [Apache logos](https://apache.org/logos) | [`Apache-2.0`](https://www.apache.org/licenses/LICENSE-2.0) | Embedded as vector-path data; Apache trademarks remain subject to the [ASF trademark policy](https://www.apache.org/foundation/marks/). | +| .NET | [.NET brand repository](https://github.com/dotnet/brand/blob/c7d0f51b8ec59531332d05fb27a5b758a7a3d689/logo/dotnet-logo.svg) | [`CC0-1.0`](https://creativecommons.org/publicdomain/zero/1.0/) | Embedded as vector-path data; color may be changed by the authored diagram. | +| JavaScript | [JS community logo](https://github.com/voodootikigod/logo.js/blob/1544bdeed6d618a6cfe4f0650d04ab8d9cfa76d9/js.svg) | [`MIT`](https://github.com/voodootikigod/logo.js/blob/1544bdeed6d618a6cfe4f0650d04ab8d9cfa76d9/LICENSE) | Embedded as vector-path data; color may be changed by the authored diagram. | +| Jenkins | [Jenkins artwork source](https://get.jenkins.io/art/) | [`CC-BY-SA-3.0`](https://creativecommons.org/licenses/by-sa/3.0/) | Embedded as vector-path data; color may be changed by the authored diagram. Jenkins retains its trademark rights. | +| Rust | [Rust project](https://www.rust-lang.org) | [`CC-BY-SA-4.0`](https://creativecommons.org/licenses/by-sa/4.0/) | Embedded as vector-path data; color may be changed by the authored diagram. See the [Rust media guide](https://www.rust-lang.org/policies/media-guide). | +| Vue.js | [Vue logo source](https://github.com/vuejs/art/blob/a1c78b74569b70a25300925b4eacfefcc143b8f6/logo.svg) | [`CC-BY-NC-SA-4.0`](https://creativecommons.org/licenses/by-nc-sa/4.0/) | Embedded as vector-path data; color may be changed by the authored diagram. The non-commercial and share-alike conditions remain applicable; see the [Vue artwork terms](https://github.com/vuejs/art/blob/a1c78b74569b70a25300925b4eacfefcc143b8f6/README.md). | + +The source, guideline, and known license fields for every packaged mark are +preserved in `renderers/shared/generated-brand-marks.mjs`. + +## OpenAI mark + +The OpenAI vector path is recorded from the +[OpenAI brand guidelines](https://openai.com/brand/), not from Simple Icons. +Use remains subject to those current guidelines and any applicable trademark +rights. Its inclusion does not state or imply endorsement by OpenAI. + +## JetBrains Mono + +Delivered Archify viewer artifacts embed the JetBrains Mono variable font +subsets served by Google Fonts. For characters covered by these subsets, font +selection does not depend on a network request or a locally installed copy. +Uncovered characters (including CJK) still use the system fallback stack; +browser and operating-system rasterization can differ. +JetBrains Mono is maintained at +[github.com/JetBrains/JetBrainsMono](https://github.com/JetBrains/JetBrainsMono) +and is distributed under the SIL Open Font License 1.1. The complete license +text is preserved in `assets/JetBrainsMono-OFL.txt` in the packaged Skill and +in the font CSS carried by standalone HTML and SVG exports. + +## No additional rights granted + +Brand names, logos, and trademarks remain the property of their respective +owners. This notice records provenance and known terms; it does not grant rights +that Archify does not hold, and it does not state that every packaged mark has +been cleared for every commercial, promotional, or redistributive use. diff --git a/scripts/vendor/archify/assets/JetBrainsMono-OFL.txt b/scripts/vendor/archify/assets/JetBrainsMono-OFL.txt new file mode 100644 index 0000000..675d198 --- /dev/null +++ b/scripts/vendor/archify/assets/JetBrainsMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/scripts/vendor/archify/assets/template.html b/scripts/vendor/archify/assets/template.html new file mode 100644 index 0000000..792c822 --- /dev/null +++ b/scripts/vendor/archify/assets/template.html @@ -0,0 +1,13709 @@ + + + + + + + + [PROJECT NAME] Architecture Diagram + + + + + + + + +
+ +
+
+
+

[PROJECT NAME] Architecture

+
+

[Subtitle description]

+
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Users + Browser/Mobile + + + + + Auth Provider + OAuth 2.0 + + + + AWS Region: us-west-2 + + + + + CloudFront + CDN + + + + + S3 Buckets + • bucket-one + • bucket-two + • bucket-three + OAI Protected + + + + sg-name :port + + + + + Load Balancer + HTTPS :443 + + + + + API Server + FastAPI :8000 + + + + + Database + PostgreSQL + + + + + Frontend + React + TypeScript + Additional detail + More info + domain.example.com + + + + + + HTTPS + + + + + + + OAI + + + + + TLS + + + + JWT + PKCE + + + Legend + + + Frontend + + + Backend + + + Cloud Service + + + Database + + + Security + + + Auth Flow + + + Security Group + + +

+ + + + + + + + + +
+ + + +
+ + + + diff --git a/scripts/vendor/archify/bin/archify.mjs b/scripts/vendor/archify/bin/archify.mjs new file mode 100755 index 0000000..fa2066d --- /dev/null +++ b/scripts/vendor/archify/bin/archify.mjs @@ -0,0 +1,6936 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const skillRoot = path.resolve(__dirname, '..'); + +const TYPES = new Set(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']); +const DELIVERY_SIDECAR_SUFFIXES = Object.freeze([ + '.delivery.json', + '.delivery-pending.json', + '.delivery-lock.json', +]); +const COMPARE_SIDECAR_SUFFIXES = Object.freeze(['.receipt.json']); +const DELIVERY_DIRECTORY_LOCK = '.archify-delivery-lock.json'; +let sidecarNamespaceComponentKeyRuntime; +let boundedSidecarStem; +let isBoundedSidecarStem; +let sidecarStemFromComponent; +let sidecarStemNeedsBounding; + +async function loadSidecarPathRuntime() { + ({ + boundedSidecarStem, + isBoundedSidecarStem, + sidecarStemFromComponent, + sidecarStemNeedsBounding, + } = await import('../renderers/shared/sidecar-path.mjs')); +} + +function deliverySidecarNamespace(artifactPath) { + let artifact = path.resolve(artifactPath); + try { + artifact = fs.realpathSync.native(artifact); + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; + } + if (typeof sidecarNamespaceComponentKeyRuntime !== 'function') { + throw new Error('The sidecar namespace runtime is unavailable.'); + } + const namespace = sidecarNamespaceComponentKeyRuntime( + path.dirname(artifact), + path.basename(artifact), + ); + if (namespace.status !== 'resolved') { + const error = new Error( + `Could not establish a stable sidecar namespace (${namespace.reason?.code || 'unknown'}).`, + ); + error.code = 'ARCHIFY_SIDECAR_NAMESPACE_INDETERMINATE'; + error.sidecarNamespaceReason = namespace.reason; + throw error; + } + return { directory: namespace.directoryPath, ...sidecarStemFromComponent(namespace.componentKey) }; +} + +function deliverySidecarPath(artifactPath, suffix) { + const { directory, stem, options } = deliverySidecarNamespace(artifactPath); + return path.join( + directory, + `${boundedSidecarStem(stem, DELIVERY_SIDECAR_SUFFIXES, options)}${suffix}`, + ); +} + +function deliveryProvenancePath(artifactPath) { + return deliverySidecarPath(artifactPath, '.delivery.json'); +} + +function deliveryPendingPath(artifactPath) { + return deliverySidecarPath(artifactPath, '.delivery-pending.json'); +} + +function rawHeadDeliverySidecarPath(artifactPath, suffix) { + const { directory, stem, options } = deliverySidecarNamespace(artifactPath); + if (options?.force) return undefined; + if (sidecarStemNeedsBounding(stem, DELIVERY_SIDECAR_SUFFIXES) + || !isBoundedSidecarStem(stem)) return undefined; + return path.join(directory, `${stem}${suffix}`); +} + +function rawHeadDeliveryProvenancePath(artifactPath) { + return rawHeadDeliverySidecarPath(artifactPath, '.delivery.json'); +} + +function rawHeadDeliveryPendingPath(artifactPath) { + return rawHeadDeliverySidecarPath(artifactPath, '.delivery-pending.json'); +} + +function legacyExtensionDeliverySidecarPath(artifactPath, suffix) { + let artifact = path.resolve(artifactPath); + try { + artifact = fs.realpathSync.native(artifact); + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; + } + const component = path.basename(artifact); + if (!/\.html$/iu.test(component) || component.endsWith('.html')) return undefined; + const legacyComponent = `${component.slice(0, -'.html'.length)}${suffix}`; + if (legacyComponent.length > 255 || Buffer.byteLength(legacyComponent, 'utf8') > 255) { + return undefined; + } + return path.join(path.dirname(artifact), legacyComponent); +} + +function canonicalExistingDeliveryOutput(outputPath) { + const output = path.resolve(outputPath); + try { + return fs.realpathSync.native(output); + } catch (error) { + if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return output; + throw error; + } +} + +function legacyDeliveryLockPath(artifactPath) { + const artifact = path.resolve(artifactPath); + return `${artifact.replace(/\.html?$/i, '')}.delivery-lock.json`; +} + +function legacyDeliveryLockPaths(artifactPaths) { + const paths = []; + for (const artifactPath of artifactPaths) { + const lockPath = legacyDeliveryLockPath(artifactPath); + const name = path.basename(lockPath); + const canExist = process.platform === 'win32' + ? name.length <= 255 + : Buffer.byteLength(name, 'utf8') <= 255; + if (canExist && !paths.includes(lockPath)) paths.push(lockPath); + } + return paths; +} + +function deliveryLockPath(artifactPath) { + // A directory-wide mutex is deliberately conservative. Future 8.3, case, + // and Unicode aliases cannot be assigned a sound canonical filename before + // the artifact exists, so a per-artifact lock can split one delivery into + // multiple owners. Provenance and pending journals remain artifact-specific. + const artifact = path.resolve(artifactPath); + let directory; + try { + directory = path.dirname(fs.realpathSync.native(artifact)); + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; + const authoredDirectory = path.dirname(artifact); + try { + directory = fs.realpathSync.native(authoredDirectory); + } catch (directoryError) { + if (directoryError?.code !== 'ENOENT' && directoryError?.code !== 'ENOTDIR') throw directoryError; + directory = authoredDirectory; + } + } + return path.join(directory, DELIVERY_DIRECTORY_LOCK); +} + +function deliveryLockInvalid(lockPath) { + return Object.assign(new Error(`Unrecognized delivery lock at "${lockPath}"; inspect the existing entry before retrying.`), { + deliveryLockCode: 'delivery/lock-invalid', + deliveryLockPath: lockPath, + }); +} + +function regularDeliveryLockExists(lockPath) { + try { + if (!fs.lstatSync(lockPath).isFile()) throw deliveryLockInvalid(lockPath); + return true; + } catch (error) { + if (error.deliveryLockCode) throw error; + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false; + throw deliveryLockInvalid(lockPath); + } +} + +function rejectExistingDeliveryLock(lockPath, output, fileBindingRuntime) { + const { + captureRegularFileBinding, + releaseRegularFileBinding, + } = fileBindingRuntime || {}; + let lock; + let captured; + try { + if (typeof captureRegularFileBinding !== 'function' + || typeof releaseRegularFileBinding !== 'function') { + throw new Error('The regular-file binding runtime is unavailable.'); + } + captured = captureRegularFileBinding(lockPath, { + subject: 'delivery-lock', + expectedLinks: 1, + includeContent: true, + }); + if (captured.status !== 'captured') throw new Error('Not a stable regular lock file.'); + lock = JSON.parse(captured.content.buffer.toString('utf8')); + if (lock?.schemaVersion !== 1 || !Number.isSafeInteger(lock.pid) || lock.pid <= 0 + || typeof lock.receiptId !== 'string' + || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(lock.receiptId)) { + throw new Error('Unrecognized delivery lock.'); + } + } catch { + throw deliveryLockInvalid(lockPath); + } finally { + if (captured?.binding) releaseRegularFileBinding(captured.binding); + } + if (processIsRunning(lock.pid)) { + throw Object.assign(new Error(`Another delivery attempt owns "${output}" through lock "${lockPath}".`), { + deliveryLockCode: 'delivery/concurrent-attempt', + deliveryLockPath: lockPath, + }); + } + throw Object.assign(new Error(`A stale delivery lock remains at "${lockPath}"; recover it serially before retrying.`), { + deliveryLockCode: 'delivery/lock-stale', + deliveryLockPath: lockPath, + deliveryLockOwner: { pid: lock.pid, receiptId: lock.receiptId }, + }); +} + +function processIsRunning(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error.code !== 'ESRCH'; + } +} + +const deliveryOwnershipStates = new WeakMap(); + +function deliveryOwnershipFailure(state, operation, reason, evidence = {}) { + const error = new Error(`Delivery ownership was lost while attempting to ${operation}: ${reason}`); + error.deliveryOwnershipCode = 'delivery/ownership-lost'; + error.deliveryOwnershipDetails = { + operation, + lock: state?.lockPath, + expectedReceiptId: state?.receiptId, + reason, + ...evidence, + }; + if (state && state.phase !== 'released') state.phase = 'lost'; + return error; +} + +function deliveryOwnershipState(ownership, operation) { + const state = deliveryOwnershipStates.get(ownership); + if (!state) throw deliveryOwnershipFailure(undefined, operation, 'the ownership capability is invalid'); + if (state.phase === 'lost' || state.phase === 'released') { + throw deliveryOwnershipFailure(state, operation, `the capability is already ${state.phase}`); + } + return state; +} + +function sameFileIdentity(actual, expected) { + const hasIdentity = (entry) => entry?.ino !== undefined && entry.ino !== 0 && entry.ino !== 0n; + return hasIdentity(actual) + && hasIdentity(expected) + && actual.dev === expected.dev + && actual.ino === expected.ino; +} + +function assertOwnedDeliveryLock(state, ownedLock, operation, { verifyReceipt = true } = {}) { + const { + captureRegularFileBinding, + releaseRegularFileBinding, + } = state?.fileBindingRuntime || {}; + let captured; + try { + if (typeof captureRegularFileBinding !== 'function' + || typeof releaseRegularFileBinding !== 'function') { + throw new Error('The regular-file binding runtime is unavailable.'); + } + captured = captureRegularFileBinding(ownedLock.path, { + subject: 'owned-delivery-lock', + ...(ownedLock.content ? { + expectedSha256: ownedLock.content.sha256, + expectedBytes: ownedLock.content.bytes, + } : {}), + expectedMode: Number(ownedLock.identity.mode & 0o777n), + expectedLinks: 1, + includeContent: verifyReceipt, + }); + } catch (error) { + throw deliveryOwnershipFailure(state, operation, 'an owned lock entry can no longer be inspected', { + lock: ownedLock.path, + ...(error?.code ? { systemCode: error.code } : {}), + }); + } + if (captured.status !== 'captured' + || captured.identity.device !== ownedLock.identity.dev + || captured.identity.inode !== ownedLock.identity.ino) { + if (captured?.binding) releaseRegularFileBinding(captured.binding); + throw deliveryOwnershipFailure(state, operation, 'an owned lock path no longer names the entry created by this attempt', { + lock: ownedLock.path, + ...(captured?.reason ? { lockState: captured.reason } : {}), + }); + } + try { + if (!verifyReceipt) return; + let lock; + try { + lock = JSON.parse(captured.content.buffer.toString('utf8')); + } catch (error) { + throw deliveryOwnershipFailure(state, operation, 'an owned lock receipt can no longer be verified', { + lock: ownedLock.path, + ...(error?.code ? { systemCode: error.code } : {}), + }); + } + if (lock?.schemaVersion !== 1 || lock.receiptId !== state.receiptId || lock.pid !== state.pid) { + throw deliveryOwnershipFailure(state, operation, 'an owned lock receipt no longer matches this attempt', { + lock: ownedLock.path, + ...(typeof lock?.receiptId === 'string' ? { observedReceiptId: lock.receiptId } : {}), + }); + } + } catch (error) { + throw error; + } finally { + releaseRegularFileBinding(captured.binding); + } +} + +function assertDeliveryOwnership(ownership, operation, { allowInitializing = false, pending = 'ignore' } = {}) { + const state = deliveryOwnershipState(ownership, operation); + const verifyReceipt = !allowInitializing || !['acquiring', 'initializing'].includes(state.phase); + for (const ownedLock of state.ownedLocks) { + assertOwnedDeliveryLock(state, ownedLock, operation, { verifyReceipt }); + } + if (pending !== 'ignore') { + const pendingPath = state.pendingPath; + if (pending === 'absent') { + try { + fs.lstatSync(pendingPath); + } catch (error) { + if (error.code === 'ENOENT') return state; + throw deliveryOwnershipFailure(state, operation, 'the finalized delivery journal can no longer be inspected', { + journal: pendingPath, + ...(error?.code ? { systemCode: error.code } : {}), + }); + } + throw deliveryOwnershipFailure(state, operation, 'the finalized delivery journal was recreated', { journal: pendingPath }); + } + const { + captureRegularFileBinding, + releaseRegularFileBinding, + } = state.fileBindingRuntime || {}; + let captured; + try { + if (typeof captureRegularFileBinding !== 'function' + || typeof releaseRegularFileBinding !== 'function') { + throw new Error('The regular-file binding runtime is unavailable.'); + } + captured = captureRegularFileBinding(pendingPath, { + subject: 'owned-delivery-journal', + expectedSha256: state.pendingContent?.sha256, + expectedBytes: state.pendingContent?.bytes, + expectedMode: state.pendingIdentity?.mode, + expectedLinks: 1, + includeContent: pending === 'owned', + }); + } catch (error) { + throw deliveryOwnershipFailure(state, operation, 'the owned delivery journal can no longer be inspected', { + journal: pendingPath, + ...(error?.code ? { systemCode: error.code } : {}), + }); + } + if (captured.status !== 'captured' + || !state.pendingIdentity + || captured.identity.device !== state.pendingIdentity.dev + || captured.identity.inode !== state.pendingIdentity.ino) { + if (captured?.binding) releaseRegularFileBinding(captured.binding); + throw deliveryOwnershipFailure(state, operation, 'the delivery journal no longer names the entry created by this attempt', { + journal: pendingPath, + ...(captured?.reason ? { journalState: captured.reason } : {}), + }); + } + try { + if (pending === 'owned') { + let journal; + try { + journal = JSON.parse(captured.content.buffer.toString('utf8')); + } catch { + throw deliveryOwnershipFailure(state, operation, 'the delivery journal receipt is malformed', { + journal: pendingPath, + }); + } + if (journal?.receiptId !== state.receiptId) { + throw deliveryOwnershipFailure(state, operation, 'the delivery journal receipt belongs to another attempt', { + journal: pendingPath, + ...(typeof journal?.receiptId === 'string' ? { observedReceiptId: journal.receiptId } : {}), + }); + } + } + } finally { + releaseRegularFileBinding(captured.binding); + } + } + return state; +} + +function quarantineRemoveOwnedDeliveryEntry(state, { + filePath, + identity, + content, + subject, + operation, + evidenceKey, +}) { + const { + captureRegularFileBinding, + quarantineRemoveRegularFileBinding, + releaseRegularFileBinding, + } = state?.fileBindingRuntime || {}; + let captured; + try { + if (typeof captureRegularFileBinding !== 'function' + || typeof quarantineRemoveRegularFileBinding !== 'function' + || typeof releaseRegularFileBinding !== 'function') { + throw new Error('The regular-file binding runtime is unavailable.'); + } + captured = captureRegularFileBinding(filePath, { + subject, + ...(content ? { + expectedSha256: content.sha256, + expectedBytes: content.bytes, + } : {}), + expectedMode: typeof identity?.mode === 'bigint' + ? Number(identity.mode & 0o777n) + : identity?.mode, + expectedIdentity: { + device: identity?.dev, + inode: identity?.ino, + }, + expectedLinks: 1, + }); + } catch (error) { + throw deliveryOwnershipFailure(state, operation, `the owned ${subject} can no longer be captured for removal`, { + [evidenceKey]: filePath, + ...(error?.code ? { systemCode: error.code } : {}), + }); + } + if (captured.status !== 'captured') { + throw deliveryOwnershipFailure(state, operation, `the owned ${subject} changed before removal`, { + [evidenceKey]: filePath, + entryState: captured.reason, + }); + } + try { + const removed = quarantineRemoveRegularFileBinding(captured.binding, filePath, { + subject, + expectedLinks: 1, + }); + if (removed.status === 'removed') return; + const removalCode = removed.reason?.code; + if (removed.status === 'unknown' + && ['removal-quarantine-create-failed', `${subject}-quarantine-move-failed`] + .includes(removalCode)) { + const detail = removed.reason?.message ? `: ${removed.reason.message}` : ''; + const error = new Error(`The owned ${subject} could not be moved into its private removal quarantine (${removalCode})${detail}.`); + if (removed.reason?.systemCode) error.code = removed.reason.systemCode; + if (subject === 'delivery-lock') { + error.deliveryOwnershipCode = 'delivery/lock-release'; + error.deliveryOwnershipDetails = { + operation, + lock: filePath, + reason: error.message, + entryState: removed.reason, + }; + } else { + error.deliveryJournalRemovalState = removed; + } + throw error; + } + throw deliveryOwnershipFailure( + state, + operation, + removed.status === 'recovery-required' + ? `a replacement ${subject} could not be restored from quarantine` + : `the public ${subject} changed during removal and was preserved`, + { + [evidenceKey]: filePath, + entryState: removed.reason, + ...(removed.status === 'recovery-required' ? { + recoveryRequired: true, + recoveryDirectory: removed.recoveryDirectory, + recoveryFiles: [{ + label: `preserved replacement ${subject}`, + path: removed.recoveryFile, + target: filePath, + }], + } : {}), + }, + ); + } finally { + releaseRegularFileBinding(captured.binding); + } +} + +function releaseDeliveryOwnership(ownership, { allowInitializing = false } = {}) { + const current = deliveryOwnershipState(ownership, 'release the delivery lock'); + const pending = current.phase === 'finalized' + ? 'absent' + : current.pendingIdentity + ? 'identity' + : 'ignore'; + const state = assertDeliveryOwnership(ownership, 'release the delivery lock', { allowInitializing, pending }); + const verifyReceipt = !allowInitializing || !['acquiring', 'initializing'].includes(state.phase); + for (const ownedLock of state.ownedLocks) { + assertOwnedDeliveryLock(state, ownedLock, 'release the delivery lock', { verifyReceipt }); + quarantineRemoveOwnedDeliveryEntry(state, { + filePath: ownedLock.path, + identity: ownedLock.identity, + content: ownedLock.content, + subject: 'delivery-lock', + operation: 'release the delivery lock', + evidenceKey: 'lock', + }); + } + state.phase = 'released'; +} + +function acquireDeliveryLock( + output, + receiptId, + inputPath, + pathsAlias, + recordInitializationFailure, + legacyOutputPaths = [], + fileBindingRuntime, +) { + const stableOutput = canonicalExistingDeliveryOutput(output); + const lockPath = deliveryLockPath(stableOutput); + const comparableLockPath = (candidate) => { + const absolute = path.resolve(candidate); + try { + return path.join(fs.realpathSync.native(path.dirname(absolute)), path.basename(absolute)); + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; + return absolute; + } + }; + const lockPathsAlias = (left, right) => { + const comparableLeft = comparableLockPath(left); + const comparableRight = comparableLockPath(right); + if (!pathsAlias(path.dirname(comparableLeft), path.dirname(comparableRight))) return false; + return pathsAlias(comparableLeft, comparableRight); + }; + const legacyFencePathsAlias = (left, right) => { + const comparableLeft = comparableLockPath(left); + const comparableRight = comparableLockPath(right); + if (comparableLeft === comparableRight) return true; + try { + return lockPathsAlias(comparableLeft, comparableRight); + } catch { + // An indeterminate future-name relation is not evidence of aliasing. + // Keep both candidates; an EEXIST below is accepted only when dev/ino + // proves that it names a lock already created by this attempt. + return false; + } + }; + const legacyLockPaths = []; + for (const candidate of legacyDeliveryLockPaths([ + ...legacyOutputPaths.filter(Boolean), + output, + stableOutput, + ])) { + if (!legacyLockPaths.some((existing) => legacyFencePathsAlias(candidate, existing))) { + legacyLockPaths.push(candidate); + } + } + const pendingPath = deliveryPendingPath(stableOutput); + const provenancePath = deliveryProvenancePath(stableOutput); + const rawHeadPendingPath = rawHeadDeliveryPendingPath(stableOutput); + const existingLegacyLocks = legacyLockPaths.filter((candidate) => regularDeliveryLockExists(candidate)); + regularDeliveryLockExists(lockPath); + const conflictingLockPath = [lockPath, ...legacyLockPaths] + .find((candidate) => lockPathsAlias(candidate, inputPath)); + if (conflictingLockPath) { + throw Object.assign(new Error('Delivery lock path aliases the input specification; choose another output path.'), { + deliveryLockCode: 'delivery/lock-path-conflict', + deliveryLockPath: conflictingLockPath, + }); + } + if (existingLegacyLocks.length) { + rejectExistingDeliveryLock(existingLegacyLocks[0], output, fileBindingRuntime); + } + const openedLocks = []; + const ownership = Object.freeze({}); + deliveryOwnershipStates.set(ownership, { + output: stableOutput, + lockPath, + pendingPath, + provenancePath, + receiptId, + pid: process.pid, + ownedLocks: openedLocks, + fileBindingRuntime, + phase: 'acquiring', + }); + let collisionPath; + try { + const openOwnedLock = (ownedLockPath, { allowOwnedAlias = false } = {}) => { + let descriptor; + try { + descriptor = fs.openSync(ownedLockPath, 'wx', 0o600); + } catch (error) { + if (error.code === 'EEXIST' && allowOwnedAlias) { + try { + const existingEntry = fs.lstatSync(ownedLockPath, { bigint: true }); + if (existingEntry.isFile() + && openedLocks.some((ownedLock) => sameFileIdentity(existingEntry, ownedLock.identity))) { + return undefined; + } + } catch { + // Preserve the original EEXIST classification below. + } + } + if (error.code === 'EEXIST') collisionPath = ownedLockPath; + throw error; + } + let handleIdentity; + let handleIdentityError; + try { + handleIdentity = fs.fstatSync(descriptor, { bigint: true }); + } catch (error) { + // A transient first inspection failure must not strand the public + // lock. Bind ownership from the still-open handle before cleanup. + try { + handleIdentity = fs.fstatSync(descriptor, { bigint: true }); + } catch { + // Both handle metadata reads failed. Stamp the still-open inode with + // this attempt's unpredictable receipt before consulting the public + // path. A replacement claimant cannot be mistaken for our empty + // lock, while a successful content-bound capture gives cleanup the + // identity it needs after the original descriptor is closed. + const recoveryReceipt = `${JSON.stringify({ schemaVersion: 1, receiptId, pid: process.pid })}\n`; + const recoveryContent = artifactIdentity(Buffer.from(recoveryReceipt)); + let captured; + try { + fs.writeFileSync(descriptor, recoveryReceipt); + captured = fileBindingRuntime?.captureRegularFileBinding?.(ownedLockPath, { + subject: 'owned-delivery-lock', + expectedSha256: recoveryContent.sha256, + expectedBytes: recoveryContent.bytes, + expectedLinks: 1, + }); + } catch {} + if (captured?.status === 'captured') { + fileBindingRuntime.releaseRegularFileBinding(captured.binding); + openedLocks.push({ + path: ownedLockPath, + identity: { + dev: captured.identity.device, + ino: captured.identity.inode, + mode: BigInt(captured.mode), + }, + content: recoveryContent, + descriptor, + }); + } else { + try { + fs.closeSync(descriptor); + } catch (closeError) { + error.lockCleanupError = closeError.message; + } + } + throw error; + } + handleIdentityError = error; + } + const ownedLock = { + path: ownedLockPath, + identity: handleIdentity, + descriptor, + }; + openedLocks.push(ownedLock); + const pathIdentity = fs.lstatSync(ownedLockPath, { bigint: true }); + if (!handleIdentity.isFile() + || handleIdentity.ino === 0n + || !pathIdentity.isFile() + || pathIdentity.isSymbolicLink() + || pathIdentity.ino === 0n + || !sameFileIdentity(pathIdentity, handleIdentity)) { + throw new Error('Delivery lock path identity could not be verified safely.'); + } + if (handleIdentityError) throw handleIdentityError; + return ownedLock; + }; + + openOwnedLock(lockPath); + for (const legacyLockPath of legacyLockPaths) { + openOwnedLock(legacyLockPath, { allowOwnedAlias: true }); + } + const state = deliveryOwnershipStates.get(ownership); + if (rawHeadPendingPath && pathEntryExists(rawHeadPendingPath)) { + throw Object.assign(new Error(`A pre-namespace delivery journal remains at "${rawHeadPendingPath}"; recover it before retrying.`), { + deliveryLockCode: 'delivery/legacy-pending', + deliveryLockPath: rawHeadPendingPath, + }); + } + state.phase = 'initializing'; + const lockReceipt = `${JSON.stringify({ schemaVersion: 1, receiptId, pid: process.pid })}\n`; + const lockContent = artifactIdentity(Buffer.from(lockReceipt)); + for (const ownedLock of openedLocks) { + fs.writeFileSync(ownedLock.descriptor, lockReceipt); + ownedLock.content = lockContent; + } + for (const ownedLock of openedLocks) { + fs.closeSync(ownedLock.descriptor); + ownedLock.descriptor = undefined; + } + state.phase = 'owned'; + return ownership; + } catch (error) { + for (const ownedLock of openedLocks) { + if (ownedLock.descriptor === undefined) continue; + try { + fs.closeSync(ownedLock.descriptor); + ownedLock.descriptor = undefined; + } catch (closeError) { + error.lockCleanupError = closeError.message; + } + } + const state = deliveryOwnershipStates.get(ownership); + let preserveOwnership = false; + try { + if (state.phase === 'initializing') { + assertDeliveryOwnership(ownership, 'record a lock initialization failure', { allowInitializing: true }); + error.deliveryFailureRecord = recordInitializationFailure?.(error, ownership); + if (error.deliveryFailureRecord?.journalRecovery) { + error.deliveryJournalRecovery = error.deliveryFailureRecord.journalRecovery.deliveryJournalRecovery; + preserveOwnership = true; + } + if (error.deliveryFailureRecord?.provenanceRecovery) { + error.deliveryProvenanceRecovery = error.deliveryFailureRecord.provenanceRecovery.deliveryProvenanceRecovery; + preserveOwnership = true; + } + } + if (!preserveOwnership) { + releaseDeliveryOwnership(ownership, { allowInitializing: true }); + } + } catch (cleanupError) { + if (cleanupError.deliveryOwnershipCode) { + cleanupError.deliveryOwnershipDetails = { + ...(cleanupError.deliveryOwnershipDetails || {}), + initializationError: error.message, + ...(error?.code ? { initializationSystemCode: error.code } : {}), + }; + if (cleanupError.deliveryOwnershipCode === 'delivery/lock-release' && error.deliveryFailureRecord) { + cleanupError.deliveryFailureRecord = error.deliveryFailureRecord; + } + throw cleanupError; + } + error.lockCleanupError = cleanupError.message; + } + if (collisionPath) rejectExistingDeliveryLock(collisionPath, output, fileBindingRuntime); + throw error; + } +} + +function pathEntryExists(file) { + try { + fs.lstatSync(file); + return true; + } catch (error) { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false; + throw error; + } +} + +function captureOwnedStagingFile(registry, filePath, fileBindingRuntime, { + subject = 'private-staging-entry', + content, + expectedLinks = 1, +} = {}) { + const { captureRegularFileBinding } = fileBindingRuntime || {}; + if (typeof captureRegularFileBinding !== 'function') { + throw new Error('The regular-file binding runtime is unavailable.'); + } + let captured = captureRegularFileBinding(filePath, { + subject, + ...(content ? { + expectedSha256: content.sha256, + expectedBytes: content.bytes, + } : {}), + expectedLinks, + }); + if (captured.status === 'unsupported' + && String(captured.reason?.code || '').endsWith('-hardlinked') + && content) { + const observedLinks = Number(captured.reason?.links); + if (Number.isSafeInteger(observedLinks) && observedLinks > expectedLinks) { + captured = captureRegularFileBinding(filePath, { + subject, + expectedSha256: content.sha256, + expectedBytes: content.bytes, + expectedLinks: observedLinks, + }); + expectedLinks = observedLinks; + } + } + if (captured.status !== 'captured') { + const error = new Error(`Could not bind the owned staging entry "${filePath}" (${captured.reason?.code || 'unknown state'}).`); + error.stagingCapture = captured; + throw error; + } + registry.push({ filePath, binding: captured.binding, subject, expectedLinks }); + return captured; +} + +function cleanupOwnedStagingDirectory(directory, identity, registry, fileBindingRuntime) { + const { + quarantineRemoveRegularFileBinding, + releaseRegularFileBinding, + } = fileBindingRuntime || {}; + if (typeof quarantineRemoveRegularFileBinding !== 'function' + || typeof releaseRegularFileBinding !== 'function') { + throw new Error('The regular-file binding runtime is unavailable.'); + } + const failures = []; + for (const entry of [...registry].reverse()) { + try { + const removed = quarantineRemoveRegularFileBinding(entry.binding, entry.filePath, { + subject: entry.subject, + expectedLinks: entry.expectedLinks, + }); + // A missing path means the transaction already moved or finalized this + // owned inode. Every other non-removal result is deliberately preserved: + // it may be a concurrent claimant rather than an entry we created. + if (removed.status !== 'removed' + && !['ENOENT', 'ENOTDIR'].includes(removed.reason?.systemCode)) { + failures.push(`${entry.filePath}: ${removed.reason?.code || removed.status}`); + } + } catch (error) { + failures.push(`${entry.filePath}: ${error.message}`); + } finally { + const released = releaseRegularFileBinding(entry.binding); + if (released.status !== 'released') { + failures.push(`${entry.filePath}: ${released.reason?.code || released.status}`); + } + } + } + registry.length = 0; + try { + if (!removeOwnedEmptyStagingDirectory(directory, identity, { throwOnFailure: true })) { + failures.push(`${directory}: staging directory was retained because its identity changed`); + } + } catch (error) { + failures.push(`${directory}: ${error.message}`); + } + if (failures.length) throw new Error(failures.join('; ')); +} + +function createOwnedEmptyStagingDirectory(prefix) { + // Node's Windows mkdtemp binding does not consistently promote an ordinary + // UNC prefix past MAX_PATH. Use the equivalent namespaced spelling for the + // syscall while preserving the same physical directory identity. + const directory = fs.mkdtempSync(path.toNamespacedPath(prefix)); + const metadata = fs.lstatSync(directory, { bigint: true }); + if (!metadata.isDirectory() || metadata.isSymbolicLink() || metadata.ino === 0n) { + throw new Error(`Private staging directory identity is unavailable: ${directory}`); + } + return { + directory, + identity: { device: metadata.dev, inode: metadata.ino }, + }; +} + +function createStagingRetirementQuarantine(parent) { + for (let attempt = 0; attempt < 8; attempt += 1) { + const directory = path.join(parent, `.archify-staging-remove-${randomUUID()}`); + try { + fs.mkdirSync(directory, { mode: 0o700 }); + return directory; + } catch (error) { + if (error?.code !== 'EEXIST') return undefined; + } + } + return undefined; +} + +const stagingRemovalSignal = new Int32Array(new SharedArrayBuffer(4)); +const stagingRemovalAttempts = 10; + +function removeEmptyStagingDirectoryWithRetry(directory) { + let failure; + for (let attempt = 0; attempt < stagingRemovalAttempts; attempt += 1) { + try { + fs.rmdirSync(directory); + return; + } catch (error) { + failure = error; + // SMB can acknowledge the last file removal before the directory view + // catches up. Retrying an empty-directory removal is claimant-safe: any + // real entry keeps returning ENOTEMPTY and is never removed recursively. + if (error?.code !== 'ENOTEMPTY' || attempt === stagingRemovalAttempts - 1) break; + Atomics.wait( + stagingRemovalSignal, + 0, + 0, + Math.min(5 * (2 ** attempt), 250), + ); + } + } + throw failure; +} + +function stagingDirectoryAppearsEmpty(directory) { + for (let attempt = 0; attempt < stagingRemovalAttempts; attempt += 1) { + try { + if (fs.readdirSync(directory).length === 0) return true; + } catch { + return false; + } + if (attempt === stagingRemovalAttempts - 1) break; + Atomics.wait( + stagingRemovalSignal, + 0, + 0, + Math.min(5 * (2 ** attempt), 250), + ); + } + return false; +} + +function removeOwnedEmptyStagingDirectory(directory, identity, { throwOnFailure = false } = {}) { + let current; + try { + current = fs.lstatSync(directory, { bigint: true }); + } catch (error) { + if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return false; + throw error; + } + if (!current.isDirectory() + || current.isSymbolicLink() + || current.ino === 0n + || current.dev !== identity.device + || current.ino !== identity.inode) return false; + if (!stagingDirectoryAppearsEmpty(directory)) return false; + const quarantine = createStagingRetirementQuarantine(path.dirname(directory)); + if (!quarantine) return false; + const movedPath = path.join(quarantine, path.basename(directory)); + try { + fs.renameSync(directory, movedPath); + } catch (error) { + if (throwOnFailure) throw error; + return false; + } + let moved; + try { + moved = fs.lstatSync(movedPath, { bigint: true }); + } catch { + return false; + } + if (!moved.isDirectory() + || moved.isSymbolicLink() + || moved.ino === 0n + || moved.dev !== identity.device + || moved.ino !== identity.inode) { + // A claimant won the public move boundary. Preserve it under the private + // quarantine name; directories have no portable no-clobber restore link. + return false; + } + try { + // The unpredictable 0700 quarantine closes the public replacement race. + // Never recurse: unexpected contents remain available for recovery. + removeEmptyStagingDirectoryWithRetry(movedPath); + removeEmptyStagingDirectoryWithRetry(quarantine); + return true; + } catch (error) { + if (throwOnFailure) throw error; + return false; + } +} + +function releaseOwnedStagingBindings(registry, fileBindingRuntime) { + const { releaseRegularFileBinding } = fileBindingRuntime || {}; + if (typeof releaseRegularFileBinding !== 'function') return; + for (const entry of registry) releaseRegularFileBinding(entry.binding); + registry.length = 0; +} + +function artifactIdentity(artifact) { + return { sha256: createHash('sha256').update(artifact).digest('hex'), bytes: artifact.byteLength }; +} + +function beginDeliveryAttempt({ ownership, input, pathsAlias, fileBindingRuntime }) { + const state = assertDeliveryOwnership(ownership, 'begin the delivery journal', { allowInitializing: true }); + const { + output, pendingPath: journal, provenancePath, receiptId, + } = state; + for (const protectedPath of [input, output, provenancePath].filter(Boolean)) { + if (pathsAlias(journal, protectedPath)) throw new Error('Delivery journal aliases an input or output.'); + } + const published = writeDeliveryProvenance(journal, { + schemaVersion: 1, command: 'deliver', status: 'pending', receiptId, + input, output: path.resolve(output), + }, { + beforeReplace: () => assertDeliveryOwnership(ownership, 'create the delivery journal', { allowInitializing: true }), + fileBindingRuntime, + }); + state.pendingIdentity = { + dev: published.identity.device, + ino: published.identity.inode, + mode: published.identity.mode, + }; + state.pendingContent = published.content; + assertDeliveryOwnership(ownership, 'finish creating the delivery journal', { allowInitializing: true }); +} + +function writeDeliveryProvenance(file, value, { beforeReplace, fileBindingRuntime } = {}) { + const { + backupPublicRegularFileBinding, + captureRegularFileBinding, + quarantineRemoveRegularFileBinding, + verifyRegularFileBinding, + releaseRegularFileBinding, + } = fileBindingRuntime || {}; + if (typeof captureRegularFileBinding !== 'function' + || typeof backupPublicRegularFileBinding !== 'function' + || typeof quarantineRemoveRegularFileBinding !== 'function' + || typeof verifyRegularFileBinding !== 'function' + || typeof releaseRegularFileBinding !== 'function') { + const error = new Error('The regular-file binding runtime is unavailable.'); + error.deliveryJournalState = { + status: 'unknown', + reason: { code: 'atomic-output-runtime-unavailable' }, + }; + throw error; + } + const staging = createOwnedEmptyStagingDirectory( + path.join(path.dirname(file), '.archify-provenance-'), + ); + const stagingDirectory = staging.directory; + const temporary = path.join(stagingDirectory, path.basename(file)); + const backup = path.join(stagingDirectory, '.previous-journal'); + const serialized = Buffer.from(`${JSON.stringify(value, null, 2)}\n`); + const expectedContent = artifactIdentity(serialized); + let candidateCapture; + let previousCapture; + let candidatePresent = false; + let backupPresent = false; + let backupVerified = false; + let published = false; + let retainStaging = false; + const bindingError = (result, action) => { + const error = new Error(`Could not ${action} delivery journal "${file}" safely (${result.reason?.code || 'unknown target state'}).`); + error.deliveryJournalState = result; + return error; + }; + try { + fs.writeFileSync(temporary, serialized, { flag: 'wx' }); + candidatePresent = true; + candidateCapture = captureRegularFileBinding(temporary, { + subject: 'delivery-journal-candidate', + expectedSha256: expectedContent.sha256, + expectedBytes: expectedContent.bytes, + expectedLinks: 1, + }); + if (candidateCapture.status !== 'captured') throw bindingError(candidateCapture, 'prepare the'); + + let existing = false; + try { + fs.lstatSync(file); + existing = true; + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; + } + if (existing) { + previousCapture = captureRegularFileBinding(file, { + subject: 'previous-delivery-journal', + expectedLinks: 1, + }); + if (previousCapture.status !== 'captured') { + throw bindingError(previousCapture, 'snapshot the existing'); + } + } + beforeReplace?.(); + if (previousCapture) { + const moved = backupPublicRegularFileBinding(previousCapture.binding, file, backup, { + subject: 'previous-delivery-journal', + }); + backupPresent = moved.backupCreated === true; + backupVerified = moved.backupVerified === true; + if (moved.status !== 'backed-up') throw bindingError(moved, 'back up the existing'); + } + + const ready = verifyRegularFileBinding(candidateCapture.binding, { + filePath: temporary, + expectedLinks: 1, + }); + if (ready.status !== 'match') throw bindingError(ready, 'verify the staged'); + try { + fs.linkSync(temporary, file); + } catch (error) { + if (error?.code === 'EEXIST') { + throw bindingError({ + status: 'different', + reason: { code: 'target-claimed-during-publish', phase: 'journal-publish' }, + }, 'publish the'); + } + throw error; + } + published = true; + for (const target of [temporary, file]) { + const linked = verifyRegularFileBinding(candidateCapture.binding, { + filePath: target, + expectedLinks: 2, + }); + if (linked.status !== 'match') throw bindingError(linked, 'verify the published'); + } + const retiredCandidate = quarantineRemoveRegularFileBinding( + candidateCapture.binding, + temporary, + { subject: 'delivery-journal-candidate', expectedLinks: 2 }, + ); + if (retiredCandidate.status !== 'removed') { + retainStaging = true; + throw bindingError(retiredCandidate, 'retire the staged'); + } + candidatePresent = false; + const finalized = verifyRegularFileBinding(candidateCapture.binding, { + filePath: file, + expectedLinks: 1, + }); + if (finalized.status !== 'match') throw bindingError(finalized, 'finalize the'); + if (backupPresent) { + const previous = verifyRegularFileBinding(previousCapture.binding, { + filePath: backup, + expectedLinks: 1, + }); + if (previous.status !== 'match') throw bindingError(previous, 'verify the previous'); + const retiredBackup = quarantineRemoveRegularFileBinding( + previousCapture.binding, + backup, + { subject: 'previous-delivery-journal', expectedLinks: 1 }, + ); + if (retiredBackup.status !== 'removed') { + retainStaging = true; + throw bindingError(retiredBackup, 'retire the previous'); + } + backupPresent = false; + } + return { + identity: candidateCapture.identity, + content: candidateCapture.content, + }; + } catch (cause) { + const rollbackErrors = []; + const publicRecoveryFiles = []; + if (cause.deliveryJournalState?.status === 'recovery-required' + && cause.deliveryJournalState.recoveryFile) { + publicRecoveryFiles.push({ + label: 'preserved journal replacement', + path: cause.deliveryJournalState.recoveryFile, + target: file, + }); + } + if (published) { + try { + const removed = quarantineRemoveRegularFileBinding(candidateCapture.binding, file, { + subject: 'published-delivery-journal', + expectedLinks: candidatePresent ? 2 : 1, + }); + if (removed.status === 'removed') { + published = false; + } else { + rollbackErrors.push(`the published journal could not be removed safely (${removed.reason?.code || 'unknown target state'})`); + if (removed.status === 'recovery-required') { + publicRecoveryFiles.push({ + label: 'preserved journal replacement', + path: removed.recoveryFile, + target: file, + }); + } + } + } catch (error) { + if (error?.code !== 'ENOENT') rollbackErrors.push(`published journal cleanup failed (${error.message})`); + } + } + if (candidatePresent) { + try { + const current = verifyRegularFileBinding(candidateCapture.binding, { + filePath: temporary, + expectedLinks: published ? 2 : 1, + }); + if (current.status === 'match') { + const retiredCandidate = quarantineRemoveRegularFileBinding( + candidateCapture.binding, + temporary, + { + subject: 'delivery-journal-candidate', + expectedLinks: published ? 2 : 1, + }, + ); + if (retiredCandidate.status !== 'removed') { + throw bindingError(retiredCandidate, 'retire the staged'); + } + candidatePresent = false; + } else { + rollbackErrors.push('the staged journal candidate was replaced before cleanup'); + retainStaging = true; + } + } catch (error) { + if (error?.code !== 'ENOENT') rollbackErrors.push(`staged journal cleanup failed (${error.message})`); + } + } + if (backupPresent) { + try { + if (!backupVerified) { + throw new Error('the backup entry was never verified as the previous journal'); + } + if (pathEntryExists(file)) throw new Error('a new journal claimant prevents restoring the previous journal'); + const previous = verifyRegularFileBinding(previousCapture.binding, { + filePath: backup, + expectedLinks: 1, + }); + if (previous.status !== 'match') { + backupVerified = false; + throw bindingError(previous, 'verify the previous'); + } + try { + fs.linkSync(backup, file); + } catch (error) { + if (error?.code === 'EEXIST') { + throw new Error('a new journal claimant prevents restoring the previous journal'); + } + throw error; + } + for (const target of [backup, file]) { + const restored = verifyRegularFileBinding(previousCapture.binding, { + filePath: target, + expectedLinks: 2, + }); + if (restored.status !== 'match') throw bindingError(restored, 'verify the restored'); + } + const retiredBackup = quarantineRemoveRegularFileBinding( + previousCapture.binding, + backup, + { subject: 'previous-delivery-journal', expectedLinks: 2 }, + ); + if (retiredBackup.status !== 'removed') { + throw bindingError(retiredBackup, 'retire the restored backup'); + } + backupPresent = false; + const restored = verifyRegularFileBinding(previousCapture.binding, { + filePath: file, + expectedLinks: 1, + }); + if (restored.status !== 'match') throw bindingError(restored, 'finalize the restored'); + } catch (error) { + rollbackErrors.push(`previous journal restore failed (${error.message})`); + retainStaging = backupPresent || candidatePresent; + } + } + const claimantConflict = cause.deliveryJournalState?.reason?.code === 'target-claimed-during-publish'; + if (rollbackErrors.length || claimantConflict) { + cause.deliveryJournalRecovery = { + recoveryRequired: true, + journal: file, + ...(claimantConflict ? { claimantConflict: true } : {}), + rollbackErrors, + ...(retainStaging ? { + recoveryDirectory: stagingDirectory, + } : publicRecoveryFiles.length ? { + recoveryDirectory: path.dirname(publicRecoveryFiles[0].path), + } : {}), + ...(backupPresent || (candidatePresent && retainStaging) || publicRecoveryFiles.length ? { + recoveryFiles: [ + ...(backupPresent ? [{ + label: backupVerified + ? 'previous delivery journal' + : 'unverified preserved journal backup entry', + path: backup, + target: file, + verifiedPrevious: backupVerified, + }] : []), + ...(candidatePresent && retainStaging ? [{ label: 'preserved staged journal entry', path: temporary }] : []), + ...publicRecoveryFiles, + ], + } : {}), + }; + } + throw cause; + } finally { + if (candidateCapture?.binding) releaseRegularFileBinding(candidateCapture.binding); + if (previousCapture?.binding) releaseRegularFileBinding(previousCapture.binding); + if (!retainStaging) removeOwnedEmptyStagingDirectory(stagingDirectory, staging.identity); + } +} + +function writeCapturedDeliveryProvenance(file, value, { + beforeCommit, + capture, + verifyAtomicOutput, + fileBindingRuntime, +} = {}) { + const { + backupPublicRegularFileBinding, + captureRegularFileBinding, + quarantineRemoveRegularFileBinding, + verifyRegularFileBinding, + releaseRegularFileBinding, + } = fileBindingRuntime || {}; + if (!capture + || typeof verifyAtomicOutput !== 'function' + || typeof backupPublicRegularFileBinding !== 'function' + || typeof captureRegularFileBinding !== 'function' + || typeof quarantineRemoveRegularFileBinding !== 'function' + || typeof verifyRegularFileBinding !== 'function' + || typeof releaseRegularFileBinding !== 'function') { + throw deliveryTargetStateError(file, 'delivery provenance', { + status: 'unknown', + reason: { code: 'atomic-output-runtime-unavailable' }, + }); + } + const staging = createOwnedEmptyStagingDirectory( + path.join(path.dirname(file), '.archify-provenance-'), + ); + const stagingDirectory = staging.directory; + const candidate = path.join(stagingDirectory, path.basename(file)); + const backup = path.join(stagingDirectory, '.previous-provenance'); + let candidateIdentity; + let candidateBinding; + let previousBinding; + let candidatePresent = false; + let backupPresent = false; + let backupVerified = false; + let published = false; + let retainStaging = false; + try { + const serialized = Buffer.from(`${JSON.stringify(value, null, 2)}\n`); + const expectedContent = artifactIdentity(serialized); + fs.writeFileSync(candidate, serialized, { flag: 'wx' }); + candidatePresent = true; + if (capture.mode !== null) fs.chmodSync(candidate, capture.mode); + const candidateCapture = captureRegularFileBinding(candidate, { + subject: 'delivery-provenance-candidate', + expectedSha256: expectedContent.sha256, + expectedBytes: expectedContent.bytes, + ...(capture.mode === null ? {} : { expectedMode: capture.mode }), + expectedLinks: 1, + }); + if (candidateCapture.status !== 'captured') { + throw deliveryTargetStateError(file, 'delivery provenance', candidateCapture); + } + candidateBinding = candidateCapture.binding; + candidateIdentity = { + dev: candidateCapture.identity.device, + ino: candidateCapture.identity.inode, + }; + beforeCommit?.(); + const verification = verifyAtomicOutput(capture.snapshot); + if (verification.status !== 'match') { + throw deliveryTargetStateError(file, 'delivery provenance', verification); + } + if (capture.snapshot.target.kind === 'file') { + const expected = capture.snapshot.target; + const previous = captureRegularFileBinding(capture.commitPath, { + subject: 'previous-delivery-provenance', + expectedIdentity: { device: expected.device, inode: expected.inode }, + expectedMode: expected.mode, + expectedLinks: 1, + }); + if (previous.status !== 'captured') { + throw deliveryTargetStateError(file, 'delivery provenance', previous); + } + previousBinding = previous.binding; + const moved = backupPublicRegularFileBinding( + previousBinding, + capture.commitPath, + backup, + { subject: 'previous-delivery-provenance' }, + ); + backupPresent = moved.backupCreated === true; + backupVerified = moved.backupVerified === true; + if (moved.status !== 'backed-up') { + throw deliveryTargetStateError(file, 'delivery provenance', moved); + } + } + const currentCandidate = verifyRegularFileBinding(candidateBinding, { + filePath: candidate, + expectedLinks: 1, + }); + if (currentCandidate.status !== 'match') { + throw deliveryTargetStateError(file, 'delivery provenance', currentCandidate); + } + try { + fs.linkSync(candidate, capture.commitPath); + } catch (error) { + if (error?.code === 'EEXIST') { + throw deliveryTargetStateError(file, 'delivery provenance', { + status: 'different', + reason: { code: 'target-claimed-during-publish' }, + }); + } + throw error; + } + published = true; + for (const target of [candidate, capture.commitPath]) { + const linked = verifyRegularFileBinding(candidateBinding, { + filePath: target, + expectedLinks: 2, + }); + if (linked.status !== 'match') { + throw deliveryTargetStateError(file, 'delivery provenance', linked); + } + } + const retiredCandidate = quarantineRemoveRegularFileBinding( + candidateBinding, + candidate, + { subject: 'delivery-provenance-candidate', expectedLinks: 2 }, + ); + if (retiredCandidate.status !== 'removed') { + retainStaging = true; + throw deliveryTargetStateError(file, 'delivery provenance', retiredCandidate); + } + candidatePresent = false; + const finalized = verifyRegularFileBinding(candidateBinding, { + filePath: capture.commitPath, + expectedLinks: 1, + }); + if (finalized.status !== 'match') { + throw deliveryTargetStateError(file, 'delivery provenance', finalized); + } + if (backupPresent) { + const currentBackup = verifyRegularFileBinding(previousBinding, { + filePath: backup, + expectedLinks: 1, + }); + if (currentBackup.status !== 'match') { + backupVerified = false; + throw deliveryTargetStateError(file, 'delivery provenance', { + status: 'different', + reason: { code: 'backup-identity-changed-before-finalize' }, + }); + } + const retiredBackup = quarantineRemoveRegularFileBinding( + previousBinding, + backup, + { subject: 'previous-delivery-provenance', expectedLinks: 1 }, + ); + if (retiredBackup.status !== 'removed') { + retainStaging = true; + throw deliveryTargetStateError(file, 'delivery provenance', retiredBackup); + } + backupPresent = false; + } + } catch (cause) { + const rollbackErrors = []; + const publicRecoveryFiles = []; + if (cause.deliveryTargetState?.status === 'recovery-required' + && cause.deliveryTargetState.recoveryFile) { + publicRecoveryFiles.push({ + label: 'preserved provenance replacement', + path: cause.deliveryTargetState.recoveryFile, + target: capture.commitPath, + }); + } + if (published) { + try { + const removed = quarantineRemoveRegularFileBinding(candidateBinding, capture.commitPath, { + subject: 'published-delivery-provenance', + expectedLinks: candidatePresent ? 2 : 1, + }); + if (removed.status === 'removed') { + published = false; + } else { + rollbackErrors.push(`the published provenance could not be removed safely (${removed.reason?.code || 'unknown target state'})`); + if (removed.status === 'recovery-required') { + publicRecoveryFiles.push({ + label: 'preserved provenance replacement', + path: removed.recoveryFile, + target: capture.commitPath, + }); + } + } + } catch (error) { + if (error?.code !== 'ENOENT') rollbackErrors.push(`published provenance cleanup failed (${error.message})`); + } + } + if (candidatePresent) { + try { + const current = verifyRegularFileBinding(candidateBinding, { + filePath: candidate, + expectedLinks: published ? 2 : 1, + }); + if (current.status === 'match') { + const retiredCandidate = quarantineRemoveRegularFileBinding( + candidateBinding, + candidate, + { + subject: 'delivery-provenance-candidate', + expectedLinks: published ? 2 : 1, + }, + ); + if (retiredCandidate.status !== 'removed') { + throw deliveryTargetStateError( + file, + 'delivery provenance', + retiredCandidate, + ); + } + candidatePresent = false; + } else { + rollbackErrors.push('the staged provenance candidate was replaced before cleanup'); + retainStaging = true; + } + } catch (error) { + if (error?.code !== 'ENOENT') { + rollbackErrors.push(`staged provenance cleanup failed (${error.message})`); + retainStaging = true; + } + } + } + if (backupPresent) { + try { + if (!backupVerified) { + throw new Error('the backup entry was never verified as the previous provenance'); + } + if (pathEntryExists(capture.commitPath)) { + throw new Error('a new target claimant prevents restoring the previous provenance'); + } + const currentBackup = verifyRegularFileBinding(previousBinding, { + filePath: backup, + expectedLinks: 1, + }); + if (currentBackup.status !== 'match') { + backupVerified = false; + throw new Error('the previous provenance backup identity changed'); + } + fs.linkSync(backup, capture.commitPath); + for (const target of [backup, capture.commitPath]) { + const restored = verifyRegularFileBinding(previousBinding, { + filePath: target, + expectedLinks: 2, + }); + if (restored.status !== 'match') { + throw new Error('the restored provenance identity could not be verified'); + } + } + const retiredBackup = quarantineRemoveRegularFileBinding( + previousBinding, + backup, + { subject: 'previous-delivery-provenance', expectedLinks: 2 }, + ); + if (retiredBackup.status !== 'removed') { + throw deliveryTargetStateError(file, 'delivery provenance', retiredBackup); + } + backupPresent = false; + const restoredFinal = verifyRegularFileBinding(previousBinding, { + filePath: capture.commitPath, + expectedLinks: 1, + }); + if (restoredFinal.status !== 'match') { + throw new Error('the restored provenance link count could not be verified'); + } + } catch (error) { + rollbackErrors.push(`previous provenance restore failed (${error.message})`); + retainStaging = true; + } + } + const recoveryFiles = [ + ...(backupPresent ? [{ + label: backupVerified + ? 'preserved delivery provenance backup entry' + : 'unverified preserved delivery provenance backup entry', + path: backup, + target: capture.commitPath, + verifiedPrevious: backupVerified, + }] : []), + ...(candidatePresent && retainStaging ? [{ label: 'preserved staged provenance entry', path: candidate }] : []), + ...publicRecoveryFiles, + ]; + if (rollbackErrors.length) { + cause.deliveryProvenanceRecovery = { + recoveryRequired: true, + rollbackErrors, + ...(recoveryFiles.length ? { + recoveryDirectory: retainStaging + ? stagingDirectory + : path.dirname(recoveryFiles[0].path), + recoveryFiles, + } : {}), + }; + } + if (rollbackErrors.length && cause?.archifyDiagnostics?.[0]) { + const [issue] = cause.archifyDiagnostics; + issue.evidence = { + ...(issue.evidence || {}), + rollbackErrors, + ...(recoveryFiles.length ? { + recoveryDirectory: retainStaging + ? stagingDirectory + : path.dirname(recoveryFiles[0].path), + recoveryFile: recoveryFiles[0].path, + recoveryFiles, + } : {}), + }; + } + throw cause; + } finally { + if (candidateBinding) releaseRegularFileBinding(candidateBinding); + if (previousBinding) releaseRegularFileBinding(previousBinding); + if (!retainStaging) removeOwnedEmptyStagingDirectory(stagingDirectory, staging.identity); + } +} + +function recordDeliveryFailure(options) { + const { + output, stage, input, error, receiptId, pathsAlias, ownership: suppliedOwnership, + releaseOwnership: shouldReleaseSuppliedOwnership = false, legacyOutputPaths = [], + captureAtomicOutput, verifyAtomicOutput, fileBindingRuntime, + } = options; + if (!output || !/\.html?$/i.test(output)) return { ok: true, status: 'not-applicable' }; + let ownership = suppliedOwnership; + let acquiredHere = false; + if (!ownership) { + try { + ownership = acquireDeliveryLock( + output, + receiptId, + input, + pathsAlias, + (lockError, initializingOwnership) => recordDeliveryFailure({ + ...options, + error: `Could not start delivery for "${output}": ${lockError.message}`, + ownership: initializingOwnership, + releaseOwnership: false, + }), + legacyOutputPaths, + fileBindingRuntime, + ); + acquiredHere = true; + } catch (lockError) { + const reportedLockError = lockError?.code === 'ARCHIFY_SIDECAR_NAMESPACE_INDETERMINATE' + && lockError.sidecarNamespaceReason?.code === 'sidecar-directory-missing' + ? Object.assign( + new Error(`Delivery lock parent directory does not exist for "${output}".`), + { code: 'ENOENT', cause: lockError }, + ) + : lockError; + return { + ...(lockError.deliveryFailureRecord || { ok: false, status: 'unrecorded' }), + lockError: reportedLockError, + }; + } + } + const state = deliveryOwnershipStates.get(ownership); + let recorded; + if (!state?.deliveryTargets) { + try { + captureDeliveryTargetState({ + state, + requestedOutput: legacyOutputPaths[0] || output, + pathsAlias, + captureAtomicOutput, + verifyAtomicOutput, + }); + } catch (captureError) { + recorded = { + ok: false, + status: 'unrecorded', + diagnostic: captureError.archifyDiagnostics?.[0] || diagnostic({ + code: 'output/target-indeterminate', + message: captureError.message, + subject: { output }, + evidence: { ...(captureError?.code ? { systemCode: captureError.code } : {}) }, + supportedFixes: ['use ordinary absent or regular-file delivery targets, then retry'], + }), + }; + } + } + const stableOutput = state?.output || canonicalExistingDeliveryOutput(output); + // Keep independent evidence even when the artifact is unreadable or the + // provenance target is locked, aliases the input, or cannot be replaced. + let journalError; + if (!recorded && !state?.pendingIdentity) { + try { + beginDeliveryAttempt({ ownership, input, pathsAlias, fileBindingRuntime }); + } catch (cause) { + if (cause.deliveryOwnershipCode === 'delivery/ownership-lost') { + recorded = { ok: false, status: 'unrecorded', ownershipError: cause }; + } else if (cause.deliveryJournalRecovery?.recoveryRequired) { + recorded = { + ok: false, + status: 'unrecorded', + journalRecovery: cause, + diagnostic: deliveryJournalRecoveryDiagnostic(stableOutput, cause), + }; + } + journalError = cause; + } + } + if (!recorded) { + let artifact; + let artifactBinding; + const { + captureRegularFileBinding, + verifyRegularFileBinding, + releaseRegularFileBinding, + } = fileBindingRuntime || {}; + try { + if (typeof captureRegularFileBinding === 'function') { + const artifactCapture = captureRegularFileBinding(stableOutput, { + subject: 'failed-delivery-artifact', + expectedLinks: 1, + includeContent: true, + }); + if (artifactCapture.status === 'captured') { + artifact = artifactCapture.content.buffer; + artifactBinding = artifactCapture.binding; + } else if (['ENOENT', 'ENOTDIR'].includes(artifactCapture.reason?.systemCode)) { + recorded = { ok: true, status: 'absent' }; + } + // A failed marker does not need an artifact hash to invalidate old + // evidence. Non-regular or changing paths are never followed. + } + if (!recorded) { + const provenancePath = state?.provenancePath || deliveryProvenancePath(stableOutput); + let aliasesProtectedPath; + try { + aliasesProtectedPath = (input && pathsAlias(provenancePath, input)) + || pathsAlias(provenancePath, stableOutput); + } catch (aliasError) { + recorded = { + ok: false, + status: 'unrecorded', + diagnostic: diagnostic({ + code: 'delivery/provenance-path-resolution', + message: 'Delivery failure provenance could not resolve its target safely.', + subject: { output: stableOutput, provenance: provenancePath }, + evidence: { + reason: aliasError.message, + ...(aliasError?.code ? { systemCode: aliasError.code } : {}), + }, + supportedFixes: ['remove the sidecar path conflict or symbolic-link cycle, then rerun deliver'], + }), + }; + } + if (!recorded && aliasesProtectedPath) { + recorded = { + ok: false, + status: 'unrecorded', + diagnostic: diagnostic({ + code: 'delivery/provenance-target-alias', + message: 'Delivery failure provenance could not be recorded without replacing an input or the artifact.', + subject: { output: stableOutput, provenance: provenancePath }, + evidence: { ...(input ? { input: path.resolve(input) } : {}) }, + supportedFixes: ['choose an output whose .delivery.json sidecar is distinct from every input and the HTML artifact'], + }), + }; + } + if (!recorded) { + try { + writeCapturedDeliveryProvenance(provenancePath, { + schemaVersion: 1, + receiptId, + status: 'failed', + command: 'deliver', + stage, + input, + output: stableOutput, + ...(artifact ? { artifact: artifactIdentity(artifact) } : {}), + error, + }, { + beforeCommit: () => { + assertDeliveryOwnership(ownership, 'record failed delivery provenance', { + allowInitializing: true, + pending: state?.pendingIdentity ? 'owned' : 'ignore', + }); + if (artifactBinding) { + const verification = verifyRegularFileBinding(artifactBinding, { + filePath: stableOutput, + expectedLinks: 1, + }); + if (verification.status !== 'match') { + throw deliveryTargetStateError( + stableOutput, + 'failed delivery artifact', + verification, + ); + } + } + }, + capture: state?.deliveryTargets?.provenance, + verifyAtomicOutput, + fileBindingRuntime, + }); + recorded = { ok: true, status: 'failed' }; + } catch (writeError) { + if (writeError.deliveryOwnershipCode === 'delivery/ownership-lost') { + recorded = { ok: false, status: 'unrecorded', ownershipError: writeError }; + } else if (writeError.deliveryProvenanceRecovery?.recoveryRequired) { + const recovery = writeError.deliveryProvenanceRecovery; + recorded = { + ok: false, + status: 'unrecorded', + provenanceRecovery: writeError, + diagnostic: diagnostic({ + code: 'delivery/provenance-recovery-required', + message: 'Failed-delivery provenance could not be rolled back safely.', + subject: { output: stableOutput, provenance: provenancePath }, + evidence: { + reason: writeError.message, + recoveryRequired: true, + ...(recovery.recoveryDirectory ? { recoveryDirectory: recovery.recoveryDirectory } : {}), + ...(recovery.recoveryFiles?.length ? { recoveryFiles: recovery.recoveryFiles } : {}), + ...(recovery.rollbackErrors?.length ? { rollbackErrors: recovery.rollbackErrors } : {}), + }, + supportedFixes: ['leave the delivery lock and journal untouched, inspect the retained recovery files, then recover serially'], + }), + }; + } else if (writeError.deliveryTargetState) { + recorded = { + ok: false, + status: 'unrecorded', + diagnostic: writeError.archifyDiagnostics?.[0], + }; + } else { + recorded = { + ok: false, + status: 'unrecorded', + diagnostic: diagnostic({ + code: 'delivery/provenance-write', + message: 'The failed delivery could not persist its stale-artifact marker.', + subject: { output: stableOutput, provenance: provenancePath }, + evidence: { + reason: writeError.message, + ...(writeError?.code ? { systemCode: writeError.code } : {}), + failureJournalRecorded: !journalError, + ...(journalError ? { journalError: journalError.message } : {}), + }, + supportedFixes: ['restore write access to the output directory, then rerun deliver before trusting the artifact'], + }), + }; + } + } + } + } + } finally { + if (artifactBinding && typeof releaseRegularFileBinding === 'function') { + releaseRegularFileBinding(artifactBinding); + } + } + } + if (recorded?.journalRecovery || recorded?.provenanceRecovery) return recorded; + if (acquiredHere || shouldReleaseSuppliedOwnership) { + try { + releaseDeliveryOwnership(ownership, { allowInitializing: true }); + } catch (lockError) { + if (lockError.deliveryOwnershipCode === 'delivery/ownership-lost') { + const ownershipError = recorded.ownershipError || lockError; + if (options.recoveryDirectory) { + ownershipError.deliveryCommitDetails = { + ...(ownershipError.deliveryCommitDetails || {}), + recoveryRequired: true, + recoveryDirectory: options.recoveryDirectory, + recoverableBackups: [], + }; + } + return { + ...recorded, + ok: false, + status: 'unrecorded', + ownershipError, + lockError, + }; + } + return { ...recorded, lockError: recorded.ownershipError || lockError }; + } + } + return recorded; +} + +function deliverySuccessProvenance(receipt) { + return { + schemaVersion: 1, + receiptId: receipt.receiptId, + status: 'current', + command: 'deliver', + type: receipt.type, + input: receipt.input, + output: receipt.output, + specification: receipt.specification, + artifact: receipt.artifact, + }; +} + +function inspectDeliveryProvenance(artifactPath, artifact, { + requireProvenance = false, + pathIdentityRuntime, + expectedSidecar, + inspection, +} = {}) { + let sidecar = deliveryProvenancePath(artifactPath); + const rawHeadSidecar = rawHeadDeliveryProvenancePath(artifactPath); + const pending = deliveryPendingPath(artifactPath); + const rawHeadPending = rawHeadDeliveryPendingPath(artifactPath); + const legacyExtensionPending = legacyExtensionDeliverySidecarPath( + artifactPath, + '.delivery-pending.json', + ); + const legacyExtensionSidecar = legacyExtensionDeliverySidecarPath( + artifactPath, + '.delivery.json', + ); + const stableOutput = canonicalExistingDeliveryOutput(artifactPath); + const locks = [ + deliveryLockPath(stableOutput), + ...legacyDeliveryLockPaths([artifactPath, stableOutput]), + ]; + let foundPending; + try { + for (const candidate of [...new Set([ + pending, + rawHeadPending, + legacyExtensionPending, + ].filter(Boolean))]) { + if (pathEntryExists(candidate)) { + foundPending = candidate; + break; + } + } + if (foundPending) { + return { + ok: false, status: 'failed', + diagnostics: [diagnostic({ + code: 'delivery/provenance-failed', + message: 'A delivery attempt is unfinished or failed; complete a successful deliver before trusting this artifact.', + subject: { artifact: path.resolve(artifactPath), provenance: sidecar }, + evidence: { pendingJournal: foundPending, found: true }, + supportedFixes: ['finish any active delivery, then rerun deliver successfully; retain the pending journal until recovery succeeds'], + })], + }; + } + } catch (error) { + return invalidProvenance(artifactPath, foundPending || pending, error.message); + } + for (const lock of locks) { + try { + if (pathEntryExists(lock)) { + return { + ok: false, status: 'locked', + diagnostics: [diagnostic({ + code: 'delivery/provenance-locked', + message: 'Delivery ownership is unresolved while the output lock remains; complete or recover delivery before trusting this artifact.', + subject: { artifact: path.resolve(artifactPath), lock }, + evidence: { deliveryLock: lock, found: true }, + supportedFixes: ['finish the active delivery or inspect and recover the preserved lock, then rerun deliver successfully'], + })], + }; + } + } catch (error) { + return invalidProvenance(artifactPath, lock, error.message); + } + } + let found; + try { + found = pathEntryExists(sidecar); + } catch (error) { + return invalidProvenance(artifactPath, sidecar, error.message); + } + if (!found && rawHeadSidecar) { + try { + found = pathEntryExists(rawHeadSidecar); + if (found) sidecar = rawHeadSidecar; + } catch (error) { + return invalidProvenance(artifactPath, rawHeadSidecar, error.message); + } + } + if (!found && legacyExtensionSidecar) { + try { + found = pathEntryExists(legacyExtensionSidecar); + if (found) sidecar = legacyExtensionSidecar; + } catch (error) { + return invalidProvenance(artifactPath, legacyExtensionSidecar, error.message); + } + } + if (!found) { + if (!requireProvenance) return { ok: true, status: 'unknown' }; + const message = 'Delivery provenance is required, but the artifact has no .delivery.json sidecar.'; + return { + ok: false, + status: 'unknown', + diagnostics: [diagnostic({ + code: 'delivery/provenance-required', + message, + subject: { artifact: path.resolve(artifactPath), provenance: sidecar }, + evidence: { required: true, found: false }, + supportedFixes: ['rerun deliver for the current specification, then retry with --require-provenance'], + })], + }; + } + let receipt; + let sidecarCapture; + try { + const { + captureRegularFileBinding, + releaseRegularFileBinding, + } = pathIdentityRuntime || {}; + if (typeof captureRegularFileBinding !== 'function' + || typeof releaseRegularFileBinding !== 'function') { + throw new Error('The regular-file binding runtime is unavailable.'); + } + sidecarCapture = captureRegularFileBinding(sidecar, { + subject: 'delivery-provenance', + ...(expectedSidecar ? { + expectedIdentity: expectedSidecar.identity, + expectedSha256: expectedSidecar.content.sha256, + expectedBytes: expectedSidecar.content.bytes, + expectedMode: expectedSidecar.mode, + } : {}), + expectedLinks: 1, + includeContent: true, + }); + if (sidecarCapture.status !== 'captured') { + if (sidecarCapture.reason?.code === 'delivery-provenance-hardlinked') { + return { + ok: false, + status: 'unsupported', + diagnostics: [diagnostic({ + code: 'delivery/provenance-hardlink-unsupported', + message: 'Delivery provenance is hard-linked and cannot prove that every linked name was updated atomically.', + subject: { artifact: path.resolve(artifactPath), provenance: sidecar }, + evidence: { links: sidecarCapture.reason.links }, + supportedFixes: ['remove extra hard links to the provenance sidecar, then rerun deliver'], + })], + }; + } + throw new Error(`Delivery provenance is not a stable regular file (${sidecarCapture.reason?.code || 'unknown target state'}).`); + } + receipt = JSON.parse(sidecarCapture.content.buffer.toString('utf8')); + const identityValid = (value) => value && typeof value.sha256 === 'string' && /^[0-9a-f]{64}$/.test(value.sha256) + && Number.isSafeInteger(value.bytes) && value.bytes >= 0; + if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt) + || receipt.schemaVersion !== 1 || receipt.command !== 'deliver' + || typeof receipt.receiptId !== 'string' + || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(receipt.receiptId) + || typeof receipt.output !== 'string' || !path.isAbsolute(receipt.output) + || typeof receipt.input !== 'string' || !path.isAbsolute(receipt.input) + || !['current', 'failed'].includes(receipt.status) + || (receipt.status === 'current' && (!TYPES.has(receipt.type) + || !identityValid(receipt.specification) || !identityValid(receipt.artifact))) + || (receipt.status === 'failed' && typeof receipt.stage !== 'string')) { + throw new Error('Delivery provenance does not conform to the supported receipt schema.'); + } + if (inspection) { + inspection.sidecar = { + path: sidecar, + identity: { + device: sidecarCapture.identity.device, + inode: sidecarCapture.identity.inode, + }, + content: { + sha256: sidecarCapture.content.sha256, + bytes: sidecarCapture.content.bytes, + }, + mode: sidecarCapture.mode, + }; + } + } catch (error) { + const message = `Could not read delivery provenance: ${error.message}`; + return { + ok: false, + status: 'invalid', + diagnostics: [diagnostic({ + code: 'delivery/provenance-invalid', + message, + subject: { artifact: path.resolve(artifactPath), provenance: sidecar }, + evidence: { reason: error.message }, + supportedFixes: ['rerun deliver to replace the invalid provenance sidecar'], + })], + }; + } finally { + if (sidecarCapture?.binding) { + pathIdentityRuntime.releaseRegularFileBinding(sidecarCapture.binding); + } + } + let outputIdentity; + if (typeof pathIdentityRuntime?.sameEntry !== 'function') { + const error = pathIdentityRuntime?.error; + outputIdentity = { + status: 'unknown', + reason: { + code: 'path-semantics-runtime-unavailable', + ...(typeof error?.code === 'string' ? { systemCode: error.code } : {}), + }, + }; + } else { + try { + outputIdentity = pathIdentityRuntime.sameEntry(receipt.output, artifactPath); + } catch (error) { + outputIdentity = { + status: 'unknown', + reason: { + code: 'path-identity-check-failed', + ...(typeof error?.code === 'string' ? { systemCode: error.code } : {}), + }, + }; + } + } + if (outputIdentity.status === 'unknown') { + return { + ok: false, + status: 'invalid', + diagnostics: [diagnostic({ + code: 'delivery/provenance-output-indeterminate', + message: 'Delivery provenance could not determine whether its recorded output identifies the inspected artifact.', + subject: { + artifact: path.resolve(artifactPath), + provenance: sidecar, + recordedOutput: receipt.output, + }, + evidence: { pathIdentity: outputIdentity.reason }, + supportedFixes: ['restore access to both the artifact and its recorded output path, then rerun deliver successfully'], + })], + }; + } + if (outputIdentity.status !== 'match') { + return invalidProvenance( + artifactPath, + sidecar, + 'The recorded delivery output does not identify this artifact.', + { recordedOutput: receipt.output, pathIdentity: outputIdentity.reason }, + ); + } + const actualSha256 = createHash('sha256').update(artifact).digest('hex'); + if (receipt.status === 'failed') { + const message = 'The artifact is stale because the latest delivery attempt failed.'; + return { + ok: false, + status: 'failed', + receiptId: receipt.receiptId, + diagnostics: [diagnostic({ + code: 'delivery/provenance-failed', + message, + subject: { artifact: path.resolve(artifactPath), provenance: sidecar }, + evidence: { stage: receipt.stage, deliveryReceiptId: receipt.receiptId }, + supportedFixes: ['repair the specification and complete a successful deliver before checking this artifact'], + })], + }; + } + if (receipt.artifact?.sha256 !== actualSha256 || receipt.artifact?.bytes !== artifact.byteLength) { + const message = 'The artifact bytes do not match their delivery provenance.'; + return { + ok: false, + status: 'mismatch', + receiptId: receipt.receiptId, + diagnostics: [diagnostic({ + code: 'delivery/provenance-mismatch', + message, + subject: { artifact: path.resolve(artifactPath), provenance: sidecar }, + evidence: { + expectedSha256: receipt.artifact?.sha256, + actualSha256, + expectedBytes: receipt.artifact?.bytes, + actualBytes: artifact.byteLength, + }, + supportedFixes: ['restore the delivered artifact or rerun deliver to create a matching artifact and provenance pair'], + })], + }; + } + return { ok: true, status: 'current', receiptId: receipt.receiptId, artifact: receipt.artifact }; +} + +function invalidProvenance(artifactPath, sidecar, reason, evidence = {}) { + return { + ok: false, status: 'invalid', + diagnostics: [diagnostic({ + code: 'delivery/provenance-invalid', message: 'Delivery provenance could not be verified.', + subject: { artifact: artifactPath, provenance: sidecar }, evidence: { reason, ...evidence }, + supportedFixes: ['restore access to the delivery records, then rerun deliver successfully'], + })], + }; +} + +function usage() { + return `Usage: + archify render [output.html] [--quality standard|showcase] [--repo-root path] + archify compare architecture [output.html] [--receipt path] [--json] [--quality standard|showcase] [--repo-root path] + archify deliver [output.html] [--json] [--open] [--quality standard|showcase] [--repo-root path] + archify finalize [--json] [--receipt path] [--out-dir ] [--quality standard|showcase] [--repo-root path] [--candidate-sha256 hex] + archify preview [output.html] [--no-open] [--quality standard|showcase] [--repo-root path] + archify validate [--json] [--layout-json] [--quality standard|showcase] [--repo-root path] + archify migrate workflow --to-schema 2 [--output portable.html] [--json] [--repo-root path] + archify inspect + archify check [--json] [--require-provenance] + archify browser-check [--json|--summary] [--require-provenance] [--out-dir ] + archify visual-check [--json|--summary] [--require-provenance] [--out-dir ] + archify guide [scenario or question] [--json] [--lang en|zh] + archify brands [name, alias, domain, or category] [--json] + archify brands capture [--json] + archify examples + archify doctor + archify demo [output-directory] + +Types: + architecture, workflow, sequence, dataflow, lifecycle +`; +} + +function fail(message, code = 2) { + console.error(message); + process.exit(code); +} + +function rejectCliArgument(message, details = {}) { + const error = new Error(message); + error.archifyArgument = { + code: details.code || 'cli/invalid-arguments', + subject: details.subject || {}, + evidence: details.evidence || {}, + supportedFixes: details.supportedFixes || ['correct the command arguments and retry'], + }; + throw error; +} + +function rendererPath(type) { + if (!TYPES.has(type)) { + rejectCliArgument(`Unknown diagram type "${type}". Expected one of: ${[...TYPES].join(', ')}`, { + code: 'cli/unknown-diagram-type', + subject: { type }, + evidence: { supportedTypes: [...TYPES] }, + supportedFixes: [`use one of: ${[...TYPES].join(', ')}`], + }); + } + return path.join(skillRoot, 'renderers', type, `render-${type}.mjs`); +} + +function runNode(args, options = {}) { + return spawnSync(process.execPath, args, { + cwd: options.cwd || process.cwd(), + encoding: 'utf8', + stdio: options.stdio || 'inherit', + env: options.env ? { ...process.env, ...options.env } : process.env, + }); +} + +function extractQualityArgs(args) { + const rest = []; + let quality; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--quality') { + quality = args[index + 1]; + if (!quality || quality.startsWith('--')) rejectCliArgument('--quality requires standard or showcase.', { + code: 'cli/missing-option-value', + subject: { option: '--quality' }, + supportedFixes: ['provide --quality standard or --quality showcase'], + }); + index += 1; + continue; + } + if (arg.startsWith('--quality=')) { + quality = arg.slice('--quality='.length); + if (!quality) rejectCliArgument('--quality requires standard or showcase.', { + code: 'cli/missing-option-value', + subject: { option: '--quality' }, + supportedFixes: ['provide --quality standard or --quality showcase'], + }); + continue; + } + rest.push(arg); + } + if (quality !== undefined && !['standard', 'showcase'].includes(quality)) { + rejectCliArgument(`Unknown quality profile "${quality}". Expected standard or showcase.`, { + code: 'cli/invalid-option-value', + subject: { option: '--quality' }, + evidence: { value: quality, supportedValues: ['standard', 'showcase'] }, + supportedFixes: ['use --quality standard or --quality showcase'], + }); + } + return { rest, quality }; +} + +function extractRepoRootArgs(args) { + const rest = []; + let repoRoot; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--repo-root') { + repoRoot = args[index + 1]; + if (!repoRoot || repoRoot.startsWith('--')) rejectCliArgument('--repo-root requires a repository path.', { + code: 'cli/missing-option-value', + subject: { option: '--repo-root' }, + supportedFixes: ['provide one repository path after --repo-root'], + }); + index += 1; + continue; + } + if (arg.startsWith('--repo-root=')) { + repoRoot = arg.slice('--repo-root='.length); + if (!repoRoot) rejectCliArgument('--repo-root requires a repository path.', { + code: 'cli/missing-option-value', + subject: { option: '--repo-root' }, + supportedFixes: ['provide one repository path after --repo-root'], + }); + continue; + } + rest.push(arg); + } + return { rest, repoRoot: repoRoot ? path.resolve(repoRoot) : undefined }; +} + +function extractOutDirArgs(args) { + const rest = []; + let outDir; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--out-dir') { + outDir = args[index + 1]; + if (!outDir || outDir.startsWith('--')) fail('--out-dir requires a directory path.'); + index += 1; + continue; + } + if (arg.startsWith('--out-dir=')) { + outDir = arg.slice('--out-dir='.length); + if (!outDir) fail('--out-dir requires a directory path.'); + continue; + } + rest.push(arg); + } + return { rest, outDir }; +} + +function rendererEnv(quality, repoRoot, diagnosticJson = false) { + return { + ...(quality ? { ARCHIFY_QUALITY_PROFILE: quality } : {}), + ...(repoRoot ? { ARCHIFY_REPO_ROOT: repoRoot } : {}), + ...(diagnosticJson ? { ARCHIFY_DIAGNOSTIC_FORMAT: 'json' } : {}), + }; +} + +function diagnostic({ code, message, subject = {}, evidence = {}, supportedFixes = [], severity = 'error' }) { + return { + code, + severity, + message, + subject, + evidence, + supportedFixes, + }; +} + +function deliveryLockFailureDiagnostic(output, error) { + const code = error.deliveryOwnershipCode || error.deliveryLockCode || 'delivery/lock-acquire'; + if (code === 'delivery/legacy-pending') { + return diagnostic({ + code, + message: `A pre-namespace delivery journal blocks delivery for "${output}".`, + subject: { output, journal: error.deliveryLockPath }, + evidence: { pendingJournal: error.deliveryLockPath, found: true }, + supportedFixes: ['recover the unfinished delivery and remove only its journal after recovery succeeds'], + }); + } + const lock = error.deliveryOwnershipDetails?.lock + || error.deliveryLockPath + || deliveryLockPath(output); + return diagnostic({ + code, + message: code === 'delivery/lock-release' + ? `Delivery lock could not be released for "${output}": ${error.message}` + : code === 'delivery/ownership-lost' + ? `Delivery ownership was lost for "${output}": ${error.message}` + : `Could not start delivery for "${output}": ${error.message}`, + subject: { output, lock }, + evidence: { + reason: error.message, + ...(error.code ? { systemCode: error.code } : {}), + ...(error.lockCleanupError ? { cleanupError: error.lockCleanupError } : {}), + ...(error.deliveryLockOwner || {}), + ...(error.deliveryOwnershipDetails || {}), + ...(error.deliveryCommitDetails || {}), + }, + supportedFixes: code === 'delivery/concurrent-attempt' + ? ['wait for the active delivery to finish, then retry'] + : code === 'delivery/lock-stale' + ? [ + 'confirm no delivery attempt is active for this output', + `remove only the preserved lock "${lock}", then rerun deliver successfully`, + ] + : code === 'delivery/lock-path-conflict' + ? ['choose an output whose lock path is distinct from the input specification'] + : code === 'delivery/lock-invalid' + ? ['inspect and preserve the unrecognized lock entry, or choose another output path'] + : code === 'delivery/ownership-lost' + ? ['leave the replacement lock and journal untouched, inspect the reported owner, then retry only after that attempt finishes or is recovered'] + : code === 'delivery/lock-release' + ? ['preserve the reported lock, restore permission to remove that exact entry, then recover serially before retrying'] + : ['resolve the reported filesystem error and any lock cleanup error, then retry'], + }); +} + +function deliveryJournalRecoveryDiagnostic(output, error) { + const recovery = error.deliveryJournalRecovery || {}; + const location = recovery.recoveryDirectory + ? ` Recovery material was retained at "${recovery.recoveryDirectory}".` + : ''; + return diagnostic({ + code: 'delivery/journal-recovery-required', + message: `Delivery journal publication could not be rolled back safely.${location}`, + subject: { output, journal: recovery.journal || deliveryPendingPath(output) }, + evidence: { + reason: error.message, + recoveryRequired: true, + ...(recovery.recoveryDirectory ? { recoveryDirectory: recovery.recoveryDirectory } : {}), + ...(recovery.recoveryFiles?.length ? { recoveryFiles: recovery.recoveryFiles } : {}), + ...(recovery.rollbackErrors?.length ? { rollbackErrors: recovery.rollbackErrors } : {}), + }, + supportedFixes: recovery.recoveryDirectory + ? [ + 'leave the delivery lock and current journal claimant untouched', + `inspect every recovery file in ${JSON.stringify(recovery.recoveryDirectory)} and restore the previous journal only after the claimant is resolved`, + ] + : ['leave the delivery lock and journal untouched, inspect their current owners, then recover serially'], + }); +} + +function deliveryCommitRecoveryDiagnostic(output, error) { + const recovery = error.deliveryCommitDetails || {}; + return diagnostic({ + code: 'delivery/commit-recovery-required', + message: `Delivery rollback could not safely restore the previous output pair for "${output}".`, + subject: { output }, + evidence: { + reason: recovery.reason || error.message, + recoveryRequired: true, + ...(recovery.recoveryDirectory ? { recoveryDirectory: recovery.recoveryDirectory } : {}), + ...(recovery.recoverableBackups?.length + ? { recoverableBackups: recovery.recoverableBackups } + : {}), + ...(recovery.rollbackErrors?.length ? { rollbackErrors: recovery.rollbackErrors } : {}), + }, + supportedFixes: [ + 'leave the delivery lock, journal, current claimants, and retained backups untouched', + 'inspect the reported files and restore the previous pair only after resolving every claimant', + ], + }); +} + +function deliveryTargetStateDiagnostic(output, role, result) { + const reason = result?.reason || { code: 'target-state-unavailable' }; + const hardlinked = typeof reason.code === 'string' && reason.code.endsWith('-hardlinked'); + const provenanceTarget = /provenance/iu.test(role); + const code = hardlinked + ? provenanceTarget ? 'delivery/provenance-hardlink-unsupported' : 'output/target-hardlinked' + : ['target-not-regular-file', 'requested-entry-symbolic-link', 'requested-entry-not-regular-file'].includes(reason.code) + ? 'output/target-not-regular-file' + : result?.status === 'different' + ? 'output/target-changed' + : 'output/target-indeterminate'; + const message = hardlinked + ? `The ${role} target is hard-linked and cannot be replaced atomically.` + : code === 'output/target-not-regular-file' + ? `The ${role} target is not a regular file.` + : code === 'output/target-changed' + ? `The ${role} target changed while the verified delivery pair was being prepared.` + : `The ${role} target identity could not be determined safely.`; + return diagnostic({ + code, + message, + subject: { output: path.resolve(output), role }, + evidence: { targetState: reason }, + supportedFixes: hardlinked + ? ['remove extra hard links or choose a new output path, then rerun deliver'] + : code === 'output/target-not-regular-file' + ? ['choose an absent or regular-file target, then rerun deliver'] + : code === 'output/target-changed' + ? ['retry only after other processes stop creating, replacing, retargeting, or changing the output pair'] + : ['use an ordinary local filesystem path with stable file identity, then retry'], + }); +} + +function deliveryTargetStateError(output, role, result) { + const issue = deliveryTargetStateDiagnostic(output, role, result); + const error = new Error(issue.message); + error.archifyDiagnostics = [issue]; + error.deliveryTargetState = result; + return error; +} + +function captureDeliveryTargetState({ + state, + requestedOutput, + pathsAlias, + captureAtomicOutput, + verifyAtomicOutput, + preparedTargets, +}) { + if (typeof captureAtomicOutput !== 'function' || typeof verifyAtomicOutput !== 'function') { + throw deliveryTargetStateError(requestedOutput, 'delivery pair', { + status: 'unknown', + reason: { code: 'atomic-output-runtime-unavailable' }, + }); + } + const artifactTarget = preparedTargets?.artifact || { + requestedPath: requestedOutput, + ...captureAtomicOutput(requestedOutput), + }; + if (artifactTarget.status !== 'captured') { + throw deliveryTargetStateError(requestedOutput, 'HTML artifact', artifactTarget); + } + if (!pathsAlias(artifactTarget.commitPath, state.output)) { + throw deliveryTargetStateError(requestedOutput, 'HTML artifact', { + status: 'different', + reason: { + code: 'write-slot-changed-before-snapshot', + plannedCommitPath: state.output, + currentCommitPath: artifactTarget.commitPath, + }, + }); + } + const requestedProvenancePath = preparedTargets?.provenance?.requestedPath + || deliveryProvenancePath(artifactTarget.commitPath); + const provenanceTarget = preparedTargets?.provenance || { + requestedPath: requestedProvenancePath, + ...captureAtomicOutput(requestedProvenancePath, { + requestedEntryPolicy: 'regular-or-absent', + }), + }; + if (provenanceTarget.status !== 'captured') { + throw deliveryTargetStateError(requestedProvenancePath, 'delivery provenance', provenanceTarget); + } + for (const [role, target] of [ + ['HTML artifact', artifactTarget], + ['delivery provenance', provenanceTarget], + ]) { + const verification = verifyAtomicOutput(target.snapshot); + if (verification.status !== 'match') { + throw deliveryTargetStateError(target.requestedPath, role, verification); + } + } + state.output = artifactTarget.commitPath; + state.provenancePath = provenanceTarget.commitPath; + state.pendingPath = deliveryPendingPath(artifactTarget.commitPath); + state.verifyAtomicOutput = verifyAtomicOutput; + state.deliveryTargets = { + artifact: artifactTarget, + provenance: provenanceTarget, + }; + return state.deliveryTargets; +} + +function inputDiagnostic(error, inputPath) { + const isSyntax = error instanceof SyntaxError; + return diagnostic({ + code: isSyntax ? 'input/json-parse' : 'input/read', + message: isSyntax + ? `Input JSON could not be parsed: ${error.message}` + : `Input could not be read: ${error.message}`, + subject: { input: inputPath }, + evidence: { + ...(error?.code ? { systemCode: error.code } : {}), + reason: error.message, + }, + supportedFixes: [isSyntax + ? 'repair the JSON syntax and run validation again' + : 'provide one readable JSON input file'], + }); +} + +function rendererFailure(result) { + if (result.error) { + return { + error: 'Renderer process could not start.', + diagnostics: [diagnostic({ + code: 'internal/renderer-process', + message: 'Renderer process could not start.', + evidence: { reason: result.error.message }, + })], + }; + } + try { + const payload = JSON.parse((result.stderr || '').trim()); + if (payload?.ok === false && Array.isArray(payload.diagnostics) && payload.diagnostics.length) { + return { + error: payload.error || payload.diagnostics[0].message, + diagnostics: payload.diagnostics, + }; + } + } catch { + // The diagnostic boundary is intentionally fail-closed. Never copy a raw + // Node stack into a machine receipt when a renderer exits unexpectedly. + } + return { + error: 'Renderer failed before emitting a structured diagnostic.', + diagnostics: [diagnostic({ + code: 'internal/unclassified', + message: 'Renderer failed before emitting a structured diagnostic.', + evidence: { exitCode: result.status ?? 1 }, + })], + }; +} + +const COMPOSITION_CHECKS = new Set([ + 'label_route_clearance', + 'relationship_crossings', + 'relationship_corridors', + 'container_border_runs', + 'route_rhythm', +]); + +const CHECK_FIXES = { + single_svg: ['remove additional SVG roots so the artifact contains exactly one diagram SVG'], + finite_svg: ['replace non-finite coordinates before rendering again'], + orthogonal_arrows: ['use renderer-supported orthogonal routing controls'], + legend_clearance: ['move the route or enlarge the viewBox so relationships do not enter the legend'], +}; + +const COMPOSITION_FIXES = { + 'composition/proper-crossing': ['if authored via/route/channelX/channelY controls exist and are not required by the user, remove them to let the renderer re-plan; otherwise preserve that intent and adjust route/via or channel coordinates so unrelated relationships use separate corridors'], + 'composition/ambiguous-corridor': ['if authored via/route/channelX/channelY controls exist and are not required by the user, remove them to let the renderer re-plan; otherwise preserve that intent and adjust route/via or channel coordinates so unrelated relationships do not visually merge'], + 'composition/container-border-run': ['if authored via/route/channelX/channelY controls exist and are not required by the user, remove them to let the renderer re-plan; otherwise preserve that intent and route across the frame perpendicularly through a clear opening'], + 'composition/label-route-clearance': ['if authored labelAt/labelDx/labelDy/labelSegment controls exist and are not required by the user, remove them to let the renderer re-plan; otherwise preserve that intent and adjust labelAt, labelDx, labelDy, labelSegment, message y, or the other relationship route'], + 'composition/label-canvas-containment': ['if authored labelAt/labelDx/labelDy/labelSegment controls exist and are not required by the user, remove them to let the renderer re-plan; otherwise preserve that intent and adjust labelAt, labelDx, labelDy, or labelSegment so the label rect stays inside the viewBox, or enlarge meta.viewBox'], + 'composition/micro-segment': ['if authored via/route/channelX/channelY controls exist and are not required by the user, remove them to let the renderer re-plan; otherwise preserve that intent and move the route/channel/via point so every visible segment is at least 8px'], + 'composition/short-interior-segment': ['if authored via/route/channelX/channelY controls exist and are not required by the user, remove them to let the renderer re-plan; otherwise preserve that intent and move the route/channel/via point so every interior turn has at least 16px'], +}; + +function checkerDiagnostics(checker) { + const diagnostics = []; + for (const issue of checker?.composition?.issues || []) { + if (issue.severity !== 'error') continue; + const { severity, code, relationship, nodeId, ...evidence } = issue; + diagnostics.push(diagnostic({ + code, + severity, + message: `Final artifact failed ${code}.`, + subject: relationship ? { relationship } : { check: 'composition', ...(nodeId ? { nodeId } : {}) }, + evidence, + supportedFixes: compositionFixes(issue), + })); + } + for (const check of checker?.checks || []) { + if (check.ok || COMPOSITION_CHECKS.has(check.name)) continue; + diagnostics.push(diagnostic({ + code: `artifact/${check.name.replaceAll('_', '-')}`, + message: (check.details || []).find(Boolean) || `Final artifact failed ${check.name}.`, + subject: { check: check.name }, + evidence: { details: check.details || [] }, + supportedFixes: CHECK_FIXES[check.name] || [], + })); + } + return diagnostics.length ? diagnostics : [diagnostic({ + code: 'artifact/check-failed', + message: 'Final artifact check failed without a classified diagnostic.', + subject: { check: 'unknown' }, + evidence: {}, + })]; +} + +function compositionFixes(issue) { + if (issue.code === 'composition/viewport-height') return [String(issue.detail || '').replace(/^\[[^\]]+\]\s*/, '')]; + if (issue.code !== 'composition/desktop-readability') return COMPOSITION_FIXES[issue.code] || []; + const sourceFontPx = Number(issue.sourceFontPx); + const actualBudgetPx = Number(issue.availableDiagramWidth); + const hardFloorPx = Number(issue.minimumProjectedFontPx); + const viewBoxWidth = Number(issue.viewBoxWidth); + const preserveIntent = 'Preserve the semantic text and any supplied coordinates, routes, sides, channels, and labels.'; + const readerCap = issue.budgetBasis === 'recognized-declared-wide' + ? ` The declared Reader reports a ${issue.budgetLimit} limit and ${actualBudgetPx}px actual diagram budget; do not assume an uncapped viewport.` + : ''; + if (![sourceFontPx, actualBudgetPx, hardFloorPx, viewBoxWidth].every(Number.isFinite) + || actualBudgetPx <= 0 || hardFloorPx <= 0 || viewBoxWidth <= 0) { + return [`${preserveIntent} Repair the measured source font, desktop budget, or complete viewBox width; position-only label controls do not change projected text size.`]; + } + if (sourceFontPx < hardFloorPx) { + return [`${preserveIntent} The diagnosed ${sourceFontPx}px source text is below the ${hardFloorPx}px hard floor even at scale 1, so use a renderer-supported semantic text-size setting or renderer-level fix. Position-only label controls cannot repair its projection.${readerCap}`]; + } + const maximumViewBoxWidth = Math.floor((sourceFontPx * actualBudgetPx) / hardFloorPx); + return [`${preserveIntent} Compactly reflow automatic spacing and empty corridors so the complete viewBox width is at most ${maximumViewBoxWidth}px (current ${viewBoxWidth}px; ${sourceFontPx}px source text at ${actualBudgetPx}px desktop budget). If supplied geometry fixes that width, use a renderer-supported semantic text-size setting or renderer-level fix instead. Position-only label controls cannot repair its projection.${readerCap}`]; +} + +function formatDiagnostics(error, diagnostics = []) { + if (!diagnostics.length) return error; + return [ + error, + ...diagnostics.map((entry) => { + const fix = entry.supportedFixes?.length ? ` Fix: ${entry.supportedFixes.join('; ')}.` : ''; + return `[${entry.code}] ${entry.message}${fix}`; + }), + ].join('\n'); +} + +function exitFrom(result) { + if (result.error) fail(result.error.message, 1); + process.exit(result.status ?? 1); +} + +function reportCompareFailure({ json, stage, error, code = 'delta/internal', details = {}, status = 1 }) { + const receipt = { + schemaVersion: 1, + ok: false, + command: 'compare', + type: 'architecture', + stage, + error, + diagnostics: [{ + code, + severity: 'error', + message: error, + subject: details.side ? { side: details.side, ...(details.path ? { path: details.path } : {}) } : {}, + evidence: Object.fromEntries(Object.entries(details).filter(([key]) => !['side', 'path', 'supportedFixes'].includes(key))), + supportedFixes: details.supportedFixes || [], + }], + }; + if (json) console.log(JSON.stringify(receipt, null, 2)); + else console.error(formatDiagnostics(error, receipt.diagnostics)); + process.exitCode = status; +} + +function extractCompareOptions(args) { + const positional = []; + let receipt; + let json = false; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--json') { + json = true; + continue; + } + if (arg === '--receipt') { + receipt = args[index + 1]; + if (!receipt || receipt.startsWith('--')) fail('--receipt requires a JSON output path.'); + index += 1; + continue; + } + if (arg.startsWith('--receipt=')) { + receipt = arg.slice('--receipt='.length); + if (!receipt) fail('--receipt requires a JSON output path.'); + continue; + } + if (arg.startsWith('--')) fail(`Unknown compare option "${arg}".`); + positional.push(arg); + } + return { positional, receipt, json }; +} + +function compareReceiptPath(outputPath) { + const { directory, stem, options } = deliverySidecarNamespace(outputPath); + return path.join( + directory, + `${boundedSidecarStem(stem, COMPARE_SIDECAR_SUFFIXES, options)}.receipt.json`, + ); +} + +function compareCommitError(message, code, details = {}) { + const error = new Error(message); + error.compareStage = 'commit'; + error.compareCode = code; + error.compareDetails = details; + return error; +} + +function compareAtomicOutputFailure(result, label, requestedPath, stage) { + const reason = result.reason || { code: 'atomic-output-unknown' }; + let code = 'output/target-indeterminate'; + let message = `The compare ${label} target identity could not be verified safely.`; + let supportedFixes = ['use an ordinary local filesystem path with stable file identity, then retry']; + if (typeof reason.code === 'string' && reason.code.endsWith('-hardlinked')) { + code = 'output/target-hardlinked'; + message = `The compare ${label} target has multiple hard links and cannot be replaced atomically.`; + supportedFixes = [`choose a ${label} path that is absent or names a regular file with one link`]; + } else if (reason.code === 'target-not-regular-file') { + code = 'output/target-not-regular-file'; + message = `The compare ${label} target is not a regular file.`; + supportedFixes = [`choose an absent or regular-file path for the ${label}`]; + } else if (result.status === 'different') { + code = 'output/target-changed'; + message = `The compare ${label} target changed while the artifact pair was being prepared.`; + supportedFixes = ['retry after other processes stop creating, replacing, or redirecting compare outputs']; + } + return { + stage, + code, + message, + details: { + side: label, + path: requestedPath, + atomicOutput: reason, + supportedFixes, + }, + }; +} + +function commitComparePair({ + htmlCandidate, + htmlContent, + receiptCandidate, + receiptContent, + outputCapture, + receiptCapture, + stagingDirectory, + verifyAtomicOutput, + fileBindingRuntime, +}) { + const { + backupPublicRegularFileBinding, + captureRegularFileBinding, + quarantineRemoveRegularFileBinding, + verifyRegularFileBinding, + releaseRegularFileBinding, + } = fileBindingRuntime || {}; + const targets = [ + { + label: 'HTML artifact', capture: outputCapture, candidate: htmlCandidate, content: htmlContent, backup: path.join(stagingDirectory, '.previous-output'), + }, + { + label: 'receipt', capture: receiptCapture, candidate: receiptCandidate, content: receiptContent, backup: path.join(stagingDirectory, '.previous-receipt'), + }, + ]; + const targetChanged = (item, result) => { + const normalized = typeof result === 'string' + ? { status: 'different', reason: { code: result } } + : result; + const failure = compareAtomicOutputFailure( + normalized, + item.label, + item.capture.snapshot.requestedPath, + 'commit', + ); + const error = compareCommitError(failure.message, failure.code, failure.details); + error.compareFailure = failure; + return error; + }; + + const backedUp = []; + const committed = []; + let retainSuccessfulBackupBindings = false; + try { + if (typeof backupPublicRegularFileBinding !== 'function' + || typeof captureRegularFileBinding !== 'function' + || typeof quarantineRemoveRegularFileBinding !== 'function' + || typeof verifyRegularFileBinding !== 'function' + || typeof releaseRegularFileBinding !== 'function') { + throw targetChanged(targets[0], { + status: 'unknown', + reason: { code: 'atomic-output-runtime-unavailable' }, + }); + } + // Re-resolve and bind both requested write slots and candidate inodes + // before moving either target. The held handles keep bytes, mode and + // identity authoritative across every publish/finalize transition. + for (const item of targets) { + const verification = verifyAtomicOutput(item.capture.snapshot); + if (verification.status !== 'match') throw targetChanged(item, verification); + if (item.capture.mode !== null) fs.chmodSync(item.candidate, item.capture.mode); + const candidate = captureRegularFileBinding(item.candidate, { + subject: 'compare-candidate', + expectedSha256: item.content.sha256, + expectedBytes: item.content.bytes, + ...(item.capture.mode === null ? {} : { expectedMode: item.capture.mode }), + expectedLinks: 1, + }); + if (candidate.status !== 'captured') throw targetChanged(item, candidate); + item.candidateBinding = candidate.binding; + item.candidatePresent = true; + item.candidateIdentity = { + dev: candidate.identity.device, + ino: candidate.identity.inode, + }; + item.candidateMode = candidate.mode; + if (item.capture.snapshot.target.kind === 'file') { + const expected = item.capture.snapshot.target; + const previous = captureRegularFileBinding(item.capture.commitPath, { + subject: 'previous-compare-target', + expectedIdentity: { device: expected.device, inode: expected.inode }, + expectedMode: expected.mode, + expectedLinks: 1, + }); + if (previous.status !== 'captured') throw targetChanged(item, previous); + item.previousBinding = previous.binding; + } + } + for (const item of targets) { + if (item.capture.snapshot.target.kind !== 'file') continue; + const moved = backupPublicRegularFileBinding( + item.previousBinding, + item.capture.commitPath, + item.backup, + { subject: 'previous-compare-target' }, + ); + item.backupPresent = moved.backupCreated === true; + item.backupVerified = moved.backupVerified === true; + if (item.backupPresent) backedUp.push(item); + if (moved.status === 'recovery-required' && moved.recoveryFile) { + item.backupRecovery = moved.recoveryFile; + } + if (moved.status !== 'backed-up') throw targetChanged(item, moved); + } + for (const item of targets) { + const candidate = verifyRegularFileBinding(item.candidateBinding, { + filePath: item.candidate, + expectedLinks: 1, + }); + if (candidate.status !== 'match') throw targetChanged(item, candidate); + try { + // linkSync is the no-clobber publish primitive: unlike renameSync, it + // fails with EEXIST if another writer claims an absent slot after the + // final snapshot verification. + fs.linkSync(item.candidate, item.capture.commitPath); + } catch (error) { + if (error?.code === 'EEXIST') { + throw targetChanged( + item, + 'target-claimed-during-publish', + ); + } + throw error; + } + item.committedIdentity = item.candidateIdentity; + committed.push(item); + const linked = verifyRegularFileBinding(item.candidateBinding, { + filePath: item.capture.commitPath, + expectedLinks: 2, + }); + if (linked.status !== 'match') throw targetChanged(item, linked); + } + for (const item of committed) { + const current = verifyRegularFileBinding(item.candidateBinding, { + filePath: item.capture.commitPath, + expectedLinks: 2, + }); + if (current.status !== 'match') throw targetChanged(item, current); + } + // The target remains the durable link. Removing the staging links inside + // the transaction keeps successful outputs at nlink=1 even if the outer + // best-effort staging-directory cleanup later fails. + for (const item of committed) { + const staged = verifyRegularFileBinding(item.candidateBinding, { + filePath: item.candidate, + expectedLinks: 2, + }); + if (staged.status !== 'match') throw targetChanged(item, staged); + const retired = quarantineRemoveRegularFileBinding( + item.candidateBinding, + item.candidate, + { subject: 'compare-candidate', expectedLinks: 2 }, + ); + if (retired.status !== 'removed') throw targetChanged(item, retired); + item.candidatePresent = false; + } + for (const item of committed) { + const finalized = verifyRegularFileBinding(item.candidateBinding, { + filePath: item.capture.commitPath, + expectedLinks: 1, + }); + if (finalized.status !== 'match') throw targetChanged(item, finalized); + } + retainSuccessfulBackupBindings = true; + return { + ownedBackupEntries: backedUp + .filter((item) => item.backupPresent) + .map((item) => ({ + filePath: item.backup, + binding: item.previousBinding, + subject: 'previous-compare-backup', + expectedLinks: 1, + })), + }; + } catch (cause) { + const rollbackErrors = []; + const recoveryFiles = targets + .filter((item) => item.backupRecovery) + .map((item) => ({ + backup: item.backupRecovery, + target: item.capture.commitPath, + verifiedPrevious: false, + })); + for (const item of [...committed].reverse()) { + try { + const removed = quarantineRemoveRegularFileBinding( + item.candidateBinding, + item.capture.commitPath, + { + subject: 'published-compare-target', + expectedLinks: item.candidatePresent ? 2 : 1, + }, + ); + if (removed.status !== 'removed') { + if (removed.status === 'recovery-required') { + recoveryFiles.push({ + backup: removed.recoveryFile, + target: item.capture.commitPath, + }); + } + throw new Error(`the committed target could not be removed safely (${removed.reason?.code || 'unknown target state'})`); + } + } catch (error) { + rollbackErrors.push(`${item.label}: remove failed (${error.message})`); + } + } + for (const item of [...backedUp].reverse()) { + try { + if (pathEntryExists(item.capture.commitPath)) { + const current = item.candidateBinding + ? verifyRegularFileBinding(item.candidateBinding, { + filePath: item.capture.commitPath, + expectedLinks: item.candidatePresent ? 2 : 1, + }) + : { status: 'different' }; + if (current.status !== 'match') { + throw new Error(`a changed target claimant prevents restoring the previous file (${current.reason?.code || 'unknown target state'})`); + } + const removed = quarantineRemoveRegularFileBinding( + item.candidateBinding, + item.capture.commitPath, + { + subject: 'published-compare-target', + expectedLinks: item.candidatePresent ? 2 : 1, + }, + ); + if (removed.status !== 'removed') { + if (removed.status === 'recovery-required') { + recoveryFiles.push({ + backup: removed.recoveryFile, + target: item.capture.commitPath, + }); + } + throw new Error(`the committed target could not be removed safely (${removed.reason?.code || 'unknown target state'})`); + } + } + if (item.backupVerified !== true) { + throw new Error('the backup entry was never verified as the previous file'); + } + const currentBackup = verifyRegularFileBinding(item.previousBinding, { + filePath: item.backup, + expectedLinks: 1, + }); + if (currentBackup.status !== 'match') { + item.backupVerified = false; + throw new Error('the recoverable backup was replaced before rollback'); + } + // Publish the previous file without clobbering a claimant that appears + // after the absence check. The backup remains the recovery source until + // the restored hard link has been identity-verified. + fs.linkSync(item.backup, item.capture.commitPath); + for (const restoredPath of [item.capture.commitPath, item.backup]) { + const restored = verifyRegularFileBinding(item.previousBinding, { + filePath: restoredPath, + expectedLinks: 2, + }); + if (restored.status !== 'match') { + throw new Error('the restored target identity could not be verified'); + } + } + const retired = quarantineRemoveRegularFileBinding( + item.previousBinding, + item.backup, + { subject: 'previous-compare-target', expectedLinks: 2 }, + ); + if (retired.status !== 'removed') { + throw new Error(`the restored backup could not be retired safely (${retired.reason?.code || 'unknown target state'})`); + } + item.backupPresent = false; + const finalized = verifyRegularFileBinding(item.previousBinding, { + filePath: item.capture.commitPath, + expectedLinks: 1, + }); + if (finalized.status !== 'match') { + throw new Error('the restored target changed before rollback finalized'); + } + } catch (error) { + rollbackErrors.push(`${item.label}: restore failed (${error.message})`); + // Track failed restoration rather than probing existence: a permission + // error must not make cleanup discard a potentially recoverable backup. + if (item.backupPresent !== false) { + recoveryFiles.push({ + backup: item.backup, + target: item.capture.commitPath, + verifiedPrevious: item.backupVerified === true, + }); + } + } + } + if (!rollbackErrors.length && cause.compareFailure) { + throw compareCommitError( + cause.message, + cause.compareFailure.code, + { + ...cause.compareFailure.details, + reason: cause.message, + }, + ); + } + throw compareCommitError( + (rollbackErrors.length + ? 'Architecture Delta pair commit failed and its previous files could not be fully restored.' + : 'Architecture Delta pair commit failed; the previous files were restored.') + + (recoveryFiles.length + ? ` Recovery directory retained at ${path.dirname(recoveryFiles[0].backup)}.` + : ''), + rollbackErrors.length ? 'delta/commit-rollback-failed' : 'delta/commit-failed', + { + reason: cause.message, + ...(rollbackErrors.length ? { rollbackErrors } : {}), + ...(recoveryFiles.length ? { + recoveryDirectory: path.dirname(recoveryFiles[0].backup), + recoveryDirectories: [...new Set(recoveryFiles.map(({ backup }) => path.dirname(backup)))], + recoveryFiles, + } : {}), + supportedFixes: recoveryFiles.length + ? [ + ...recoveryFiles.map(({ backup, target, verifiedPrevious }) => verifiedPrevious + ? `resolve the filesystem error, inspect the current target, then restore ${JSON.stringify(backup)} to ${JSON.stringify(target)} before retrying` + : `preserve and inspect the unverified entry at ${JSON.stringify(backup)}; do not treat it as the previous file for ${JSON.stringify(target)}`), + 'remove the recovery directory only after the previous files have been recovered and verified', + ] + : ['check that both output paths are writable regular files, then retry'], + }, + ); + } finally { + for (const item of targets) { + if (item.candidateBinding) releaseRegularFileBinding(item.candidateBinding); + if (item.previousBinding + && !(retainSuccessfulBackupBindings && item.backupPresent)) { + releaseRegularFileBinding(item.previousBinding); + } + } + } +} + +function commitDeliveryPair({ + htmlCandidate, + htmlContent, + provenanceCandidate, + provenanceContent, + stagingDirectory, + ownership, + verifyAtomicOutput, + fileBindingRuntime, +}) { + const { + backupPublicRegularFileBinding, + captureRegularFileBinding, + quarantineRemoveRegularFileBinding, + verifyRegularFileBinding, + releaseRegularFileBinding, + } = fileBindingRuntime || {}; + const state = assertDeliveryOwnership(ownership, 'start the delivery pair commit', { pending: 'owned' }); + const snapshots = state.deliveryTargets; + if (!snapshots?.artifact || !snapshots?.provenance) { + throw deliveryTargetStateError(state.output, 'delivery pair', { + status: 'unknown', + reason: { code: 'target-snapshot-missing' }, + }); + } + const targets = [ + { + label: 'HTML artifact', + target: snapshots.artifact.commitPath, + candidate: htmlCandidate, + content: htmlContent, + backup: path.join(stagingDirectory, '.previous-output'), + capture: snapshots.artifact, + }, + { + label: 'delivery provenance', + target: snapshots.provenance.commitPath, + candidate: provenanceCandidate, + content: provenanceContent, + backup: path.join(stagingDirectory, '.previous-provenance'), + capture: snapshots.provenance, + }, + ]; + const backedUp = []; + const retainedBackups = new Set(); + const publicRecoveryBackups = []; + let retainSuccessfulBackupBindings = false; + const committed = []; + try { + if (typeof backupPublicRegularFileBinding !== 'function' + || typeof captureRegularFileBinding !== 'function' + || typeof quarantineRemoveRegularFileBinding !== 'function' + || typeof verifyRegularFileBinding !== 'function' + || typeof releaseRegularFileBinding !== 'function') { + throw deliveryTargetStateError(state.output, 'delivery pair', { + status: 'unknown', + reason: { code: 'atomic-output-runtime-unavailable' }, + }); + } + for (const item of targets) { + const verification = verifyAtomicOutput(item.capture.snapshot); + if (verification.status !== 'match') { + throw deliveryTargetStateError(item.capture.requestedPath, item.label, verification); + } + if (item.capture.mode !== null) fs.chmodSync(item.candidate, item.capture.mode); + const candidate = captureRegularFileBinding(item.candidate, { + subject: 'delivery-candidate', + expectedSha256: item.content.sha256, + expectedBytes: item.content.bytes, + ...(item.capture.mode === null ? {} : { expectedMode: item.capture.mode }), + expectedLinks: 1, + }); + if (candidate.status !== 'captured') { + throw deliveryTargetStateError(item.capture.requestedPath, item.label, candidate); + } + item.candidateBinding = candidate.binding; + item.candidatePresent = true; + item.candidateIdentity = { + dev: candidate.identity.device, + ino: candidate.identity.inode, + }; + item.candidateMode = candidate.mode; + if (item.capture.snapshot.target.kind === 'file') { + const expected = item.capture.snapshot.target; + const previous = captureRegularFileBinding(item.target, { + subject: 'previous-delivery-target', + expectedIdentity: { device: expected.device, inode: expected.inode }, + expectedMode: expected.mode, + expectedLinks: 1, + }); + if (previous.status !== 'captured') { + throw deliveryTargetStateError(item.capture.requestedPath, item.label, previous); + } + item.previousBinding = previous.binding; + } + } + for (const item of targets) { + if (item.capture.snapshot.target.kind !== 'file') continue; + assertDeliveryOwnership(ownership, `back up the ${item.label}`, { pending: 'owned' }); + const moved = backupPublicRegularFileBinding( + item.previousBinding, + item.target, + item.backup, + { subject: 'previous-delivery-target' }, + ); + item.backupPresent = moved.backupCreated === true; + item.backupVerified = moved.backupVerified === true; + if (item.backupPresent) backedUp.push(item); + if (moved.status === 'recovery-required' && moved.recoveryFile) { + publicRecoveryBackups.push({ + label: `preserved replacement for ${item.label}`, + path: moved.recoveryFile, + target: item.target, + }); + } + if (moved.status !== 'backed-up') { + throw deliveryTargetStateError(item.capture.requestedPath, item.label, moved); + } + retainedBackups.add(item); + } + for (const item of targets) { + assertDeliveryOwnership(ownership, `commit the ${item.label}`, { pending: 'owned' }); + const candidate = verifyRegularFileBinding(item.candidateBinding, { + filePath: item.candidate, + expectedLinks: 1, + }); + if (candidate.status !== 'match') { + throw deliveryTargetStateError(item.capture.requestedPath, item.label, candidate); + } + try { + fs.linkSync(item.candidate, item.target); + } catch (error) { + if (error?.code === 'EEXIST') { + throw deliveryTargetStateError(item.capture.requestedPath, item.label, { + status: 'different', + reason: { code: 'target-claimed-during-publish' }, + }); + } + throw error; + } + item.committedIdentity = item.candidateIdentity; + committed.push(item); + const linked = verifyRegularFileBinding(item.candidateBinding, { + filePath: item.target, + expectedLinks: 2, + }); + if (linked.status !== 'match') { + throw deliveryTargetStateError(item.capture.requestedPath, item.label, linked); + } + } + for (const item of committed) { + assertDeliveryOwnership(ownership, `verify the committed ${item.label}`, { pending: 'owned' }); + const current = verifyRegularFileBinding(item.candidateBinding, { + filePath: item.target, + expectedLinks: 2, + }); + if (current.status !== 'match') { + throw deliveryTargetStateError(item.capture.requestedPath, item.label, current); + } + } + for (const item of committed) { + const staged = verifyRegularFileBinding(item.candidateBinding, { + filePath: item.candidate, + expectedLinks: 2, + }); + if (staged.status !== 'match') { + throw deliveryTargetStateError(item.capture.requestedPath, item.label, staged); + } + const retired = quarantineRemoveRegularFileBinding( + item.candidateBinding, + item.candidate, + { subject: 'delivery-candidate', expectedLinks: 2 }, + ); + if (retired.status !== 'removed') { + throw deliveryTargetStateError(item.capture.requestedPath, item.label, retired); + } + item.candidatePresent = false; + } + for (const item of committed) { + const finalized = verifyRegularFileBinding(item.candidateBinding, { + filePath: item.target, + expectedLinks: 1, + }); + if (finalized.status !== 'match') { + throw deliveryTargetStateError(item.capture.requestedPath, item.label, finalized); + } + } + // Finalization is part of the commit: keep the old files recoverable until + // the journal has been removed and checkers can accept the new artifact. + assertDeliveryOwnership(ownership, 'finalize the delivery journal', { pending: 'owned' }); + quarantineRemoveOwnedDeliveryEntry(state, { + filePath: state.pendingPath, + identity: state.pendingIdentity, + content: state.pendingContent, + subject: 'delivery-journal', + operation: 'finalize the delivery journal', + evidenceKey: 'journal', + }); + assertDeliveryOwnership(ownership, 'finish finalizing the delivery journal', { pending: 'absent' }); + state.pendingIdentity = undefined; + state.pendingContent = undefined; + state.phase = 'finalized'; + retainSuccessfulBackupBindings = true; + return { + recoverableBackups: [ + ...[...retainedBackups].map((item) => ({ label: item.label, path: item.backup })), + ...publicRecoveryBackups, + ], + ownedBackupEntries: backedUp + .filter((item) => item.backupPresent) + .map((item) => ({ + filePath: item.backup, + binding: item.previousBinding, + subject: 'previous-delivery-backup', + expectedLinks: 1, + })), + }; + } catch (cause) { + const recoveryDetails = () => ({ + recoveryRequired: true, + recoveryDirectory: stagingDirectory, + recoverableBackups: [ + ...[...retainedBackups].map((item) => ({ label: item.label, path: item.backup })), + ...publicRecoveryBackups, + ], + }); + if (cause.deliveryOwnershipCode === 'delivery/ownership-lost') { + cause.deliveryCommitDetails = { ...(cause.deliveryCommitDetails || {}), ...recoveryDetails() }; + throw cause; + } + const rollbackErrors = []; + const recoverableBackups = []; + try { + for (const item of [...committed].reverse()) { + try { + assertDeliveryOwnership(ownership, `roll back the ${item.label}`, { pending: 'identity' }); + const removed = quarantineRemoveRegularFileBinding(item.candidateBinding, item.target, { + subject: 'published-delivery-target', + expectedLinks: item.candidatePresent ? 2 : 1, + }); + if (removed.status !== 'removed') { + if (removed.status === 'recovery-required') { + publicRecoveryBackups.push({ + label: `preserved replacement for ${item.label}`, + path: removed.recoveryFile, + target: item.target, + }); + } + throw new Error(`the committed target could not be removed safely (${removed.reason?.code || 'unknown target state'})`); + } + } catch (error) { + if (error.deliveryOwnershipCode === 'delivery/ownership-lost') throw error; + rollbackErrors.push(`${item.label}: remove failed (${error.message})`); + } + } + for (const item of [...backedUp].reverse()) { + try { + assertDeliveryOwnership(ownership, `restore the previous ${item.label}`, { pending: 'identity' }); + if (pathEntryExists(item.target)) { + const current = item.candidateBinding + ? verifyRegularFileBinding(item.candidateBinding, { + filePath: item.target, + expectedLinks: item.candidatePresent ? 2 : 1, + }) + : { status: 'different' }; + if (current.status !== 'match') { + throw new Error(`a changed target claimant prevents restoring the previous file (${current.reason?.code || 'unknown target state'})`); + } + const removed = quarantineRemoveRegularFileBinding(item.candidateBinding, item.target, { + subject: 'published-delivery-target', + expectedLinks: item.candidatePresent ? 2 : 1, + }); + if (removed.status !== 'removed') { + if (removed.status === 'recovery-required') { + publicRecoveryBackups.push({ + label: `preserved replacement for ${item.label}`, + path: removed.recoveryFile, + target: item.target, + }); + } + throw new Error(`the committed target could not be removed safely (${removed.reason?.code || 'unknown target state'})`); + } + } + assertDeliveryOwnership(ownership, `restore the previous ${item.label}`, { pending: 'identity' }); + if (item.backupVerified !== true) { + throw new Error('the backup entry was never verified as the previous delivery file'); + } + const currentBackup = verifyRegularFileBinding(item.previousBinding, { + filePath: item.backup, + expectedLinks: 1, + }); + if (currentBackup.status !== 'match') { + item.backupVerified = false; + retainedBackups.delete(item); + throw new Error('the previous delivery backup identity changed'); + } + try { + fs.linkSync(item.backup, item.target); + } catch (error) { + if (error?.code === 'EEXIST') { + throw new Error('a new target claimant prevents restoring the previous file'); + } + throw error; + } + for (const restoredPath of [item.target, item.backup]) { + const restored = verifyRegularFileBinding(item.previousBinding, { + filePath: restoredPath, + expectedLinks: 2, + }); + if (restored.status !== 'match') { + throw new Error('the restored delivery target identity could not be verified'); + } + } + const retired = quarantineRemoveRegularFileBinding( + item.previousBinding, + item.backup, + { subject: 'previous-delivery-target', expectedLinks: 2 }, + ); + if (retired.status !== 'removed') { + throw new Error(`the restored delivery backup could not be retired safely (${retired.reason?.code || 'unknown target state'})`); + } + item.backupPresent = false; + const restoredFinal = verifyRegularFileBinding(item.previousBinding, { + filePath: item.target, + expectedLinks: 1, + }); + if (restoredFinal.status !== 'match') { + throw new Error('the restored delivery target link count could not be verified'); + } + retainedBackups.delete(item); + } catch (error) { + if (error.deliveryOwnershipCode === 'delivery/ownership-lost') throw error; + rollbackErrors.push(`${item.label}: restore failed (${error.message})`); + // A failed restoration leaves this exact backup at its source path. + // Record it without probing the failing recovery path again. + if (item.backupPresent !== false) { + recoverableBackups.push({ + label: item.backupVerified === true + ? item.label + : `unverified preserved backup entry for ${item.label}`, + path: item.backup, + target: item.target, + verifiedPrevious: item.backupVerified === true, + }); + } + } + } + assertDeliveryOwnership(ownership, 'finish the delivery rollback', { pending: 'identity' }); + } catch (rollbackError) { + if (rollbackError.deliveryOwnershipCode === 'delivery/ownership-lost') { + rollbackError.deliveryCommitDetails = { + ...(rollbackError.deliveryCommitDetails || {}), + commitError: { + reason: cause.message, + ...(cause?.code ? { systemCode: cause.code } : {}), + }, + ...recoveryDetails(), + }; + throw rollbackError; + } + rollbackErrors.push(rollbackError.message); + } + const error = new Error(rollbackErrors.length + ? 'Delivery pair commit failed and its previous files could not be fully restored.' + : 'Delivery pair commit failed; the previous files were restored.'); + error.deliveryCommitDetails = { + reason: cause.message, + ...(rollbackErrors.length ? { + rollbackErrors, + recoveryRequired: true, + recoveryDirectory: stagingDirectory, + recoverableBackups: [...recoverableBackups, ...publicRecoveryBackups], + } : {}), + }; + if (cause.deliveryTargetState) { + error.deliveryTargetState = cause.deliveryTargetState; + error.archifyDiagnostics = cause.archifyDiagnostics; + } + throw error; + } finally { + for (const item of targets) { + if (item.candidateBinding) releaseRegularFileBinding(item.candidateBinding); + if (item.previousBinding + && !(retainSuccessfulBackupBindings && item.backupPresent)) { + releaseRegularFileBinding(item.previousBinding); + } + } + } +} + +function renderValidatedArchitecture(inputPath, outputPath, quality, repoRoot, captureStagingFile) { + const render = runNode([rendererPath('architecture'), inputPath, outputPath], { + stdio: 'pipe', + env: rendererEnv(quality, repoRoot, true), + }); + if (render.status !== 0) { + const failure = rendererFailure(render); + const error = new Error(failure.error); + error.compareStage = 'input'; + error.compareStatus = render.status ?? 1; + error.diagnostics = failure.diagnostics; + throw error; + } + let artifact; + if (captureStagingFile) { + artifact = fs.readFileSync(outputPath); + captureStagingFile(outputPath, artifactIdentity(artifact)); + } + const check = runNode([path.join(skillRoot, 'scripts/check-render-output.mjs'), outputPath], { stdio: 'pipe' }); + if (check.status !== 0) { + const error = new Error('Validated snapshot failed final artifact checks.'); + error.compareStage = 'check'; + error.compareStatus = check.status ?? 1; + try { + error.checker = JSON.parse(check.stdout); + error.diagnostics = checkerDiagnostics(error.checker); + } catch { + error.diagnostics = []; + } + throw error; + } + artifact ||= fs.readFileSync(outputPath); + return { + artifact, + html: artifact.toString('utf8'), + checks: JSON.parse(check.stdout), + sourceEvidence: sourceEvidenceFromArtifact(artifact), + }; +} + +async function commandCompare(args) { + await loadSidecarPathRuntime(); + const { + canonicalFuturePath, + resolveOutputPath, + validateAuthoredOutputPath, + } = await import('../renderers/shared/output-path.mjs'); + const { + captureAtomicOutput, + verifyAtomicOutput, + backupPublicRegularFileBinding, + captureRegularFileBinding, + quarantineRemoveRegularFileBinding, + verifyRegularFileBinding, + releaseRegularFileBinding, + } = await import('../renderers/shared/atomic-output.mjs'); + const fileBindingRuntime = { + backupPublicRegularFileBinding, + captureRegularFileBinding, + quarantineRemoveRegularFileBinding, + verifyRegularFileBinding, + releaseRegularFileBinding, + }; + const { + sameParent, + sidecarNamespaceComponentKey, + } = await import('../renderers/shared/path-semantics.mjs'); + sidecarNamespaceComponentKeyRuntime = sidecarNamespaceComponentKey; + const qualityArgs = extractQualityArgs(args); + const repoArgs = extractRepoRootArgs(qualityArgs.rest); + const options = extractCompareOptions(repoArgs.rest); + const [type, baseInput, headInput, requestedOutput] = options.positional; + if (type !== 'architecture' || !baseInput || !headInput || options.positional.length > 4) fail(usage()); + let deltaRuntime; + try { + deltaRuntime = await import(pathToFileURL(path.join(skillRoot, 'delta/architecture-delta.mjs')).href); + } catch (error) { + reportCompareFailure({ json: options.json, stage: 'prepare', error: 'Architecture compare runtime is unavailable.', code: 'delta/runtime-missing', details: { reason: error.message, supportedFixes: ['install the complete Archify skill package'] } }); + return; + } + const { + ArchitectureDeltaError, + annotateArchitectureSideSvg, + buildDeltaSvg, + canonicalArchitecture, + canonicalArchitectureJson, + compareArchitecture, + extractArchitectureSvg, + extractArtifactCss, + renderArchitectureDeltaHtml, + validateArchitectureDeltaHtml, + } = deltaRuntime; + + const basePath = path.resolve(baseInput); + const headPath = path.resolve(headInput); + const explicitReceiptTarget = options.receipt ? path.resolve(options.receipt) : undefined; + const rawOutputTarget = requestedOutput || 'architecture-delta.html'; + const rawOutputExtension = path.extname(rawOutputTarget); + // Preserve the established alias-first diagnostic for an already-invalid + // CLI extension without letting this legacy spelling select the real + // sidecar namespace for any valid compare output. + const invalidOutputAliasPreflight = !explicitReceiptTarget + && rawOutputExtension.toLowerCase() !== '.html' + ? path.resolve(rawOutputExtension + ? `${rawOutputTarget.slice(0, -rawOutputExtension.length)}.receipt.json` + : `${rawOutputTarget}.receipt.json`) + : undefined; + let outputPath; + try { + ({ outputPath } = resolveOutputPath({ + requestedOutput, + defaultOutput: 'architecture-delta.html', + inputPaths: [basePath, headPath], + otherOutputPaths: explicitReceiptTarget + ? [explicitReceiptTarget] + : (invalidOutputAliasPreflight ? [invalidOutputAliasPreflight] : []), + })); + } catch (error) { + const outputDiagnostic = error.archifyDiagnostics?.[0]; + reportCompareFailure({ + json: options.json, + stage: 'prepare', + error: error.message, + code: outputDiagnostic?.code || 'output/path-resolution', + details: { + ...(outputDiagnostic?.subject || {}), + ...(outputDiagnostic?.evidence || {}), + supportedFixes: outputDiagnostic?.supportedFixes || ['choose a safe output path and retry'], + }, + }); + return; + } + const requestedOutputPath = outputPath; + let receiptPath; + let requestedReceiptPath; + if (explicitReceiptTarget) { + try { + ({ outputPath: receiptPath } = resolveOutputPath({ + requestedOutput: options.receipt, + defaultOutput: options.receipt, + requiredExtension: '.json', + inputPaths: [basePath, headPath], + otherOutputPaths: [outputPath], + })); + requestedReceiptPath = receiptPath; + } catch (error) { + const outputDiagnostic = error.archifyDiagnostics?.[0]; + reportCompareFailure({ + json: options.json, + stage: 'prepare', + error: error.message, + code: outputDiagnostic?.code || 'output/path-resolution', + details: { + ...(outputDiagnostic?.subject || {}), + ...(outputDiagnostic?.evidence || {}), + supportedFixes: outputDiagnostic?.supportedFixes || ['choose a safe receipt path and retry'], + }, + }); + return; + } + } + try { + outputPath = canonicalFuturePath(requestedOutputPath); + if (requestedReceiptPath) receiptPath = canonicalFuturePath(requestedReceiptPath); + } catch (error) { + const outputDiagnostic = error.archifyDiagnostics?.[0]; + reportCompareFailure({ + json: options.json, + stage: 'prepare', + error: error.message, + code: outputDiagnostic?.code || 'output/path-resolution', + details: { + ...(outputDiagnostic?.subject || {}), + ...(outputDiagnostic?.evidence || {}), + supportedFixes: outputDiagnostic?.supportedFixes || ['choose safe compare output paths and retry'], + }, + }); + return; + } + let baseBuffer; + let headBuffer; + let base; + let head; + try { + baseBuffer = fs.readFileSync(basePath); + base = JSON.parse(baseBuffer.toString('utf8')); + } catch (error) { + reportCompareFailure({ json: options.json, stage: 'input', error: `Could not read base input: ${error.message}`, code: 'delta/base-input', details: { side: 'base', reason: error.message } }); + return; + } + try { + headBuffer = fs.readFileSync(headPath); + head = JSON.parse(headBuffer.toString('utf8')); + } catch (error) { + reportCompareFailure({ json: options.json, stage: 'input', error: `Could not read head input: ${error.message}`, code: 'delta/head-input', details: { side: 'head', reason: error.message } }); + return; + } + + for (const [side, document] of [['base', base], ['head', head]]) { + try { + validateAuthoredOutputPath(document?.meta?.output); + } catch (error) { + const diagnosticEntry = error.archifyDiagnostics?.[0]; + reportCompareFailure({ + json: options.json, + stage: 'input', + error: `${side === 'base' ? 'Base' : 'Head'} snapshot failed validation: ${error.message}`, + code: diagnosticEntry?.code || `delta/${side}-validation`, + details: { + side, + ...(diagnosticEntry?.subject?.path ? { path: diagnosticEntry.subject.path } : {}), + ...(diagnosticEntry?.evidence || {}), + supportedFixes: diagnosticEntry?.supportedFixes || [], + }, + }); + return; + } + } + + let outputDirectory = path.dirname(outputPath); + if (receiptPath) { + const explicitReceiptDirectoryIdentity = sameParent(outputPath, receiptPath); + if (explicitReceiptDirectoryIdentity.status === 'unknown') { + reportCompareFailure({ + json: options.json, + stage: 'prepare', + error: 'The compare receipt directory identity could not be verified.', + code: 'delta/receipt-directory-identity-indeterminate', + details: { + artifactDirectory: outputDirectory, + receiptDirectory: path.dirname(receiptPath), + pathIdentity: explicitReceiptDirectoryIdentity.reason, + supportedFixes: ['restore access to both output directories or choose paths in one verifiable directory'], + }, + }); + return; + } + if (explicitReceiptDirectoryIdentity.status !== 'match') { + reportCompareFailure({ + json: options.json, + stage: 'prepare', + error: 'The compare receipt must be written beside the HTML artifact.', + code: 'delta/receipt-directory', + details: { + artifactDirectory: outputDirectory, + receiptDirectory: path.dirname(receiptPath), + pathIdentity: explicitReceiptDirectoryIdentity.reason, + supportedFixes: ['choose a --receipt path in the same directory as output.html'], + }, + }); + return; + } + } + try { + fs.mkdirSync(outputDirectory, { recursive: true }); + } catch (error) { + reportCompareFailure({ json: options.json, stage: 'prepare', error: `Could not create compare output directory: ${error.message}`, code: 'delta/output-directory', details: { reason: error.message } }); + return; + } + + if (!receiptPath) { + try { + const defaultReceiptPath = compareReceiptPath(outputPath); + ({ outputPath: receiptPath } = resolveOutputPath({ + requestedOutput: defaultReceiptPath, + defaultOutput: defaultReceiptPath, + requiredExtension: '.json', + inputPaths: [basePath, headPath], + otherOutputPaths: [outputPath], + })); + requestedReceiptPath = receiptPath; + receiptPath = canonicalFuturePath(requestedReceiptPath); + } catch (error) { + const outputDiagnostic = error.archifyDiagnostics?.[0]; + const namespaceIndeterminate = error?.code === 'ARCHIFY_SIDECAR_NAMESPACE_INDETERMINATE'; + reportCompareFailure({ + json: options.json, + stage: 'prepare', + error: error.message, + code: namespaceIndeterminate + ? 'delta/receipt-namespace-indeterminate' + : outputDiagnostic?.code || 'output/path-resolution', + details: { + ...(outputDiagnostic?.subject || {}), + ...(outputDiagnostic?.evidence || {}), + ...(namespaceIndeterminate ? { pathIdentity: error.sidecarNamespaceReason } : {}), + supportedFixes: outputDiagnostic?.supportedFixes + || ['choose a safe receipt path beside the compare artifact and retry'], + }, + }); + return; + } + } + + const receiptDirectoryIdentity = sameParent(outputPath, receiptPath); + if (receiptDirectoryIdentity.status === 'unknown') { + reportCompareFailure({ + json: options.json, + stage: 'prepare', + error: 'The compare receipt directory identity could not be verified.', + code: 'delta/receipt-directory-identity-indeterminate', + details: { + artifactDirectory: outputDirectory, + receiptDirectory: path.dirname(receiptPath), + pathIdentity: receiptDirectoryIdentity.reason, + supportedFixes: ['restore access to both output directories or choose paths in one verifiable directory'], + }, + }); + return; + } + if (receiptDirectoryIdentity.status !== 'match') { + reportCompareFailure({ + json: options.json, + stage: 'prepare', + error: 'The compare receipt must be written beside the HTML artifact.', + code: 'delta/receipt-directory', + details: { + artifactDirectory: outputDirectory, + receiptDirectory: path.dirname(receiptPath), + pathIdentity: receiptDirectoryIdentity.reason, + supportedFixes: ['choose a --receipt path in the same directory as output.html'], + }, + }); + return; + } + + const outputCapture = captureAtomicOutput(requestedOutputPath); + const receiptCapture = captureAtomicOutput(requestedReceiptPath); + for (const [capture, label, requestedPath] of [ + [outputCapture, 'HTML artifact', requestedOutputPath], + [receiptCapture, 'receipt', requestedReceiptPath], + ]) { + if (capture.status === 'captured') continue; + const failure = compareAtomicOutputFailure(capture, label, requestedPath, 'prepare'); + reportCompareFailure({ + json: options.json, + stage: failure.stage, + error: failure.message, + code: failure.code, + details: failure.details, + }); + return; + } + const outputParent = outputCapture.snapshot.slot; + const receiptParent = receiptCapture.snapshot.slot; + if (outputParent.parentDevice !== receiptParent.parentDevice + || outputParent.parentInode !== receiptParent.parentInode) { + reportCompareFailure({ + json: options.json, + stage: 'prepare', + error: 'The compare receipt must be written beside the HTML artifact.', + code: 'delta/receipt-directory', + details: { + artifactDirectory: outputParent.parentPath, + receiptDirectory: receiptParent.parentPath, + supportedFixes: ['choose a --receipt path in the same physical directory as output.html'], + }, + }); + return; + } + outputPath = outputCapture.commitPath; + receiptPath = receiptCapture.commitPath; + outputDirectory = path.dirname(outputPath); + + let stagingDirectory; + let stagingIdentity; + try { + const staging = createOwnedEmptyStagingDirectory( + path.join(outputDirectory, '.archify-compare-'), + ); + stagingDirectory = staging.directory; + stagingIdentity = staging.identity; + } catch (error) { + reportCompareFailure({ json: options.json, stage: 'prepare', error: `Could not create compare candidate: ${error.message}`, code: 'delta/candidate-directory', details: { reason: error.message } }); + return; + } + + const baseCandidate = path.join(stagingDirectory, 'base.html'); + const headCandidate = path.join(stagingDirectory, 'head.html'); + const rawBaseCandidate = path.join(stagingDirectory, 'base.raw.html'); + const rawHeadCandidate = path.join(stagingDirectory, 'head.raw.html'); + const rawBaseInput = path.join(stagingDirectory, 'base.snapshot.json'); + const rawHeadInput = path.join(stagingDirectory, 'head.snapshot.json'); + const canonicalBaseInput = path.join(stagingDirectory, 'base.architecture.json'); + const canonicalHeadInput = path.join(stagingDirectory, 'head.architecture.json'); + const htmlCandidate = path.join(stagingDirectory, path.basename(outputPath)); + const receiptCandidate = path.join(stagingDirectory, path.basename(receiptPath)); + let preserveRecoveryDirectory = false; + const stagingOwnership = []; + const captureCompareStagingFile = (filePath, content) => captureOwnedStagingFile( + stagingOwnership, + filePath, + fileBindingRuntime, + { subject: 'compare-staging-entry', content }, + ); + + try { + let baseResult; + let headResult; + for (const { side, snapshotPath, buffer } of [ + { side: 'base', snapshotPath: rawBaseInput, buffer: baseBuffer }, + { side: 'head', snapshotPath: rawHeadInput, buffer: headBuffer }, + ]) { + try { + fs.writeFileSync(snapshotPath, buffer, { flag: 'wx' }); + captureCompareStagingFile(snapshotPath, artifactIdentity(buffer)); + } catch (error) { + const message = `Could not freeze ${side} compare snapshot: ${error.message}`; + reportCompareFailure({ + json: options.json, + stage: 'prepare', + error: message, + code: 'delta/freeze-snapshot', + details: { + side, + ...(error?.code ? { systemCode: error.code } : {}), + reason: error.message, + supportedFixes: ['choose a writable compare output directory on the target filesystem'], + }, + }); + return; + } + } + try { + renderValidatedArchitecture( + rawBaseInput, + rawBaseCandidate, + qualityArgs.quality, + repoArgs.repoRoot, + captureCompareStagingFile, + ); + } catch (error) { + const diagnosticEntry = error.diagnostics?.[0]; + reportCompareFailure({ + json: options.json, + stage: error.compareStage || 'validate', + error: `Base snapshot failed validation: ${error.message}`, + code: diagnosticEntry?.code || 'delta/base-validation', + details: { side: 'base', ...(diagnosticEntry?.subject?.path ? { path: diagnosticEntry.subject.path } : {}), ...(diagnosticEntry?.evidence || {}), supportedFixes: diagnosticEntry?.supportedFixes || [] }, + status: error.compareStatus || 1, + }); + return; + } + try { + renderValidatedArchitecture( + rawHeadInput, + rawHeadCandidate, + qualityArgs.quality, + repoArgs.repoRoot, + captureCompareStagingFile, + ); + } catch (error) { + const diagnosticEntry = error.diagnostics?.[0]; + reportCompareFailure({ + json: options.json, + stage: error.compareStage || 'validate', + error: `Head snapshot failed validation: ${error.message}`, + code: diagnosticEntry?.code || 'delta/head-validation', + details: { side: 'head', ...(diagnosticEntry?.subject?.path ? { path: diagnosticEntry.subject.path } : {}), ...(diagnosticEntry?.evidence || {}), supportedFixes: diagnosticEntry?.supportedFixes || [] }, + status: error.compareStatus || 1, + }); + return; + } + + // Validation must see the exact authored inputs. Only after both sides + // pass do we canonicalize their collection order for deterministic SVG + // geometry and stable artifact bytes. + const canonicalBase = canonicalArchitecture(base); + const canonicalHead = canonicalArchitecture(head); + canonicalBase.meta.output = base.meta.output; + canonicalHead.meta.output = head.meta.output; + const canonicalBaseBytes = Buffer.from(JSON.stringify(canonicalBase)); + const canonicalHeadBytes = Buffer.from(JSON.stringify(canonicalHead)); + fs.writeFileSync(canonicalBaseInput, canonicalBaseBytes); + captureCompareStagingFile(canonicalBaseInput, artifactIdentity(canonicalBaseBytes)); + fs.writeFileSync(canonicalHeadInput, canonicalHeadBytes); + captureCompareStagingFile(canonicalHeadInput, artifactIdentity(canonicalHeadBytes)); + baseResult = renderValidatedArchitecture( + canonicalBaseInput, + baseCandidate, + qualityArgs.quality, + repoArgs.repoRoot, + captureCompareStagingFile, + ); + headResult = renderValidatedArchitecture( + canonicalHeadInput, + headCandidate, + qualityArgs.quality, + repoArgs.repoRoot, + captureCompareStagingFile, + ); + + const semanticHash = (diagram) => createHash('sha256').update(canonicalArchitectureJson(diagram)).digest('hex'); + let compareIr; + try { + compareIr = compareArchitecture(base, head, { + baseRawSha256: createHash('sha256').update(baseBuffer).digest('hex'), + headRawSha256: createHash('sha256').update(headBuffer).digest('hex'), + baseSemanticSha256: semanticHash(base), + headSemanticSha256: semanticHash(head), + baseBytes: baseBuffer.byteLength, + headBytes: headBuffer.byteLength, + baseVerified: Boolean(baseResult.sourceEvidence), + headVerified: Boolean(headResult.sourceEvidence), + }); + } catch (error) { + if (!(error instanceof ArchitectureDeltaError)) throw error; + reportCompareFailure({ json: options.json, stage: 'compare', error: error.message, code: error.code, details: error.details }); + return; + } + + const baseSourceSvg = extractArchitectureSvg(baseResult.html); + const headSourceSvg = extractArchitectureSvg(headResult.html); + const baseSvg = annotateArchitectureSideSvg(baseSourceSvg, compareIr, 'base'); + const headSvg = annotateArchitectureSideSvg(headSourceSvg, compareIr, 'head'); + const deltaSvg = buildDeltaSvg(baseSourceSvg, headSourceSvg, compareIr); + // Raw input hashes and byte counts belong in the sidecar receipt, not the + // artifact. Keeping them out makes formatting-only input rewrites produce + // the exact same canonical review HTML and artifact hash. + const artifactIr = { + ...compareIr, + base: Object.fromEntries(Object.entries(compareIr.base).filter(([key]) => !['rawSha256', 'bytes'].includes(key))), + head: Object.fromEntries(Object.entries(compareIr.head).filter(([key]) => !['rawSha256', 'bytes'].includes(key))), + }; + const html = renderArchitectureDeltaHtml({ + receipt: artifactIr, + baseSvg, + deltaSvg, + headSvg, + baseHtml: baseResult.html, + headHtml: headResult.html, + artifactCss: extractArtifactCss(headResult.html), + }); + const deltaValidation = validateArchitectureDeltaHtml(html, artifactIr); + fs.writeFileSync(htmlCandidate, html); + if (outputCapture.mode !== null) fs.chmodSync(htmlCandidate, outputCapture.mode); + const artifact = fs.readFileSync(htmlCandidate); + captureCompareStagingFile(htmlCandidate, artifactIdentity(artifact)); + const baseChecks = baseResult.checks.checks.filter((check) => check.ok).length; + const headChecks = headResult.checks.checks.filter((check) => check.ok).length; + const finalReceipt = { + ...compareIr, + artifact: artifactIdentity(artifact), + validation: { + checksPassed: baseChecks + headChecks + deltaValidation.checksPassed, + checkCount: baseResult.checks.checks.length + headResult.checks.checks.length + deltaValidation.checkCount, + baseComposition: baseResult.checks.composition.status, + headComposition: headResult.checks.composition.status, + }, + }; + const receiptBytes = Buffer.from(`${JSON.stringify(finalReceipt, null, 2)}\n`); + fs.writeFileSync(receiptCandidate, receiptBytes); + if (receiptCapture.mode !== null) fs.chmodSync(receiptCandidate, receiptCapture.mode); + captureCompareStagingFile(receiptCandidate, artifactIdentity(receiptBytes)); + + try { + const currentOutput = resolveOutputPath({ + requestedOutput, + defaultOutput: 'architecture-delta.html', + inputPaths: [basePath, headPath], + otherOutputPaths: [requestedReceiptPath], + }).outputPath; + resolveOutputPath({ + requestedOutput: options.receipt || compareReceiptPath(currentOutput), + defaultOutput: compareReceiptPath(currentOutput), + requiredExtension: '.json', + inputPaths: [basePath, headPath], + otherOutputPaths: [currentOutput], + }); + } catch (error) { + const outputDiagnostic = error.archifyDiagnostics?.[0]; + reportCompareFailure({ + json: options.json, + stage: 'commit', + error: error.message, + code: outputDiagnostic?.code || 'output/path-resolution', + details: { + ...(outputDiagnostic?.subject || {}), + ...(outputDiagnostic?.evidence || {}), + supportedFixes: outputDiagnostic?.supportedFixes || ['restore safe output paths and retry'], + }, + }); + return; + } + + const committed = commitComparePair({ + htmlCandidate, + htmlContent: finalReceipt.artifact, + receiptCandidate, + receiptContent: artifactIdentity(receiptBytes), + outputCapture, + receiptCapture, + stagingDirectory, + verifyAtomicOutput, + fileBindingRuntime, + }); + stagingOwnership.push(...committed.ownedBackupEntries); + if (options.json) console.log(JSON.stringify(finalReceipt, null, 2)); + else { + console.log(`compared architecture ${requestedOutputPath}`); + console.log(`${finalReceipt.validation.checksPassed}/${finalReceipt.validation.checkCount} checks; completeness ${finalReceipt.completeness}; ${finalReceipt.proofLevel}; sha256 ${finalReceipt.artifact.sha256.slice(0, 12)}`); + console.log(`receipt ${requestedReceiptPath}`); + } + } catch (error) { + if (error instanceof ArchitectureDeltaError) { + reportCompareFailure({ json: options.json, stage: 'artifact', error: error.message, code: error.code, details: error.details }); + } else if (error.compareStage === 'commit') { + preserveRecoveryDirectory = Boolean(error.compareDetails?.recoveryFiles?.length); + reportCompareFailure({ + json: options.json, + stage: error.compareStage, + error: error.message, + code: error.compareCode, + details: error.compareDetails, + }); + } else { + reportCompareFailure({ json: options.json, stage: 'internal', error: 'Architecture compare failed before commit.', code: 'delta/internal', details: { reason: error.message } }); + } + } finally { + try { + if (!preserveRecoveryDirectory) { + cleanupOwnedStagingDirectory( + stagingDirectory, + stagingIdentity, + stagingOwnership, + fileBindingRuntime, + ); + } else { + releaseOwnedStagingBindings(stagingOwnership, fileBindingRuntime); + } + } catch (error) { + console.error(`Warning: could not remove compare staging directory: ${error.message}`); + } + } +} + +function commandRender(args) { + const qualityArgs = extractQualityArgs(args); + const repoArgs = extractRepoRootArgs(qualityArgs.rest); + // render takes no options of its own once --quality and --repo-root are + // stripped, so anything left starting with -- is a typo. Without this a + // mistyped flag was taken as the output path: `render architecture spec.json + // --json out.html` wrote a file literally named `--json` and never wrote + // out.html, exiting 0. Every sibling subcommand already guards this. + const unknown = repoArgs.rest.filter((arg) => arg.startsWith('--')); + if (unknown.length) fail(`Unknown render option "${unknown[0]}".`); + const [type, input, output] = repoArgs.rest; + if (!type || !input || repoArgs.rest.length > 3) fail(usage()); + const result = runNode([rendererPath(type), input, ...(output ? [output] : [])], { + env: rendererEnv(qualityArgs.quality, repoArgs.repoRoot), + }); + if (result.status !== 0) exitFrom(result); +} + +function reportArtifactFailure({ command, json, stage, type, input, output, error, diagnostics = [], status = 1, checker, receiptId, provenance, update }) { + const receipt = { + schemaVersion: 1, + ok: false, + command, + ...(receiptId ? { receiptId } : {}), + stage, + type, + input, + ...(output === undefined ? {} : { output }), + error, + diagnostics, + ...(provenance ? { provenance } : {}), + ...(checker ? { checker } : {}), + ...(update ? { update } : {}), + }; + if (json) console.log(JSON.stringify(receipt, null, 2)); + else { + console.error(formatDiagnostics(error, diagnostics)); + if (update?.noticeRequired) console.log(update.noticeText); + } + process.exitCode = status; +} + +function writeDeliveryFailureReceipt(options) { + const receiptId = options.receiptId || randomUUID(); + const recorded = recordDeliveryFailure({ ...options, receiptId }); + const ownershipOrLockError = recorded.ownershipError || recorded.lockError; + if (ownershipOrLockError) { + const failureWasRecorded = recorded.status && recorded.status !== 'unrecorded'; + const lockOrOwnershipFailure = Boolean( + ownershipOrLockError.deliveryLockCode + || ownershipOrLockError.deliveryOwnershipCode, + ); + reportArtifactFailure({ + ...options, + command: 'deliver', + receiptId, + status: 1, + error: lockOrOwnershipFailure + ? `Could not safely continue delivery for "${options.output}": ${ownershipOrLockError.message}` + : options.error, + ...(failureWasRecorded ? { provenance: recorded.status } : {}), + diagnostics: lockOrOwnershipFailure + ? [ + deliveryLockFailureDiagnostic(options.output, ownershipOrLockError), + ...(options.diagnostics || []), + ...(recorded.diagnostic ? [recorded.diagnostic] : []), + ] + : [ + ...(options.diagnostics || []), + deliveryLockFailureDiagnostic(options.output, ownershipOrLockError), + ...(recorded.diagnostic ? [recorded.diagnostic] : []), + ], + }); + return recorded; + } + const recoveryDiagnosticFirst = Boolean(recorded.journalRecovery || recorded.provenanceRecovery); + const diagnostics = recoveryDiagnosticFirst + ? [ + ...(recorded.diagnostic ? [recorded.diagnostic] : []), + ...(options.diagnostics || []), + ] + : [ + ...(options.diagnostics || []), + ...(recorded.diagnostic ? [recorded.diagnostic] : []), + ]; + reportArtifactFailure({ + ...options, + command: 'deliver', + receiptId, + provenance: recorded.status, + diagnostics, + }); + return recorded; +} + +function reportValidateFailure(options) { + reportArtifactFailure({ ...options, command: 'validate' }); +} + +function reportArtifactArgumentFailure(command, error) { + const details = error.archifyArgument || {}; + reportArtifactFailure({ + command, + json: true, + stage: 'arguments', + error: error.message, + diagnostics: [diagnostic({ + code: details.code || 'cli/invalid-arguments', + message: error.message, + subject: { command, ...(details.subject || {}) }, + evidence: details.evidence || {}, + supportedFixes: details.supportedFixes || ['correct the command arguments and retry'], + })], + status: 2, + }); +} + +function sourceEvidenceFromArtifact(artifact) { + const html = artifact.toString('utf8'); + const match = html.match(/ + +`; +} + +function compactMessage(value) { + let text = String(value || 'Preview build failed without a diagnostic.').trim(); + const lines = text.split(/\r?\n/); + const errorLine = lines.findIndex((line) => /^Error:\s/.test(line)); + if (errorLine > 0) text = lines.slice(errorLine).join('\n'); + const relevant = text.split(/\r?\n/); + const stackLine = relevant.findIndex((line, index) => index > 0 && /^\s*at\s/.test(line)); + if (stackLine > 0) text = relevant.slice(0, stackLine).join('\n'); + return text.length > 6000 ? `${text.slice(0, 6000)}\n… diagnostic truncated` : text; +} + +function redactDiagnostic(value, paths) { + let text = compactMessage(value); + for (const [absolutePath, replacement] of paths) { + if (!absolutePath) continue; + text = text.split(absolutePath).join(replacement); + } + return text; +} + +function safeJson(value) { + return JSON.stringify(value).replace(/ { resolveClosed = resolve; }); + + function publicState() { + return JSON.parse(JSON.stringify(state)); + } + + function sendState(res) { + res.write(`event: state\ndata: ${safeJson(publicState())}\n\n`); + } + + function broadcast() { + for (const res of clients) sendState(res); + } + + const page = Buffer.from(previewPage()); + const server = http.createServer((req, res) => { + const expectedHost = `${loopbackHost}:${port}`; + if (req.headers.host !== expectedHost) { + res.writeHead(403, responseHeaders('text/plain; charset=utf-8')); + res.end('Forbidden host'); + return; + } + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405, { ...responseHeaders('text/plain; charset=utf-8'), Allow: 'GET, HEAD' }); + res.end('Method not allowed'); + return; + } + + let url; + try { + url = new URL(req.url, `http://${expectedHost}`); + } catch { + res.writeHead(400, responseHeaders('text/plain; charset=utf-8')); + res.end('Bad request'); + return; + } + + if (url.pathname === '/') { + res.writeHead(200, { + ...responseHeaders('text/html; charset=utf-8'), + 'Content-Security-Policy': "default-src 'none'; frame-src 'self'; connect-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'", + 'Content-Length': page.byteLength, + }); + if (req.method === 'HEAD') res.end(); + else res.end(page); + return; + } + if (url.pathname === '/state') { + const body = Buffer.from(`${safeJson(publicState())}\n`); + res.writeHead(200, { ...responseHeaders('application/json; charset=utf-8'), 'Content-Length': body.byteLength }); + if (req.method === 'HEAD') res.end(); + else res.end(body); + return; + } + if (url.pathname === '/artifact.html') { + if (!artifactBuffer) { + res.writeHead(404, responseHeaders('text/plain; charset=utf-8')); + res.end('No verified artifact yet'); + return; + } + res.writeHead(200, { ...responseHeaders('text/html; charset=utf-8'), 'Content-Length': artifactBuffer.byteLength }); + if (req.method === 'HEAD') res.end(); + else res.end(artifactBuffer); + return; + } + if (url.pathname === '/events' && req.method === 'GET') { + res.writeHead(200, { + ...responseHeaders('text/event-stream; charset=utf-8'), + Connection: 'keep-alive', + }); + res.write('retry: 1000\n\n'); + clients.add(res); + sendState(res); + req.on('close', () => clients.delete(res)); + return; + } + + res.writeHead(404, responseHeaders('text/plain; charset=utf-8')); + res.end('Not found'); + }); + + server.on('connection', (socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + // A connection event already queued when force-stop begins must not keep + // server.close() waiting after the current sockets have been destroyed. + if (forceStopping) socket.destroy(); + }); + + try { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, loopbackHost, () => { + server.off('error', reject); + port = server.address().port; + resolve(); + }); + }); + } catch (error) { + try { server.close(); } catch {} + cleanupOwnedDirectory(stagingDirectory, stagingIdentity); + throw error; + } + + const url = `http://${loopbackHost}:${port}/`; + + function finishStop() { + if (stopped || child || !serverClosed) return; + stopped = true; + clearTimeout(debounceTimer); + clearInterval(pollTimer); + clearTimeout(stopGraceTimer); + clearTimeout(stopKillTimer); + try { + cleanupOwnedDirectory(stagingDirectory, stagingIdentity); + } finally { + resolveClosed(); + } + } + + function signalActiveChild(signal) { + if (!child || child.exitCode !== null || child.signalCode !== null) return; + try { + if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal); + else child.kill(signal); + } catch (error) { + if (error.code === 'ESRCH') return; + try { child.kill(signal); } catch {} + } + } + + function closeServer() { + if (serverClosing) return; + serverClosing = true; + for (const res of clients) res.end(); + clients.clear(); + server.close(() => { + serverClosed = true; + finishStop(); + }); + server.closeIdleConnections?.(); + } + + function startBoundedChildDrain() { + if (!child || stopGraceTimer || stopKillTimer) return; + stopGraceTimer = setTimeout(() => { + stopGraceTimer = undefined; + if (!child) return finishStop(); + signalActiveChild('SIGTERM'); + stopKillTimer = setTimeout(() => { + stopKillTimer = undefined; + signalActiveChild('SIGKILL'); + }, stopKillMs); + }, stopGraceMs); + } + + async function stop({ force = false } = {}) { + if (force) forceStopping = true; + if (!stopping) { + stopping = true; + clearTimeout(debounceTimer); + clearInterval(pollTimer); + watcher?.close(); + closeServer(); + } + if (forceStopping) { + for (const socket of sockets) socket.destroy(); + } + if (child && force) { + clearTimeout(stopGraceTimer); + clearTimeout(stopKillTimer); + stopGraceTimer = undefined; + stopKillTimer = undefined; + signalActiveChild('SIGKILL'); + } else if (child) { + startBoundedChildDrain(); + } else { + finishStop(); + } + return closed; + } + + function publishFailure(receipt, stdout, stderr, candidatePath, snapshotPath) { + const repairDetails = receipt?.diagnostics + ?.slice(0, 12) + .map((entry) => { + const fix = entry.supportedFixes?.length ? `\nFix: ${entry.supportedFixes.join('; ')}` : ''; + return `[${entry.code}] ${entry.message}${fix}`; + }) || []; + const checkerDetails = receipt?.checker?.checks + ?.filter((check) => !check.ok) + .flatMap((check) => check.details || []) + .filter(Boolean) + .slice(0, 12) || []; + const diagnostic = [ + receipt?.error, + ...(repairDetails.length ? repairDetails : checkerDetails), + ].filter(Boolean).join('\n') || stderr || stdout; + state.status = 'needs-fix'; + state.failure = { + stage: receipt?.stage || 'render', + ...(receipt?.code ? { code: receipt.code } : {}), + ...(receipt?.evidence ? { evidence: receipt.evidence } : {}), + message: redactDiagnostic( + diagnostic, + [ + [inputPath, ''], + [outputPath, ''], + [snapshotPath, ''], + [candidatePath, ''], + [stagingDirectory, ''], + [path.resolve(here, '..'), ''], + [path.resolve(options.cwd || process.cwd()), ''], + ...(options.repoRoot ? [[path.resolve(options.repoRoot), '']] : []), + ], + ), + }; + broadcast(); + } + + function commitCandidate(candidatePath, receipt, generationHash, outputCapture) { + let candidate; + let sourceCandidateBinding; + let sourceCandidateIdentity; + let commitCandidatePath; + let commitCandidateIdentity; + let commitCandidateBinding; + try { + const sourceCapture = captureRegularFileBinding(candidatePath, { + subject: 'candidate', + expectedSha256: receipt?.artifact?.sha256, + expectedBytes: receipt?.artifact?.bytes, + includeContent: true, + }); + if (sourceCapture.status !== 'captured') throw atomicOutputError(sourceCapture); + sourceCandidateBinding = sourceCapture.binding; + sourceCandidateIdentity = sourceCapture.identity; + candidate = sourceCapture.content.buffer; + const digest = sourceCapture.content.sha256; + const releasedSource = releaseRegularFileBinding(sourceCandidateBinding); + sourceCandidateBinding = undefined; + if (releasedSource.status !== 'released') throw atomicOutputError(releasedSource); + resolveOutputPath(outputRequest); + const sameArtifact = state.lastVerified?.sha256 === digest; + const currentSource = sourceDigest(inputPath); + if (currentSource.hash !== generationHash) { + return { + committed: false, + supersededBy: currentSource, + candidateIdentity: sourceCandidateIdentity, + }; + } + const beforeStage = verifyAtomicOutput(outputCapture.snapshot); + if (beforeStage.status !== 'match') throw atomicOutputError(beforeStage); + ({ + candidatePath: commitCandidatePath, + identity: commitCandidateIdentity, + } = stagePreviewCommit(outputCapture.commitPath, candidate, outputCapture.mode)); + const candidateCapture = captureRegularFileBinding(commitCandidatePath, { + subject: 'candidate', + expectedSha256: digest, + expectedBytes: candidate.byteLength, + expectedIdentity: commitCandidateIdentity, + ...(outputCapture.mode === null ? {} : { expectedMode: outputCapture.mode }), + }); + if (candidateCapture.status !== 'captured') throw atomicOutputError(candidateCapture); + commitCandidateBinding = candidateCapture.binding; + const beforeCommit = verifyAtomicOutput(outputCapture.snapshot); + if (beforeCommit.status !== 'match') throw atomicOutputError(beforeCommit); + const publication = publishRegularFileBinding( + commitCandidateBinding, + commitCandidatePath, + outputCapture.snapshot, + { subject: 'candidate' }, + ); + if (!['committed', 'committed-with-warning'].includes(publication.status)) { + throw atomicOutputError(publication); + } + const releasedCandidate = releaseRegularFileBinding(commitCandidateBinding); + commitCandidateBinding = undefined; + if (releasedCandidate.status !== 'released') throw atomicOutputError(releasedCandidate); + commitCandidatePath = undefined; + commitCandidateIdentity = undefined; + artifactBuffer = candidate; + lastGoodSourceHash = generationHash; + state.status = 'verified'; + if (!sameArtifact) { + state.revision += 1; + state.lastVerified = { + sha256: digest, + bytes: candidate.byteLength, + checksPassed: receipt.validation.checksPassed, + checkCount: receipt.validation.checkCount, + compositionProfile: receipt.validation.compositionProfile, + compositionStatus: receipt.validation.compositionStatus, + }; + } + state.failure = null; + broadcast(); + return { committed: true, supersededBy: null, candidateIdentity: sourceCandidateIdentity }; + } catch (error) { + publishFailure({ + stage: 'commit', + error: `Could not publish the verified preview: ${error.message}`, + ...(error.previewFailure || {}), + }, '', '', candidatePath); + return { committed: false, supersededBy: null, candidateIdentity: sourceCandidateIdentity }; + } finally { + if (sourceCandidateBinding) releaseRegularFileBinding(sourceCandidateBinding); + if (commitCandidateBinding) releaseRegularFileBinding(commitCandidateBinding); + if (commitCandidatePath && commitCandidateIdentity) { + removeOwnedRegularFile(commitCandidatePath, commitCandidateIdentity); + } + } + } + + function beginBuild(digest, epoch) { + if (stopping || child) return; + activeHash = digest.hash; + activeEpoch = epoch; + state.generation += 1; + state.status = 'checking'; + state.failure = null; + broadcast(); + + const candidatePath = path.join(stagingDirectory, `generation-${state.generation}.html`); + const snapshotPath = path.join(stagingDirectory, `generation-${state.generation}.json`); + let snapshotIdentity; + const outputCapture = captureAtomicOutput(outputPath); + if (outputCapture.status !== 'captured') { + const failure = atomicOutputFailure(outputCapture); + publishFailure( + { stage: 'prepare', error: failure.message, ...failure }, + '', + '', + candidatePath, + snapshotPath, + ); + return; + } + const beforeBuild = verifyAtomicOutput(outputCapture.snapshot); + if (beforeBuild.status !== 'match') { + const failure = atomicOutputFailure(beforeBuild); + publishFailure( + { stage: 'prepare', error: failure.message, ...failure }, + '', + '', + candidatePath, + snapshotPath, + ); + return; + } + if (digest.bytes !== null) { + try { + fs.writeFileSync(snapshotPath, digest.bytes, { flag: 'wx', mode: 0o600 }); + const metadata = fs.lstatSync(snapshotPath, { bigint: true }); + if (metadata.isFile() && metadata.ino !== 0n) { + snapshotIdentity = { device: metadata.dev, inode: metadata.ino }; + } + } catch (error) { + publishFailure( + { stage: 'prepare', error: `Could not snapshot the observed input: ${error.message}` }, + '', + '', + candidatePath, + snapshotPath, + ); + return; + } + } + const args = [options.deliveryCli || cliPath, 'deliver', type, snapshotPath, candidatePath, '--json']; + if (options.quality) args.push('--quality', options.quality); + if (options.repoRoot) args.push('--repo-root', path.resolve(options.repoRoot)); + let stdout = ''; + let stderr = ''; + child = spawn(process.execPath, args, { + cwd: options.cwd || process.cwd(), + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + detached: process.platform !== 'win32', + }); + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('error', (error) => { stderr += error.message; }); + child.on('close', (code) => { + const receipt = parseReceipt(stdout); + const generationEpoch = activeEpoch; + const generationHash = activeHash; + const stale = generationEpoch !== sourceEpoch; + let supersededBy = null; + let candidateIdentity; + const deliverySidecars = captureOwnedDeliverySidecars( + candidatePath, + snapshotPath, + receipt, + ); + child = null; + clearTimeout(stopGraceTimer); + clearTimeout(stopKillTimer); + stopGraceTimer = undefined; + stopKillTimer = undefined; + if (!stopping && !stale && code === 0 && receipt?.ok) { + ({ supersededBy, candidateIdentity } = commitCandidate( + candidatePath, + receipt, + generationHash, + outputCapture, + )); + } else if (code === 0 && receipt?.ok) { + const abandonedCandidate = captureRegularFileBinding(candidatePath, { + subject: 'abandoned-preview-candidate', + expectedSha256: receipt.artifact?.sha256, + expectedBytes: receipt.artifact?.bytes, + }); + if (abandonedCandidate.status === 'captured') { + candidateIdentity = abandonedCandidate.identity; + releaseRegularFileBinding(abandonedCandidate.binding); + } + } else if (!stopping && !stale) { + publishFailure(receipt, stdout, stderr, candidatePath, snapshotPath); + } + if (candidateIdentity) removeOwnedRegularFile(candidatePath, candidateIdentity); + if (snapshotIdentity) removeOwnedRegularFile(snapshotPath, snapshotIdentity, { subject: 'snapshot' }); + for (const deliverySidecar of deliverySidecars) { + removeOwnedRegularFile(deliverySidecar.path, deliverySidecar.identity, { + subject: 'preview-delivery-sidecar', + }); + } + + if (stopping) { + finishStop(); + } else if (pendingBuild || stale || supersededBy) { + pendingBuild = false; + if (supersededBy && sourceEpoch === generationEpoch) sourceEpoch += 1; + const digest = supersededBy || sourceDigest(inputPath); + queueStableBuild(digest.hash, true); + } + }); + } + + function queueStableBuild(hash, immediate = false) { + queuedHash = hash; + clearTimeout(debounceTimer); + const launch = () => { + if (stopping) return; + const digest = sourceDigest(inputPath); + if (digest.hash !== queuedHash) { + queueStableBuild(digest.hash); + return; + } + if (digest.hash === lastGoodSourceHash) { + if (state.status !== 'verified' && state.lastVerified) { + state.status = 'verified'; + state.failure = null; + broadcast(); + } + return; + } + if (child) { + pendingBuild = true; + return; + } + beginBuild(digest, sourceEpoch); + }; + debounceTimer = setTimeout(launch, immediate ? 0 : debounceMs); + } + + function observeSource({ immediate = false } = {}) { + const digest = sourceDigest(inputPath); + if (!immediate && digest.hash === queuedHash) return; + sourceEpoch += 1; + queueStableBuild(digest.hash, immediate); + } + + if (options.watch !== false) { + try { + // `path.resolve` keeps Windows 8.3 names intact. Canonicalize short names + // and junctions before libuv opens the directory so its callback path has + // the same prefix as the watched path. + const watchedDirectory = fs.realpathSync.native(path.dirname(inputPath)); + watcher = fs.watch(watchedDirectory, (event, filename) => { + // On Windows the watcher hands us just the basename; on POSIX it can be + // null. Resolve named events against the directory libuv actually opened + // so file-level case, 8.3, and hard-link aliases still identify the input. + if ( + !filename + || sameLocation(path.join(watchedDirectory, filename.toString()), inputPath).status === 'match' + ) { + observeSource(); + } + }); + watcher.on('error', () => { + const failedWatcher = watcher; + watcher = undefined; + failedWatcher?.close(); + }); + } catch (error) { + await stop(); + throw new Error(`Could not watch the input directory: ${error.message}`); + } + } + pollTimer = setInterval(() => observeSource(), pollMs); + + let opener = null; + if (shouldOpen) { + try { + opener = openLoopbackUrl(url); + } catch { + opener = { requested: true, status: 'unsupported', target: url, method: null }; + } + } + + observeSource({ immediate: true }); + + return { + url, + input: inputPath, + output: outputPath, + opener, + state: publicState, + stop, + closed, + }; +} + +export async function runPreview(options) { + const preview = await startPreview(options); + console.log(`preview ${preview.url}`); + console.log(`watching ${preview.input}`); + console.log(`output ${preview.output}`); + if (preview.opener && preview.opener.status !== 'opened') { + console.error(`Could not open the preview (${preview.opener.status}). ${preview.opener.failure?.reason || 'Open it manually.'} Target: ${preview.url}`); + } + + let signalCount = 0; + const stop = () => { + signalCount += 1; + if (signalCount === 1) { + console.log('\nstopping preview…'); + preview.stop(); + } else { + console.log('\nforcing preview shutdown…'); + preview.stop({ force: true }); + } + }; + process.on('SIGINT', stop); + process.on('SIGTERM', stop); + await preview.closed; + process.off('SIGINT', stop); + process.off('SIGTERM', stop); +} diff --git a/scripts/vendor/archify/bin/recover-output.mjs b/scripts/vendor/archify/bin/recover-output.mjs new file mode 100644 index 0000000..9b84261 --- /dev/null +++ b/scripts/vendor/archify/bin/recover-output.mjs @@ -0,0 +1,41 @@ +import { recoverRetiredPublication } from '../renderers/shared/atomic-output.mjs'; + +function usage(stream = process.stderr) { + stream.write('Usage: node bin/recover-output.mjs [--json]\n'); +} + +const args = process.argv.slice(2); +if (args.length === 1 && ['--help', '-h'].includes(args[0])) { + usage(process.stdout); +} else { + let json = false; + let recoveryDirectory; + let invalid = false; + for (const argument of args) { + if (argument === '--json' && !json) { + json = true; + } else if (argument.startsWith('-') || recoveryDirectory !== undefined) { + invalid = true; + } else { + recoveryDirectory = argument; + } + } + + if (invalid || recoveryDirectory === undefined) { + usage(); + process.exitCode = 64; + } else { + const result = recoverRetiredPublication(recoveryDirectory); + if (json) { + process.stdout.write(`${JSON.stringify(result)}\n`); + } else { + const reason = result.reason?.code || 'publication-recovery-unknown'; + process.stdout.write(`${result.status}: ${reason}\n`); + } + // A complete prior recovery is idempotent. A preserved target is a safe, + // deliberate non-action that needs the operator to inspect the claimant. + process.exitCode = ['recovered', 'absent'].includes(result.status) + ? 0 + : result.status === 'preserved' ? 2 : 1; + } +} diff --git a/scripts/vendor/archify/bin/visual-check.mjs b/scripts/vendor/archify/bin/visual-check.mjs new file mode 100644 index 0000000..277505a --- /dev/null +++ b/scripts/vendor/archify/bin/visual-check.mjs @@ -0,0 +1,2667 @@ +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + DESKTOP_READABILITY_VIEWPORT, + MIN_PROJECTED_NODE_TEXT_PX, +} from '../renderers/shared/desktop-readability.mjs'; +import { + backupPublicRegularFileBinding, + captureAtomicOutput, + captureRegularFileBinding, + quarantineRemoveLinkedRegularFileAlias, + quarantineRemoveRegularFileBinding, + releaseRegularFileBinding, + removeEmptyDirectoryWithRetry, + verifyAtomicOutput, + verifyRegularFileBinding, +} from '../renderers/shared/atomic-output.mjs'; +import { + sameEntry, + sameLocation, + sameParent, + sidecarNamespaceComponentKey, +} from '../renderers/shared/path-semantics.mjs'; +import { + boundedSidecarStem, + sidecarStemFromComponent, +} from '../renderers/shared/sidecar-path.mjs'; + +export const VISUAL_CHECK_VIEWPORTS = Object.freeze([ + DESKTOP_READABILITY_VIEWPORT, + Object.freeze({ width: 1600, height: 1000 }), + Object.freeze({ width: 1920, height: 1080 }), + Object.freeze({ width: 2048, height: 1320 }), +]); + +export const CAPTURE_VIEWPORTS = Object.freeze([ + VISUAL_CHECK_VIEWPORTS[0], + VISUAL_CHECK_VIEWPORTS[VISUAL_CHECK_VIEWPORTS.length - 1], +]); +export const THEMES = Object.freeze(['light', 'dark']); +const EXIT = Object.freeze({ pass: 0, fail: 1, skipped: 2 }); +export const CHROME_NO_SANDBOX_ENV = 'ARCHIFY_CHROME_NO_SANDBOX'; +const VISUAL_SIDECAR_SUFFIXES = Object.freeze([ + '.visual-check.json', + '.visual-check.html', + ...CAPTURE_VIEWPORTS.flatMap(({ width, height }) => THEMES.map( + (theme) => `.visual-check.${width}x${height}.${theme}.png`, + )), +]); +const visualEvidenceOwnerships = new WeakSet(); +export const CHROME_STARTUP_TIMEOUT_MS = 90000; + +function sha256(buffer) { + return createHash('sha256').update(buffer).digest('hex'); +} + +function htmlEscape(value) { + return String(value).replace(/[&<>"']/g, (char) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + })[char]); +} + +function pathEntry(file) { + try { + return { exists: true, stat: fs.lstatSync(file) }; + } catch (error) { + if (error?.code === 'ENOENT') return { exists: false }; + return { exists: null, error }; + } +} + +function visualEvidencePaths(outputs) { + return outputs.capture === false ? [] : [outputs.contactSheet, ...outputs.screenshots.map((entry) => entry.path)]; +} + +function regularFileEvidence(file, { expectedLinks = 1 } = {}) { + const captured = captureRegularFileBinding(file, { + subject: 'evidence-file', + expectedLinks, + }); + if (captured.status !== 'captured') { + const systemCode = captured.reason?.systemCode; + return { + ok: false, + reason: systemCode === 'ENOENT' + ? { code: 'missing-evidence-file' } + : captured.reason?.code === 'evidence-file-not-regular-file' + ? { code: 'evidence-path-not-regular-file' } + : { + code: 'evidence-file-unreadable', + ...(systemCode ? { systemCode } : {}), + ...(captured.reason?.code ? { bindingCode: captured.reason.code } : {}), + }, + }; + } + const released = releaseRegularFileBinding(captured.binding); + if (released.status !== 'released') { + return { + ok: false, + reason: { + code: 'evidence-file-unreadable', + ...(released.reason?.systemCode ? { systemCode: released.reason.systemCode } : {}), + ...(released.reason?.code ? { bindingCode: released.reason.code } : {}), + }, + }; + } + return { + ok: true, + evidence: { + file: path.basename(file), + sha256: captured.content.sha256, + bytes: captured.content.bytes, + }, + identity: captured.identity, + }; +} + +function readRegularFileContents(file, { subject = 'file' } = {}) { + const captured = captureRegularFileBinding(file, { + subject, + expectedLinks: 1, + includeContent: true, + }); + if (captured.status !== 'captured') return { ok: false, reason: captured.reason }; + const released = releaseRegularFileBinding(captured.binding); + if (released.status !== 'released') return { ok: false, reason: released.reason }; + return { + ok: true, + bytes: captured.content.buffer, + evidence: { + sha256: captured.content.sha256, + bytes: captured.content.bytes, + }, + identity: captured.identity, + }; +} + +function regularFileCaptureError(label, captured) { + const reason = captured?.reason || { code: 'regular-file-capture-failed' }; + const error = new Error(`${label} could not be read safely (${reason.code}).`); + if (reason.systemCode) error.code = reason.systemCode; + error.evidenceReason = reason; + return error; +} + +function screenshotKey(width, height, theme) { + return `${width}x${height}:${theme}`; +} + +function resolvedSidecarNamespace(artifact, component) { + const namespace = sidecarNamespaceComponentKey(path.dirname(artifact), component); + if (namespace.status === 'resolved') return namespace; + const error = new Error( + `Could not establish a stable sidecar namespace (${namespace.reason?.code || 'unknown'}).`, + ); + error.code = 'ARCHIFY_SIDECAR_NAMESPACE_INDETERMINATE'; + error.sidecarNamespaceReason = namespace.reason; + throw error; +} + +function portableSidecarComponentKey(component) { + return component + .normalize('NFC') + .toLocaleUpperCase('en-US') + .toLocaleLowerCase('en-US') + .normalize('NFC'); +} + +function targetSidecarNamespace(directory, component) { + const namespace = sidecarNamespaceComponentKey(directory, component); + if (namespace.status === 'resolved') return namespace.componentKey; + if (namespace.reason?.code === 'sidecar-directory-missing') { + // A pure path calculation must not create --out-dir. Use the portable + // collision envelope until the directory exists and can be probed. + return portableSidecarComponentKey(component); + } + const error = new Error( + `Could not establish target sidecar semantics (${namespace.reason?.code || 'unknown'}).`, + ); + error.code = 'ARCHIFY_SIDECAR_NAMESPACE_INDETERMINATE'; + error.sidecarNamespaceReason = namespace.reason; + throw error; +} + +function evidenceSidecarPaths(artifactPath, { outDir, command = 'visual-check', capture = true } = {}) { + let artifact = path.resolve(artifactPath); + try { + // Existing aliases, including Windows 8.3 spellings, must share the + // physical artifact's evidence namespace even when the stem is short. + artifact = fs.realpathSync.native(artifact); + } catch (error) { + if (!['ENOENT', 'ENOTDIR', 'ENAMETOOLONG'].includes(error?.code)) throw error; + // Missing artifacts still need deterministic failure sidecar paths. + } + const namespace = resolvedSidecarNamespace(artifact, path.basename(artifact)); + artifact = path.join(namespace.directoryPath, path.basename(artifact)); + const namespaceStem = sidecarStemFromComponent(namespace.componentKey); + const directory = outDir ? path.resolve(outDir) : path.dirname(artifact); + const targetComponentKey = outDir + ? targetSidecarNamespace(directory, namespace.componentKey) + : namespace.componentKey; + const portableTargetKey = outDir + ? portableSidecarComponentKey(namespace.componentKey) + : namespace.componentKey; + const targetAliasesSourceSpelling = targetComponentKey !== namespace.componentKey + || portableTargetKey !== namespace.componentKey; + const stem = boundedSidecarStem( + namespaceStem.stem, + capture ? VISUAL_SIDECAR_SUFFIXES : ['.browser-check.json'], + targetAliasesSourceSpelling ? { + force: true, + hashDomain: `${command}-artifact\0${artifact}\0target-component\0${portableTargetKey}`, + } : namespaceStem.options, + ); + const base = path.join(directory, `${stem}.${command}`); + const screenshots = (capture ? CAPTURE_VIEWPORTS : []).flatMap(({ width, height }) => THEMES.map((theme) => ({ + width, + height, + theme, + path: `${base}.${width}x${height}.${theme}.png`, + }))); + return { + base, command, capture, + receipt: `${base}.json`, + contactSheet: `${base}.html`, + screenshots, + }; +} + +export function sidecarPaths(artifactPath, options = {}) { + return evidenceSidecarPaths(artifactPath, options); +} + +export function browserCheckSidecarPaths(artifactPath, options = {}) { + return evidenceSidecarPaths(artifactPath, { ...options, command: 'browser-check', capture: false }); +} + +function evidencePathConflict(artifactPath, outputs, file, reason) { + const target = file || outputs.receipt; + const error = `${outputs.command} will not replace the existing unowned evidence path "${target}".`; + return { + ok: false, + error, + diagnostic: failureDiagnostic({ + code: 'viewer/evidence-path-conflict', + message: error, + subject: { artifact: path.resolve(artifactPath), evidencePath: target }, + evidence: { reason }, + supportedFixes: [ + `move the conflicting file aside, or choose a separate --out-dir, then rerun ${outputs.command}`, + ], + }), + }; +} + +function visualEvidenceOwnershipMatches(ownership, artifactPath, outputs) { + return visualEvidenceOwnerships.has(ownership) + && ownership.active + && sameEntry(ownership.artifact, artifactPath).status === 'match' + && sameLocation(ownership.receipt, outputs.receipt).status === 'match'; +} + +function visualEvidenceTargets(outputs) { + return [ + { kind: 'receipt', path: outputs.receipt }, + ...(outputs.capture === false ? [] : [{ kind: 'contact-sheet', path: outputs.contactSheet }]), + ...outputs.screenshots.map((entry, index) => ({ + kind: 'screenshot', + index, + path: entry.path, + })), + ]; +} + +function evidenceWriteFailure(artifactPath, message, errors) { + return { + ok: false, + error: message, + diagnostic: failureDiagnostic({ + code: 'viewer/evidence-write', + message, + subject: { artifact: path.resolve(artifactPath) }, + evidence: { errors }, + supportedFixes: ['restore write access to the evidence directory and rerun visual-check'], + }), + }; +} + +function fileIdentity(stat) { + return { + device: stat.dev, + inode: stat.ino, + mode: Number(stat.mode & 0o777n), + links: stat.nlink, + }; +} + +function identityMatches(stat, identity, { directory = false } = {}) { + return stat.dev === identity.device + && stat.ino === identity.inode + && Number(stat.mode & 0o777n) === identity.mode + && (directory ? stat.isDirectory() : stat.isFile()) + && !stat.isSymbolicLink(); +} + +function evidenceIdentityMatches(actual, expected, expectedLinks = expected?.links ?? 1) { + return actual?.device === expected?.device + && actual?.inode === expected?.inode + && actual?.mode === expected?.mode + && BigInt(actual?.links ?? 0) === BigInt(expectedLinks); +} + +function stagedOutputs(outputs, stagingDirectory) { + return { + receipt: path.join(stagingDirectory, 'receipt.json'), + contactSheet: path.join(stagingDirectory, 'contact-sheet.html'), + screenshots: outputs.screenshots.map((entry, index) => ({ + ...entry, + path: path.join(stagingDirectory, `capture-${index}.png`), + })), + }; +} + +function stagedPathFor(ownership, finalPath) { + return ownership.stagingPaths.get(finalPath); +} + +function registerStagedEvidence(ownership, finalPath) { + const stagedPath = stagedPathFor(ownership, finalPath); + let stat; + try { + stat = fs.lstatSync(stagedPath, { bigint: true }); + } catch (error) { + throw new Error(`visual-check could not inspect staged evidence "${stagedPath}": ${error.message}`); + } + if (!stat.isFile() || stat.isSymbolicLink() || stat.ino === 0n) { + throw new Error(`visual-check staged evidence is not a uniquely owned regular file: "${stagedPath}".`); + } + if (stat.nlink !== 1n) { + ownership.stagedEntries.set(finalPath, { + path: stagedPath, + identity: fileIdentity(stat), + }); + throw new Error(`visual-check staged evidence has an external hard link: "${stagedPath}".`); + } + const targetMode = ownership.targets.get(finalPath)?.mode; + if (targetMode !== null && targetMode !== undefined + && Number(stat.mode & 0o777n) !== targetMode) { + fs.chmodSync(stagedPath, targetMode); + const adjusted = fs.lstatSync(stagedPath, { bigint: true }); + if (adjusted.dev !== stat.dev || adjusted.ino !== stat.ino || !adjusted.isFile()) { + throw new Error(`visual-check staged evidence changed while preserving its mode: "${stagedPath}".`); + } + stat = adjusted; + } + const entry = { + path: stagedPath, + identity: fileIdentity(stat), + }; + ownership.stagedEntries.set(finalPath, entry); + const evidence = regularFileEvidence(stagedPath); + if (!evidence.ok || !evidenceIdentityMatches(evidence.identity, entry.identity)) { + throw new Error(`visual-check could not bind staged evidence "${stagedPath}" to its contents.`); + } + entry.evidence = { + sha256: evidence.evidence.sha256, + bytes: evidence.evidence.bytes, + }; +} + +function writeStagedEvidence(ownership, finalPath, contents) { + const stagedPath = stagedPathFor(ownership, finalPath); + const target = ownership.targets.get(finalPath); + const mode = target?.mode ?? 0o666; + let descriptor; + try { + descriptor = fs.openSync(stagedPath, 'wx', mode); + let stat = fs.fstatSync(descriptor, { bigint: true }); + if (!stat.isFile() || stat.ino === 0n) { + throw new Error('the newly created staging entry has no unique regular-file identity'); + } + if (target?.mode !== null && target?.mode !== undefined + && Number(stat.mode & 0o777n) !== target.mode) { + fs.fchmodSync(descriptor, target.mode); + const adjusted = fs.fstatSync(descriptor, { bigint: true }); + if (adjusted.dev !== stat.dev || adjusted.ino !== stat.ino || !adjusted.isFile() + || Number(adjusted.mode & 0o777n) !== target.mode) { + throw new Error('the staged evidence mode could not be preserved'); + } + stat = adjusted; + } + const entry = { + path: stagedPath, + identity: fileIdentity(stat), + }; + ownership.stagedEntries.set(finalPath, entry); + if (stat.nlink !== 1n) { + throw new Error('the newly created staging entry has an external hard link'); + } + fs.writeFileSync(descriptor, contents); + const bytes = Buffer.isBuffer(contents) ? contents : Buffer.from(contents); + entry.evidence = { sha256: sha256(bytes), bytes: bytes.byteLength }; + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +function unlinkOwnedEntry(file, identity, { + evidence, + expectedLinks, +} = {}) { + const verifiedLinks = Number(expectedLinks ?? identity.links ?? 1); + let captured = captureRegularFileBinding(file, { + subject: 'visual-cleanup', + expectedIdentity: identity, + expectedMode: identity.mode, + expectedLinks: verifiedLinks, + ...(evidence ? { + expectedSha256: evidence.sha256, + expectedBytes: evidence.bytes, + } : {}), + }); + const reportedLinks = Number(captured.reason?.links); + if (captured.status === 'unsupported' + && captured.reason?.code === 'visual-cleanup-hardlinked' + && Number.isSafeInteger(reportedLinks) + && reportedLinks > verifiedLinks) { + captured = captureRegularFileBinding(file, { + subject: 'visual-cleanup', + expectedIdentity: identity, + expectedMode: identity.mode, + expectedLinks: reportedLinks, + ...(evidence ? { + expectedSha256: evidence.sha256, + expectedBytes: evidence.bytes, + } : {}), + }); + } + if (captured.status !== 'captured') { + if (captured.reason?.systemCode === 'ENOENT') return { status: 'absent' }; + return { + status: captured.status === 'different' || captured.status === 'unsupported' + ? 'different' + : 'unknown', + reason: captured.reason?.code?.includes('content-changed') ? 'content' : 'identity', + error: new Error(captured.reason?.code || 'visual cleanup binding failed'), + bindingState: captured.reason, + }; + } + let removal; + let released; + try { + removal = quarantineRemoveRegularFileBinding(captured.binding, file, { + subject: 'visual-cleanup', + expectedLinks: captured.identity?.links ? Number(captured.identity.links) : verifiedLinks, + }); + } finally { + released = releaseRegularFileBinding(captured.binding); + } + if (released.status !== 'released') { + return { + status: 'unknown', + reason: 'identity', + error: new Error(released.reason?.code || 'visual cleanup binding release failed'), + bindingState: released.reason, + }; + } + if (removal.status === 'removed' || removal.status === 'absent') return removal; + return { + status: removal.status === 'preserved' || removal.status === 'different' + || removal.status === 'unsupported' ? 'different' : 'unknown', + reason: 'identity', + error: new Error(removal.reason?.code || 'visual cleanup quarantine failed'), + bindingState: removal.reason, + ...(removal.recoveryDirectory ? { + recoveryDirectory: removal.recoveryDirectory, + recoveryFile: removal.recoveryFile, + } : {}), + }; +} + +function backupOwnedPublishedEntry(file, backupPath, identity, evidence) { + const captured = captureRegularFileBinding(file, { + subject: 'previous-evidence', + expectedIdentity: identity, + expectedMode: identity.mode, + expectedSha256: evidence.sha256, + expectedBytes: evidence.bytes, + expectedLinks: 1, + }); + if (captured.status !== 'captured') { + return { ...captured, backupCreated: false, backupVerified: false }; + } + let backup; + let released; + try { + backup = backupPublicRegularFileBinding(captured.binding, file, backupPath, { + subject: 'previous-evidence', + }); + } finally { + released = releaseRegularFileBinding(captured.binding); + } + if (released.status !== 'released') { + return { + status: 'recovery-required', + reason: { + code: 'previous-evidence-binding-release-failed', + bindingState: released.reason, + }, + recoveryDirectory: backup?.recoveryDirectory || path.dirname(backupPath), + recoveryFile: backup?.recoveryFile || (backup?.backupCreated ? backupPath : undefined), + target: backup?.target || file, + backupCreated: backup?.backupCreated === true, + backupVerified: backup?.backupVerified === true, + }; + } + return backup; +} + +function publishedRemovalError(file, result) { + const code = result.reason?.code; + let reason; + if (result.status === 'preserved') { + reason = 'published entry changed during retirement; its successor was restored and preserved'; + } else if (result.status === 'recovery-required') { + reason = 'published entry changed during retirement; recovery material was preserved'; + } else if (code?.includes('content-changed')) { + reason = 'published entry content changed; it was preserved'; + } else if (code?.includes('mode-changed')) { + reason = 'published entry mode changed; it was preserved'; + } else if (result.status === 'different' || result.status === 'unsupported') { + reason = 'published entry identity changed; it was preserved'; + } else { + reason = code || 'published entry could not be safely retired; it was preserved'; + } + return { + file, + reason, + ...(code ? { code } : {}), + ...(result.recoveryDirectory ? { recoveryDirectory: result.recoveryDirectory } : {}), + ...(result.recoveryFile ? { recoveryFile: result.recoveryFile } : {}), + ...(result.target ? { target: result.target } : {}), + }; +} + +function cleanupStagedEvidence(ownership, { removeDirectory = false } = {}) { + const errors = []; + for (const [finalPath, entry] of [...ownership.stagedEntries]) { + const result = unlinkOwnedEntry(entry.path, entry.identity, { evidence: entry.evidence }); + if (result.status === 'removed' || result.status === 'absent') { + ownership.stagedEntries.delete(finalPath); + } else { + errors.push({ + file: entry.path, + reason: result.status === 'different' && result.reason === 'content' + ? 'staging entry content changed; it was preserved' + : result.status === 'different' + ? 'staging entry identity changed; it was preserved' + : result.error?.message, + }); + } + } + const directories = [ + ownership.inspection, + ...(removeDirectory ? [{ + directory: ownership.stagingDirectory, + identity: ownership.stagingIdentity, + }] : []), + ].filter(Boolean); + for (const { directory, identity } of directories) { + try { + const stat = fs.lstatSync(directory, { bigint: true }); + if (!identity || !identityMatches(stat, identity, { directory: true })) { + errors.push({ + file: directory, + recoveryDirectory: directory, + reason: 'staging directory identity changed; it was preserved', + }); + } else { + removeEmptyDirectoryWithRetry(directory); + } + } catch (error) { + if (error?.code !== 'ENOENT') { + errors.push({ file: directory, recoveryDirectory: directory, reason: error.message }); + } + } + } + return errors; +} + +function verifyVisualEvidenceTargets(artifactPath, outputs, ownership) { + for (const target of visualEvidenceTargets(outputs)) { + const captured = ownership.targets.get(target.path); + const verification = captured ? verifyAtomicOutput(captured.snapshot) : { + status: 'unknown', + reason: { code: 'evidence-snapshot-missing' }, + }; + if (verification.status !== 'match') { + return evidencePathConflict(artifactPath, outputs, target.path, verification.reason); + } + } + return { ok: true }; +} + +function beginVisualEvidenceWrite(artifactPath, artifactBytes, outputs) { + const targets = visualEvidenceTargets(outputs); + const candidates = targets.map((target) => target.path); + const previousEvidence = new Map(); + try { + fs.mkdirSync(path.dirname(outputs.receipt), { recursive: true }); + } catch (error) { + return evidenceWriteFailure( + artifactPath, + 'visual-check could not create its evidence directory.', + [{ file: path.dirname(outputs.receipt), reason: error.message }], + ); + } + + const capturedTargets = new Map(); + for (const target of targets) { + const captured = captureAtomicOutput(target.path, { + requestedEntryPolicy: 'regular-or-absent', + }); + if (captured.status !== 'captured') { + return evidencePathConflict(artifactPath, outputs, target.path, captured.reason); + } + capturedTargets.set(target.path, captured); + } + + const parent = capturedTargets.get(outputs.receipt).snapshot.slot; + for (const target of targets.slice(1)) { + const slot = capturedTargets.get(target.path).snapshot.slot; + if (slot.parentDevice !== parent.parentDevice || slot.parentInode !== parent.parentInode) { + return evidencePathConflict(artifactPath, outputs, target.path, { + code: 'evidence-parent-mismatch', + }); + } + } + + const existing = candidates.filter((file) => ( + capturedTargets.get(file).snapshot.requestedEntry.kind === 'existing' + )); + if (existing.length) { + const receiptEntry = capturedTargets.get(outputs.receipt).snapshot.requestedEntry; + if (receiptEntry.kind !== 'existing') { + return evidencePathConflict(artifactPath, outputs, existing[0], { + code: 'ownership-receipt-missing', + }); + } + + const previousReceipt = readRegularFileContents(outputs.receipt, { + subject: 'ownership-receipt', + }); + const capturedReceipt = capturedTargets.get(outputs.receipt).snapshot.target; + if (!previousReceipt.ok || !evidenceIdentityMatches(previousReceipt.identity, { + device: capturedReceipt.device, + inode: capturedReceipt.inode, + mode: capturedReceipt.mode, + links: 1, + })) { + return evidencePathConflict(artifactPath, outputs, outputs.receipt, { + code: 'ownership-receipt-invalid', + ...(previousReceipt.reason?.code ? { bindingCode: previousReceipt.reason.code } : {}), + ...(previousReceipt.reason?.systemCode + ? { systemCode: previousReceipt.reason.systemCode } : {}), + }); + } + const previousBytes = previousReceipt.bytes; + let previous; + try { + previous = JSON.parse(previousBytes.toString('utf8')); + } catch (error) { + return evidencePathConflict(artifactPath, outputs, outputs.receipt, { + code: 'ownership-receipt-invalid', + ...(error?.code ? { systemCode: error.code } : {}), + }); + } + previousEvidence.set(outputs.receipt, { + ...previousReceipt.evidence, + }); + + if (previous?.schemaVersion !== 1 || previous?.command !== outputs.command) { + return evidencePathConflict(artifactPath, outputs, outputs.receipt, { + code: 'ownership-receipt-schema-mismatch', + }); + } + const recordedReceiptName = previous?.sidecars?.receipt; + const recordedContactName = previous?.sidecars?.contactSheet; + const recordedDirectory = previous?.sidecars?.directory || path.dirname(previous?.artifact?.path || ''); + const receiptNameSafe = typeof recordedReceiptName === 'string' + // path-contract-allow: portable-logical-path -- receipt filenames are serialized logical basenames. + && path.basename(recordedReceiptName) === recordedReceiptName; + const contactNameSafe = outputs.capture === false || typeof recordedContactName === 'string' + // path-contract-allow: portable-logical-path -- contact-sheet filenames are serialized logical basenames. + && path.basename(recordedContactName) === recordedContactName; + if (!path.isAbsolute(recordedDirectory) || !receiptNameSafe || !contactNameSafe + || sameLocation(path.join(recordedDirectory, recordedReceiptName), outputs.receipt).status !== 'match' + || (outputs.capture !== false && sameLocation(path.join(recordedDirectory, recordedContactName), outputs.contactSheet).status !== 'match')) { + return evidencePathConflict(artifactPath, outputs, outputs.receipt, { + code: 'ownership-sidecar-path-mismatch', + }); + } + if (typeof previous?.artifact?.path !== 'string' + || !path.isAbsolute(previous.artifact.path) + || sameEntry(previous.artifact.path, artifactPath).status !== 'match' + || previous.artifact.sha256 !== sha256(artifactBytes) + || previous.artifact.bytes !== artifactBytes.byteLength) { + return evidencePathConflict(artifactPath, outputs, outputs.receipt, { + code: 'ownership-artifact-mismatch', + }); + } + + const existingEvidence = visualEvidencePaths(outputs).filter((file) => ( + capturedTargets.get(file).snapshot.requestedEntry.kind === 'existing' + )); + const recordedEvidence = previous?.sidecars?.files; + if (existingEvidence.length && !Array.isArray(recordedEvidence)) { + return evidencePathConflict(artifactPath, outputs, existingEvidence[0], { + code: 'ownership-evidence-digests-missing', + }); + } + if (Array.isArray(recordedEvidence)) { + const allowed = new Map(visualEvidencePaths(outputs).map((file) => [path.basename(file), file])); + const recorded = new Map(); + for (const item of recordedEvidence) { + if (!item || typeof item.file !== 'string' || recorded.has(item.file) || !allowed.has(item.file)) { + return evidencePathConflict(artifactPath, outputs, outputs.receipt, { + code: 'ownership-evidence-list-invalid', + }); + } + recorded.set(item.file, item); + } + if (recorded.size !== existingEvidence.length + || existingEvidence.some((file) => !recorded.has(path.basename(file)))) { + return evidencePathConflict(artifactPath, outputs, outputs.receipt, { + code: 'ownership-evidence-list-mismatch', + }); + } + for (const file of existingEvidence) { + const actual = regularFileEvidence(file); + const expected = recorded.get(path.basename(file)); + if (!actual.ok + || actual.evidence.sha256 !== expected.sha256 + || actual.evidence.bytes !== expected.bytes) { + return evidencePathConflict(artifactPath, outputs, file, { + code: actual.ok ? 'ownership-evidence-digest-mismatch' : actual.reason.code, + ...(!actual.ok && actual.reason.systemCode + ? { systemCode: actual.reason.systemCode } : {}), + }); + } + previousEvidence.set(file, { + sha256: actual.evidence.sha256, + bytes: actual.evidence.bytes, + }); + } + } + + } + + for (const target of targets) { + const verification = verifyAtomicOutput(capturedTargets.get(target.path).snapshot); + if (verification.status !== 'match') { + return evidencePathConflict(artifactPath, outputs, target.path, verification.reason); + } + } + + let stagingDirectory; + let stagingStat; + try { + stagingDirectory = fs.mkdtempSync(path.toNamespacedPath( + path.join(parent.parentPath, '.archify-visual-check-'), + )); + stagingStat = fs.lstatSync(stagingDirectory, { bigint: true }); + if (!stagingStat.isDirectory() || stagingStat.isSymbolicLink() || stagingStat.ino === 0n) { + throw new Error('the newly created staging directory has no stable directory identity'); + } + } catch (error) { + return evidenceWriteFailure( + artifactPath, + 'visual-check could not create a private evidence staging directory.', + [{ file: parent.parentPath, reason: error.message }], + ); + } + + const staged = stagedOutputs(outputs, stagingDirectory); + const ownership = { + active: true, + artifact: path.resolve(artifactPath), + receipt: outputs.receipt, + targets: capturedTargets, + stagingDirectory, + stagingIdentity: fileIdentity(stagingStat), + stagedOutputs: staged, + stagingPaths: new Map([ + [outputs.receipt, staged.receipt], + [outputs.contactSheet, staged.contactSheet], + ...outputs.screenshots.map((entry, index) => [entry.path, staged.screenshots[index].path]), + ]), + stagedEntries: new Map(), + previousEvidence, + }; + visualEvidenceOwnerships.add(ownership); + return { ok: true, ownership }; +} + +function recordVisualEvidenceFiles(receipt, outputs, ownership) { + const files = []; + for (const file of visualEvidencePaths(outputs)) { + const staged = ownership.stagedEntries.get(file); + if (!staged || !currentEvidenceMatches(staged.path, staged.identity, staged.evidence)) { + const error = new Error(`visual-check could not bind evidence file "${file}" to its receipt.`); + error.evidenceReason = { code: 'staged-evidence-content-mismatch' }; + throw error; + } + files.push({ ...staged.evidence, file: path.basename(file) }); + } + receipt.sidecars.files = files; +} + +function currentEvidenceMatches(file, identity, evidence, { + expectedLinks = identity.links ?? 1, +} = {}) { + if (!evidence) return false; + const current = regularFileEvidence(file, { expectedLinks: Number(expectedLinks) }); + return current.ok + && evidenceIdentityMatches(current.identity, identity, expectedLinks) + && current.evidence.sha256 === evidence.sha256 + && current.evidence.bytes === evidence.bytes; +} + +function restoreOwnedBackup(backup, errors) { + const expectedLinks = backup.identity.links ?? 1n; + const expectedLinkCount = Number(expectedLinks); + let backupStat; + try { + backupStat = fs.lstatSync(backup.path, { bigint: true }); + } catch { + errors.push({ file: backup.path, reason: 'backup identity changed; it was preserved' }); + return; + } + if (!identityMatches(backupStat, backup.identity) || backupStat.nlink !== expectedLinks) { + errors.push({ file: backup.path, reason: 'backup identity changed; it was preserved' }); + return; + } + let captured = backup.evidence && Number.isSafeInteger(expectedLinkCount) + ? captureRegularFileBinding(backup.path, { + subject: 'restore-backup', + expectedIdentity: backup.identity, + expectedMode: backup.identity.mode, + expectedSha256: backup.evidence.sha256, + expectedBytes: backup.evidence.bytes, + expectedLinks: expectedLinkCount, + }) + : { status: 'unknown', reason: { code: 'restore-backup-evidence-unavailable' } }; + let contentChanged = false; + if (captured.status !== 'captured' + && captured.reason?.code === 'restore-backup-content-changed') { + captured = captureRegularFileBinding(backup.path, { + subject: 'restore-backup-claimant', + expectedIdentity: backup.identity, + expectedMode: backup.identity.mode, + expectedLinks: expectedLinkCount, + }); + contentChanged = captured.status === 'captured'; + } + if (captured.status !== 'captured') { + errors.push({ + file: backup.path, + reason: 'backup bytes, mode, or identity changed; it was preserved', + ...(captured.reason?.code ? { code: captured.reason.code } : {}), + }); + return; + } + let released; + try { + const finalEntry = pathEntry(backup.finalPath); + if (finalEntry.exists === true) { + errors.push({ + file: backup.path, + reason: 'final path is occupied; the owned backup was preserved', + }); + return; + } + if (finalEntry.exists === null) { + errors.push({ file: backup.finalPath, reason: finalEntry.error?.message }); + return; + } + try { + fs.linkSync(backup.path, backup.finalPath); + const linkedCount = expectedLinkCount + 1; + const staged = verifyRegularFileBinding(captured.binding, { + filePath: backup.path, + expectedLinks: linkedCount, + }); + const published = verifyRegularFileBinding(captured.binding, { + filePath: backup.finalPath, + expectedLinks: linkedCount, + }); + if (staged.status !== 'match' || published.status !== 'match') { + const removed = quarantineRemoveRegularFileBinding( + captured.binding, + backup.finalPath, + { subject: 'restored-evidence', expectedLinks: linkedCount }, + ); + errors.push({ + file: backup.path, + reason: removed.status === 'removed' + ? 'restored entry identity could not be verified; the owned backup was preserved' + : removed.status === 'preserved' + ? 'restored entry changed during cleanup; its successor and the owned backup were preserved' + : 'restored entry changed before rollback; both entries were preserved', + ...(removed.reason?.code ? { code: removed.reason.code } : {}), + ...(removed.recoveryDirectory ? { recoveryDirectory: removed.recoveryDirectory } : {}), + ...(removed.recoveryFile ? { recoveryFile: removed.recoveryFile } : {}), + }); + return; + } + const removed = unlinkOwnedEntry(backup.path, backup.identity, { + evidence: captured.content, + expectedLinks: linkedCount, + }); + const finalMatches = verifyRegularFileBinding(captured.binding, { + filePath: backup.finalPath, + expectedLinks: expectedLinkCount, + }).status === 'match'; + if (removed.status !== 'removed' || !finalMatches) { + errors.push({ file: backup.path, reason: 'restored entry link count could not be finalized' }); + } else if (contentChanged) { + errors.push({ + file: backup.finalPath, + reason: 'the backup content changed externally; its current bytes were restored and preserved', + }); + } + } catch (error) { + errors.push({ + file: backup.path, + reason: error?.code === 'EEXIST' + ? 'final path was claimed during restore; the owned backup was preserved' + : error.message, + }); + } + } finally { + released = releaseRegularFileBinding(captured.binding); + if (released.status !== 'released') { + errors.push({ + file: backup.path, + reason: 'restored backup binding could not be released cleanly', + ...(released.reason?.code ? { code: released.reason.code } : {}), + }); + } + } +} + +function commitVisualEvidence(artifactPath, outputs, ownership, desiredPaths) { + const verification = verifyVisualEvidenceTargets(artifactPath, outputs, ownership); + if (!verification.ok) { + const cleanupErrors = cleanupStagedEvidence(ownership, { removeDirectory: true }); + if (cleanupErrors.length) verification.diagnostic.evidence.rollbackErrors = cleanupErrors; + ownership.active = false; + return verification; + } + + const desired = new Set(desiredPaths); + const stagedBindings = new Map(); + const releaseStagedBindings = () => { + const failures = []; + for (const [finalPath, binding] of stagedBindings) { + const released = releaseRegularFileBinding(binding); + if (released.status !== 'released') { + failures.push({ + file: finalPath, + reason: released.reason?.code, + bindingState: released.reason, + ...(released.reason?.recoveryDirectory + ? { recoveryDirectory: released.reason.recoveryDirectory } : {}), + }); + } + } + stagedBindings.clear(); + return failures; + }; + for (const finalPath of desired) { + const staged = ownership.stagedEntries.get(finalPath); + const captured = staged ? captureRegularFileBinding(staged.path, { + subject: 'staged-evidence', + expectedIdentity: staged.identity, + expectedMode: staged.identity.mode, + expectedSha256: staged.evidence?.sha256, + expectedBytes: staged.evidence?.bytes, + expectedLinks: 1, + }) : null; + if (!staged || captured?.status !== 'captured') { + const failure = evidenceWriteFailure( + artifactPath, + 'visual-check could not verify its staged evidence before publication.', + [{ + file: staged?.path || stagedPathFor(ownership, finalPath), + reason: captured?.reason?.code || 'staged entry missing or changed', + }], + ); + failure.diagnostic.evidence.errors.push( + ...releaseStagedBindings(), + ...cleanupStagedEvidence(ownership, { removeDirectory: true }), + ); + ownership.active = false; + return failure; + } + stagedBindings.set(finalPath, captured.binding); + } + + const backups = []; + const published = []; + let conflict = null; + try { + for (const [index, target] of visualEvidenceTargets(outputs).entries()) { + const captured = ownership.targets.get(target.path); + if (captured.snapshot.target.kind !== 'file') continue; + const backupPath = path.join(ownership.stagingDirectory, `previous-${index}`); + const identity = { + device: captured.snapshot.target.device, + inode: captured.snapshot.target.inode, + mode: captured.snapshot.target.mode, + links: 1n, + }; + const evidence = ownership.previousEvidence.get(target.path); + if (!evidence) { + conflict = evidencePathConflict(artifactPath, outputs, target.path, { + code: 'target-evidence-missing-during-commit', + }); + throw new Error(conflict.error); + } + const backup = { + targetPath: target.path, + finalPath: captured.commitPath, + path: backupPath, + identity, + evidence, + }; + const backupResult = backupOwnedPublishedEntry( + captured.commitPath, + backupPath, + identity, + evidence, + ); + if (backupResult.backupCreated) backups.push(backup); + if (backupResult.status !== 'backed-up' + || !backupResult.backupCreated + || !backupResult.backupVerified) { + conflict = evidencePathConflict(artifactPath, outputs, target.path, { + code: backupResult.reason?.code || 'target-backup-failed-during-commit', + backupCreated: backupResult.backupCreated === true, + backupVerified: backupResult.backupVerified === true, + ...(backupResult.recoveryDirectory + ? { recoveryDirectory: backupResult.recoveryDirectory } : {}), + ...(backupResult.recoveryFile ? { recoveryFile: backupResult.recoveryFile } : {}), + ...(backupResult.target ? { target: backupResult.target } : {}), + ...(backupResult.reason ? { bindingState: backupResult.reason } : {}), + }); + throw new Error(conflict.error); + } + } + + const publicationOrder = visualEvidenceTargets(outputs) + .filter((target) => desired.has(target.path)) + .sort((left, right) => Number(left.kind === 'receipt') - Number(right.kind === 'receipt')); + for (const target of publicationOrder) { + const captured = ownership.targets.get(target.path); + const staged = ownership.stagedEntries.get(target.path); + const stagedBinding = stagedBindings.get(target.path); + const beforeLink = verifyRegularFileBinding(stagedBinding, { + filePath: staged.path, + expectedLinks: 1, + }); + if (beforeLink.status !== 'match') { + conflict = evidencePathConflict(artifactPath, outputs, target.path, { + code: beforeLink.reason?.code?.includes('content-changed') + ? 'staged-evidence-content-mismatch' + : beforeLink.reason?.code || 'staged-evidence-content-mismatch', + }); + throw new Error(conflict.error); + } + try { + fs.linkSync(staged.path, captured.commitPath); + } catch (error) { + if (error?.code === 'EEXIST') { + conflict = evidencePathConflict(artifactPath, outputs, target.path, { + code: 'target-created-during-commit', + }); + } + throw error; + } + published.push({ + finalPath: captured.commitPath, + stagedPath: staged.path, + identity: staged.identity, + evidence: staged.evidence, + binding: stagedBinding, + }); + const stagedLinked = verifyRegularFileBinding(stagedBinding, { + filePath: staged.path, + expectedLinks: 2, + }); + const publishedLinked = verifyRegularFileBinding(stagedBinding, { + filePath: captured.commitPath, + expectedLinks: 2, + }); + if (stagedLinked.status !== 'match' || publishedLinked.status !== 'match') { + conflict = evidencePathConflict(artifactPath, outputs, target.path, { + code: 'published-entry-identity-mismatch', + stagedState: stagedLinked.reason, + publishedState: publishedLinked.reason, + }); + throw new Error(conflict.error); + } + const removed = quarantineRemoveRegularFileBinding(stagedBinding, staged.path, { + subject: 'staged-evidence', + expectedLinks: 2, + }); + if (removed.status !== 'removed' && removed.status !== 'preserved') { + conflict = evidencePathConflict(artifactPath, outputs, target.path, { + code: removed.reason?.code || 'published-entry-identity-mismatch', + }); + throw new Error(`visual-check could not retire staged evidence "${staged.path}".`); + } + if (removed.status === 'removed') ownership.stagedEntries.delete(target.path); + const finalized = verifyRegularFileBinding(stagedBinding, { + filePath: captured.commitPath, + expectedLinks: 1, + }); + if (finalized.status !== 'match') { + conflict = evidencePathConflict(artifactPath, outputs, target.path, { + code: 'published-entry-link-count-mismatch', + bindingState: finalized.reason, + }); + throw new Error(conflict.error); + } + } + + // Receipt publication is the linearization point for the evidence set. + // Recheck every earlier member afterwards so a writer that replaces a PNG + // or the contact sheet while later members publish cannot yield success. + for (const entry of published) { + const finalState = verifyRegularFileBinding(entry.binding, { + filePath: entry.finalPath, + expectedLinks: 1, + }); + if (finalState.status !== 'match') { + conflict = evidencePathConflict(artifactPath, outputs, entry.finalPath, { + code: 'published-set-identity-mismatch', + bindingState: finalState.reason, + }); + throw new Error(conflict.error); + } + } + + for (const backup of backups) { + if (!currentEvidenceMatches( + backup.path, + backup.identity, + ownership.previousEvidence.get(backup.targetPath), + )) { + conflict = evidencePathConflict(artifactPath, outputs, backup.targetPath, { + code: 'backup-content-changed-before-cleanup', + }); + throw new Error(conflict.error); + } + } + + // SMB keeps removed staging names pending until every bound handle closes. + // Publication and backup verification are complete before directory cleanup. + const cleanupErrors = releaseStagedBindings(); + for (const backup of backups) { + const removed = unlinkOwnedEntry(backup.path, backup.identity, { + evidence: backup.evidence, + }); + if (removed.status !== 'removed' && removed.status !== 'absent') { + cleanupErrors.push({ + file: backup.path, + reason: removed.status === 'different' && removed.reason === 'content' + ? 'backup content changed; it was preserved' + : removed.status === 'different' + ? 'backup identity changed; it was preserved' + : removed.error?.message, + }); + } + } + cleanupErrors.push(...cleanupStagedEvidence(ownership, { removeDirectory: true })); + ownership.active = false; + let recoveryDirectory; + if (cleanupErrors.length) { + try { + const retained = fs.lstatSync(ownership.stagingDirectory, { bigint: true }); + if (identityMatches(retained, ownership.stagingIdentity, { directory: true })) { + recoveryDirectory = ownership.stagingDirectory; + } + } catch {} + recoveryDirectory ||= cleanupErrors.find((entry) => entry.recoveryDirectory)?.recoveryDirectory; + } + return { + ok: true, + status: cleanupErrors.length ? 'committed-with-warning' : 'committed', + cleanupErrors, + ...(recoveryDirectory ? { recoveryDirectory } : {}), + committedReceipt: outputs.receipt, + }; + } catch (error) { + const rollbackErrors = []; + for (const entry of [...published].reverse()) { + let removed; + let ownedMismatch; + for (const expectedLinks of [2, 1]) { + const matches = verifyRegularFileBinding(entry.binding, { + filePath: entry.finalPath, + expectedLinks, + }); + if (matches.status === 'match') { + removed = quarantineRemoveRegularFileBinding( + entry.binding, + entry.finalPath, + { subject: 'published-evidence', expectedLinks }, + ); + break; + } + if (matches.reason?.code?.includes('content-changed') + || matches.reason?.code?.includes('mode-changed')) { + ownedMismatch = matches; + } else { + ownedMismatch ||= matches; + } + const reportedLinks = Number(matches.reason?.links); + if (matches.status === 'unsupported' + && matches.reason?.code === 'staged-evidence-hardlinked' + && Number.isSafeInteger(reportedLinks) + && reportedLinks > expectedLinks) { + const current = verifyRegularFileBinding(entry.binding, { + filePath: entry.finalPath, + expectedLinks: reportedLinks, + }); + if (current.status === 'match') { + removed = quarantineRemoveRegularFileBinding( + entry.binding, + entry.finalPath, + { subject: 'published-evidence', expectedLinks: reportedLinks }, + ); + break; + } + } + } + if (!removed) { + const claimantRemoval = quarantineRemoveLinkedRegularFileAlias( + entry.stagedPath, + entry.finalPath, + { subject: 'published-evidence-claimant' }, + ); + removed = claimantRemoval.status === 'removed' ? claimantRemoval : ownedMismatch; + } + if (removed.status !== 'removed' && removed.status !== 'absent') { + rollbackErrors.push(publishedRemovalError(entry.finalPath, removed)); + } + } + for (const backup of [...backups].reverse()) restoreOwnedBackup(backup, rollbackErrors); + rollbackErrors.push(...releaseStagedBindings()); + rollbackErrors.push(...cleanupStagedEvidence(ownership, { removeDirectory: true })); + ownership.active = false; + if (conflict) { + if (rollbackErrors.length) conflict.diagnostic.evidence.rollbackErrors = rollbackErrors; + return conflict; + } + return evidenceWriteFailure( + artifactPath, + 'visual-check could not atomically publish its evidence set.', + [{ reason: error.message }, ...rollbackErrors], + ); + } finally { + releaseStagedBindings(); + } +} + +function executable(file, platform = process.platform) { + if (!file) return null; + try { + fs.accessSync(file, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK); + return path.resolve(file); + } catch { + return null; + } +} + +function findOnPath(command, env, platform, resolveExecutable) { + const pathApi = platform === 'win32' ? path.win32 : path; + const directories = String(env.PATH || '').split(pathApi.delimiter).filter(Boolean); + const extensions = platform === 'win32' + ? String(env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean) + : ['']; + for (const directory of directories) { + for (const extension of extensions) { + const candidate = pathApi.join(directory, `${command}${extension}`); + const resolved = resolveExecutable(candidate, platform); + if (resolved) return resolved; + } + } + return null; +} + +export function findChrome({ + env = process.env, + platform = process.platform, + resolveExecutable = executable, +} = {}) { + if (Object.prototype.hasOwnProperty.call(env, 'ARCHIFY_CHROME')) { + return resolveExecutable(env.ARCHIFY_CHROME, platform); + } + + const fixed = []; + const commands = []; + if (platform === 'darwin') { + fixed.push( + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + ); + } else if (platform === 'win32') { + const pathApi = path.win32; + for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA].filter(Boolean)) { + fixed.push( + pathApi.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'), + pathApi.join(root, 'Chromium', 'Application', 'chrome.exe'), + ); + } + commands.push('chrome', 'google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'); + } else { + commands.push('google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'); + } + + for (const candidate of fixed) { + const resolved = resolveExecutable(candidate, platform); + if (resolved) return resolved; + } + for (const command of commands) { + const resolved = findOnPath(command, env, platform, resolveExecutable); + if (resolved) return resolved; + } + return null; +} + +class PipeCdp { + constructor(child, { failureDetails = () => '' } = {}) { + this.child = child; + this.failureDetails = failureDetails; + this.nextId = 1; + this.buffer = ''; + this.receivedBytes = 0; + this.receivedMessages = 0; + this.completedWrites = 0; + this.writtenBytes = 0; + this.pending = new Map(); + this.waiters = []; + this.writePipe = child.stdio[3]; + this.readPipe = child.stdio[4]; + this.readPipe.setEncoding('utf8'); + this.readPipe.on('data', (chunk) => this.consume(chunk)); + this.writePipe.on('error', (error) => this.failAll(this.failure('write pipe', error))); + this.readPipe.on('error', (error) => this.failAll(this.failure('read pipe', error))); + this.readPipe.once('end', () => this.failAll(this.failure('read pipe', new Error('stream ended')))); + this.readPipe.once('close', () => this.failAll(this.failure('read pipe', new Error('stream closed')))); + this.writePipe.once('close', () => this.failAll(this.failure('write pipe', new Error('stream closed')))); + child.once('error', (error) => this.failAll(this.failure('process launch', error))); + child.once('close', (code, signal) => { + const ending = signal ? `signal ${signal}` : `exit code ${code}`; + this.failAll(this.failure('process exit', new Error(`Chrome closed with ${ending}`))); + }); + } + + failure(stage, error) { + const code = error?.code ? ` [${error.code}]` : ''; + const details = this.failureDetails(); + const failure = new Error([ + `Chrome DevTools ${stage} failed: ${error?.message || String(error)}${code}`, + details, + `Chrome transport: Node ${process.version}, libuv ${process.versions.uv}, ${process.platform}; pid=${this.child.pid ?? 'unavailable'}, exitCode=${this.child.exitCode}, signalCode=${this.child.signalCode}.`, + `Chrome read pipe: readable=${this.readPipe.readable}, ended=${this.readPipe.readableEnded}, destroyed=${this.readPipe.destroyed}, receivedBytes=${this.receivedBytes}, messages=${this.receivedMessages}, bufferedBytes=${Buffer.byteLength(this.buffer)}.`, + `Chrome write pipe: writable=${this.writePipe.writable}, ended=${this.writePipe.writableEnded}, destroyed=${this.writePipe.destroyed}, completedWrites=${this.completedWrites}, writtenBytes=${this.writtenBytes}, bufferedBytes=${this.writePipe.writableLength}.`, + ].filter(Boolean).join('\n')); + if (error?.code) failure.code = error.code; + if (error?.method) failure.method = error.method; + return failure; + } + + consume(chunk) { + this.receivedBytes += Buffer.byteLength(chunk); + this.buffer += chunk; + let boundary; + while ((boundary = this.buffer.indexOf('\0')) >= 0) { + const raw = this.buffer.slice(0, boundary); + this.buffer = this.buffer.slice(boundary + 1); + if (!raw) continue; + let message; + try { + message = JSON.parse(raw); + } catch (error) { + this.failAll(new Error(`Chrome DevTools returned invalid JSON: ${error.message}`)); + continue; + } + this.receivedMessages += 1; + if (message.id) { + const pending = this.pending.get(message.id); + if (!pending) continue; + clearTimeout(pending.timer); + this.pending.delete(message.id); + if (message.error) pending.reject(new Error(`${pending.method}: ${message.error.message}`)); + else pending.resolve(message.result || {}); + continue; + } + for (const waiter of [...this.waiters]) { + if (waiter.method !== message.method) continue; + if (waiter.sessionId && waiter.sessionId !== message.sessionId) continue; + clearTimeout(waiter.timer); + this.waiters.splice(this.waiters.indexOf(waiter), 1); + waiter.resolve(message.params || {}); + } + } + } + + send(method, params = {}, sessionId = undefined, timeoutMs = 15000) { + const id = this.nextId++; + const message = { id, method, params }; + if (sessionId) message.sessionId = sessionId; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + const error = new Error(`${method}: timed out after ${timeoutMs}ms`); + error.code = 'ERR_CHROME_CDP_TIMEOUT'; + error.method = method; + reject(this.failure('protocol timeout', error)); + }, timeoutMs); + this.pending.set(id, { method, resolve, reject, timer }); + try { + const serialized = `${JSON.stringify(message)}\0`; + this.writePipe.write(serialized, (error) => { + if (error) { + this.failAll(this.failure('write pipe', error)); + } else { + this.completedWrites += 1; + this.writtenBytes += Buffer.byteLength(serialized); + } + }); + } catch (error) { + this.failAll(this.failure('write pipe', error)); + } + }); + } + + waitFor(method, sessionId, timeoutMs = 15000) { + return new Promise((resolve, reject) => { + const waiter = { method, sessionId, resolve, reject, timer: null }; + waiter.timer = setTimeout(() => { + this.waiters.splice(this.waiters.indexOf(waiter), 1); + reject(this.failure('event timeout', new Error(`${method}: event timed out after ${timeoutMs}ms`))); + }, timeoutMs); + this.waiters.push(waiter); + }); + } + + failAll(error) { + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + for (const waiter of this.waiters) { + clearTimeout(waiter.timer); + waiter.reject(error); + } + this.pending.clear(); + this.waiters = []; + } +} + +export function chromeVisualBrowserArgs(profileRoot, { + env = process.env, + getuid = typeof process.getuid === 'function' ? () => process.getuid() : null, +} = {}) { + const args = [ + '--headless=new', + '--remote-debugging-pipe', + '--disable-gpu', + '--hide-scrollbars', + '--disable-background-networking', + '--disable-component-update', + '--disable-default-apps', + '--disable-sync', + '--metrics-recording-only', + '--no-first-run', + '--no-default-browser-check', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-renderer-backgrounding', + '--force-device-scale-factor=1', + `--user-data-dir=${profileRoot}`, + 'about:blank', + ]; + const rootUser = typeof getuid === 'function' && getuid() === 0; + const sandboxOptOut = env?.[CHROME_NO_SANDBOX_ENV] === '1'; + if (rootUser || sandboxOptOut) args.unshift('--no-sandbox'); + return args; +} + +async function evaluate(cdp, sessionId, expression, awaitPromise = false) { + const response = await cdp.send('Runtime.evaluate', { + expression, + awaitPromise, + returnByValue: true, + }, sessionId); + if (response.exceptionDetails) { + throw new Error(response.exceptionDetails.exception?.description + || response.exceptionDetails.text + || 'Runtime.evaluate failed'); + } + return response.result?.value; +} + +export class ChromeVisualBrowser { + constructor(chromePath, { + env = process.env, + getuid = typeof process.getuid === 'function' ? () => process.getuid() : null, + spawnImpl = spawn, + startupTimeoutMs = CHROME_STARTUP_TIMEOUT_MS, + } = {}) { + this.profileRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-visual-check-profile-')); + this.stderr = ''; + const args = chromeVisualBrowserArgs(this.profileRoot, { env, getuid }); + this.child = spawnImpl(chromePath, args, { stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'] }); + this.child.stderr.setEncoding('utf8'); + this.child.stderr.on('data', (chunk) => { + this.stderr = `${this.stderr}${chunk}`.slice(-8000); + }); + this.child.stderr.on('error', (error) => { + this.stderr = `${this.stderr}\nChrome stderr stream failed: ${error.message}`.trim().slice(-8000); + }); + this.cdp = new PipeCdp(this.child, { + failureDetails: () => { + const exit = this.child.signalCode + ? `signal ${this.child.signalCode}` + : this.child.exitCode == null ? 'still running' : `exit code ${this.child.exitCode}`; + const stderr = this.stderr.trim(); + return [ + `Chrome process: ${exit}.`, + stderr ? `Chrome stderr:\n${stderr}` : '', + ].filter(Boolean).join('\n'); + }, + }); + this.startupTimeoutMs = startupTimeoutMs; + this.sessionPromise = this.attach(); + } + + async attach() { + // Process launch can be delayed substantially on a busy desktop. Keep the + // longer allowance inside one gate invocation so an authoring agent does + // not turn startup jitter into repeated tool calls or candidate edits. + const targets = await this.cdp.send( + 'Target.getTargets', {}, undefined, this.startupTimeoutMs, + ); + let target = targets.targetInfos?.find((item) => item.type === 'page'); + if (!target) { + const created = await this.cdp.send('Target.createTarget', { url: 'about:blank' }); + target = { targetId: created.targetId }; + } + const attached = await this.cdp.send('Target.attachToTarget', { + targetId: target.targetId, + flatten: true, + }); + await this.cdp.send('Page.enable', {}, attached.sessionId); + await this.cdp.send('Runtime.enable', {}, attached.sessionId); + return attached.sessionId; + } + + async inspect({ artifactPath, width, height, theme, screenshotPath, writeScreenshot }) { + const sessionId = await this.sessionPromise; + await this.cdp.send('Emulation.setDeviceMetricsOverride', { + width, + height, + deviceScaleFactor: 1, + mobile: false, + }, sessionId); + + const url = pathToFileURL(artifactPath); + url.searchParams.set('theme', theme); + const loaded = this.cdp.waitFor('Page.loadEventFired', sessionId); + // Navigation can fail before this waiter is awaited. Attach a rejection + // handler immediately so a later load failure never escapes as an + // unhandled rejection; awaiting `loaded` below still reports it normally. + loaded.catch(() => {}); + const navigation = await this.cdp.send('Page.navigate', { url: url.href }, sessionId); + if (navigation.errorText) throw new Error(`Chrome navigation failed: ${navigation.errorText}`); + await loaded; + await evaluate(this.cdp, sessionId, `(function () { + document.documentElement.setAttribute('data-motion', 'still'); + var panel = document.querySelector('.diagram-container'); + if (panel) panel.setAttribute('data-detail-level', 'read'); + var fontsReady = document.fonts && document.fonts.ready + ? document.fonts.ready.catch(function () {}) + : Promise.resolve(); + if (window.Archify && Archify.layoutStability && typeof Archify.layoutStability.whenStable === 'function') { + return fontsReady.then(function () { return Archify.layoutStability.whenStable(); }); + } + return fontsReady.then(function () { + if (window.Archify && Archify.readerLayout && typeof Archify.readerLayout.whenStable === 'function') { + return Archify.readerLayout.whenStable(); + } + }).then(function () { + if (window.Archify && Archify.viewerChromeLayout && typeof Archify.viewerChromeLayout.whenStable === 'function') { + return Archify.viewerChromeLayout.whenStable(); + } + }).then(function () { + if (window.Archify && Archify.readerLayout && typeof Archify.readerLayout.whenStable === 'function') { + return Archify.readerLayout.whenStable(); + } + }).then(function () { + if (window.Archify && Archify.viewerChromeLayout && typeof Archify.viewerChromeLayout.whenStable === 'function') { + return Archify.viewerChromeLayout.whenStable(); + } + return new Promise(function (resolve) { + requestAnimationFrame(function () { requestAnimationFrame(resolve); }); + }); + }); + })()`, true); + + const metrics = await evaluate(this.cdp, sessionId, `(function () { + var reader = document.querySelector('.container'); + var diagram = document.querySelector('.diagram-container'); + var svg = diagram && ( + diagram.querySelector(':scope > svg') || + diagram.querySelector(':scope > .diagram-stage > svg') + ); + var stage = diagram && (diagram.querySelector(':scope > .diagram-stage') || svg); + var legend = svg && svg.querySelector('[data-legend]'); + var navigationDock = diagram && diagram.querySelector('.diagram-nav'); + var viewBox = svg && svg.viewBox && svg.viewBox.baseVal; + var diagramWidth = svg ? svg.getBoundingClientRect().width : 0; + var viewBoxWidth = viewBox ? viewBox.width : 0; + var scale = viewBoxWidth > 0 ? Math.min(1, diagramWidth / viewBoxWidth) : 0; + var minimum = null; + var minimumNonEdge = null; + var minimumEdge = null; + if (svg && scale > 0) { + Array.from(svg.querySelectorAll('text[data-node-label], text[data-boundary-label], text[data-detail="context"], g[data-detail="context"] text')).forEach(function (text) { + if (text.getAttribute('data-detail') === 'fine' || text.closest('[data-detail="fine"]')) return; + var edge = text.closest('[data-edge-from][data-edge-to]'); + var node = text.closest('[data-node-id]'); + var context = text.getAttribute('data-detail') === 'context' || Boolean(text.closest('g[data-detail="context"]')); + var detail = text.hasAttribute('data-node-label') + ? 'primary' + : text.hasAttribute('data-boundary-label') ? 'boundary' + : context && edge ? 'edge' : 'context'; + if (detail === 'context' && !node) return; + var sourceFontPx = parseFloat(text.getAttribute('font-size') || ''); + if (!Number.isFinite(sourceFontPx)) return; + var projectedFontPx = sourceFontPx * scale; + var entry = { + text: (text.textContent || '').trim(), + detail: detail, + owner: edge ? { + kind: 'edge', + id: edge.getAttribute('data-edge-id'), + from: edge.getAttribute('data-edge-from'), + to: edge.getAttribute('data-edge-to') + } : node ? { kind: 'node', id: node.getAttribute('data-node-id') } + : detail === 'boundary' ? { kind: 'boundary', id: null } : null, + sourceFontPx: sourceFontPx, + projectedFontPx: projectedFontPx + }; + if (!minimum || projectedFontPx < minimum.projectedFontPx) minimum = entry; + if (detail === 'edge') { + if (!minimumEdge || projectedFontPx < minimumEdge.projectedFontPx) minimumEdge = entry; + } else if (!minimumNonEdge || projectedFontPx < minimumNonEdge.projectedFontPx) { + minimumNonEdge = entry; + } + }); + } + function intersectionArea(a, b) { + if (!a || !b || !a.width || !a.height || !b.width || !b.height) return 0; + var width = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left)); + var height = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top)); + return width * height; + } + var legendRect = legend ? legend.getBoundingClientRect() : null; + var stageRect = window.Archify && Archify.viewerChromeLayout + && typeof Archify.viewerChromeLayout.stageRect === 'function' + ? Archify.viewerChromeLayout.stageRect() + : (stage ? stage.getBoundingClientRect() : null); + // The dock may lift to the viewport floor while the page scrolls; the + // stage contract is about its resting position. + var navigationDockRect = !navigationDock ? null + : window.Archify && Archify.viewerChromeLayout && typeof Archify.viewerChromeLayout.dockRect === 'function' + ? Archify.viewerChromeLayout.dockRect() + : navigationDock.getBoundingClientRect(); + var stageDockIntersectionArea = intersectionArea(stageRect, navigationDockRect); + var viewerChromeReceipt = window.Archify && Archify.viewerChromeLayout + && typeof Archify.viewerChromeLayout.receipt === 'function' + ? Archify.viewerChromeLayout.receipt() + : null; + // Read rendered boxes only; source ownership, offsets and hard pins are unknown. + var workflowLanes = svg ? Array.from(svg.querySelectorAll('rect[data-composition-frame-kind="lane"]')).map(function (lane) { + var rect = lane.getBoundingClientRect(); + var members = Array.from(svg.querySelectorAll('g[data-node-id]')).map(function (node) { + var box = node.querySelector('rect'); + if (!box) return null; + var bounds = box.getBoundingClientRect(); + if (bounds.top < rect.top - 1 || bounds.bottom > rect.bottom + 1 + || bounds.left < rect.left - 1 || bounds.right > rect.right + 1) return null; + return { id: node.getAttribute('data-node-id'), top: bounds.top, bottom: bounds.bottom }; + }).filter(Boolean); + var top = members.length ? members.reduce(function (minimum, node) { return Math.min(minimum, node.top); }, Infinity) : null; + var bottom = members.length ? members.reduce(function (maximum, node) { return Math.max(maximum, node.bottom); }, -Infinity) : null; + return { + frameId: lane.getAttribute('data-composition-frame-id'), + heightPx: Math.round(rect.height), + nodeCount: members.length, + nodeIds: members.slice(0, 12).map(function (node) { return node.id; }), + nodeSpanPx: top === null ? null : Math.round(bottom - top), + spaceAboveNodesPx: top === null ? null : Math.round(top - rect.top), + spaceBelowNodesPx: bottom === null ? null : Math.round(rect.bottom - bottom) + }; + }).sort(function (a, b) { return b.heightPx - a.heightPx; }).slice(0, 6) : []; + // Vertical page budget: every block that stacks above or below the SVG, + // so an overflow receipt can say which part must give up how many pixels. + function outerHeight(element) { + if (!element) return 0; + var style = window.getComputedStyle(element); + if (element.hidden || style.display === 'none') return 0; + return element.getBoundingClientRect().height + + (parseFloat(style.marginTop) || 0) + (parseFloat(style.marginBottom) || 0); + } + var bodyStyle = window.getComputedStyle(document.body); + var diagramStyle = diagram ? window.getComputedStyle(diagram) : null; + var svgHeight = svg ? svg.getBoundingClientRect().height : 0; + var notesBelowFold = Boolean(reader) && document.documentElement.getAttribute('data-reader-rail') === 'bottom'; + var pageComposition = { + bodyPaddingPx: Math.round((parseFloat(bodyStyle.paddingTop) || 0) + (parseFloat(bodyStyle.paddingBottom) || 0)), + headerPx: Math.round(outerHeight(reader && reader.querySelector('.header'))), + diagramChromePx: Math.round(outerHeight(diagram) - svgHeight), + svgPx: Math.round(svgHeight), + cardsPx: notesBelowFold ? 0 : Math.round(outerHeight(reader && reader.querySelector('.cards'))), + viewBoxHeight: viewBox ? viewBox.height : 0 + }; + var svgRect = svg && svg.getBoundingClientRect(); + var diagramRect = diagram && diagram.getBoundingClientRect(); + var documentScrollUnclipped = Boolean(svgRect && diagramRect + && svgRect.top >= diagramRect.top - 1 && svgRect.left >= diagramRect.left - 1 + && svgRect.bottom <= diagramRect.bottom + 1 && svgRect.right <= diagramRect.right + 1 + && diagram.scrollHeight <= diagram.clientHeight + 1 + && diagram.scrollWidth <= diagram.clientWidth + 1 + && [document.documentElement, document.body].every(function (element) { + return !['hidden', 'clip'].includes(window.getComputedStyle(element).overflowY); + })); + return { + pageComposition: pageComposition, + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + scrollWidth: Math.ceil(document.documentElement.scrollWidth), + // Bottom notes and index are reading material below the fold; the + // Reader excludes them from the one-screen fit, and so does this check. + scrollHeight: Math.max(window.innerHeight, Math.ceil(document.documentElement.scrollHeight - ( + notesBelowFold ? outerHeight(reader.querySelector('.reader-rail')) : 0))), + resolvedTheme: document.documentElement.getAttribute('data-theme') || '', + readerLayout: document.documentElement.getAttribute('data-reader-layout') || null, + readerOverflow: document.documentElement.getAttribute('data-reader-overflow') || null, + readerFit: svg ? svg.getAttribute('data-reader-fit') : null, + diagramType: svg ? svg.getAttribute('data-diagram-type') : null, + documentScrollUnclipped: documentScrollUnclipped, + readerWidth: reader ? reader.getBoundingClientRect().width : 0, + diagramWidth: diagramWidth, + viewBoxWidth: viewBoxWidth, + workflowLanes: workflowLanes, + minimumProjectedNodeTextPx: minimum ? minimum.projectedFontPx : null, + minimumProjectedNonEdgeTextPx: minimumNonEdge ? minimumNonEdge.projectedFontPx : null, + minimumProjectedEdgeTextPx: minimumEdge ? minimumEdge.projectedFontPx : null, + minimumProjectedNodeText: minimum ? minimum.text : null, + minimumProjectedNodeTextDetail: minimum ? minimum.detail : null, + minimumProjectedNodeTextOwner: minimum ? minimum.owner : null, + hasLegend: Boolean(legendRect && legendRect.width && legendRect.height), + hasNavigationDock: Boolean(navigationDockRect && navigationDockRect.width && navigationDockRect.height), + legendDockIntersectionArea: stageDockIntersectionArea > 0 + ? intersectionArea(legendRect, navigationDockRect) + : 0, + dockStageIntersectionArea: stageDockIntersectionArea, + dockStageGap: stageRect && navigationDockRect ? navigationDockRect.top - stageRect.bottom : null, + viewerChromeRequiredGap: viewerChromeReceipt ? viewerChromeReceipt.gap : null, + viewerChromeReserve: viewerChromeReceipt ? viewerChromeReceipt.reserve : 0, + viewerChromeActive: viewerChromeReceipt ? viewerChromeReceipt.active : false + }; + })()`); + if (!metrics || !Number.isFinite(metrics.scrollWidth) || !Number.isFinite(metrics.scrollHeight)) { + throw new Error('Chrome returned incomplete containment metrics.'); + } + + if (screenshotPath) { + const capture = await this.cdp.send('Page.captureScreenshot', { + format: 'png', + fromSurface: true, + captureBeyondViewport: false, + }, sessionId, 20000); + if (!capture.data) throw new Error('Chrome returned an empty screenshot.'); + const screenshot = Buffer.from(capture.data, 'base64'); + if (writeScreenshot) writeScreenshot(screenshot); + else fs.writeFileSync(screenshotPath, screenshot, { flag: 'wx' }); + } + return metrics; + } + + close() { + if (!this.closePromise) this.closePromise = this.finishClose(); + return this.closePromise; + } + + async finishClose() { + this.cdp.failAll(new Error('visual-check finished')); + try { + if (this.child.exitCode === null && this.child.signalCode === null) { + await new Promise((resolve) => { + let timer = null; + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + this.child.removeListener('exit', finish); + resolve(); + }; + this.child.once('exit', finish); + this.child.kill('SIGTERM'); + if (settled) return; + timer = setTimeout(() => { + if (this.child.exitCode === null && this.child.signalCode === null) this.child.kill('SIGKILL'); + finish(); + }, 1500); + }); + } + } finally { + // `exit` only reports that Chrome's main process is gone. A helper may + // still hold an inherited pipe open, so explicitly release every stream + // this browser instance owns before its test cleanup hook resolves. + for (const stream of [this.child.stderr, this.child.stdio[3], this.child.stdio[4]]) { + if (stream && !stream.destroyed) stream.destroy(); + } + } + try { + fs.rmSync(this.profileRoot, { recursive: true, force: true }); + } catch { + // Chrome may briefly retain profile files on Windows; evidence is done. + } + } +} + +function observation({ width, height, theme, metrics }) { + const innerWidth = Number(metrics.innerWidth); + const innerHeight = Number(metrics.innerHeight); + const scrollWidth = Number(metrics.scrollWidth); + const scrollHeight = Number(metrics.scrollHeight); + const overflowX = scrollWidth > innerWidth; + const overflowY = scrollHeight > innerHeight; + const minimumProjectedNodeTextPx = metrics.minimumProjectedNodeTextPx == null + ? null + : Number(metrics.minimumProjectedNodeTextPx); + const minimumProjectedNonEdgeTextPx = metrics.minimumProjectedNonEdgeTextPx == null + ? null + : Number(metrics.minimumProjectedNonEdgeTextPx); + const minimumProjectedEdgeTextPx = metrics.minimumProjectedEdgeTextPx == null + ? null + : Number(metrics.minimumProjectedEdgeTextPx); + const readabilityOk = minimumProjectedNodeTextPx == null + || minimumProjectedNodeTextPx >= MIN_PROJECTED_NODE_TEXT_PX; + const readerLayout = metrics.readerLayout || null; + const readerOverflow = metrics.readerOverflow || null; + const readerFit = metrics.readerFit || null; + const verticalScrollAccepted = Boolean( + overflowY + && !overflowX + && readabilityOk + && Number.isFinite(minimumProjectedNodeTextPx) + && ((readerLayout === 'adaptive' && readerOverflow === 'authored' && readerFit === 'intrinsic-height') + || (readerFit === 'authored-height' && metrics.diagramType === 'architecture' + && metrics.documentScrollUnclipped === true)) + ); + const authoredClipped = readerFit === 'authored-height' && metrics.diagramType === 'architecture' + && metrics.documentScrollUnclipped !== true; + const containmentOk = !authoredClipped && !overflowX && (!overflowY || verticalScrollAccepted); + const legendDockIntersectionArea = Number(metrics.legendDockIntersectionArea) || 0; + const dockStageIntersectionArea = Number(metrics.dockStageIntersectionArea) || 0; + const dockStageGap = metrics.dockStageGap == null ? null : Number(metrics.dockStageGap); + const receiptDockStageGap = metrics.viewerChromeRequiredGap == null + ? null + : Number(metrics.viewerChromeRequiredGap); + const requiredDockStageGap = Number.isFinite(receiptDockStageGap) ? receiptDockStageGap : 0; + const viewerChromeStageOk = !metrics.hasNavigationDock || ( + Number.isFinite(dockStageGap) + && dockStageIntersectionArea <= 0.5 + && dockStageGap >= requiredDockStageGap - 1 + ); + const viewerChromeOk = legendDockIntersectionArea <= 0.5 && viewerChromeStageOk; + return { + width, + height, + theme, + innerWidth, + innerHeight, + scrollWidth, + scrollHeight, + overflowX, + overflowY, + verticalScrollAccepted, + overflowDisposition: authoredClipped || overflowX || (overflowY && !verticalScrollAccepted) + ? 'unexpected-overflow' + : verticalScrollAccepted ? 'readable-vertical-scroll' : 'contained', + readerLayout, + readerOverflow, + readerFit, + ...(readerFit === 'authored-height' ? { diagramType: metrics.diagramType, documentScrollUnclipped: metrics.documentScrollUnclipped === true } : {}), + ok: containmentOk, + readerWidth: Number(metrics.readerWidth) || null, + diagramWidth: Number(metrics.diagramWidth) || null, + ...(metrics.pageComposition ? { pageComposition: metrics.pageComposition } : {}), + viewBoxWidth: Number(metrics.viewBoxWidth) || null, + ...(metrics.workflowLanes?.length ? { workflowLanes: metrics.workflowLanes } : {}), + minimumProjectedNodeTextPx, + minimumProjectedNonEdgeTextPx, + minimumProjectedEdgeTextPx, + minimumProjectedNodeText: metrics.minimumProjectedNodeText || null, + minimumProjectedNodeTextDetail: metrics.minimumProjectedNodeTextDetail || null, + minimumProjectedNodeTextOwner: metrics.minimumProjectedNodeTextOwner || null, + minimumRequiredNodeTextPx: MIN_PROJECTED_NODE_TEXT_PX, + readabilityOk, + hasLegend: Boolean(metrics.hasLegend), + hasNavigationDock: Boolean(metrics.hasNavigationDock), + legendDockIntersectionArea, + dockStageIntersectionArea, + dockStageGap, + requiredDockStageGap, + viewerChromeStageOk, + viewerChromeReserve: Number(metrics.viewerChromeReserve) || 0, + viewerChromeActive: Boolean(metrics.viewerChromeActive), + viewerChromeOk, + resolvedTheme: metrics.resolvedTheme || theme, + }; +} + +function contactSheetHtml({ artifactPath, receipt, screenshots }) { + const cards = screenshots.map((entry) => ` +
+ ${htmlEscape(`${entry.theme} ${entry.width} by ${entry.height}`)} +
${htmlEscape(entry.theme.toUpperCase())} · ${entry.width}×${entry.height} · containment ${entry.ok ? 'pass' : 'fail'}${entry.verticalScrollAccepted ? ' · readable vertical scroll' : ''}
+
`).join(''); + return ` + + + + +Archify automated browser evidence · ${htmlEscape(path.basename(artifactPath))} + + + +

Automated browser evidence

${htmlEscape(path.basename(artifactPath))} · visual-check containment ${htmlEscape(receipt.containment.status)} · perceptual visual review pending

+
${cards} +
+ + +`; +} + +const publishedBrowserEvidenceReceipts = new WeakSet(); + +// Keep detailed observations in the bound receipt; expose every review image and +// every failure without repeating the same viewport metrics in CLI context. +export function summarizeBrowserEvidence(receipt) { + const directory = receipt.sidecars?.directory || path.dirname(receipt.artifact.path); + const evidencePath = (file) => file ? path.resolve(directory, file) : undefined; + const published = publishedBrowserEvidenceReceipts.has(receipt); + return { + schemaVersion: receipt.schemaVersion, + command: receipt.command, + ok: receipt.ok, + status: receipt.status, + evidenceKind: receipt.evidenceKind, + visualReview: receipt.visualReview, + artifact: receipt.artifact, + provenance: receipt.provenance, + deliveryReceiptId: receipt.deliveryReceiptId, + state: receipt.state, + chrome: receipt.chrome, + checks: Object.fromEntries(['containment', 'readability', 'viewerChrome', 'themeStates', 'captures'] + .filter((key) => receipt[key]) + .map((key) => [key, receipt[key].status])), + error: receipt.error, + publication: receipt.publication, + diagnostics: receipt.diagnostics || [], + evidence: { + receipt: published ? evidencePath(receipt.sidecars?.receipt) : undefined, + contactSheet: published ? evidencePath(receipt.captures?.contactSheet) : undefined, + screenshots: (published ? receipt.captures?.screenshots || [] : []).map((entry) => ({ + path: evidencePath(entry.file), + width: entry.width, + height: entry.height, + theme: entry.theme, + resolvedTheme: entry.resolvedTheme, + })), + }, + }; +} + +function viewportSubject(artifact, entry) { + return { + artifact, + viewport: { width: entry.width, height: entry.height, theme: entry.theme }, + }; +} + +function failureDiagnostic({ code, message, subject, evidence, supportedFixes, severity = 'error' }) { + return { code, severity, message, subject, evidence, supportedFixes }; +} + +function resolveSidecarDirectory(artifactPath, outputs, compareParents) { + const artifact = path.resolve(artifactPath); + const artifactDirectory = path.dirname(artifact); + const sidecarDirectory = path.dirname(outputs.receipt); + const comparison = compareParents(outputs.receipt, artifact); + + const sidecars = { + ...(comparison?.status === 'different' ? { directory: sidecarDirectory } : {}), + receipt: path.basename(outputs.receipt), + ...(outputs.capture === false ? {} : { contactSheet: path.basename(outputs.contactSheet) }), + }; + if (comparison?.status === 'match' || comparison?.status === 'different') { + return { ok: true, sidecars }; + } + + const reason = comparison?.reason || { code: 'invalid-path-identity-result' }; + const error = `visual-check could not determine the physical identity of its evidence directory "${sidecarDirectory}" relative to the artifact directory "${artifactDirectory}".`; + return { + ok: false, + sidecars, + error, + diagnostic: failureDiagnostic({ + code: 'viewer/sidecar-directory-identity', + message: error, + subject: { artifact, sidecarDirectory }, + evidence: { comparison: reason }, + supportedFixes: [ + 'use existing, accessible artifact and evidence directories, then rerun visual-check', + ], + }), + }; +} + +// Turn a measured vertical overflow into the pixel budget an author can act on: +// which stacked block holds the height, and what the SVG or cards must shrink to. +export function verticalBudgetFixes(entry) { + const page = entry.pageComposition; + if (!entry.overflowY || !page) return []; + const excess = entry.scrollHeight - entry.innerHeight; + const fixes = []; + const stacked = `${page.bodyPaddingPx}px body padding + ${page.headerPx}px header + ${page.diagramChromePx}px diagram chrome + ${page.svgPx}px SVG + ${page.cardsPx}px cards = ${entry.scrollHeight}px against ${entry.innerHeight}px`; + if (page.svgPx > 0 && page.viewBoxHeight > 0) { + const targetSvg = page.svgPx - excess; + const targetViewBoxHeight = Math.floor(page.viewBoxHeight * targetSvg / page.svgPx); + if (entry.readerLayout === 'adaptive') { + fixes.push(targetSvg > 0 + ? `the page is ${excess}px too tall (${stacked}); the Reader already narrowed the stage to its ${entry.diagramWidth}px minimum, so the SVG height only follows meta.viewBox: keep every node and relationship and reduce the viewBox height to at most ${targetViewBoxHeight} (from ${page.viewBoxHeight}) by tightening vertical gaps and empty rows` + : `the page is ${excess}px too tall (${stacked}) and the SVG alone exceeds the viewport at the minimum reader width; split the diagram into two`); + } else { + fixes.push(`the page is ${excess}px too tall (${stacked}); the SVG spans the full ${entry.diagramWidth}px reader width because its viewBox ratio is below 1.55 and it declares no intrinsic-height fit, so the Reader can neither narrow it nor accept readable vertical scroll: either remove meta.viewBox so the renderer sizes the canvas and declares the fit, or make the viewBox at least 1.55x wider than tall`); + } + } + if (page.cardsPx >= excess) { + fixes.push(`alternatively, conclusion cards occupy ${page.cardsPx}px below the diagram; shortening card copy or dropping a card row so cards take at most ${page.cardsPx - excess}px also fits`); + } + return fixes; +} + +function observationDiagnostics({ artifact, allObservations, readabilityObservations, command }) { + const diagnostics = []; + for (const entry of allObservations) { + if (entry.resolvedTheme !== entry.theme) { + diagnostics.push(failureDiagnostic({ + code: 'viewer/theme-state', + message: `The rendered artifact resolved ${entry.resolvedTheme || 'no theme'} instead of the requested ${entry.theme} theme at ${entry.width}x${entry.height}.`, + subject: viewportSubject(artifact, entry), + evidence: { + requestedTheme: entry.theme, + resolvedTheme: entry.resolvedTheme, + }, + supportedFixes: [ + `restore deterministic ${entry.theme} theme resolution, then rerun ${command}`, + ], + })); + } + if (!entry.ok) { + const authoredClipped = entry.readerFit === 'authored-height' && entry.diagramType === 'architecture' + && !entry.documentScrollUnclipped; + const budgetFixes = authoredClipped + ? ['restore the full SVG inside the diagram panel and allow normal document scrolling; remove internal scrollers and clipping without changing authored geometry'] + : verticalBudgetFixes(entry); + diagnostics.push(failureDiagnostic({ + code: authoredClipped ? 'viewer/diagram-clipped' : 'viewer/viewport-overflow', + message: authoredClipped + ? `The authored Architecture canvas is clipped or cannot scroll in the document at ${entry.width}x${entry.height} (${entry.theme}).` + : `The rendered artifact overflows the ${entry.width}x${entry.height} ${entry.theme} viewport.`, + subject: viewportSubject(artifact, entry), + evidence: { + innerWidth: entry.innerWidth, + innerHeight: entry.innerHeight, + scrollWidth: entry.scrollWidth, + scrollHeight: entry.scrollHeight, + overflowX: entry.overflowX, + overflowY: entry.overflowY, + overflowDisposition: entry.overflowDisposition, + readerLayout: entry.readerLayout, + readerOverflow: entry.readerOverflow, + readerFit: entry.readerFit, + ...(authoredClipped ? { documentScrollUnclipped: false } : {}), + ...(entry.overflowY && entry.pageComposition ? { + pageComposition: entry.pageComposition, + pageCompositionMeasurement: 'CSS pixels at this viewport; body padding, header, diagram chrome, SVG and cards stack vertically and sum to scrollHeight', + } : {}), + ...(entry.overflowY && entry.workflowLanes?.length ? { + workflowLanes: entry.workflowLanes, + measurement: 'CSS pixels; rendered node boxes geometrically contained in each lane frame; spaces include headers and routing, not guaranteed removable space', + } : {}), + }, + supportedFixes: [ + ...budgetFixes, + ...(entry.overflowY && entry.workflowLanes?.length ? [ + 'run validate workflow --layout-json and compare the tallest rendered lane frames with source lanes, col and yOffset; frame IDs are rendered indices, not source lane IDs', + 'where ownership and explicit geometry permit, distribute stacked steps across logical columns and meaningful lanes before increasing yOffset; preserve nodes, branches, labels and hard pins', + `read references/authoring-contract.md#workflow-viewport-repair, then validate and deliver the changed source before rerunning ${command} on the new artifact; this is inspection guidance, not a verified coordinate fix`, + ] : budgetFixes.length ? [] : [`contain the rendered layout within ${entry.width}x${entry.height}, then rerun ${command}`]), + ], + })); + } + if (entry.legendDockIntersectionArea > 0.5) { + diagnostics.push(failureDiagnostic({ + code: 'viewer/chrome-legend-clearance', + message: `The navigation Dock obscures the SVG Legend at ${entry.width}x${entry.height} (${entry.theme}).`, + subject: viewportSubject(artifact, entry), + evidence: { legendDockIntersectionArea: entry.legendDockIntersectionArea }, + supportedFixes: [ + `move the SVG Legend or Viewer Dock until legendDockIntersectionArea is 0, then rerun ${command}`, + ], + })); + } + if (!entry.viewerChromeStageOk) { + const stageOverlapsDock = entry.dockStageIntersectionArea > 0.5; + diagnostics.push(failureDiagnostic({ + code: 'viewer/chrome-stage-clearance', + message: stageOverlapsDock + ? `Navigation Dock enters the protected SVG stage at ${entry.width}x${entry.height} (${entry.theme}).` + : `Navigation Dock clearance from the protected SVG stage is below the required gap at ${entry.width}x${entry.height} (${entry.theme}).`, + subject: viewportSubject(artifact, entry), + evidence: { + dockStageIntersectionArea: entry.dockStageIntersectionArea, + dockStageGap: entry.dockStageGap, + requiredDockStageGap: entry.requiredDockStageGap, + }, + supportedFixes: [ + `adjust Viewer stage reservation or clipping until dockStageGap is at least ${entry.requiredDockStageGap} and dockStageIntersectionArea is 0, then rerun ${command}`, + ], + })); + } + } + for (const entry of readabilityObservations) { + if (entry.readabilityOk) continue; + diagnostics.push(failureDiagnostic({ + code: 'viewer/projected-text-readability', + message: `Projected ${entry.minimumProjectedNodeTextDetail || 'node'} text is below the readability floor at ${entry.width}x${entry.height}.`, + subject: viewportSubject(artifact, entry), + evidence: { + text: entry.minimumProjectedNodeText, + detail: entry.minimumProjectedNodeTextDetail, + owner: entry.minimumProjectedNodeTextOwner, + minimumProjectedNodeTextPx: entry.minimumProjectedNodeTextPx, + minimumRequiredNodeTextPx: entry.minimumRequiredNodeTextPx, + }, + supportedFixes: [ + `increase projected ${entry.minimumProjectedNodeTextOwner?.kind === 'edge' ? 'relationship label' : entry.minimumProjectedNodeTextOwner?.kind === 'boundary' ? 'boundary label' : 'node text'} to at least ${entry.minimumRequiredNodeTextPx}px at ${entry.width}x${entry.height}, then rerun ${command}`, + ], + })); + } + return diagnostics; +} + +function baseReceipt({ artifactPath, artifact, sidecars, chrome, deliveryProvenance, command, capture }) { + return { + schemaVersion: 1, + ok: false, + command, + evidenceKind: 'automated-browser', + status: 'fail', + visualReview: capture ? 'pending' : 'not-requested', + ...(deliveryProvenance ? { + provenance: deliveryProvenance.status, + ...(deliveryProvenance.receiptId ? { deliveryReceiptId: deliveryProvenance.receiptId } : {}), + } : {}), + artifact: { + path: artifactPath, + sha256: sha256(artifact), + bytes: artifact.byteLength, + }, + state: { detail: 'read', motion: 'still' }, + chrome, + diagnostics: [], + containment: { + status: 'fail', + policy: 'fit-or-reader-declared-readable-vertical-scroll', + viewports: [], + }, + themeStates: { status: 'fail', viewports: [] }, + readability: { status: 'fail', minimumProjectedNodeTextPx: MIN_PROJECTED_NODE_TEXT_PX, viewports: [] }, + viewerChrome: { status: 'fail', viewports: [] }, + captures: { status: capture ? 'fail' : 'not-requested', screenshots: [], contactSheet: null }, + sidecars: { ...sidecars, files: [] }, + }; +} + +function appendEvidencePublicationFailure(receipt, failure) { + if (!failure?.ok) { + if (!receipt.error) receipt.error = failure.error; + receipt.diagnostics = [...(receipt.diagnostics || []), failure.diagnostic]; + } +} + +function appendEvidenceCleanupWarning(receipt, publication) { + if (publication?.status !== 'committed-with-warning') return false; + const committedStatus = receipt.status; + const cleanupErrors = publication.cleanupErrors || []; + const cleanupMessage = publication.recoveryDirectory + ? `The evidence set was committed, but cleanup is incomplete. Recovery directory: "${publication.recoveryDirectory}".` + : 'The evidence set was committed, but cleanup is incomplete.'; + receipt.ok = false; + receipt.status = 'fail'; + receipt.error = receipt.error ? `${receipt.error} ${cleanupMessage}` : cleanupMessage; + receipt.publication = { + status: publication.status, + committedReceipt: publication.committedReceipt, + ...(publication.recoveryDirectory + ? { recoveryDirectory: publication.recoveryDirectory } : {}), + cleanupErrors, + committedStatus, + }; + receipt.diagnostics = [...(receipt.diagnostics || []), failureDiagnostic({ + code: 'viewer/evidence-cleanup-incomplete', + severity: 'warning', + message: cleanupMessage, + subject: { + artifact: receipt.artifact?.path, + receipt: publication.committedReceipt, + }, + evidence: { + publicationStatus: publication.status, + ...(publication.recoveryDirectory + ? { recoveryDirectory: publication.recoveryDirectory } : {}), + cleanupErrors, + }, + supportedFixes: publication.recoveryDirectory + ? [`inspect and safely retire the retained files under "${publication.recoveryDirectory}"`] + : ['resolve the reported filesystem cleanup failure before rerunning visual-check'], + })]; + return true; +} + +function publishReceiptOnly(artifactPath, outputs, receipt, ownership) { + const cleanupErrors = cleanupStagedEvidence(ownership); + if (cleanupErrors.length) { + const failure = evidenceWriteFailure( + artifactPath, + 'visual-check could not safely retire this run\'s incomplete staged evidence.', + cleanupErrors, + ); + cleanupStagedEvidence(ownership, { removeDirectory: true }); + ownership.active = false; + return failure; + } + try { + writeStagedEvidence(ownership, outputs.receipt, `${JSON.stringify(receipt, null, 2)}\n`); + } catch (error) { + const failure = evidenceWriteFailure( + artifactPath, + 'visual-check could not stage its evidence receipt.', + [{ file: stagedPathFor(ownership, outputs.receipt), reason: error.message }], + ); + cleanupStagedEvidence(ownership, { removeDirectory: true }); + ownership.active = false; + return failure; + } + return commitVisualEvidence(artifactPath, outputs, ownership, [outputs.receipt]); +} + +function persistBrowserEvidenceFailure( + artifactPath, + failure, + { outDir, compareSidecarParents = sameParent, ownership, command = 'visual-check', capture = true } = {}, +) { + const outputs = evidenceSidecarPaths(artifactPath, { outDir, command, capture }); + const directory = resolveSidecarDirectory(artifactPath, outputs, compareSidecarParents); + const capturedArtifact = readRegularFileContents(artifactPath, { + subject: 'failure-artifact', + }); + const artifactBytes = capturedArtifact.ok ? capturedArtifact.bytes : null; + const receipt = { + ...failure, + ok: false, status: 'fail', + ...(artifactBytes ? { + artifact: { + path: path.resolve(artifactPath), + sha256: sha256(artifactBytes), + bytes: artifactBytes.byteLength, + }, + } : {}), + containment: { + status: 'fail', + policy: 'fit-or-reader-declared-readable-vertical-scroll', + viewports: [], + }, + captures: { status: capture ? 'fail' : 'not-requested', screenshots: [], contactSheet: null }, + sidecars: { ...directory.sidecars, files: [] }, + }; + if (!directory.ok) { + receipt.error = directory.error; + receipt.diagnostics = [...(failure.diagnostics || []), directory.diagnostic]; + return receipt; + } + let activeOwnership = ownership; + if (!visualEvidenceOwnershipMatches(activeOwnership, artifactPath, outputs)) { + if (!artifactBytes) { + const conflict = evidencePathConflict(artifactPath, outputs, outputs.receipt, { + code: 'artifact-unreadable', + }); + receipt.error = conflict.error; + receipt.diagnostics = [...(failure.diagnostics || []), conflict.diagnostic]; + return receipt; + } + const preflight = beginVisualEvidenceWrite(artifactPath, artifactBytes, outputs); + if (!preflight.ok) { + receipt.error = preflight.error; + receipt.diagnostics = [...(failure.diagnostics || []), preflight.diagnostic]; + return receipt; + } + activeOwnership = preflight.ownership; + } + const publication = publishReceiptOnly(artifactPath, outputs, receipt, activeOwnership); + if (publication.ok) publishedBrowserEvidenceReceipts.add(receipt); + appendEvidencePublicationFailure(receipt, publication); + appendEvidenceCleanupWarning(receipt, publication); + return receipt; +} + +async function runBrowserEvidence({ + artifactPath, + outDir, + chromePath, + resolveChrome = findChrome, + browserFactory = async (resolvedChrome) => new ChromeVisualBrowser(resolvedChrome), + deliveryProvenance, + verifyArtifact, + compareSidecarParents = sameParent, + command, capture, +} = {}) { + if (!artifactPath) throw new Error(`${command} requires one delivered HTML artifact.`); + const artifact = path.resolve(artifactPath); + if (!/\.html?$/i.test(artifact)) throw new Error(`${command} requires an .html artifact.`); + const capturedArtifact = captureRegularFileBinding(artifact, { + subject: 'visual-check-artifact', + expectedLinks: 1, + includeContent: true, + }); + if (capturedArtifact.status !== 'captured') { + throw regularFileCaptureError('The visual-check artifact', capturedArtifact); + } + const artifactBytes = capturedArtifact.content.buffer; + try { + const outputs = evidenceSidecarPaths(artifact, { outDir, command, capture }); + const directory = resolveSidecarDirectory(artifact, outputs, compareSidecarParents); + const receipt = baseReceipt({ + artifactPath: artifact, + artifact: artifactBytes, + sidecars: directory.sidecars, + chrome: { status: 'not-checked', executable: null }, + deliveryProvenance, command, capture, + }); + if (!directory.ok) { + receipt.error = directory.error; + receipt.diagnostics = [directory.diagnostic]; + return { exitCode: EXIT.fail, receipt }; + } + const preflight = beginVisualEvidenceWrite(artifact, artifactBytes, outputs); + if (!preflight.ok) { + receipt.error = preflight.error; + receipt.diagnostics = [preflight.diagnostic]; + return { exitCode: EXIT.fail, receipt }; + } + const ownership = preflight.ownership; + let inspectionArtifact; + try { + // Chrome's file loader cannot reliably open long Windows/UNC paths. Keep + // the one inspected snapshot local; publish evidence on its target volume. + ownership.inspection = { + directory: fs.mkdtempSync(path.join(os.tmpdir(), 'archify-inspection-')), + }; + const stat = fs.lstatSync(ownership.inspection.directory, { bigint: true }); + if (!stat.isDirectory() || stat.isSymbolicLink() || stat.ino === 0n) { + throw new Error('the private inspection directory has no stable identity'); + } + ownership.inspection.identity = fileIdentity(stat); + inspectionArtifact = path.join(ownership.inspection.directory, 'artifact-snapshot.html'); + ownership.stagingPaths.set(artifact, inspectionArtifact); + writeStagedEvidence(ownership, artifact, artifactBytes); + } catch (error) { + return { exitCode: EXIT.fail, receipt: persistBrowserEvidenceFailure(artifact, { + ...receipt, + status: 'fail', + ok: false, + error: `visual-check could not stage its private artifact snapshot: ${error.message}`, + diagnostics: [failureDiagnostic({ + code: 'viewer/artifact-snapshot', + message: 'visual-check could not bind a private artifact snapshot.', + subject: { artifact }, + evidence: { reason: error.message }, + supportedFixes: ['retry after resolving the reported staging filesystem error'], + })], + }, { outDir, compareSidecarParents, ownership, command, capture }) }; + } + const verifyInspectionArtifact = () => { + const entry = ownership.stagedEntries.get(artifact); + if (!entry || !currentEvidenceMatches(entry.path, entry.identity, entry.evidence)) { + throw new Error('The private visual-check artifact snapshot changed during inspection.'); + } + }; + try { + verifyArtifact?.(artifactBytes); + } catch (error) { + return { exitCode: EXIT.fail, receipt: persistBrowserEvidenceFailure(artifact, { + schemaVersion: 1, command, evidenceKind: 'automated-browser', visualReview: capture ? 'pending' : 'not-requested', + artifact: { path: artifact, sha256: sha256(artifactBytes), bytes: artifactBytes.byteLength }, + provenance: error.deliveryProvenance?.status, error: error.message, + diagnostics: error.archifyDiagnostics || [], + }, { outDir, compareSidecarParents, ownership, command, capture }) }; + } + + const resolvedChrome = chromePath || resolveChrome(); + receipt.chrome = resolvedChrome + ? { status: 'available', executable: resolvedChrome } + : { status: 'unavailable', executable: null }; + + if (!resolvedChrome) { + receipt.status = 'skipped'; + receipt.containment.status = 'skipped'; + receipt.readability.status = 'skipped'; + receipt.viewerChrome.status = 'skipped'; + receipt.themeStates.status = 'skipped'; + if (capture) receipt.captures.status = 'skipped'; + receipt.error = 'Chrome or Chromium is unavailable. Set ARCHIFY_CHROME to its executable path.'; + receipt.diagnostics = [failureDiagnostic({ + code: 'viewer/chrome-unavailable', + severity: 'warning', + message: receipt.error, + subject: { artifact }, + evidence: { executable: null }, + supportedFixes: [`set ARCHIFY_CHROME to a Chrome or Chromium executable and rerun ${command}`], + })]; + const publication = publishReceiptOnly(artifact, outputs, receipt, ownership); + if (publication.ok) publishedBrowserEvidenceReceipts.add(receipt); + if (!publication.ok) { + appendEvidencePublicationFailure(receipt, publication); + return { exitCode: EXIT.fail, receipt }; + } + if (appendEvidenceCleanupWarning(receipt, publication)) { + return { exitCode: EXIT.fail, receipt }; + } + return { exitCode: EXIT.skipped, receipt }; + } + + let browser; + try { + browser = await browserFactory(resolvedChrome); + const observations = new Map(); + const screenshotsByKey = new Map(outputs.screenshots.map((entry, index) => [ + screenshotKey(entry.width, entry.height, entry.theme), + { ...entry, stagedPath: ownership.stagedOutputs.screenshots[index].path }, + ])); + + for (const viewport of VISUAL_CHECK_VIEWPORTS) { + const key = screenshotKey(viewport.width, viewport.height, 'light'); + const screenshot = screenshotsByKey.get(key); + const metrics = await browser.inspect({ + artifactPath: inspectionArtifact, + ...viewport, + theme: 'light', + ...(screenshot ? { + screenshotPath: screenshot.stagedPath, + writeScreenshot: (bytes) => writeStagedEvidence(ownership, screenshot.path, bytes), + } : {}), + }); + verifyInspectionArtifact(); + if (screenshot) { + if (screenshot && !ownership.stagedEntries.has(screenshot.path)) { + registerStagedEvidence(ownership, screenshot.path); + } else { + const staged = ownership.stagedEntries.get(screenshot.path); + if (!currentEvidenceMatches(staged.path, staged.identity, staged.evidence)) { + throw new Error('The staged visual-check screenshot changed after capture.'); + } + } + } + observations.set(key, observation({ ...viewport, theme: 'light', metrics })); + } + for (const viewport of CAPTURE_VIEWPORTS) { + const key = screenshotKey(viewport.width, viewport.height, 'dark'); + const screenshot = screenshotsByKey.get(key); + const metrics = await browser.inspect({ + artifactPath: inspectionArtifact, + ...viewport, + theme: 'dark', + ...(screenshot ? { + screenshotPath: screenshot.stagedPath, + writeScreenshot: (bytes) => writeStagedEvidence(ownership, screenshot.path, bytes), + } : {}), + }); + verifyInspectionArtifact(); + if (screenshot && !ownership.stagedEntries.has(screenshot.path)) { + registerStagedEvidence(ownership, screenshot.path); + } else if (screenshot) { + const staged = ownership.stagedEntries.get(screenshot.path); + if (!currentEvidenceMatches(staged.path, staged.identity, staged.evidence)) { + throw new Error('The staged visual-check screenshot changed after capture.'); + } + } + observations.set(key, observation({ ...viewport, theme: 'dark', metrics })); + } + + let artifactVerification = verifyRegularFileBinding(capturedArtifact.binding); + if (artifactVerification.status !== 'match') { + throw regularFileCaptureError(`The delivered artifact changed while ${command} was running`, artifactVerification); + } + verifyArtifact?.(artifactBytes); + artifactVerification = verifyRegularFileBinding(capturedArtifact.binding); + if (artifactVerification.status !== 'match') { + throw regularFileCaptureError(`The delivered artifact changed while ${command} was running`, artifactVerification); + } + + receipt.containment.viewports = VISUAL_CHECK_VIEWPORTS.map(({ width, height }) => ( + observations.get(screenshotKey(width, height, 'light')) + )); + receipt.readability.viewports = receipt.containment.viewports.map((entry) => ({ ...entry })); + receipt.viewerChrome.viewports = receipt.containment.viewports.map((entry) => ({ ...entry })); + receipt.captures.screenshots = outputs.screenshots.map((entry) => ({ + ...observations.get(screenshotKey(entry.width, entry.height, entry.theme)), + file: path.basename(entry.path), + })); + const allObservations = [...observations.values()]; + receipt.themeStates.viewports = allObservations.map((entry) => ({ + width: entry.width, + height: entry.height, + requestedTheme: entry.theme, + resolvedTheme: entry.resolvedTheme, + ok: entry.resolvedTheme === entry.theme, + })); + const themeStatePass = receipt.themeStates.viewports.every(entry => entry.ok); + const containmentPass = allObservations.every((entry) => entry.ok); + const readabilityPass = receipt.readability.viewports.every((entry) => entry.readabilityOk); + const viewerChromePass = allObservations.every((entry) => entry.viewerChromeOk); + receipt.diagnostics = observationDiagnostics({ + artifact, + allObservations, + readabilityObservations: receipt.readability.viewports, command, + }); + receipt.themeStates.status = themeStatePass ? 'pass' : 'fail'; + receipt.containment.status = containmentPass ? 'pass' : 'fail'; + receipt.readability.status = readabilityPass ? 'pass' : 'fail'; + receipt.viewerChrome.status = viewerChromePass ? 'pass' : 'fail'; + if (capture) { + receipt.captures.status = 'pass'; + receipt.captures.contactSheet = path.basename(outputs.contactSheet); + } + receipt.status = containmentPass && themeStatePass && readabilityPass && viewerChromePass ? 'pass' : 'fail'; + receipt.ok = containmentPass && themeStatePass && readabilityPass && viewerChromePass; + if (capture) writeStagedEvidence(ownership, outputs.contactSheet, contactSheetHtml({ + artifactPath: artifact, + receipt, + screenshots: receipt.captures.screenshots, + })); + recordVisualEvidenceFiles(receipt, outputs, ownership); + writeStagedEvidence(ownership, outputs.receipt, `${JSON.stringify(receipt, null, 2)}\n`); + const publication = commitVisualEvidence( + artifact, + outputs, + ownership, + visualEvidenceTargets(outputs).map((target) => target.path), + ); + if (!publication.ok) { + appendEvidencePublicationFailure(receipt, publication); + receipt.ok = false; + receipt.status = 'fail'; + return { exitCode: EXIT.fail, receipt }; + } + publishedBrowserEvidenceReceipts.add(receipt); + if (appendEvidenceCleanupWarning(receipt, publication)) { + return { exitCode: EXIT.fail, receipt }; + } + return { exitCode: receipt.ok ? EXIT.pass : EXIT.fail, receipt }; + } catch (error) { + const startupTimeout = error.code === 'ERR_CHROME_CDP_TIMEOUT' + && error.method === 'Target.getTargets'; + receipt.status = 'fail'; + receipt.ok = false; + receipt.error = error.message; + receipt.containment.status = 'fail'; + receipt.readability.status = 'fail'; + receipt.viewerChrome.status = 'fail'; + receipt.themeStates.status = 'fail'; + receipt.captures.status = capture ? 'fail' : 'not-requested'; + receipt.captures.screenshots = []; + receipt.captures.contactSheet = null; + if (error.deliveryProvenance) receipt.provenance = error.deliveryProvenance.status; + receipt.diagnostics = error.archifyDiagnostics || [failureDiagnostic({ + code: startupTimeout ? 'viewer/chrome-startup-timeout' : `viewer/${command}-runtime`, + message: startupTimeout + ? 'Chrome did not finish its initial DevTools handshake within the startup window.' + : `${command} could not complete its Chrome inspection.`, + subject: { artifact }, + evidence: { reason: error.message }, + supportedFixes: startupTimeout + ? [ + 'do not edit or simplify the artifact because this is a browser-startup failure', + `retry ${command} once after host load subsides; if it repeats, stop and report the environment failure`, + ] + : [`resolve the reported Chrome inspection error, then rerun ${command}`], + })]; + return { + exitCode: EXIT.fail, + receipt: persistBrowserEvidenceFailure(artifact, receipt, { + outDir, + compareSidecarParents, + ownership, command, capture, + }), + }; + } finally { + if (browser?.close) await browser.close(); + } + } finally { + releaseRegularFileBinding(capturedArtifact.binding); + } +} + +export function persistVisualCheckFailure(artifactPath, failure, options = {}) { + return persistBrowserEvidenceFailure(artifactPath, failure, { ...options, command: 'visual-check', capture: true }); +} +export function persistBrowserCheckFailure(artifactPath, failure, options = {}) { + return persistBrowserEvidenceFailure(artifactPath, failure, { ...options, command: 'browser-check', capture: false }); +} +export function runVisualCheck(options = {}) { + return runBrowserEvidence({ ...options, command: 'visual-check', capture: true }); +} +export function runBrowserCheck(options = {}) { + return runBrowserEvidence({ ...options, command: 'browser-check', capture: false }); +} diff --git a/scripts/vendor/archify/brand-marks/README.md b/scripts/vendor/archify/brand-marks/README.md new file mode 100644 index 0000000..83578f6 --- /dev/null +++ b/scripts/vendor/archify/brand-marks/README.md @@ -0,0 +1,31 @@ +# Built-in brand marks + +Archify ships a bounded catalogue of 107 commonly used brands for architecture, +workflow, sequence, data-flow, and lifecycle nodes. The mark is optional authored +identity: it never replaces the node's semantic `type`, color, label, or +relationships. + +Unknown sites are handled by an explicit two-stage workflow. Run +`node bin/archify.mjs brands capture --json`, then author the returned +digest-pinned `brand` value. Normal render and validate commands do not perform +an unpinned capture, and changed or unavailable content fails closed. + +Most vector paths and brand metadata are generated from Simple Icons 16.28.0. +The OpenAI mark is traced to OpenAI's official brand guidelines. Every generated +entry records its source and, when available upstream, its guidelines and license +metadata in `renderers/shared/generated-brand-marks.mjs`. + +Brand names and logos may be trademarks of their respective owners. Simple +Icons' CC0 license covers its collection work, not every underlying trademark or +artwork. Contributors must review the recorded source, current brand guidelines, +and intended referential use before adding or updating a mark. Archify does not +imply sponsorship, endorsement, or partnership. + +Edit `catalog.json`, then regenerate the committed zero-runtime-dependency bundle: + +```bash +npm run generate:brand-marks +npm run check:brand-marks +``` + +Do not hand-edit `renderers/shared/generated-brand-marks.mjs`. diff --git a/scripts/vendor/archify/brand-marks/catalog.json b/scripts/vendor/archify/brand-marks/catalog.json new file mode 100644 index 0000000..ac56989 --- /dev/null +++ b/scripts/vendor/archify/brand-marks/catalog.json @@ -0,0 +1,131 @@ +{ + "schemaVersion": 1, + "marks": [ + { + "id": "openai", + "title": "OpenAI", + "category": "ai", + "aliases": ["chatgpt", "gpt", "codex"], + "domains": ["openai.com", "chatgpt.com"], + "custom": { + "viewBox": 20, + "hex": "000000", + "path": "M11.248 18.25q-.825 0-1.568-.314a4.3 4.3 0 0 1-1.32-.874 4 4 0 0 1-1.304.214 4 4 0 0 1-2.046-.544 4.27 4.27 0 0 1-1.518-1.485 4 4 0 0 1-.56-2.095q0-.48.131-1.04A4.4 4.4 0 0 1 2.04 10.71a4.07 4.07 0 0 1 .017-3.4 4.2 4.2 0 0 1 1.056-1.418 3.8 3.8 0 0 1 1.6-.842 3.9 3.9 0 0 1 .76-1.683q.593-.759 1.451-1.188a4.04 4.04 0 0 1 1.832-.429q.825 0 1.567.313.742.314 1.32.875a4 4 0 0 1 1.304-.215q1.106 0 2.046.545a4.14 4.14 0 0 1 1.501 1.485q.578.941.578 2.095 0 .48-.132 1.04.66.61 1.023 1.419.363.792.363 1.666 0 .892-.38 1.717a4.3 4.3 0 0 1-1.072 1.435 3.8 3.8 0 0 1-1.584.825 3.8 3.8 0 0 1-.775 1.683 4.06 4.06 0 0 1-1.436 1.188 4.04 4.04 0 0 1-1.832.429m-4.076-2.062q.825 0 1.435-.347l3.103-1.782a.36.36 0 0 0 .164-.313v-1.42L7.881 14.62a.67.67 0 0 1-.726 0l-3.118-1.798a.5.5 0 0 1-.017.115v.198q0 .841.396 1.551.413.693 1.139 1.089a3.2 3.2 0 0 0 1.617.412m.165-2.69a.4.4 0 0 0 .181.05q.083 0 .165-.05l1.238-.71-3.977-2.31a.7.7 0 0 1-.363-.643v-3.58q-.825.362-1.32 1.122a2.9 2.9 0 0 0-.495 1.65q0 .809.413 1.55.412.743 1.072 1.123zm3.91 3.663q.875 0 1.585-.396a2.96 2.96 0 0 0 1.534-2.64v-3.564a.32.32 0 0 0-.165-.297l-1.254-.726v4.604a.7.7 0 0 1-.363.643l-3.119 1.799a3 3 0 0 0 1.783.577m.627-6.039V8.878L10.01 7.822 8.129 8.878v2.244l1.881 1.056zM7.057 5.859a.7.7 0 0 1 .363-.644l3.119-1.798a3 3 0 0 0-1.782-.578q-.874 0-1.584.396A2.96 2.96 0 0 0 6.05 4.324a3.07 3.07 0 0 0-.396 1.551v3.547q0 .199.165.314l1.237.726zm8.383 7.887q.825-.364 1.303-1.123.495-.758.495-1.65a3.15 3.15 0 0 0-.412-1.55q-.413-.743-1.073-1.123l-3.086-1.782q-.099-.065-.181-.049a.3.3 0 0 0-.165.05l-1.238.692 3.993 2.327a.6.6 0 0 1 .264.264.64.64 0 0 1 .1.363zm-3.317-8.382a.63.63 0 0 1 .726 0l3.135 1.831v-.297q0-.792-.396-1.501a2.86 2.86 0 0 0-1.105-1.155q-.71-.43-1.65-.43-.825 0-1.436.347L8.294 5.941a.36.36 0 0 0-.165.314v1.418z", + "source": "https://openai.com/brand/", + "guidelines": "https://openai.com/brand/" + } + }, + { "id": "claude", "category": "ai", "simpleIcon": "claude", "aliases": ["claude-ai"], "domains": ["claude.ai"] }, + { "id": "anthropic", "category": "ai", "simpleIcon": "anthropic", "domains": ["anthropic.com"] }, + { "id": "google-gemini", "category": "ai", "simpleIcon": "googlegemini", "aliases": ["gemini"], "domains": ["gemini.google.com"] }, + { "id": "deepseek", "category": "ai", "simpleIcon": "deepseek", "domains": ["deepseek.com"] }, + { "id": "qwen", "category": "ai", "simpleIcon": "qwen", "domains": ["qwen.ai"] }, + { "id": "meta", "category": "ai", "simpleIcon": "meta", "aliases": ["llama"], "domains": ["meta.com"] }, + { "id": "mistral-ai", "category": "ai", "simpleIcon": "mistralai", "aliases": ["mistral"], "domains": ["mistral.ai"] }, + { "id": "hugging-face", "category": "ai", "simpleIcon": "huggingface", "aliases": ["huggingface"], "domains": ["huggingface.co"] }, + { "id": "ollama", "category": "ai", "simpleIcon": "ollama", "domains": ["ollama.com"] }, + { "id": "openrouter", "category": "ai", "simpleIcon": "openrouter", "aliases": ["open-router"], "domains": ["openrouter.ai"] }, + { "id": "perplexity", "category": "ai", "simpleIcon": "perplexity", "domains": ["perplexity.ai"] }, + { "id": "replicate", "category": "ai", "simpleIcon": "replicate", "domains": ["replicate.com"] }, + + { "id": "google-cloud", "category": "cloud", "simpleIcon": "googlecloud", "aliases": ["gcp", "googlecloud"], "domains": ["cloud.google.com"] }, + { "id": "cloudflare", "category": "cloud", "simpleIcon": "cloudflare", "domains": ["cloudflare.com"] }, + { "id": "vercel", "category": "cloud", "simpleIcon": "vercel", "domains": ["vercel.com"] }, + { "id": "netlify", "category": "cloud", "simpleIcon": "netlify", "domains": ["netlify.com"] }, + { "id": "digitalocean", "category": "cloud", "simpleIcon": "digitalocean", "aliases": ["digital-ocean"], "domains": ["digitalocean.com"] }, + { "id": "render", "category": "cloud", "simpleIcon": "render", "domains": ["render.com"] }, + { "id": "railway", "category": "cloud", "simpleIcon": "railway", "domains": ["railway.com", "railway.app"] }, + { "id": "fly-io", "category": "cloud", "simpleIcon": "flydotio", "aliases": ["fly.io"], "domains": ["fly.io"] }, + { "id": "cloudinary", "category": "cloud", "simpleIcon": "cloudinary", "domains": ["cloudinary.com"] }, + { "id": "alibaba-cloud", "category": "cloud", "simpleIcon": "alibabacloud", "aliases": ["aliyun"], "domains": ["alibabacloud.com", "aliyun.com"] }, + { "id": "firebase", "category": "cloud", "simpleIcon": "firebase", "domains": ["firebase.google.com"] }, + { "id": "supabase", "category": "cloud", "simpleIcon": "supabase", "domains": ["supabase.com"] }, + { "id": "neon", "category": "cloud", "simpleIcon": "neon", "domains": ["neon.tech"] }, + + { "id": "github", "category": "engineering", "simpleIcon": "github", "domains": ["github.com"] }, + { "id": "gitlab", "category": "engineering", "simpleIcon": "gitlab", "domains": ["gitlab.com"] }, + { "id": "bitbucket", "category": "engineering", "simpleIcon": "bitbucket", "domains": ["bitbucket.org"] }, + { "id": "docker", "category": "engineering", "simpleIcon": "docker", "domains": ["docker.com"] }, + { "id": "kubernetes", "category": "engineering", "simpleIcon": "kubernetes", "aliases": ["k8s"], "domains": ["kubernetes.io"] }, + { "id": "terraform", "category": "engineering", "simpleIcon": "terraform", "domains": ["terraform.io"] }, + { "id": "pulumi", "category": "engineering", "simpleIcon": "pulumi", "domains": ["pulumi.com"] }, + { "id": "ansible", "category": "engineering", "simpleIcon": "ansible", "domains": ["ansible.com"] }, + { "id": "jenkins", "category": "engineering", "simpleIcon": "jenkins", "domains": ["jenkins.io"] }, + { "id": "circleci", "category": "engineering", "simpleIcon": "circleci", "aliases": ["circle-ci"], "domains": ["circleci.com"] }, + { "id": "github-actions", "category": "engineering", "simpleIcon": "githubactions" }, + { "id": "argo", "category": "engineering", "simpleIcon": "argo", "aliases": ["argocd", "argo-cd"], "domains": ["argoproj.github.io"] }, + { "id": "helm", "category": "engineering", "simpleIcon": "helm", "domains": ["helm.sh"] }, + { "id": "grafana", "category": "engineering", "simpleIcon": "grafana", "domains": ["grafana.com"] }, + { "id": "prometheus", "category": "engineering", "simpleIcon": "prometheus", "domains": ["prometheus.io"] }, + { "id": "sentry", "category": "engineering", "simpleIcon": "sentry", "domains": ["sentry.io"] }, + { "id": "datadog", "category": "engineering", "simpleIcon": "datadog", "domains": ["datadoghq.com"] }, + { "id": "pagerduty", "category": "engineering", "simpleIcon": "pagerduty", "aliases": ["pager-duty"], "domains": ["pagerduty.com"] }, + + { "id": "postgresql", "category": "data", "simpleIcon": "postgresql", "aliases": ["postgres"], "domains": ["postgresql.org"] }, + { "id": "mysql", "category": "data", "simpleIcon": "mysql", "domains": ["mysql.com"] }, + { "id": "mongodb", "category": "data", "simpleIcon": "mongodb", "aliases": ["mongo"], "domains": ["mongodb.com"] }, + { "id": "redis", "category": "data", "simpleIcon": "redis", "domains": ["redis.io"] }, + { "id": "apache-kafka", "category": "data", "simpleIcon": "apachekafka", "aliases": ["kafka"], "domains": ["kafka.apache.org"] }, + { "id": "rabbitmq", "category": "data", "simpleIcon": "rabbitmq", "aliases": ["rabbit-mq"], "domains": ["rabbitmq.com"] }, + { "id": "clickhouse", "category": "data", "simpleIcon": "clickhouse", "domains": ["clickhouse.com"] }, + { "id": "elasticsearch", "category": "data", "simpleIcon": "elasticsearch", "aliases": ["elastic"], "domains": ["elastic.co"] }, + { "id": "opensearch", "category": "data", "simpleIcon": "opensearch", "aliases": ["open-search"], "domains": ["opensearch.org"] }, + { "id": "snowflake", "category": "data", "simpleIcon": "snowflake", "domains": ["snowflake.com"] }, + { "id": "databricks", "category": "data", "simpleIcon": "databricks", "domains": ["databricks.com"] }, + { "id": "planetscale", "category": "data", "simpleIcon": "planetscale", "aliases": ["planet-scale"], "domains": ["planetscale.com"] }, + { "id": "prisma", "category": "data", "simpleIcon": "prisma", "domains": ["prisma.io"] }, + { "id": "sqlite", "category": "data", "simpleIcon": "sqlite", "domains": ["sqlite.org"] }, + { "id": "mariadb", "category": "data", "simpleIcon": "mariadb", "aliases": ["maria-db"], "domains": ["mariadb.org"] }, + { "id": "influxdb", "category": "data", "simpleIcon": "influxdb", "aliases": ["influx-db"], "domains": ["influxdata.com"] }, + { "id": "apache-airflow", "category": "data", "simpleIcon": "apacheairflow", "aliases": ["airflow"], "domains": ["airflow.apache.org"] }, + + { "id": "notion", "category": "collaboration", "simpleIcon": "notion", "domains": ["notion.so"] }, + { "id": "figma", "category": "collaboration", "simpleIcon": "figma", "domains": ["figma.com"] }, + { "id": "jira", "category": "collaboration", "simpleIcon": "jira", "domains": ["atlassian.com"] }, + { "id": "linear", "category": "collaboration", "simpleIcon": "linear", "domains": ["linear.app"] }, + { "id": "discord", "category": "collaboration", "simpleIcon": "discord", "domains": ["discord.com"] }, + { "id": "zoom", "category": "collaboration", "simpleIcon": "zoom", "domains": ["zoom.us"] }, + { "id": "trello", "category": "collaboration", "simpleIcon": "trello", "domains": ["trello.com"] }, + { "id": "asana", "category": "collaboration", "simpleIcon": "asana", "domains": ["asana.com"] }, + { "id": "airtable", "category": "collaboration", "simpleIcon": "airtable", "domains": ["airtable.com"] }, + { "id": "miro", "category": "collaboration", "simpleIcon": "miro", "domains": ["miro.com"] }, + { "id": "stripe", "category": "business", "simpleIcon": "stripe", "domains": ["stripe.com"] }, + { "id": "shopify", "category": "business", "simpleIcon": "shopify", "domains": ["shopify.com"] }, + { "id": "hubspot", "category": "business", "simpleIcon": "hubspot", "domains": ["hubspot.com"] }, + { "id": "paypal", "category": "business", "simpleIcon": "paypal", "domains": ["paypal.com"] }, + { "id": "intercom", "category": "business", "simpleIcon": "intercom", "domains": ["intercom.com"] }, + { "id": "zendesk", "category": "business", "simpleIcon": "zendesk", "domains": ["zendesk.com"] }, + { "id": "wordpress", "category": "business", "simpleIcon": "wordpress", "domains": ["wordpress.org", "wordpress.com"] }, + { "id": "woocommerce", "category": "business", "simpleIcon": "woocommerce", "aliases": ["woo-commerce"], "domains": ["woocommerce.com"] }, + + { "id": "wechat", "category": "channel", "simpleIcon": "wechat", "aliases": ["weixin", "微信"], "domains": ["weixin.qq.com"] }, + { "id": "youtube", "category": "channel", "simpleIcon": "youtube", "domains": ["youtube.com", "youtu.be"] }, + { "id": "tiktok", "category": "channel", "simpleIcon": "tiktok", "aliases": ["douyin", "抖音"], "domains": ["tiktok.com", "douyin.com"] }, + { "id": "x", "category": "channel", "simpleIcon": "x", "aliases": ["twitter"], "domains": ["x.com", "twitter.com"] }, + { "id": "instagram", "category": "channel", "simpleIcon": "instagram", "domains": ["instagram.com"] }, + { "id": "facebook", "category": "channel", "simpleIcon": "facebook", "domains": ["facebook.com"] }, + { "id": "reddit", "category": "channel", "simpleIcon": "reddit", "domains": ["reddit.com"] }, + { "id": "telegram", "category": "channel", "simpleIcon": "telegram", "domains": ["telegram.org", "t.me"] }, + { "id": "whatsapp", "category": "channel", "simpleIcon": "whatsapp", "domains": ["whatsapp.com"] }, + { "id": "pinterest", "category": "channel", "simpleIcon": "pinterest", "domains": ["pinterest.com"] }, + + { "id": "python", "category": "language", "simpleIcon": "python", "domains": ["python.org"] }, + { "id": "typescript", "category": "language", "simpleIcon": "typescript", "aliases": ["ts"], "domains": ["typescriptlang.org"] }, + { "id": "javascript", "category": "language", "simpleIcon": "javascript", "aliases": ["js"] }, + { "id": "go", "category": "language", "simpleIcon": "go", "aliases": ["golang"], "domains": ["go.dev"] }, + { "id": "rust", "category": "language", "simpleIcon": "rust", "domains": ["rust-lang.org"] }, + { "id": "node-js", "category": "framework", "simpleIcon": "nodedotjs", "aliases": ["node", "nodejs"], "domains": ["nodejs.org"] }, + { "id": "react", "category": "framework", "simpleIcon": "react", "aliases": ["reactjs"], "domains": ["react.dev"] }, + { "id": "vue", "category": "framework", "simpleIcon": "vuedotjs", "aliases": ["vuejs", "vue.js"], "domains": ["vuejs.org"] }, + { "id": "next-js", "category": "framework", "simpleIcon": "nextdotjs", "aliases": ["nextjs", "next.js"], "domains": ["nextjs.org"] }, + { "id": "pytorch", "category": "framework", "simpleIcon": "pytorch", "domains": ["pytorch.org"] }, + { "id": "tensorflow", "category": "framework", "simpleIcon": "tensorflow", "domains": ["tensorflow.org"] }, + { "id": "angular", "category": "framework", "simpleIcon": "angular", "domains": ["angular.dev"] }, + { "id": "svelte", "category": "framework", "simpleIcon": "svelte", "domains": ["svelte.dev"] }, + { "id": "django", "category": "framework", "simpleIcon": "django", "domains": ["djangoproject.com"] }, + { "id": "flask", "category": "framework", "simpleIcon": "flask", "domains": ["palletsprojects.com"] }, + { "id": "fastapi", "category": "framework", "simpleIcon": "fastapi", "domains": ["fastapi.tiangolo.com"] }, + { "id": "spring", "category": "framework", "simpleIcon": "spring", "aliases": ["spring-boot"], "domains": ["spring.io"] }, + { "id": "dotnet", "category": "framework", "simpleIcon": "dotnet", "aliases": [".net"], "domains": ["dotnet.microsoft.com"] } + ] +} diff --git a/scripts/vendor/archify/delta/architecture-delta.mjs b/scripts/vendor/archify/delta/architecture-delta.mjs new file mode 100644 index 0000000..5c608f3 --- /dev/null +++ b/scripts/vendor/archify/delta/architecture-delta.mjs @@ -0,0 +1,1313 @@ +import { parseRepositoryRemote } from '../renderers/shared/repository-location.mjs'; + +const COMPARATOR_VERSION = 1; +const CANONICAL_VERSION = 1; + +export class ArchitectureDeltaError extends Error { + constructor(code, message, details = {}) { + super(message); + this.name = 'ArchitectureDeltaError'; + this.code = code; + this.details = details; + } +} + +const codepointOrder = (left, right) => (left < right ? -1 : left > right ? 1 : 0); +const sorted = (values) => [...values].sort((left, right) => codepointOrder(String(left), String(right))); + +function canonical(value) { + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort(codepointOrder).map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`; + } + return JSON.stringify(value); +} + +const equal = (left, right) => canonical(left) === canonical(right); + +function sortedObjects(values) { + return [...values].sort((left, right) => codepointOrder(canonical(left), canonical(right))); +} + +function sortedBy(values, keyFor) { + return [...values].sort((left, right) => codepointOrder(String(keyFor(left)), String(keyFor(right)))); +} + +function normalizeRepository(repository) { + if (!repository) return undefined; + const location = parseRepositoryRemote(repository.url, { authored: true }); + const url = location?.url || String(repository.url || ''); + return { + url: location?.provider === 'github' ? url.toLowerCase() : url, + revision: String(repository.revision || '').toLowerCase(), + ...(repository.provider !== undefined ? { provider: repository.provider } : {}), + ...(repository.link_mode !== undefined ? { link_mode: repository.link_mode } : {}), + }; +} + +const PROVENANCE_FIELDS = ['url', 'revision', 'provider', 'link_mode']; + +function provenanceSide(repository) { + if (!repository) return null; + return { + revision: repository.revision, + ...(repository.provider !== undefined ? { provider: repository.provider } : {}), + ...(repository.link_mode !== undefined ? { linkMode: repository.link_mode } : {}), + }; +} + +function provenanceChange(base, head) { + if (equal(base, head)) return undefined; + const changedFields = !base || !head + ? ['/repository'] + : PROVENANCE_FIELDS + .filter((field) => !equal(base[field], head[field])) + .map((field) => `/${field}`) + .sort(codepointOrder); + return { + changedFields, + base: provenanceSide(base), + head: provenanceSide(head), + }; +} + +function normalizeComponent(component) { + return { + ...component, + ...(Array.isArray(component.sources) ? { sources: sortedObjects(component.sources) } : {}), + }; +} + +function normalizeBoundary(boundary) { + return { ...boundary, wraps: sorted(boundary.wraps || []) }; +} + +export function canonicalArchitecture(diagram) { + const meta = { ...(diagram.meta || {}) }; + delete meta.output; + if (meta.repository) meta.repository = normalizeRepository(meta.repository); + return { + schema_version: diagram.schema_version, + diagram_type: diagram.diagram_type, + meta, + ...(diagram.layout ? { layout: diagram.layout } : {}), + components: sortedBy((diagram.components || []).map(normalizeComponent), (component) => component.id), + boundaries: sortedBy((diagram.boundaries || []).map(normalizeBoundary), boundaryKey), + connections: sortedBy(diagram.connections || [], (connection) => connection.id || ''), + ...(diagram.cards ? { cards: diagram.cards } : {}), + }; +} + +export function canonicalArchitectureJson(diagram) { + return canonical(canonicalArchitecture(diagram)); +} + +function fail(code, message, details) { + throw new ArchitectureDeltaError(code, message, details); +} + +function requireComparableShape(diagram, side) { + if (diagram?.schema_version !== 1) { + fail('delta/schema-version-mismatch', `${side} must use schema_version 1.`, { side, path: '/schema_version', actual: diagram?.schema_version }); + } + if (diagram?.diagram_type !== 'architecture') { + fail('delta/type-mismatch', `${side} must use diagram_type architecture.`, { side, path: '/diagram_type', actual: diagram?.diagram_type }); + } +} + +function stableIndex(items, collection, side, missingCode = 'delta/stable-id-required') { + const index = new Map(); + const missing = []; + const duplicates = []; + (items || []).forEach((item, itemIndex) => { + if (!item?.id) missing.push(`/${collection}/${itemIndex}/id`); + else if (index.has(item.id)) duplicates.push(item.id); + else index.set(item.id, item); + }); + if (missing.length) { + fail(missingCode, `${side} ${collection} require authored stable ids for comparison.`, { + side, + paths: sorted(missing), + supportedFixes: [`add a unique id to every ${collection} item`], + }); + } + if (duplicates.length) { + fail('delta/duplicate-stable-id', `${side} ${collection} contain duplicate ids.`, { + side, + collection, + ids: sorted(new Set(duplicates)), + supportedFixes: [`make every ${collection} id unique`], + }); + } + return index; +} + +const boundaryKey = (boundary) => `${boundary.kind}\u001f${boundary.label}`; + +function boundaryIndex(boundaries, side) { + const index = new Map(); + const ambiguous = []; + for (const boundary of boundaries || []) { + const key = boundaryKey(boundary); + if (index.has(key)) ambiguous.push(`${boundary.kind}:${boundary.label}`); + else index.set(key, boundary); + } + if (ambiguous.length) { + fail('delta/boundary-key-ambiguous', `${side} boundary kind + label keys must be unique.`, { + side, + boundaries: sorted(new Set(ambiguous)), + supportedFixes: ['rename one duplicate boundary or add stable boundary ids in a future schema version'], + }); + } + return index; +} + +function normalizedField(item, field) { + const value = item?.[field]; + if (field === 'sources' && Array.isArray(value)) return sortedObjects(value); + if (field === 'wraps' && Array.isArray(value)) return sorted(value); + return value; +} + +function fieldChanges(before, after, groups) { + const classifications = []; + const changedFields = []; + for (const [classification, fields] of Object.entries(groups)) { + const changed = fields.filter((field) => !equal(normalizedField(before, field), normalizedField(after, field))); + if (changed.length) classifications.push(classification); + changedFields.push(...changed.map((field) => `/${field}`)); + } + return { classifications: sorted(classifications), changedFields: sorted(changedFields) }; +} + +const COMPONENT_FIELDS = { + semantic: ['type', 'label', 'sublabel', 'tag', 'brand', 'icon'], + evidence: ['sources'], + geometry: ['row', 'col', 'pos', 'size'], +}; +const CONNECTION_FIELDS = { + topology: ['from', 'to'], + semantic: ['label', 'variant'], + geometry: ['fromSide', 'toSide', 'route', 'via', 'labelAt', 'labelDx', 'labelDy', 'labelSegment', 'width'], +}; +const BOUNDARY_FIELDS = { scope: ['wraps'], geometry: ['pad'] }; + +function statusFor(classifications, kind) { + if (classifications.some((value) => ['topology', 'semantic', 'scope'].includes(value))) return 'changed'; + if (classifications.includes('evidence')) return 'evidence-changed'; + if (classifications.includes('geometry')) return kind === 'connection' ? 'rerouted' : kind === 'component' ? 'moved' : 'geometry-changed'; + return 'same'; +} + +function compareEntities(baseIndex, headIndex, kind, groups, describe) { + const changes = []; + const identityClassification = kind === 'connection' ? 'topology' : kind === 'boundary' ? 'scope' : 'semantic'; + for (const id of sorted(new Set([...baseIndex.keys(), ...headIndex.keys()]))) { + const base = baseIndex.get(id); + const head = headIndex.get(id); + if (!base) changes.push({ ...describe(id, undefined, head), status: 'added', classifications: [identityClassification], changedFields: [] }); + else if (!head) changes.push({ ...describe(id, base, undefined), status: 'removed', classifications: [identityClassification], changedFields: [] }); + else { + const fields = fieldChanges(base, head, groups); + const status = statusFor(fields.classifications, kind); + if (status !== 'same') changes.push({ ...describe(id, base, head), status, ...fields }); + } + } + return changes; +} + +function summaryFor(changes, shape) { + const summary = Object.fromEntries(shape.map((key) => [key, 0])); + for (const change of changes) { + const key = change.status.replace(/-([a-z])/g, (_all, letter) => letter.toUpperCase()); + if (Object.hasOwn(summary, key)) summary[key] += 1; + } + return summary; +} + +function presentationChanged(base, head) { + const basePresentation = { + title: base.meta?.title, + locale: base.meta?.locale, + subtitle: base.meta?.subtitle, + animation: base.meta?.animation, + visual_preset: base.meta?.visual_preset, + quality_profile: base.meta?.quality_profile, + engineering_profile: base.meta?.engineering_profile, + legend: base.meta?.legend, + viewBox: base.meta?.viewBox, + layout: base.layout, + cards: base.cards, + }; + const headPresentation = { + title: head.meta?.title, + locale: head.meta?.locale, + subtitle: head.meta?.subtitle, + animation: head.meta?.animation, + visual_preset: head.meta?.visual_preset, + quality_profile: head.meta?.quality_profile, + engineering_profile: head.meta?.engineering_profile, + legend: head.meta?.legend, + viewBox: head.meta?.viewBox, + layout: head.layout, + cards: head.cards, + }; + return !equal(basePresentation, headPresentation); +} + +export function compareArchitecture(base, head, evidence = {}) { + requireComparableShape(base, 'base'); + requireComparableShape(head, 'head'); + const baseComponents = stableIndex(base.components, 'components', 'base'); + const headComponents = stableIndex(head.components, 'components', 'head'); + const shared = sorted([...baseComponents.keys()].filter((id) => headComponents.has(id))); + if (!shared.length) { + fail('delta/no-shared-component-id', 'The snapshots share no component id, so Archify cannot prove that they describe the same system.', { + supportedFixes: ['preserve at least one authored component id across snapshots'], + }); + } + + const baseConnections = stableIndex(base.connections, 'connections', 'base', 'delta/relationship-id-required'); + const headConnections = stableIndex(head.connections, 'connections', 'head', 'delta/relationship-id-required'); + const baseBoundaries = boundaryIndex(base.boundaries, 'base'); + const headBoundaries = boundaryIndex(head.boundaries, 'head'); + + const baseRepository = normalizeRepository(base.meta?.repository); + const headRepository = normalizeRepository(head.meta?.repository); + const identity = (repository) => parseRepositoryRemote(repository.url, { authored: true })?.identity || repository.url; + if (baseRepository && headRepository && identity(baseRepository) !== identity(headRepository)) { + fail('delta/repository-mismatch', 'The snapshots name different repositories.', { + baseRepository: baseRepository.url, + headRepository: headRepository.url, + supportedFixes: ['compare snapshots from the same repository or remove repository evidence from both inputs'], + }); + } + const proofLevel = baseRepository && headRepository + && evidence.baseVerified && evidence.headVerified + && /^[a-f0-9]{40}$/.test(baseRepository.revision) + && /^[a-f0-9]{40}$/.test(headRepository.revision) + ? 'revision-pinned' + : 'authored'; + + const components = compareEntities(baseComponents, headComponents, 'component', COMPONENT_FIELDS, (id, before, after) => ({ + id, + baseLabel: before?.label, + headLabel: after?.label, + })); + const connections = compareEntities(baseConnections, headConnections, 'connection', CONNECTION_FIELDS, (id, before, after) => ({ + id, + ...(before ? { base: { from: before.from, to: before.to, label: before.label || '' } } : {}), + ...(after ? { head: { from: after.from, to: after.to, label: after.label || '' } } : {}), + })); + const boundaries = compareEntities(baseBoundaries, headBoundaries, 'boundary', BOUNDARY_FIELDS, (_key, before, after) => ({ + key: `${(after || before).kind}:${(after || before).label}`, + kind: (after || before).kind, + label: (after || before).label, + })); + const provenance = provenanceChange(baseRepository, headRepository); + const provenanceChanged = Boolean(provenance); + + return { + schemaVersion: 1, + ok: true, + command: 'compare', + type: 'architecture', + comparatorVersion: COMPARATOR_VERSION, + canonicalVersion: CANONICAL_VERSION, + completeness: 'complete', + proofLevel, + base: { + title: base.meta?.title || '', + ...(evidence.baseRawSha256 ? { rawSha256: evidence.baseRawSha256 } : {}), + ...(evidence.baseSemanticSha256 ? { semanticSha256: evidence.baseSemanticSha256 } : {}), + ...(Number.isInteger(evidence.baseBytes) ? { bytes: evidence.baseBytes } : {}), + ...(baseRepository?.revision ? { revision: baseRepository.revision } : {}), + }, + head: { + title: head.meta?.title || '', + ...(evidence.headRawSha256 ? { rawSha256: evidence.headRawSha256 } : {}), + ...(evidence.headSemanticSha256 ? { semanticSha256: evidence.headSemanticSha256 } : {}), + ...(Number.isInteger(evidence.headBytes) ? { bytes: evidence.headBytes } : {}), + ...(headRepository?.revision ? { revision: headRepository.revision } : {}), + }, + summary: { + components: summaryFor(components, ['added', 'changed', 'evidenceChanged', 'removed', 'moved']), + connections: summaryFor(connections, ['added', 'changed', 'removed', 'rerouted']), + boundaries: summaryFor(boundaries, ['added', 'changed', 'removed', 'geometryChanged']), + presentationChanged: presentationChanged(base, head), + provenanceChanged, + }, + changes: { components, connections, boundaries }, + ...(provenance ? { provenance } : {}), + identity: { + components: 'components[].id', + connections: 'connections[].id (required)', + boundaries: 'boundaries[].kind + boundaries[].label (derived)', + }, + view: { visualPreset: head.meta?.visual_preset || 'classic' }, + limitations: [ + 'Authored Architecture IR only; no runtime impact, causality, risk, or mergeability is inferred.', + 'Boundary identity is conservatively derived from kind + label.', + ], + }; +} + +function esc(value) { + return String(value ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); +} + +const safeJson = (value) => JSON.stringify(value, null, 2).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026'); + +export function extractArchitectureSvg(html) { + const match = html.match(//); + if (!match) fail('delta/svg-missing', 'A validated Architecture artifact did not contain its primary SVG.'); + return match[0]; +} + +export function extractArtifactCss(html) { + const match = html.match(/Repository provenance changed${esc(provenanceDescription)}` + : ''; + const rowHtml = rows.length ? rows.map((row, index) => { + const label = row.headLabel || row.baseLabel || row.head?.label || row.base?.label || row.label || row.id; + const targetSignature = expectedReviewTargetSignature(row); + return `
  • `; + }).join('\n') : provenanceChanged + ? '
  • No component, relationship, or boundary changes; repository provenance changed.
  • ' + : '
  • No authored architecture changes.
  • '; + const baseView = baseHtml + ? `` + : baseSvg; + const headView = headHtml + ? `` + : headSvg; + const html = ` + +${esc(receipt.head.title)} Architecture Delta + +

    ARCHITECTURE DELTA · ${proof}

    See what changed
    before you merge.

    ${esc(receipt.base.title)} → ${esc(receipt.head.title)}

    ${total(receipt.summary, 'added')}ADDED
    ${total(receipt.summary, 'removed')}REMOVED
    ${changed}CHANGED
    ${provenanceNotice} +
    + ADD− DEL~ MOD↔ MOVE
    + +
    ${deltaSvg}
    +Exact authored changes · ${rows.length}
      ${rowHtml}
    +
    Stable IDs only · completeness: complete · ${proof}Authored IR only · no risk or mergeability inference
    + +`; + return html.replace(/[ \t]+$/gm, ''); +} + +export function validateArchitectureDeltaHtml(html, receipt) { + const failures = []; + const rows = architectureDeltaChangeRows(receipt); + const deltaMarkup = html.match(/
    ([\s\S]*?)<\/section>/)?.[1] || ''; + const svgTags = [...deltaMarkup.matchAll(/<\/?svg\b[^>]*>/g)]; + let svgDepth = 0; + let svgRoots = 0; + let rootStart = -1; + let rootEnd = -1; + let svgBalanced = true; + for (const match of svgTags) { + if (match[0].startsWith(' (html.match(new RegExp(`
    0; + const provenanceDescription = provenanceChanged && (legacyProvenance || validProvenanceFields) ? provenanceDetail(receipt.provenance) : ''; + const provenanceFieldLabel = legacyProvenance ? 'repository metadata' : validProvenanceFields ? provenanceFields.join(', ') : ''; + if (provenanceNotices !== (provenanceChanged ? 1 : 0) + || (!provenanceChanged && !legacyProvenance) + || (provenanceChanged && !legacyProvenance && !validProvenanceFields) + || (provenanceChanged && !html.includes(`data-provenance-fields="${esc(provenanceFieldLabel)}"`)) + || (provenanceChanged && !html.includes(`${esc(provenanceDescription)}`))) { + failures.push('provenance change notice does not match the receipt'); + } + if (provenanceChanged && rows.length === 0 && html.includes('No authored architecture changes.')) failures.push('provenance-only delta claims no authored changes'); + if (!html.includes('aria-label="Authored change review"')) failures.push('missing exact-ID change navigator'); + if ((html.match(/class="change-row"/g) || []).length !== rows.length) failures.push('change navigator row count does not match the receipt'); + if (!html.includes('id="export-svg"') || !html.includes('id="share-card"') + || !html.includes('window.Archify.deltaExport = { canonicalSvg: canonicalDeltaSvg, shareCard')) { + failures.push('missing canonical Delta SVG or Share Card export contract'); + } + for (const [index, row] of rows.entries()) { + const safeKey = esc(row.key).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const rowMatches = [...html.matchAll(new RegExp(`