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,142 @@
1
+ /**
2
+ * README 黑盒测试独立硬门纯函数。
3
+ *
4
+ * 供 runner(run-readme-blackbox.mjs)和单测使用,用于独立验证
5
+ * maintainer persona 产生的 workspace 产物,不信任 persona 自报布尔值。
6
+ *
7
+ * @module blackbox-hard-gates
8
+ */
9
+
10
+ import { validateVersionConsistency } from './version-consistency.mjs';
11
+ import { validatePlanActionCompleteness } from './plan.mjs';
12
+
13
+ /**
14
+ * 验证 project.yaml 配置中的 release unit 属性。
15
+ *
16
+ * @param {object} config - 已解析的 project.yaml 对象
17
+ * @param {object} expectations
18
+ * @param {string} expectations.expectedUnitId - 预期 unit id(如 'my-plugin')
19
+ * @param {string} expectations.expectedSource - 预期 source 路径(如 'packages/my-plugin')
20
+ * @param {string} expectations.expectedVersionSource - 预期 version.source(如 'package.json')
21
+ * @param {string} [expectations.expectedPublicRepo] - 预期 publicRepo
22
+ * @param {string} [expectations.expectedTagTemplate] - 预期 tagTemplate
23
+ * @param {string[]} [expectations.expectedDistributionTypes] - 预期 distribution types
24
+ * @returns {{ passed: boolean, failures: string[] }}
25
+ */
26
+ export function verifyMaintainerConfig(config, expectations) {
27
+ const failures = [];
28
+ const {
29
+ expectedUnitId,
30
+ expectedSource,
31
+ expectedVersionSource,
32
+ expectedPublicRepo,
33
+ expectedTagTemplate,
34
+ expectedDistributionTypes,
35
+ } = expectations;
36
+
37
+ if (!config || typeof config !== 'object') {
38
+ return { passed: false, failures: ['config is null or not an object'] };
39
+ }
40
+
41
+ const units = config.releaseUnits;
42
+ if (!Array.isArray(units) || units.length === 0) {
43
+ failures.push('releaseUnits is missing or empty');
44
+ return { passed: false, failures };
45
+ }
46
+
47
+ const unit = units.find(u => u.id === expectedUnitId);
48
+ if (!unit) {
49
+ failures.push(`unit "${expectedUnitId}" not found in releaseUnits`);
50
+ } else {
51
+ if (unit.source !== expectedSource) {
52
+ failures.push(`unit "${expectedUnitId}" source is ${JSON.stringify(unit.source)}, expected ${JSON.stringify(expectedSource)}`);
53
+ }
54
+ const versionSource = unit.version?.source;
55
+ if (versionSource !== expectedVersionSource) {
56
+ failures.push(`unit "${expectedUnitId}" version.source is ${JSON.stringify(versionSource)}, expected ${JSON.stringify(expectedVersionSource)}`);
57
+ }
58
+ // publicRepo
59
+ if (expectedPublicRepo && unit.publicRepo !== expectedPublicRepo) {
60
+ failures.push(`unit "${expectedUnitId}" publicRepo is ${JSON.stringify(unit.publicRepo)}, expected ${JSON.stringify(expectedPublicRepo)}`);
61
+ }
62
+ // tagTemplate
63
+ if (expectedTagTemplate && unit.version?.tagTemplate !== expectedTagTemplate) {
64
+ failures.push(`unit "${expectedUnitId}" version.tagTemplate is ${JSON.stringify(unit.version?.tagTemplate)}, expected ${JSON.stringify(expectedTagTemplate)}`);
65
+ }
66
+ // distributions
67
+ if (expectedDistributionTypes) {
68
+ const actualTypes = (unit.distributions ?? []).map(d => d.type).sort();
69
+ const sorted = [...expectedDistributionTypes].sort();
70
+ if (JSON.stringify(actualTypes) !== JSON.stringify(sorted)) {
71
+ failures.push(`unit "${expectedUnitId}" distribution types are ${JSON.stringify(actualTypes)}, expected ${JSON.stringify(sorted)}`);
72
+ }
73
+ }
74
+ }
75
+
76
+ return { passed: failures.length === 0, failures };
77
+ }
78
+
79
+ /**
80
+ * 验证冻结发布计划的一致性。
81
+ *
82
+ * 两个独立门:
83
+ * 1. 版本/tag 精确校验(validateVersionConsistency)
84
+ * 2. 动作完整性校验(validatePlanActionCompleteness)
85
+ *
86
+ * @param {object} plan - 已解析的 release-plan.json 对象
87
+ * @param {object} expectations
88
+ * @param {string} expectations.expectedUnitId - 预期 unit id
89
+ * @param {string} expectations.expectedVersion - 预期版本号
90
+ * @returns {{ passed: boolean, failures: string[], details: object }}
91
+ */
92
+ export function verifyPlanConsistency(plan, expectations) {
93
+ const { expectedUnitId, expectedVersion } = expectations;
94
+
95
+ // Gate 1: Version/tag exact consistency
96
+ const versionResult = validateVersionConsistency(plan, expectedVersion, {
97
+ expectedUnitId,
98
+ });
99
+
100
+ // Gate 2: Action completeness (every unit has its required actions)
101
+ const actionResult = validatePlanActionCompleteness(plan);
102
+
103
+ const allFailures = [
104
+ ...versionResult.details.failures,
105
+ ...actionResult.details.failures,
106
+ ];
107
+
108
+ return {
109
+ passed: versionResult.passed && actionResult.passed,
110
+ failures: allFailures,
111
+ details: {
112
+ unitCount: versionResult.details.unitCount,
113
+ actionCount: versionResult.details.actionCount,
114
+ expectedActionCount: actionResult.details.expectedCount,
115
+ actualActionCount: actionResult.details.actualCount,
116
+ versionGatePassed: versionResult.passed,
117
+ actionCompletenessGatePassed: actionResult.passed,
118
+ },
119
+ };
120
+ }
121
+
122
+ /**
123
+ * 计算 README 黑盒测试总体结论。
124
+ *
125
+ * 每个 persona 必须同时满足 success === true 和 verdict === 'PASS',
126
+ * 总体才为 PASS。persona 自报 verdict=PASS 但 success=false 时,
127
+ * 总体必须 FAIL。
128
+ *
129
+ * @param {Array<{ success: boolean, verdict: string }>} personaResults
130
+ * @returns {{ overall_verdict: 'PASS'|'FAIL', pass_count: number, fail_count: number }}
131
+ */
132
+ export function computeOverallVerdict(personaResults) {
133
+ const passCount = personaResults.filter(
134
+ r => r.success === true && r.verdict === 'PASS',
135
+ ).length;
136
+ const failCount = personaResults.length - passCount;
137
+ return {
138
+ overall_verdict: failCount === 0 && personaResults.length > 0 ? 'PASS' : 'FAIL',
139
+ pass_count: passCount,
140
+ fail_count: failCount,
141
+ };
142
+ }
@@ -0,0 +1,448 @@
1
+ /**
2
+ * Secure project configuration loader for the release-skill system.
3
+ *
4
+ * Design constraints:
5
+ * - Reads `.release-skill/project.yaml` by default (path overridable).
6
+ * - YAML parsing rejects aliases (including merge keys) and duplicate keys.
7
+ * - Parsed content is validated against the release-project JSON Schema
8
+ * loaded from the formal `schemas/release-project.schema.json` file
9
+ * (single source of truth).
10
+ * - A deterministic configDigest is computed via canonicalJson + sha256Hex.
11
+ * - Paths are validated to remain within the project root.
12
+ * - `unit.source`, `publicFiles.from/to`, and `requiredPublicFiles` are
13
+ * validated through the shared `canonicalPublicPath` helper.
14
+ * - Target collisions use the shared `publicPathCollisionKey` helper.
15
+ *
16
+ * @module config
17
+ */
18
+
19
+ import { readFile } from 'node:fs/promises';
20
+ import { resolve, isAbsolute, relative, normalize, dirname } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import YAML, { Alias } from 'yaml';
23
+ import Ajv from 'ajv';
24
+ import addFormats from 'ajv-formats';
25
+ import { canonicalJson, sha256Hex } from './digest.mjs';
26
+ import { ReleaseError, CONFIG_INVALID } from './errors.mjs';
27
+ import { canonicalPublicPath, publicPathCollisionKey } from '../snapshot/public-path.mjs';
28
+ import { isReservedReleaseControlPath } from './baseline.mjs';
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Constants
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /** Default configuration file path, relative to the project root. */
35
+ const DEFAULT_CONFIG_REL = '.release-skill/project.yaml';
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Load the formal release-project JSON Schema (single source of truth)
39
+ // ---------------------------------------------------------------------------
40
+
41
+ const __dirname = dirname(fileURLToPath(import.meta.url));
42
+ const FORMAL_SCHEMA_PATH = resolve(__dirname, '..', '..', 'schemas', 'release-project.schema.json');
43
+
44
+ let RELEASE_PROJECT_SCHEMA;
45
+ try {
46
+ const schemaRaw = await readFile(FORMAL_SCHEMA_PATH, 'utf8');
47
+ RELEASE_PROJECT_SCHEMA = JSON.parse(schemaRaw);
48
+ } catch (err) {
49
+ // Fail closed: if the formal schema cannot be loaded, refuse to operate.
50
+ throw new ReleaseError(
51
+ CONFIG_INVALID,
52
+ `cannot load formal release-project schema: ${err.message}`,
53
+ { schemaPath: FORMAL_SCHEMA_PATH, cause: err.code },
54
+ );
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Schema validator (compiled once, reused across calls)
59
+ // ---------------------------------------------------------------------------
60
+
61
+ const ajv = new Ajv({ allErrors: true, strict: false });
62
+ addFormats(ajv);
63
+ const validateConfig = ajv.compile(RELEASE_PROJECT_SCHEMA);
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // YAML safety checks
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Recursively walk a YAML AST node and return true if any Alias node is found.
71
+ *
72
+ * An Alias node represents a YAML alias reference (`*name`) or a merge key
73
+ * (`<<: *name`). Both are rejected for security and determinism reasons.
74
+ *
75
+ * @param {import('yaml').Node | null | undefined} node
76
+ * @returns {boolean}
77
+ */
78
+ function containsAlias(node) {
79
+ if (!node || typeof node !== 'object') {
80
+ return false;
81
+ }
82
+
83
+ // Direct alias node (from the yaml library's Alias class)
84
+ if (node instanceof Alias) {
85
+ return true;
86
+ }
87
+
88
+ // YAMLMap or YAMLSeq: walk children
89
+ if (node.items && Array.isArray(node.items)) {
90
+ for (const item of node.items) {
91
+ // Pair (map entry) - check key and value
92
+ if (item.key !== undefined && containsAlias(item.key)) {
93
+ return true;
94
+ }
95
+ if (item.value !== undefined && containsAlias(item.value)) {
96
+ return true;
97
+ }
98
+ // Bare node in a sequence
99
+ if (item.key === undefined && containsAlias(item)) {
100
+ return true;
101
+ }
102
+ }
103
+ }
104
+
105
+ return false;
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Path validation
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * Resolve and validate a configuration file path.
114
+ *
115
+ * The resolved path must be within (or equal to) the project root. Both
116
+ * absolute and relative configPath values are accepted; if relative, they
117
+ * are resolved against root.
118
+ *
119
+ * @param {string} root - Absolute project root path.
120
+ * @param {string} configPath - Absolute or relative path to the config file.
121
+ * @returns {string} The resolved, validated absolute path.
122
+ * @throws {ReleaseError} CONFIG_INVALID if the path escapes root.
123
+ */
124
+ function resolveConfigPath(root, configPath) {
125
+ const rootNorm = normalize(root);
126
+
127
+ // Resolve configPath against root if relative
128
+ const resolved = isAbsolute(configPath)
129
+ ? normalize(configPath)
130
+ : resolve(rootNorm, configPath);
131
+
132
+ // Ensure resolved path is inside root
133
+ const rel = relative(rootNorm, resolved);
134
+ if (rel.startsWith('..') || rel === '..' || isAbsolute(rel)) {
135
+ throw new ReleaseError(
136
+ CONFIG_INVALID,
137
+ `config path "${configPath}" resolves outside project root "${root}"`,
138
+ { configPath, root, resolved },
139
+ );
140
+ }
141
+
142
+ return resolved;
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Public API
147
+ // ---------------------------------------------------------------------------
148
+
149
+ /**
150
+ * Load, parse, validate, and digest a project configuration file.
151
+ *
152
+ * Steps:
153
+ * 1. Resolve and validate the config file path against root.
154
+ * 2. Read the file content as UTF-8.
155
+ * 3. Parse as YAML using `parseDocument` to access the AST.
156
+ * 4. Reject YAML aliases (including merge keys) by walking the AST.
157
+ * 5. Reject duplicate keys via the document's error list.
158
+ * 6. Extract the parsed JavaScript value and validate against the schema.
159
+ * 7. Validate paths using shared canonicalPublicPath helper.
160
+ * 8. Compile forbiddenContentPatterns (fail closed on invalid regex).
161
+ * 9. Compute a deterministic configDigest from the canonical JSON.
162
+ *
163
+ * @param {Object} options
164
+ * @param {string} options.root - Absolute path to the project root directory.
165
+ * @param {string} [options.configPath] - Path to the config file. If relative,
166
+ * resolved against root. Defaults to `.release-skill/project.yaml`.
167
+ *
168
+ * @returns {Promise<{ config: object, configPath: string, configDigest: string }>}
169
+ *
170
+ * @throws {ReleaseError} CONFIG_INVALID on any validation failure, including:
171
+ * - File not found or unreadable
172
+ * - YAML syntax errors
173
+ * - YAML aliases or merge keys detected
174
+ * - Duplicate YAML keys
175
+ * - Schema validation failure
176
+ * - Path escaping project root
177
+ * - Invalid path characters (backslash, NUL, traversal)
178
+ * - Target collisions (exact, case-fold, NFC)
179
+ * - Invalid regex in forbiddenContentPatterns
180
+ */
181
+ export async function loadProjectConfig({ root, configPath } = {}) {
182
+ // --- Validate root ---
183
+ if (!root || typeof root !== 'string') {
184
+ throw new ReleaseError(CONFIG_INVALID, 'root must be a non-empty string');
185
+ }
186
+
187
+ // --- Resolve config path ---
188
+ const effectivePath = configPath ?? DEFAULT_CONFIG_REL;
189
+ const absConfigPath = resolveConfigPath(root, effectivePath);
190
+
191
+ // --- Read file ---
192
+ let content;
193
+ try {
194
+ content = await readFile(absConfigPath, 'utf8');
195
+ } catch (err) {
196
+ throw new ReleaseError(
197
+ CONFIG_INVALID,
198
+ `cannot read config file: ${err.message}`,
199
+ { configPath: absConfigPath, cause: err.code },
200
+ );
201
+ }
202
+
203
+ // --- Parse YAML with AST access ---
204
+ let doc;
205
+ try {
206
+ doc = YAML.parseDocument(content);
207
+ } catch (err) {
208
+ throw new ReleaseError(
209
+ CONFIG_INVALID,
210
+ `YAML parse error: ${err.message}`,
211
+ { configPath: absConfigPath },
212
+ );
213
+ }
214
+
215
+ // --- Reject YAML aliases (including merge keys) ---
216
+ if (containsAlias(doc.contents)) {
217
+ throw new ReleaseError(
218
+ CONFIG_INVALID,
219
+ 'YAML aliases and merge keys are not allowed in project configuration',
220
+ { configPath: absConfigPath },
221
+ );
222
+ }
223
+
224
+ // --- Reject duplicate keys ---
225
+ const dupKeyErrors = doc.errors.filter(
226
+ (e) => e.message && e.message.includes('unique'),
227
+ );
228
+ if (dupKeyErrors.length > 0) {
229
+ throw new ReleaseError(
230
+ CONFIG_INVALID,
231
+ `duplicate keys detected in YAML: ${dupKeyErrors[0].message}`,
232
+ { configPath: absConfigPath, errors: dupKeyErrors.map((e) => e.message) },
233
+ );
234
+ }
235
+
236
+ // --- Check for any other YAML errors ---
237
+ if (doc.errors.length > 0) {
238
+ throw new ReleaseError(
239
+ CONFIG_INVALID,
240
+ `YAML parse errors: ${doc.errors.map((e) => e.message).join('; ')}`,
241
+ { configPath: absConfigPath, errors: doc.errors.map((e) => e.message) },
242
+ );
243
+ }
244
+
245
+ // --- Extract parsed value ---
246
+ const config = doc.toJSON();
247
+ if (config === null || config === undefined || typeof config !== 'object') {
248
+ throw new ReleaseError(
249
+ CONFIG_INVALID,
250
+ 'config file must contain a YAML mapping (object)',
251
+ { configPath: absConfigPath },
252
+ );
253
+ }
254
+
255
+ // Detect the removed policy-level field before generic schema validation so
256
+ // existing projects receive one stable, actionable migration diagnostic.
257
+ if (
258
+ config.policy &&
259
+ typeof config.policy === 'object' &&
260
+ Object.hasOwn(config.policy, 'requiredPublicFiles')
261
+ ) {
262
+ throw new ReleaseError(
263
+ CONFIG_INVALID,
264
+ 'policy.requiredPublicFiles is no longer supported; move it to releaseUnits[].requiredPublicFiles',
265
+ {
266
+ field: 'policy.requiredPublicFiles',
267
+ migrationTarget: 'releaseUnits[].requiredPublicFiles',
268
+ },
269
+ );
270
+ }
271
+
272
+ // --- Contextual path prevalidation (BEFORE schema validation) ---
273
+ // Validates known path fields using the shared canonicalPublicPath helper
274
+ // to produce rich error details with unitId and field name. Structure
275
+ // errors (missing fields, wrong types) are left to schema validation.
276
+ // Contextual prevalidation: confirm releaseUnits is an array; each unit
277
+ // must be a non-null object before reading properties.
278
+ if (!Array.isArray(config.releaseUnits)) {
279
+ // Schema validation will catch this with CONFIG_INVALID.
280
+ // Skip prevalidation to avoid TypeError.
281
+ } else {
282
+ for (const unit of config.releaseUnits) {
283
+ if (unit === null || unit === undefined || typeof unit !== 'object') continue;
284
+ if (typeof unit.id !== 'string' || typeof unit.source !== 'string') continue;
285
+
286
+ // Validate unit.source (allow standalone `.`)
287
+ try {
288
+ canonicalPublicPath(unit.source, { allowDot: true });
289
+ } catch (err) {
290
+ throw new ReleaseError(
291
+ CONFIG_INVALID,
292
+ `unit "${unit.id}" has invalid source path: ${err.message}`,
293
+ { unitId: unit.id, source: unit.source, field: 'source' },
294
+ );
295
+ }
296
+
297
+ // Validate publicFiles[].from and .to
298
+ if (Array.isArray(unit.publicFiles)) {
299
+ for (const mapping of unit.publicFiles) {
300
+ if (typeof mapping === 'object' && mapping !== null) {
301
+ if (typeof mapping.from === 'string') {
302
+ try {
303
+ const canonicalFrom = canonicalPublicPath(mapping.from).path;
304
+ if (isReservedReleaseControlPath(canonicalFrom)) {
305
+ throw new Error('release-skill control-plane paths are reserved and cannot be public inputs');
306
+ }
307
+ } catch (err) {
308
+ throw new ReleaseError(
309
+ CONFIG_INVALID,
310
+ `unit "${unit.id}" has invalid publicFiles[].from: ${err.message}`,
311
+ { unitId: unit.id, from: mapping.from, field: 'publicFiles[].from' },
312
+ );
313
+ }
314
+ }
315
+ if (typeof mapping.to === 'string') {
316
+ try {
317
+ canonicalPublicPath(mapping.to);
318
+ } catch (err) {
319
+ throw new ReleaseError(
320
+ CONFIG_INVALID,
321
+ `unit "${unit.id}" has invalid publicFiles[].to: ${err.message}`,
322
+ { unitId: unit.id, to: mapping.to, field: 'publicFiles[].to' },
323
+ );
324
+ }
325
+ }
326
+ }
327
+ }
328
+ }
329
+
330
+ // Validate requiredPublicFiles
331
+ if (Array.isArray(unit.requiredPublicFiles)) {
332
+ for (const req of unit.requiredPublicFiles) {
333
+ if (typeof req === 'string') {
334
+ try {
335
+ canonicalPublicPath(req);
336
+ } catch (err) {
337
+ throw new ReleaseError(
338
+ CONFIG_INVALID,
339
+ `unit "${unit.id}" has invalid requiredPublicFiles entry: ${err.message}`,
340
+ { unitId: unit.id, required: req, field: 'requiredPublicFiles' },
341
+ );
342
+ }
343
+ }
344
+ }
345
+ }
346
+ }
347
+ } // end of contextual prevalidation else block
348
+
349
+ // --- Schema validation (using formal JSON schema) ---
350
+ const valid = validateConfig(config);
351
+ if (!valid) {
352
+ const errors = validateConfig.errors ?? [];
353
+ const summary = errors
354
+ .map((e) => `${e.instancePath || '/'}: ${e.message}`)
355
+ .join('; ');
356
+ throw new ReleaseError(
357
+ CONFIG_INVALID,
358
+ `config schema validation failed: ${summary}`,
359
+ { configPath: absConfigPath, validationErrors: errors },
360
+ );
361
+ }
362
+
363
+ // --- Compile forbiddenContentPatterns (fail closed on invalid regex) ---
364
+ const forbiddenContentPatterns = config.policy?.forbiddenContentPatterns ?? [];
365
+ for (const pattern of forbiddenContentPatterns) {
366
+ if (typeof pattern !== 'string' || pattern.length === 0) continue;
367
+ try {
368
+ new RegExp(pattern);
369
+ } catch (err) {
370
+ throw new ReleaseError(
371
+ CONFIG_INVALID,
372
+ `invalid regex in forbiddenContentPatterns: "${pattern}": ${err.message}`,
373
+ { configPath: absConfigPath, pattern, cause: err.message },
374
+ );
375
+ }
376
+ }
377
+
378
+ // --- Cross-field validation (cannot be expressed in JSON Schema) ---
379
+ // JSON Schema validates structure; these checks verify semantic constraints
380
+ // across fields within each release unit.
381
+ if (!Array.isArray(config.releaseUnits)) {
382
+ // Schema validation will catch this with CONFIG_INVALID.
383
+ } else
384
+ for (const unit of config.releaseUnits) {
385
+ if (unit === null || unit === undefined || typeof unit !== 'object') continue;
386
+ // 1. Validate publicFiles[].to uniqueness using shared collision key
387
+ const toTargets = unit.publicFiles ?? [];
388
+ const exactSet = new Set();
389
+ const collisionKeyMap = new Map();
390
+ for (const mapping of toTargets) {
391
+ const to = mapping.to;
392
+ // Exact duplicate
393
+ if (exactSet.has(to)) {
394
+ throw new ReleaseError(
395
+ CONFIG_INVALID,
396
+ `unit "${unit.id}" has duplicate publicFiles[].to: "${to}"`,
397
+ { unitId: unit.id, target: to },
398
+ );
399
+ }
400
+ exactSet.add(to);
401
+
402
+ // Collision key: NFC + case-fold (shared helper)
403
+ const key = publicPathCollisionKey(to);
404
+ if (collisionKeyMap.has(key)) {
405
+ const existing = collisionKeyMap.get(key);
406
+ if (existing !== to) {
407
+ // Determine collision kind
408
+ const nfc = to.normalize('NFC');
409
+ const existingNfc = existing.normalize('NFC');
410
+ const isNfc = nfc === existingNfc;
411
+ const isCase = nfc.toLowerCase() === existingNfc.toLowerCase();
412
+ let kind = 'case+NFC';
413
+ if (isNfc && !isCase) kind = 'NFC';
414
+ else if (!isNfc && isCase) kind = 'case-fold';
415
+
416
+ throw new ReleaseError(
417
+ CONFIG_INVALID,
418
+ `unit "${unit.id}" has ${kind} collision in publicFiles[].to: "${to}" and "${existing}"`,
419
+ { unitId: unit.id, target: to, existing },
420
+ );
421
+ }
422
+ }
423
+ collisionKeyMap.set(key, to);
424
+ }
425
+
426
+ // 2. Validate requiredPublicFiles coverage: every required file must
427
+ // be exactly covered by some publicFiles[].to.
428
+ const required = unit.requiredPublicFiles ?? [];
429
+ const toSet = new Set(toTargets.map((m) => m.to));
430
+ const uncovered = required.filter((r) => !toSet.has(r));
431
+ if (uncovered.length > 0) {
432
+ throw new ReleaseError(
433
+ CONFIG_INVALID,
434
+ `unit "${unit.id}" requiredPublicFiles not covered by publicFiles[].to: ${uncovered.join(', ')}`,
435
+ { unitId: unit.id, uncovered },
436
+ );
437
+ }
438
+ }
439
+
440
+ // --- Compute deterministic digest ---
441
+ const configDigest = sha256Hex(canonicalJson(config));
442
+
443
+ return {
444
+ config,
445
+ configPath: absConfigPath,
446
+ configDigest,
447
+ };
448
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Deterministic canonical JSON serialisation and SHA-256 digest.
3
+ *
4
+ * `canonicalJson` recursively sorts object keys (deep-first) while preserving
5
+ * array element order, then serialises the result as a UTF-8 JSON string.
6
+ * Two objects with the same logical content but different key insertion order
7
+ * always produce the identical output.
8
+ *
9
+ * `sha256Hex` computes the SHA-256 hash of a UTF-8 string (or Buffer) and
10
+ * returns the lowercase hex encoding.
11
+ *
12
+ * @module digest
13
+ */
14
+
15
+ import { createHash } from 'node:crypto';
16
+
17
+ /**
18
+ * Recursively sort every object key in depth-first order and serialise as
19
+ * a deterministic UTF-8 JSON string.
20
+ *
21
+ * Rules:
22
+ * - Object keys are sorted lexicographically (same order as `Array.sort()`).
23
+ * - Array element order is preserved.
24
+ * - Primitives (`null`, booleans, numbers, strings) pass through unchanged.
25
+ * - `undefined` values in objects are omitted (matching `JSON.stringify`).
26
+ * - `undefined` values inside arrays become `null` (matching `JSON.stringify`).
27
+ * - `BigInt` values throw (matching `JSON.stringify`).
28
+ * - `Date` objects are serialised via `.toISOString()` (matching
29
+ * `JSON.stringify`).
30
+ *
31
+ * @param {*} obj - Any JSON-serialisable value.
32
+ * @returns {string} A UTF-8 JSON string whose key ordering is deterministic.
33
+ */
34
+ export function canonicalJson(obj) {
35
+ return JSON.stringify(canonicalise(obj));
36
+ }
37
+
38
+ /**
39
+ * Compute the SHA-256 digest of a UTF-8 string or Buffer.
40
+ *
41
+ * @param {string | Buffer} input - The data to hash.
42
+ * @returns {string} Lowercase hexadecimal SHA-256 digest (64 hex chars).
43
+ */
44
+ export function sha256Hex(input) {
45
+ return createHash('sha256').update(input).digest('hex');
46
+ }
47
+
48
+ // ---- internal helpers (not exported) ----
49
+
50
+ /**
51
+ * Deep-clone a value while sorting all object keys lexicographically.
52
+ *
53
+ * @param {*} value
54
+ * @returns {*}
55
+ */
56
+ function canonicalise(value) {
57
+ if (value === null || value === undefined) {
58
+ return value;
59
+ }
60
+
61
+ if (Array.isArray(value)) {
62
+ return value.map((item) => canonicalise(item));
63
+ }
64
+
65
+ // Date gets its own branch so we can call toISOString() before the
66
+ // typeof === 'object' check swallows it.
67
+ if (value instanceof Date) {
68
+ return value.toISOString();
69
+ }
70
+
71
+ // Buffer gets its own branch: toJSON() returns {type:'Buffer', data:[...]}
72
+ // which matches JSON.stringify and survives a JSON roundtrip.
73
+ if (Buffer.isBuffer(value)) {
74
+ return canonicalise(value.toJSON());
75
+ }
76
+
77
+ if (typeof value === 'object') {
78
+ const sorted = {};
79
+ for (const key of Object.keys(value).sort()) {
80
+ const v = value[key];
81
+ // Skip undefined object properties (mirrors JSON.stringify behaviour).
82
+ if (v === undefined) continue;
83
+ sorted[key] = canonicalise(v);
84
+ }
85
+ return sorted;
86
+ }
87
+
88
+ // Primitives: string, number, boolean, null.
89
+ return value;
90
+ }