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.
- package/.agents/plugins/marketplace.json +23 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +26 -0
- package/CHANGELOG.md +68 -0
- package/CODE_OF_CONDUCT.md +76 -0
- package/CONTRIBUTING.md +49 -0
- package/INSTALL.md +182 -0
- package/LICENSE +21 -0
- package/NOTICE +25 -0
- package/README.md +501 -0
- package/README.zh-CN.md +463 -0
- package/SECURITY.md +48 -0
- package/adapters/claude/.claude-plugin/marketplace.json +16 -0
- package/adapters/claude/.claude-plugin/plugin.json +10 -0
- package/adapters/claude/skills/release-assess/SKILL.md +52 -0
- package/adapters/claude/skills/release-help/SKILL.md +60 -0
- package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
- package/adapters/claude/skills/release-publish/SKILL.md +55 -0
- package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/claude/skills/release-verify/SKILL.md +70 -0
- package/adapters/codex/.codex-plugin/plugin.json +26 -0
- package/adapters/codex/skills/release-assess/SKILL.md +52 -0
- package/adapters/codex/skills/release-help/SKILL.md +60 -0
- package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
- package/adapters/codex/skills/release-publish/SKILL.md +55 -0
- package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/codex/skills/release-verify/SKILL.md +70 -0
- package/bin/release-skill.mjs +743 -0
- package/native/safe-write/binding.gyp +40 -0
- package/native/safe-write/prebuilds.json +4 -0
- package/native/safe-write/src/safe_write.cc +2023 -0
- package/package.json +75 -0
- package/references/.render-manifest.json +33 -0
- package/references/00-target-state.md +124 -0
- package/references/01-state-machine.md +155 -0
- package/references/02-project-config.md +217 -0
- package/references/03-readme-quality.md +136 -0
- package/references/04-supply-chain.md +147 -0
- package/references/05-evidence-and-errors.md +164 -0
- package/references/06-adapter-contract.md +178 -0
- package/schemas/.render-manifest.json +37 -0
- package/schemas/approval-record.schema.json +115 -0
- package/schemas/artifact-lock.schema.json +111 -0
- package/schemas/artifact-plan.schema.json +52 -0
- package/schemas/artifact-policy.schema.json +76 -0
- package/schemas/evidence-event.schema.json +89 -0
- package/schemas/release-plan.schema.json +369 -0
- package/schemas/release-project.schema.json +359 -0
- package/schemas/release-run.schema.json +195 -0
- package/skills/release-assess/SKILL.md +52 -0
- package/skills/release-help/SKILL.md +60 -0
- package/skills/release-prepare/SKILL.md +71 -0
- package/skills/release-publish/SKILL.md +55 -0
- package/skills/release-reconcile/SKILL.md +73 -0
- package/skills/release-verify/SKILL.md +70 -0
- package/skills-src/release-assess/SKILL.md +52 -0
- package/skills-src/release-help/SKILL.md +60 -0
- package/skills-src/release-prepare/SKILL.md +71 -0
- package/skills-src/release-publish/SKILL.md +55 -0
- package/skills-src/release-reconcile/SKILL.md +73 -0
- package/skills-src/release-verify/SKILL.md +70 -0
- package/src/adapters/contract.mjs +214 -0
- package/src/adapters/git-github.mjs +214 -0
- package/src/adapters/npm.mjs +947 -0
- package/src/adapters/plugin-marketplace.mjs +1365 -0
- package/src/adapters/push-snapshot.mjs +216 -0
- package/src/artifacts/adoption.mjs +743 -0
- package/src/artifacts/artifact-plan.mjs +162 -0
- package/src/artifacts/entry.mjs +240 -0
- package/src/artifacts/git-authority.mjs +637 -0
- package/src/artifacts/graph.mjs +189 -0
- package/src/artifacts/inspect.mjs +520 -0
- package/src/artifacts/inventory.mjs +192 -0
- package/src/artifacts/merge/binary.mjs +77 -0
- package/src/artifacts/merge/entry-merge.mjs +228 -0
- package/src/artifacts/merge/json.mjs +641 -0
- package/src/artifacts/merge/markdown.mjs +246 -0
- package/src/artifacts/merge/regions.mjs +156 -0
- package/src/artifacts/merge/text.mjs +432 -0
- package/src/artifacts/merge/tree.mjs +202 -0
- package/src/artifacts/merge/yaml.mjs +669 -0
- package/src/artifacts/path-key.mjs +94 -0
- package/src/artifacts/policy.mjs +319 -0
- package/src/artifacts/producer-registry.mjs +439 -0
- package/src/artifacts/project-lock.mjs +732 -0
- package/src/artifacts/resolution.mjs +658 -0
- package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
- package/src/artifacts/safe-fs.mjs +72 -0
- package/src/artifacts/state.mjs +495 -0
- package/src/artifacts/transaction-journal.mjs +983 -0
- package/src/artifacts/transaction.mjs +1361 -0
- package/src/commands/approve.mjs +280 -0
- package/src/commands/artifacts.mjs +627 -0
- package/src/commands/assess.mjs +838 -0
- package/src/commands/prepare.mjs +1377 -0
- package/src/commands/publish.mjs +883 -0
- package/src/commands/reconcile.mjs +1255 -0
- package/src/commands/verify.mjs +915 -0
- package/src/core/approval.mjs +332 -0
- package/src/core/baseline.mjs +272 -0
- package/src/core/blackbox-hard-gates.mjs +142 -0
- package/src/core/config.mjs +448 -0
- package/src/core/digest.mjs +90 -0
- package/src/core/errors.mjs +113 -0
- package/src/core/evidence.mjs +167 -0
- package/src/core/hooks.mjs +241 -0
- package/src/core/node-version.mjs +64 -0
- package/src/core/plan.mjs +735 -0
- package/src/core/previous-public-baseline.mjs +204 -0
- package/src/core/run.mjs +681 -0
- package/src/core/state-machine.mjs +76 -0
- package/src/core/version-consistency.mjs +111 -0
- package/src/producers/build-adapters.mjs +231 -0
- package/src/producers/render-public-assets.mjs +152 -0
- package/src/producers/sync-skills.mjs +96 -0
- package/src/readme/contract.mjs +297 -0
- package/src/readme/examples.mjs +288 -0
- package/src/readme/parity.mjs +122 -0
- package/src/snapshot/export.mjs +99 -0
- package/src/snapshot/frozen.mjs +401 -0
- package/src/snapshot/manifest.mjs +207 -0
- package/src/snapshot/public-map.mjs +1459 -0
- package/src/snapshot/public-path.mjs +110 -0
- 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
|
+
}
|