release-skill 0.6.1 → 0.6.2

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 (31) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +17 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +12 -9
  10. package/README.zh-CN.md +12 -9
  11. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/release-skill.bundle.mjs +326 -70
  14. package/adapters/claude/schemas/release-plan.schema.json +9 -0
  15. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  16. package/adapters/codex/bin/release-skill.bundle.mjs +326 -70
  17. package/adapters/codex/schemas/release-plan.schema.json +9 -0
  18. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  19. package/adapters/kimi/bin/release-skill.bundle.mjs +326 -70
  20. package/adapters/kimi/schemas/release-plan.schema.json +9 -0
  21. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  22. package/adapters/workbuddy/bin/release-skill.bundle.mjs +326 -70
  23. package/adapters/workbuddy/schemas/release-plan.schema.json +9 -0
  24. package/bin/release-skill.bundle.mjs +326 -70
  25. package/package.json +1 -1
  26. package/schemas/release-plan.schema.json +9 -0
  27. package/src/commands/lineage.mjs +101 -32
  28. package/src/commands/prepare.mjs +59 -1
  29. package/src/commands/publish.mjs +10 -1
  30. package/src/core/skill-resource-closure.mjs +240 -10
  31. package/src/platforms/registry.mjs +12 -0
@@ -14,7 +14,7 @@ import { canonicalJson, digestDocument } from 'skill-family-contracts';
14
14
  import { GATE_FAILED, ReleaseError } from './errors.mjs';
15
15
  import { computeFrozenSnapshot } from '../snapshot/frozen.mjs';
16
16
 
17
- export const CHECKER_VERSION = 'skill-resource-closure-v1';
17
+ export const CHECKER_VERSION = 'skill-resource-closure-v2';
18
18
 
19
19
  const BARE_PREFIXES = ['references/', 'assets/', 'schemas/', 'examples/', 'scripts/'];
