release-skill 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/.agents/plugins/marketplace.json +23 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +10 -0
  4. package/.codex-plugin/plugin.json +26 -0
  5. package/CHANGELOG.md +68 -0
  6. package/CODE_OF_CONDUCT.md +76 -0
  7. package/CONTRIBUTING.md +49 -0
  8. package/INSTALL.md +182 -0
  9. package/LICENSE +21 -0
  10. package/NOTICE +25 -0
  11. package/README.md +501 -0
  12. package/README.zh-CN.md +463 -0
  13. package/SECURITY.md +48 -0
  14. package/adapters/claude/.claude-plugin/marketplace.json +16 -0
  15. package/adapters/claude/.claude-plugin/plugin.json +10 -0
  16. package/adapters/claude/skills/release-assess/SKILL.md +52 -0
  17. package/adapters/claude/skills/release-help/SKILL.md +60 -0
  18. package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
  19. package/adapters/claude/skills/release-publish/SKILL.md +55 -0
  20. package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
  21. package/adapters/claude/skills/release-verify/SKILL.md +70 -0
  22. package/adapters/codex/.codex-plugin/plugin.json +26 -0
  23. package/adapters/codex/skills/release-assess/SKILL.md +52 -0
  24. package/adapters/codex/skills/release-help/SKILL.md +60 -0
  25. package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
  26. package/adapters/codex/skills/release-publish/SKILL.md +55 -0
  27. package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
  28. package/adapters/codex/skills/release-verify/SKILL.md +70 -0
  29. package/bin/release-skill.mjs +743 -0
  30. package/native/safe-write/binding.gyp +40 -0
  31. package/native/safe-write/prebuilds.json +4 -0
  32. package/native/safe-write/src/safe_write.cc +2023 -0
  33. package/package.json +75 -0
  34. package/references/.render-manifest.json +33 -0
  35. package/references/00-target-state.md +124 -0
  36. package/references/01-state-machine.md +155 -0
  37. package/references/02-project-config.md +217 -0
  38. package/references/03-readme-quality.md +136 -0
  39. package/references/04-supply-chain.md +147 -0
  40. package/references/05-evidence-and-errors.md +164 -0
  41. package/references/06-adapter-contract.md +178 -0
  42. package/schemas/.render-manifest.json +37 -0
  43. package/schemas/approval-record.schema.json +115 -0
  44. package/schemas/artifact-lock.schema.json +111 -0
  45. package/schemas/artifact-plan.schema.json +52 -0
  46. package/schemas/artifact-policy.schema.json +76 -0
  47. package/schemas/evidence-event.schema.json +89 -0
  48. package/schemas/release-plan.schema.json +369 -0
  49. package/schemas/release-project.schema.json +359 -0
  50. package/schemas/release-run.schema.json +195 -0
  51. package/skills/release-assess/SKILL.md +52 -0
  52. package/skills/release-help/SKILL.md +60 -0
  53. package/skills/release-prepare/SKILL.md +71 -0
  54. package/skills/release-publish/SKILL.md +55 -0
  55. package/skills/release-reconcile/SKILL.md +73 -0
  56. package/skills/release-verify/SKILL.md +70 -0
  57. package/skills-src/release-assess/SKILL.md +52 -0
  58. package/skills-src/release-help/SKILL.md +60 -0
  59. package/skills-src/release-prepare/SKILL.md +71 -0
  60. package/skills-src/release-publish/SKILL.md +55 -0
  61. package/skills-src/release-reconcile/SKILL.md +73 -0
  62. package/skills-src/release-verify/SKILL.md +70 -0
  63. package/src/adapters/contract.mjs +214 -0
  64. package/src/adapters/git-github.mjs +214 -0
  65. package/src/adapters/npm.mjs +947 -0
  66. package/src/adapters/plugin-marketplace.mjs +1365 -0
  67. package/src/adapters/push-snapshot.mjs +216 -0
  68. package/src/artifacts/adoption.mjs +743 -0
  69. package/src/artifacts/artifact-plan.mjs +162 -0
  70. package/src/artifacts/entry.mjs +240 -0
  71. package/src/artifacts/git-authority.mjs +637 -0
  72. package/src/artifacts/graph.mjs +189 -0
  73. package/src/artifacts/inspect.mjs +520 -0
  74. package/src/artifacts/inventory.mjs +192 -0
  75. package/src/artifacts/merge/binary.mjs +77 -0
  76. package/src/artifacts/merge/entry-merge.mjs +228 -0
  77. package/src/artifacts/merge/json.mjs +641 -0
  78. package/src/artifacts/merge/markdown.mjs +246 -0
  79. package/src/artifacts/merge/regions.mjs +156 -0
  80. package/src/artifacts/merge/text.mjs +432 -0
  81. package/src/artifacts/merge/tree.mjs +202 -0
  82. package/src/artifacts/merge/yaml.mjs +669 -0
  83. package/src/artifacts/path-key.mjs +94 -0
  84. package/src/artifacts/policy.mjs +319 -0
  85. package/src/artifacts/producer-registry.mjs +439 -0
  86. package/src/artifacts/project-lock.mjs +732 -0
  87. package/src/artifacts/resolution.mjs +658 -0
  88. package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
  89. package/src/artifacts/safe-fs.mjs +72 -0
  90. package/src/artifacts/state.mjs +495 -0
  91. package/src/artifacts/transaction-journal.mjs +983 -0
  92. package/src/artifacts/transaction.mjs +1361 -0
  93. package/src/commands/approve.mjs +280 -0
  94. package/src/commands/artifacts.mjs +627 -0
  95. package/src/commands/assess.mjs +838 -0
  96. package/src/commands/prepare.mjs +1377 -0
  97. package/src/commands/publish.mjs +883 -0
  98. package/src/commands/reconcile.mjs +1255 -0
  99. package/src/commands/verify.mjs +915 -0
  100. package/src/core/approval.mjs +332 -0
  101. package/src/core/baseline.mjs +272 -0
  102. package/src/core/blackbox-hard-gates.mjs +142 -0
  103. package/src/core/config.mjs +448 -0
  104. package/src/core/digest.mjs +90 -0
  105. package/src/core/errors.mjs +113 -0
  106. package/src/core/evidence.mjs +167 -0
  107. package/src/core/hooks.mjs +241 -0
  108. package/src/core/node-version.mjs +64 -0
  109. package/src/core/plan.mjs +735 -0
  110. package/src/core/previous-public-baseline.mjs +204 -0
  111. package/src/core/run.mjs +681 -0
  112. package/src/core/state-machine.mjs +76 -0
  113. package/src/core/version-consistency.mjs +111 -0
  114. package/src/producers/build-adapters.mjs +231 -0
  115. package/src/producers/render-public-assets.mjs +152 -0
  116. package/src/producers/sync-skills.mjs +96 -0
  117. package/src/readme/contract.mjs +297 -0
  118. package/src/readme/examples.mjs +288 -0
  119. package/src/readme/parity.mjs +122 -0
  120. package/src/snapshot/export.mjs +99 -0
  121. package/src/snapshot/frozen.mjs +401 -0
  122. package/src/snapshot/manifest.mjs +207 -0
  123. package/src/snapshot/public-map.mjs +1459 -0
  124. package/src/snapshot/public-path.mjs +110 -0
  125. package/src/snapshot/scan.mjs +419 -0
