release-skill 0.1.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.
Files changed (125) hide show
  1. package/.agents/plugins/marketplace.json +23 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +10 -0
  4. package/.codex-plugin/plugin.json +26 -0
  5. package/CHANGELOG.md +68 -0
  6. package/CODE_OF_CONDUCT.md +76 -0
  7. package/CONTRIBUTING.md +49 -0
  8. package/INSTALL.md +182 -0
  9. package/LICENSE +21 -0
  10. package/NOTICE +25 -0
  11. package/README.md +501 -0
  12. package/README.zh-CN.md +463 -0
  13. package/SECURITY.md +48 -0
  14. package/adapters/claude/.claude-plugin/marketplace.json +16 -0
  15. package/adapters/claude/.claude-plugin/plugin.json +10 -0
  16. package/adapters/claude/skills/release-assess/SKILL.md +52 -0
  17. package/adapters/claude/skills/release-help/SKILL.md +60 -0
  18. package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
  19. package/adapters/claude/skills/release-publish/SKILL.md +55 -0
  20. package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
  21. package/adapters/claude/skills/release-verify/SKILL.md +70 -0
  22. package/adapters/codex/.codex-plugin/plugin.json +26 -0
  23. package/adapters/codex/skills/release-assess/SKILL.md +52 -0
  24. package/adapters/codex/skills/release-help/SKILL.md +60 -0
  25. package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
  26. package/adapters/codex/skills/release-publish/SKILL.md +55 -0
  27. package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
  28. package/adapters/codex/skills/release-verify/SKILL.md +70 -0
  29. package/bin/release-skill.mjs +743 -0
  30. package/native/safe-write/binding.gyp +40 -0
  31. package/native/safe-write/prebuilds.json +4 -0
  32. package/native/safe-write/src/safe_write.cc +2023 -0
  33. package/package.json +75 -0
  34. package/references/.render-manifest.json +33 -0
  35. package/references/00-target-state.md +124 -0
  36. package/references/01-state-machine.md +155 -0
  37. package/references/02-project-config.md +217 -0
  38. package/references/03-readme-quality.md +136 -0
  39. package/references/04-supply-chain.md +147 -0
  40. package/references/05-evidence-and-errors.md +164 -0
  41. package/references/06-adapter-contract.md +178 -0
  42. package/schemas/.render-manifest.json +37 -0
  43. package/schemas/approval-record.schema.json +115 -0
  44. package/schemas/artifact-lock.schema.json +111 -0
  45. package/schemas/artifact-plan.schema.json +52 -0
  46. package/schemas/artifact-policy.schema.json +76 -0
  47. package/schemas/evidence-event.schema.json +89 -0
  48. package/schemas/release-plan.schema.json +369 -0
  49. package/schemas/release-project.schema.json +359 -0
  50. package/schemas/release-run.schema.json +195 -0
  51. package/skills/release-assess/SKILL.md +52 -0
  52. package/skills/release-help/SKILL.md +60 -0
  53. package/skills/release-prepare/SKILL.md +71 -0
  54. package/skills/release-publish/SKILL.md +55 -0
  55. package/skills/release-reconcile/SKILL.md +73 -0
  56. package/skills/release-verify/SKILL.md +70 -0
  57. package/skills-src/release-assess/SKILL.md +52 -0
  58. package/skills-src/release-help/SKILL.md +60 -0
  59. package/skills-src/release-prepare/SKILL.md +71 -0
  60. package/skills-src/release-publish/SKILL.md +55 -0
  61. package/skills-src/release-reconcile/SKILL.md +73 -0
  62. package/skills-src/release-verify/SKILL.md +70 -0
  63. package/src/adapters/contract.mjs +214 -0
  64. package/src/adapters/git-github.mjs +214 -0
  65. package/src/adapters/npm.mjs +947 -0
  66. package/src/adapters/plugin-marketplace.mjs +1365 -0
  67. package/src/adapters/push-snapshot.mjs +216 -0
  68. package/src/artifacts/adoption.mjs +743 -0
  69. package/src/artifacts/artifact-plan.mjs +162 -0
  70. package/src/artifacts/entry.mjs +240 -0
  71. package/src/artifacts/git-authority.mjs +637 -0
  72. package/src/artifacts/graph.mjs +189 -0
  73. package/src/artifacts/inspect.mjs +520 -0
  74. package/src/artifacts/inventory.mjs +192 -0
  75. package/src/artifacts/merge/binary.mjs +77 -0
  76. package/src/artifacts/merge/entry-merge.mjs +228 -0
  77. package/src/artifacts/merge/json.mjs +641 -0
  78. package/src/artifacts/merge/markdown.mjs +246 -0
  79. package/src/artifacts/merge/regions.mjs +156 -0
  80. package/src/artifacts/merge/text.mjs +432 -0
  81. package/src/artifacts/merge/tree.mjs +202 -0
  82. package/src/artifacts/merge/yaml.mjs +669 -0
  83. package/src/artifacts/path-key.mjs +94 -0
  84. package/src/artifacts/policy.mjs +319 -0
  85. package/src/artifacts/producer-registry.mjs +439 -0
  86. package/src/artifacts/project-lock.mjs +732 -0
  87. package/src/artifacts/resolution.mjs +658 -0
  88. package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
  89. package/src/artifacts/safe-fs.mjs +72 -0
  90. package/src/artifacts/state.mjs +495 -0
  91. package/src/artifacts/transaction-journal.mjs +983 -0
  92. package/src/artifacts/transaction.mjs +1361 -0
  93. package/src/commands/approve.mjs +280 -0
  94. package/src/commands/artifacts.mjs +627 -0
  95. package/src/commands/assess.mjs +838 -0
  96. package/src/commands/prepare.mjs +1377 -0
  97. package/src/commands/publish.mjs +883 -0
  98. package/src/commands/reconcile.mjs +1255 -0
  99. package/src/commands/verify.mjs +915 -0
  100. package/src/core/approval.mjs +332 -0
  101. package/src/core/baseline.mjs +272 -0
  102. package/src/core/blackbox-hard-gates.mjs +142 -0
  103. package/src/core/config.mjs +448 -0
  104. package/src/core/digest.mjs +90 -0
  105. package/src/core/errors.mjs +113 -0
  106. package/src/core/evidence.mjs +167 -0
  107. package/src/core/hooks.mjs +241 -0
  108. package/src/core/node-version.mjs +64 -0
  109. package/src/core/plan.mjs +735 -0
  110. package/src/core/previous-public-baseline.mjs +204 -0
  111. package/src/core/run.mjs +681 -0
  112. package/src/core/state-machine.mjs +76 -0
  113. package/src/core/version-consistency.mjs +111 -0
  114. package/src/producers/build-adapters.mjs +231 -0
  115. package/src/producers/render-public-assets.mjs +152 -0
  116. package/src/producers/sync-skills.mjs +96 -0
  117. package/src/readme/contract.mjs +297 -0
  118. package/src/readme/examples.mjs +288 -0
  119. package/src/readme/parity.mjs +122 -0
  120. package/src/snapshot/export.mjs +99 -0
  121. package/src/snapshot/frozen.mjs +401 -0
  122. package/src/snapshot/manifest.mjs +207 -0
  123. package/src/snapshot/public-map.mjs +1459 -0
  124. package/src/snapshot/public-path.mjs +110 -0
  125. package/src/snapshot/scan.mjs +419 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Canonical artifact path helpers.
