release-skill 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +3 -3
- package/CHANGELOG.md +29 -0
- package/INSTALL.md +69 -32
- package/INSTALL.zh-CN.md +64 -26
- package/README.md +101 -37
- package/README.zh-CN.md +87 -28
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +79284 -0
- package/adapters/claude/bin/release-skill.mjs +34 -0
- package/adapters/claude/native/safe-write/binding.gyp +40 -0
- package/adapters/claude/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/adapters/claude/native/safe-write/prebuilds.json +24 -0
- package/adapters/claude/native/safe-write/src/safe_write.cc +2032 -0
- package/adapters/claude/schemas/.render-manifest.json +37 -0
- package/adapters/claude/schemas/approval-record.schema.json +115 -0
- package/adapters/claude/schemas/artifact-lock.schema.json +111 -0
- package/adapters/claude/schemas/artifact-plan.schema.json +52 -0
- package/adapters/claude/schemas/artifact-policy.schema.json +76 -0
- package/adapters/claude/schemas/evidence-event.schema.json +89 -0
- package/adapters/claude/schemas/release-plan.schema.json +860 -0
- package/adapters/claude/schemas/release-project.schema.json +736 -0
- package/adapters/claude/schemas/release-run.schema.json +342 -0
- package/adapters/claude/skills/release-assess/SKILL.md +5 -6
- package/adapters/claude/skills/release-help/SKILL.md +11 -18
- package/adapters/claude/skills/release-prepare/SKILL.md +6 -6
- package/adapters/claude/skills/release-publish/SKILL.md +1 -4
- package/adapters/claude/skills/release-reconcile/SKILL.md +5 -5
- package/adapters/claude/skills/release-setup/SKILL.md +64 -80
- package/adapters/claude/skills/release-verify/SKILL.md +4 -7
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +79284 -0
- package/adapters/codex/bin/release-skill.mjs +34 -0
- package/adapters/codex/native/safe-write/binding.gyp +40 -0
- package/adapters/codex/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/adapters/codex/native/safe-write/prebuilds.json +24 -0
- package/adapters/codex/native/safe-write/src/safe_write.cc +2032 -0
- package/adapters/codex/schemas/.render-manifest.json +37 -0
- package/adapters/codex/schemas/approval-record.schema.json +115 -0
- package/adapters/codex/schemas/artifact-lock.schema.json +111 -0
- package/adapters/codex/schemas/artifact-plan.schema.json +52 -0
- package/adapters/codex/schemas/artifact-policy.schema.json +76 -0
- package/adapters/codex/schemas/evidence-event.schema.json +89 -0
- package/adapters/codex/schemas/release-plan.schema.json +860 -0
- package/adapters/codex/schemas/release-project.schema.json +736 -0
- package/adapters/codex/schemas/release-run.schema.json +342 -0
- package/adapters/codex/skills/release-assess/SKILL.md +12 -6
- package/adapters/codex/skills/release-help/SKILL.md +18 -18
- package/adapters/codex/skills/release-prepare/SKILL.md +13 -6
- package/adapters/codex/skills/release-publish/SKILL.md +8 -4
- package/adapters/codex/skills/release-reconcile/SKILL.md +12 -5
- package/adapters/codex/skills/release-setup/SKILL.md +71 -80
- package/adapters/codex/skills/release-verify/SKILL.md +11 -7
- package/bin/release-skill-cli.mjs +807 -0
- package/bin/release-skill.bundle.mjs +79284 -0
- package/bin/release-skill.mjs +23 -788
- package/package.json +6 -2
- package/references/02-project-config.md +2 -2
- package/schemas/release-plan.schema.json +7 -1
- package/schemas/release-project.schema.json +8 -0
- package/scripts/build-bundle.mjs +133 -0
- package/skills/release-assess/SKILL.md +5 -6
- package/skills/release-help/SKILL.md +11 -18
- package/skills/release-prepare/SKILL.md +6 -6
- package/skills/release-publish/SKILL.md +1 -4
- package/skills/release-reconcile/SKILL.md +5 -5
- package/skills/release-setup/SKILL.md +64 -80
- package/skills/release-verify/SKILL.md +4 -7
- package/skills-src/release-assess/SKILL.md +5 -6
- package/skills-src/release-help/SKILL.md +11 -18
- package/skills-src/release-prepare/SKILL.md +6 -6
- package/skills-src/release-publish/SKILL.md +1 -4
- package/skills-src/release-reconcile/SKILL.md +5 -5
- package/skills-src/release-setup/SKILL.md +64 -80
- package/skills-src/release-verify/SKILL.md +4 -7
- package/src/adapters/npm.mjs +5 -13
- package/src/adapters/plugin-marketplace.mjs +67 -31
- package/src/artifacts/policy.mjs +4 -7
- package/src/artifacts/safe-fs-backend-internal.mjs +69 -21
- package/src/commands/prepare.mjs +22 -1
- package/src/commands/publish.mjs +1 -0
- package/src/commands/setup.mjs +667 -28
- package/src/core/approval.mjs +4 -6
- package/src/core/config.mjs +8 -8
- package/src/core/pkg-root.mjs +22 -0
- package/src/core/plan.mjs +9 -4
- package/src/core/run.mjs +4 -4
- package/src/core/trusted-resource.mjs +96 -0
- package/src/docs/version-gate.mjs +164 -0
- package/src/producers/build-adapters.mjs +512 -55
- package/src/snapshot/frozen.mjs +133 -3
- package/src/snapshot/public-map.mjs +7 -4
- package/src/snapshot/scan.mjs +2 -1
package/src/commands/setup.mjs
CHANGED
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
realpath,
|
|
18
18
|
} from 'node:fs/promises';
|
|
19
19
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
20
|
-
import { fileURLToPath } from 'node:url';
|
|
21
20
|
import YAML from 'yaml';
|
|
22
21
|
import Ajv from 'ajv';
|
|
23
22
|
import addFormats from 'ajv-formats';
|
|
@@ -30,6 +29,7 @@ import {
|
|
|
30
29
|
ReleaseError,
|
|
31
30
|
SETUP_DIGEST_MISMATCH,
|
|
32
31
|
} from '../core/errors.mjs';
|
|
32
|
+
import { readTrustedPackageResource } from '../core/trusted-resource.mjs';
|
|
33
33
|
|
|
34
34
|
const execFile = promisify(execFileCb);
|
|
35
35
|
const SKIP_DIRS = new Set([
|
|
@@ -39,8 +39,9 @@ const SKIP_DIRS = new Set([
|
|
|
39
39
|
'runs', 'test', 'tests', 'test-fixtures', 'fixtures', 'examples',
|
|
40
40
|
]);
|
|
41
41
|
const MAX_JSON_BYTES = 1024 * 1024;
|
|
42
|
-
const
|
|
43
|
-
|
|
42
|
+
const schema = JSON.parse((await readTrustedPackageResource(
|
|
43
|
+
'schemas/release-project.schema.json',
|
|
44
|
+
)).toString('utf8'));
|
|
44
45
|
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
45
46
|
addFormats(ajv);
|
|
46
47
|
const validateProjectConfig = ajv.compile(schema);
|
|
@@ -66,7 +67,7 @@ async function readJsonBounded(path, label) {
|
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
async function walkDiscoveryFiles(root, maxDepth =
|
|
70
|
+
async function walkDiscoveryFiles(root, maxDepth = 8) {
|
|
70
71
|
const found = [];
|
|
71
72
|
async function walk(directory, depth) {
|
|
72
73
|
if (depth > maxDepth) return;
|
|
@@ -81,6 +82,7 @@ async function walkDiscoveryFiles(root, maxDepth = 5) {
|
|
|
81
82
|
child.isFile() &&
|
|
82
83
|
(child.name === 'package.json' ||
|
|
83
84
|
child.name === 'public-release.json' ||
|
|
85
|
+
child.name === 'SKILL.md' ||
|
|
84
86
|
/^README(?:\.|$)/i.test(child.name) ||
|
|
85
87
|
/^LICENSE(?:\.|$)/i.test(child.name) ||
|
|
86
88
|
/^CHANGELOG(?:\.|$)/i.test(child.name) ||
|
|
@@ -213,15 +215,73 @@ function normalizeLegacyCommand(value) {
|
|
|
213
215
|
}
|
|
214
216
|
|
|
215
217
|
function classifyScript(name, command, unitId, distributionTypes) {
|
|
216
|
-
const
|
|
217
|
-
const
|
|
218
|
-
const
|
|
219
|
-
const
|
|
220
|
-
const
|
|
218
|
+
const normalizedName = name.toLowerCase();
|
|
219
|
+
const inspectedArgv = normalizeLegacyCommand(command);
|
|
220
|
+
const argv = inspectedArgv?.map((token) => token.toLowerCase()) ?? [];
|
|
221
|
+
const executable = basename(argv[0] ?? '');
|
|
222
|
+
const subcommand = argv[1] ?? '';
|
|
223
|
+
|
|
224
|
+
// Script names only express purpose/cost. Network and interactive behavior
|
|
225
|
+
// is derived from parsed argv so repository names such as "release-notes"
|
|
226
|
+
// and paths containing "development" cannot trigger false positives.
|
|
227
|
+
const isSmoke = /smoke/.test(normalizedName);
|
|
228
|
+
const llmLikely = /(?:^|[:_-])llm(?:$|[:_-])/.test(normalizedName) ||
|
|
229
|
+
argv.some((token) => /(?:^|[-_/])(?:llm|claude|openai)(?:[-_.\/]|$)/.test(token));
|
|
230
|
+
const highCost = /(?:^|[:_-])(integration|e2e|browser|real|self-iteration)(?:$|[:_-])/.test(normalizedName) || llmLikely;
|
|
231
|
+
const packagePublish = ['npm', 'pnpm', 'yarn'].includes(executable) && subcommand === 'publish';
|
|
232
|
+
const githubRelease = executable === 'gh' && subcommand === 'release';
|
|
233
|
+
const networkLikely = packagePublish || githubRelease || ['curl', 'wget', 'npx'].includes(executable) ||
|
|
234
|
+
(executable === 'git' && ['push', 'fetch', 'pull'].includes(subcommand)) || llmLikely;
|
|
235
|
+
const interactive = /(?:^|[:_-])(watch|dev|serve)(?:$|[:_-])/.test(normalizedName) ||
|
|
236
|
+
argv.some((token) => token === '--watch' || token.startsWith('--watch=')) ||
|
|
237
|
+
['nodemon', 'vite'].includes(executable) ||
|
|
238
|
+
subcommand === 'serve' || (executable === 'next' && subcommand === 'dev');
|
|
239
|
+
const mayWrite = /(?:^|[:_-])(build|generate|update|fix|format|codegen)(?:$|[:_-])/.test(normalizedName) ||
|
|
240
|
+
argv.includes('--fix') || packagePublish || githubRelease;
|
|
221
241
|
const distribution = distributionTypes.length === 1 ? distributionTypes[0] : null;
|
|
242
|
+
const consumerContextUnproven = isSmoke;
|
|
243
|
+
|
|
244
|
+
// Indirect execution: script interpreters with local script paths, or
|
|
245
|
+
// package manager run/test commands. Their actual behavior cannot be
|
|
246
|
+
// proven from argv alone, so they must fail closed.
|
|
247
|
+
// For node/python/python3/bash/sh: detect when an argument looks like a
|
|
248
|
+
// script path (contains / or .) or when node is invoked as a test runner
|
|
249
|
+
// (--test flag). Exclude pure flags (starting with -) for non-node interpreters.
|
|
250
|
+
// A package script is an indirection boundary regardless of its first argv.
|
|
251
|
+
// It may execute a relative shebang file, load plugins, source another file,
|
|
252
|
+
// or delegate through an unrecognised interpreter. Discovery therefore never
|
|
253
|
+
// proves side effects; only explicit human selection may register it as a gate.
|
|
254
|
+
const sideEffectsUnproven = inspectedArgv !== null &&
|
|
255
|
+
!networkLikely && !interactive && !highCost && !mayWrite;
|
|
256
|
+
|
|
257
|
+
const eligibleForRecommendation =
|
|
258
|
+
inspectedArgv !== null &&
|
|
259
|
+
!networkLikely &&
|
|
260
|
+
!interactive &&
|
|
261
|
+
!highCost &&
|
|
262
|
+
!mayWrite &&
|
|
263
|
+
!consumerContextUnproven &&
|
|
264
|
+
!sideEffectsUnproven;
|
|
265
|
+
const ineligibilityReason = networkLikely
|
|
266
|
+
? 'NETWORK_LIKELY'
|
|
267
|
+
: interactive
|
|
268
|
+
? 'INTERACTIVE'
|
|
269
|
+
: highCost
|
|
270
|
+
? 'HIGH_COST'
|
|
271
|
+
: mayWrite
|
|
272
|
+
? 'MAY_WRITE_FILES'
|
|
273
|
+
: inspectedArgv === null
|
|
274
|
+
? 'UNPARSEABLE_COMMAND'
|
|
275
|
+
: sideEffectsUnproven
|
|
276
|
+
? 'SIDE_EFFECTS_UNPROVEN'
|
|
277
|
+
: consumerContextUnproven
|
|
278
|
+
? 'CONSUMER_CONTEXT_UNPROVEN'
|
|
279
|
+
: null;
|
|
280
|
+
|
|
222
281
|
return {
|
|
223
282
|
id: `${unitId}-script-${name.toLowerCase().replace(/[^a-z0-9._-]+/g, '-')}`,
|
|
224
283
|
script: name,
|
|
284
|
+
inspectedArgv,
|
|
225
285
|
command: ['npm', 'run', name],
|
|
226
286
|
recommendedPhase: isSmoke ? 'consumer-verify' : 'snapshot-verify',
|
|
227
287
|
scope: {
|
|
@@ -233,12 +293,15 @@ function classifyScript(name, command, unitId, distributionTypes) {
|
|
|
233
293
|
? { distributionCandidates: [...distributionTypes] }
|
|
234
294
|
: {}
|
|
235
295
|
),
|
|
236
|
-
cost: highCost ? 'high' : /test|smoke/.test(
|
|
296
|
+
cost: highCost ? 'high' : /test|smoke/.test(normalizedName) ? 'medium' : 'low',
|
|
237
297
|
sideEffects: {
|
|
238
298
|
mayWriteFiles: mayWrite,
|
|
239
299
|
networkLikely,
|
|
300
|
+
interactive,
|
|
240
301
|
unsandboxed: true,
|
|
241
302
|
},
|
|
303
|
+
eligibleForRecommendation,
|
|
304
|
+
...(ineligibilityReason ? { ineligibilityReason } : {}),
|
|
242
305
|
reason: isSmoke
|
|
243
306
|
? '脚本名称表明它可能验证安装后的实际使用;必须人工确认后才能注册。'
|
|
244
307
|
: '项目已声明质量脚本,可在冻结快照副本上复用;不会自动注册。',
|
|
@@ -272,6 +335,86 @@ async function discoverGit(root) {
|
|
|
272
335
|
head: await run(['rev-parse', 'HEAD']) || null,
|
|
273
336
|
tags: (await run(['tag', '--list'])).split('\n').filter(Boolean).sort(),
|
|
274
337
|
remotes,
|
|
338
|
+
trackedFiles: (await run(['ls-files'])).split('\n').filter(Boolean).sort(),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Discover Git information for a release unit's source directory.
|
|
344
|
+
*
|
|
345
|
+
* Returns a JSON-serializable evidence object per unit including git root,
|
|
346
|
+
* independence status, remotes, branch, HEAD, tags, and tracked files within
|
|
347
|
+
* the unit directory. For independent sub-repos, branch/head/tags come from
|
|
348
|
+
* the sub-repo; for shared-repo units they come from the parent.
|
|
349
|
+
*
|
|
350
|
+
* @param {string} unitAbsDir - Absolute path to the unit source directory.
|
|
351
|
+
* @param {string} parentRoot - Absolute path to the parent workspace root.
|
|
352
|
+
* @param {string} unitRelDir - Relative directory of the unit (for tracked files filtering).
|
|
353
|
+
* @returns {Promise<object>} JSON-serializable per-unit Git evidence.
|
|
354
|
+
*/
|
|
355
|
+
async function discoverUnitGit(unitAbsDir, parentRoot) {
|
|
356
|
+
const run = async (cwd, args) => {
|
|
357
|
+
try {
|
|
358
|
+
const { stdout } = await execFile('git', args, { cwd, shell: false, encoding: 'utf8', timeout: 5000 });
|
|
359
|
+
return stdout.trim();
|
|
360
|
+
} catch {
|
|
361
|
+
return '';
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
// Check if this unit's source dir has its own Git root
|
|
365
|
+
const unitGitRoot = await run(unitAbsDir, ['rev-parse', '--show-toplevel']);
|
|
366
|
+
if (!unitGitRoot) {
|
|
367
|
+
return { gitRoot: null, ownRepo: false, ownRemotes: [], branch: null, head: null, tags: [], trackedFiles: [] };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const parentGitRoot = await run(parentRoot, ['rev-parse', '--show-toplevel']);
|
|
371
|
+
const isIndependent = unitGitRoot !== parentGitRoot;
|
|
372
|
+
|
|
373
|
+
// Run from the unit directory. Git selects the correct enclosing repo and
|
|
374
|
+
// `ls-files -- .` then returns paths relative to the unit, for both nested
|
|
375
|
+
// independent repositories and monorepo subdirectories.
|
|
376
|
+
const branch = await run(unitAbsDir, ['branch', '--show-current']) || null;
|
|
377
|
+
const head = await run(unitAbsDir, ['rev-parse', 'HEAD']) || null;
|
|
378
|
+
const tags = (await run(unitAbsDir, ['tag', '--list'])).split('\n').filter(Boolean).sort();
|
|
379
|
+
|
|
380
|
+
// Discover remotes from the effective git directory
|
|
381
|
+
const remoteLines = (await run(unitAbsDir, ['remote', '-v'])).split('\n').filter(Boolean);
|
|
382
|
+
const ownRemotes = [];
|
|
383
|
+
const seen = new Set();
|
|
384
|
+
for (const line of remoteLines) {
|
|
385
|
+
const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/);
|
|
386
|
+
if (!match) continue;
|
|
387
|
+
const key = `${match[1]}\0${match[2]}`;
|
|
388
|
+
if (seen.has(key)) continue;
|
|
389
|
+
seen.add(key);
|
|
390
|
+
ownRemotes.push({ name: match[1], url: match[2], repo: parseGithubRepo(match[2]) });
|
|
391
|
+
}
|
|
392
|
+
ownRemotes.sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b)));
|
|
393
|
+
|
|
394
|
+
// Tracked files within the unit directory
|
|
395
|
+
const lsOutput = await run(unitAbsDir, ['ls-files', '--', '.']);
|
|
396
|
+
const trackedFiles = lsOutput ? lsOutput.split('\n').filter(Boolean).sort() : [];
|
|
397
|
+
const upstream = await run(unitAbsDir, ['rev-parse', '--abbrev-ref', '@{upstream}']) || null;
|
|
398
|
+
const aheadBehind = upstream
|
|
399
|
+
? (await run(unitAbsDir, ['rev-list', '--left-right', '--count', `HEAD...${upstream}`]))
|
|
400
|
+
.split(/\s+/).map(Number)
|
|
401
|
+
: [];
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
gitRoot: safeRelative(parentRoot, unitGitRoot),
|
|
405
|
+
ownRepo: isIndependent,
|
|
406
|
+
ownRemotes,
|
|
407
|
+
branch,
|
|
408
|
+
head,
|
|
409
|
+
tags,
|
|
410
|
+
trackedFiles,
|
|
411
|
+
remoteTracking: {
|
|
412
|
+
upstream,
|
|
413
|
+
ahead: Number.isFinite(aheadBehind[0]) ? aheadBehind[0] : null,
|
|
414
|
+
behind: Number.isFinite(aheadBehind[1]) ? aheadBehind[1] : null,
|
|
415
|
+
provenance: 'local-git-observation',
|
|
416
|
+
networkFreshness: 'not-checked',
|
|
417
|
+
},
|
|
275
418
|
};
|
|
276
419
|
}
|
|
277
420
|
|
|
@@ -328,12 +471,181 @@ async function discoverFacts(root) {
|
|
|
328
471
|
legacyReleaseConfigs.push(summarizeLegacyReleaseConfig(value, safeRelative(root, path)));
|
|
329
472
|
}
|
|
330
473
|
|
|
331
|
-
|
|
474
|
+
const git = await discoverGit(root);
|
|
475
|
+
const skills = files
|
|
476
|
+
.filter((path) => basename(path) === 'SKILL.md')
|
|
477
|
+
.map((path) => {
|
|
478
|
+
const relPath = safeRelative(root, path);
|
|
479
|
+
const segments = relPath.split('/');
|
|
480
|
+
const skillIndex = segments.lastIndexOf('skills');
|
|
481
|
+
return {
|
|
482
|
+
path: relPath,
|
|
483
|
+
name: skillIndex >= 0 ? segments[skillIndex + 1] ?? null : null,
|
|
484
|
+
host: relPath.includes('/adapters/claude/') ? 'claude'
|
|
485
|
+
: relPath.includes('/adapters/codex/') ? 'codex'
|
|
486
|
+
: 'shared',
|
|
487
|
+
};
|
|
488
|
+
})
|
|
489
|
+
.filter((item) => item.name)
|
|
490
|
+
.sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b)));
|
|
491
|
+
|
|
492
|
+
// Per-unit Git discovery: detect independent sub-repos for each package
|
|
493
|
+
const unitGit = {};
|
|
494
|
+
for (const pkg of packages) {
|
|
495
|
+
const unitAbsDir = resolve(root, pkg.directory);
|
|
496
|
+
unitGit[pkg.directory] = await discoverUnitGit(unitAbsDir, root);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return { git, packages, manifests, skills, legacyReleaseConfigs, fileDigests, unitGit };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Build publicFileMappingCandidates for a unit, merging from all sources.
|
|
504
|
+
*
|
|
505
|
+
* Each mapping is { from, to, mode: 'preserve', sources: string[] }.
|
|
506
|
+
* When multiple sources map to the same `to`, all sources are merged into a
|
|
507
|
+
* single entry. If two different `from` paths map to the same `to`, that
|
|
508
|
+
* is a conflict and must be reported.
|
|
509
|
+
*/
|
|
510
|
+
function packagePatternMatches(pattern, relativePath) {
|
|
511
|
+
const normalized = pattern.replace(/^\.\//, '').replace(/\/$/, '');
|
|
512
|
+
if (!normalized) return false;
|
|
513
|
+
if (!/[?*]/.test(normalized)) {
|
|
514
|
+
return relativePath === normalized || relativePath.startsWith(`${normalized}/`);
|
|
515
|
+
}
|
|
516
|
+
const escaped = normalized.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
517
|
+
const regexSource = escaped
|
|
518
|
+
.replace(/\*\*/g, '\u0000')
|
|
519
|
+
.replace(/\*/g, '[^/]*')
|
|
520
|
+
.replace(/\?/g, '[^/]')
|
|
521
|
+
.replace(/\u0000/g, '.*');
|
|
522
|
+
return new RegExp(`^${regexSource}(?:/.*)?$`).test(relativePath);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function buildPublicFileMappingCandidates(pkg, matchingLegacyUnits, manifestOwners, facts, knownFiles) {
|
|
526
|
+
const unitDir = pkg.directory;
|
|
527
|
+
const prefix = unitDir === '.' ? '' : `${unitDir}/`;
|
|
528
|
+
const toFromMap = new Map();
|
|
529
|
+
const unitTrackedFiles = facts.unitGit?.[unitDir]?.trackedFiles ?? [];
|
|
530
|
+
const availableFiles = new Set([
|
|
531
|
+
...knownFiles,
|
|
532
|
+
...(facts.git.trackedFiles ?? []),
|
|
533
|
+
...unitTrackedFiles.map((path) => `${prefix}${path}`),
|
|
534
|
+
]);
|
|
535
|
+
|
|
536
|
+
function addMapping(from, to, source, priority = 1) {
|
|
537
|
+
if (!availableFiles.has(from)) return;
|
|
538
|
+
const existing = toFromMap.get(to);
|
|
539
|
+
if (existing) {
|
|
540
|
+
if (existing.from === from) {
|
|
541
|
+
existing.sources.add(source);
|
|
542
|
+
} else if (priority > existing.priority) {
|
|
543
|
+
toFromMap.set(to, {
|
|
544
|
+
from,
|
|
545
|
+
priority,
|
|
546
|
+
sources: new Set([source, ...existing.sources]),
|
|
547
|
+
});
|
|
548
|
+
} else if (priority === existing.priority) {
|
|
549
|
+
existing.conflictingFrom = from;
|
|
550
|
+
existing.sources.add(source);
|
|
551
|
+
} else {
|
|
552
|
+
existing.sources.add(`${source}:superseded`);
|
|
553
|
+
}
|
|
554
|
+
} else {
|
|
555
|
+
toFromMap.set(to, { from, priority, sources: new Set([source]) });
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Explicit legacy sources are authoritative over generic package inference.
|
|
560
|
+
for (const config of facts.legacyReleaseConfigs) {
|
|
561
|
+
for (const shared of config.sharedFileCandidates) {
|
|
562
|
+
addMapping(shared.source, shared.target, 'legacy-shared-file', 3);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
for (const legacy of matchingLegacyUnits) {
|
|
566
|
+
if (legacy.docsSource) {
|
|
567
|
+
const config = facts.legacyReleaseConfigs.find((item) => item.releaseUnits.includes(legacy));
|
|
568
|
+
for (const name of config?.docFileCandidates ?? []) {
|
|
569
|
+
addMapping(`${legacy.docsSource}/${name}`, name, 'legacy-doc-source', 3);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
addMapping(pkg.path, 'package.json', 'package-manifest', 2);
|
|
575
|
+
|
|
576
|
+
const STANDARD_NAMES = [
|
|
577
|
+
'README.md', 'README.zh-CN.md', 'LICENSE', 'NOTICE', 'CHANGELOG.md',
|
|
578
|
+
'INSTALL.md', 'CONTRIBUTING.md', 'CODE_OF_CONDUCT.md', 'SECURITY.md',
|
|
579
|
+
];
|
|
580
|
+
for (const name of STANDARD_NAMES) {
|
|
581
|
+
addMapping(`${prefix}${name}`, name, 'standard-human-file', 1);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
for (const manifest of facts.manifests) {
|
|
585
|
+
if (manifestOwners.get(manifest.path) === pkg.path) {
|
|
586
|
+
const to = manifest.path.startsWith(prefix) ? manifest.path.slice(prefix.length) : manifest.path;
|
|
587
|
+
addMapping(manifest.path, to, 'plugin-manifest', 2);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
for (const legacy of matchingLegacyUnits) {
|
|
592
|
+
for (const reqPath of legacy.requiredPathCandidates) {
|
|
593
|
+
addMapping(`${prefix}${reqPath}`, reqPath, 'legacy-required-path', 2);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
for (const pattern of pkg.files) {
|
|
598
|
+
for (const tracked of unitTrackedFiles) {
|
|
599
|
+
if (packagePatternMatches(pattern, tracked)) {
|
|
600
|
+
addMapping(`${prefix}${tracked}`, tracked, 'package-files', 1);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const mappings = [];
|
|
606
|
+
const conflicts = [];
|
|
607
|
+
for (const [to, entry] of toFromMap) {
|
|
608
|
+
if (entry.conflictingFrom) {
|
|
609
|
+
conflicts.push({
|
|
610
|
+
to,
|
|
611
|
+
from: entry.from,
|
|
612
|
+
conflictingFrom: entry.conflictingFrom,
|
|
613
|
+
sources: [...entry.sources].sort(),
|
|
614
|
+
});
|
|
615
|
+
} else {
|
|
616
|
+
mappings.push({
|
|
617
|
+
from: entry.from,
|
|
618
|
+
to,
|
|
619
|
+
mode: 'preserve',
|
|
620
|
+
sourceScope: unitDir === '.' || entry.from.startsWith(prefix) ? 'unit' : 'workspace',
|
|
621
|
+
sources: [...entry.sources].sort(),
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
mappings.sort((a, b) => a.to.localeCompare(b.to));
|
|
626
|
+
conflicts.sort((a, b) => a.to.localeCompare(b.to));
|
|
627
|
+
return { mappings, conflicts };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function entrySkillCandidatesForUnit(facts, pkg, id) {
|
|
631
|
+
const prefix = pkg.directory === '.' ? '' : `${pkg.directory}/`;
|
|
632
|
+
const names = [...new Set((facts.skills ?? [])
|
|
633
|
+
.filter((skill) => skill.path.startsWith(prefix))
|
|
634
|
+
.map((skill) => skill.name))];
|
|
635
|
+
const priority = (name) => {
|
|
636
|
+
if (/(?:^|-)help$/.test(name)) return 0;
|
|
637
|
+
if (/(?:^|-)initial$/.test(name)) return 1;
|
|
638
|
+
if (/(?:^|-)setup$/.test(name)) return 2;
|
|
639
|
+
if (name === id) return 3;
|
|
640
|
+
return 4;
|
|
641
|
+
};
|
|
642
|
+
return names.sort((a, b) => priority(a) - priority(b) || a.localeCompare(b));
|
|
332
643
|
}
|
|
333
644
|
|
|
334
645
|
function buildCandidates(facts) {
|
|
335
646
|
const gitRepos = facts.git.remotes.map((remote) => remote.repo).filter(Boolean);
|
|
336
647
|
const uniqueGitRepos = [...new Set(gitRepos)];
|
|
648
|
+
const unitGit = facts.unitGit ?? {};
|
|
337
649
|
const units = [];
|
|
338
650
|
const gates = [];
|
|
339
651
|
const ids = new Set();
|
|
@@ -376,28 +688,76 @@ function buildCandidates(facts) {
|
|
|
376
688
|
const npmExplicitlyForbidden = matchingLegacyUnits.some((unit) => (
|
|
377
689
|
unit.npmPackageDeclared && unit.npmPackage === null
|
|
378
690
|
));
|
|
691
|
+
// npm channel rule: only suppress npm when the legacy config explicitly
|
|
692
|
+
// sets npmPackage: null. Field absence must not override package.json
|
|
693
|
+
// metadata that proves the package is publishable.
|
|
379
694
|
if (
|
|
380
695
|
!pkg.private &&
|
|
381
696
|
pkg.name &&
|
|
382
|
-
!npmExplicitlyForbidden
|
|
383
|
-
(!legacyChannelsAreAuthoritative || npmExplicitlyDeclared)
|
|
697
|
+
!npmExplicitlyForbidden
|
|
384
698
|
) distributions.push('npm');
|
|
385
699
|
if (pluginHosts.includes('claude')) distributions.push('claude-plugin');
|
|
386
700
|
if (pluginHosts.includes('codex')) distributions.push('codex-plugin');
|
|
387
701
|
if (pkg.private && matchingLegacyUnits.length === 0 && facts.legacyReleaseConfigs.length > 0) continue;
|
|
388
702
|
if (pkg.private && distributions.length === 0) continue;
|
|
703
|
+
|
|
704
|
+
// Per-unit Git: if this unit's source directory is inside a separate Git
|
|
705
|
+
// repo, use that repo's remotes instead of the parent workspace remotes.
|
|
706
|
+
const unitGitEntry = unitGit[pkg.directory];
|
|
707
|
+
// The root package's Git remote is unit-level evidence even though its Git
|
|
708
|
+
// root is also the workspace root. Nested shared-repo packages still use
|
|
709
|
+
// the parent remote only as a fallback.
|
|
710
|
+
const unitOwnRemotes = ((pkg.directory === '.' || unitGitEntry?.ownRepo)
|
|
711
|
+
? unitGitEntry?.ownRemotes ?? []
|
|
712
|
+
: [])
|
|
713
|
+
.map((r) => r.repo)
|
|
714
|
+
.filter(Boolean);
|
|
715
|
+
// Authority-priority collapsing: package.json repository and legacy
|
|
716
|
+
// publicRepo are authoritative. Parent workspace remotes are only a
|
|
717
|
+
// fallback when no unit-level repo evidence exists.
|
|
718
|
+
const legacyRepos = matchingLegacyUnits.map((unit) => unit.publicRepo).filter(Boolean);
|
|
719
|
+
const packageRepos = pkg.repository ? [pkg.repository] : [];
|
|
720
|
+
// Collect all non-fallback authority sources for conflict detection.
|
|
721
|
+
// Each source is { source: string, repos: string[] }.
|
|
722
|
+
const authoritySources = [];
|
|
723
|
+
if (legacyRepos.length > 0) authoritySources.push({ source: 'legacy-publicRepo', repos: [...new Set(legacyRepos)] });
|
|
724
|
+
if (packageRepos.length > 0) authoritySources.push({ source: 'package.json-repository', repos: [...new Set(packageRepos)] });
|
|
725
|
+
if (unitOwnRemotes.length > 0) authoritySources.push({ source: 'git-remote', repos: [...new Set(unitOwnRemotes)] });
|
|
726
|
+
const unitLevelRepos = legacyRepos.length > 0
|
|
727
|
+
? legacyRepos
|
|
728
|
+
: packageRepos.length > 0
|
|
729
|
+
? packageRepos
|
|
730
|
+
: unitOwnRemotes;
|
|
731
|
+
const hasUnitLevelRepo = unitLevelRepos.length > 0;
|
|
732
|
+
// Only include parent workspace Git repos when the unit has no repo
|
|
733
|
+
// candidates from package.json, legacy config, or independent remotes.
|
|
734
|
+
const fallbackGitRepos = hasUnitLevelRepo ? [] : uniqueGitRepos;
|
|
735
|
+
// Detect authority conflict: when two or more non-fallback sources
|
|
736
|
+
// give different repos, all unique candidates are preserved and a
|
|
737
|
+
// PUBLIC_REPO_AUTHORITY_CONFLICT is recorded.
|
|
738
|
+
const allAuthorityRepos = [...new Set(authoritySources.flatMap((s) => s.repos))];
|
|
739
|
+
const hasAuthorityConflict = authoritySources.length >= 2 && allAuthorityRepos.length >= 2;
|
|
389
740
|
const repositoryCandidates = [...new Set([
|
|
390
|
-
|
|
391
|
-
...
|
|
392
|
-
...uniqueGitRepos,
|
|
741
|
+
...allAuthorityRepos,
|
|
742
|
+
...fallbackGitRepos,
|
|
393
743
|
].filter(Boolean))];
|
|
394
744
|
const legacyTagTemplates = matchingLegacyUnits
|
|
395
745
|
.map((unit) => unit.tagPrefix ? `${unit.tagPrefix}{version}` : null)
|
|
396
746
|
.filter(Boolean);
|
|
397
747
|
const branchCandidates = [...new Set([
|
|
398
748
|
...legacyDefaultBranches,
|
|
399
|
-
|
|
749
|
+
unitGitEntry?.branch,
|
|
400
750
|
].filter(Boolean))];
|
|
751
|
+
const unitTags = unitGitEntry?.tags ?? [];
|
|
752
|
+
const mappingResult = buildPublicFileMappingCandidates(
|
|
753
|
+
pkg, matchingLegacyUnits, manifestOwners, facts, knownFiles,
|
|
754
|
+
);
|
|
755
|
+
const requiredPublicFileCandidates = [...new Set([
|
|
756
|
+
...matchingLegacyUnits.flatMap((unit) => unit.requiredPathCandidates),
|
|
757
|
+
...mappingResult.mappings
|
|
758
|
+
.map((mapping) => mapping.to)
|
|
759
|
+
.filter((path) => /^(?:package\.json|README(?:\.|$)|LICENSE(?:\.|$))/i.test(path)),
|
|
760
|
+
])].filter((path) => mappingResult.mappings.some((mapping) => mapping.to === path)).sort();
|
|
401
761
|
units.push({
|
|
402
762
|
id,
|
|
403
763
|
source: pkg.directory,
|
|
@@ -407,8 +767,8 @@ function buildCandidates(facts) {
|
|
|
407
767
|
distributionCandidates: distributions,
|
|
408
768
|
tagTemplateCandidates: [...new Set([
|
|
409
769
|
...legacyTagTemplates,
|
|
410
|
-
...(
|
|
411
|
-
...(
|
|
770
|
+
...(unitTags.some((tag) => pkg.version && tag === `v${pkg.version}`) ? ['v{version}'] : []),
|
|
771
|
+
...(unitTags.some((tag) => pkg.version && tag === `${id}-v${pkg.version}`)
|
|
412
772
|
? [`${id}-v{version}`]
|
|
413
773
|
: []),
|
|
414
774
|
])],
|
|
@@ -418,7 +778,7 @@ function buildCandidates(facts) {
|
|
|
418
778
|
: [],
|
|
419
779
|
previousPublicBaselineStatus: repositoryCandidates.length === 0
|
|
420
780
|
? 'CHANNEL_MISSING'
|
|
421
|
-
:
|
|
781
|
+
: unitTags.length > 0
|
|
422
782
|
? 'BOUND_REQUIRES_ONLINE_OBSERVATION'
|
|
423
783
|
: 'FIRST_RELEASE_OR_BOUND_REQUIRES_HUMAN_DECISION',
|
|
424
784
|
publicFileCandidates: [
|
|
@@ -432,6 +792,18 @@ function buildCandidates(facts) {
|
|
|
432
792
|
].filter((value, index, array) => array.indexOf(value) === index && knownFiles.has(value)).sort(),
|
|
433
793
|
legacyPublicFileHints: matchingLegacyUnits.flatMap((unit) => unit.requiredPathCandidates).sort(),
|
|
434
794
|
packageFilePatternCandidates: [...pkg.files],
|
|
795
|
+
publicFileMappingCandidates: mappingResult.mappings,
|
|
796
|
+
publicFileMappingConflicts: mappingResult.conflicts,
|
|
797
|
+
requiredPublicFileCandidates,
|
|
798
|
+
entrySkillCandidates: entrySkillCandidatesForUnit(facts, pkg, id),
|
|
799
|
+
...(hasAuthorityConflict ? {
|
|
800
|
+
authorityConflict: {
|
|
801
|
+
code: 'PUBLIC_REPO_AUTHORITY_CONFLICT',
|
|
802
|
+
unit: id,
|
|
803
|
+
evidence: authoritySources.map((s) => ({ source: s.source, repos: s.repos.sort() }))
|
|
804
|
+
.sort((a, b) => a.source.localeCompare(b.source)),
|
|
805
|
+
},
|
|
806
|
+
} : {}),
|
|
435
807
|
});
|
|
436
808
|
for (const [script, command] of Object.entries(pkg.scripts)) {
|
|
437
809
|
if (/^(docs|build|test|typecheck|lint|check|validate|verify|smoke)(?:$|[:_-])/.test(script)) {
|
|
@@ -450,6 +822,10 @@ function buildCandidates(facts) {
|
|
|
450
822
|
cost: 'medium',
|
|
451
823
|
sideEffects: { mayWriteFiles: true, networkLikely: false, unsandboxed: true },
|
|
452
824
|
requiresManualCommandArray: !command,
|
|
825
|
+
// Legacy snapshot commands are never auto-recommended: their
|
|
826
|
+
// side-effect profile has not been independently verified.
|
|
827
|
+
eligibleForRecommendation: false,
|
|
828
|
+
ineligibilityReason: command === null ? 'UNPARSEABLE_COMMAND' : 'SIDE_EFFECTS_UNPROVEN',
|
|
453
829
|
reason: '旧发布配置声明了快照校验;迁移为 gate 前必须人工确认命令数组、副作用和耗时。',
|
|
454
830
|
});
|
|
455
831
|
});
|
|
@@ -469,6 +845,11 @@ function buildCandidates(facts) {
|
|
|
469
845
|
let suffix = 2;
|
|
470
846
|
while (ids.has(id)) id = `${baseId}-${suffix++}`;
|
|
471
847
|
ids.add(id);
|
|
848
|
+
const publicFileCandidates = [...knownFiles]
|
|
849
|
+
.filter((path) => pluginRoot === '.' || path.startsWith(`${pluginRoot}/`))
|
|
850
|
+
.filter((path) => /(?:README|LICENSE|CHANGELOG|plugin\.json|marketplace\.json)/i.test(path))
|
|
851
|
+
.sort();
|
|
852
|
+
const pluginPrefix = pluginRoot === '.' ? '' : `${pluginRoot}/`;
|
|
472
853
|
units.push({
|
|
473
854
|
id,
|
|
474
855
|
source: pluginRoot,
|
|
@@ -486,10 +867,20 @@ function buildCandidates(facts) {
|
|
|
486
867
|
? 'BOUND_REQUIRES_ONLINE_OBSERVATION'
|
|
487
868
|
: 'FIRST_RELEASE_OR_BOUND_REQUIRES_HUMAN_DECISION')
|
|
488
869
|
: 'CHANNEL_MISSING',
|
|
489
|
-
publicFileCandidates
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
.
|
|
870
|
+
publicFileCandidates,
|
|
871
|
+
publicFileMappingCandidates: publicFileCandidates.map((path) => ({
|
|
872
|
+
from: path,
|
|
873
|
+
to: path.startsWith(pluginPrefix) ? path.slice(pluginPrefix.length) : path,
|
|
874
|
+
mode: 'preserve',
|
|
875
|
+
sources: ['plugin-only-discovery'],
|
|
876
|
+
})),
|
|
877
|
+
publicFileMappingConflicts: [],
|
|
878
|
+
requiredPublicFileCandidates: publicFileCandidates
|
|
879
|
+
.map((path) => path.startsWith(pluginPrefix) ? path.slice(pluginPrefix.length) : path)
|
|
880
|
+
.filter((path) => /^(?:README(?:\.|$)|LICENSE(?:\.|$))/i.test(path)),
|
|
881
|
+
entrySkillCandidates: [...new Set((facts.skills ?? [])
|
|
882
|
+
.filter((skill) => skill.path.startsWith(pluginPrefix))
|
|
883
|
+
.map((skill) => skill.name))].sort(),
|
|
493
884
|
});
|
|
494
885
|
}
|
|
495
886
|
units.sort((a, b) => a.id.localeCompare(b.id));
|
|
@@ -497,6 +888,183 @@ function buildCandidates(facts) {
|
|
|
497
888
|
return { units, gates };
|
|
498
889
|
}
|
|
499
890
|
|
|
891
|
+
/**
|
|
892
|
+
* Build a complete recommendedAnswers from candidates, or null when
|
|
893
|
+
* conflicts prevent safe automatic recommendation.
|
|
894
|
+
*
|
|
895
|
+
* The recommendedAnswers is explicitly a proposal pending human confirmation.
|
|
896
|
+
* It only includes gates with eligibleForRecommendation=true.
|
|
897
|
+
*/
|
|
898
|
+
function buildRecommendedProposal(facts, candidates) {
|
|
899
|
+
const assumptions = [];
|
|
900
|
+
const legacyOwner = facts.legacyReleaseConfigs
|
|
901
|
+
.map((c) => c.owner)
|
|
902
|
+
.find(Boolean);
|
|
903
|
+
|
|
904
|
+
const units = [];
|
|
905
|
+
for (const unit of candidates.units) {
|
|
906
|
+
// Authority conflict: multiple non-fallback sources disagree on repo
|
|
907
|
+
if (unit.authorityConflict) {
|
|
908
|
+
return {
|
|
909
|
+
answers: null,
|
|
910
|
+
conflicts: [unit.authorityConflict],
|
|
911
|
+
assumptions,
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
// Require exactly one public repo candidate for auto-recommendation
|
|
915
|
+
if (unit.publicRepoCandidates.length !== 1) {
|
|
916
|
+
return {
|
|
917
|
+
answers: null,
|
|
918
|
+
conflicts: [{ code: 'PUBLIC_REPO_AMBIGUOUS', unit: unit.id, candidates: unit.publicRepoCandidates }],
|
|
919
|
+
assumptions,
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
const publicRepo = unit.publicRepoCandidates[0];
|
|
923
|
+
|
|
924
|
+
// Mapping conflicts prevent auto-recommendation
|
|
925
|
+
if ((unit.publicFileMappingConflicts ?? []).length > 0) {
|
|
926
|
+
return {
|
|
927
|
+
answers: null,
|
|
928
|
+
conflicts: [{ code: 'PUBLIC_FILE_MAPPING_CONFLICT', unit: unit.id, conflicts: unit.publicFileMappingConflicts }],
|
|
929
|
+
assumptions,
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
if ((unit.publicFileMappingCandidates ?? []).length === 0) {
|
|
933
|
+
return {
|
|
934
|
+
answers: null,
|
|
935
|
+
conflicts: [{ code: 'PUBLIC_FILE_BOUNDARY_EMPTY', unit: unit.id }],
|
|
936
|
+
assumptions,
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Infer npm publisher: prefer legacy owner, then parse from repo slug
|
|
941
|
+
const repoOwner = publicRepo.includes('/') ? publicRepo.split('/')[0] : null;
|
|
942
|
+
const npmPublisher = legacyOwner ?? repoOwner ?? null;
|
|
943
|
+
const pkg = facts.packages.find((item) => item.directory === unit.source);
|
|
944
|
+
if (unit.distributionCandidates.includes('npm') && (!pkg?.name || !npmPublisher)) {
|
|
945
|
+
return {
|
|
946
|
+
answers: null,
|
|
947
|
+
conflicts: [{ code: 'NPM_IDENTITY_INCOMPLETE', unit: unit.id }],
|
|
948
|
+
assumptions,
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
if (
|
|
952
|
+
unit.distributionCandidates.some((type) => type.endsWith('-plugin')) &&
|
|
953
|
+
!unit.entrySkillCandidates?.[0]
|
|
954
|
+
) {
|
|
955
|
+
return {
|
|
956
|
+
answers: null,
|
|
957
|
+
conflicts: [{ code: 'PLUGIN_ENTRY_SKILL_MISSING', unit: unit.id }],
|
|
958
|
+
assumptions,
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const distributions = unit.distributionCandidates.map((type) => {
|
|
963
|
+
if (type === 'npm') {
|
|
964
|
+
return {
|
|
965
|
+
type: 'npm',
|
|
966
|
+
package: pkg.name,
|
|
967
|
+
registry: pkg.publishRegistry ?? 'https://registry.npmjs.org',
|
|
968
|
+
...(npmPublisher ? { publisher: npmPublisher } : {}),
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
const entrySkill = unit.entrySkillCandidates?.[0];
|
|
972
|
+
return {
|
|
973
|
+
type,
|
|
974
|
+
plugin: unit.id,
|
|
975
|
+
marketplace: unit.id,
|
|
976
|
+
entrySkill,
|
|
977
|
+
};
|
|
978
|
+
}).filter(Boolean);
|
|
979
|
+
|
|
980
|
+
// If any distribution failed to construct, bail
|
|
981
|
+
if (distributions.length !== unit.distributionCandidates.length) return null;
|
|
982
|
+
|
|
983
|
+
const tagTemplate = unit.tagTemplateCandidates[0] ?? 'v{version}';
|
|
984
|
+
const unitPrefix = unit.source === '.' ? '' : `${unit.source}/`;
|
|
985
|
+
const versionSource = unit.packagePath?.startsWith(unitPrefix)
|
|
986
|
+
? unit.packagePath.slice(unitPrefix.length)
|
|
987
|
+
: unit.packagePath;
|
|
988
|
+
|
|
989
|
+
units.push({
|
|
990
|
+
id: unit.id,
|
|
991
|
+
source: unit.source,
|
|
992
|
+
publicRepo,
|
|
993
|
+
version: { source: versionSource, tagTemplate },
|
|
994
|
+
distributions,
|
|
995
|
+
publicFiles: unit.publicFileMappingCandidates.map((m) => ({
|
|
996
|
+
from: m.from,
|
|
997
|
+
to: m.to,
|
|
998
|
+
mode: m.mode,
|
|
999
|
+
...(m.sourceScope === 'workspace' ? { sourceScope: 'workspace' } : {}),
|
|
1000
|
+
})),
|
|
1001
|
+
requiredPublicFiles: unit.requiredPublicFileCandidates ?? [],
|
|
1002
|
+
previousPublicBaseline: { mode: 'none' },
|
|
1003
|
+
});
|
|
1004
|
+
assumptions.push({
|
|
1005
|
+
code: 'PREVIOUS_PUBLIC_BASELINE_REQUIRES_CONFIRMATION',
|
|
1006
|
+
unit: unit.id,
|
|
1007
|
+
proposedMode: 'none',
|
|
1008
|
+
observedStatus: unit.previousPublicBaselineStatus,
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const selectedGateIds = candidates.gates
|
|
1013
|
+
.filter((gate) => gate.eligibleForRecommendation)
|
|
1014
|
+
.map((gate) => gate.id);
|
|
1015
|
+
|
|
1016
|
+
const projectConfig = {
|
|
1017
|
+
apiVersion: 'release-skill/v1',
|
|
1018
|
+
kind: 'ReleaseProject',
|
|
1019
|
+
project: {
|
|
1020
|
+
name: facts.packages[0]?.name ?? 'project',
|
|
1021
|
+
defaultBranch: facts.legacyReleaseConfigs[0]?.defaultBranch ?? facts.git.branch ?? 'main',
|
|
1022
|
+
},
|
|
1023
|
+
releaseUnits: units,
|
|
1024
|
+
...(selectedGateIds.length > 0 ? {
|
|
1025
|
+
verificationGates: candidates.gates
|
|
1026
|
+
.filter((gate) => gate.eligibleForRecommendation)
|
|
1027
|
+
.map((gate) => ({
|
|
1028
|
+
id: gate.id,
|
|
1029
|
+
phase: gate.recommendedPhase,
|
|
1030
|
+
scope: gate.scope,
|
|
1031
|
+
command: gate.command,
|
|
1032
|
+
cwd: candidates.units.find((unit) => unit.id === gate.scope.unit)?.source ?? '.',
|
|
1033
|
+
timeoutMs: gate.cost === 'medium' ? 600_000 : 120_000,
|
|
1034
|
+
envAllowlist: [],
|
|
1035
|
+
})),
|
|
1036
|
+
} : {}),
|
|
1037
|
+
};
|
|
1038
|
+
|
|
1039
|
+
const forbiddenPaths = [...new Set(facts.legacyReleaseConfigs
|
|
1040
|
+
.flatMap((config) => config.forbiddenPathCandidates))].sort();
|
|
1041
|
+
const forbiddenContentPatterns = [...new Set(facts.legacyReleaseConfigs
|
|
1042
|
+
.flatMap((config) => config.forbiddenContentPatternCandidates))].sort();
|
|
1043
|
+
if (forbiddenPaths.length > 0 || forbiddenContentPatterns.length > 0) {
|
|
1044
|
+
projectConfig.policy = { forbiddenPaths, forbiddenContentPatterns };
|
|
1045
|
+
}
|
|
1046
|
+
if (!validateProjectConfig(projectConfig)) {
|
|
1047
|
+
return {
|
|
1048
|
+
answers: null,
|
|
1049
|
+
conflicts: [{
|
|
1050
|
+
code: 'RECOMMENDED_CONFIG_SCHEMA_INVALID',
|
|
1051
|
+
validationErrors: (validateProjectConfig.errors ?? []).map((error) => ({
|
|
1052
|
+
instancePath: error.instancePath,
|
|
1053
|
+
keyword: error.keyword,
|
|
1054
|
+
message: error.message,
|
|
1055
|
+
})),
|
|
1056
|
+
}],
|
|
1057
|
+
assumptions,
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
return {
|
|
1062
|
+
answers: { projectConfig, selectedGateIds },
|
|
1063
|
+
conflicts: [],
|
|
1064
|
+
assumptions,
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
|
|
500
1068
|
function buildDecisionsRequired(candidates, localOnly) {
|
|
501
1069
|
const decisions = [];
|
|
502
1070
|
if (localOnly) {
|
|
@@ -532,6 +1100,50 @@ function buildDecisionsRequired(candidates, localOnly) {
|
|
|
532
1100
|
return decisions;
|
|
533
1101
|
}
|
|
534
1102
|
|
|
1103
|
+
/**
|
|
1104
|
+
* Build a deterministic compact summary from the final report.
|
|
1105
|
+
*
|
|
1106
|
+
* Only includes fields needed for human review. Does not repeat
|
|
1107
|
+
* facts.fileDigests, full mapping arrays, or full answers.
|
|
1108
|
+
*/
|
|
1109
|
+
function buildCompactSummary(report) {
|
|
1110
|
+
return {
|
|
1111
|
+
status: report.status,
|
|
1112
|
+
setupDigest: report.setupDigest ?? null,
|
|
1113
|
+
releaseUnitCandidates: (report.releaseUnitCandidates ?? []).map((unit) => ({
|
|
1114
|
+
id: unit.id,
|
|
1115
|
+
source: unit.source,
|
|
1116
|
+
publicRepoCandidates: [...unit.publicRepoCandidates].sort(),
|
|
1117
|
+
branchCandidates: [...unit.branchCandidates].sort(),
|
|
1118
|
+
distributionCandidates: [...unit.distributionCandidates].sort(),
|
|
1119
|
+
publicFileMappingCount: (unit.publicFileMappingCandidates ?? []).length,
|
|
1120
|
+
requiredPublicFileCount: (unit.requiredPublicFileCandidates ?? []).length,
|
|
1121
|
+
})),
|
|
1122
|
+
gateCandidates: (report.gateCandidates ?? []).map((gate) => ({
|
|
1123
|
+
id: gate.id,
|
|
1124
|
+
script: gate.script ?? null,
|
|
1125
|
+
command: gate.command ?? null,
|
|
1126
|
+
eligibleForRecommendation: gate.eligibleForRecommendation,
|
|
1127
|
+
ineligibilityReason: gate.ineligibilityReason ?? null,
|
|
1128
|
+
sideEffects: gate.sideEffects ?? null,
|
|
1129
|
+
})),
|
|
1130
|
+
recommendedGateIds: [...(report.recommendedGateIds ?? [])].sort(),
|
|
1131
|
+
proposalConflicts: report.proposalConflicts ?? [],
|
|
1132
|
+
proposalAssumptions: report.proposalAssumptions ?? [],
|
|
1133
|
+
productionReadiness: report.productionReadiness ?? null,
|
|
1134
|
+
...(report.audit ? {
|
|
1135
|
+
audit: {
|
|
1136
|
+
configuredUnitIds: [...(report.audit.configuredUnitIds ?? [])].sort(),
|
|
1137
|
+
discoveredUnitIds: [...(report.audit.discoveredUnitIds ?? [])].sort(),
|
|
1138
|
+
configuredGateIds: [...(report.audit.configuredGateIds ?? [])].sort(),
|
|
1139
|
+
unconfiguredGateCandidateIds: [...(report.audit.unconfiguredGateCandidateIds ?? [])].sort(),
|
|
1140
|
+
...(report.audit.parseError ? { parseError: report.audit.parseError } : {}),
|
|
1141
|
+
validationErrorCount: (report.audit.validationErrors ?? []).length,
|
|
1142
|
+
},
|
|
1143
|
+
} : {}),
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
|
|
535
1147
|
function validateAnswers(answers, gateCandidates) {
|
|
536
1148
|
if (!answers || typeof answers !== 'object' || Array.isArray(answers)) {
|
|
537
1149
|
throw setupError(CONFIG_INVALID, 'setup answers must be a JSON object');
|
|
@@ -728,7 +1340,7 @@ export async function setupProject({ root, answersPath, write = false, confirmSe
|
|
|
728
1340
|
.map((gate) => gate.id)
|
|
729
1341
|
.filter((id) => !configuredGateIds.includes(id))
|
|
730
1342
|
.sort();
|
|
731
|
-
|
|
1343
|
+
const existingReport = {
|
|
732
1344
|
setupVersion: 1,
|
|
733
1345
|
status: 'ALREADY_CONFIGURED',
|
|
734
1346
|
configPath,
|
|
@@ -756,8 +1368,14 @@ export async function setupProject({ root, answersPath, write = false, confirmSe
|
|
|
756
1368
|
: []),
|
|
757
1369
|
],
|
|
758
1370
|
},
|
|
1371
|
+
recommendedGateIds: [],
|
|
1372
|
+
proposalConflicts: [],
|
|
1373
|
+
proposalAssumptions: [],
|
|
1374
|
+
productionReadiness: 'ASSESS_REQUIRED',
|
|
759
1375
|
next: '运行 release-skill assess 审计已有配置;需要调整时依据建议人工增量编辑。',
|
|
760
1376
|
};
|
|
1377
|
+
existingReport.compactSummary = buildCompactSummary(existingReport);
|
|
1378
|
+
return existingReport;
|
|
761
1379
|
}
|
|
762
1380
|
|
|
763
1381
|
const facts = await discoverFacts(rootReal);
|
|
@@ -778,18 +1396,33 @@ export async function setupProject({ root, answersPath, write = false, confirmSe
|
|
|
778
1396
|
projectConfig: answers?.projectConfig ?? null,
|
|
779
1397
|
};
|
|
780
1398
|
const setupDigest = sha256Hex(canonicalJson(digestAuthority));
|
|
781
|
-
const hasDiscoveredRemoteChannel =
|
|
782
|
-
|
|
1399
|
+
const hasDiscoveredRemoteChannel = candidates.units.some((unit) => (
|
|
1400
|
+
unit.publicRepoCandidates.length > 0
|
|
1401
|
+
)) || facts.packages.some((pkg) => pkg.publishRegistry);
|
|
783
1402
|
const status = answers
|
|
784
1403
|
? 'READY_TO_WRITE'
|
|
785
1404
|
: hasDiscoveredRemoteChannel
|
|
786
1405
|
? 'NEEDS_INPUT'
|
|
787
1406
|
: 'LOCAL_ONLY_DETECTED';
|
|
788
1407
|
const localOnly = status === 'LOCAL_ONLY_DETECTED';
|
|
1408
|
+
// Deterministic recommendation: gates eligible for automatic inclusion
|
|
1409
|
+
// without human review. Agents use this to build recommendedAnswers.
|
|
1410
|
+
const recommendedGateIds = candidates.gates
|
|
1411
|
+
.filter((gate) => gate.eligibleForRecommendation)
|
|
1412
|
+
.map((gate) => gate.id);
|
|
1413
|
+
// Build recommendedAnswers: a complete proposal for human review,
|
|
1414
|
+
// or null when conflicts prevent safe automatic recommendation.
|
|
1415
|
+
const recommendedProposal = answers
|
|
1416
|
+
? { answers: null, conflicts: [], assumptions: [] }
|
|
1417
|
+
: buildRecommendedProposal(facts, candidates);
|
|
789
1418
|
const report = {
|
|
790
1419
|
...digestAuthority,
|
|
791
1420
|
status,
|
|
792
1421
|
setupDigest,
|
|
1422
|
+
recommendedGateIds,
|
|
1423
|
+
recommendedAnswers: recommendedProposal.answers,
|
|
1424
|
+
proposalConflicts: recommendedProposal.conflicts,
|
|
1425
|
+
proposalAssumptions: recommendedProposal.assumptions,
|
|
793
1426
|
productionReadiness: status === 'LOCAL_ONLY_DETECTED'
|
|
794
1427
|
? 'LOCAL_ONLY'
|
|
795
1428
|
: answers
|
|
@@ -803,6 +1436,9 @@ export async function setupProject({ root, answersPath, write = false, confirmSe
|
|
|
803
1436
|
overwrite: false,
|
|
804
1437
|
},
|
|
805
1438
|
};
|
|
1439
|
+
// Derive compact summary from the final report to avoid state/digest/conflict
|
|
1440
|
+
// inconsistencies. Must not read or execute project scripts.
|
|
1441
|
+
report.compactSummary = buildCompactSummary(report);
|
|
806
1442
|
|
|
807
1443
|
if (!write) return report;
|
|
808
1444
|
if (!answers) throw setupError(CONFIG_INVALID, 'setup --write requires --answers <json>');
|
|
@@ -873,9 +1509,10 @@ export async function setupProject({ root, answersPath, write = false, confirmSe
|
|
|
873
1509
|
} finally {
|
|
874
1510
|
await lock.release();
|
|
875
1511
|
}
|
|
876
|
-
|
|
1512
|
+
const createdReport = {
|
|
877
1513
|
...report,
|
|
878
1514
|
status: 'CONFIG_CREATED',
|
|
1515
|
+
productionReadiness: 'ASSESS_REQUIRED',
|
|
879
1516
|
configPath: committedConfig.path,
|
|
880
1517
|
configSha256: committedConfig.configSha256,
|
|
881
1518
|
committedSetupDigest: committedConfig.commitAuthority.setupDigest,
|
|
@@ -883,4 +1520,6 @@ export async function setupProject({ root, answersPath, write = false, confirmSe
|
|
|
883
1520
|
committedAnswersDigest: committedConfig.commitAuthority.answersDigest,
|
|
884
1521
|
next: '运行 release-skill assess;再根据 gate 副作用决定 prepare/verify 的显式授权。',
|
|
885
1522
|
};
|
|
1523
|
+
createdReport.compactSummary = buildCompactSummary(createdReport);
|
|
1524
|
+
return createdReport;
|
|
886
1525
|
}
|