@pzy560117/opentest 0.1.3 → 0.1.5

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 (42) hide show
  1. package/README.md +57 -8
  2. package/assets/manifest.json +84 -27
  3. package/assets/skills/opentest/SKILL.md +44 -44
  4. package/assets/skills/opentest/references/codex-harness-coverage-heuristics.md +55 -55
  5. package/assets/skills/opentest/references/lifecycle.md +1 -1
  6. package/assets/skills/opentest/references/matrix-format.md +14 -14
  7. package/assets/skills/opentest/references/opentest-driven-development.md +32 -32
  8. package/assets/skills/opentest/templates/acceptance-template.md +1 -1
  9. package/assets/skills/opentest/templates/matrix-template.md +3 -3
  10. package/assets/skills/opentest/templates/plan-template.md +17 -17
  11. package/assets/skills/opentest/templates/report-template.md +1 -1
  12. package/assets/skills/opentest-accept/SKILL.md +13 -13
  13. package/assets/skills/opentest-archive/SKILL.md +2 -2
  14. package/assets/skills/opentest-author/SKILL.md +14 -14
  15. package/assets/skills/opentest-heal/SKILL.md +2 -2
  16. package/assets/skills/opentest-plan/SKILL.md +17 -17
  17. package/assets/skills/opentest-run/SKILL.md +16 -16
  18. package/assets/skills/opentest-verify/SKILL.md +11 -11
  19. package/assets/skills-zh/opentest/SKILL.md +93 -0
  20. package/assets/skills-zh/opentest/references/acceptance-evidence.md +27 -0
  21. package/assets/skills-zh/opentest/references/codex-harness-coverage-heuristics.md +83 -0
  22. package/assets/skills-zh/opentest/references/command-routing.md +9 -0
  23. package/assets/skills-zh/opentest/references/lifecycle.md +16 -0
  24. package/assets/skills-zh/opentest/references/matrix-format.md +27 -0
  25. package/assets/skills-zh/opentest/references/opentest-driven-development.md +48 -0
  26. package/assets/skills-zh/opentest/references/quality-gate.md +24 -0
  27. package/assets/skills-zh/opentest/templates/acceptance-template.md +32 -0
  28. package/assets/skills-zh/opentest/templates/archive-layout.md +14 -0
  29. package/assets/skills-zh/opentest/templates/matrix-template.md +6 -0
  30. package/assets/skills-zh/opentest/templates/plan-template.md +28 -0
  31. package/assets/skills-zh/opentest/templates/report-template.md +28 -0
  32. package/assets/skills-zh/opentest-accept/SKILL.md +25 -0
  33. package/assets/skills-zh/opentest-archive/SKILL.md +8 -0
  34. package/assets/skills-zh/opentest-author/SKILL.md +27 -0
  35. package/assets/skills-zh/opentest-heal/SKILL.md +8 -0
  36. package/assets/skills-zh/opentest-plan/SKILL.md +30 -0
  37. package/assets/skills-zh/opentest-run/SKILL.md +28 -0
  38. package/assets/skills-zh/opentest-verify/SKILL.md +24 -0
  39. package/bin/opentest.js +318 -29
  40. package/package.json +1 -1
  41. package/scripts/prepublish-check.js +105 -6
  42. package/scripts/smoke-test.js +493 -23
@@ -1,12 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { readFileSync } from 'fs';
4
- import { access } from 'fs/promises';
4
+ import { access, mkdir, mkdtemp, rm, writeFile } from 'fs/promises';
5
5
  import path from 'path';
6
+ import { tmpdir } from 'os';
6
7
  import { spawnSync } from 'child_process';
7
8
 
8
9
  const manifest = JSON.parse(readFileSync('assets/manifest.json', 'utf8'));
9
10
  let failures = 0;
11
+ const requiredLanguages = ['en', 'zh'];
12
+ const requiredPlatforms = ['codex', 'claude', 'cursor', 'opencode', 'gemini', 'qwen', 'qoder'];
13
+ const expectedSharedFiles = [
14
+ 'opentest/scripts/opentest-state.sh',
15
+ 'opentest/scripts/opentest-detect.sh',
16
+ 'opentest/scripts/opentest-guard.sh',
17
+ ];
10
18
 