3
+ *
4
+ * Single source of truth for artifact path canonicalization used by both
5
+ * snapshot public-map and artifact inventory/policy.
6
+ *
7
+ * @module artifacts/path-key
8
+ */
9
+
10
+ import { normalize } from 'node:path';
11
+ import { ReleaseError, PATH_UNSAFE } from '../core/errors.mjs';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Windows reserved device names (case-insensitive)
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const WINDOWS_RESERVED_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i;
18
+
19
+ /**
20
+ * Check whether a path segment is a Windows reserved device name.
21
+ *
22
+ * @param {string} segment
23
+ * @returns {boolean}
24
+ */
25
+ function isWindowsReservedSegment(segment) {
26
+ return WINDOWS_RESERVED_RE.test(segment);
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Public API
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /**
34
+ * Canonicalise an artifact path.
35
+ *
36
+ * Requires POSIX separators, applies Unicode NFC,
37
+ * and rejects:
38
+ * - Non-string or empty input
39
+ * - NUL bytes
40
+ * - Absolute paths (POSIX `/`, Windows drive-letter `<drive>:\`, UNC `\\`)
41
+ * - Path traversal (`..`), dot segments (`.`), empty segments
42
+ * - Windows reserved device names (CON, PRN, AUX, NUL, COM1–9, LPT1–9)
43
+ * - Colons (NTFS Alternate Data Streams, Windows drive-relative)
44
+ *
45
+ * Returns a frozen object with:
46
+ * - `path` — the canonical POSIX-style relative path
47
+ * - `collisionKey` — stable case-fold key for cross-platform collision detection
48
+ *
49
+ * @param {string} input - Raw path string.
50
+ * @returns {{ path: string, collisionKey: string }}
51
+ * @throws {ReleaseError} PATH_UNSAFE on any violation.
52
+ */
53
+ export function canonicalArtifactPath(input) {
54
+ if (typeof input !== 'string' || input.length === 0) {
55
+ throw new ReleaseError(PATH_UNSAFE, 'path must be a non-empty string', { input });
56
+ }
57
+
58
+ // Reject NUL bytes
59
+ if (input.includes('\0')) {
60
+ throw new ReleaseError(PATH_UNSAFE, 'path contains NUL byte', { input });
61
+ }
62
+
63
+ // Artifact identities are POSIX-only. Converting a backslash would make an
64
+ // invalid Windows/UNC spelling alias a different public path.
65
+ if (input.includes('\\')) {
66
+ throw new ReleaseError(PATH_UNSAFE, 'path must use POSIX separators', { input });
67
+ }
68
+ const path = input.normalize('NFC');
69
+
70
+ // Reject absolute paths
71
+ if (path.startsWith('/') || /^[A-Za-z]:/.test(path) || path.startsWith('\\\\')) {
72
+ throw new ReleaseError(PATH_UNSAFE, 'path must not be absolute', { input });
73
+ }
74
+
75
+ // Reject colons (ADS, drive-relative)
76
+ if (path.includes(':')) {
77
+ throw new ReleaseError(PATH_UNSAFE, 'path must not contain colon', { input });
78
+ }
79
+
80
+ // Split and validate segments
81
+ const parts = path.split('/');
82
+ if (path.startsWith('/') || parts.some((p) => p === '' || p === '.' || p === '..')) {
83
+ throw new ReleaseError(PATH_UNSAFE, 'path contains empty, dot, or traversal segment', { input });
84
+ }
85
+
86
+ if (parts.some(isWindowsReservedSegment)) {
87
+ throw new ReleaseError(PATH_UNSAFE, 'path contains Windows reserved name', { input });
88
+ }
89
+
90
+ // Compute collision key: lowercase for cross-platform case-fold
91
+ const collisionKey = path.toLocaleLowerCase('en-US');
92
+
93
+ return Object.freeze({ path, collisionKey });
94
+ }
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Artifact policy loader and validator.
3
+ *
4
+ * Provides:
5
+ * - `validateArtifactPolicy(policy)` — schema validation
6
+ * - `loadArtifactPolicy({ root, policyPath, previousLock })` — safe YAML
7
+ * parse, schema validation, digest computation, and protection comparison
8
+ *
9
+ * @module artifacts/policy
10
+ */
11
+
12
+ import { readFile } from 'node:fs/promises';
13
+ import { readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { createHash } from 'node:crypto';
16
+ import YAML from 'yaml';
17
+ import Ajv from 'ajv';
18
+ import addFormats from 'ajv-formats';
19
+ import {
20
+ ReleaseError,
21
+ ARTIFACT_POLICY_INVALID,
22
+ PATH_UNSAFE,
23
+ } from '../core/errors.mjs';
24
+ import { canonicalArtifactPath } from './path-key.mjs';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Schema validation
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const POLICY_SCHEMA_PATH = new URL(
31
+ '../../schemas/artifact-policy.schema.json',
32
+ import.meta.url,
33
+ );
34
+
35
+ const ajv = new Ajv({ allErrors: true, strict: false });
36
+ addFormats(ajv);
37
+
38
+ // Compile schema once at module load (synchronous).
39
+ const _validate = ajv.compile(JSON.parse(readFileSync(POLICY_SCHEMA_PATH, 'utf8')));
40
+
41
+ /**
42
+ * Validate a policy object against the artifact-policy JSON Schema.
43
+ *
44
+ * @param {object} policy - Parsed policy object.
45
+ * @returns {{ valid: boolean, errors?: object[], summary?: string, graphInput?: object[] }}
46
+ */
47
+ export function validateArtifactPolicy(policy) {
48
+ const ok = _validate(policy);
49
+ if (!ok) {
50
+ return {
51
+ valid: false,
52
+ errors: _validate.errors.map((e) => ({
53
+ instancePath: e.instancePath,
54
+ message: e.message,
55
+ params: e.params,
56
+ })),
57
+ summary: _validate.errors
58
+ .map((e) => `${e.instancePath || '/'} ${e.message}`)
59
+ .join('; '),
60
+ };
61
+ }
62
+
63
+ // Runtime conditional: generated artifacts require producer, sourceArtifacts, adoptionRoutes
64
+ const runtimeErrors = [];
65
+ const artifactIds = new Set((policy.artifacts ?? []).map((artifact) => artifact.id));
66
+ for (const [i, artifact] of (policy.artifacts ?? []).entries()) {
67
+ if (artifact.type === 'generated') {
68
+ if (!artifact.producer) {
69
+ runtimeErrors.push({
70
+ instancePath: `/artifacts/${i}`,
71
+ message: 'generated artifact requires "producer"',
72
+ params: { missingProperty: 'producer' },
73
+ });
74
+ }
75
+ if (!Array.isArray(artifact.sourceArtifacts) || artifact.sourceArtifacts.length === 0) {
76
+ runtimeErrors.push({
77
+ instancePath: `/artifacts/${i}`,
78
+ message: 'generated artifact requires non-empty "sourceArtifacts"',
79
+ params: { missingProperty: 'sourceArtifacts' },
80
+ });
81
+ }
82
+ if (!Array.isArray(artifact.adoptionRoutes) || artifact.adoptionRoutes.length === 0) {
83
+ runtimeErrors.push({
84
+ instancePath: `/artifacts/${i}`,
85
+ message: 'generated artifact requires non-empty "adoptionRoutes"',
86
+ params: { missingProperty: 'adoptionRoutes' },
87
+ });
88
+ } else {
89
+ // Validate each route is an object with required fields
90
+ for (const [ri, route] of artifact.adoptionRoutes.entries()) {
91
+ if (typeof route === 'string') {
92
+ runtimeErrors.push({
93
+ instancePath: `/artifacts/${i}/adoptionRoutes/${ri}`,
94
+ message: `adoptionRoutes must be objects {target, sourceArtifact, mode}, got string "${route}"`,
95
+ });
96
+ } else if (!route.target || !route.sourceArtifact || !route.mode) {
97
+ runtimeErrors.push({
98
+ instancePath: `/artifacts/${i}/adoptionRoutes/${ri}`,
99
+ message: 'adoptionRoute must have {target, sourceArtifact, mode}',
100
+ });
101
+ } else if (!artifactIds.has(route.sourceArtifact) ||
102
+ !artifact.sourceArtifacts?.includes(route.sourceArtifact)) {
103
+ runtimeErrors.push({
104
+ instancePath: `/artifacts/${i}/adoptionRoutes/${ri}/sourceArtifact`,
105
+ message: 'adoptionRoute sourceArtifact must be a registered direct sourceArtifact',
106
+ });
107
+ }
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ if (runtimeErrors.length > 0) {
114
+ return {
115
+ valid: false,
116
+ errors: runtimeErrors,
117
+ summary: runtimeErrors.map((e) => `${e.instancePath} ${e.message}`).join('; '),
118
+ };
119
+ }
120
+
121
+ // Extract graphInput: generated artifacts
122
+ const graphInput = (policy.artifacts ?? []).filter((a) => a.type === 'generated');
123
+ return { valid: true, graphInput, policyPath: null };
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Safe YAML parsing (rejects aliases, merge keys, duplicate keys)
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * Recursively walk a YAML AST node and return true if any Alias node is found.
132
+ *
133
+ * @param {import('yaml').Node | null | undefined} node
134
+ * @returns {boolean}
135
+ */
136
+ function containsAlias(node) {
137
+ if (!node || typeof node !== 'object') return false;
138
+ if (node.constructor?.name === 'Alias') return true;
139
+ if (node.items) {
140
+ for (const item of node.items) {
141
+ if (containsAlias(item)) return true;
142
+ if (item.value && containsAlias(item.value)) return true;
143
+ }
144
+ }
145
+ if (node.value && containsAlias(node.value)) return true;
146
+ return false;
147
+ }
148
+
149
+ /**
150
+ * Parse a YAML file safely within a root directory.
151
+ *
152
+ * Rejects YAML aliases (including merge keys), duplicate keys, and
153
+ * paths that escape root.
154
+ *
155
+ * @param {string} root - Repository root (absolute).
156
+ * @param {string} policyPath - Relative path to policy YAML.
157
+ * @returns {Promise<object>} Parsed policy object.
158
+ * @throws {ReleaseError} PATH_UNSAFE if path escapes root.
159
+ * @throws {ReleaseError} ARTIFACT_POLICY_INVALID on YAML errors.
160
+ */
161
+ async function parseSafeYamlWithinRoot(root, policyPath) {
162
+ // Validate path doesn't escape root using canonicalArtifactPath.
163
+ // The policyPath is a relative POSIX path; validate its segments.
164
+ try {
165
+ canonicalArtifactPath(policyPath);
166
+ } catch (err) {
167
+ if (err.code === PATH_UNSAFE) {
168
+ throw new ReleaseError(
169
+ ARTIFACT_POLICY_INVALID,
170
+ `invalid policy path: ${err.message}`,
171
+ { policyPath, root },
172
+ );
173
+ }
174
+ throw err;
175
+ }
176
+
177
+ const fullPath = join(root, policyPath);
178
+
179
+ let content;
180
+ try {
181
+ content = await readFile(fullPath, 'utf8');
182
+ } catch (err) {
183
+ throw new ReleaseError(
184
+ ARTIFACT_POLICY_INVALID,
185
+ `cannot read policy file: ${err.message}`,
186
+ { policyPath, fullPath, cause: err.code },
187
+ );
188
+ }
189
+
190
+ // Parse with AST access for alias detection
191
+ let doc;
192
+ try {
193
+ doc = YAML.parseDocument(content);
194
+ } catch (err) {
195
+ throw new ReleaseError(
196
+ ARTIFACT_POLICY_INVALID,
197
+ `YAML parse error: ${err.message}`,
198
+ { policyPath },
199
+ );
200
+ }
201
+
202
+ // Reject YAML aliases (including merge keys)
203
+ if (containsAlias(doc.contents)) {
204
+ throw new ReleaseError(
205
+ ARTIFACT_POLICY_INVALID,
206
+ 'YAML aliases are not allowed',
207
+ { policyPath },
208
+ );
209
+ }
210
+
211
+ // Reject duplicate keys
212
+ const errors = doc.errors ?? [];
213
+ const warnings = doc.warnings ?? [];
214
+ const allIssues = [...errors, ...warnings];
215
+ for (const issue of allIssues) {
216
+ if (issue.code === 'YAML_MAP_KEY' || /duplicate key/i.test(issue.message ?? '')) {
217
+ throw new ReleaseError(
218
+ ARTIFACT_POLICY_INVALID,
219
+ `duplicate YAML key: ${issue.message}`,
220
+ { policyPath, line: issue.linePos },
221
+ );
222
+ }
223
+ }
224
+
225
+ // Convert to plain object
226
+ const policy = doc.toJSON();
227
+ if (!policy || typeof policy !== 'object') {
228
+ throw new ReleaseError(
229
+ ARTIFACT_POLICY_INVALID,
230
+ 'policy YAML must be an object',
231
+ { policyPath },
232
+ );
233
+ }
234
+
235
+ return policy;
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Digest helpers
240
+ // ---------------------------------------------------------------------------
241
+
242
+ /**
243
+ * Canonical JSON for stable hashing (sorted keys).
244
+ */
245
+ function canonicalJson(obj) {
246
+ return JSON.stringify(obj, Object.keys(obj ?? {}).sort());
247
+ }
248
+
249
+ function sha256Hex(data) {
250
+ return createHash('sha256').update(data).digest('hex');
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // Protection comparison
255
+ // ---------------------------------------------------------------------------
256
+
257
+ /**
258
+ * Compare previous lock's artifact inventory against current policy.
259
+ *
260
+ * Returns `UNCHANGED` if no artifacts were removed, or
261
+ * `POLICY_CHANGE_PENDING` if the prior inventory shrank.
262
+ *
263
+ * @param {object | undefined} previousLock - Previous artifact-lock object.
264
+ * @param {object} policy - Current validated policy.
265
+ * @returns {{ status: string, removedArtifactIds?: string[] }}
266
+ */
267
+ function compareProtection(previousLock, policy) {
268
+ if (!previousLock || !Array.isArray(previousLock.artifactIds)) {
269
+ return { status: 'UNCHANGED' };
270
+ }
271
+
272
+ const currentIds = new Set((policy.artifacts ?? []).map((a) => a.id));
273
+ const removed = previousLock.artifactIds.filter((id) => !currentIds.has(id));
274
+
275
+ if (removed.length > 0) {
276
+ return { status: 'POLICY_CHANGE_PENDING', removedArtifactIds: removed };
277
+ }
278
+
279
+ return { status: 'UNCHANGED' };
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // Public API
284
+ // ---------------------------------------------------------------------------
285
+
286
+ /**
287
+ * Load and validate an artifact policy from YAML.
288
+ *
289
+ * @param {object} options
290
+ * @param {string} options.root - Repository root (absolute).
291
+ * @param {string} [options.policyPath='.release-skill/artifact-policy.yaml'] - Relative policy path.
292
+ * @param {object} [options.previousLock] - Previous artifact-lock for protection comparison.
293
+ * @returns {Promise<{ policy: object, policyPath: string, policyDigest: string, graphInput: object[], protectionChange: object }>}
294
+ * @throws {ReleaseError} ARTIFACT_POLICY_INVALID on validation failure.
295
+ */
296
+ export async function loadArtifactPolicy({
297
+ root,
298
+ policyPath = '.release-skill/artifact-policy.yaml',
299
+ previousLock,
300
+ } = {}) {
301
+ const policy = await parseSafeYamlWithinRoot(root, policyPath);
302
+
303
+ const result = validateArtifactPolicy(policy);
304
+ if (!result.valid) {
305
+ throw new ReleaseError(
306
+ ARTIFACT_POLICY_INVALID,
307
+ result.summary ?? 'artifact policy validation failed',
308
+ { errors: result.errors },
309
+ );
310
+ }
311
+
312
+ return Object.freeze({
313
+ policy: Object.freeze(policy),
314
+ policyPath,
315
+ policyDigest: `sha256:${sha256Hex(canonicalJson(policy))}`,
316
+ graphInput: Object.freeze(result.graphInput ?? []),
317
+ protectionChange: compareProtection(previousLock, policy),
318
+ });
319
+ }