20
20
  const EXPLICIT_PREFIXES = [
@@ -27,7 +27,20 @@ const EXPLICIT_PREFIXES = [
27
27
  ];
28
28
  const SOURCE_TREE_PATTERN = /(?:^|\/)packages\/[A-Za-z0-9._-]+(?:\/|$)/u;
29
29
  const MACHINE_ABSOLUTE_PATTERN = /^(?:\/(?:Users|home|root|tmp|var|etc|opt)(?:\/|$)|[A-Za-z]:[\\/])/u;
30
+ // G2: user-home search paths (`~/...`, `$HOME/...`, `${HOME}/...`) with at
31
+ // least one trailing path character. Only code-span/fence/link tokens reach
32
+ // isPathToken, so prose words are never matched; fail-closed over silent miss.
33
+ const HOME_DIRECTORY_PATTERN = /^(?:~|\$HOME|\$\{HOME\})\/.+/u;
34
+ const ADAPTER_SURFACE_PATTERN = /^adapters\/([^/]+)$/u;
35
+ // G3: skill-local resource closure directories. Regular files inside these
36
+ // directories (under a skill directory) must be referenced by a SKILL.md of
37
+ // the same surface or they are reported as stale on adapter surfaces.
38
+ const CLOSURE_RESOURCE_DIRS = Object.freeze(['references', 'assets', 'schemas', 'examples', 'scripts']);
30
39
  const GLOB_PATTERN = /[*?\[]/u;
40
+ // D3: the source-only exemption is scoped to the code snippet that carries the
41
+ // marker — the same fence line, inline code span, or link target from which
42
+ // the token was extracted. A prose marker elsewhere on the line exempts
43
+ // nothing (fail-closed over a line-wide false pass).
31
44
  const SOURCE_ONLY_MARKER = /(?:\bsource-only\b|仅源码包)/iu;
32
45
  const TRANSPORT_EXCLUSIONS = Object.freeze([
33
46
  '.git',
@@ -41,7 +54,10 @@ export const CLASSIFICATION = Object.freeze({
41
54
  PLUGIN_ROOT: 'plugin_root',
42
55
  SOURCE_BACKJUMP: 'source_backjump',
43
56
  MACHINE_ABSOLUTE: 'machine_absolute',
57
+ HOME_DIRECTORY: 'home_directory',
44
58
  OUT_OF_BOUNDS: 'out_of_bounds',
59
+ RESOURCE_DRIFT: 'resource_drift',
60
+ STALE_RESOURCE: 'stale_resource',
45
61
  });
46
62
 
47
63
  export const FINDING_CODE = Object.freeze({
@@ -50,8 +66,11 @@ export const FINDING_CODE = Object.freeze({
50
66
  RESOURCE_NOT_REGULAR_FILE: 'RESOURCE_NOT_REGULAR_FILE',
51
67
  SOURCE_BACKJUMP: 'SOURCE_BACKJUMP',
52
68
  MACHINE_ABSOLUTE_PATH: 'MACHINE_ABSOLUTE_PATH',
69
+ HOME_DIRECTORY_SEARCH: 'HOME_DIRECTORY_SEARCH',
53
70
  OUT_OF_BOUNDS: 'OUT_OF_BOUNDS',
54
71
  SYMLINK_NOT_ALLOWED: 'SYMLINK_NOT_ALLOWED',
72
+ RESOURCE_DRIFT: 'RESOURCE_DRIFT',
73
+ STALE_RESOURCE: 'STALE_RESOURCE',
55
74
  });
56
75
 
57
76
  function toPosix(value) {
@@ -115,6 +134,7 @@ function isPathToken(value) {
115
134
  if (!token || GLOB_PATTERN.test(token)) return false;
116
135
  return BARE_PREFIXES.some((prefix) => token.startsWith(prefix))
117
136
  || EXPLICIT_PREFIXES.some((prefix) => token.startsWith(prefix))
137
+ || HOME_DIRECTORY_PATTERN.test(token)
118
138
  || SOURCE_TREE_PATTERN.test(token)
119
139
  || MACHINE_ABSOLUTE_PATTERN.test(token);
120
140
  }
@@ -144,16 +164,21 @@ export function extractPathTokens(content) {
144
164
  inFence = !inFence;
145
165
  continue;
146
166
  }
147
- const sourceOnly = SOURCE_ONLY_MARKER.test(line);
148
- if (inFence) scanSnippet(line, lineNumber, sourceOnly, results, seen);
167
+ // D3: the source-only marker is evaluated per code snippet, never per
168
+ // line. Only tokens extracted from the very snippet text that contains
169
+ // the marker (this fence line, this inline code span, this link target)
170
+ // are exempt; unrelated tokens elsewhere on the line stay fail-closed.
171
+ if (inFence) scanSnippet(line, lineNumber, SOURCE_ONLY_MARKER.test(line), results, seen);
149
172
 
150
173
  for (const match of line.matchAll(/`([^`]+)`/gu)) {
151
- scanSnippet(match[1], lineNumber, sourceOnly, results, seen);
174
+ scanSnippet(match[1], lineNumber, SOURCE_ONLY_MARKER.test(match[1]), results, seen);
152
175
  }
153
176
  for (const match of line.matchAll(/\]\(([^)]+)\)/gu)) {
154
177
  const target = match[1].trim().split(/[?#]/u)[0];
155
178
  if (!/^(?:https?:|#)/iu.test(target)) {
156
- scanSnippet(target, lineNumber, sourceOnly, results, seen);
179
+ // The whole link-target syntax is the code context for the marker
180
+ // (query/fragment are stripped only for path resolution).
181
+ scanSnippet(target, lineNumber, SOURCE_ONLY_MARKER.test(match[1]), results, seen);
157
182
  }
158
183
  }
159
184
  }
@@ -176,6 +201,14 @@ export function classifyReference(token, skillRoot, pluginRoot, scanRoot) {
176
201
  absoluteTarget: token,
177
202
  };
178
203
  }
204
+ if (HOME_DIRECTORY_PATTERN.test(token)) {
205
+ return {
206
+ classification: CLASSIFICATION.HOME_DIRECTORY,
207
+ resolutionRoot: '(home)',
208
+ resolvedTarget: token,
209
+ absoluteTarget: token,
210
+ };
211
+ }
179
212
  if (SOURCE_TREE_PATTERN.test(token)) {
180
213
  return {
181
214
  classification: CLASSIFICATION.SOURCE_BACKJUMP,
@@ -226,8 +259,29 @@ async function inspectRegularFile(root, target) {
226
259
  return { code: null };
227
260
  }
228
261
 
262
+ async function collectRegularFiles(dir, results) {
263
+ let entries;
264
+ try {
265
+ entries = await readdir(dir, { withFileTypes: true });
266
+ } catch (error) {
267
+ if (error.code === 'ENOENT') return;
268
+ throw error;
269
+ }
270
+ for (const entry of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
271
+ // Symlinks are owned by the snapshot safety gate (SNAPSHOT_UNSAFE); the
272
+ // stale scan never follows or reports them.
273
+ if (entry.isSymbolicLink()) continue;
274
+ const absolute = join(dir, entry.name);
275
+ if (entry.isDirectory()) {
276
+ await collectRegularFiles(absolute, results);
277
+ } else if (entry.isFile()) {
278
+ results.push(absolute);
279
+ }
280
+ }
281
+ }
282
+
229
283
  function inferSurfaceHost(surfaceId, defaultHost) {
230
- const match = /^adapters\/([^/]+)$/u.exec(surfaceId);
284
+ const match = ADAPTER_SURFACE_PATTERN.exec(surfaceId);
231
285
  return match?.[1] ?? defaultHost;
232
286
  }
233
287
 
@@ -239,6 +293,7 @@ function receiptProjection(result) {
239
293
  skillCount: result.skillCount,
240
294
  referenceCount: result.referenceCount,
241
295
  sourceOnlyCount: result.sourceOnlyCount,
296
+ sourceOnlyReferences: result.sourceOnlyReferences,
242
297
  findingCount: result.findings.length,
243
298
  };
244
299
  }
@@ -328,9 +383,31 @@ export async function checkSkillResourceClosure({
328
383
  const skillPaths = await discoverSkills(scanRoot);
329
384
  const findings = [];
330
385
  const surfaceMap = new Map();
386
+ const skillsBySurface = new Map();
387
+ // G3: successfully resolved targets per surface. resolvedResources maps the
388
+ // surface-relative resource path to its absolute target (drift comparison);
389
+ // referencedTargets holds every absolute target some SKILL.md of the surface
390
+ // resolved (stale exemption). resourceReferences records, per surface and
391
+ // surface-relative resource path, the skill/line that referenced it (D4:
392
+ // localizes RESOURCE_DRIFT findings). All stay out of the receipt projection.
393
+ const resolvedResourcesBySurface = new Map();
394
+ const referencedTargetsBySurface = new Map();
395
+ const resourceReferencesBySurface = new Map();
396
+ const sourceOnlyReferences = [];
331
397
  let referenceCount = 0;
332
398
  let sourceOnlyCount = 0;
333
399
 
400
+ const recordSourceOnlyExemption = (surface, skill, pathToken) => {
401
+ sourceOnlyCount += 1;
402
+ surface.sourceOnlyCount += 1;
403
+ sourceOnlyReferences.push({
404
+ skill,
405
+ line: pathToken.line,
406
+ reference: pathToken.token,
407
+ surface: surface.id,
408
+ });
409
+ };
410
+
334
411
  for (const skill of skillPaths) {
335
412
  const skillRoot = resolveSkillRoot(skill, scanRoot);
336
413
  const pluginRoot = resolvePluginRoot(skill, scanRoot);
@@ -345,6 +422,8 @@ export async function checkSkillResourceClosure({
345
422
  };
346
423
  surface.skillCount += 1;
347
424
  surfaceMap.set(surfaceId, surface);
425
+ if (!skillsBySurface.has(surfaceId)) skillsBySurface.set(surfaceId, []);
426
+ skillsBySurface.get(surfaceId).push(skill);
348
427
 
349
428
  const content = await readFile(resolve(scanRoot, skill), 'utf8');
350
429
  for (const pathToken of extractPathTokens(content)) {
@@ -369,8 +448,7 @@ export async function checkSkillResourceClosure({
369
448
 
370
449
  if (classification.classification === CLASSIFICATION.SOURCE_BACKJUMP) {
371
450
  if (pathToken.sourceOnly) {
372
- sourceOnlyCount += 1;
373
- surface.sourceOnlyCount += 1;
451
+ recordSourceOnlyExemption(surface, skill, pathToken);
374
452
  } else {
375
453
  findings.push({ ...findingBase, code: FINDING_CODE.SOURCE_BACKJUMP });
376
454
  }
@@ -380,6 +458,10 @@ export async function checkSkillResourceClosure({
380
458
  findings.push({ ...findingBase, code: FINDING_CODE.MACHINE_ABSOLUTE_PATH });
381
459
  continue;
382
460
  }
461
+ if (classification.classification === CLASSIFICATION.HOME_DIRECTORY) {
462
+ findings.push({ ...findingBase, code: FINDING_CODE.HOME_DIRECTORY_SEARCH });
463
+ continue;
464
+ }
383
465
  if (classification.classification === CLASSIFICATION.OUT_OF_BOUNDS) {
384
466
  findings.push({ ...findingBase, code: FINDING_CODE.OUT_OF_BOUNDS });
385
467
  continue;
@@ -394,15 +476,138 @@ export async function checkSkillResourceClosure({
394
476
  );
395
477
  if (inspection.code) {
396
478
  if (pathToken.sourceOnly && inspection.code === FINDING_CODE.RESOURCE_MISSING) {
397
- sourceOnlyCount += 1;
398
- surface.sourceOnlyCount += 1;
479
+ recordSourceOnlyExemption(surface, skill, pathToken);
399
480
  } else {
400
481
  findings.push({ ...findingBase, code: inspection.code });
401
482
  }
483
+ continue;
484
+ }
485
+
486
+ const relativeResource = relativeStable(pluginRoot, classification.absoluteTarget);
487
+ if (!resolvedResourcesBySurface.has(surfaceId)) {
488
+ resolvedResourcesBySurface.set(surfaceId, new Map());
489
+ referencedTargetsBySurface.set(surfaceId, new Set());
490
+ resourceReferencesBySurface.set(surfaceId, new Map());
491
+ }
492
+ resolvedResourcesBySurface.get(surfaceId).set(relativeResource, classification.absoluteTarget);
493
+ referencedTargetsBySurface.get(surfaceId).add(classification.absoluteTarget);
494
+ const resourceReferences = resourceReferencesBySurface.get(surfaceId);
495
+ if (!resourceReferences.has(relativeResource)) resourceReferences.set(relativeResource, []);
496
+ resourceReferences.get(relativeResource).push({
497
+ surface: surfaceId,
498
+ skill,
499
+ line: pathToken.line,
500
+ });
501
+ }
502
+ }
503
+
504
+ // G3 RESOURCE_DRIFT: the same surface-relative resource path resolved on two
505
+ // surfaces must be byte-identical (typical case: root vs adapters/<name>
506
+ // projections of the same skill resource). Content digests are cached per
507
+ // absolute target.
508
+ const contentDigestCache = new Map();
509
+ const digestOfTarget = async (absoluteTarget) => {
510
+ if (!contentDigestCache.has(absoluteTarget)) {
511
+ contentDigestCache.set(
512
+ absoluteTarget,
513
+ digestDocument({ content: await readFile(absoluteTarget, 'utf8') }),
514
+ );
515
+ }
516
+ return contentDigestCache.get(absoluteTarget);
517
+ };
518
+ const surfaceIds = [...resolvedResourcesBySurface.keys()].sort((a, b) => a.localeCompare(b));
519
+ for (let i = 0; i < surfaceIds.length; i += 1) {
520
+ for (let j = i + 1; j < surfaceIds.length; j += 1) {
521
+ const [leftId, rightId] = [surfaceIds[i], surfaceIds[j]];
522
+ const left = resolvedResourcesBySurface.get(leftId);
523
+ const right = resolvedResourcesBySurface.get(rightId);
524
+ const commonPaths = [...left.keys()]
525
+ .filter((path) => right.has(path))
526
+ .sort((a, b) => a.localeCompare(b));
527
+ for (const resourcePath of commonPaths) {
528
+ const [leftDigest, rightDigest] = await Promise.all([
529
+ digestOfTarget(left.get(resourcePath)),
530
+ digestOfTarget(right.get(resourcePath)),
531
+ ]);
532
+ if (leftDigest === rightDigest) continue;
533
+ const rightSurface = surfaceMap.get(rightId);
534
+ // D4: localize the drift — list every skill/line on both surfaces that
535
+ // resolved this resource path (deduped, deterministically sorted).
536
+ // Findings never enter the receipt projection, so this is digest-safe.
537
+ const driftReferences = [];
538
+ const seenReference = new Set();
539
+ for (const surfaceId of [leftId, rightId]) {
540
+ for (const ref of resourceReferencesBySurface.get(surfaceId)?.get(resourcePath) ?? []) {
541
+ const key = `${ref.surface}\u0000${ref.skill}\u0000${ref.line}`;
542
+ if (seenReference.has(key)) continue;
543
+ seenReference.add(key);
544
+ driftReferences.push({ surface: ref.surface, skill: ref.skill, line: ref.line });
545
+ }
546
+ }
547
+ driftReferences.sort((a, b) => (
548
+ a.surface.localeCompare(b.surface)
549
+ || a.skill.localeCompare(b.skill)
550
+ || (a.line - b.line)
551
+ ));
552
+ findings.push({
553
+ host: rightSurface.host,
554
+ surface: rightId,
555
+ skill: null,
556
+ line: null,
557
+ reference: resourcePath,
558
+ classification: CLASSIFICATION.RESOURCE_DRIFT,
559
+ resolutionRoot: rightId,
560
+ resolvedTarget: resourcePath,
561
+ surfaces: [leftId, rightId],
562
+ references: driftReferences,
563
+ code: FINDING_CODE.RESOURCE_DRIFT,
564
+ });
565
+ }
566
+ }
567
+ }
568
+
569
+ // G3 STALE_RESOURCE: on adapter surfaces only, every regular file inside a
570
+ // skill's resource closure directories must be referenced by some SKILL.md
571
+ // of the same surface (bare or explicit plugin-root form). The root surface
572
+ // is exempt (repository roots carry many legitimate unreferenced files).
573
+ for (const surfaceId of [...skillsBySurface.keys()].sort((a, b) => a.localeCompare(b))) {
574
+ if (!ADAPTER_SURFACE_PATTERN.test(surfaceId)) continue;
575
+ const surface = surfaceMap.get(surfaceId);
576
+ const surfaceRootAbsolute = resolve(scanRoot, surfaceId);
577
+ const referencedTargets = referencedTargetsBySurface.get(surfaceId) ?? new Set();
578
+ const stale = [];
579
+ for (const skill of skillsBySurface.get(surfaceId)) {
580
+ const skillDir = resolve(scanRoot, dirname(skill));
581
+ for (const closureDir of CLOSURE_RESOURCE_DIRS) {
582
+ const files = [];
583
+ await collectRegularFiles(join(skillDir, closureDir), files);
584
+ for (const absolute of files) {
585
+ if (referencedTargets.has(absolute)) continue;
586
+ const relativeResource = relativeStable(surfaceRootAbsolute, absolute);
587
+ stale.push({
588
+ host: surface.host,
589
+ surface: surfaceId,
590
+ skill,
591
+ line: null,
592
+ reference: relativeResource,
593
+ classification: CLASSIFICATION.STALE_RESOURCE,
594
+ resolutionRoot: surfaceId,
595
+ resolvedTarget: relativeResource,
596
+ code: FINDING_CODE.STALE_RESOURCE,
597
+ });
598
+ }
402
599
  }
403
600
  }
601
+ stale.sort((a, b) => (a.skill.localeCompare(b.skill) || a.resolvedTarget.localeCompare(b.resolvedTarget)));
602
+ findings.push(...stale);
404
603
  }
405
604
 
605
+ sourceOnlyReferences.sort((a, b) => (
606
+ a.skill.localeCompare(b.skill)
607
+ || (a.line - b.line)
608
+ || a.reference.localeCompare(b.reference)
609
+ ));
610
+
406
611
  if (snapshotError) {
407
612
  findings.push({
408
613
  host,
@@ -426,8 +631,33 @@ export async function checkSkillResourceClosure({
426
631
  skillCount: skillPaths.length,
427
632
  referenceCount,
428
633
  sourceOnlyCount,
634
+ sourceOnlyReferences,
429
635
  findings,
430
636
  };
431
637
  result.receiptDigest = digestDocument(receiptProjection(result));
432
638
  return result;
433
639
  }
640
+
641
+ /**
642
+ * G4: reconcile declared plugin distributions against observed surfaces.
643
+ *
644
+ * Every declared plugin host must be present among the receipt surfaces with
645
+ * at least one skill; a host whose adapter tree was dropped from publicFiles
646
+ * (or whose projection produced no skills) must fail closed at prepare.
647
+ * `expectedHosts` are adapter directory names (e.g. `codebuddy-plugin`
648
+ * expects the historical `workbuddy` adapter directory).
649
+ *
650
+ * @param {string[]} expectedHosts - declared plugin host surface names
651
+ * @param {Array<{ id: string, host: string, skillCount: number }>} surfaces
652
+ * @returns {{ passed: boolean, missing: Array<{ host: string, skillCount: number }> }}
653
+ */
654
+ export function evaluateDeclaredHostSurfaceCoverage(expectedHosts, surfaces) {
655
+ const missing = [];
656
+ for (const expectedHost of [...new Set(expectedHosts ?? [])].sort((a, b) => a.localeCompare(b))) {
657
+ const surface = (surfaces ?? []).find((item) => item.host === expectedHost);
658
+ if (!surface || !(surface.skillCount >= 1)) {
659
+ missing.push({ host: expectedHost, skillCount: surface?.skillCount ?? 0 });
660
+ }
661
+ }
662
+ return { passed: missing.length === 0, missing };
663
+ }
@@ -179,6 +179,9 @@ const CLAUDE = Object.freeze({
179
179
  schemaRequiredFields: Object.freeze(['plugin', 'marketplace', 'entrySkill']),
180
180
  skillRendering: Object.freeze({ mode: 'verbatim', preamble: null, placeholder: '${CLAUDE_PLUGIN_ROOT}' }),
181
181
  buildAdapter: Object.freeze({
182
+ // D6: explicit adapter directory name (adapters/claude/); assertRegistry
183
+ // requires this on every platform.
184
+ name: 'claude',
182
185
  pluginDirName: '.claude-plugin',
183
186
  templateFileName: 'plugin.json',
184
187
  marketplaceFileName: 'marketplace.json',
@@ -237,6 +240,8 @@ const CODEX = Object.freeze({
237
240
  schemaRequiredFields: Object.freeze(['plugin', 'marketplace', 'entrySkill']),
238
241
  skillRendering: Object.freeze({ mode: 'substitute', preamble: 'codex', placeholder: '${CLAUDE_PLUGIN_ROOT}' }),
239
242
  buildAdapter: Object.freeze({
243
+ // D6: explicit adapter directory name (adapters/codex/).
244
+ name: 'codex',
240
245
  pluginDirName: '.codex-plugin',
241
246
  templateFileName: 'plugin.json',
242
247
  marketplaceFileName: null,
@@ -294,6 +299,8 @@ const KIMI = Object.freeze({
294
299
  schemaRequiredFields: Object.freeze(['plugin', 'entrySkill']),
295
300
  skillRendering: Object.freeze({ mode: 'substitute', preamble: 'kimi', placeholder: '${CLAUDE_PLUGIN_ROOT}' }),
296
301
  buildAdapter: Object.freeze({
302
+ // D6: explicit adapter directory name (adapters/kimi/).
303
+ name: 'kimi',
297
304
  pluginDirName: '.kimi-plugin',
298
305
  templateFileName: 'plugin.json',
299
306
  marketplaceFileName: null,
@@ -478,6 +485,11 @@ export function assertRegistry(registry = PLATFORMS) {
478
485
  if (!platform.buildAdapter || typeof platform.buildAdapter !== 'object') {
479
486
  throw new Error(`platform registry: ${label} buildAdapter must be an object`);
480
487
  }
488
+ // D6: the adapter directory name is always explicit — prepare's G4
489
+ // declared-host reconciliation and the build producer both key on it.
490
+ if (typeof platform.buildAdapter.name !== 'string' || platform.buildAdapter.name.length === 0) {
491
+ throw new Error(`platform registry: ${label} buildAdapter needs a non-empty name (its adapter directory name)`);
492
+ }
481
493
  if (!VALID_LIST_OUTPUTS.has(platform.jsonProtocol?.listOutput)) {
482
494
  throw new Error(`platform registry: ${label} has illegal jsonProtocol.listOutput "${platform.jsonProtocol?.listOutput}"`);
483
495
  }