release-skill 0.2.6 → 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 (63) 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 +42 -0
  7. package/INSTALL.md +12 -12
  8. package/INSTALL.zh-CN.md +10 -10
  9. package/README.md +26 -11
  10. package/README.zh-CN.md +22 -11
  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 +2619 -1728
  14. package/adapters/claude/schemas/.render-manifest.json +2 -2
  15. package/adapters/claude/schemas/release-project.schema.json +60 -0
  16. package/adapters/claude/skills/release-prepare/SKILL.md +5 -0
  17. package/adapters/claude/skills/release-setup/SKILL.md +10 -1
  18. package/adapters/claude/skills/release-verify/SKILL.md +4 -2
  19. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  20. package/adapters/codex/bin/release-skill.bundle.mjs +2619 -1728
  21. package/adapters/codex/schemas/.render-manifest.json +2 -2
  22. package/adapters/codex/schemas/release-project.schema.json +60 -0
  23. package/adapters/codex/skills/release-prepare/SKILL.md +5 -0
  24. package/adapters/codex/skills/release-setup/SKILL.md +10 -1
  25. package/adapters/codex/skills/release-verify/SKILL.md +4 -2
  26. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  27. package/adapters/kimi/bin/release-skill.bundle.mjs +2619 -1728
  28. package/adapters/kimi/schemas/.render-manifest.json +2 -2
  29. package/adapters/kimi/schemas/release-project.schema.json +60 -0
  30. package/adapters/kimi/skills/release-prepare/SKILL.md +5 -0
  31. package/adapters/kimi/skills/release-setup/SKILL.md +10 -1
  32. package/adapters/kimi/skills/release-verify/SKILL.md +4 -2
  33. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  34. package/adapters/workbuddy/bin/release-skill.bundle.mjs +2619 -1728
  35. package/adapters/workbuddy/schemas/.render-manifest.json +2 -2
  36. package/adapters/workbuddy/schemas/release-project.schema.json +60 -0
  37. package/adapters/workbuddy/skills/release-prepare/SKILL.md +5 -0
  38. package/adapters/workbuddy/skills/release-setup/SKILL.md +10 -1
  39. package/adapters/workbuddy/skills/release-verify/SKILL.md +4 -2
  40. package/bin/release-skill.bundle.mjs +2619 -1728
  41. package/package.json +1 -1
  42. package/schemas/.render-manifest.json +2 -2
  43. package/schemas/release-project.schema.json +60 -0
  44. package/skills/release-prepare/SKILL.md +5 -0
  45. package/skills/release-setup/SKILL.md +10 -1
  46. package/skills/release-verify/SKILL.md +4 -2
  47. package/skills-src/release-prepare/SKILL.md +5 -0
  48. package/skills-src/release-setup/SKILL.md +10 -1
  49. package/skills-src/release-verify/SKILL.md +4 -2
  50. package/src/adapters/npm.mjs +54 -2
  51. package/src/adapters/plugin-marketplace.mjs +4 -29
  52. package/src/commands/prepare.mjs +49 -3
  53. package/src/commands/publish.mjs +2 -2
  54. package/src/commands/reconcile.mjs +39 -0
  55. package/src/commands/setup.mjs +168 -0
  56. package/src/commands/verify.mjs +28 -1
  57. package/src/core/config.mjs +48 -0
  58. package/src/core/public-surface.mjs +509 -0
  59. package/src/npm/npm-entry-closure.mjs +195 -0
  60. package/src/platforms/codebuddy.mjs +19 -11
  61. package/src/platforms/codex.mjs +18 -10
  62. package/src/platforms/kimi.mjs +26 -39
  63. package/src/snapshot/frozen.mjs +51 -19
