@mnstry/atelier 0.2.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +383 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +262 -0
- package/SECURITY.md +93 -0
- package/TRADEMARKS.md +88 -0
- package/announcements/2026-08-06-announcements-channel.v1.json +12 -0
- package/announcements/keys/mnstry-announcements.public.v1.json +9 -0
- package/bin/atelier.mjs +2 -0
- package/bin/mnstry-atelier.mjs +3 -0
- package/contracts/analysis-adapter.v1.schema.json +248 -0
- package/contracts/atelier-action-intent.v1.schema.json +143 -0
- package/contracts/atelier-analysis-adapter.v1.schema.json +360 -0
- package/contracts/atelier-attestation.v1.schema.json +239 -0
- package/contracts/atelier-boundary-policy.v1.schema.json +339 -0
- package/contracts/atelier-claim.v1.schema.json +97 -0
- package/contracts/atelier-export.v1.schema.json +1015 -0
- package/contracts/atelier-extension-pack.v1.schema.json +290 -0
- package/contracts/atelier-kit-manifest.v1.schema.json +273 -0
- package/contracts/atelier-lock.v1.schema.json +364 -0
- package/contracts/atelier-migration.v1.schema.json +162 -0
- package/contracts/atelier-project-config.v1.schema.json +352 -0
- package/contracts/atelier-readiness-protocol.v1.schema.json +517 -0
- package/contracts/atelier-readiness-run.v1.schema.json +229 -0
- package/contracts/atelier-readiness.v1.schema.json +457 -0
- package/contracts/compat-baseline.json +10 -0
- package/contracts/git-promote-event.v1.schema.json +145 -0
- package/contracts/knowledge-source-sidecar.v1.schema.json +157 -0
- package/contracts/mnstry-atelier-semantic-profile.v1.json +254 -0
- package/docs/announcements.md +101 -0
- package/docs/atelier-runtime.md +34 -0
- package/docs/attestation.md +201 -0
- package/docs/continuity.md +55 -0
- package/docs/contract-stability.md +111 -0
- package/docs/distributions.md +220 -0
- package/docs/install.md +140 -0
- package/docs/knowledge-graph.md +140 -0
- package/docs/ontology.md +206 -0
- package/docs/release-engineering.md +151 -0
- package/docs/repo-boundary-guard.md +199 -0
- package/docs/tenant-readiness.md +64 -0
- package/docs/upgrade.md +124 -0
- package/fixtures/analysis/invalid/native-graph.v1.json +4 -0
- package/fixtures/analysis/invalid/promoted-claim.v1.json +17 -0
- package/fixtures/analysis/invalid/runtime-import.v1.json +20 -0
- package/fixtures/analysis/valid/output.claims.v1.json +17 -0
- package/fixtures/analysis-adapter/invalid/manifest-hidden-provider.v1.json +24 -0
- package/fixtures/analysis-adapter/invalid/output-frontmatter-mutation.v1.json +21 -0
- package/fixtures/analysis-adapter/invalid/output-native-graph.v1.json +14 -0
- package/fixtures/analysis-adapter/manifest.disabled.v1.json +22 -0
- package/fixtures/analysis-adapter/manifest.enabled-local.v1.json +23 -0
- package/fixtures/analysis-adapter/output.claims.v1.json +21 -0
- package/fixtures/atelier-action-intent/invalid/runtime-mutation-authority.v1.json +16 -0
- package/fixtures/atelier-action-intent/invalid/unknown-top-level-field.v1.json +17 -0
- package/fixtures/atelier-action-intent/invalid/writes-beyond-clipboard.v1.json +16 -0
- package/fixtures/atelier-action-intent/valid/copy-agent-prompt.v1.json +16 -0
- package/fixtures/atelier-action-intent/valid/copy-repo-path.v1.json +22 -0
- package/fixtures/atelier-analysis-adapter/invalid/hidden-model-provider.v1.json +24 -0
- package/fixtures/atelier-analysis-adapter/invalid/inert-with-execute-command.v1.json +24 -0
- package/fixtures/atelier-analysis-adapter/invalid/proposals-with-runtime-import.v1.json +30 -0
- package/fixtures/atelier-analysis-adapter/valid/manifest-inert.v1.json +24 -0
- package/fixtures/atelier-analysis-adapter/valid/proposals-claim-only.v1.json +27 -0
- package/fixtures/atelier-attestation/invalid/bad-digest.v1.json +23 -0
- package/fixtures/atelier-attestation/invalid/conformance-scope.v1.json +23 -0
- package/fixtures/atelier-attestation/invalid/missing-signature.v1.json +22 -0
- package/fixtures/atelier-attestation/invalid/unknown-root-field.v1.json +24 -0
- package/fixtures/atelier-attestation/keys/roundtrip-issuer.public.v1.json +9 -0
- package/fixtures/atelier-attestation/valid/rejected-with-reasons.v1.json +49 -0
- package/fixtures/atelier-attestation/valid/signed-admitted.v1.json +35 -0
- package/fixtures/atelier-attestation/valid/signed-roundtrip.v1.json +29 -0
- package/fixtures/atelier-attestation/valid/unsigned-advisory.v1.json +23 -0
- package/fixtures/atelier-claim/invalid/unknown-provider.v1.json +15 -0
- package/fixtures/atelier-claim/invalid/unknown-top-level-field.v1.json +16 -0
- package/fixtures/atelier-claim/invalid/wrong-schema-const.v1.json +15 -0
- package/fixtures/atelier-claim/valid/adapter-style-proposed-relation.v1.json +19 -0
- package/fixtures/atelier-claim/valid/codex-proposed-relation.v1.json +16 -0
- package/fixtures/atelier-claim/valid/manual-proposed-relation.v1.json +16 -0
- package/fixtures/atelier-claim/valid/readiness-proposed-relation.v1.json +18 -0
- package/fixtures/atelier-export/invalid/dirty-unforced-empty-taint.v1.json +19 -0
- package/fixtures/atelier-export/invalid/object-local-audience-visibility.v1.json +11 -0
- package/fixtures/atelier-export/invalid/open-object-class-owner.v1.json +23 -0
- package/fixtures/atelier-export/invalid/pricing-legacy-amount.v1.json +23 -0
- package/fixtures/atelier-export/invalid/public-disguised-internal-source-ref.v1.json +11 -0
- package/fixtures/atelier-export/invalid/public-nested-private-source-ref.v1.json +17 -0
- package/fixtures/atelier-export/invalid/public-resolved-private-source-ref.v1.json +13 -0
- package/fixtures/atelier-export/invalid/public-resolved-sensitive-source-ref.v1.json +13 -0
- package/fixtures/atelier-export/invalid/public-source-ref-missing-provenance.v1.json +20 -0
- package/fixtures/atelier-export/invalid/public-source-team-kg.json +12 -0
- package/fixtures/atelier-export/invalid/source-node-legacy-visibility.v1.json +21 -0
- package/fixtures/atelier-export/invalid/source-node-unknown-field.v1.json +18 -0
- package/fixtures/atelier-export/invalid/unresolved-public-source-ref.v1.json +11 -0
- package/fixtures/atelier-export/sample-studio-offer.v1.json +397 -0
- package/fixtures/atelier-extension-pack/invalid/bundled-slug-collision.v1.json +22 -0
- package/fixtures/atelier-extension-pack/invalid/escapes-root.v1.json +22 -0
- package/fixtures/atelier-extension-pack/invalid/posture-tampered.v1.json +22 -0
- package/fixtures/atelier-extension-pack/invalid/protocols/autonomous-authority.v1.json +118 -0
- package/fixtures/atelier-extension-pack/invalid/protocols/offer-map.v1.json +109 -0
- package/fixtures/atelier-extension-pack/invalid/redefines-runtime-term.v1.json +19 -0
- package/fixtures/atelier-extension-pack/invalid/wrong-namespace.v1.json +19 -0
- package/fixtures/atelier-extension-pack/valid/protocols/contract-gate.v1.json +109 -0
- package/fixtures/atelier-extension-pack/valid/sample-pack.v1.json +48 -0
- package/fixtures/atelier-kit-manifest/invalid/runtime-copy-without-canonical-role.v1.json +53 -0
- package/fixtures/atelier-kit-manifest/invalid/telemetry-send-path.v1.json +21 -0
- package/fixtures/atelier-kit-manifest/valid/mnstry-atelier.valid.v1.json +76 -0
- package/fixtures/atelier-lock/invalid/absolute-contract-path.v1.json +40 -0
- package/fixtures/atelier-lock/invalid/runtime-mutation-enabled.v1.json +41 -0
- package/fixtures/atelier-lock/invalid/unknown-top-level-field.v1.json +42 -0
- package/fixtures/atelier-lock/valid/extension-pack.v1.json +55 -0
- package/fixtures/atelier-lock/valid/minimal.v1.json +44 -0
- package/fixtures/atelier-migration/invalid/live-apply-mode.v1.json +21 -0
- package/fixtures/atelier-migration/invalid/missing-review-evidence.v1.json +20 -0
- package/fixtures/atelier-migration/invalid/runtime-mutation-enabled.v1.json +21 -0
- package/fixtures/atelier-migration/valid/minimal.v1.json +32 -0
- package/fixtures/atelier-project-config/invalid/absolute-repo-path.v1.json +11 -0
- package/fixtures/atelier-project-config/invalid/ext-not-object.v1.json +12 -0
- package/fixtures/atelier-project-config/invalid/external-kind-read-boundary.v1.json +12 -0
- package/fixtures/atelier-project-config/invalid/missing-repos-and-alignment.v1.json +7 -0
- package/fixtures/atelier-project-config/invalid/unknown-top-level-field.v1.json +14 -0
- package/fixtures/atelier-project-config/valid/alignment-scaffold.v1.json +15 -0
- package/fixtures/atelier-project-config/valid/ext-extension-packs.v1.json +36 -0
- package/fixtures/atelier-project-config/valid/repo-workspace.v1.json +23 -0
- package/fixtures/atelier-project-config/validator-only/ext-member-not-object.v1.json +14 -0
- package/fixtures/atelier-readiness/invalid/dry-run-only-false.v1.json +87 -0
- package/fixtures/atelier-readiness/invalid/support-bundle-send-path.v1.json +87 -0
- package/fixtures/atelier-readiness/invalid/unknown-top-level-field.v1.json +88 -0
- package/fixtures/atelier-readiness/valid/ready-workspace.v1.json +84 -0
- package/fixtures/boundary-policy/invalid/content-rule-exception-blanket-path.v1.json +67 -0
- package/fixtures/boundary-policy/invalid/invalid-actor-private-repo-mapping.v1.json +20 -0
- package/fixtures/boundary-policy/invalid/legacy-warning-missing-warning.v1.json +20 -0
- package/fixtures/boundary-policy/invalid/missing-repo-coverage.v1.json +19 -0
- package/fixtures/boundary-policy/invalid/unknown-key.v1.json +12 -0
- package/fixtures/boundary-policy/valid/content-rule-exception.v1.json +67 -0
- package/fixtures/boundary-policy/valid/legacy-warning.v1.json +33 -0
- package/fixtures/boundary-policy/valid/strict-private-shared.v1.json +40 -0
- package/fixtures/git-promote/invalid/provision-named-revocable.v1.json +29 -0
- package/fixtures/git-promote/private-to-team.v1.json +29 -0
- package/fixtures/knowledge-source-sidecar/invalid/legacy-visibility-field.v1.json +17 -0
- package/fixtures/knowledge-source-sidecar/invalid/missing-audience.v1.json +15 -0
- package/fixtures/knowledge-source-sidecar/invalid/unknown-relation-kind.v1.json +18 -0
- package/fixtures/knowledge-source-sidecar/valid/html-source.v1.json +18 -0
- package/fixtures/knowledge-source-sidecar/valid/pdf-evidence.v1.json +18 -0
- package/fixtures/projects/neutral-project/atelier.project.json +15 -0
- package/fixtures/projects/neutral-project/ops/config/repo-access.v1.json +12 -0
- package/fixtures/projects/neutral-project/project-knowledge.graph.json +69 -0
- package/fixtures/projects/sample-workspace/atelier.project.json +23 -0
- package/fixtures/projects/sample-workspace/boundary-policy.v1.json +59 -0
- package/fixtures/projects/sample-workspace/content/README.md +16 -0
- package/fixtures/projects/sample-workspace/content/source.html +5 -0
- package/fixtures/projects/sample-workspace/content/source.html.kg.json +15 -0
- package/fixtures/projects/sample-workspace/repo-access.v1.json +9 -0
- package/fixtures/projects/source-formats-workspace/atelier.project.json +23 -0
- package/fixtures/projects/source-formats-workspace/content/README.md +17 -0
- package/fixtures/projects/source-formats-workspace/content/data.json +7 -0
- package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +15 -0
- package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +15 -0
- package/fixtures/projects/source-formats-workspace/content/metrics.csv +3 -0
- package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +15 -0
- package/fixtures/projects/source-formats-workspace/content/pipeline.yaml +4 -0
- package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +15 -0
- package/fixtures/projects/source-formats-workspace/repo-access.v1.json +9 -0
- package/fixtures/readiness-protocols/extension-pack/invalid/protocol-missing-path.v1.json +20 -0
- package/fixtures/readiness-protocols/extension-pack/valid/with-protocol.v1.json +24 -0
- package/fixtures/readiness-protocols/protocol/invalid/missing-check-target.v1.json +103 -0
- package/fixtures/readiness-protocols/protocol/invalid/runtime-mutation-enabled.v1.json +107 -0
- package/fixtures/readiness-protocols/protocol/valid/minimal.v1.json +100 -0
- package/fixtures/readiness-protocols/run/invalid/missing-evidence.v1.json +37 -0
- package/fixtures/readiness-protocols/run/invalid/pass-with-blocker.v1.json +22 -0
- package/fixtures/readiness-protocols/run/invalid/runtime-mutation-enabled.v1.json +22 -0
- package/fixtures/readiness-protocols/run/valid/pass.v1.json +44 -0
- package/fixtures/support/invalid/leaky-support.v1.json +13 -0
- package/fixtures/support/valid/minimal-safe.v1.json +20 -0
- package/package.json +98 -0
- package/skills/claude/atelier-open/SKILL.md +68 -0
- package/skills/claude/mnstry-readiness/SKILL.md +41 -0
- package/skills/codex/atelier-open/SKILL.md +68 -0
- package/skills/codex/mnstry-readiness/SKILL.md +41 -0
- package/src/analysis/adapter.mjs +20 -0
- package/src/analysis/analysis-claim-contract.mjs +335 -0
- package/src/attestation/jcs.mjs +64 -0
- package/src/attestation/sign.mjs +305 -0
- package/src/boundary/content-rules.mjs +262 -0
- package/src/boundary/policy.mjs +806 -0
- package/src/check-atelier-export-contract.mjs +116 -0
- package/src/cli/run.mjs +283 -0
- package/src/collaboration/proposals.mjs +262 -0
- package/src/commands/analysis.mjs +8 -0
- package/src/commands/announcements.mjs +308 -0
- package/src/commands/attestation.mjs +198 -0
- package/src/commands/boundary.mjs +25 -0
- package/src/commands/config.mjs +4 -0
- package/src/commands/context.mjs +3 -0
- package/src/commands/distribution.mjs +224 -0
- package/src/commands/egress.mjs +11 -0
- package/src/commands/extension-pack.mjs +209 -0
- package/src/commands/feedback.mjs +3 -0
- package/src/commands/graph.mjs +3 -0
- package/src/commands/init.mjs +130 -0
- package/src/commands/lock.mjs +3 -0
- package/src/commands/project.mjs +3 -0
- package/src/commands/promote.mjs +4 -0
- package/src/commands/readiness.mjs +3 -0
- package/src/commands/server.mjs +6 -0
- package/src/commands/setup.mjs +250 -0
- package/src/commands/support.mjs +3 -0
- package/src/commands/upgrade.mjs +3 -0
- package/src/contracts/corpus.mjs +171 -0
- package/src/egress/check.mjs +45 -0
- package/src/egress/forbidden-egress.mjs +323 -0
- package/src/export/atelier-export-contract.mjs +722 -0
- package/src/extension-packs/loader.mjs +491 -0
- package/src/graph/graph.mjs +370 -0
- package/src/graph/knowledge-graph.mjs +887 -0
- package/src/harness/context-client.mjs +144 -0
- package/src/harness/context.mjs +27 -0
- package/src/index.mjs +88 -0
- package/src/project/config.mjs +557 -0
- package/src/project/file-class.mjs +113 -0
- package/src/project/git-ignore.mjs +31 -0
- package/src/project/path-match.mjs +30 -0
- package/src/project/repo-identity.mjs +176 -0
- package/src/projection/alignment-projection.mjs +169 -0
- package/src/projection/policy.mjs +90 -0
- package/src/projection/project.mjs +152 -0
- package/src/readiness/readiness.mjs +312 -0
- package/src/readiness-protocols/bundled-pack.mjs +766 -0
- package/src/readiness-protocols/runtime.mjs +419 -0
- package/src/server/local-sidecar.mjs +640 -0
- package/src/server/security.mjs +229 -0
- package/src/server/server.mjs +42 -0
- package/src/support/feedback-report.mjs +414 -0
- package/src/support/support-bundle.mjs +226 -0
- package/src/ui/html-primitives.mjs +104 -0
- package/src/upgrade/upgrade.mjs +701 -0
- package/src/validate-atelier-export-dry-run.mjs +75 -0
- package/templates/atelier.project.example.json +23 -0
- package/templates/distribution-workspace/.claude/launch.json +12 -0
- package/templates/distribution-workspace/README.md +86 -0
- package/templates/distribution-workspace/atelier.project.json +36 -0
- package/templates/distribution-workspace/boundary-policy.v1.json +61 -0
- package/templates/distribution-workspace/gitignore +29 -0
- package/templates/distribution-workspace/governance/repo-boundary-ledger.md +5 -0
- package/templates/distribution-workspace/project/README.md +19 -0
- package/templates/distribution-workspace/repo-access.v1.json +9 -0
- package/templates/launch.json +12 -0
- package/templates/private-domain-workspace/.claude/launch.json +12 -0
- package/templates/private-domain-workspace/README.md +50 -0
- package/templates/private-domain-workspace/atelier.project.json +28 -0
- package/templates/private-domain-workspace/boundary-policy.v1.json +54 -0
- package/templates/private-domain-workspace/domain/README.md +19 -0
- package/templates/private-domain-workspace/gitignore +29 -0
- package/templates/private-domain-workspace/governance/repo-boundary-ledger.md +4 -0
- package/templates/private-domain-workspace/repo-access.v1.json +9 -0
- package/templates/shared-project-workspace/.claude/launch.json +12 -0
- package/templates/shared-project-workspace/README.md +51 -0
- package/templates/shared-project-workspace/atelier.project.json +28 -0
- package/templates/shared-project-workspace/boundary-policy.v1.json +61 -0
- package/templates/shared-project-workspace/gitignore +29 -0
- package/templates/shared-project-workspace/governance/repo-boundary-ledger.md +5 -0
- package/templates/shared-project-workspace/project/README.md +19 -0
- package/templates/shared-project-workspace/repo-access.v1.json +9 -0
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { buildGraph } from '../graph/graph.mjs'
|
|
5
|
+
import { commandProject, firstString, parseArgs, readJson, resolvePathValue, writeJson } from '../project/config.mjs'
|
|
6
|
+
import { matchesPathPattern } from '../project/path-match.mjs'
|
|
7
|
+
import {
|
|
8
|
+
diffForPushRange,
|
|
9
|
+
parseAddedContent,
|
|
10
|
+
parsePushRefUpdates,
|
|
11
|
+
resolveContentRules,
|
|
12
|
+
scanAddedContent,
|
|
13
|
+
scanTree,
|
|
14
|
+
stagedDiff,
|
|
15
|
+
validateContentRuleExceptions,
|
|
16
|
+
validateContentRules,
|
|
17
|
+
} from './content-rules.mjs'
|
|
18
|
+
|
|
19
|
+
export const BOUNDARY_POLICY_SCHEMA = 'mnstry.atelier-boundary-policy@v1'
|
|
20
|
+
export const PROMOTE_EVENT_SCHEMA = 'git-promote-event@v1'
|
|
21
|
+
// Promote events append to a durable governance ledger; lines written before the
|
|
22
|
+
// contract-stability epoch carry the legacy schema id and must stay readable.
|
|
23
|
+
export const LEGACY_PROMOTE_EVENT_SCHEMAS = new Set(['mnstry.git-promote-event@v1'])
|
|
24
|
+
export const VALID_BOUNDARY_MODES = new Set(['strict', 'legacy-warning'])
|
|
25
|
+
export const VALID_REPO_KINDS = new Set(['private_domain', 'shared', 'generated', 'archive'])
|
|
26
|
+
export const VALID_AUDIENCES = new Set(['private', 'team', 'operator', 'staff', 'public', 'sensitive'])
|
|
27
|
+
export const VALID_AUTOCOMMIT = new Set(['allowed', 'blocked', 'guarded'])
|
|
28
|
+
export const PRIVATE_AUDIENCES = new Set(['private', 'sensitive'])
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_FORBIDDEN_PATHS = [
|
|
31
|
+
'.atelier-local',
|
|
32
|
+
'.atelier-local/**',
|
|
33
|
+
'atelier.local.json',
|
|
34
|
+
'atelier.workspace.local.json',
|
|
35
|
+
'.mnstry-local',
|
|
36
|
+
'.mnstry-local/**',
|
|
37
|
+
'.atelier-proposals',
|
|
38
|
+
'.atelier-proposals/**',
|
|
39
|
+
'.atelier-current',
|
|
40
|
+
'.atelier-current.json',
|
|
41
|
+
'.atelier-presence.json',
|
|
42
|
+
'.atelier-nonce',
|
|
43
|
+
'.atelier-capability-grants.json',
|
|
44
|
+
'.atelier-events.jsonl',
|
|
45
|
+
'.atelier-audit.jsonl',
|
|
46
|
+
'.atelier-session.json',
|
|
47
|
+
'.atelier-session.jsonl',
|
|
48
|
+
'.atelier-sessions/**',
|
|
49
|
+
'.atelier-support',
|
|
50
|
+
'.atelier-support/**',
|
|
51
|
+
'support-bundle',
|
|
52
|
+
'support-bundle/**',
|
|
53
|
+
'support-bundles/**',
|
|
54
|
+
'support-bundle.*',
|
|
55
|
+
'*support-bundle*',
|
|
56
|
+
'transcripts/**',
|
|
57
|
+
'**/transcripts/**',
|
|
58
|
+
'prompts/**',
|
|
59
|
+
'**/prompts/**',
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
const SEMANTIC_FIELD_RE = /(^|["'\s.-])(kg[.]audience|audience|handling|sensitivity|data_boundary)(["'\s:.-]|$)/i
|
|
63
|
+
const SEMANTIC_ASSIGNMENT_RE = /(?:^|[^\w.])["']?(kg\.audience|audience|handling|sensitivity|data_boundary)["']?\s*:\s*(.*)$/i
|
|
64
|
+
const REVIEW_MARKER_RE = /(Atelier-Boundary-Review|boundary-review)\s*:\s*(approved|reviewed)/i
|
|
65
|
+
|
|
66
|
+
// Initializing a boundary field to a value that discloses nothing is not a disclosure
|
|
67
|
+
// decision, so the gate lets it through unreviewed. Widening, narrowing, or removing an
|
|
68
|
+
// existing value still needs a human. Keeping this in sync with the fail-closed default
|
|
69
|
+
// written by generatedFrontmatter() is what stops the kit's own apply tooling from
|
|
70
|
+
// producing changes the kit's own gate refuses.
|
|
71
|
+
export const SEMANTIC_INITIALIZATION_DEFAULTS = Object.freeze({
|
|
72
|
+
audience: Object.freeze([...PRIVATE_AUDIENCES].sort()),
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const managedRepos = (project) => (project?.repos ?? []).filter((repo) => !repo.external)
|
|
76
|
+
|
|
77
|
+
const normalize = (value) => String(value ?? '').replaceAll('\\', '/').replace(/^\.\/+/, '')
|
|
78
|
+
const isObject = (value) => value && typeof value === 'object' && !Array.isArray(value)
|
|
79
|
+
const asArray = (value) => (Array.isArray(value) ? value : [])
|
|
80
|
+
const unique = (values) => [...new Set(values.filter(Boolean))]
|
|
81
|
+
|
|
82
|
+
export function projectBoundaryPolicyPath(project) {
|
|
83
|
+
const boundaries = project.config?.boundaries && typeof project.config.boundaries === 'object' ? project.config.boundaries : {}
|
|
84
|
+
return (
|
|
85
|
+
resolvePathValue(firstString(boundaries.policyPath), project.configDir) ||
|
|
86
|
+
resolvePathValue(firstString(project.config?.boundaryPolicyPath), project.configDir) ||
|
|
87
|
+
path.join(project.configDir, 'boundary-policy.v1.json')
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function projectGovernanceLedgerPath(project, policy = null) {
|
|
92
|
+
const boundaries = project.config?.boundaries && typeof project.config.boundaries === 'object' ? project.config.boundaries : {}
|
|
93
|
+
return (
|
|
94
|
+
resolvePathValue(firstString(boundaries.governanceLedgerPath), project.configDir) ||
|
|
95
|
+
resolvePathValue(firstString(policy?.governanceLedgerPath), project.configDir) ||
|
|
96
|
+
null
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function loadBoundaryPolicy(project, policyPath = projectBoundaryPolicyPath(project)) {
|
|
101
|
+
if (!fs.existsSync(policyPath)) {
|
|
102
|
+
return { ok: false, policy: null, policyPath, errors: [`boundary policy not found: ${policyPath}`] }
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
return { ok: true, policy: readJson(policyPath), policyPath, errors: [] }
|
|
106
|
+
} catch (error) {
|
|
107
|
+
return { ok: false, policy: null, policyPath, errors: [error.message] }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function unknownKeys(value, label, allowed) {
|
|
112
|
+
if (!isObject(value)) return []
|
|
113
|
+
return Object.keys(value)
|
|
114
|
+
.filter((key) => !allowed.has(key))
|
|
115
|
+
.map((key) => `${label} must not include additional property ${key}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function validAudienceList(value, label, required = false) {
|
|
119
|
+
if (value == null && !required) return []
|
|
120
|
+
if (!Array.isArray(value)) return [`${label} must be a list`]
|
|
121
|
+
const errors = []
|
|
122
|
+
for (const item of value) {
|
|
123
|
+
if (!VALID_AUDIENCES.has(item)) errors.push(`${label} contains invalid audience ${item}`)
|
|
124
|
+
}
|
|
125
|
+
return errors
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function validateBoundaryPolicy(policy, project = null) {
|
|
129
|
+
const errors = []
|
|
130
|
+
if (!isObject(policy)) return ['boundary policy must be a JSON object']
|
|
131
|
+
errors.push(...unknownKeys(policy, '/', new Set(['schema', 'mode', 'actors', 'repos', 'promotion', 'forbiddenPaths', 'contentRules', 'contentRuleExceptions', 'governanceLedgerPath'])))
|
|
132
|
+
if (policy.schema !== BOUNDARY_POLICY_SCHEMA) errors.push(`schema must be ${BOUNDARY_POLICY_SCHEMA}`)
|
|
133
|
+
if (!VALID_BOUNDARY_MODES.has(policy.mode)) errors.push('mode must be strict or legacy-warning')
|
|
134
|
+
|
|
135
|
+
const actors = policy.actors
|
|
136
|
+
const repos = policy.repos
|
|
137
|
+
const promotion = isObject(policy.promotion) ? policy.promotion : {}
|
|
138
|
+
if (!isObject(actors)) errors.push('actors must be an object')
|
|
139
|
+
if (!isObject(repos)) errors.push('repos must be an object')
|
|
140
|
+
if (policy.promotion != null && !isObject(policy.promotion)) errors.push('promotion must be an object')
|
|
141
|
+
errors.push(...unknownKeys(promotion, 'promotion', new Set(['requiresGitPromote', 'recordsPath'])))
|
|
142
|
+
if (policy.forbiddenPaths != null && !Array.isArray(policy.forbiddenPaths)) errors.push('forbiddenPaths must be a list')
|
|
143
|
+
if (policy.contentRules != null) errors.push(...validateContentRules(policy.contentRules))
|
|
144
|
+
if (policy.contentRuleExceptions != null) {
|
|
145
|
+
errors.push(...validateContentRuleExceptions(policy.contentRuleExceptions, resolveContentRules(policy)))
|
|
146
|
+
const declaredRepos = isObject(repos) ? repos : {}
|
|
147
|
+
for (const [index, exception] of policy.contentRuleExceptions.entries()) {
|
|
148
|
+
const name = firstString(exception?.repo)
|
|
149
|
+
if (name && Object.keys(declaredRepos).length && !declaredRepos[name]) {
|
|
150
|
+
errors.push(`contentRuleExceptions[${index}].repo ${name} is not declared in repos`)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const [actorId, actor] of Object.entries(isObject(actors) ? actors : {})) {
|
|
156
|
+
errors.push(...unknownKeys(actor, `actors.${actorId}`, new Set(['githubLogin', 'gitEmails', 'privateDomainRepo'])))
|
|
157
|
+
if (!isObject(actor)) {
|
|
158
|
+
errors.push(`actors.${actorId} must be an object`)
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
if (!firstString(actor.githubLogin) && !asArray(actor.gitEmails).length) {
|
|
162
|
+
errors.push(`actors.${actorId} must declare githubLogin or gitEmails`)
|
|
163
|
+
}
|
|
164
|
+
if (!firstString(actor.privateDomainRepo)) errors.push(`actors.${actorId}.privateDomainRepo is required`)
|
|
165
|
+
if (firstString(actor.privateDomainRepo) && isObject(repos)) {
|
|
166
|
+
const privateRepo = repos[actor.privateDomainRepo]
|
|
167
|
+
if (!privateRepo) {
|
|
168
|
+
errors.push(`actors.${actorId}.privateDomainRepo ${actor.privateDomainRepo} is not declared in repos`)
|
|
169
|
+
} else if (privateRepo.kind !== 'private_domain') {
|
|
170
|
+
errors.push(`actors.${actorId}.privateDomainRepo ${actor.privateDomainRepo} must reference a private_domain repo`)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const email of asArray(actor.gitEmails)) {
|
|
174
|
+
if (typeof email !== 'string' || !email.includes('@')) errors.push(`actors.${actorId}.gitEmails must contain email strings`)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const [repoName, repo] of Object.entries(isObject(repos) ? repos : {})) {
|
|
179
|
+
errors.push(...unknownKeys(repo, `repos.${repoName}`, new Set(['kind', 'ownerActor', 'readBoundary', 'allowedAudiences', 'forbiddenAudiences', 'autoCommit'])))
|
|
180
|
+
if (!isObject(repo)) {
|
|
181
|
+
errors.push(`repos.${repoName} must be an object`)
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
if (!VALID_REPO_KINDS.has(repo.kind)) errors.push(`repos.${repoName}.kind is invalid`)
|
|
185
|
+
if (!VALID_AUDIENCES.has(repo.readBoundary)) errors.push(`repos.${repoName}.readBoundary is invalid`)
|
|
186
|
+
if (!VALID_AUTOCOMMIT.has(repo.autoCommit)) errors.push(`repos.${repoName}.autoCommit is invalid`)
|
|
187
|
+
errors.push(...validAudienceList(repo.allowedAudiences, `repos.${repoName}.allowedAudiences`, true))
|
|
188
|
+
errors.push(...validAudienceList(repo.forbiddenAudiences, `repos.${repoName}.forbiddenAudiences`))
|
|
189
|
+
if (repo.kind === 'private_domain') {
|
|
190
|
+
if (!firstString(repo.ownerActor)) errors.push(`repos.${repoName}.ownerActor is required for private_domain repos`)
|
|
191
|
+
if (repo.ownerActor && isObject(actors) && !actors[repo.ownerActor]) errors.push(`repos.${repoName}.ownerActor ${repo.ownerActor} is not declared`)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (project?.repos?.length && isObject(repos)) {
|
|
196
|
+
for (const repo of project.repos) {
|
|
197
|
+
if (repo.external) {
|
|
198
|
+
// Declaring a boundary for a repo nobody manages is the confusion the
|
|
199
|
+
// external kind exists to remove.
|
|
200
|
+
if (repos[repo.name]) errors.push(`policy repos.${repo.name} must not be declared; it is an external (unmanaged) repo`)
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
if (!repos[repo.name]) errors.push(`policy repos.${repo.name} must be declared`)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return errors
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function resolveCurrentActor({ policy, project, actor = null, env = process.env } = {}) {
|
|
211
|
+
const actors = policy?.actors ?? {}
|
|
212
|
+
const explicit = actor || env.MNSTRY_ATELIER_ACTOR || env.GITHUB_ACTOR
|
|
213
|
+
if (explicit && actors[explicit]) return { actorId: explicit, source: 'explicit' }
|
|
214
|
+
const gitEmails = gitEmailsForProject(project)
|
|
215
|
+
for (const [actorId, info] of Object.entries(actors)) {
|
|
216
|
+
const actorEmails = new Set(asArray(info.gitEmails).map((email) => email.toLowerCase()))
|
|
217
|
+
if (gitEmails.some((email) => actorEmails.has(email.toLowerCase()))) return { actorId, source: 'git-email', gitEmails }
|
|
218
|
+
}
|
|
219
|
+
const login = env.GITHUB_ACTOR || ghLogin()
|
|
220
|
+
if (login) {
|
|
221
|
+
for (const [actorId, info] of Object.entries(actors)) {
|
|
222
|
+
if (String(info.githubLogin || '').toLowerCase() === String(login).toLowerCase()) return { actorId, source: 'github-login', githubLogin: login }
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { actorId: null, source: 'unverified', gitEmails, githubLogin: login || null }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function gitEmailsForProject(project) {
|
|
229
|
+
const roots = unique([project?.repoOpsRoot, project?.workspaceRoot, ...managedRepos(project).map((repo) => repo.path)])
|
|
230
|
+
const emails = []
|
|
231
|
+
for (const root of roots) {
|
|
232
|
+
if (!root || !fs.existsSync(root)) continue
|
|
233
|
+
const result = spawnSync('git', ['-C', root, 'config', 'user.email'], { encoding: 'utf8' })
|
|
234
|
+
if (result.status === 0 && result.stdout.trim()) emails.push(result.stdout.trim())
|
|
235
|
+
}
|
|
236
|
+
return unique(emails)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function ghLogin() {
|
|
240
|
+
try {
|
|
241
|
+
return execFileSync('gh', ['api', 'user', '--jq', '.login'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
242
|
+
} catch {
|
|
243
|
+
return null
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function repoPolicy(policy, repoName) {
|
|
248
|
+
return policy?.repos?.[repoName] ?? null
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function finding({ severity = 'error', code, message, repo = null, path: itemPath = null, node = null, details = {} }) {
|
|
252
|
+
return { severity, code, message, repo, path: itemPath, node, details }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function severityFor(policy) {
|
|
256
|
+
return policy?.mode === 'legacy-warning' ? 'warning' : 'error'
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function nodePlacementFindings({ node, policy }) {
|
|
260
|
+
const findings = []
|
|
261
|
+
const repo = repoPolicy(policy, node.repo)
|
|
262
|
+
const severity = severityFor(policy)
|
|
263
|
+
if (!repo) {
|
|
264
|
+
findings.push(finding({ severity, code: 'repo-policy-missing', repo: node.repo, path: node.path, node: node.id, message: `${node.repo} is missing boundary policy coverage` }))
|
|
265
|
+
return findings
|
|
266
|
+
}
|
|
267
|
+
if (!repo.allowedAudiences?.includes(node.audience)) {
|
|
268
|
+
findings.push(finding({ severity, code: 'audience-not-allowed-in-repo', repo: node.repo, path: node.path, node: node.id, message: `${node.repo}/${node.path}: audience ${node.audience} is not allowed in ${repo.kind} repo ${node.repo}` }))
|
|
269
|
+
}
|
|
270
|
+
if (repo.forbiddenAudiences?.includes(node.audience)) {
|
|
271
|
+
findings.push(finding({ severity, code: 'audience-forbidden-in-repo', repo: node.repo, path: node.path, node: node.id, message: `${node.repo}/${node.path}: audience ${node.audience} is forbidden in ${repo.kind} repo ${node.repo}` }))
|
|
272
|
+
}
|
|
273
|
+
if (PRIVATE_AUDIENCES.has(node.audience) && repo.kind !== 'private_domain') {
|
|
274
|
+
findings.push(finding({ severity, code: 'private-audience-in-shared-repo', repo: node.repo, path: node.path, node: node.id, message: `${node.repo}/${node.path}: ${node.audience} material must live in a private domain repo, not ${repo.kind}` }))
|
|
275
|
+
}
|
|
276
|
+
return findings
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function actorFindings({ policy, project, actor }) {
|
|
280
|
+
const findings = []
|
|
281
|
+
const current = resolveCurrentActor({ policy, project, actor })
|
|
282
|
+
const severity = severityFor(policy)
|
|
283
|
+
for (const [repoName, repo] of Object.entries(policy.repos ?? {})) {
|
|
284
|
+
if (repo.kind !== 'private_domain') continue
|
|
285
|
+
if (!repo.ownerActor) continue
|
|
286
|
+
if (!current.actorId) {
|
|
287
|
+
findings.push(finding({ severity, code: 'private-domain-actor-unverified', repo: repoName, message: `${repoName}: could not verify local actor for private domain repo owned by ${repo.ownerActor}` }))
|
|
288
|
+
} else if (current.actorId !== repo.ownerActor) {
|
|
289
|
+
findings.push(finding({ severity, code: 'private-domain-actor-mismatch', repo: repoName, message: `${repoName}: private domain repo is owned by ${repo.ownerActor}, but current actor is ${current.actorId}` }))
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return findings
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function stagedPathsForProject(project) {
|
|
296
|
+
const paths = []
|
|
297
|
+
for (const repo of managedRepos(project)) {
|
|
298
|
+
if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) continue
|
|
299
|
+
const result = spawnSync('git', ['-C', repo.path, 'diff', '--cached', '--name-only', '--diff-filter=ACMR'], { encoding: 'utf8' })
|
|
300
|
+
if (result.status !== 0) continue
|
|
301
|
+
for (const rel of result.stdout.split('\n').map((line) => line.trim()).filter(Boolean)) {
|
|
302
|
+
paths.push({ repo: repo.name, repoRoot: repo.path, path: normalize(rel) })
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return paths
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function forbiddenPathFindings({ policy, stagedPaths }) {
|
|
309
|
+
const patterns = [...DEFAULT_FORBIDDEN_PATHS, ...asArray(policy.forbiddenPaths)]
|
|
310
|
+
const findings = []
|
|
311
|
+
const severity = severityFor(policy)
|
|
312
|
+
for (const item of stagedPaths) {
|
|
313
|
+
const matched = patterns.find((pattern) => matchesPathPattern(pattern, item.path))
|
|
314
|
+
if (matched) {
|
|
315
|
+
findings.push(finding({ severity, code: 'forbidden-path-staged', repo: item.repo, path: item.path, message: `${item.repo}/${item.path}: staged path is protected by boundary policy pattern ${matched}` }))
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return findings
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function unquoteValue(value) {
|
|
322
|
+
const text = String(value ?? '')
|
|
323
|
+
.trim()
|
|
324
|
+
.replace(/,$/, '')
|
|
325
|
+
.trim()
|
|
326
|
+
const quoted = text.match(/^(["'])(.*)\1$/)
|
|
327
|
+
return quoted ? quoted[2] : text
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function semanticFieldOnLine(line) {
|
|
331
|
+
if (!SEMANTIC_FIELD_RE.test(line)) return null
|
|
332
|
+
const assignment = line.match(SEMANTIC_ASSIGNMENT_RE)
|
|
333
|
+
if (assignment) {
|
|
334
|
+
return { field: assignment[1].toLowerCase().replace(/^kg\./, ''), value: unquoteValue(assignment[2]), raw: line.trim() }
|
|
335
|
+
}
|
|
336
|
+
// A boundary field is always declared as `field: value` in front matter and in sidecar
|
|
337
|
+
// JSON. A bare mention with no colon is prose, not a declaration.
|
|
338
|
+
if (!line.includes(':')) return null
|
|
339
|
+
return { field: null, value: null, raw: line.trim() }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function isInitializationDefault(field, value) {
|
|
343
|
+
const defaults = SEMANTIC_INITIALIZATION_DEFAULTS[field]
|
|
344
|
+
return Array.isArray(defaults) && defaults.includes(value)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function diffFileSections(diff) {
|
|
348
|
+
const files = []
|
|
349
|
+
let current = null
|
|
350
|
+
let hunk = null
|
|
351
|
+
for (const line of String(diff ?? '').split('\n')) {
|
|
352
|
+
if (line.startsWith('diff --git ')) {
|
|
353
|
+
const header = line.match(/^diff --git a\/(.*) b\/(.*)$/)
|
|
354
|
+
current = { path: header ? normalize(header[2]) : null, hunks: [], lines: [] }
|
|
355
|
+
files.push(current)
|
|
356
|
+
hunk = null
|
|
357
|
+
continue
|
|
358
|
+
}
|
|
359
|
+
if (!current) continue
|
|
360
|
+
current.lines.push(line)
|
|
361
|
+
if (line.startsWith('@@')) {
|
|
362
|
+
hunk = { added: [], removed: [] }
|
|
363
|
+
current.hunks.push(hunk)
|
|
364
|
+
continue
|
|
365
|
+
}
|
|
366
|
+
if (!hunk) continue
|
|
367
|
+
if (/^\+(?!\+)/.test(line)) hunk.added.push(line.slice(1))
|
|
368
|
+
else if (/^-(?!-)/.test(line)) hunk.removed.push(line.slice(1))
|
|
369
|
+
}
|
|
370
|
+
return files
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function semanticChangesInFile(file) {
|
|
374
|
+
const changes = []
|
|
375
|
+
for (const hunk of file.hunks ?? []) {
|
|
376
|
+
const pending = hunk.removed.map(semanticFieldOnLine).filter(Boolean)
|
|
377
|
+
for (const entry of hunk.added.map(semanticFieldOnLine).filter(Boolean)) {
|
|
378
|
+
const index = pending.findIndex((item) => item.field === entry.field)
|
|
379
|
+
const prior = index === -1 ? null : pending.splice(index, 1)[0]
|
|
380
|
+
if (prior) {
|
|
381
|
+
changes.push(entry.field ? `${entry.field} changed from "${prior.value}" to "${entry.value}"` : `boundary line changed to "${entry.raw}"`)
|
|
382
|
+
} else if (!entry.field || !isInitializationDefault(entry.field, entry.value)) {
|
|
383
|
+
changes.push(entry.field ? `${entry.field} introduced as "${entry.value}"` : `boundary line introduced: "${entry.raw}"`)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
for (const entry of pending) {
|
|
387
|
+
changes.push(entry.field ? `${entry.field} removed (was "${entry.value}")` : `boundary line removed: "${entry.raw}"`)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return changes
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function semanticDiffFindings({ policy, project }) {
|
|
394
|
+
const findings = []
|
|
395
|
+
const severity = severityFor(policy)
|
|
396
|
+
for (const repo of managedRepos(project)) {
|
|
397
|
+
if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) continue
|
|
398
|
+
const result = spawnSync('git', ['-C', repo.path, 'diff', '--cached', '--unified=0', '--', '*.md', '*.kg.json'], { encoding: 'utf8' })
|
|
399
|
+
if (result.status !== 0 || !result.stdout.trim()) continue
|
|
400
|
+
for (const file of diffFileSections(result.stdout)) {
|
|
401
|
+
const changes = semanticChangesInFile(file)
|
|
402
|
+
if (!changes.length) continue
|
|
403
|
+
// The marker has to travel in the same file's diff as the change it approves —
|
|
404
|
+
// a marker elsewhere in the commit approves nothing.
|
|
405
|
+
if (REVIEW_MARKER_RE.test(file.lines.join('\n'))) continue
|
|
406
|
+
const label = `${repo.name}/${file.path ?? '(unknown path)'}`
|
|
407
|
+
findings.push(
|
|
408
|
+
finding({
|
|
409
|
+
severity,
|
|
410
|
+
code: 'semantic-field-change-needs-review',
|
|
411
|
+
repo: repo.name,
|
|
412
|
+
path: file.path,
|
|
413
|
+
message: `${label}: ${changes.join('; ')} — add an Atelier-Boundary-Review: approved marker to this file's diff`,
|
|
414
|
+
details: { changes },
|
|
415
|
+
}),
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return findings
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function contentRuleFindings({ policy, project, files }) {
|
|
423
|
+
const rules = resolveContentRules(policy)
|
|
424
|
+
const exceptions = asArray(policy?.contentRuleExceptions)
|
|
425
|
+
const findings = []
|
|
426
|
+
for (const entry of files) {
|
|
427
|
+
for (const item of scanAddedContent({ files: entry.files, rules, exceptions, repo: entry.repo })) {
|
|
428
|
+
// The rule id and line stay on the finding: they are what a person needs to
|
|
429
|
+
// either fix the line or write the exception.
|
|
430
|
+
findings.push({ ...finding({ ...item, details: { rule: item.rule, line: item.line } }), rule: item.rule, line: item.line })
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return findings
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function stagedContentFindings({ policy, project }) {
|
|
437
|
+
const files = managedRepos(project)
|
|
438
|
+
.filter((repo) => repo.path && fs.existsSync(path.join(repo.path, '.git')))
|
|
439
|
+
.map((repo) => ({ repo: repo.name, files: parseAddedContent(stagedDiff(repo.path)) }))
|
|
440
|
+
return contentRuleFindings({ policy, project, files })
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function realPath(value) {
|
|
444
|
+
try {
|
|
445
|
+
return fs.realpathSync(path.resolve(value))
|
|
446
|
+
} catch {
|
|
447
|
+
return path.resolve(value)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Matched through realpath: a workspace reached via a symlink (a macOS temp dir, a
|
|
452
|
+
// linked checkout) otherwise fails to match its own configured path, and the guard
|
|
453
|
+
// silently judges nothing.
|
|
454
|
+
function repoForCwd(project, cwd) {
|
|
455
|
+
const resolved = realPath(cwd)
|
|
456
|
+
return (
|
|
457
|
+
managedRepos(project).find((repo) => {
|
|
458
|
+
if (!repo.path) return false
|
|
459
|
+
const repoPath = realPath(repo.path)
|
|
460
|
+
return resolved === repoPath || resolved.startsWith(`${repoPath}${path.sep}`)
|
|
461
|
+
}) ?? null
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Judge only what is being pushed. The whole-tree view lives in `boundary audit`,
|
|
467
|
+
* which reports without blocking, so an accepted usage elsewhere in the repo can
|
|
468
|
+
* never strand unrelated work on the machine.
|
|
469
|
+
*/
|
|
470
|
+
export function checkPushContent({ project, policy, repo, updates, cwd = process.cwd() } = {}) {
|
|
471
|
+
const target = repo ?? repoForCwd(project, cwd)
|
|
472
|
+
if (!target) {
|
|
473
|
+
// Fail closed. This hook is only installed into managed repos, so failing to
|
|
474
|
+
// identify one means the guard would judge nothing — a silent no-op is the
|
|
475
|
+
// failure mode this whole guard exists to avoid.
|
|
476
|
+
const unresolved = finding({
|
|
477
|
+
code: 'push-check-repo-unresolved',
|
|
478
|
+
repo: null,
|
|
479
|
+
message: `${cwd} is not a repo declared in ${project?.configPath ?? 'the project config'}; the push guard cannot tell what it is judging`,
|
|
480
|
+
})
|
|
481
|
+
return { ok: false, repo: null, findings: [unresolved], errors: [unresolved], warnings: [] }
|
|
482
|
+
}
|
|
483
|
+
const files = []
|
|
484
|
+
for (const update of updates) files.push(...parseAddedContent(diffForPushRange(target.path, update)))
|
|
485
|
+
const findings = contentRuleFindings({ policy, project, files: [{ repo: target.name, files }] })
|
|
486
|
+
const errors = findings.filter((item) => item.severity === 'error')
|
|
487
|
+
return { ok: errors.length === 0, repo: target.name, updates, findings, errors, warnings: findings.filter((item) => item.severity !== 'error') }
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export function auditContentRules({ project, policy } = {}) {
|
|
491
|
+
const rules = resolveContentRules(policy)
|
|
492
|
+
const exceptions = asArray(policy?.contentRuleExceptions)
|
|
493
|
+
const findings = []
|
|
494
|
+
for (const repo of managedRepos(project)) {
|
|
495
|
+
if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) continue
|
|
496
|
+
findings.push(...scanTree(repo.path, { rules, exceptions, repo: repo.name }))
|
|
497
|
+
}
|
|
498
|
+
const accepted = []
|
|
499
|
+
for (const repo of managedRepos(project)) {
|
|
500
|
+
for (const exception of exceptions) {
|
|
501
|
+
if (firstString(exception?.repo) !== repo.name) continue
|
|
502
|
+
accepted.push({ repo: repo.name, rule: exception.rule, paths: exception.paths, reason: exception.reason })
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return { schema: BOUNDARY_POLICY_SCHEMA, findings, accepted }
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function promotionRecords(project, policy) {
|
|
509
|
+
const recordsPath = firstString(policy?.promotion?.recordsPath)
|
|
510
|
+
if (!recordsPath) return []
|
|
511
|
+
const abs = resolvePathValue(recordsPath, project.configDir)
|
|
512
|
+
if (!abs || !fs.existsSync(abs)) return []
|
|
513
|
+
return fs
|
|
514
|
+
.readFileSync(abs, 'utf8')
|
|
515
|
+
.split('\n')
|
|
516
|
+
.map((line) => line.trim())
|
|
517
|
+
.filter(Boolean)
|
|
518
|
+
.map((line) => {
|
|
519
|
+
try {
|
|
520
|
+
return JSON.parse(line)
|
|
521
|
+
} catch {
|
|
522
|
+
return { invalid: true, line }
|
|
523
|
+
}
|
|
524
|
+
})
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function promotionFindings({ policy, project, graph }) {
|
|
528
|
+
if (!policy?.promotion?.requiresGitPromote) return []
|
|
529
|
+
const records = promotionRecords(project, policy)
|
|
530
|
+
const nodeById = new Map(graph.nodes.map((node) => [node.id, node]))
|
|
531
|
+
const recordKeys = new Set(records.filter((record) => !record.invalid).map((record) => `${record.source?.repo}:${record.source?.kgId}->${record.target?.repo}:${record.target?.kgId}`))
|
|
532
|
+
const findings = []
|
|
533
|
+
const severity = severityFor(policy)
|
|
534
|
+
for (const node of graph.nodes) {
|
|
535
|
+
const targetRepo = repoPolicy(policy, node.repo)
|
|
536
|
+
if (!targetRepo || targetRepo.kind === 'private_domain') continue
|
|
537
|
+
for (const sourceId of asArray(node.relations?.supersedes)) {
|
|
538
|
+
const sourceNode = nodeById.get(sourceId)
|
|
539
|
+
if (!sourceNode) continue
|
|
540
|
+
const sourceRepo = repoPolicy(policy, sourceNode.repo)
|
|
541
|
+
if (sourceRepo?.kind !== 'private_domain') continue
|
|
542
|
+
const key = `${sourceNode.repo}:${sourceNode.id}->${node.repo}:${node.id}`
|
|
543
|
+
if (!recordKeys.has(key)) {
|
|
544
|
+
findings.push(finding({ severity, code: 'git-promote-required', repo: node.repo, path: node.path, node: node.id, message: `${node.repo}/${node.path}: promotion from private ${sourceNode.id} requires git.promote record` }))
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
for (const [index, record] of records.entries()) {
|
|
549
|
+
if (record.invalid) {
|
|
550
|
+
findings.push(finding({ severity, code: 'git-promote-record-invalid-json', message: `git.promote record ${index + 1} is invalid JSON` }))
|
|
551
|
+
} else if (record.schema && record.schema !== PROMOTE_EVENT_SCHEMA && !LEGACY_PROMOTE_EVENT_SCHEMAS.has(record.schema)) {
|
|
552
|
+
findings.push(finding({ severity, code: 'git-promote-record-invalid-schema', message: `git.promote record ${index + 1} has invalid schema ${record.schema}` }))
|
|
553
|
+
} else if (record.event?.type !== 'git.promote' || record.event?.revocable !== false) {
|
|
554
|
+
findings.push(finding({ severity, code: 'git-promote-record-invalid', message: `git.promote record ${index + 1} must be durable and non-revocable` }))
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return findings
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export function checkBoundaryPolicy({ project, policy, staged = false, stagedOnly = false, actor = null } = {}) {
|
|
561
|
+
const validationErrors = validateBoundaryPolicy(policy, project)
|
|
562
|
+
let graph = null
|
|
563
|
+
const findings = validationErrors.map((message) => finding({ severity: severityFor(policy), code: 'boundary-policy-invalid', message }))
|
|
564
|
+
if (validationErrors.length === 0) {
|
|
565
|
+
if (!stagedOnly) {
|
|
566
|
+
graph = buildGraph(project)
|
|
567
|
+
findings.push(...graph.errors.map((message) => finding({ severity: severityFor(policy), code: 'knowledge-graph-invalid', message })))
|
|
568
|
+
for (const node of graph.nodes ?? []) findings.push(...nodePlacementFindings({ node, policy }))
|
|
569
|
+
findings.push(...promotionFindings({ policy, project, graph }))
|
|
570
|
+
}
|
|
571
|
+
findings.push(...actorFindings({ policy, project, actor }))
|
|
572
|
+
if (staged) {
|
|
573
|
+
const stagedPaths = stagedPathsForProject(project)
|
|
574
|
+
findings.push(...forbiddenPathFindings({ policy, stagedPaths }))
|
|
575
|
+
findings.push(...semanticDiffFindings({ policy, project }))
|
|
576
|
+
findings.push(...stagedContentFindings({ policy, project }))
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const errors = findings.filter((item) => item.severity === 'error')
|
|
580
|
+
const warnings = findings.filter((item) => item.severity !== 'error')
|
|
581
|
+
return {
|
|
582
|
+
ok: errors.length === 0,
|
|
583
|
+
mode: policy?.mode ?? null,
|
|
584
|
+
schema: BOUNDARY_POLICY_SCHEMA,
|
|
585
|
+
graphCounts: graph?.counts ?? null,
|
|
586
|
+
findings,
|
|
587
|
+
errors,
|
|
588
|
+
warnings,
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export function runBoundaryCheckCommand(argv = process.argv.slice(2)) {
|
|
593
|
+
const args = parseArgs(argv)
|
|
594
|
+
const staged = Boolean(args.staged)
|
|
595
|
+
const stagedOnly = Boolean(args['staged-only'])
|
|
596
|
+
const json = Boolean(args.json)
|
|
597
|
+
const project = commandProject({ argv })
|
|
598
|
+
const loaded = loadBoundaryPolicy(project)
|
|
599
|
+
const missingFindings = loaded.errors?.map((message) => finding({ code: 'boundary-policy-missing', message })) ?? []
|
|
600
|
+
const report = loaded.ok
|
|
601
|
+
? checkBoundaryPolicy({ project, policy: loaded.policy, staged, stagedOnly, actor: firstString(args.actor) })
|
|
602
|
+
: { ok: false, mode: null, schema: BOUNDARY_POLICY_SCHEMA, graphCounts: null, findings: missingFindings, errors: missingFindings, warnings: [] }
|
|
603
|
+
|
|
604
|
+
if (json) {
|
|
605
|
+
console.log(JSON.stringify(report, null, 2))
|
|
606
|
+
} else {
|
|
607
|
+
const label = report.ok ? 'passed' : 'failed'
|
|
608
|
+
console.log(`[boundary:check] ${label} · ${report.errors.length ?? report.findings.filter((item) => item.severity === 'error').length} errors · ${report.warnings.length ?? report.findings.filter((item) => item.severity !== 'error').length} warnings`)
|
|
609
|
+
for (const item of report.findings.slice(0, 50)) console.log(`${item.severity ?? 'error'} ${item.code}: ${item.message}`)
|
|
610
|
+
}
|
|
611
|
+
process.exit(report.ok ? 0 : 1)
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export function installBoundaryHooks({ project, force = false } = {}) {
|
|
615
|
+
const installed = []
|
|
616
|
+
const skipped = []
|
|
617
|
+
for (const repo of managedRepos(project)) {
|
|
618
|
+
if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) continue
|
|
619
|
+
const hooksDir = path.join(repo.path, '.git', 'hooks')
|
|
620
|
+
fs.mkdirSync(hooksDir, { recursive: true })
|
|
621
|
+
for (const hookName of ['pre-commit', 'pre-push']) {
|
|
622
|
+
const hookPath = path.join(hooksDir, hookName)
|
|
623
|
+
const existing = fs.existsSync(hookPath) ? fs.readFileSync(hookPath, 'utf8') : ''
|
|
624
|
+
const script = hookScript(project.configPath, hookName)
|
|
625
|
+
if (existing && !existing.includes('MNSTRY_ATELIER_BOUNDARY_GUARD') && !force) {
|
|
626
|
+
const sidecar = `${hookPath}.mnstry-atelier-boundary`
|
|
627
|
+
fs.writeFileSync(sidecar, script)
|
|
628
|
+
fs.chmodSync(sidecar, 0o755)
|
|
629
|
+
skipped.push({ repo: repo.name, hook: hookName, path: hookPath, sidecar })
|
|
630
|
+
continue
|
|
631
|
+
}
|
|
632
|
+
fs.writeFileSync(hookPath, script)
|
|
633
|
+
fs.chmodSync(hookPath, 0o755)
|
|
634
|
+
installed.push({ repo: repo.name, hook: hookName, path: hookPath })
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return { installed, skipped }
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function hookScript(projectConfigPath, hookName) {
|
|
641
|
+
const config = projectConfigPath ? `--project-config=${projectConfigPath.replaceAll('"', '\\"')}` : ''
|
|
642
|
+
// pre-push reads the ref updates git writes to stdin and judges only that range;
|
|
643
|
+
// pre-commit judges the staged diff. Neither scans the whole tree — that view is
|
|
644
|
+
// `atelier boundary audit`, which reports without blocking.
|
|
645
|
+
const invocation = hookName === 'pre-push' ? `boundary push-check ${config}` : `boundary check --staged ${config}`
|
|
646
|
+
return `#!/usr/bin/env bash
|
|
647
|
+
# MNSTRY_ATELIER_BOUNDARY_GUARD ${hookName}
|
|
648
|
+
set -euo pipefail
|
|
649
|
+
if command -v atelier >/dev/null 2>&1; then
|
|
650
|
+
exec atelier ${invocation}
|
|
651
|
+
elif [ -x "./node_modules/.bin/atelier" ]; then
|
|
652
|
+
exec ./node_modules/.bin/atelier ${invocation}
|
|
653
|
+
elif command -v mnstry-atelier >/dev/null 2>&1; then
|
|
654
|
+
exec mnstry-atelier ${invocation}
|
|
655
|
+
elif [ -x "./node_modules/.bin/mnstry-atelier" ]; then
|
|
656
|
+
exec ./node_modules/.bin/mnstry-atelier ${invocation}
|
|
657
|
+
elif [ -n "\${MNSTRY_ATELIER_PACKAGE_ROOT:-}" ]; then
|
|
658
|
+
exec node "$MNSTRY_ATELIER_PACKAGE_ROOT/bin/mnstry-atelier.mjs" ${invocation}
|
|
659
|
+
else
|
|
660
|
+
echo "Atelier boundary guard is not installed on PATH" >&2
|
|
661
|
+
exit 1
|
|
662
|
+
fi
|
|
663
|
+
`
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export function runBoundaryPushCheckCommand(argv = process.argv.slice(2), { stdin = readStdin() } = {}) {
|
|
667
|
+
const args = parseArgs(argv)
|
|
668
|
+
const project = commandProject({ argv })
|
|
669
|
+
const loaded = loadBoundaryPolicy(project)
|
|
670
|
+
if (!loaded.ok) {
|
|
671
|
+
console.error(loaded.errors.join('\n'))
|
|
672
|
+
process.exit(1)
|
|
673
|
+
}
|
|
674
|
+
const updates = parsePushRefUpdates(stdin)
|
|
675
|
+
if (!updates.length) {
|
|
676
|
+
console.log('[boundary:push-check] no ref updates on stdin · nothing to judge')
|
|
677
|
+
process.exit(0)
|
|
678
|
+
}
|
|
679
|
+
const report = checkPushContent({ project, policy: loaded.policy, updates, cwd: process.cwd() })
|
|
680
|
+
if (args.json) {
|
|
681
|
+
console.log(JSON.stringify(report, null, 2))
|
|
682
|
+
} else {
|
|
683
|
+
console.log(`[boundary:push-check] ${report.ok ? 'passed' : 'blocked'} · ${report.repo ?? 'unknown repo'} · ${report.errors.length} errors · ${report.warnings.length} warnings`)
|
|
684
|
+
for (const item of report.findings.slice(0, 50)) console.log(`${item.severity} ${item.rule ?? item.code}: ${item.message}`)
|
|
685
|
+
if (!report.ok && report.repo) {
|
|
686
|
+
console.log('')
|
|
687
|
+
console.log('If this usage is intentional, declare it in the boundary policy:')
|
|
688
|
+
console.log(' contentRuleExceptions: [{ rule, repo, paths: [...], reason: "why this is accepted" }]')
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
process.exit(report.ok ? 0 : 1)
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export function runBoundaryAuditCommand(argv = process.argv.slice(2)) {
|
|
695
|
+
const args = parseArgs(argv)
|
|
696
|
+
const project = commandProject({ argv })
|
|
697
|
+
const loaded = loadBoundaryPolicy(project)
|
|
698
|
+
if (!loaded.ok) {
|
|
699
|
+
console.error(loaded.errors.join('\n'))
|
|
700
|
+
process.exit(1)
|
|
701
|
+
}
|
|
702
|
+
const report = auditContentRules({ project, policy: loaded.policy })
|
|
703
|
+
if (args.json) {
|
|
704
|
+
console.log(JSON.stringify(report, null, 2))
|
|
705
|
+
} else {
|
|
706
|
+
console.log(`[boundary:audit] ${report.findings.length} content-rule matches across the tree · ${report.accepted.length} declared exceptions`)
|
|
707
|
+
for (const item of report.findings.slice(0, 100)) console.log(`${item.severity} ${item.rule}: ${item.message}`)
|
|
708
|
+
for (const item of report.accepted) console.log(`accepted ${item.rule} in ${item.repo} (${item.paths.join(', ')}): ${item.reason}`)
|
|
709
|
+
}
|
|
710
|
+
// Reporting only. A pre-existing accepted usage must never block unrelated work.
|
|
711
|
+
process.exit(0)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function readStdin() {
|
|
715
|
+
try {
|
|
716
|
+
return fs.readFileSync(0, 'utf8')
|
|
717
|
+
} catch {
|
|
718
|
+
return ''
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export function runBoundaryInstallHooksCommand(argv = process.argv.slice(2)) {
|
|
723
|
+
const args = parseArgs(argv)
|
|
724
|
+
const project = commandProject({ argv })
|
|
725
|
+
const result = installBoundaryHooks({ project, force: Boolean(args.force) })
|
|
726
|
+
console.log(JSON.stringify({ ok: result.skipped.length === 0, ...result }, null, 2))
|
|
727
|
+
process.exit(result.skipped.length === 0 ? 0 : 1)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function parsePromoteArgs(argv) {
|
|
731
|
+
const args = parseArgs(argv)
|
|
732
|
+
const sourceRepo = firstString(args['source-repo'], args.sourceRepo)
|
|
733
|
+
const targetRepo = firstString(args['target-repo'], args.targetRepo)
|
|
734
|
+
const kgId = firstString(args['kg-id'], args.kgId)
|
|
735
|
+
const targetKgId = firstString(args['target-kg-id'], args.targetKgId) || kgId
|
|
736
|
+
const actor = firstString(args.actor, process.env.MNSTRY_ATELIER_ACTOR, process.env.GITHUB_ACTOR, process.env.USER) || 'unknown'
|
|
737
|
+
return { args, sourceRepo, targetRepo, kgId, targetKgId, actor }
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
export function createPromoteEvent({ project, policy, sourceRepo, targetRepo, kgId, targetKgId = kgId, actor }) {
|
|
741
|
+
if (!sourceRepo || !targetRepo || !kgId) throw new Error('promote requires --source-repo, --target-repo, and --kg-id')
|
|
742
|
+
const source = project.repos.find((repo) => repo.name === sourceRepo)
|
|
743
|
+
const target = project.repos.find((repo) => repo.name === targetRepo)
|
|
744
|
+
if (!source) throw new Error(`source repo not configured: ${sourceRepo}`)
|
|
745
|
+
if (!target) throw new Error(`target repo not configured: ${targetRepo}`)
|
|
746
|
+
const sourcePolicy = repoPolicy(policy, sourceRepo)
|
|
747
|
+
const targetPolicy = repoPolicy(policy, targetRepo)
|
|
748
|
+
const sourceCommit = gitHead(source.path)
|
|
749
|
+
const targetCommit = gitHead(target.path)
|
|
750
|
+
return {
|
|
751
|
+
schema: PROMOTE_EVENT_SCHEMA,
|
|
752
|
+
source: {
|
|
753
|
+
repo: sourceRepo,
|
|
754
|
+
repoAccess: sourcePolicy?.readBoundary ?? source.readBoundary ?? 'private',
|
|
755
|
+
kgId,
|
|
756
|
+
audience: 'private',
|
|
757
|
+
commit: sourceCommit,
|
|
758
|
+
},
|
|
759
|
+
target: {
|
|
760
|
+
repo: targetRepo,
|
|
761
|
+
repoAccess: targetPolicy?.readBoundary ?? target.readBoundary ?? 'team',
|
|
762
|
+
kgId: targetKgId,
|
|
763
|
+
audience: 'team',
|
|
764
|
+
commit: targetCommit,
|
|
765
|
+
},
|
|
766
|
+
event: {
|
|
767
|
+
type: 'git.promote',
|
|
768
|
+
actor,
|
|
769
|
+
disclosureClass: 'durable',
|
|
770
|
+
revocable: false,
|
|
771
|
+
recordedAt: new Date().toISOString(),
|
|
772
|
+
},
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function gitHead(repoPath) {
|
|
777
|
+
try {
|
|
778
|
+
return execFileSync('git', ['-C', repoPath, 'rev-parse', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
779
|
+
} catch {
|
|
780
|
+
return 'unknown'
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export function runPromoteCommand(argv = process.argv.slice(2)) {
|
|
785
|
+
const parsed = parsePromoteArgs(argv)
|
|
786
|
+
const project = commandProject({ argv })
|
|
787
|
+
const loaded = loadBoundaryPolicy(project)
|
|
788
|
+
if (!loaded.ok) {
|
|
789
|
+
console.error(loaded.errors.join('\n'))
|
|
790
|
+
process.exit(1)
|
|
791
|
+
}
|
|
792
|
+
const event = createPromoteEvent({ project, policy: loaded.policy, ...parsed })
|
|
793
|
+
const recordsPath = firstString(loaded.policy.promotion?.recordsPath)
|
|
794
|
+
if (!recordsPath) {
|
|
795
|
+
console.error('boundary policy promotion.recordsPath is required')
|
|
796
|
+
process.exit(1)
|
|
797
|
+
}
|
|
798
|
+
const abs = resolvePathValue(recordsPath, project.configDir)
|
|
799
|
+
if (parsed.args['dry-run']) {
|
|
800
|
+
console.log(JSON.stringify({ ok: true, dryRun: true, path: abs, event }, null, 2))
|
|
801
|
+
process.exit(0)
|
|
802
|
+
}
|
|
803
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
804
|
+
fs.appendFileSync(abs, `${JSON.stringify(event)}\n`)
|
|
805
|
+
console.log(JSON.stringify({ ok: true, path: abs, event }, null, 2))
|
|
806
|
+
}
|