release-skill 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/.agents/plugins/marketplace.json +23 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +10 -0
  4. package/.codex-plugin/plugin.json +26 -0
  5. package/CHANGELOG.md +68 -0
  6. package/CODE_OF_CONDUCT.md +76 -0
  7. package/CONTRIBUTING.md +49 -0
  8. package/INSTALL.md +182 -0
  9. package/LICENSE +21 -0
  10. package/NOTICE +25 -0
  11. package/README.md +501 -0
  12. package/README.zh-CN.md +463 -0
  13. package/SECURITY.md +48 -0
  14. package/adapters/claude/.claude-plugin/marketplace.json +16 -0
  15. package/adapters/claude/.claude-plugin/plugin.json +10 -0
  16. package/adapters/claude/skills/release-assess/SKILL.md +52 -0
  17. package/adapters/claude/skills/release-help/SKILL.md +60 -0
  18. package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
  19. package/adapters/claude/skills/release-publish/SKILL.md +55 -0
  20. package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
  21. package/adapters/claude/skills/release-verify/SKILL.md +70 -0
  22. package/adapters/codex/.codex-plugin/plugin.json +26 -0
  23. package/adapters/codex/skills/release-assess/SKILL.md +52 -0
  24. package/adapters/codex/skills/release-help/SKILL.md +60 -0
  25. package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
  26. package/adapters/codex/skills/release-publish/SKILL.md +55 -0
  27. package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
  28. package/adapters/codex/skills/release-verify/SKILL.md +70 -0
  29. package/bin/release-skill.mjs +743 -0
  30. package/native/safe-write/binding.gyp +40 -0
  31. package/native/safe-write/prebuilds.json +4 -0
  32. package/native/safe-write/src/safe_write.cc +2023 -0
  33. package/package.json +75 -0
  34. package/references/.render-manifest.json +33 -0
  35. package/references/00-target-state.md +124 -0
  36. package/references/01-state-machine.md +155 -0
  37. package/references/02-project-config.md +217 -0
  38. package/references/03-readme-quality.md +136 -0
  39. package/references/04-supply-chain.md +147 -0
  40. package/references/05-evidence-and-errors.md +164 -0
  41. package/references/06-adapter-contract.md +178 -0
  42. package/schemas/.render-manifest.json +37 -0
  43. package/schemas/approval-record.schema.json +115 -0
  44. package/schemas/artifact-lock.schema.json +111 -0
  45. package/schemas/artifact-plan.schema.json +52 -0
  46. package/schemas/artifact-policy.schema.json +76 -0
  47. package/schemas/evidence-event.schema.json +89 -0
  48. package/schemas/release-plan.schema.json +369 -0
  49. package/schemas/release-project.schema.json +359 -0
  50. package/schemas/release-run.schema.json +195 -0
  51. package/skills/release-assess/SKILL.md +52 -0
  52. package/skills/release-help/SKILL.md +60 -0
  53. package/skills/release-prepare/SKILL.md +71 -0
  54. package/skills/release-publish/SKILL.md +55 -0
  55. package/skills/release-reconcile/SKILL.md +73 -0
  56. package/skills/release-verify/SKILL.md +70 -0
  57. package/skills-src/release-assess/SKILL.md +52 -0
  58. package/skills-src/release-help/SKILL.md +60 -0
  59. package/skills-src/release-prepare/SKILL.md +71 -0
  60. package/skills-src/release-publish/SKILL.md +55 -0
  61. package/skills-src/release-reconcile/SKILL.md +73 -0
  62. package/skills-src/release-verify/SKILL.md +70 -0
  63. package/src/adapters/contract.mjs +214 -0
  64. package/src/adapters/git-github.mjs +214 -0
  65. package/src/adapters/npm.mjs +947 -0
  66. package/src/adapters/plugin-marketplace.mjs +1365 -0
  67. package/src/adapters/push-snapshot.mjs +216 -0
  68. package/src/artifacts/adoption.mjs +743 -0
  69. package/src/artifacts/artifact-plan.mjs +162 -0
  70. package/src/artifacts/entry.mjs +240 -0
  71. package/src/artifacts/git-authority.mjs +637 -0
  72. package/src/artifacts/graph.mjs +189 -0
  73. package/src/artifacts/inspect.mjs +520 -0
  74. package/src/artifacts/inventory.mjs +192 -0
  75. package/src/artifacts/merge/binary.mjs +77 -0
  76. package/src/artifacts/merge/entry-merge.mjs +228 -0
  77. package/src/artifacts/merge/json.mjs +641 -0
  78. package/src/artifacts/merge/markdown.mjs +246 -0
  79. package/src/artifacts/merge/regions.mjs +156 -0
  80. package/src/artifacts/merge/text.mjs +432 -0
  81. package/src/artifacts/merge/tree.mjs +202 -0
  82. package/src/artifacts/merge/yaml.mjs +669 -0
  83. package/src/artifacts/path-key.mjs +94 -0
  84. package/src/artifacts/policy.mjs +319 -0
  85. package/src/artifacts/producer-registry.mjs +439 -0
  86. package/src/artifacts/project-lock.mjs +732 -0
  87. package/src/artifacts/resolution.mjs +658 -0
  88. package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
  89. package/src/artifacts/safe-fs.mjs +72 -0
  90. package/src/artifacts/state.mjs +495 -0
  91. package/src/artifacts/transaction-journal.mjs +983 -0
  92. package/src/artifacts/transaction.mjs +1361 -0
  93. package/src/commands/approve.mjs +280 -0
  94. package/src/commands/artifacts.mjs +627 -0
  95. package/src/commands/assess.mjs +838 -0
  96. package/src/commands/prepare.mjs +1377 -0
  97. package/src/commands/publish.mjs +883 -0
  98. package/src/commands/reconcile.mjs +1255 -0
  99. package/src/commands/verify.mjs +915 -0
  100. package/src/core/approval.mjs +332 -0
  101. package/src/core/baseline.mjs +272 -0
  102. package/src/core/blackbox-hard-gates.mjs +142 -0
  103. package/src/core/config.mjs +448 -0
  104. package/src/core/digest.mjs +90 -0
  105. package/src/core/errors.mjs +113 -0
  106. package/src/core/evidence.mjs +167 -0
  107. package/src/core/hooks.mjs +241 -0
  108. package/src/core/node-version.mjs +64 -0
  109. package/src/core/plan.mjs +735 -0
  110. package/src/core/previous-public-baseline.mjs +204 -0
  111. package/src/core/run.mjs +681 -0
  112. package/src/core/state-machine.mjs +76 -0
  113. package/src/core/version-consistency.mjs +111 -0
  114. package/src/producers/build-adapters.mjs +231 -0
  115. package/src/producers/render-public-assets.mjs +152 -0
  116. package/src/producers/sync-skills.mjs +96 -0
  117. package/src/readme/contract.mjs +297 -0
  118. package/src/readme/examples.mjs +288 -0
  119. package/src/readme/parity.mjs +122 -0
  120. package/src/snapshot/export.mjs +99 -0
  121. package/src/snapshot/frozen.mjs +401 -0
  122. package/src/snapshot/manifest.mjs +207 -0
  123. package/src/snapshot/public-map.mjs +1459 -0
  124. package/src/snapshot/public-path.mjs +110 -0
  125. package/src/snapshot/scan.mjs +419 -0
@@ -0,0 +1,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
+ }