11
19
  async function exists(filePath) {
12
20
  try {
@@ -17,45 +25,507 @@ async function exists(filePath) {
17
25
  }
18
26
  }
19
27
 
20
- for (const skillPath of manifest.skills) {
21
- const fullPath = path.join('assets', 'skills', skillPath);
28
+ function assert(condition, message) {
29
+ if (!condition) {
30
+ console.error(message);
31
+ failures += 1;
32
+ }
33
+ }
34
+
35
+ function pathSegments(file) {
36
+ return file.split(/[\\/]+/).filter(Boolean);
37
+ }
38
+
39
+ function manifestPathRelativityErrors(kind, file) {
40
+ const errors = [];
41
+ const normalized = file.replace(/\\/g, '/');
42
+
43
+ if (path.posix.isAbsolute(normalized) || path.win32.isAbsolute(file)) {
44
+ errors.push(`[${kind}] Managed file path must be relative: ${file}`);
45
+ }
46
+ if (pathSegments(file).includes('..')) {
47
+ errors.push(`[${kind}] Managed file path must not traverse parent directories: ${file}`);
48
+ }
49
+ if (kind === 'LOCALIZED' && (normalized.startsWith('skills/') || normalized.startsWith('skills-zh/'))) {
50
+ errors.push(`[${kind}] Localized file must be relative to each language skills dir: ${file}`);
51
+ }
52
+
53
+ return errors;
54
+ }
55
+
56
+ function assertManifestPathRelativity(kind, file) {
57
+ for (const error of manifestPathRelativityErrors(kind, file)) {
58
+ console.error(error);
59
+ failures += 1;
60
+ }
61
+ }
62
+
63
+ function runManifestPathRelativitySelfCheck() {
64
+ const localizedInvalidPaths = [
65
+ path.resolve('opentest/SKILL.md'),
66
+ '/tmp/x.md',
67
+ 'C:/tmp/x.md',
68
+ 'C:\\tmp\\x.md',
69
+ '\\\\server\\share\\x.md',
70
+ '../opentest/SKILL.md',
71
+ 'skills/opentest/SKILL.md',
72
+ 'skills-zh/opentest/SKILL.md',
73
+ ];
74
+
75
+ for (const file of localizedInvalidPaths) {
76
+ assert(
77
+ manifestPathRelativityErrors('LOCALIZED', file).length > 0,
78
+ `[SELFTEST] managedFiles.localized path relativity check did not reject ${file}`,
79
+ );
80
+ }
81
+
82
+ const sharedInvalidPaths = [
83
+ path.resolve('opentest/scripts/opentest-state.sh'),
84
+ '/tmp/x.md',
85
+ 'C:/tmp/x.md',
86
+ 'C:\\tmp\\x.md',
87
+ '\\\\server\\share\\x.md',
88
+ '../opentest/scripts/opentest-state.sh',
89
+ ];
90
+
91
+ for (const file of sharedInvalidPaths) {
92
+ assert(
93
+ manifestPathRelativityErrors('SHARED', file).length > 0,
94
+ `[SELFTEST] managedFiles.shared path relativity check did not reject ${file}`,
95
+ );
96
+ }
97
+ }
98
+
99
+ function languageById(languageId) {
100
+ return Array.isArray(manifest.languages)
101
+ ? manifest.languages.find((entry) => entry.id === languageId)
102
+ : undefined;
103
+ }
104
+
105
+ function assertManifestStructure() {
106
+ assert(typeof manifest.version === 'string' && manifest.version.length > 0, '[MANIFEST] missing version');
107
+ assert(Array.isArray(manifest.languages), '[MANIFEST] languages must be an array');
108
+ for (const language of requiredLanguages) {
109
+ const entry = languageById(language);
110
+ assert(entry, `[MANIFEST] missing languages entry ${language}`);
111
+ assert(entry?.name, `[MANIFEST] missing languages.${language}.name`);
112
+ assert(entry?.skillsDir, `[MANIFEST] missing languages.${language}.skillsDir`);
113
+ }
114
+ assert(languageById('en')?.name === 'English', '[MANIFEST] languages.en.name must be English');
115
+ assert(languageById('en')?.skillsDir === 'skills', '[MANIFEST] languages.en.skillsDir must be skills');
116
+ assert(languageById('zh')?.name === '中文', '[MANIFEST] languages.zh.name must be 中文');
117
+ assert(languageById('zh')?.skillsDir === 'skills-zh', '[MANIFEST] languages.zh.skillsDir must be skills-zh');
118
+
119
+ assert(Array.isArray(manifest.platforms), '[MANIFEST] missing platforms');
120
+ for (const platformId of requiredPlatforms) {
121
+ const platform = manifest.platforms?.find((entry) => entry.id === platformId);
122
+ assert(platform, `[MANIFEST] missing platforms.${platformId}`);
123
+ assert(platform?.name, `[MANIFEST] missing platforms.${platformId}.name`);
124
+ assert(platform?.projectSkillsDir, `[MANIFEST] missing platforms.${platformId}.projectSkillsDir`);
125
+ assert(platform?.globalSkillsDir, `[MANIFEST] missing platforms.${platformId}.globalSkillsDir`);
126
+ }
127
+ const opencode = manifest.platforms?.find((entry) => entry.id === 'opencode');
128
+ assert(opencode?.globalSkillsDir === '.config/opencode/skills', '[MANIFEST] platforms.opencode.globalSkillsDir must be .config/opencode/skills');
129
+
130
+ assert(manifest.managedFiles && typeof manifest.managedFiles === 'object' && !Array.isArray(manifest.managedFiles), '[MANIFEST] missing managedFiles');
131
+ assert(Array.isArray(manifest.managedFiles?.localized), '[MANIFEST] missing managedFiles.localized');
132
+ assert(Array.isArray(manifest.managedFiles?.shared), '[MANIFEST] missing managedFiles.shared');
133
+ for (const file of expectedSharedFiles) {
134
+ assert(manifest.managedFiles?.shared?.includes(file), `[MANIFEST] missing managedFiles.shared entry ${file}`);
135
+ }
136
+ for (const file of manifest.managedFiles?.shared ?? []) {
137
+ assert(!file.startsWith('skills/'), `[MANIFEST] managedFiles.shared must be relative to English skills dir: ${file}`);
138
+ }
139
+ }
140
+
141
+ function assertPrepublishGateCoversManifestAssets() {
142
+ const prepublishCheck = readFileSync('scripts/prepublish-check.js', 'utf8');
143
+ const secretsScanIndex = prepublishCheck.indexOf('for (const filePath of walkFiles');
144
+ const manifestReadIndex = prepublishCheck.indexOf("assets/manifest.json");
145
+
146
+ assert(secretsScanIndex !== -1, '[PREPUBLISH] secrets scan loop not found');
147
+ assert(manifestReadIndex !== -1, '[PREPUBLISH] prepublish check must load assets/manifest.json');
148
+ assert(
149
+ manifestReadIndex !== -1 && secretsScanIndex !== -1 && manifestReadIndex < secretsScanIndex,
150
+ '[PREPUBLISH] manifest checks must run before secrets scanning',
151
+ );
152
+ assert(prepublishCheck.includes('manifest.languages'), '[PREPUBLISH] must validate manifest.languages');
153
+ assert(prepublishCheck.includes("entry.id === 'zh'"), '[PREPUBLISH] must require zh language in manifest');
154
+ assert(prepublishCheck.includes('manifest.managedFiles?.localized'), '[PREPUBLISH] must validate managedFiles.localized');
155
+ assert(prepublishCheck.includes('manifest.managedFiles?.shared'), '[PREPUBLISH] must validate managedFiles.shared');
156
+ assert(prepublishCheck.includes("assets/skills-zh"), '[PREPUBLISH] must validate assets/skills-zh exists');
157
+ }
158
+
159
+ async function assertPrepublishRejectsPublishSecretTextFiles() {
160
+ const fixtureDir = path.join('assets', 'opentest-smoke-secret-fixtures');
161
+ const fixtures = [
162
+ {
163
+ file: path.join(fixtureDir, 'fixture.pem'),
164
+ content: `${['-----BEGIN PRIVATE', 'KEY-----'].join(' ')}\nnot-a-real-key\n-----END PRIVATE KEY-----\n`,
165
+ expected: 'Private key',
166
+ },
167
+ {
168
+ file: path.join(fixtureDir, '.env'),
169
+ content: `${['OPENTEST', 'TOKEN'].join('_')}="super-secret-token-value"\n`,
170
+ expected: 'Secret/token',
171
+ },
172
+ {
173
+ file: path.join(fixtureDir, 'extensionless-secret'),
174
+ content: `${['api', 'key'].join('_')}="1234567890abcdefghijklmnop"\n`,
175
+ expected: 'API key',
176
+ },
177
+ ];
178
+
179
+ try {
180
+ await mkdir(fixtureDir, { recursive: true });
181
+ await Promise.all(fixtures.map((fixture) => writeFile(fixture.file, fixture.content)));
182
+
183
+ const result = spawnSync(process.execPath, ['scripts/prepublish-check.js'], { encoding: 'utf8' });
184
+ assert(result.status !== 0, '[PREPUBLISH] expected secrets in publish text files to fail prepublish check');
185
+ for (const fixture of fixtures) {
186
+ assert(
187
+ result.stderr.includes(fixture.expected) && result.stderr.includes(fixture.file),
188
+ `[PREPUBLISH] expected ${fixture.file} to be reported as ${fixture.expected}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
189
+ );
190
+ }
191
+ } finally {
192
+ await rm(fixtureDir, { recursive: true, force: true });
193
+ }
194
+ }
195
+
196
+ function localizedFiles() {
197
+ if (!Array.isArray(manifest.managedFiles?.localized)) {
198
+ return [];
199
+ }
200
+
201
+ return manifest.managedFiles.localized.flatMap((entry) => (
202
+ (manifest.languages ?? []).map((language) => path.join(language.skillsDir, entry))
203
+ ));
204
+ }
205
+
206
+ function sharedFiles() {
207
+ return Array.isArray(manifest.managedFiles?.shared) ? manifest.managedFiles.shared : [];
208
+ }
209
+
210
+ function sharedAssetFiles() {
211
+ return sharedFiles().map((entry) => path.join(languageById('en')?.skillsDir ?? 'skills', entry));
212
+ }
213
+
214
+ function hasChineseText(content) {
215
+ return /\p{Script=Han}/u.test(content);
216
+ }
217
+
218
+ function manifestDirSearchRoot(manifestDir, scope) {
219
+ const normalized = manifestDir.replace(/\\/g, '/');
220
+ if (scope === 'global') {
221
+ return normalized.startsWith('~/') ? `$HOME/${normalized.slice(2)}` : `$HOME/${normalized}`;
222
+ }
223
+ return normalized;
224
+ }
225
+
226
+ function assertOpenTestSearchRootsCoverPlatforms() {
227
+ for (const language of manifest.languages ?? []) {
228
+ const languageLabel = `${language.id ?? '<missing id>'} (${language.name ?? '<missing name>'})`;
229
+ if (!language.skillsDir) {
230
+ assert(false, `[SEARCH_ROOTS] ${languageLabel} missing skillsDir`);
231
+ continue;
232
+ }
233
+
234
+ const skillPath = path.join('assets', language.skillsDir, 'opentest', 'SKILL.md');
235
+ let skillContent;
236
+ try {
237
+ skillContent = readFileSync(skillPath, 'utf8');
238
+ } catch {
239
+ assert(false, `[SEARCH_ROOTS] ${languageLabel} missing router at ${skillPath}`);
240
+ continue;
241
+ }
242
+
243
+ const searchRootsLine = skillContent.split(/\r?\n/).find((line) => line.startsWith('OPENTEST_SEARCH_ROOTS='));
244
+ assert(searchRootsLine, `[SEARCH_ROOTS] ${languageLabel} router ${skillPath} must define OPENTEST_SEARCH_ROOTS`);
245
+ if (!searchRootsLine) continue;
246
+
247
+ for (const platform of manifest.platforms ?? []) {
248
+ for (const [scope, manifestDir] of [
249
+ ['project', platform.projectSkillsDir],
250
+ ['global', platform.globalSkillsDir],
251
+ ]) {
252
+ if (!manifestDir) {
253
+ assert(false, `[SEARCH_ROOTS] ${languageLabel} missing ${platform.id} ${scope} manifest dir`);
254
+ continue;
255
+ }
256
+
257
+ const expectedRoot = manifestDirSearchRoot(manifestDir, scope);
258
+ assert(
259
+ searchRootsLine.includes(`"${expectedRoot}"`),
260
+ `[SEARCH_ROOTS] ${languageLabel} router ${skillPath} missing ${platform.id} ${scope} search root ${expectedRoot}`,
261
+ );
262
+ }
263
+ }
264
+ }
265
+ }
266
+
267
+ function assertLanguageAssetContracts() {
268
+ for (const file of manifest.managedFiles?.localized ?? []) {
269
+ const englishPath = path.join('assets', languageById('en')?.skillsDir ?? 'skills', file);
270
+ const chinesePath = path.join('assets', languageById('zh')?.skillsDir ?? 'skills-zh', file);
271
+
272
+ if (existsSyncLike(englishPath)) {
273
+ const content = readFileSync(englishPath, 'utf8');
274
+ assert(!hasChineseText(content), `[LANGUAGE] English asset must not contain Chinese text: ${englishPath}`);
275
+ }
276
+
277
+ if (existsSyncLike(chinesePath)) {
278
+ const content = readFileSync(chinesePath, 'utf8');
279
+ assert(hasChineseText(content), `[LANGUAGE] Chinese asset must remain Chinese: ${chinesePath}`);
280
+ }
281
+ }
282
+ }
283
+
284
+ function existsSyncLike(filePath) {
285
+ try {
286
+ readFileSync(filePath);
287
+ return true;
288
+ } catch {
289
+ return false;
290
+ }
291
+ }
292
+
293
+ function assertCliRejects(args, expectedStderr) {
294
+ const result = spawnSync(process.execPath, ['bin/opentest.js', ...args], { encoding: 'utf8' });
295
+ assert(result.status !== 0, `[CLI] expected failure for: ${args.join(' ')}`);
296
+ assert(
297
+ result.stderr.includes(expectedStderr),
298
+ `[CLI] expected stderr to include "${expectedStderr}" for: ${args.join(' ')}\n${result.stderr}`,
299
+ );
300
+ }
301
+
302
+ function runCli(args, options = {}) {
303
+ return spawnSync(process.execPath, ['bin/opentest.js', ...args], {
304
+ encoding: 'utf8',
305
+ ...options,
306
+ env: {
307
+ ...process.env,
308
+ ...(options.env ?? {}),
309
+ },
310
+ });
311
+ }
312
+
313
+ function assertCliSucceeds(args, options = {}) {
314
+ const result = runCli(args, options);
315
+ assert(
316
+ result.status === 0,
317
+ `[CLI] expected success for: ${args.join(' ')}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
318
+ );
319
+ return result;
320
+ }
321
+
322
+ async function makeTempDir(prefix) {
323
+ return mkdtemp(path.join(tmpdir(), prefix));
324
+ }
325
+
326
+ async function assertExists(filePath, message) {
327
+ assert(await exists(filePath), message);
328
+ }
329
+
330
+ async function assertMissing(filePath, message) {
331
+ assert(!(await exists(filePath)), message);
332
+ }
333
+
334
+ async function assertInstallBehavior() {
335
+ const project = await makeTempDir('opentest-project-');
336
+ const projectResult = assertCliSucceeds([
337
+ 'install',
338
+ '--scope',
339
+ 'project',
340
+ '--platform',
341
+ 'qoder',
342
+ '--language',
343
+ 'zh',
344
+ '--project',
345
+ project,
346
+ ]);
347
+ const qoderSkills = path.join(project, '.qoder', 'skills');
348
+ await assertExists(path.join(qoderSkills, 'opentest', 'SKILL.md'), '[INSTALL] missing zh qoder project opentest/SKILL.md');
349
+ await assertExists(
350
+ path.join(qoderSkills, 'opentest', 'scripts', 'opentest-state.sh'),
351
+ '[INSTALL] missing shared script copied from English assets for zh qoder project install',
352
+ );
353
+ assert(projectResult.stdout.includes('Platform: Qoder'), '[INSTALL] project output must include Platform: Qoder');
354
+ assert(projectResult.stdout.includes('Language: 中文'), '[INSTALL] project output must include Language: 中文');
355
+ assert(
356
+ projectResult.stdout.includes('Restart your AI coding tool or open a new session'),
357
+ '[INSTALL] restart hint must be generic across AI coding tools',
358
+ );
359
+
360
+ const home = await makeTempDir('opentest-home-');
361
+ assertCliSucceeds(['install', '--scope', 'global', '--platform', 'opencode', '--language', 'en'], {
362
+ env: {
363
+ HOME: home,
364
+ USERPROFILE: home,
365
+ },
366
+ });
367
+ await assertExists(
368
+ path.join(home, '.config', 'opencode', 'skills', 'opentest', 'SKILL.md'),
369
+ '[INSTALL] missing en opencode global opentest/SKILL.md',
370
+ );
371
+
372
+ const forceProject = await makeTempDir('opentest-force-');
373
+ const forceSkills = path.join(forceProject, '.qoder', 'skills');
374
+ await mkdir(path.join(forceSkills, 'opentest'), { recursive: true });
375
+ await mkdir(path.join(forceSkills, 'user-skill'), { recursive: true });
376
+ await writeFile(path.join(forceSkills, 'opentest', 'stale.txt'), 'stale opentest managed file\n');
377
+ await writeFile(path.join(forceSkills, 'user-skill', 'SKILL.md'), 'user managed skill\n');
378
+
379
+ const forceResult = assertCliSucceeds([
380
+ 'install',
381
+ '--scope',
382
+ 'project',
383
+ '--platform',
384
+ 'qoder',
385
+ '--language',
386
+ 'zh',
387
+ '--project',
388
+ forceProject,
389
+ '--force',
390
+ ]);
391
+ await assertExists(
392
+ path.join(forceSkills, 'user-skill', 'SKILL.md'),
393
+ '[INSTALL] --force must preserve non-OpenTest user skill directories',
394
+ );
395
+ await assertMissing(
396
+ path.join(forceSkills, 'opentest', 'stale.txt'),
397
+ '[INSTALL] --force must replace OpenTest managed directories',
398
+ );
399
+ assert(forceResult.stdout.includes('Platform: Qoder'), '[INSTALL] force output must include Platform: Qoder');
400
+
401
+ const noForceProject = await makeTempDir('opentest-no-force-');
402
+ const existingSkill = path.join(noForceProject, '.qoder', 'skills', 'opentest');
403
+ await mkdir(existingSkill, { recursive: true });
404
+ await writeFile(path.join(existingSkill, 'SKILL.md'), 'existing opentest skill\n');
405
+ const noForceResult = runCli([
406
+ 'install',
407
+ '--scope',
408
+ 'project',
409
+ '--platform',
410
+ 'qoder',
411
+ '--language',
412
+ 'zh',
413
+ '--project',
414
+ noForceProject,
415
+ ]);
416
+ assert(noForceResult.status !== 0, '[INSTALL] expected existing OpenTest skill to fail without --force');
417
+ assert(
418
+ noForceResult.stderr.includes('already exists. Re-run with --force to overwrite.'),
419
+ `[INSTALL] expected overwrite guidance for existing skill\n${noForceResult.stderr}`,
420
+ );
421
+
422
+ const detectedProject = await makeTempDir('opentest-detected-');
423
+ const detectedResult = assertCliSucceeds(['install', '--project', detectedProject], {
424
+ env: {
425
+ OPENTEST_PLATFORM: 'claude',
426
+ OPENTEST_LANGUAGE: 'zh',
427
+ },
428
+ });
429
+ await assertExists(
430
+ path.join(detectedProject, '.claude', 'skills', 'opentest', 'SKILL.md'),
431
+ '[INSTALL] OPENTEST_PLATFORM=claude must install project skills into .claude/skills',
432
+ );
433
+ assert(detectedResult.stdout.includes('Platform: Claude Code'), '[INSTALL] detected output must include Platform: Claude Code');
434
+ assert(detectedResult.stdout.includes('Language: 中文'), '[INSTALL] detected output must include Language: 中文');
435
+ }
436
+
437
+ async function assertInitBehavior() {
438
+ const project = await makeTempDir('opentest-init-');
439
+ const initResult = assertCliSucceeds(['init'], {
440
+ input: `2\n2\n1\n${project}\n`,
441
+ });
442
+
443
+ await assertExists(
444
+ path.join(project, '.claude', 'skills', 'opentest', 'SKILL.md'),
445
+ '[INIT] interactive init must install Claude project skills when Claude Code is selected',
446
+ );
447
+ await assertExists(
448
+ path.join(project, '.claude', 'skills', 'opentest', 'scripts', 'opentest-state.sh'),
449
+ '[INIT] interactive init must install shared OpenTest scripts',
450
+ );
451
+ assert(initResult.stdout.includes('OpenTest init'), '[INIT] output must show init wizard heading');
452
+ assert(initResult.stdout.includes('Target AI coding tool:'), '[INIT] output must prompt for platform');
453
+ assert(initResult.stdout.includes('Skill language:'), '[INIT] output must prompt for language');
454
+ assert(initResult.stdout.includes('Install scope:'), '[INIT] output must prompt for scope');
455
+ assert(initResult.stdout.includes('Platform: Claude Code'), '[INIT] output must include selected platform');
456
+ assert(initResult.stdout.includes('Language: 中文'), '[INIT] output must include selected language');
457
+ }
458
+
459
+ runManifestPathRelativitySelfCheck();
460
+ assertManifestStructure();
461
+ assertPrepublishGateCoversManifestAssets();
462
+ assertOpenTestSearchRootsCoverPlatforms();
463
+ assertLanguageAssetContracts();
464
+ await assertPrepublishRejectsPublishSecretTextFiles();
465
+
466
+ assertCliRejects(['install', '--language', 'jp'], 'Available languages: en, zh');
467
+ assertCliRejects(['install', '--platform', 'invalid'], 'Available platforms: codex, claude, cursor, opencode, gemini, qwen, qoder');
468
+ assertCliRejects(['install', '--scope', 'team'], 'Available scopes: project, global');
469
+ await assertInstallBehavior();
470
+ await assertInitBehavior();
471
+
472
+ for (const assetPath of [...localizedFiles(), ...sharedAssetFiles()]) {
473
+ const fullPath = path.join('assets', assetPath);
22
474
  if (!(await exists(fullPath))) {
23
475
  console.error(`[MISSING] ${fullPath}`);
24
476
  failures += 1;
25
477
  }
26
478
  }
27
479
 
28
- for (const skillName of [
29
- 'opentest',
30
- 'opentest-plan',
31
- 'opentest-author',
32
- 'opentest-run',
33
- 'opentest-accept',
34
- 'opentest-verify',
35
- 'opentest-heal',
36
- 'opentest-archive',
37
- ]) {
38
- const skillFile = path.join('assets', 'skills', skillName, 'SKILL.md');
39
- const content = readFileSync(skillFile, 'utf8');
480
+ for (const skillFile of localizedFiles().filter((entry) => entry.endsWith('SKILL.md'))) {
481
+ const fullPath = path.join('assets', skillFile);
482
+ if (!(await exists(fullPath))) {
483
+ continue;
484
+ }
485
+
486
+ const content = readFileSync(fullPath, 'utf8');
40
487
  if (!/^name:\s+/m.test(content) || !/^description:\s+/m.test(content)) {
41
- console.error(`[FRONTMATTER] Missing name or description in ${skillFile}`);
488
+ console.error(`[FRONTMATTER] Missing name or description in ${fullPath}`);
42
489
  failures += 1;
43
490
  }
44
491
  }
45
492
 
46
- for (const script of [
47
- 'assets/skills/opentest/scripts/opentest-state.sh',
48
- 'assets/skills/opentest/scripts/opentest-detect.sh',
49
- 'assets/skills/opentest/scripts/opentest-guard.sh',
50
- ]) {
51
- const result = spawnSync('bash', ['-n', script], { encoding: 'utf8' });
493
+ for (const script of sharedAssetFiles()) {
494
+ const scriptPath = path.join('assets', script);
495
+ if (!(await exists(scriptPath))) {
496
+ continue;
497
+ }
498
+
499
+ if (!scriptPath.endsWith('.sh')) {
500
+ console.error(`[SCRIPT] Shared file must be a shell script: ${scriptPath}`);
501
+ failures += 1;
502
+ continue;
503
+ }
504
+
505
+ const result = spawnSync('bash', ['-n', scriptPath], { encoding: 'utf8' });
52
506
  if (result.status !== 0) {
53
- console.error(`[BASH] ${script}`);
507
+ console.error(`[BASH] ${scriptPath}`);
54
508
  console.error(result.stderr || result.stdout);
55
509
  failures += 1;
56
510
  }
57
511
  }
58
512
 
513
+ for (const file of manifest.managedFiles?.localized ?? []) {
514
+ assertManifestPathRelativity('LOCALIZED', file);
515
+ if (!file.endsWith('.md')) {
516
+ console.error(`[LOCALIZED] Localized file must be Markdown: ${file}`);
517
+ failures += 1;
518
+ }
519
+ }
520
+
521
+ for (const file of manifest.managedFiles?.shared ?? []) {
522
+ assertManifestPathRelativity('SHARED', file);
523
+ if (file.endsWith('.md')) {
524
+ console.error(`[SHARED] Shared file must not be Markdown: ${file}`);
525
+ failures += 1;
526
+ }
527
+ }
528
+
59
529
  if (failures > 0) {
60
530
  console.error(`[SMOKE] ${failures} failure(s)`);
61
531
  process.exit(1);