release-skill 0.2.7 → 0.2.8

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 (34) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +21 -0
  7. package/INSTALL.md +4 -4
  8. package/INSTALL.zh-CN.md +4 -4
  9. package/README.md +10 -10
  10. package/README.zh-CN.md +10 -10
  11. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/release-skill.bundle.mjs +1116 -618
  14. package/adapters/claude/schemas/.render-manifest.json +2 -2
  15. package/adapters/claude/schemas/release-project.schema.json +60 -0
  16. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  17. package/adapters/codex/bin/release-skill.bundle.mjs +1116 -618
  18. package/adapters/codex/schemas/.render-manifest.json +2 -2
  19. package/adapters/codex/schemas/release-project.schema.json +60 -0
  20. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  21. package/adapters/kimi/bin/release-skill.bundle.mjs +1116 -618
  22. package/adapters/kimi/schemas/.render-manifest.json +2 -2
  23. package/adapters/kimi/schemas/release-project.schema.json +60 -0
  24. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  25. package/adapters/workbuddy/bin/release-skill.bundle.mjs +1116 -618
  26. package/adapters/workbuddy/schemas/.render-manifest.json +2 -2
  27. package/adapters/workbuddy/schemas/release-project.schema.json +60 -0
  28. package/bin/release-skill.bundle.mjs +1116 -618
  29. package/package.json +1 -1
  30. package/schemas/.render-manifest.json +2 -2
  31. package/schemas/release-project.schema.json +60 -0
  32. package/src/commands/prepare.mjs +46 -0
  33. package/src/core/config.mjs +48 -0
  34. package/src/core/public-surface.mjs +509 -0