@@ -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
+ }
@@ -0,0 +1,195 @@
1
+ import { tmpdir } from 'node:os';
2
+
3
+ import {
4
+ buildNpmTarballFileIndex,
5
+ computeFrozenSnapshot,
6
+ } from '../snapshot/frozen.mjs';
7
+
8
+ const SIMPLE_FIELDS = ['main', 'module', 'types', 'typings'];
9
+
10
+ function entryError(field, target, reason, message) {
11
+ return { field, target, reason, message };
12
+ }
13
+
14
+ function validateRelativeTarget(target, field, { requireDotSlash = false } = {}) {
15
+ if (typeof target !== 'string' || target.length === 0) {
16
+ return {
17
+ error: entryError(field, String(target ?? ''), 'invalid_type', `${field} target must be a non-empty string`),
18
+ };
19
+ }
20
+ if (target.includes('\0')) {
21
+ return { error: entryError(field, target, 'nul_in_path', `${field} target must not contain NUL`) };
22
+ }
23
+ if (
24
+ target.startsWith('/') ||
25
+ /^[A-Za-z]:[/\\]/.test(target) ||
26
+ target.startsWith('\\\\')
27
+ ) {
28
+ return { error: entryError(field, target, 'absolute_path', `${field} target must be package-relative`) };
29
+ }
30
+ if (target.includes('\\')) {
31
+ return { error: entryError(field, target, 'backslash_path', `${field} target must use forward slashes`) };
32
+ }
33
+ if (requireDotSlash && !target.startsWith('./')) {
34
+ return {
35
+ error: entryError(field, target, 'missing_dot_slash', `${field} target must start with "./"`),
36
+ };
37
+ }
38
+
39
+ const relativeTarget = target.startsWith('./') ? target.slice(2) : target;
40
+ const segments = relativeTarget.split('/');
41
+ if (segments.includes('..')) {
42
+ return {
43
+ error: entryError(field, target, 'path_escape', `${field} target escapes the package root`),
44
+ };
45
+ }
46
+ if (
47
+ relativeTarget.length === 0 ||
48
+ segments.some((segment) => segment === '' || segment === '.')
49
+ ) {
50
+ return {
51
+ error: entryError(field, target, 'unsafe_segment', `${field} target contains an unsafe path segment`),
52
+ };
53
+ }
54
+ if (requireDotSlash && segments.includes('node_modules')) {
55
+ return {
56
+ error: entryError(field, target, 'unsafe_segment', `${field} target must not contain node_modules`),
57
+ };
58
+ }
59
+ return { target: relativeTarget };
60
+ }
61
+
62
+ function checkTarget({ field, target, fileIndex, requireDotSlash = false }) {
63
+ const validated = validateRelativeTarget(target, field, { requireDotSlash });
64
+ if (validated.error) return { errors: [validated.error], entries: [] };
65
+
66
+ const indexed = fileIndex.get(validated.target);
67
+ const found = indexed?.type === 'file';
68
+ const entries = [{ field, target: validated.target, found }];
69
+ if (found) return { entries, errors: [] };
70
+
71
+ const reason = indexed === undefined ? 'entry_missing' : 'entry_not_regular_file';
72
+ return {
73
+ entries,
74
+ errors: [
75
+ entryError(
76
+ field,
77
+ validated.target,
78
+ reason,
79
+ indexed === undefined
80
+ ? `${field} target "${validated.target}" is missing`
81
+ : `${field} target "${validated.target}" is not a regular file`,
82
+ ),
83
+ ],
84
+ };
85
+ }
86
+
87
+ function collectExports(value, location, result) {
88
+ if (value === null) return;
89
+ if (typeof value === 'string') {
90
+ if (value.includes('*')) {
91
+ result.errors.push(
92
+ entryError(
93
+ 'exports',
94
+ value,
95
+ 'unsupported_entry_shape',
96
+ `${location} uses a wildcard target that the static gate cannot verify`,
97
+ ),
98
+ );
99
+ } else {
100
+ result.targets.push({ field: `exports ${location}`, target: value });
101
+ }
102
+ return;
103
+ }
104
+ if (Array.isArray(value)) {
105
+ result.errors.push(
106
+ entryError('exports', location, 'unsupported_entry_shape', `${location} uses unsupported fallback-array semantics`),
107
+ );
108
+ return;
109
+ }
110
+ if (typeof value !== 'object' || value === undefined) {
111
+ result.errors.push(
112
+ entryError('exports', String(value), 'invalid_exports_type', `${location} has an unsupported exports value`),
113
+ );
114
+ return;
115
+ }
116
+
117
+ for (const [key, child] of Object.entries(value)) {
118
+ const childLocation = `${location}.${key}`;
119
+ if (key.includes('*')) {
120
+ result.errors.push(
121
+ entryError(
122
+ 'exports',
123
+ key,
124
+ 'unsupported_entry_shape',
125
+ `${childLocation} uses a wildcard subpath that the static gate cannot verify`,
126
+ ),
127
+ );
128
+ continue;
129
+ }
130
+ collectExports(child, childLocation, result);
131
+ }
132
+ }
133
+
134
+ export function checkNpmEntryClosure(manifest, fileIndex) {
135
+ const result = { entries: [], errors: [], diagnostics: [] };
136
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
137
+ result.errors.push(entryError('manifest', '', 'invalid_manifest', 'manifest must be an object'));
138
+ return result;
139
+ }
140
+ if (!(fileIndex instanceof Map)) {
141
+ result.errors.push(entryError('fileIndex', '', 'invalid_file_index', 'fileIndex must be a Map'));
142
+ return result;
143
+ }
144
+
145
+ if (manifest.bin !== undefined && manifest.bin !== null) {
146
+ if (typeof manifest.bin === 'string') {
147
+ const checked = checkTarget({ field: 'bin', target: manifest.bin, fileIndex });
148
+ result.entries.push(...checked.entries);
149
+ result.errors.push(...checked.errors);
150
+ } else if (typeof manifest.bin === 'object' && !Array.isArray(manifest.bin)) {
151
+ for (const [name, target] of Object.entries(manifest.bin)) {
152
+ const checked = checkTarget({ field: 'bin', target, fileIndex });
153
+ result.entries.push(...checked.entries);
154
+ result.errors.push(...checked.errors);
155
+ }
156
+ } else {
157
+ result.errors.push(entryError('bin', String(manifest.bin), 'invalid_type', 'bin must be a string or object'));
158
+ }
159
+ }
160
+
161
+ for (const field of SIMPLE_FIELDS) {
162
+ if (manifest[field] === undefined || manifest[field] === null) continue;
163
+ const checked = checkTarget({ field, target: manifest[field], fileIndex });
164
+ result.entries.push(...checked.entries);
165
+ result.errors.push(...checked.errors);
166
+ }
167
+
168
+ if (manifest.exports !== undefined) {
169
+ const exportsResult = { targets: [], errors: [], diagnostics: [] };
170
+ collectExports(manifest.exports, 'exports', exportsResult);
171
+ result.errors.push(...exportsResult.errors);
172
+ result.diagnostics.push(...exportsResult.diagnostics);
173
+ for (const target of exportsResult.targets) {
174
+ const checked = checkTarget({
175
+ field: 'exports',
176
+ target: target.target,
177
+ fileIndex,
178
+ requireDotSlash: true,
179
+ });
180
+ result.entries.push(...checked.entries);
181
+ result.errors.push(...checked.errors);
182
+ }
183
+ }
184
+
185
+ return result;
186
+ }
187
+
188
+ export async function buildTarballFileIndex(tarballBytes, tarballDir = tmpdir()) {
189
+ return buildNpmTarballFileIndex({ tarballBytes, tarballDir });
190
+ }
191
+
192
+ export async function buildDirectoryFileIndex(packageDir) {
193
+ const snapshot = await computeFrozenSnapshot(packageDir, { excludeRootEntries: ['.git'] });
194
+ return new Map(snapshot.entries.map((entry) => [entry.path, { type: entry.type }]));
195
+ }