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