@@ -0,0 +1,838 @@
1
+ /**
2
+ * Read-only assess command for release-skill.
3
+ *
4
+ * Performs read-only diagnostics: config schema validation, topology
5
+ * identification, common docs check, plugin manifest check, package metadata,
6
+ * remote prerequisites (skipped in --offline mode), and basic README structure.
7
+ *
8
+ * Classifies gaps into three scopes:
9
+ * - common: universally required (README, LICENSE, config validity)
10
+ * - profile: required for a specific distribution type (npm needs package.json,
11
+ * plugin needs manifest)
12
+ * - project: project-specific (policy violations, custom requirements)
13
+ *
14
+ * Does NOT modify the working tree unless an explicit --output path is given.
15
+ *
16
+ * @module commands/assess
17
+ */
18
+
19
+ import { readFile, stat, writeFile, mkdir } from 'node:fs/promises';
20
+ import { resolve, dirname, relative } from 'node:path';
21
+ import { execFile as execFileCb } from 'node:child_process';
22
+ import { promisify } from 'node:util';
23
+
24
+ import { loadProjectConfig } from '../core/config.mjs';
25
+ import { ReleaseError, CONFIG_INVALID, GATE_FAILED } from '../core/errors.mjs';
26
+
27
+ const execFile = promisify(execFileCb);
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Gap severity levels
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /** @enum {string} */
34
+ export const Severity = Object.freeze({
35
+ ERROR: 'error',
36
+ WARNING: 'warning',
37
+ });
38
+
39
+ /** @enum {string} */
40
+ export const GapScope = Object.freeze({
41
+ COMMON: 'common',
42
+ PROFILE: 'profile',
43
+ PROJECT: 'project',
44
+ });
45
+
46
+ /** @enum {string} */
47
+ export const GapCategory = Object.freeze({
48
+ CONFIG: 'config',
49
+ DOCS: 'docs',
50
+ MANIFEST: 'manifest',
51
+ METADATA: 'metadata',
52
+ REMOTE: 'remote',
53
+ README: 'readme',
54
+ POLICY: 'policy',
55
+ });
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Internal helpers
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * Check whether a file exists and is accessible.
63
+ *
64
+ * @param {string} filePath - Absolute path.
65
+ * @returns {Promise<boolean>}
66
+ */
67
+ async function fileExists(filePath) {
68
+ try {
69
+ await stat(filePath);
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Create a gap entry.
78
+ *
79
+ * @param {Object} params
80
+ * @param {string} params.scope - GapScope value.
81
+ * @param {string} params.category - GapCategory value.
82
+ * @param {string} params.severity - Severity value.
83
+ * @param {string} params.code - Machine-readable gap code.
84
+ * @param {string} params.message - Human-readable message (Chinese).
85
+ * @param {string} [params.file] - Optional file path related to the gap.
86
+ * @returns {Object}
87
+ */
88
+ function createGap({ scope, category, severity, code, message, file }) {
89
+ const gap = { scope, category, severity, code, message };
90
+ if (file !== undefined) {
91
+ gap.file = file;
92
+ }
93
+ return Object.freeze(gap);
94
+ }
95
+
96
+ /**
97
+ * Return a project-root-relative display path for a file inside a release unit.
98
+ *
99
+ * @param {Object} unit - Release unit configuration.
100
+ * @param {string} file - Path relative to the unit source.
101
+ * @returns {string}
102
+ */
103
+ function unitFile(unit, file) {
104
+ return unit.source === '.' ? file : `${unit.source}/${file}`;
105
+ }
106
+
107
+ /**
108
+ * Determine the project topology from the loaded config.
109
+ *
110
+ * Examines the distribution types to classify the project.
111
+ *
112
+ * @param {Object} config - The validated project config.
113
+ * @returns {{ type: string, releaseUnits: string[], distributions: string[] }}
114
+ */
115
+ function identifyTopology(config) {
116
+ const units = config.releaseUnits ?? [];
117
+ const unitIds = units.map((u) => u.id);
118
+
119
+ const allDistTypes = [];
120
+ for (const unit of units) {
121
+ for (const dist of unit.distributions ?? []) {
122
+ allDistTypes.push(dist.type);
123
+ }
124
+ }
125
+ const uniqueDistTypes = [...new Set(allDistTypes)];
126
+
127
+ let type = 'unknown';
128
+ const hasNpm = uniqueDistTypes.includes('npm');
129
+ const hasPlugin =
130
+ uniqueDistTypes.includes('claude-plugin') ||
131
+ uniqueDistTypes.includes('codex-plugin');
132
+
133
+ if (units.length === 0) {
134
+ type = 'no-release-units';
135
+ } else if (units.length === 1) {
136
+ if (hasNpm && hasPlugin) {
137
+ type = 'hybrid-plugin-npm';
138
+ } else if (hasNpm) {
139
+ type = 'single-npm';
140
+ } else if (hasPlugin) {
141
+ type = 'single-plugin';
142
+ } else {
143
+ type = 'single-unit';
144
+ }
145
+ } else {
146
+ type = 'split-public-repos';
147
+ }
148
+
149
+ return { type, releaseUnits: unitIds, distributions: uniqueDistTypes };
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Individual assessment checks
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * Validate the project configuration.
158
+ *
159
+ * @param {string} root - Project root.
160
+ * @returns {Promise<{ config: Object|null, configPath: string|null, configDigest: string|null, gaps: Object[] }>}
161
+ */
162
+ async function checkConfig(root) {
163
+ try {
164
+ const result = await loadProjectConfig({ root });
165
+ return {
166
+ config: result.config,
167
+ configPath: result.configPath,
168
+ configDigest: result.configDigest,
169
+ gaps: [],
170
+ };
171
+ } catch (err) {
172
+ if (err instanceof ReleaseError && err.code === CONFIG_INVALID) {
173
+ return {
174
+ config: null,
175
+ configPath: null,
176
+ configDigest: null,
177
+ gaps: [
178
+ createGap({
179
+ scope: GapScope.COMMON,
180
+ category: GapCategory.CONFIG,
181
+ severity: Severity.ERROR,
182
+ code: 'CONFIG_INVALID',
183
+ message: `配置文件无效: ${err.message}`,
184
+ }),
185
+ ],
186
+ };
187
+ }
188
+ // Unexpected error
189
+ return {
190
+ config: null,
191
+ configPath: null,
192
+ configDigest: null,
193
+ gaps: [
194
+ createGap({
195
+ scope: GapScope.COMMON,
196
+ category: GapCategory.CONFIG,
197
+ severity: Severity.ERROR,
198
+ code: 'CONFIG_ERROR',
199
+ message: `配置加载失败: ${err.message}`,
200
+ }),
201
+ ],
202
+ };
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Check for required and recommended common documentation files.
208
+ *
209
+ * @param {string} root - Project root.
210
+ * @param {Object} config - The validated project config.
211
+ * @returns {Promise<Object[]>} Array of gap entries.
212
+ */
213
+ async function checkCommonDocs(root, config) {
214
+ const gaps = [];
215
+ const units = config.releaseUnits ?? [];
216
+
217
+ // Required files
218
+ const requiredDocs = [
219
+ { file: 'README.md', code: 'README_MISSING', message: '缺少 README.md 文件' },
220
+ { file: 'LICENSE', code: 'LICENSE_MISSING', message: '缺少 LICENSE 文件' },
221
+ ];
222
+
223
+ for (const unit of units) {
224
+ const unitRoot = resolve(root, unit.source);
225
+ for (const doc of requiredDocs) {
226
+ const exists = await fileExists(resolve(unitRoot, doc.file));
227
+ if (!exists) {
228
+ gaps.push(
229
+ createGap({
230
+ scope: GapScope.COMMON,
231
+ category: GapCategory.DOCS,
232
+ severity: Severity.ERROR,
233
+ code: doc.code,
234
+ message: `${doc.message}(发布单元 "${unit.id}")`,
235
+ file: unitFile(unit, doc.file),
236
+ }),
237
+ );
238
+ }
239
+ }
240
+ }
241
+
242
+ // Recommended files (warning-level)
243
+ const recommendedDocs = [
244
+ { file: 'CHANGELOG.md', code: 'CHANGELOG_MISSING', message: '建议添加 CHANGELOG.md' },
245
+ { file: 'SECURITY.md', code: 'SECURITY_MISSING', message: '建议添加 SECURITY.md' },
246
+ { file: 'CONTRIBUTING.md', code: 'CONTRIBUTING_MISSING', message: '建议添加 CONTRIBUTING.md' },
247
+ ];
248
+
249
+ for (const unit of units) {
250
+ const unitRoot = resolve(root, unit.source);
251
+ for (const doc of recommendedDocs) {
252
+ const exists = await fileExists(resolve(unitRoot, doc.file));
253
+ if (!exists) {
254
+ gaps.push(
255
+ createGap({
256
+ scope: GapScope.COMMON,
257
+ category: GapCategory.DOCS,
258
+ severity: Severity.WARNING,
259
+ code: doc.code,
260
+ message: `${doc.message}(发布单元 "${unit.id}")`,
261
+ file: unitFile(unit, doc.file),
262
+ }),
263
+ );
264
+ }
265
+ }
266
+ }
267
+
268
+ return gaps;
269
+ }
270
+
271
+ /**
272
+ * Check plugin manifests for Claude and Codex plugin distributions.
273
+ *
274
+ * @param {string} root - Project root.
275
+ * @param {Object} config - The validated project config.
276
+ * @returns {Promise<Object[]>} Array of gap entries.
277
+ */
278
+ async function checkPluginManifests(root, config) {
279
+ const gaps = [];
280
+ const units = config.releaseUnits ?? [];
281
+
282
+ for (const unit of units) {
283
+ const distributionTypes = new Set((unit.distributions ?? []).map((dist) => dist.type));
284
+ const unitRoot = resolve(root, unit.source);
285
+
286
+ if (distributionTypes.has('claude-plugin')) {
287
+ const manifestPath = resolve(unitRoot, '.claude-plugin', 'plugin.json');
288
+ const displayPath = unitFile(unit, '.claude-plugin/plugin.json');
289
+ const exists = await fileExists(manifestPath);
290
+ if (!exists) {
291
+ gaps.push(
292
+ createGap({
293
+ scope: GapScope.PROFILE,
294
+ category: GapCategory.MANIFEST,
295
+ severity: Severity.ERROR,
296
+ code: 'CLAUDE_MANIFEST_MISSING',
297
+ message: `发布单元 "${unit.id}" 缺少 .claude-plugin/plugin.json 插件清单`,
298
+ file: displayPath,
299
+ }),
300
+ );
301
+ } else {
302
+ // Validate manifest structure
303
+ try {
304
+ const content = await readFile(manifestPath, 'utf8');
305
+ const manifest = JSON.parse(content);
306
+ const requiredFields = ['name', 'version', 'description'];
307
+ const missingFields = requiredFields.filter((f) => !(f in manifest));
308
+ if (missingFields.length > 0) {
309
+ gaps.push(
310
+ createGap({
311
+ scope: GapScope.PROFILE,
312
+ category: GapCategory.MANIFEST,
313
+ severity: Severity.ERROR,
314
+ code: 'CLAUDE_MANIFEST_INCOMPLETE',
315
+ message: `Claude 插件清单缺少必填字段: ${missingFields.join(', ')}`,
316
+ file: displayPath,
317
+ }),
318
+ );
319
+ }
320
+ } catch {
321
+ gaps.push(
322
+ createGap({
323
+ scope: GapScope.PROFILE,
324
+ category: GapCategory.MANIFEST,
325
+ severity: Severity.ERROR,
326
+ code: 'CLAUDE_MANIFEST_INVALID',
327
+ message: '.claude-plugin/plugin.json 解析失败',
328
+ file: displayPath,
329
+ }),
330
+ );
331
+ }
332
+ }
333
+ }
334
+
335
+ if (distributionTypes.has('codex-plugin')) {
336
+ const manifestPath = resolve(unitRoot, '.codex-plugin', 'plugin.json');
337
+ const displayPath = unitFile(unit, '.codex-plugin/plugin.json');
338
+ const exists = await fileExists(manifestPath);
339
+ if (!exists) {
340
+ gaps.push(
341
+ createGap({
342
+ scope: GapScope.PROFILE,
343
+ category: GapCategory.MANIFEST,
344
+ severity: Severity.ERROR,
345
+ code: 'CODEX_MANIFEST_MISSING',
346
+ message: `发布单元 "${unit.id}" 缺少 .codex-plugin/plugin.json 插件清单`,
347
+ file: displayPath,
348
+ }),
349
+ );
350
+ } else {
351
+ try {
352
+ const content = await readFile(manifestPath, 'utf8');
353
+ const manifest = JSON.parse(content);
354
+ const requiredFields = ['name', 'version', 'description'];
355
+ const missingFields = requiredFields.filter((f) => !(f in manifest));
356
+ if (missingFields.length > 0) {
357
+ gaps.push(
358
+ createGap({
359
+ scope: GapScope.PROFILE,
360
+ category: GapCategory.MANIFEST,
361
+ severity: Severity.ERROR,
362
+ code: 'CODEX_MANIFEST_INCOMPLETE',
363
+ message: `Codex 插件清单缺少必填字段: ${missingFields.join(', ')}`,
364
+ file: displayPath,
365
+ }),
366
+ );
367
+ }
368
+ } catch {
369
+ gaps.push(
370
+ createGap({
371
+ scope: GapScope.PROFILE,
372
+ category: GapCategory.MANIFEST,
373
+ severity: Severity.ERROR,
374
+ code: 'CODEX_MANIFEST_INVALID',
375
+ message: '.codex-plugin/plugin.json 解析失败',
376
+ file: displayPath,
377
+ }),
378
+ );
379
+ }
380
+ }
381
+ }
382
+ }
383
+
384
+ return gaps;
385
+ }
386
+
387
+ /**
388
+ * Check npm package metadata for npm distributions.
389
+ *
390
+ * @param {string} root - Project root.
391
+ * @param {Object} config - The validated project config.
392
+ * @returns {Promise<Object[]>} Array of gap entries.
393
+ */
394
+ async function checkPackageMetadata(root, config) {
395
+ const gaps = [];
396
+ const units = config.releaseUnits ?? [];
397
+
398
+ let hasNpm = false;
399
+ for (const unit of units) {
400
+ for (const dist of unit.distributions ?? []) {
401
+ if (dist.type === 'npm') {
402
+ hasNpm = true;
403
+ break;
404
+ }
405
+ }
406
+ if (hasNpm) break;
407
+ }
408
+
409
+ if (!hasNpm) return gaps;
410
+
411
+ // Check that each npm-distributed unit has a package.json in its source
412
+ for (const unit of units) {
413
+ const hasNpmDist = (unit.distributions ?? []).some((d) => d.type === 'npm');
414
+ if (!hasNpmDist) continue;
415
+
416
+ const unitRoot = resolve(root, unit.source);
417
+ const pkgPath = resolve(unitRoot, 'package.json');
418
+ const exists = await fileExists(pkgPath);
419
+
420
+ if (!exists) {
421
+ gaps.push(
422
+ createGap({
423
+ scope: GapScope.PROFILE,
424
+ category: GapCategory.METADATA,
425
+ severity: Severity.ERROR,
426
+ code: 'PACKAGE_JSON_MISSING',
427
+ message: `发布单元 "${unit.id}" 的 source 目录缺少 package.json`,
428
+ file: `${unit.source}/package.json`,
429
+ }),
430
+ );
431
+ continue;
432
+ }
433
+
434
+ try {
435
+ const content = await readFile(pkgPath, 'utf8');
436
+ const pkg = JSON.parse(content);
437
+ const requiredFields = ['name', 'version'];
438
+ const missingFields = requiredFields.filter((f) => !(f in pkg));
439
+ if (missingFields.length > 0) {
440
+ gaps.push(
441
+ createGap({
442
+ scope: GapScope.PROFILE,
443
+ category: GapCategory.METADATA,
444
+ severity: Severity.ERROR,
445
+ code: 'PACKAGE_JSON_INCOMPLETE',
446
+ message: `发布单元 "${unit.id}" 的 package.json 缺少字段: ${missingFields.join(', ')}`,
447
+ file: `${unit.source}/package.json`,
448
+ }),
449
+ );
450
+ }
451
+ } catch {
452
+ gaps.push(
453
+ createGap({
454
+ scope: GapScope.PROFILE,
455
+ category: GapCategory.METADATA,
456
+ severity: Severity.ERROR,
457
+ code: 'PACKAGE_JSON_INVALID',
458
+ message: `发布单元 "${unit.id}" 的 package.json 解析失败`,
459
+ file: `${unit.source}/package.json`,
460
+ }),
461
+ );
462
+ }
463
+ }
464
+
465
+ return gaps;
466
+ }
467
+
468
+ /**
469
+ * Check remote prerequisites (git remote, npm version conflicts).
470
+ * Skipped entirely in offline mode.
471
+ *
472
+ * @param {string} root - Project root.
473
+ * @param {Object} config - The validated project config.
474
+ * @param {boolean} offline - Whether to skip remote checks.
475
+ * @returns {Promise<Object[]>} Array of gap entries.
476
+ */
477
+ async function checkRemotePrerequisites(root, config, offline) {
478
+ if (offline) return [];
479
+
480
+ const gaps = [];
481
+ const units = config.releaseUnits ?? [];
482
+
483
+ // Check git remote
484
+ try {
485
+ const { stdout } = await execFile('git', ['remote', 'get-url', 'origin'], {
486
+ cwd: root,
487
+ shell: false,
488
+ encoding: 'utf8',
489
+ timeout: 10_000,
490
+ });
491
+ if (!stdout.trim()) {
492
+ gaps.push(
493
+ createGap({
494
+ scope: GapScope.COMMON,
495
+ category: GapCategory.REMOTE,
496
+ severity: Severity.WARNING,
497
+ code: 'GIT_REMOTE_EMPTY',
498
+ message: 'Git remote "origin" URL 为空',
499
+ }),
500
+ );
501
+ }
502
+ } catch {
503
+ gaps.push(
504
+ createGap({
505
+ scope: GapScope.COMMON,
506
+ category: GapCategory.REMOTE,
507
+ severity: Severity.WARNING,
508
+ code: 'GIT_REMOTE_MISSING',
509
+ message: '未找到 Git remote "origin"',
510
+ }),
511
+ );
512
+ }
513
+
514
+ // Check npm registry for existing versions
515
+ for (const unit of units) {
516
+ const npmDist = (unit.distributions ?? []).find((d) => d.type === 'npm');
517
+ if (!npmDist?.package) continue;
518
+
519
+ const pkgPath = resolve(root, unit.source, 'package.json');
520
+ let version;
521
+ try {
522
+ const content = await readFile(pkgPath, 'utf8');
523
+ const pkg = JSON.parse(content);
524
+ version = pkg.version;
525
+ } catch {
526
+ continue; // Already reported in checkPackageMetadata
527
+ }
528
+
529
+ if (!version) continue;
530
+
531
+ try {
532
+ await execFile(
533
+ 'npm',
534
+ ['view', `${npmDist.package}@${version}`, 'version'],
535
+ { cwd: root, shell: false, encoding: 'utf8', timeout: 15_000 },
536
+ );
537
+ // If we get here, the version already exists on npm
538
+ gaps.push(
539
+ createGap({
540
+ scope: GapScope.PROFILE,
541
+ category: GapCategory.REMOTE,
542
+ severity: Severity.ERROR,
543
+ code: 'NPM_VERSION_CONFLICT',
544
+ message: `npm 包 ${npmDist.package}@${version} 已存在于 registry`,
545
+ }),
546
+ );
547
+ } catch {
548
+ // Version not published -- good, no gap
549
+ }
550
+ }
551
+
552
+ return gaps;
553
+ }
554
+
555
+ /**
556
+ * Perform a basic README structural check.
557
+ *
558
+ * @param {string} root - Project root.
559
+ * @param {Object} config - The validated project config.
560
+ * @returns {Promise<Object[]>} Array of gap entries.
561
+ */
562
+ async function checkReadmeStructure(root, config) {
563
+ const gaps = [];
564
+ const units = config.releaseUnits ?? [];
565
+
566
+ for (const unit of units) {
567
+ const readmePath = resolve(root, unit.source, 'README.md');
568
+ const displayPath = unitFile(unit, 'README.md');
569
+ const exists = await fileExists(readmePath);
570
+ if (!exists) continue; // Already reported by checkCommonDocs
571
+
572
+ try {
573
+ const content = await readFile(readmePath, 'utf8');
574
+
575
+ // Basic structural checks
576
+ const hasHeading = /^#\s+/m.test(content);
577
+ if (!hasHeading) {
578
+ gaps.push(
579
+ createGap({
580
+ scope: GapScope.COMMON,
581
+ category: GapCategory.README,
582
+ severity: Severity.WARNING,
583
+ code: 'README_NO_HEADING',
584
+ message: `发布单元 "${unit.id}" 的 README.md 缺少标题(一级标题)`,
585
+ file: displayPath,
586
+ }),
587
+ );
588
+ }
589
+
590
+ const hasInstallSection = /install|安装|setup/i.test(content);
591
+ if (!hasInstallSection) {
592
+ gaps.push(
593
+ createGap({
594
+ scope: GapScope.COMMON,
595
+ category: GapCategory.README,
596
+ severity: Severity.WARNING,
597
+ code: 'README_NO_INSTALL',
598
+ message: `发布单元 "${unit.id}" 的 README.md 缺少安装说明`,
599
+ file: displayPath,
600
+ }),
601
+ );
602
+ }
603
+
604
+ // Check for very short README (likely incomplete)
605
+ if (content.trim().length < 100) {
606
+ gaps.push(
607
+ createGap({
608
+ scope: GapScope.COMMON,
609
+ category: GapCategory.README,
610
+ severity: Severity.WARNING,
611
+ code: 'README_TOO_SHORT',
612
+ message: `发布单元 "${unit.id}" 的 README.md 内容过短(少于 100 字符),可能不完整`,
613
+ file: displayPath,
614
+ }),
615
+ );
616
+ }
617
+ } catch {
618
+ // File exists but can't be read - unusual, report it
619
+ gaps.push(
620
+ createGap({
621
+ scope: GapScope.COMMON,
622
+ category: GapCategory.README,
623
+ severity: Severity.ERROR,
624
+ code: 'README_UNREADABLE',
625
+ message: `发布单元 "${unit.id}" 的 README.md 存在但无法读取`,
626
+ file: displayPath,
627
+ }),
628
+ );
629
+ }
630
+ }
631
+
632
+ return gaps;
633
+ }
634
+
635
+ // ---------------------------------------------------------------------------
636
+ // Chinese summary generation
637
+ // ---------------------------------------------------------------------------
638
+
639
+ /**
640
+ * Generate a Chinese summary from the assessment report data.
641
+ *
642
+ * @param {Object} params
643
+ * @param {Object|null} params.config - The loaded config or null.
644
+ * @param {Object} params.topology - Topology information.
645
+ * @param {Object[]} params.gaps - Array of gap entries.
646
+ * @param {boolean} params.offline - Whether remote checks were skipped.
647
+ * @returns {string} Chinese summary text.
648
+ */
649
+ function generateSummary({ config, topology, gaps, offline }) {
650
+ const lines = [];
651
+
652
+ // Project identity
653
+ if (config) {
654
+ lines.push(`项目: ${config.project?.name ?? '(未命名)'}`);
655
+ } else {
656
+ lines.push('项目: 配置加载失败');
657
+ }
658
+
659
+ // Topology
660
+ const topologyLabels = {
661
+ 'single-npm': '单 npm 包',
662
+ 'single-plugin': '单插件',
663
+ 'hybrid-plugin-npm': '混合插件+npm 包',
664
+ 'split-public-repos': '多公开仓库',
665
+ 'single-unit': '单发布单元',
666
+ 'no-release-units': '无发布单元',
667
+ 'unknown': '未知拓扑',
668
+ };
669
+ lines.push(`拓扑: ${topologyLabels[topology.type] ?? topology.type}`);
670
+ lines.push(`发布单元: ${topology.releaseUnits.length} 个`);
671
+ if (topology.distributions.length > 0) {
672
+ lines.push(`分发类型: ${topology.distributions.join(', ')}`);
673
+ }
674
+
675
+ // Gap summary
676
+ const errors = gaps.filter((g) => g.severity === Severity.ERROR);
677
+ const warnings = gaps.filter((g) => g.severity === Severity.WARNING);
678
+
679
+ if (gaps.length === 0) {
680
+ lines.push('诊断结果: 未发现缺口');
681
+ } else {
682
+ lines.push(`诊断结果: ${errors.length} 个错误, ${warnings.length} 个警告`);
683
+
684
+ // Group by scope
685
+ const commonGaps = gaps.filter((g) => g.scope === GapScope.COMMON);
686
+ const profileGaps = gaps.filter((g) => g.scope === GapScope.PROFILE);
687
+ const projectGaps = gaps.filter((g) => g.scope === GapScope.PROJECT);
688
+
689
+ if (commonGaps.length > 0) {
690
+ lines.push(`\n通用缺口 (${commonGaps.length}):`);
691
+ for (const gap of commonGaps) {
692
+ const prefix = gap.severity === Severity.ERROR ? '[错误]' : '[警告]';
693
+ lines.push(` ${prefix} ${gap.message}`);
694
+ }
695
+ }
696
+
697
+ if (profileGaps.length > 0) {
698
+ lines.push(`\nProfile 缺口 (${profileGaps.length}):`);
699
+ for (const gap of profileGaps) {
700
+ const prefix = gap.severity === Severity.ERROR ? '[错误]' : '[警告]';
701
+ lines.push(` ${prefix} ${gap.message}`);
702
+ }
703
+ }
704
+
705
+ if (projectGaps.length > 0) {
706
+ lines.push(`\n项目个性化缺口 (${projectGaps.length}):`);
707
+ for (const gap of projectGaps) {
708
+ const prefix = gap.severity === Severity.ERROR ? '[错误]' : '[警告]';
709
+ lines.push(` ${prefix} ${gap.message}`);
710
+ }
711
+ }
712
+ }
713
+
714
+ if (offline) {
715
+ lines.push('\n注意: 已跳过远端前置条件检查(--offline 模式)');
716
+ }
717
+
718
+ return lines.join('\n');
719
+ }
720
+
721
+ // ---------------------------------------------------------------------------
722
+ // Public API
723
+ // ---------------------------------------------------------------------------
724
+
725
+ /**
726
+ * Perform a read-only assessment of a project's release readiness.
727
+ *
728
+ * @param {Object} options
729
+ * @param {string} options.root - Absolute path to the project root.
730
+ * @param {boolean} [options.offline=true] - Skip remote checks when true.
731
+ * @param {string} [options.output] - Optional file path to write the JSON report.
732
+ * If not provided, no files are written.
733
+ *
734
+ * @returns {Promise<Object>} The AssessmentReport:
735
+ * - status: 'ASSESSED' | 'NEEDS_INPUT' | 'BLOCKED'
736
+ * - configDigest: string | null
737
+ * - topology: { type, releaseUnits, distributions }
738
+ * - gaps: Array<{ scope, category, severity, code, message, file? }>
739
+ * - summary: string (Chinese)
740
+ * - assessedAt: string (ISO-8601)
741
+ * - offline: boolean
742
+ */
743
+ export async function assessProject(options) {
744
+ const { root, offline = true, output } = options;
745
+
746
+ if (!root || typeof root !== 'string') {
747
+ throw new ReleaseError(CONFIG_INVALID, 'root must be a non-empty string');
748
+ }
749
+
750
+ const allGaps = [];
751
+
752
+ // --- 1. Config validation ---
753
+ const configResult = await checkConfig(root);
754
+ allGaps.push(...configResult.gaps);
755
+
756
+ // If config is completely broken, early return with NEEDS_INPUT
757
+ if (!configResult.config) {
758
+ const report = {
759
+ status: 'NEEDS_INPUT',
760
+ configDigest: null,
761
+ topology: { type: 'unknown', releaseUnits: [], distributions: [] },
762
+ gaps: allGaps,
763
+ summary: generateSummary({
764
+ config: null,
765
+ topology: { type: 'unknown', releaseUnits: [], distributions: [] },
766
+ gaps: allGaps,
767
+ offline,
768
+ }),
769
+ assessedAt: new Date().toISOString(),
770
+ offline,
771
+ };
772
+
773
+ if (output) {
774
+ await writeReport(output, report);
775
+ }
776
+
777
+ return report;
778
+ }
779
+
780
+ const config = configResult.config;
781
+
782
+ // --- 2. Topology identification ---
783
+ const topology = identifyTopology(config);
784
+
785
+ // --- 3. Common docs check ---
786
+ const docGaps = await checkCommonDocs(root, config);
787
+ allGaps.push(...docGaps);
788
+
789
+ // --- 4. Plugin manifest check ---
790
+ const manifestGaps = await checkPluginManifests(root, config);
791
+ allGaps.push(...manifestGaps);
792
+
793
+ // --- 5. Package metadata check ---
794
+ const metadataGaps = await checkPackageMetadata(root, config);
795
+ allGaps.push(...metadataGaps);
796
+
797
+ // --- 6. Remote prerequisites ---
798
+ const remoteGaps = await checkRemotePrerequisites(root, config, offline);
799
+ allGaps.push(...remoteGaps);
800
+
801
+ // --- 7. README structure check ---
802
+ const readmeGaps = await checkReadmeStructure(root, config);
803
+ allGaps.push(...readmeGaps);
804
+
805
+ // --- Determine status ---
806
+ const hasErrors = allGaps.some((g) => g.severity === Severity.ERROR);
807
+ const status = hasErrors ? 'NEEDS_INPUT' : 'ASSESSED';
808
+
809
+ // --- Build report ---
810
+ const report = {
811
+ status,
812
+ configDigest: configResult.configDigest,
813
+ topology,
814
+ gaps: allGaps,
815
+ summary: generateSummary({ config, topology, gaps: allGaps, offline }),
816
+ assessedAt: new Date().toISOString(),
817
+ offline,
818
+ };
819
+
820
+ // --- Write output if requested ---
821
+ if (output) {
822
+ await writeReport(output, report);
823
+ }
824
+
825
+ return report;
826
+ }
827
+
828
+ /**
829
+ * Write the assessment report to a file.
830
+ *
831
+ * @param {string} outputPath - Absolute or relative path to write the report.
832
+ * @param {Object} report - The AssessmentReport object.
833
+ */
834
+ async function writeReport(outputPath, report) {
835
+ const dir = dirname(outputPath);
836
+ await mkdir(dir, { recursive: true });
837
+ await writeFile(outputPath, JSON.stringify(report, null, 2), 'utf8');
838
+ }