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,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* README contract evaluation for release-skill.
|
|
3
|
+
*
|
|
4
|
+
* Validates that README files in a snapshot directory contain
|
|
5
|
+
* machine-readable HTML-comment markers covering capability, command,
|
|
6
|
+
* safety, and version requirements. Also extracts executable code
|
|
7
|
+
* blocks annotated with release-skill:exec metadata.
|
|
8
|
+
*
|
|
9
|
+
* Marker formats supported:
|
|
10
|
+
* - Simple: <!-- release-skill:<name> -->
|
|
11
|
+
* - Categorized: <!-- release-skill:<category>:<name> -->
|
|
12
|
+
* Exec metadata: <!-- release-skill:exec fixture=<id> -->
|
|
13
|
+
*
|
|
14
|
+
* @module readme/contract
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFile } from 'node:fs/promises';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Marker definitions
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* All known contract markers with their category and bilingual key.
|
|
26
|
+
* Markers may appear in READMEs as:
|
|
27
|
+
* <!-- release-skill:<name> --> (simple)
|
|
28
|
+
* <!-- release-skill:<category>:<name> --> (categorized)
|
|
29
|
+
*/
|
|
30
|
+
const MARKER_DEFS = [
|
|
31
|
+
// capability
|
|
32
|
+
{ category: 'capability', name: 'safe-first-command', bilingualKey: 'safe-first-command', required: true },
|
|
33
|
+
{ category: 'capability', name: 'supported-topology', bilingualKey: 'supported-topology', required: false },
|
|
34
|
+
{ category: 'capability', name: 'unsupported-scope', bilingualKey: 'unsupported-scope', required: false },
|
|
35
|
+
{ category: 'capability', name: 'external-write-boundary', bilingualKey: 'external-write-boundary', required: true },
|
|
36
|
+
// command
|
|
37
|
+
{ category: 'command', name: 'release-help', bilingualKey: 'release-help', required: false },
|
|
38
|
+
{ category: 'command', name: 'safe-assess-command', bilingualKey: 'safe-assess-command', required: false },
|
|
39
|
+
// safety
|
|
40
|
+
{ category: 'safety', name: 'publish-authorization', bilingualKey: 'publish-authorization', required: false },
|
|
41
|
+
{ category: 'safety', name: 'reconcile-guidance', bilingualKey: 'reconcile-guidance', required: false },
|
|
42
|
+
{ category: 'safety', name: 'security', bilingualKey: 'security', required: false },
|
|
43
|
+
{ category: 'safety', name: 'troubleshooting', bilingualKey: 'troubleshooting', required: false },
|
|
44
|
+
// version
|
|
45
|
+
{ category: 'version', name: 'skill-list', bilingualKey: 'skill-list', required: false },
|
|
46
|
+
{ category: 'version', name: 'version-info', bilingualKey: 'version-info', required: false },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/** Set of required marker names. */
|
|
50
|
+
const REQUIRED_MARKER_NAMES = new Set(
|
|
51
|
+
MARKER_DEFS.filter((d) => d.required).map((d) => d.name),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Internal helpers
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Extract all marker names present in a README string.
|
|
60
|
+
*
|
|
61
|
+
* Handles both simple (release-skill:<name>) and categorized
|
|
62
|
+
* (release-skill:<category>:<name>) formats. The "exec" pseudo-marker
|
|
63
|
+
* is excluded since it serves a different purpose (executable metadata).
|
|
64
|
+
*
|
|
65
|
+
* @param {string} content
|
|
66
|
+
* @returns {Set<string>} Set of plain marker names.
|
|
67
|
+
*/
|
|
68
|
+
function extractMarkers(content) {
|
|
69
|
+
const regex = /<!--\s*release-skill:((?:[\w-]+:)*[\w-]+)\s*-->/g;
|
|
70
|
+
const found = new Set();
|
|
71
|
+
let match;
|
|
72
|
+
while ((match = regex.exec(content)) !== null) {
|
|
73
|
+
const raw = match[1];
|
|
74
|
+
// Skip exec metadata pseudo-marker
|
|
75
|
+
if (raw.startsWith('exec')) continue;
|
|
76
|
+
// The canonical name is the last colon-separated segment.
|
|
77
|
+
const parts = raw.split(':');
|
|
78
|
+
const name = parts[parts.length - 1];
|
|
79
|
+
found.add(name);
|
|
80
|
+
}
|
|
81
|
+
return found;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Extract executable commands from fenced code blocks that carry
|
|
86
|
+
* release-skill:exec metadata on the preceding line.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} content
|
|
89
|
+
* @returns {Array<{ fixture: string, language: string, commands: string[] }>}
|
|
90
|
+
*/
|
|
91
|
+
function extractExecBlocks(content) {
|
|
92
|
+
const results = [];
|
|
93
|
+
const regex = /<!--\s*release-skill:exec\s+fixture=([\w-]+)\s*-->\s*\n```(sh|bash)\n([\s\S]*?)```/g;
|
|
94
|
+
let match;
|
|
95
|
+
while ((match = regex.exec(content)) !== null) {
|
|
96
|
+
const fixture = match[1];
|
|
97
|
+
const language = match[2];
|
|
98
|
+
const blockBody = match[3];
|
|
99
|
+
const commands = blockBody
|
|
100
|
+
.split('\n')
|
|
101
|
+
.map((line) => line.trim())
|
|
102
|
+
.filter((line) => line.length > 0 && !line.startsWith('#'));
|
|
103
|
+
results.push({ fixture, language, commands });
|
|
104
|
+
}
|
|
105
|
+
return results;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Safely read a file; return null if absent.
|
|
110
|
+
* @param {string} filePath
|
|
111
|
+
* @returns {Promise<string | null>}
|
|
112
|
+
*/
|
|
113
|
+
async function safeRead(filePath) {
|
|
114
|
+
try {
|
|
115
|
+
return await readFile(filePath, 'utf8');
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resolve a single skill entry to its name.
|
|
123
|
+
*
|
|
124
|
+
* Handles both the legacy object format ({ name: string }) and the
|
|
125
|
+
* official Codex plugin manifest string-path format
|
|
126
|
+
* (e.g. "../skills-src/release-help/SKILL.md").
|
|
127
|
+
*
|
|
128
|
+
* @param {string | { name: string }} entry
|
|
129
|
+
* @returns {string | null}
|
|
130
|
+
*/
|
|
131
|
+
function resolveSkillName(entry) {
|
|
132
|
+
if (typeof entry === 'string') {
|
|
133
|
+
// String path: extract directory name as skill name.
|
|
134
|
+
// "../skills-src/release-help/SKILL.md" → "release-help"
|
|
135
|
+
const parts = entry.replace(/\\/g, '/').split('/');
|
|
136
|
+
// Walk backwards to find the first segment that looks like a skill name
|
|
137
|
+
// (skipping SKILL.md or similar file names).
|
|
138
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
139
|
+
const seg = parts[i];
|
|
140
|
+
if (seg && !seg.includes('.')) return seg;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
if (entry && typeof entry === 'object' && typeof entry.name === 'string') {
|
|
145
|
+
return entry.name;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Extract all skill names from a plugin manifest skills array.
|
|
152
|
+
* Supports both object arrays ({ name }) and string-path arrays.
|
|
153
|
+
*
|
|
154
|
+
* @param {Array<{ name: string } | string>} skills
|
|
155
|
+
* @returns {string[]}
|
|
156
|
+
*/
|
|
157
|
+
export function extractManifestSkillNames(skills) {
|
|
158
|
+
if (!Array.isArray(skills)) return [];
|
|
159
|
+
return skills.map(resolveSkillName).filter(Boolean);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Find Skill names mentioned in README content.
|
|
164
|
+
* @param {string} content
|
|
165
|
+
* @param {string[]} manifestSkillNames
|
|
166
|
+
* @returns {string[]}
|
|
167
|
+
*/
|
|
168
|
+
function findSkillNames(content, manifestSkillNames) {
|
|
169
|
+
return manifestSkillNames.filter((name) => content.includes(name));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Public API
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Evaluate README files in a snapshot directory against the release-skill
|
|
178
|
+
* contract. Requires both README.md and README.zh-CN.md to be present.
|
|
179
|
+
*
|
|
180
|
+
* @param {object} options
|
|
181
|
+
* @param {string} options.snapshotDir Path to the snapshot directory.
|
|
182
|
+
* @param {{ name?: string, entrySkill?: string, skills?: Array<{ name: string }> }} [options.pluginManifest]
|
|
183
|
+
* Optional plugin manifest for Skill name validation.
|
|
184
|
+
* @returns {Promise<ReadmeReport>}
|
|
185
|
+
*
|
|
186
|
+
* @typedef {object} ReadmeReport
|
|
187
|
+
* @property {string[]} present Required marker names found in README.md.
|
|
188
|
+
* @property {string[]} missing Required marker names absent from README.md.
|
|
189
|
+
* @property {object} bilingualMarkers Per-category arrays of { en, zh-CN } values.
|
|
190
|
+
* @property {object} firstScreen Booleans for the four first-screen questions.
|
|
191
|
+
* @property {string[]} skillNames Skill names found in README content.
|
|
192
|
+
* @property {Array<{ fixture: string, language: string, commands: string[] }>} execCommands
|
|
193
|
+
* @property {Array<{ code: string, message: string, language?: string }>} findings
|
|
194
|
+
*/
|
|
195
|
+
export async function evaluateReadme({ snapshotDir, pluginManifest }) {
|
|
196
|
+
const findings = [];
|
|
197
|
+
|
|
198
|
+
// ---- 1. Read README files ----
|
|
199
|
+
const enPath = path.join(snapshotDir, 'README.md');
|
|
200
|
+
const zhPath = path.join(snapshotDir, 'README.zh-CN.md');
|
|
201
|
+
|
|
202
|
+
const enContent = await safeRead(enPath);
|
|
203
|
+
const zhContent = await safeRead(zhPath);
|
|
204
|
+
|
|
205
|
+
if (!enContent) {
|
|
206
|
+
findings.push({
|
|
207
|
+
code: 'README_MISSING',
|
|
208
|
+
message: 'README.md not found in snapshot directory',
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (!zhContent) {
|
|
212
|
+
findings.push({
|
|
213
|
+
code: 'LANG_MISSING',
|
|
214
|
+
message: 'README.zh-CN.md not found in snapshot directory',
|
|
215
|
+
language: 'zh-CN',
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---- 2. Extract markers from each README ----
|
|
220
|
+
const enMarkers = enContent ? extractMarkers(enContent) : new Set();
|
|
221
|
+
const zhMarkers = zhContent ? extractMarkers(zhContent) : new Set();
|
|
222
|
+
|
|
223
|
+
// ---- 3. Determine present / missing for required markers ----
|
|
224
|
+
const present = [...REQUIRED_MARKER_NAMES].filter((name) => enMarkers.has(name)).sort();
|
|
225
|
+
const missing = [...REQUIRED_MARKER_NAMES].filter((name) => !enMarkers.has(name)).sort();
|
|
226
|
+
|
|
227
|
+
// ---- 4. Build bilingual markers per category ----
|
|
228
|
+
const bilingualMarkers = {};
|
|
229
|
+
for (const category of ['capability', 'command', 'safety', 'version']) {
|
|
230
|
+
bilingualMarkers[category] = [];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
for (const def of MARKER_DEFS) {
|
|
234
|
+
bilingualMarkers[def.category].push({
|
|
235
|
+
en: enMarkers.has(def.name) ? def.bilingualKey : '',
|
|
236
|
+
'zh-CN': zhMarkers.has(def.name) ? def.bilingualKey : '',
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---- 5. First-screen questions and readability checks ----
|
|
241
|
+
const firstScreen = {
|
|
242
|
+
identity: enContent ? /^#\s+.+$/m.test(enContent) : false,
|
|
243
|
+
audienceProblem: enContent
|
|
244
|
+
? enMarkers.has('unsupported-scope') ||
|
|
245
|
+
(enContent.includes('release-skill') && /who|适合|需要|problem|问题/i.test(enContent))
|
|
246
|
+
: false,
|
|
247
|
+
externalWriteBoundary: enMarkers.has('external-write-boundary'),
|
|
248
|
+
safeFirstCommand: enMarkers.has('safe-first-command'),
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Readability checks: installation, minimal example, failure diagnosis
|
|
252
|
+
const readabilityChecks = {
|
|
253
|
+
hasInstall: enContent
|
|
254
|
+
? /npm\s+install|npx\s+release-skill|npm\s+i\s+release-skill/i.test(enContent)
|
|
255
|
+
: false,
|
|
256
|
+
hasMinimalExample: enContent
|
|
257
|
+
? /```[\s\S]*?(release-skill|assess|prepare|help)[\s\S]*?```/i.test(enContent)
|
|
258
|
+
: false,
|
|
259
|
+
hasFailureDiagnosis: enContent
|
|
260
|
+
? /CONFIG_INVALID|PARTIAL|GATE_FAILED|if.*fail|如果.*失败|故障|troubleshoot/i.test(enContent)
|
|
261
|
+
: false,
|
|
262
|
+
hasNextSteps: enContent
|
|
263
|
+
? /next.*step|下一步|后续|see.*also|参阅|进一步/i.test(enContent)
|
|
264
|
+
: false,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// ---- 6. Skill names from manifest ----
|
|
268
|
+
const manifestSkillNames = extractManifestSkillNames(pluginManifest?.skills);
|
|
269
|
+
const skillNames = enContent
|
|
270
|
+
? findSkillNames(enContent, manifestSkillNames)
|
|
271
|
+
: [];
|
|
272
|
+
|
|
273
|
+
// ---- 7. Extract executable commands ----
|
|
274
|
+
const execCommands = enContent ? extractExecBlocks(enContent) : [];
|
|
275
|
+
if (zhContent) {
|
|
276
|
+
const zhExec = extractExecBlocks(zhContent);
|
|
277
|
+
for (const block of zhExec) {
|
|
278
|
+
const existing = execCommands.find(
|
|
279
|
+
(e) => e.fixture === block.fixture && e.language === block.language,
|
|
280
|
+
);
|
|
281
|
+
if (!existing) {
|
|
282
|
+
execCommands.push(block);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
present,
|
|
289
|
+
missing,
|
|
290
|
+
bilingualMarkers,
|
|
291
|
+
firstScreen,
|
|
292
|
+
readabilityChecks,
|
|
293
|
+
skillNames,
|
|
294
|
+
execCommands,
|
|
295
|
+
findings,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* README executable examples extraction and sandboxed execution.
|
|
3
|
+
*
|
|
4
|
+
* Extracts fenced code blocks annotated with <!-- release-skill:exec fixture=<id> -->
|
|
5
|
+
* metadata from README files, copies the referenced fixture into a temporary
|
|
6
|
+
* directory, and executes each command using `execFile` (never shell) in isolation.
|
|
7
|
+
* The original fixture directory is never modified.
|
|
8
|
+
*
|
|
9
|
+
* @module readme/examples
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
13
|
+
import { promisify } from 'node:util';
|
|
14
|
+
import { readFile, cp, mkdtemp, rm } from 'node:fs/promises';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import { ReleaseError } from '../core/errors.mjs';
|
|
18
|
+
|
|
19
|
+
const execFileAsync = promisify(execFileCb);
|
|
20
|
+
|
|
21
|
+
/** Default command timeout in milliseconds (30 seconds). */
|
|
22
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
23
|
+
|
|
24
|
+
/** Maximum buffer for child process stdout/stderr (10 MiB). */
|
|
25
|
+
const MAX_BUFFER = 10 * 1024 * 1024;
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Internal helpers
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Extract executable command blocks from README content.
|
|
33
|
+
*
|
|
34
|
+
* A block is executable when the line immediately before the opening fence
|
|
35
|
+
* contains: <!-- release-skill:exec fixture=<id> -->
|
|
36
|
+
*
|
|
37
|
+
* @param {string} content - README file content.
|
|
38
|
+
* @returns {Array<{ fixture: string, language: string, commands: string[] }>}
|
|
39
|
+
*/
|
|
40
|
+
function extractExecBlocks(content) {
|
|
41
|
+
const results = [];
|
|
42
|
+
const regex =
|
|
43
|
+
/<!--\s*release-skill:exec\s+fixture=([\w-]+)\s*-->\s*\n```(sh|bash)\n([\s\S]*?)```/g;
|
|
44
|
+
let match;
|
|
45
|
+
while ((match = regex.exec(content)) !== null) {
|
|
46
|
+
const fixture = match[1];
|
|
47
|
+
const language = match[2];
|
|
48
|
+
const blockBody = match[3];
|
|
49
|
+
const commands = blockBody
|
|
50
|
+
.split('\n')
|
|
51
|
+
.map((line) => line.trim())
|
|
52
|
+
.filter((line) => line.length > 0 && !line.startsWith('#'));
|
|
53
|
+
results.push({ fixture, language, commands });
|
|
54
|
+
}
|
|
55
|
+
return results;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse a shell-style command line into [executable, ...args].
|
|
60
|
+
*
|
|
61
|
+
* Handles quoted arguments (single and double) and strips inline comments.
|
|
62
|
+
* This is a simple parser -- it does NOT invoke a shell.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} line - A single command line.
|
|
65
|
+
* @returns {string[]} - [executable, ...args]
|
|
66
|
+
*/
|
|
67
|
+
function parseCommand(line) {
|
|
68
|
+
const tokens = [];
|
|
69
|
+
let current = '';
|
|
70
|
+
let inSingle = false;
|
|
71
|
+
let inDouble = false;
|
|
72
|
+
|
|
73
|
+
for (let i = 0; i < line.length; i++) {
|
|
74
|
+
const ch = line[i];
|
|
75
|
+
|
|
76
|
+
if (inSingle) {
|
|
77
|
+
if (ch === "'") {
|
|
78
|
+
inSingle = false;
|
|
79
|
+
} else {
|
|
80
|
+
current += ch;
|
|
81
|
+
}
|
|
82
|
+
} else if (inDouble) {
|
|
83
|
+
if (ch === '"') {
|
|
84
|
+
inDouble = false;
|
|
85
|
+
} else if (ch === '\\' && i + 1 < line.length) {
|
|
86
|
+
current += line[++i];
|
|
87
|
+
} else {
|
|
88
|
+
current += ch;
|
|
89
|
+
}
|
|
90
|
+
} else if (ch === '#') {
|
|
91
|
+
// Inline comment -- stop processing this line
|
|
92
|
+
break;
|
|
93
|
+
} else if (ch === "'") {
|
|
94
|
+
inSingle = true;
|
|
95
|
+
} else if (ch === '"') {
|
|
96
|
+
inDouble = true;
|
|
97
|
+
} else if (ch === ' ' || ch === '\t') {
|
|
98
|
+
if (current.length > 0) {
|
|
99
|
+
tokens.push(current);
|
|
100
|
+
current = '';
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
current += ch;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (current.length > 0) {
|
|
108
|
+
tokens.push(current);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return tokens;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Safely read a file; return null if absent.
|
|
116
|
+
* @param {string} filePath
|
|
117
|
+
* @returns {Promise<string | null>}
|
|
118
|
+
*/
|
|
119
|
+
async function safeRead(filePath) {
|
|
120
|
+
try {
|
|
121
|
+
return await readFile(filePath, 'utf8');
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Public API
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @typedef {object} ExecResult
|
|
133
|
+
* @property {string} fixture - The fixture id from the exec metadata.
|
|
134
|
+
* @property {string} command - The original command line string.
|
|
135
|
+
* @property {string[]} parsed - Parsed [executable, ...args].
|
|
136
|
+
* @property {string} stdout - Captured stdout.
|
|
137
|
+
* @property {string} stderr - Captured stderr.
|
|
138
|
+
* @property {number} exitCode - Process exit code (0 for success).
|
|
139
|
+
* @property {string} cwd - Absolute path to the sandboxed working directory.
|
|
140
|
+
* @property {'ok'|'timeout'|'error'} status - Execution outcome.
|
|
141
|
+
*/
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Extract executable commands from README files and run them in isolated
|
|
145
|
+
* fixture copies using `execFile` (never shell).
|
|
146
|
+
*
|
|
147
|
+
* For each fixture referenced by exec metadata:
|
|
148
|
+
* 1. The fixture directory is copied to a fresh temporary directory.
|
|
149
|
+
* 2. Every command in the block is executed in that copy via `execFile`.
|
|
150
|
+
* 3. The original fixture is never modified.
|
|
151
|
+
*
|
|
152
|
+
* @param {object} options
|
|
153
|
+
* @param {string} options.snapshotDir - Directory containing README.md / README.zh-CN.md.
|
|
154
|
+
* @param {string} options.fixturesDir - Directory containing fixture subdirectories.
|
|
155
|
+
* @param {number} [options.timeoutMs=30000] - Per-command timeout in milliseconds.
|
|
156
|
+
* @returns {Promise<ExecResult[]>}
|
|
157
|
+
*/
|
|
158
|
+
export async function extractExecutableCommands({
|
|
159
|
+
snapshotDir,
|
|
160
|
+
fixturesDir,
|
|
161
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
162
|
+
}) {
|
|
163
|
+
// 1. Read README files and extract exec blocks
|
|
164
|
+
const enContent = await safeRead(path.join(snapshotDir, 'README.md'));
|
|
165
|
+
const zhContent = await safeRead(path.join(snapshotDir, 'README.zh-CN.md'));
|
|
166
|
+
|
|
167
|
+
/** @type {Map<string, { fixture: string, commands: string[] }>} */
|
|
168
|
+
const blocksByFixture = new Map();
|
|
169
|
+
|
|
170
|
+
const addBlocks = (content) => {
|
|
171
|
+
if (!content) return;
|
|
172
|
+
for (const block of extractExecBlocks(content)) {
|
|
173
|
+
if (!blocksByFixture.has(block.fixture)) {
|
|
174
|
+
blocksByFixture.set(block.fixture, {
|
|
175
|
+
fixture: block.fixture,
|
|
176
|
+
commands: block.commands,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
addBlocks(enContent);
|
|
183
|
+
addBlocks(zhContent);
|
|
184
|
+
|
|
185
|
+
if (blocksByFixture.size === 0) {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 2. Create a parent temp directory for all fixture copies
|
|
190
|
+
const parentTmp = await mkdtemp(path.join(os.tmpdir(), 'release-skill-examples-'));
|
|
191
|
+
|
|
192
|
+
/** @type {ExecResult[]} */
|
|
193
|
+
const results = [];
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
for (const [fixtureId, block] of blocksByFixture) {
|
|
197
|
+
const srcDir = path.join(fixturesDir, fixtureId);
|
|
198
|
+
|
|
199
|
+
// Create an isolated copy for this fixture
|
|
200
|
+
const sandboxDir = path.join(parentTmp, fixtureId);
|
|
201
|
+
await cp(srcDir, sandboxDir, { recursive: true });
|
|
202
|
+
|
|
203
|
+
for (const line of block.commands) {
|
|
204
|
+
const parsed = parseCommand(line);
|
|
205
|
+
if (parsed.length === 0) continue;
|
|
206
|
+
|
|
207
|
+
const executable = parsed[0];
|
|
208
|
+
const args = parsed.slice(1);
|
|
209
|
+
|
|
210
|
+
/** @type {ExecResult} */
|
|
211
|
+
let result;
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
// Set up timeout via AbortController
|
|
215
|
+
const controller = new AbortController();
|
|
216
|
+
const handle = setTimeout(() => controller.abort(), timeoutMs);
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const { stdout, stderr } = await execFileAsync(executable, args, {
|
|
220
|
+
cwd: sandboxDir,
|
|
221
|
+
shell: false,
|
|
222
|
+
maxBuffer: MAX_BUFFER,
|
|
223
|
+
signal: controller.signal,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
result = {
|
|
227
|
+
fixture: fixtureId,
|
|
228
|
+
command: line,
|
|
229
|
+
parsed,
|
|
230
|
+
stdout: stdout ?? '',
|
|
231
|
+
stderr: stderr ?? '',
|
|
232
|
+
exitCode: 0,
|
|
233
|
+
cwd: sandboxDir,
|
|
234
|
+
status: 'ok',
|
|
235
|
+
};
|
|
236
|
+
} finally {
|
|
237
|
+
clearTimeout(handle);
|
|
238
|
+
}
|
|
239
|
+
} catch (err) {
|
|
240
|
+
// Timeout detection: AbortError or killed via SIGTERM
|
|
241
|
+
if (err.name === 'AbortError' || (err.killed && err.signal === 'SIGTERM')) {
|
|
242
|
+
result = {
|
|
243
|
+
fixture: fixtureId,
|
|
244
|
+
command: line,
|
|
245
|
+
parsed,
|
|
246
|
+
stdout: err.stdout ?? '',
|
|
247
|
+
stderr: err.stderr ?? '',
|
|
248
|
+
exitCode: 16, // HOOK_TIMEOUT exit code
|
|
249
|
+
cwd: sandboxDir,
|
|
250
|
+
status: 'timeout',
|
|
251
|
+
};
|
|
252
|
+
} else if ('stdout' in err) {
|
|
253
|
+
// Non-zero exit
|
|
254
|
+
result = {
|
|
255
|
+
fixture: fixtureId,
|
|
256
|
+
command: line,
|
|
257
|
+
parsed,
|
|
258
|
+
stdout: err.stdout ?? '',
|
|
259
|
+
stderr: err.stderr ?? '',
|
|
260
|
+
exitCode: typeof err.code === 'number' ? err.code : 1,
|
|
261
|
+
cwd: sandboxDir,
|
|
262
|
+
status: 'error',
|
|
263
|
+
};
|
|
264
|
+
} else {
|
|
265
|
+
// Unexpected failure (e.g., ENOENT)
|
|
266
|
+
result = {
|
|
267
|
+
fixture: fixtureId,
|
|
268
|
+
command: line,
|
|
269
|
+
parsed,
|
|
270
|
+
stdout: '',
|
|
271
|
+
stderr: err.message ?? String(err),
|
|
272
|
+
exitCode: 1,
|
|
273
|
+
cwd: sandboxDir,
|
|
274
|
+
status: 'error',
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
results.push(result);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
} finally {
|
|
283
|
+
// Clean up the temporary directory tree
|
|
284
|
+
await rm(parentTmp, { recursive: true, force: true });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return results;
|
|
288
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bilingual README parity check for release-skill.
|
|
3
|
+
*
|
|
4
|
+
* Compares machine-readable HTML-comment markers between README.md and
|
|
5
|
+
* README.zh-CN.md to ensure capability, command, safety, and version
|
|
6
|
+
* markers are present in both languages. Does NOT perform natural
|
|
7
|
+
* language semantic comparison -- only marker set equality.
|
|
8
|
+
*
|
|
9
|
+
* Marker formats supported:
|
|
10
|
+
* - Simple: <!-- release-skill:<name> -->
|
|
11
|
+
* - Categorized: <!-- release-skill:<category>:<name> -->
|
|
12
|
+
*
|
|
13
|
+
* @module readme/parity
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFile } from 'node:fs/promises';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Internal helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Extract all release-skill marker names from a README string.
|
|
25
|
+
*
|
|
26
|
+
* Matches HTML comments of the form:
|
|
27
|
+
* <!-- release-skill:<name> -->
|
|
28
|
+
* <!-- release-skill:<category>:<name> -->
|
|
29
|
+
*
|
|
30
|
+
* The canonical name is the last colon-separated segment.
|
|
31
|
+
* The "exec" pseudo-marker is excluded (it serves a different purpose).
|
|
32
|
+
*
|
|
33
|
+
* @param {string} content
|
|
34
|
+
* @returns {Set<string>} Set of marker names.
|
|
35
|
+
*/
|
|
36
|
+
function extractMarkers(content) {
|
|
37
|
+
const regex = /<!--\s*release-skill:((?:[\w-]+:)*[\w-]+)\s*-->/g;
|
|
38
|
+
const found = new Set();
|
|
39
|
+
let match;
|
|
40
|
+
while ((match = regex.exec(content)) !== null) {
|
|
41
|
+
const raw = match[1];
|
|
42
|
+
// Skip exec metadata pseudo-marker
|
|
43
|
+
if (raw.startsWith('exec')) continue;
|
|
44
|
+
// The canonical name is the last colon-separated segment.
|
|
45
|
+
const parts = raw.split(':');
|
|
46
|
+
const name = parts[parts.length - 1];
|
|
47
|
+
found.add(name);
|
|
48
|
+
}
|
|
49
|
+
return found;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Safely read a file; return null if absent.
|
|
54
|
+
* @param {string} filePath
|
|
55
|
+
* @returns {Promise<string | null>}
|
|
56
|
+
*/
|
|
57
|
+
async function safeRead(filePath) {
|
|
58
|
+
try {
|
|
59
|
+
return await readFile(filePath, 'utf8');
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Public API
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Compare bilingual README markers and report any drift.
|
|
71
|
+
*
|
|
72
|
+
* Extracts machine-readable markers from both README.md and
|
|
73
|
+
* README.zh-CN.md in the given snapshot directory, then computes:
|
|
74
|
+
* - enOnly: markers present in English but missing from Chinese
|
|
75
|
+
* - zhOnly: markers present in Chinese but missing from English
|
|
76
|
+
* - balanced: markers present in both languages
|
|
77
|
+
*
|
|
78
|
+
* @param {object} options
|
|
79
|
+
* @param {string} options.snapshotDir Path to the snapshot directory.
|
|
80
|
+
* @returns {Promise<ParityReport>}
|
|
81
|
+
*
|
|
82
|
+
* @typedef {object} ParityReport
|
|
83
|
+
* @property {string[]} enOnly Markers found only in README.md.
|
|
84
|
+
* @property {string[]} zhOnly Markers found only in README.zh-CN.md.
|
|
85
|
+
* @property {string[]} balanced Markers found in both README files.
|
|
86
|
+
*
|
|
87
|
+
* @throws {Error} With code 'LANG_MISSING' if README.zh-CN.md is absent.
|
|
88
|
+
*/
|
|
89
|
+
export async function checkBilingualParity({ snapshotDir }) {
|
|
90
|
+
const enPath = path.join(snapshotDir, 'README.md');
|
|
91
|
+
const zhPath = path.join(snapshotDir, 'README.zh-CN.md');
|
|
92
|
+
|
|
93
|
+
const enContent = await safeRead(enPath);
|
|
94
|
+
const zhContent = await safeRead(zhPath);
|
|
95
|
+
|
|
96
|
+
if (!enContent) {
|
|
97
|
+
const error = new Error('README.md not found in snapshot directory');
|
|
98
|
+
error.code = 'README_MISSING';
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!zhContent) {
|
|
103
|
+
const error = new Error('README.zh-CN.md not found in snapshot directory');
|
|
104
|
+
error.code = 'LANG_MISSING';
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const enMarkers = extractMarkers(enContent);
|
|
109
|
+
const zhMarkers = extractMarkers(zhContent);
|
|
110
|
+
|
|
111
|
+
const enOnly = [...enMarkers]
|
|
112
|
+
.filter((name) => !zhMarkers.has(name))
|
|
113
|
+
.sort();
|
|
114
|
+
const zhOnly = [...zhMarkers]
|
|
115
|
+
.filter((name) => !enMarkers.has(name))
|
|
116
|
+
.sort();
|
|
117
|
+
const balanced = [...enMarkers]
|
|
118
|
+
.filter((name) => zhMarkers.has(name))
|
|
119
|
+
.sort();
|
|
120
|
+
|
|
121
|
+
return { enOnly, zhOnly, balanced };
|
|
122
|
+
}
|