release-skill 0.5.0 → 0.6.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +31 -0
- package/INSTALL.md +2 -2
- package/INSTALL.zh-CN.md +2 -2
- package/README.md +13 -21
- package/README.zh-CN.md +14 -22
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/error-codes.json +1 -1
- package/adapters/claude/bin/kernel-protocol.json +1 -1
- package/adapters/claude/bin/registry.json +15 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +38147 -23284
- package/adapters/claude/bin/rules.json +1 -1
- package/adapters/claude/schemas/.render-manifest.json +6 -6
- package/adapters/claude/schemas/release-plan.schema.json +401 -2
- package/adapters/claude/schemas/release-project.schema.json +291 -0
- package/adapters/claude/schemas/release-run.schema.json +59 -6
- package/adapters/claude/skills/release-config/SKILL.md +140 -0
- package/adapters/claude/skills/release-docs/SKILL.md +106 -0
- package/adapters/claude/skills/release-help/SKILL.md +39 -0
- package/adapters/claude/skills/release-marketplace/SKILL.md +147 -0
- package/adapters/claude/skills/release-publish/SKILL.md +25 -6
- package/adapters/claude/skills/release-verify/SKILL.md +10 -0
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/error-codes.json +1 -1
- package/adapters/codex/bin/kernel-protocol.json +1 -1
- package/adapters/codex/bin/registry.json +15 -1
- package/adapters/codex/bin/release-skill.bundle.mjs +38147 -23284
- package/adapters/codex/bin/rules.json +1 -1
- package/adapters/codex/schemas/.render-manifest.json +6 -6
- package/adapters/codex/schemas/release-plan.schema.json +401 -2
- package/adapters/codex/schemas/release-project.schema.json +291 -0
- package/adapters/codex/schemas/release-run.schema.json +59 -6
- package/adapters/codex/skills/release-config/SKILL.md +147 -0
- package/adapters/codex/skills/release-docs/SKILL.md +113 -0
- package/adapters/codex/skills/release-help/SKILL.md +39 -0
- package/adapters/codex/skills/release-marketplace/SKILL.md +154 -0
- package/adapters/codex/skills/release-publish/SKILL.md +25 -6
- package/adapters/codex/skills/release-verify/SKILL.md +10 -0
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/error-codes.json +1 -1
- package/adapters/kimi/bin/kernel-protocol.json +1 -1
- package/adapters/kimi/bin/registry.json +15 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +38147 -23284
- package/adapters/kimi/bin/rules.json +1 -1
- package/adapters/kimi/schemas/.render-manifest.json +6 -6
- package/adapters/kimi/schemas/release-plan.schema.json +401 -2
- package/adapters/kimi/schemas/release-project.schema.json +291 -0
- package/adapters/kimi/schemas/release-run.schema.json +59 -6
- package/adapters/kimi/skills/release-config/SKILL.md +147 -0
- package/adapters/kimi/skills/release-docs/SKILL.md +113 -0
- package/adapters/kimi/skills/release-help/SKILL.md +39 -0
- package/adapters/kimi/skills/release-marketplace/SKILL.md +154 -0
- package/adapters/kimi/skills/release-publish/SKILL.md +25 -6
- package/adapters/kimi/skills/release-verify/SKILL.md +10 -0
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/error-codes.json +1 -1
- package/adapters/workbuddy/bin/kernel-protocol.json +1 -1
- package/adapters/workbuddy/bin/registry.json +15 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +38147 -23284
- package/adapters/workbuddy/bin/rules.json +1 -1
- package/adapters/workbuddy/schemas/.render-manifest.json +6 -6
- package/adapters/workbuddy/schemas/release-plan.schema.json +401 -2
- package/adapters/workbuddy/schemas/release-project.schema.json +291 -0
- package/adapters/workbuddy/schemas/release-run.schema.json +59 -6
- package/adapters/workbuddy/skills/release-config/SKILL.md +140 -0
- package/adapters/workbuddy/skills/release-docs/SKILL.md +106 -0
- package/adapters/workbuddy/skills/release-help/SKILL.md +39 -0
- package/adapters/workbuddy/skills/release-marketplace/SKILL.md +147 -0
- package/adapters/workbuddy/skills/release-publish/SKILL.md +25 -6
- package/adapters/workbuddy/skills/release-verify/SKILL.md +10 -0
- package/bin/error-codes.json +1 -1
- package/bin/kernel-protocol.json +1 -1
- package/bin/registry.json +15 -1
- package/bin/release-skill-cli.mjs +235 -7
- package/bin/release-skill.bundle.mjs +38147 -23284
- package/bin/rules.json +1 -1
- package/package.json +5 -4
- package/platform-manifest.json +359 -0
- package/references/.render-manifest.json +9 -9
- package/references/02-project-config.md +35 -0
- package/references/05-evidence-and-errors.md +44 -0
- package/references/06-adapter-contract.md +13 -0
- package/schemas/.render-manifest.json +6 -6
- package/schemas/release-plan.schema.json +401 -2
- package/schemas/release-project.schema.json +291 -0
- package/schemas/release-run.schema.json +59 -6
- package/skills/release-config/SKILL.md +140 -0
- package/skills/release-docs/SKILL.md +106 -0
- package/skills/release-help/SKILL.md +39 -0
- package/skills/release-marketplace/SKILL.md +147 -0
- package/skills/release-publish/SKILL.md +25 -6
- package/skills/release-verify/SKILL.md +10 -0
- package/skills-src/release-config/SKILL.md +140 -0
- package/skills-src/release-docs/SKILL.md +106 -0
- package/skills-src/release-help/SKILL.md +39 -0
- package/skills-src/release-marketplace/SKILL.md +147 -0
- package/skills-src/release-publish/SKILL.md +25 -6
- package/skills-src/release-verify/SKILL.md +10 -0
- package/src/adapters/contract.mjs +5 -0
- package/src/adapters/distribute-git.mjs +625 -0
- package/src/adapters/plugin-marketplace.mjs +48 -4
- package/src/adapters/push-snapshot.mjs +12 -4
- package/src/commands/assess.mjs +171 -0
- package/src/commands/distribute.mjs +1078 -0
- package/src/commands/hooks.mjs +6 -1
- package/src/commands/lineage.mjs +616 -0
- package/src/commands/prepare.mjs +413 -72
- package/src/commands/route.mjs +686 -0
- package/src/commands/ship.mjs +45 -1
- package/src/commands/verify.mjs +176 -0
- package/src/core/baseline-advance.mjs +185 -0
- package/src/core/baseline.mjs +11 -1
- package/src/core/checkpoints.mjs +25 -0
- package/src/core/foundation-inflight.mjs +7 -0
- package/src/core/plan.mjs +12 -2
- package/src/core/postpublish.mjs +315 -0
package/src/commands/hooks.mjs
CHANGED
|
@@ -28,7 +28,12 @@ export async function validateDeclaredHooks(options = {}) {
|
|
|
28
28
|
clock: () => new Date().toISOString(),
|
|
29
29
|
});
|
|
30
30
|
const startedAt = new Date().toISOString();
|
|
31
|
-
await runDeclaredHooks(config, root, evidence, undefined, {
|
|
31
|
+
await runDeclaredHooks(config, root, evidence, undefined, {
|
|
32
|
+
hookCache,
|
|
33
|
+
// Explicit env delivery (0.5.1 hook-env-delivery fix): same semantics as
|
|
34
|
+
// prepare — allowlisted keys are read from this explicit map only.
|
|
35
|
+
env: process.env,
|
|
36
|
+
});
|
|
32
37
|
return {
|
|
33
38
|
command: 'hooks validate',
|
|
34
39
|
status: 'PASSED',
|
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lineage Repair Tool — handoff §2.2 option A (local rebuild, no ref writes)
|
|
3
|
+
*
|
|
4
|
+
* Rebuilds the published release commit chain inside the LOCAL object
|
|
5
|
+
* database only:
|
|
6
|
+
*
|
|
7
|
+
* - every rebuilt commit's tree hash equals the corresponding release tag
|
|
8
|
+
* commit's tree hash (published bytes are unchanged);
|
|
9
|
+
* - every rebuilt commit's parent is the previous version's rebuilt commit
|
|
10
|
+
* (the oldest version becomes the chain root);
|
|
11
|
+
* - original author/committer identity and dates (including synthetic
|
|
12
|
+
* dates) are preserved;
|
|
13
|
+
* - version gaps (e.g. v0.1.2 with no tag) are registered as ABSENT and are
|
|
14
|
+
* NEVER fabricated into fake tags;
|
|
15
|
+
* - zero ref writes (no tag update-ref, no branch), zero push — pushing a
|
|
16
|
+
* `release-history` branch is a separate, explicitly authorized action.
|
|
17
|
+
*
|
|
18
|
+
* Commands:
|
|
19
|
+
* - analyze --root <path> [--json] : read-only lineage analysis
|
|
20
|
+
* - rebuild --root <path> [--dry-run] [--json] : local chain rebuild +
|
|
21
|
+
* per-node tree verification report
|
|
22
|
+
*
|
|
23
|
+
* The tool is fully local: it never reads tokens, never calls the GitHub API,
|
|
24
|
+
* never writes refs, and never pushes. Failures propagate to the caller
|
|
25
|
+
* (fail-closed) instead of being swallowed into fake success.
|
|
26
|
+
*
|
|
27
|
+
* @module commands/lineage
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
31
|
+
import { promisify } from 'node:util';
|
|
32
|
+
|
|
33
|
+
const execFile = promisify(execFileCb);
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// Version parsing & comparison (with release-tag prefix support)
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse a semantic version string into components.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} version - e.g. "1.2.3", "2.0.0-beta.1", "0.5.1"
|
|
43
|
+
* @returns {{ major: number, minor: number, patch: number, prerelease?: string, original: string }}
|
|
44
|
+
* @throws {Error} when the version is not a valid semver string.
|
|
45
|
+
*/
|
|
46
|
+
export function parseSemverVersion(version) {
|
|
47
|
+
if (typeof version !== 'string') {
|
|
48
|
+
throw new Error(`version must be a string, got ${typeof version}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.-]+))?(?:\+([a-zA-Z0-9.-]+))?$/);
|
|
52
|
+
if (!match) {
|
|
53
|
+
throw new Error(`invalid semver version: "${version}"`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (version.match(/^\d+\.\d+\.\d+\.\d+/)) {
|
|
57
|
+
throw new Error(`invalid semver version: "${version}"`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const [, major, minor, patch, prereleaseRaw] = match;
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
major: parseInt(major, 10),
|
|
64
|
+
minor: parseInt(minor, 10),
|
|
65
|
+
patch: parseInt(patch, 10),
|
|
66
|
+
prerelease: prereleaseRaw || undefined,
|
|
67
|
+
original: version,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Compare two semver versions.
|
|
73
|
+
*
|
|
74
|
+
* @returns {-1 | 0 | 1} -1 if v1 < v2, 0 if equal, 1 if v1 > v2
|
|
75
|
+
*/
|
|
76
|
+
export function compareSemverVersions(v1, v2) {
|
|
77
|
+
const p1 = parseSemverVersion(v1);
|
|
78
|
+
const p2 = parseSemverVersion(v2);
|
|
79
|
+
|
|
80
|
+
if (p1.major !== p2.major) return p1.major < p2.major ? -1 : 1;
|
|
81
|
+
if (p1.minor !== p2.minor) return p1.minor < p2.minor ? -1 : 1;
|
|
82
|
+
if (p1.patch !== p2.patch) return p1.patch < p2.patch ? -1 : 1;
|
|
83
|
+
|
|
84
|
+
if (!p1.prerelease && !p2.prerelease) return 0;
|
|
85
|
+
if (!p1.prerelease) return 1;
|
|
86
|
+
if (!p2.prerelease) return -1;
|
|
87
|
+
|
|
88
|
+
if (p1.prerelease < p2.prerelease) return -1;
|
|
89
|
+
if (p1.prerelease > p2.prerelease) return 1;
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Classify a git tag name as a release tag.
|
|
95
|
+
*
|
|
96
|
+
* Accepts `v0.1.0`, `0.1.0`, and product-prefixed forms such as
|
|
97
|
+
* `release-skill-v0.5.0` (the tag shape used by release-skill's own public
|
|
98
|
+
* repository). The version part must be the tail of the tag name.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} tagName - a git tag name.
|
|
101
|
+
* @returns {{ version: string, prefix: string | null } | null}
|
|
102
|
+
* null when the tag is not a release tag (e.g. `some-topic-tag`).
|
|
103
|
+
*/
|
|
104
|
+
export function parseReleaseTag(tagName) {
|
|
105
|
+
if (typeof tagName !== 'string' || tagName.length === 0) return null;
|
|
106
|
+
const match = tagName.match(/(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/);
|
|
107
|
+
if (!match) return null;
|
|
108
|
+
const version = match[1];
|
|
109
|
+
const rawPrefix = tagName.slice(0, match.index);
|
|
110
|
+
// `v0.1.0` → prefix 'v' (bare version tag); `release-skill-v0.5.0` →
|
|
111
|
+
// prefix 'release-skill' (trailing '-v' is the version separator, not part
|
|
112
|
+
// of the product prefix). `1.0.0` (no prefix) → prefix null.
|
|
113
|
+
let prefix = rawPrefix.replace(/-v$/, '');
|
|
114
|
+
if (prefix === '') prefix = rawPrefix === 'v' ? 'v' : null;
|
|
115
|
+
return { version, prefix };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ============================================================================
|
|
119
|
+
// Local git operations (read-only except commit object creation in rebuild)
|
|
120
|
+
// ============================================================================
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Run a git command with the given cwd; returns trimmed stdout.
|
|
124
|
+
*/
|
|
125
|
+
async function git(root, args, options = {}) {
|
|
126
|
+
const { stdout } = await execFile('git', args, {
|
|
127
|
+
cwd: root,
|
|
128
|
+
encoding: 'utf8',
|
|
129
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
130
|
+
...options,
|
|
131
|
+
});
|
|
132
|
+
return stdout.trim();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* List all tags with their commit SHAs.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} root - repository root
|
|
139
|
+
* @returns {Promise<Array<{ name: string, commitSha: string }>>}
|
|
140
|
+
*/
|
|
141
|
+
export async function getLocalTags(root) {
|
|
142
|
+
const output = await git(root, ['tag', '-l', '--format=%(refname:short) %(objectname)']);
|
|
143
|
+
if (!output) return [];
|
|
144
|
+
return output
|
|
145
|
+
.split('\n')
|
|
146
|
+
.map((line) => line.trim())
|
|
147
|
+
.filter((line) => line.length > 0)
|
|
148
|
+
.map((line) => {
|
|
149
|
+
const [name, commitSha] = line.split(/\s+/);
|
|
150
|
+
return { name, commitSha };
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* List release tags (version-shaped) sorted by version ascending.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} root - repository root
|
|
158
|
+
* @returns {Promise<Array<{ name: string, commitSha: string, version: string, prefix: string | null }>>}
|
|
159
|
+
*/
|
|
160
|
+
export async function listReleaseTags(root) {
|
|
161
|
+
const tags = await getLocalTags(root);
|
|
162
|
+
const releases = [];
|
|
163
|
+
for (const tag of tags) {
|
|
164
|
+
const parsed = parseReleaseTag(tag.name);
|
|
165
|
+
if (!parsed) continue;
|
|
166
|
+
// Reject malformed version numbers (parse throws on invalid semver).
|
|
167
|
+
parseSemverVersion(parsed.version);
|
|
168
|
+
releases.push({ name: tag.name, commitSha: tag.commitSha, ...parsed });
|
|
169
|
+
}
|
|
170
|
+
releases.sort((a, b) => compareSemverVersions(a.version, b.version));
|
|
171
|
+
return releases;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get the tree hash of a commit.
|
|
176
|
+
*
|
|
177
|
+
* @param {string} root - repository root
|
|
178
|
+
* @param {string} commitSha - commit SHA
|
|
179
|
+
* @returns {Promise<string>} tree hash
|
|
180
|
+
*/
|
|
181
|
+
export async function getCommitTreeHash(root, commitSha) {
|
|
182
|
+
return git(root, ['rev-parse', `${commitSha}^{tree}`]);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Check whether an object exists in the local object database.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} root - repository root
|
|
189
|
+
* @param {string} sha - object SHA
|
|
190
|
+
* @returns {Promise<boolean>}
|
|
191
|
+
*/
|
|
192
|
+
export async function objectExists(root, sha) {
|
|
193
|
+
try {
|
|
194
|
+
await git(root, ['cat-file', '-e', `${sha}^{commit}`]);
|
|
195
|
+
return true;
|
|
196
|
+
} catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Read the raw commit object and extract identity/date fields.
|
|
203
|
+
*
|
|
204
|
+
* @param {string} root - repository root
|
|
205
|
+
* @param {string} commitSha - commit SHA
|
|
206
|
+
* @returns {Promise<{ message: string, author: { name: string, email: string, date: string }, committer: { name: string, email: string, date: string } }>}
|
|
207
|
+
*/
|
|
208
|
+
export async function readCommitMeta(root, commitSha) {
|
|
209
|
+
const raw = await git(root, ['cat-file', 'commit', commitSha]);
|
|
210
|
+
const lines = raw.split('\n');
|
|
211
|
+
const headers = {};
|
|
212
|
+
const messageLines = [];
|
|
213
|
+
let inBody = false;
|
|
214
|
+
for (const line of lines) {
|
|
215
|
+
if (inBody) {
|
|
216
|
+
messageLines.push(line);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (line === '') {
|
|
220
|
+
inBody = true;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const colon = line.indexOf(' ');
|
|
224
|
+
if (colon === -1) continue;
|
|
225
|
+
const key = line.slice(0, colon);
|
|
226
|
+
const value = line.slice(colon + 1);
|
|
227
|
+
headers[key] = value;
|
|
228
|
+
}
|
|
229
|
+
const message = messageLines.join('\n');
|
|
230
|
+
const authorParts = (headers.author ?? '').split(/\s+>/);
|
|
231
|
+
const committerParts = (headers.committer ?? '').split(/\s+>/);
|
|
232
|
+
return {
|
|
233
|
+
message,
|
|
234
|
+
author: {
|
|
235
|
+
name: authorParts[0] ?? '',
|
|
236
|
+
email: (authorParts[1] ?? '').replace(/^</, '').replace(/\d+ [+-]\d{4}$/, '').trim(),
|
|
237
|
+
date: (headers.author ?? '').match(/(\d+ [+-]\d{4})$/)?.[1] ?? '',
|
|
238
|
+
},
|
|
239
|
+
committer: {
|
|
240
|
+
name: committerParts[0] ?? '',
|
|
241
|
+
email: (committerParts[1] ?? '').replace(/^</, '').replace(/\d+ [+-]\d{4}$/, '').trim(),
|
|
242
|
+
date: (headers.committer ?? '').match(/(\d+ [+-]\d{4})$/)?.[1] ?? '',
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Read the parent commit of a commit (`<sha>^`); empty string when the commit
|
|
249
|
+
* is a root.
|
|
250
|
+
*
|
|
251
|
+
* @param {string} root - repository root
|
|
252
|
+
* @param {string} commitSha - commit SHA
|
|
253
|
+
* @returns {Promise<string>} parent commit SHA or ''
|
|
254
|
+
*/
|
|
255
|
+
export async function getParentCommit(root, commitSha) {
|
|
256
|
+
try {
|
|
257
|
+
return await git(root, ['rev-parse', `${commitSha}^`]);
|
|
258
|
+
} catch {
|
|
259
|
+
return '';
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Check whether a commit is an ancestor of a ref.
|
|
265
|
+
*
|
|
266
|
+
* @param {string} root - repository root
|
|
267
|
+
* @param {string} commitSha - commit SHA
|
|
268
|
+
* @param {string} ref - ref name, e.g. 'main'
|
|
269
|
+
* @returns {Promise<boolean>}
|
|
270
|
+
*/
|
|
271
|
+
export async function isAncestorOf(root, commitSha, ref = 'main') {
|
|
272
|
+
try {
|
|
273
|
+
await git(root, ['merge-base', '--is-ancestor', commitSha, ref]);
|
|
274
|
+
return true;
|
|
275
|
+
} catch {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ============================================================================
|
|
281
|
+
// Analyze (read-only)
|
|
282
|
+
// ============================================================================
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Analyze the release-tag lineage of a repository (read-only).
|
|
286
|
+
*
|
|
287
|
+
* Reports, in version order:
|
|
288
|
+
* - tag name, tag commit, commit tree hash;
|
|
289
|
+
* - chain relation (parent of tag commit);
|
|
290
|
+
* - orphan roots (tag commits without a parent) and chain breaks (tag
|
|
291
|
+
* commit parent is not the previous version's tag commit);
|
|
292
|
+
* - version gaps (missing versions between the oldest and newest release
|
|
293
|
+
* tag) registered as ABSENT — never fabricated.
|
|
294
|
+
*
|
|
295
|
+
* @param {string} root - repository root
|
|
296
|
+
* @returns {Promise<{
|
|
297
|
+
* nodes: Array<{ version: string, tag: string, commitSha: string, treeHash: string, parentCommit: string, chainRoot: boolean, chainBreak: boolean, ancestorOfMain: boolean }>,
|
|
298
|
+
* gaps: Array<{ version: string, status: 'ABSENT' }>,
|
|
299
|
+
* summary: { releaseTagCount: number, gapCount: number, chainBreakCount: number, orphanRootCount: number }
|
|
300
|
+
* }>}
|
|
301
|
+
*/
|
|
302
|
+
export async function analyzeLineage(root) {
|
|
303
|
+
const releases = await listReleaseTags(root);
|
|
304
|
+
const nodes = [];
|
|
305
|
+
const gaps = [];
|
|
306
|
+
|
|
307
|
+
for (let i = 0; i < releases.length; i += 1) {
|
|
308
|
+
const release = releases[i];
|
|
309
|
+
const treeHash = await getCommitTreeHash(root, release.commitSha);
|
|
310
|
+
const parentCommit = await getParentCommit(root, release.commitSha);
|
|
311
|
+
let ancestorOfMain = false;
|
|
312
|
+
try {
|
|
313
|
+
ancestorOfMain = await isAncestorOf(root, release.commitSha, 'main');
|
|
314
|
+
} catch {
|
|
315
|
+
ancestorOfMain = false;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const chainRoot = parentCommit === '';
|
|
319
|
+
let chainBreak = false;
|
|
320
|
+
if (!chainRoot && i > 0) {
|
|
321
|
+
// Previous release tag commit should be this commit's parent.
|
|
322
|
+
chainBreak = parentCommit !== releases[i - 1].commitSha;
|
|
323
|
+
} else if (!chainRoot && i === 0) {
|
|
324
|
+
// Oldest release tag should be a root (or at least not chained to
|
|
325
|
+
// anything else) — a parent on the first release is reported.
|
|
326
|
+
chainBreak = true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
nodes.push({
|
|
330
|
+
version: release.version,
|
|
331
|
+
tag: release.name,
|
|
332
|
+
commitSha: release.commitSha,
|
|
333
|
+
treeHash,
|
|
334
|
+
parentCommit,
|
|
335
|
+
chainRoot,
|
|
336
|
+
chainBreak,
|
|
337
|
+
ancestorOfMain,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Version gaps between consecutive release tags (bounded enumeration).
|
|
342
|
+
// Same major.minor → skipped patches (0.1.1 → 0.1.3 registers 0.1.2).
|
|
343
|
+
// Minor jump → skipped minors' .0 (0.1.0 → 0.3.0 registers 0.2.0).
|
|
344
|
+
// Major jump → skipped majors' 0.0 (0.1.0 → 2.0.0 registers 1.0.0).
|
|
345
|
+
// Prereleases are excluded from gap detection.
|
|
346
|
+
if (releases.length >= 2) {
|
|
347
|
+
for (let i = 1; i < releases.length; i += 1) {
|
|
348
|
+
const prev = parseSemverVersion(releases[i - 1].version);
|
|
349
|
+
const curr = parseSemverVersion(releases[i].version);
|
|
350
|
+
if (prev.prerelease || curr.prerelease) continue;
|
|
351
|
+
if (curr.major === prev.major && curr.minor === prev.minor) {
|
|
352
|
+
for (let p = prev.patch + 1; p < curr.patch; p += 1) {
|
|
353
|
+
gaps.push({ version: `${curr.major}.${curr.minor}.${p}`, status: 'ABSENT' });
|
|
354
|
+
}
|
|
355
|
+
} else if (curr.major === prev.major) {
|
|
356
|
+
for (let m = prev.minor + 1; m < curr.minor; m += 1) {
|
|
357
|
+
gaps.push({ version: `${curr.major}.${m}.0`, status: 'ABSENT' });
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
for (let M = prev.major + 1; M < curr.major; M += 1) {
|
|
361
|
+
gaps.push({ version: `${M}.0.0`, status: 'ABSENT' });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
nodes,
|
|
369
|
+
gaps,
|
|
370
|
+
summary: {
|
|
371
|
+
releaseTagCount: releases.length,
|
|
372
|
+
gapCount: gaps.length,
|
|
373
|
+
chainBreakCount: nodes.filter((n) => n.chainBreak).length,
|
|
374
|
+
orphanRootCount: nodes.filter((n) => n.chainRoot).length,
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ============================================================================
|
|
380
|
+
// Rebuild (handoff §2.2 option A — local object database only)
|
|
381
|
+
// ============================================================================
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Create a rebuilt commit object with `commit-tree`, preserving the original
|
|
385
|
+
* author/committer identity and dates via environment variables.
|
|
386
|
+
*
|
|
387
|
+
* Writes ONLY a commit object into the local object database. Never writes a
|
|
388
|
+
* ref and never pushes.
|
|
389
|
+
*
|
|
390
|
+
* @param {string} root - repository root
|
|
391
|
+
* @param {string} treeSha - tree hash for the rebuilt commit
|
|
392
|
+
* @param {string|null} parentSha - parent commit (null → chain root)
|
|
393
|
+
* @param {string} message - commit message
|
|
394
|
+
* @param {{ author: {name: string, email: string, date: string}, committer: {name: string, email: string, date: string} }} identity
|
|
395
|
+
* @returns {Promise<string>} rebuilt commit SHA
|
|
396
|
+
*/
|
|
397
|
+
export async function createRebuiltCommit(root, treeSha, parentSha, message, identity) {
|
|
398
|
+
const args = ['commit-tree', treeSha];
|
|
399
|
+
if (parentSha) args.push('-p', parentSha);
|
|
400
|
+
args.push('-m', message);
|
|
401
|
+
const env = {
|
|
402
|
+
...process.env,
|
|
403
|
+
GIT_AUTHOR_NAME: identity.author.name,
|
|
404
|
+
GIT_AUTHOR_EMAIL: identity.author.email,
|
|
405
|
+
GIT_AUTHOR_DATE: identity.author.date,
|
|
406
|
+
GIT_COMMITTER_NAME: identity.committer.name,
|
|
407
|
+
GIT_COMMITTER_EMAIL: identity.committer.email,
|
|
408
|
+
GIT_COMMITTER_DATE: identity.committer.date,
|
|
409
|
+
};
|
|
410
|
+
const { stdout } = await execFile('git', args, { cwd: root, encoding: 'utf8', env });
|
|
411
|
+
return stdout.trim();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Rebuild the release commit chain in the local object database (方案 A).
|
|
416
|
+
*
|
|
417
|
+
* For every release tag in version order:
|
|
418
|
+
* 1. read the tag commit's tree (published bytes) and identity;
|
|
419
|
+
* 2. create a rebuilt commit with that exact tree, parent = previous
|
|
420
|
+
* rebuilt commit (oldest version = chain root), original identity/date;
|
|
421
|
+
* 3. verify: rebuilt tree hash === tag commit tree hash.
|
|
422
|
+
*
|
|
423
|
+
* Version gaps (missing tags such as v0.1.2) are registered as ABSENT and are
|
|
424
|
+
* never fabricated into tags. Zero ref writes, zero push — pushing a
|
|
425
|
+
* `release-history` branch remains a separately authorized action.
|
|
426
|
+
*
|
|
427
|
+
* @param {string} root - repository root
|
|
428
|
+
* @param {{ dryRun?: boolean }} [options]
|
|
429
|
+
* @returns {Promise<{
|
|
430
|
+
* status: 'REBUILT' | 'DRY_RUN',
|
|
431
|
+
* nodes: Array<{
|
|
432
|
+
* version: string, tag: string, originalCommit: string, originalTree: string,
|
|
433
|
+
* rebuiltCommit: string | null, rebuiltTree: string | null,
|
|
434
|
+
* parentCommit: string | null, chainRoot: boolean,
|
|
435
|
+
* verified: boolean | null, error?: string
|
|
436
|
+
* }>,
|
|
437
|
+
* gaps: Array<{ version: string, status: 'ABSENT' }>,
|
|
438
|
+
* summary: { rebuiltCount: number, verifiedCount: number, failedCount: number, absentCount: number },
|
|
439
|
+
* note: string
|
|
440
|
+
* }>}
|
|
441
|
+
*/
|
|
442
|
+
export async function rebuildLineage(root, options = {}) {
|
|
443
|
+
const { dryRun = false } = options;
|
|
444
|
+
const analysis = await analyzeLineage(root);
|
|
445
|
+
const nodes = [];
|
|
446
|
+
let previousRebuiltCommit = null;
|
|
447
|
+
|
|
448
|
+
for (const release of analysis.nodes) {
|
|
449
|
+
const treeHash = release.treeHash;
|
|
450
|
+
const meta = await readCommitMeta(root, release.commitSha);
|
|
451
|
+
const identity = {
|
|
452
|
+
author: meta.author,
|
|
453
|
+
committer: meta.committer,
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const node = {
|
|
457
|
+
version: release.version,
|
|
458
|
+
tag: release.tag,
|
|
459
|
+
originalCommit: release.commitSha,
|
|
460
|
+
originalTree: treeHash,
|
|
461
|
+
rebuiltCommit: null,
|
|
462
|
+
rebuiltTree: null,
|
|
463
|
+
parentCommit: previousRebuiltCommit,
|
|
464
|
+
chainRoot: previousRebuiltCommit === null,
|
|
465
|
+
verified: null,
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
if (dryRun) {
|
|
470
|
+
node.verified = null;
|
|
471
|
+
} else {
|
|
472
|
+
const rebuiltCommit = await createRebuiltCommit(
|
|
473
|
+
root,
|
|
474
|
+
treeHash,
|
|
475
|
+
previousRebuiltCommit,
|
|
476
|
+
meta.message || `Release ${release.version}`,
|
|
477
|
+
identity,
|
|
478
|
+
);
|
|
479
|
+
const rebuiltTree = await getCommitTreeHash(root, rebuiltCommit);
|
|
480
|
+
node.rebuiltCommit = rebuiltCommit;
|
|
481
|
+
node.rebuiltTree = rebuiltTree;
|
|
482
|
+
node.verified = rebuiltTree === treeHash;
|
|
483
|
+
if (!node.verified) {
|
|
484
|
+
throw new Error(
|
|
485
|
+
`rebuilt commit tree mismatch: rebuilt=${rebuiltTree} expected=${treeHash} (version ${release.version})`,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
previousRebuiltCommit = rebuiltCommit;
|
|
489
|
+
}
|
|
490
|
+
} catch (err) {
|
|
491
|
+
node.error = err.message;
|
|
492
|
+
// Fail-closed: record the failed node, then stop the chain — the
|
|
493
|
+
// failed node must appear in the report (failedCount ≥ 1) and no
|
|
494
|
+
// later node may be created on top of a broken chain.
|
|
495
|
+
nodes.push(node);
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
nodes.push(node);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const rebuiltNodes = nodes.filter((n) => n.rebuiltCommit !== null);
|
|
503
|
+
const failedNodes = nodes.filter((n) => n.error);
|
|
504
|
+
|
|
505
|
+
return {
|
|
506
|
+
status: dryRun ? 'DRY_RUN' : 'REBUILT',
|
|
507
|
+
nodes,
|
|
508
|
+
gaps: analysis.gaps,
|
|
509
|
+
summary: {
|
|
510
|
+
rebuiltCount: rebuiltNodes.length,
|
|
511
|
+
verifiedCount: rebuiltNodes.filter((n) => n.verified).length,
|
|
512
|
+
failedCount: failedNodes.length,
|
|
513
|
+
absentCount: analysis.gaps.length,
|
|
514
|
+
},
|
|
515
|
+
note: '本地对象库内重建(不写任何 ref、不推送)。推送 release-history 分支属独立授权动作,未授权前不执行。',
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ============================================================================
|
|
520
|
+
// CLI entry
|
|
521
|
+
// ============================================================================
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Parse command-line arguments for the lineage command.
|
|
525
|
+
*
|
|
526
|
+
* @param {string[]} args
|
|
527
|
+
* @returns {{ command: 'analyze' | 'rebuild', root: string, dryRun: boolean, json: boolean, help: boolean }}
|
|
528
|
+
*/
|
|
529
|
+
export function parseArgs(args) {
|
|
530
|
+
const result = {
|
|
531
|
+
command: 'analyze',
|
|
532
|
+
root: process.cwd(),
|
|
533
|
+
dryRun: false,
|
|
534
|
+
json: false,
|
|
535
|
+
help: false,
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
let i = 0;
|
|
539
|
+
while (i < args.length) {
|
|
540
|
+
const arg = args[i];
|
|
541
|
+
if (arg === 'analyze' || arg === 'rebuild') {
|
|
542
|
+
result.command = arg;
|
|
543
|
+
} else if (arg === '--root' && i + 1 < args.length) {
|
|
544
|
+
result.root = args[++i];
|
|
545
|
+
} else if (arg === '--dry-run') {
|
|
546
|
+
result.dryRun = true;
|
|
547
|
+
} else if (arg === '--json') {
|
|
548
|
+
result.json = true;
|
|
549
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
550
|
+
result.help = true;
|
|
551
|
+
}
|
|
552
|
+
i += 1;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return result;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const HELP_TEXT = `Lineage Repair Tool — handoff §2.2 option A (local rebuild)
|
|
559
|
+
|
|
560
|
+
在本地对象库内重建发布提交链(方案 A):每个重建提交的 tree = 对应 tag 提交的
|
|
561
|
+
tree(发布字节不变),parent = 前一版本的重建提交(最老版本为链根),保留原始
|
|
562
|
+
author/committer 身份与日期。零 ref 写入、零推送;推送 release-history 分支
|
|
563
|
+
属独立授权动作。
|
|
564
|
+
|
|
565
|
+
Usage:
|
|
566
|
+
release-skill lineage <command> [options]
|
|
567
|
+
|
|
568
|
+
Commands:
|
|
569
|
+
analyze 只读分析发布标签血缘:版本序、链关系、孤儿根、链断裂、版本缺口
|
|
570
|
+
rebuild 本地重建发布提交链 + 逐节点 tree 校验报告(--dry-run 仅演练)
|
|
571
|
+
|
|
572
|
+
Options:
|
|
573
|
+
--root <path> 仓库根目录(默认当前目录)
|
|
574
|
+
--dry-run 只演练,不创建任何 git 对象
|
|
575
|
+
--json 输出 JSON
|
|
576
|
+
-h, --help 显示本帮助
|
|
577
|
+
`;
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Main entry point for the lineage command.
|
|
581
|
+
*
|
|
582
|
+
* Errors propagate to the caller (fail-closed); no error is swallowed into a
|
|
583
|
+
* fake success result.
|
|
584
|
+
*
|
|
585
|
+
* @param {string[]} args
|
|
586
|
+
* @returns {Promise<{ status: string, [key: string]: unknown }>}
|
|
587
|
+
*/
|
|
588
|
+
export async function runLineageCommand(args = []) {
|
|
589
|
+
const parsed = parseArgs(args);
|
|
590
|
+
|
|
591
|
+
if (parsed.help) {
|
|
592
|
+
console.log(HELP_TEXT);
|
|
593
|
+
return { status: 'HELP_SHOWN' };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (parsed.command === 'analyze') {
|
|
597
|
+
const analysis = await analyzeLineage(parsed.root);
|
|
598
|
+
const output = analysis;
|
|
599
|
+
console.log(parsed.json ? JSON.stringify(output, null, 2) : JSON.stringify(output, null, 2));
|
|
600
|
+
return { status: 'ANALYZED', ...analysis.summary };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (parsed.command === 'rebuild') {
|
|
604
|
+
const result = await rebuildLineage(parsed.root, { dryRun: parsed.dryRun });
|
|
605
|
+
console.log(JSON.stringify(result, null, 2));
|
|
606
|
+
if (!parsed.json) {
|
|
607
|
+
console.log(`\n重建节点:${result.summary.rebuiltCount}(校验通过 ${result.summary.verifiedCount},失败 ${result.summary.failedCount},缺席版本 ${result.summary.absentCount})`);
|
|
608
|
+
console.log(result.note);
|
|
609
|
+
}
|
|
610
|
+
return { status: result.status, ...result.summary };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
throw new Error(`Unknown lineage subcommand: ${parsed.command}`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export default runLineageCommand;
|