@@ -0,0 +1,509 @@
1
+ /**
2
+ * Project-declared expected public surface.
3
+ *
4
+ * Each configured scan root classifies every observed regular file as exactly
5
+ * one of include or exclude. Included workspace-relative sources must match
6
+ * unit.publicFiles by the exact `(sourceScope, from)` identity.
7
+ *
8
+ * @module core/public-surface
9
+ */
10
+
11
+ import { lstat, readdir, realpath } from 'node:fs/promises';
12
+ import {
13
+ isAbsolute,
14
+ join,
15
+ relative,
16
+ resolve,
17
+ sep,
18
+ } from 'node:path';
19
+
20
+ import { CONFIG_INVALID, GATE_FAILED, ReleaseError } from './errors.mjs';
21
+ import { canonicalPublicPath } from '../snapshot/public-path.mjs';
22
+
23
+ const ROOT_CONTROL_DIRECTORIES = Object.freeze(['.git', '.release-skill']);
24
+ const UNSUPPORTED_GLOB_CHARACTERS = /[!()[\]{}]/u;
25
+
26
+ function compareStrings(left, right) {
27
+ return left < right ? -1 : left > right ? 1 : 0;
28
+ }
29
+
30
+ function toPosixPath(path) {
31
+ return path.split(sep).join('/');
32
+ }
33
+
34
+ function isContainedOrEqual(root, candidate) {
35
+ const rel = relative(root, candidate);
36
+ return rel === '' || (
37
+ rel !== '..' &&
38
+ !rel.startsWith(`..${sep}`) &&
39
+ !isAbsolute(rel)
40
+ );
41
+ }
42
+
43
+ function isWorkspaceControlPath(workspaceRoot, candidate) {
44
+ return ROOT_CONTROL_DIRECTORIES.some((name) => (
45
+ isContainedOrEqual(join(workspaceRoot, name), candidate)
46
+ ));
47
+ }
48
+
49
+ function failConfig(message, details) {
50
+ throw new ReleaseError(CONFIG_INVALID, message, details);
51
+ }
52
+
53
+ /**
54
+ * Validate the intentionally small public-surface glob language.
55
+ *
56
+ * Supported wildcards: `*`, `?`, and `**` as a complete path segment.
57
+ *
58
+ * @param {string} glob
59
+ * @returns {string}
60
+ */
61
+ export function validatePublicSurfaceGlob(glob) {
62
+ if (typeof glob !== 'string' || glob.length === 0) {
63
+ failConfig('public surface glob must be a non-empty string', {
64
+ reason: 'PUBLIC_SURFACE_GLOB_INVALID',
65
+ glob,
66
+ });
67
+ }
68
+ if (
69
+ glob.startsWith('/') ||
70
+ glob.startsWith('./') ||
71
+ glob.includes('\\') ||
72
+ glob.includes('\0') ||
73
+ glob.includes(':') ||
74
+ glob.endsWith('/') ||
75
+ UNSUPPORTED_GLOB_CHARACTERS.test(glob)
76
+ ) {
77
+ failConfig(`unsupported or unsafe public surface glob "${glob}"`, {
78
+ reason: 'PUBLIC_SURFACE_GLOB_INVALID',
79
+ glob,
80
+ });
81
+ }
82
+
83
+ const segments = glob.split('/');
84
+ if (segments.some((segment) => (
85
+ segment === '' ||
86
+ segment === '.' ||
87
+ segment === '..' ||
88
+ (segment.includes('**') && segment !== '**')
89
+ ))) {
90
+ failConfig(`unsupported or unsafe public surface glob "${glob}"`, {
91
+ reason: 'PUBLIC_SURFACE_GLOB_INVALID',
92
+ glob,
93
+ });
94
+ }
95
+ return glob;
96
+ }
97
+
98
+ function compileGlob(glob) {
99
+ validatePublicSurfaceGlob(glob);
100
+ let source = '';
101
+ for (let index = 0; index < glob.length;) {
102
+ if (glob.startsWith('**/', index)) {
103
+ // Zero or more complete leading/intermediate path segments. The zero
104
+ // case is what makes **/*.mjs match a root-level main.mjs.
105
+ source += '(?:.*/)?';
106
+ index += 3;
107
+ continue;
108
+ }
109
+ if (glob.startsWith('**', index)) {
110
+ // `**` is validated as a complete segment. At the end (e.g. dir/**)
111
+ // it matches every descendant depth.
112
+ source += '.*';
113
+ index += 2;
114
+ continue;
115
+ }
116
+
117
+ const character = glob[index];
118
+ if (character === '*') {
119
+ source += '[^/]*';
120
+ } else if (character === '?') {
121
+ source += '[^/]';
122
+ } else {
123
+ source += character.replace(/[.+^$|\\]/gu, '\\$&');
124
+ }
125
+ index += 1;
126
+ }
127
+ return new RegExp(`^${source}$`, 'u');
128
+ }
129
+
130
+ function compilePatterns(patterns) {
131
+ return (patterns ?? []).map((pattern) => ({
132
+ pattern,
133
+ regexp: compileGlob(pattern),
134
+ }));
135
+ }
136
+
137
+ function matchingPatterns(matchers, path) {
138
+ return matchers
139
+ .filter(({ regexp }) => regexp.test(path))
140
+ .map(({ pattern }) => pattern)
141
+ .sort(compareStrings);
142
+ }
143
+
144
+ async function assertSafeDirectoryPath({
145
+ workspaceRoot,
146
+ containmentRoot,
147
+ candidate,
148
+ unitId,
149
+ configuredRoot,
150
+ }) {
151
+ if (!isContainedOrEqual(containmentRoot, candidate)) {
152
+ failConfig(`public surface scan root escapes its ${containmentRoot === workspaceRoot ? 'workspace' : 'unit'} scope`, {
153
+ reason: 'PUBLIC_SURFACE_SCAN_ROOT_UNSAFE',
154
+ unitId,
155
+ root: configuredRoot,
156
+ });
157
+ }
158
+
159
+ const rel = relative(workspaceRoot, candidate);
160
+ if (!isContainedOrEqual(workspaceRoot, candidate)) {
161
+ failConfig('public surface scan root escapes the workspace', {
162
+ reason: 'PUBLIC_SURFACE_SCAN_ROOT_UNSAFE',
163
+ unitId,
164
+ root: configuredRoot,
165
+ });
166
+ }
167
+
168
+ let cursor = workspaceRoot;
169
+ for (const segment of rel.split(sep).filter(Boolean)) {
170
+ cursor = join(cursor, segment);
171
+ let stat;
172
+ try {
173
+ stat = await lstat(cursor);
174
+ } catch (error) {
175
+ failConfig(`cannot inspect public surface scan root "${configuredRoot}"`, {
176
+ reason: 'PUBLIC_SURFACE_SCAN_ROOT_UNSAFE',
177
+ unitId,
178
+ root: configuredRoot,
179
+ cause: error.code ?? 'UNKNOWN',
180
+ });
181
+ }
182
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
183
+ failConfig(`public surface scan root or ancestor is not a physical directory: "${configuredRoot}"`, {
184
+ reason: 'PUBLIC_SURFACE_SCAN_ROOT_UNSAFE',
185
+ unitId,
186
+ root: configuredRoot,
187
+ });
188
+ }
189
+ }
190
+
191
+ let physicalCandidate;
192
+ let physicalContainmentRoot;
193
+ try {
194
+ [physicalCandidate, physicalContainmentRoot] = await Promise.all([
195
+ realpath(candidate),
196
+ realpath(containmentRoot),
197
+ ]);
198
+ } catch (error) {
199
+ failConfig(`cannot resolve public surface scan root "${configuredRoot}"`, {
200
+ reason: 'PUBLIC_SURFACE_SCAN_ROOT_UNSAFE',
201
+ unitId,
202
+ root: configuredRoot,
203
+ cause: error.code ?? 'UNKNOWN',
204
+ });
205
+ }
206
+ if (
207
+ !isContainedOrEqual(workspaceRoot, physicalCandidate) ||
208
+ !isContainedOrEqual(physicalContainmentRoot, physicalCandidate)
209
+ ) {
210
+ failConfig(`public surface scan root escapes physical containment: "${configuredRoot}"`, {
211
+ reason: 'PUBLIC_SURFACE_SCAN_ROOT_UNSAFE',
212
+ unitId,
213
+ root: configuredRoot,
214
+ });
215
+ }
216
+ return physicalCandidate;
217
+ }
218
+
219
+ async function resolveScanRoots({ root, unit }) {
220
+ let workspaceRoot;
221
+ try {
222
+ workspaceRoot = await realpath(resolve(root));
223
+ } catch (error) {
224
+ failConfig('cannot resolve public surface workspace root', {
225
+ reason: 'PUBLIC_SURFACE_SCAN_ROOT_UNSAFE',
226
+ unitId: unit.id,
227
+ cause: error.code ?? 'UNKNOWN',
228
+ });
229
+ }
230
+
231
+ const unitSource = canonicalPublicPath(unit.source, { allowDot: true }).path;
232
+ const unitRoot = resolve(workspaceRoot, unitSource);
233
+ await assertSafeDirectoryPath({
234
+ workspaceRoot,
235
+ containmentRoot: workspaceRoot,
236
+ candidate: unitRoot,
237
+ unitId: unit.id,
238
+ configuredRoot: unit.source,
239
+ });
240
+
241
+ const resolvedRoots = [];
242
+ for (const [index, rule] of unit.expectedPublicSurface.scanRoots.entries()) {
243
+ const sourceScope = rule.sourceScope ?? 'unit';
244
+ const configuredRoot = rule.root ?? '.';
245
+ const rootPath = canonicalPublicPath(configuredRoot, { allowDot: true }).path;
246
+ const scopeRoot = sourceScope === 'workspace' ? workspaceRoot : unitRoot;
247
+ const candidate = resolve(scopeRoot, rootPath);
248
+ if (isWorkspaceControlPath(workspaceRoot, candidate)) {
249
+ failConfig(`public surface scan root cannot target workspace control paths: "${configuredRoot}"`, {
250
+ reason: 'PUBLIC_SURFACE_SCAN_ROOT_CONTROL_PATH',
251
+ unitId: unit.id,
252
+ root: configuredRoot,
253
+ });
254
+ }
255
+ const physicalPath = await assertSafeDirectoryPath({
256
+ workspaceRoot,
257
+ containmentRoot: scopeRoot,
258
+ candidate,
259
+ unitId: unit.id,
260
+ configuredRoot,
261
+ });
262
+ resolvedRoots.push({
263
+ index,
264
+ sourceScope,
265
+ configuredRoot,
266
+ physicalPath,
267
+ includeMatchers: compilePatterns(rule.include),
268
+ excludeMatchers: compilePatterns(rule.exclude),
269
+ });
270
+ }
271
+
272
+ for (let leftIndex = 0; leftIndex < resolvedRoots.length; leftIndex += 1) {
273
+ for (let rightIndex = leftIndex + 1; rightIndex < resolvedRoots.length; rightIndex += 1) {
274
+ const left = resolvedRoots[leftIndex];
275
+ const right = resolvedRoots[rightIndex];
276
+ if (
277
+ isContainedOrEqual(left.physicalPath, right.physicalPath) ||
278
+ isContainedOrEqual(right.physicalPath, left.physicalPath)
279
+ ) {
280
+ failConfig(`public surface scan roots overlap for unit "${unit.id}"`, {
281
+ reason: 'PUBLIC_SURFACE_SCAN_ROOT_OVERLAP',
282
+ unitId: unit.id,
283
+ scanRoots: [
284
+ { index: left.index, sourceScope: left.sourceScope, root: left.configuredRoot },
285
+ { index: right.index, sourceScope: right.sourceScope, root: right.configuredRoot },
286
+ ],
287
+ });
288
+ }
289
+ }
290
+ }
291
+
292
+ return { workspaceRoot, resolvedRoots };
293
+ }
294
+
295
+ function unsupportedEntryError({ unit, rule, workspaceRelative, relativePath, entry, includePatterns, excludePatterns }) {
296
+ return new ReleaseError(
297
+ GATE_FAILED,
298
+ `public surface contains an unsupported non-regular entry "${workspaceRelative}"`,
299
+ {
300
+ reason: 'PUBLIC_SURFACE_UNSUPPORTED_ENTRY',
301
+ unitId: unit.id,
302
+ sourceScope: rule.sourceScope,
303
+ from: workspaceRelative,
304
+ scanRoot: rule.configuredRoot,
305
+ relativePath,
306
+ entryType: entry.isSymbolicLink() ? 'symlink' : 'special',
307
+ includePatterns,
308
+ excludePatterns,
309
+ },
310
+ );
311
+ }
312
+
313
+ async function scanRoot({ workspaceRoot, unit, rule }) {
314
+ const files = [];
315
+ let skippedNonRegularCount = 0;
316
+ const skippedControlPaths = new Set(
317
+ ROOT_CONTROL_DIRECTORIES.map((name) => join(workspaceRoot, name)),
318
+ );
319
+
320
+ async function walk(directory, relativeDirectory) {
321
+ let entries;
322
+ try {
323
+ entries = await readdir(directory, { withFileTypes: true });
324
+ } catch (error) {
325
+ failConfig(`cannot read public surface scan root for unit "${unit.id}"`, {
326
+ reason: 'PUBLIC_SURFACE_SCAN_ROOT_UNSAFE',
327
+ unitId: unit.id,
328
+ root: rule.configuredRoot,
329
+ cause: error.code ?? 'UNKNOWN',
330
+ });
331
+ }
332
+ entries.sort((left, right) => compareStrings(left.name, right.name));
333
+
334
+ for (const entry of entries) {
335
+ const absolutePath = join(directory, entry.name);
336
+ // Check the exact workspace-root control paths before interpreting entry
337
+ // type because `.git` is commonly a regular file in linked worktrees.
338
+ // Nested paths with the same name remain project content.
339
+ if (skippedControlPaths.has(absolutePath)) continue;
340
+
341
+ if (entry.isDirectory()) {
342
+ const childRelative = relativeDirectory
343
+ ? `${relativeDirectory}/${entry.name}`
344
+ : entry.name;
345
+ await walk(absolutePath, childRelative);
346
+ continue;
347
+ }
348
+
349
+ const relativePath = relativeDirectory
350
+ ? `${relativeDirectory}/${entry.name}`
351
+ : entry.name;
352
+ const includePatterns = matchingPatterns(rule.includeMatchers, relativePath);
353
+ const excludePatterns = matchingPatterns(rule.excludeMatchers, relativePath);
354
+ const workspaceRelative = canonicalPublicPath(
355
+ toPosixPath(relative(workspaceRoot, absolutePath)),
356
+ ).path;
357
+
358
+ if (!entry.isFile()) {
359
+ if (includePatterns.length === 0 && excludePatterns.length > 0) {
360
+ skippedNonRegularCount += 1;
361
+ continue;
362
+ }
363
+ throw unsupportedEntryError({
364
+ unit,
365
+ rule,
366
+ workspaceRelative,
367
+ relativePath,
368
+ entry,
369
+ includePatterns,
370
+ excludePatterns,
371
+ });
372
+ }
373
+
374
+ files.push({
375
+ identity: `${rule.sourceScope}\0${workspaceRelative}`,
376
+ sourceScope: rule.sourceScope,
377
+ from: workspaceRelative,
378
+ scanRoot: rule.configuredRoot,
379
+ relativePath,
380
+ includePatterns,
381
+ excludePatterns,
382
+ });
383
+ }
384
+ }
385
+
386
+ await walk(rule.physicalPath, '');
387
+ return { files, skippedNonRegularCount };
388
+ }
389
+
390
+ function publicSourceIdentity(sourceScope, from) {
391
+ return `${sourceScope}\0${from}`;
392
+ }
393
+
394
+ function sortBySource(left, right) {
395
+ return compareStrings(left.sourceScope, right.sourceScope) ||
396
+ compareStrings(left.from, right.from);
397
+ }
398
+
399
+ /**
400
+ * Inspect one release unit's expected public surface without changing files.
401
+ *
402
+ * @returns {Promise<object>}
403
+ */
404
+ export async function inspectExpectedPublicSurface({ root, unit } = {}) {
405
+ if (!unit?.expectedPublicSurface) {
406
+ return Object.freeze({ enabled: false, passed: true, unitId: unit?.id ?? null });
407
+ }
408
+
409
+ const { workspaceRoot, resolvedRoots } = await resolveScanRoots({ root, unit });
410
+ const observedFiles = [];
411
+ let skippedNonRegularCount = 0;
412
+ for (const rule of resolvedRoots) {
413
+ const observed = await scanRoot({ workspaceRoot, unit, rule });
414
+ observedFiles.push(...observed.files);
415
+ skippedNonRegularCount += observed.skippedNonRegularCount;
416
+ }
417
+
418
+ const includedByIdentity = new Map();
419
+ const unclassifiedFiles = [];
420
+ const ambiguousFiles = [];
421
+ let excludedFileCount = 0;
422
+
423
+ for (const file of observedFiles) {
424
+ const included = file.includePatterns.length > 0;
425
+ const excluded = file.excludePatterns.length > 0;
426
+ if (included) includedByIdentity.set(file.identity, file);
427
+ if (!included && !excluded) {
428
+ unclassifiedFiles.push(file);
429
+ } else if (included && excluded) {
430
+ ambiguousFiles.push(file);
431
+ } else if (excluded) {
432
+ excludedFileCount += 1;
433
+ }
434
+ }
435
+
436
+ const mappingsByIdentity = new Map();
437
+ for (const mapping of unit.publicFiles ?? []) {
438
+ const sourceScope = mapping.sourceScope ?? 'unit';
439
+ const from = canonicalPublicPath(mapping.from).path;
440
+ const identity = publicSourceIdentity(sourceScope, from);
441
+ const current = mappingsByIdentity.get(identity) ?? {
442
+ sourceScope,
443
+ from,
444
+ targets: [],
445
+ };
446
+ current.targets.push(canonicalPublicPath(mapping.to).path);
447
+ current.targets.sort(compareStrings);
448
+ mappingsByIdentity.set(identity, current);
449
+ }
450
+
451
+ const missingMappings = [...includedByIdentity.entries()]
452
+ .filter(([identity]) => !mappingsByIdentity.has(identity))
453
+ .map(([, file]) => ({
454
+ sourceScope: file.sourceScope,
455
+ from: file.from,
456
+ }))
457
+ .sort(sortBySource);
458
+ const unexpectedMappings = [...mappingsByIdentity.entries()]
459
+ .filter(([identity]) => !includedByIdentity.has(identity))
460
+ .map(([, mapping]) => mapping)
461
+ .sort(sortBySource);
462
+
463
+ unclassifiedFiles.sort(sortBySource);
464
+ ambiguousFiles.sort(sortBySource);
465
+
466
+ const passed = (
467
+ missingMappings.length === 0 &&
468
+ unexpectedMappings.length === 0 &&
469
+ unclassifiedFiles.length === 0 &&
470
+ ambiguousFiles.length === 0
471
+ );
472
+ return {
473
+ enabled: true,
474
+ passed,
475
+ unitId: unit.id,
476
+ summary: {
477
+ scannedFileCount: observedFiles.length,
478
+ includedFileCount: includedByIdentity.size,
479
+ excludedFileCount,
480
+ mappedSourceCount: mappingsByIdentity.size,
481
+ skippedNonRegularCount,
482
+ },
483
+ missingMappings,
484
+ unexpectedMappings,
485
+ unclassifiedFiles,
486
+ ambiguousFiles,
487
+ };
488
+ }
489
+
490
+ /**
491
+ * Inspect and fail closed when any classification or mapping difference exists.
492
+ */
493
+ export async function assertExpectedPublicSurface(options) {
494
+ const result = await inspectExpectedPublicSurface(options);
495
+ if (!result.enabled || result.passed) return result;
496
+ throw new ReleaseError(
497
+ GATE_FAILED,
498
+ `expected public surface mismatch for unit "${result.unitId}"`,
499
+ {
500
+ reason: 'PUBLIC_SURFACE_MISMATCH',
501
+ unitId: result.unitId,
502
+ summary: result.summary,
503
+ missingMappings: result.missingMappings,
504
+ unexpectedMappings: result.unexpectedMappings,
505
+ unclassifiedFiles: result.unclassifiedFiles,
506
+ ambiguousFiles: result.ambiguousFiles,
507
+ },
508
+ );
509
+ }