@intentius/chant 0.44.13 → 0.45.0

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 (175) hide show
  1. package/dist/audit/discover.d.ts +26 -2
  2. package/dist/audit/discover.d.ts.map +1 -1
  3. package/dist/audit/report-model.d.ts +10 -0
  4. package/dist/audit/report-model.d.ts.map +1 -1
  5. package/dist/audit/rules-doc.d.ts.map +1 -1
  6. package/dist/cli/build-params-cli.d.ts +16 -7
  7. package/dist/cli/build-params-cli.d.ts.map +1 -1
  8. package/dist/cli/commands/audit.d.ts +20 -0
  9. package/dist/cli/commands/audit.d.ts.map +1 -1
  10. package/dist/cli/commands/build.d.ts +9 -0
  11. package/dist/cli/commands/build.d.ts.map +1 -1
  12. package/dist/cli/commands/check-lexicon-docs.d.ts +13 -0
  13. package/dist/cli/commands/check-lexicon-docs.d.ts.map +1 -1
  14. package/dist/cli/commands/check-lexicon-examples.d.ts +24 -14
  15. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
  16. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  17. package/dist/cli/commands/init-lexicon/templates/codegen.d.ts.map +1 -1
  18. package/dist/cli/commands/init-lexicon/templates/docs.d.ts +5 -0
  19. package/dist/cli/commands/init-lexicon/templates/docs.d.ts.map +1 -1
  20. package/dist/cli/commands/init-lexicon.d.ts.map +1 -1
  21. package/dist/cli/commands/onboard.d.ts +28 -0
  22. package/dist/cli/commands/onboard.d.ts.map +1 -1
  23. package/dist/cli/handlers/build.d.ts.map +1 -1
  24. package/dist/cli/handlers/graph.d.ts.map +1 -1
  25. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  26. package/dist/cli/handlers/run.d.ts.map +1 -1
  27. package/dist/cli/handlers/search.d.ts +27 -0
  28. package/dist/cli/handlers/search.d.ts.map +1 -1
  29. package/dist/cli/main.d.ts.map +1 -1
  30. package/dist/codegen/docs-pages.d.ts +39 -0
  31. package/dist/codegen/docs-pages.d.ts.map +1 -0
  32. package/dist/codegen/docs-sections.d.ts.map +1 -1
  33. package/dist/codegen/docs-sidebar.d.ts +13 -2
  34. package/dist/codegen/docs-sidebar.d.ts.map +1 -1
  35. package/dist/codegen/docs-types.d.ts +30 -10
  36. package/dist/codegen/docs-types.d.ts.map +1 -1
  37. package/dist/codegen/docs.d.ts +14 -2
  38. package/dist/codegen/docs.d.ts.map +1 -1
  39. package/dist/codegen/surface-snapshot.d.ts +27 -3
  40. package/dist/codegen/surface-snapshot.d.ts.map +1 -1
  41. package/dist/codegen/validate.d.ts +11 -3
  42. package/dist/codegen/validate.d.ts.map +1 -1
  43. package/dist/components/capability-plugin.d.ts +13 -2
  44. package/dist/components/capability-plugin.d.ts.map +1 -1
  45. package/dist/components/driver.d.ts +38 -0
  46. package/dist/components/driver.d.ts.map +1 -1
  47. package/dist/components/index.d.ts +1 -1
  48. package/dist/components/index.d.ts.map +1 -1
  49. package/dist/components/starter-plugin.d.ts.map +1 -1
  50. package/dist/config.d.ts +57 -5
  51. package/dist/config.d.ts.map +1 -1
  52. package/dist/discovery/fold-import.d.ts.map +1 -1
  53. package/dist/fold/fold.d.ts +100 -1
  54. package/dist/fold/fold.d.ts.map +1 -1
  55. package/dist/graph-ir.d.ts +12 -0
  56. package/dist/graph-ir.d.ts.map +1 -1
  57. package/dist/graph-ops.d.ts +17 -0
  58. package/dist/graph-ops.d.ts.map +1 -0
  59. package/dist/lifecycle/change-set.d.ts +23 -2
  60. package/dist/lifecycle/change-set.d.ts.map +1 -1
  61. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  62. package/dist/lifecycle/observe.d.ts +8 -0
  63. package/dist/lifecycle/observe.d.ts.map +1 -1
  64. package/dist/lifecycle/replay.d.ts.map +1 -1
  65. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  66. package/dist/lifecycle/types.d.ts +7 -0
  67. package/dist/lifecycle/types.d.ts.map +1 -1
  68. package/dist/lint/policy.d.ts.map +1 -1
  69. package/dist/managed-fields.d.ts +33 -21
  70. package/dist/managed-fields.d.ts.map +1 -1
  71. package/dist/observation.d.ts +23 -1
  72. package/dist/observation.d.ts.map +1 -1
  73. package/dist/op/index.d.ts +1 -1
  74. package/dist/op/index.d.ts.map +1 -1
  75. package/dist/yaml.d.ts +0 -8
  76. package/dist/yaml.d.ts.map +1 -1
  77. package/package.json +1 -1
  78. package/src/__snapshots__/okf.test.ts.snap +20 -0
  79. package/src/audit/discover.test.ts +55 -1
  80. package/src/audit/discover.ts +70 -2
  81. package/src/audit/edge-init-safety.test.ts +149 -18
  82. package/src/audit/report-model.ts +12 -1
  83. package/src/audit/rules-doc.ts +1 -0
  84. package/src/build-params.test.ts +52 -0
  85. package/src/cli/build-params-cli.test.ts +25 -8
  86. package/src/cli/build-params-cli.ts +37 -23
  87. package/src/cli/commands/__fixtures__/audit-coverage/.github/workflows/ci.yml +5 -0
  88. package/src/cli/commands/__fixtures__/audit-coverage/Dockerfile +2 -0
  89. package/src/cli/commands/__fixtures__/audit-coverage/infra/main.tf +3 -0
  90. package/src/cli/commands/__fixtures__/audit-coverage/infra/stack.json +6 -0
  91. package/src/cli/commands/__fixtures__/audit-coverage/k8s/deploy.yaml +12 -0
  92. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/pages/getting-started.mdx +16 -0
  93. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content.config.ts +10 -2
  94. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate-cli.ts +3 -1
  95. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate.ts +2 -1
  96. package/src/cli/commands/__snapshots__/init-lexicon.test.ts.snap +2 -1
  97. package/src/cli/commands/audit.test.ts +83 -1
  98. package/src/cli/commands/audit.ts +121 -28
  99. package/src/cli/commands/build.test.ts +144 -5
  100. package/src/cli/commands/build.ts +66 -14
  101. package/src/cli/commands/check-lexicon-docs.ts +30 -0
  102. package/src/cli/commands/check-lexicon-examples.test.ts +73 -7
  103. package/src/cli/commands/check-lexicon-examples.ts +66 -17
  104. package/src/cli/commands/check-lexicon.test.ts +1 -1
  105. package/src/cli/commands/check-lexicon.ts +19 -4
  106. package/src/cli/commands/init-lexicon/templates/codegen.ts +5 -2
  107. package/src/cli/commands/init-lexicon/templates/docs.ts +35 -2
  108. package/src/cli/commands/init-lexicon.test.ts +42 -1
  109. package/src/cli/commands/init-lexicon.ts +3 -1
  110. package/src/cli/commands/lexicon-rolling-upgrade.ts +1 -1
  111. package/src/cli/commands/lexicon-surface-diff.test.ts +1 -1
  112. package/src/cli/commands/lexicon-surface-diff.ts +1 -1
  113. package/src/cli/commands/onboard.test.ts +319 -225
  114. package/src/cli/commands/onboard.ts +144 -58
  115. package/src/cli/handlers/build.test.ts +3 -3
  116. package/src/cli/handlers/build.ts +2 -0
  117. package/src/cli/handlers/components.ts +2 -2
  118. package/src/cli/handlers/graph.test.ts +44 -0
  119. package/src/cli/handlers/graph.ts +22 -2
  120. package/src/cli/handlers/lifecycle.test.ts +117 -0
  121. package/src/cli/handlers/lifecycle.ts +16 -0
  122. package/src/cli/handlers/run.test.ts +5 -5
  123. package/src/cli/handlers/run.ts +1 -0
  124. package/src/cli/handlers/search-live.test.ts +217 -0
  125. package/src/cli/handlers/search.test.ts +41 -1
  126. package/src/cli/handlers/search.ts +55 -6
  127. package/src/cli/main.ts +5 -3
  128. package/src/cli/security-boundary.test.ts +2 -1
  129. package/src/codegen/docs-pages.test.ts +194 -0
  130. package/src/codegen/docs-pages.ts +138 -0
  131. package/src/codegen/docs-sections.ts +0 -8
  132. package/src/codegen/docs-sidebar.ts +50 -37
  133. package/src/codegen/docs-types.ts +32 -4
  134. package/src/codegen/docs.ts +78 -40
  135. package/src/codegen/lexicon-regen.ts +3 -1
  136. package/src/codegen/rolling-upgrade.test.ts +1 -1
  137. package/src/codegen/rolling-upgrade.ts +1 -1
  138. package/src/codegen/surface-snapshot.test.ts +139 -0
  139. package/src/codegen/surface-snapshot.ts +297 -122
  140. package/src/codegen/validate.test.ts +16 -3
  141. package/src/codegen/validate.ts +17 -7
  142. package/src/components/auto-release.ts +1 -1
  143. package/src/components/capability-plugin.ts +23 -2
  144. package/src/components/driver.test.ts +45 -0
  145. package/src/components/driver.ts +64 -27
  146. package/src/components/index.ts +2 -0
  147. package/src/components/starter-plugin.ts +5 -2
  148. package/src/config.test.ts +56 -1
  149. package/src/config.ts +152 -8
  150. package/src/discovery/fold-import.test.ts +427 -2
  151. package/src/discovery/fold-import.ts +163 -7
  152. package/src/discovery/sandbox/driver.test.ts +11 -0
  153. package/src/fold/fold.test.ts +41 -0
  154. package/src/fold/fold.ts +372 -2
  155. package/src/graph-ir-live.test.ts +42 -0
  156. package/src/graph-ir.ts +42 -2
  157. package/src/graph-ops.test.ts +69 -0
  158. package/src/graph-ops.ts +42 -0
  159. package/src/lifecycle/change-set.test.ts +41 -0
  160. package/src/lifecycle/change-set.ts +30 -2
  161. package/src/lifecycle/deep-observe.ts +4 -0
  162. package/src/lifecycle/observe.ts +29 -2
  163. package/src/lifecycle/replay.ts +3 -0
  164. package/src/lifecycle/snapshot.ts +3 -0
  165. package/src/lifecycle/types.ts +7 -0
  166. package/src/lint/policy.ts +14 -3
  167. package/src/managed-fields.test.ts +15 -42
  168. package/src/managed-fields.ts +43 -26
  169. package/src/observation.test.ts +24 -8
  170. package/src/observation.ts +48 -4
  171. package/src/op/builders-exports.test.ts +60 -0
  172. package/src/op/index.ts +1 -1
  173. package/src/ownership.test.ts +63 -1
  174. package/src/yaml.test.ts +15 -0
  175. package/src/yaml.ts +19 -2
@@ -72,8 +72,8 @@ export interface RemovedEntry {
72
72
  entry: SurfaceEntry;
73
73
  }
74
74
 
75
- export interface ChangedEntry {
76
- name: string;
75
+ /** Structural differences between two versions of one entry. */
76
+ export interface EntryChanges {
77
77
  /** New resourceType (breaking). */
78
78
  resourceTypeChanged?: { before: string; after: string };
79
79
  /** kind changed from resource to property or vice-versa (breaking). */
@@ -96,14 +96,40 @@ export interface ChangedEntry {
96
96
  taggableChanged?: { before?: boolean; after?: boolean };
97
97
  }
98
98
 
99
+ export interface ChangedEntry extends EntryChanges {
100
+ name: string;
101
+ }
102
+
103
+ /**
104
+ * An entry whose TS export name changed while its kind and resourceType
105
+ * stayed the same (#1460). Paired from one removal and one addition.
106
+ */
107
+ export interface RenamedEntry {
108
+ /** TS export name in the baseline. */
109
+ from: string;
110
+ /** TS export name in the fresh surface. */
111
+ to: string;
112
+ /** The entry as it now appears. */
113
+ entry: SurfaceEntry;
114
+ /** Structural differences between the two, when any. */
115
+ changes?: EntryChanges;
116
+ }
117
+
99
118
  export interface SurfaceDelta {
100
119
  added: AddedEntry[];
101
120
  changed: ChangedEntry[];
102
121
  removed: RemovedEntry[];
122
+ /**
123
+ * Removals and additions paired by kind + resourceType. A rename still
124
+ * breaks the old import, so it counts as breaking; it is reported apart
125
+ * from removals so a reader can tell the two kinds of break apart.
126
+ */
127
+ renamed: RenamedEntry[];
103
128
  /**
104
129
  * Rolled-up severity. "none" means no surface changes at all.
105
130
  * "additive" means only new resources / new optional props / new attrs.
106
- * "breaking" means any removal or change that could break existing consumers.
131
+ * "breaking" means any removal, rename, or change that could break
132
+ * existing consumers.
107
133
  */
108
134
  severity: ChangeSeverity;
109
135
  }
@@ -314,9 +340,9 @@ export function extractPropsFromDts(dts: string): Map<string, string[]> {
314
340
  * - taggable: false→true or undefined→true → additive
315
341
  */
316
342
  export function diffSurface(baseline: SurfaceSnapshot, fresh: SurfaceSnapshot): SurfaceDelta {
317
- const added: AddedEntry[] = [];
318
343
  const changed: ChangedEntry[] = [];
319
- const removed: RemovedEntry[] = [];
344
+ let added: AddedEntry[] = [];
345
+ let removed: RemovedEntry[] = [];
320
346
 
321
347
  const baseNames = new Set(Object.keys(baseline.entries));
322
348
  const freshNames = new Set(Object.keys(fresh.entries));
@@ -335,119 +361,253 @@ export function diffSurface(baseline: SurfaceSnapshot, fresh: SurfaceSnapshot):
335
361
  }
336
362
  }
337
363
 
364
+ // Renamed (#1460): a removal and an addition that carry the same
365
+ // resourceType and kind are one entry under a new TS name. Pair them
366
+ // before classifying so the report shows what upstream actually did.
367
+ const renamed: RenamedEntry[] = [];
368
+ const paired = pairRenames(removed, added);
369
+ for (const { from, to } of paired) {
370
+ const entry: RenamedEntry = { from: from.name, to: to.name, entry: to.entry };
371
+ const changes = diffEntries(from.entry, to.entry);
372
+ if (changes) entry.changes = changes;
373
+ renamed.push(entry);
374
+ }
375
+ if (paired.length > 0) {
376
+ const fromNames = new Set(paired.map((p) => p.from.name));
377
+ const toNames = new Set(paired.map((p) => p.to.name));
378
+ removed = removed.filter((r) => !fromNames.has(r.name));
379
+ added = added.filter((a) => !toNames.has(a.name));
380
+ }
381
+ renamed.sort((a, b) => a.from.localeCompare(b.from));
382
+
338
383
  // Changed
339
384
  for (const name of baseNames) {
340
385
  if (!freshNames.has(name)) continue;
386
+ const changes = diffEntries(baseline.entries[name], fresh.entries[name]);
387
+ if (changes) changed.push({ name, ...changes });
388
+ }
341
389
 
342
- const before = baseline.entries[name];
343
- const after = fresh.entries[name];
390
+ // Severity
391
+ let severity: ChangeSeverity = "none";
344
392
 
345
- const change: ChangedEntry = { name };
346
- let hasChange = false;
393
+ if (added.length > 0) {
394
+ severity = "additive";
395
+ }
347
396
 
348
- if (before.resourceType !== after.resourceType) {
349
- change.resourceTypeChanged = { before: before.resourceType, after: after.resourceType };
350
- hasChange = true;
351
- }
397
+ // Additive-only changes in the changed[] set
398
+ const hasAdditiveChanges = changed.some(isAdditiveChange);
352
399
 
353
- if (before.kind !== after.kind) {
354
- change.kindChanged = { before: before.kind, after: after.kind };
355
- hasChange = true;
356
- }
400
+ if (hasAdditiveChanges && severity === "none") {
401
+ severity = "additive";
402
+ }
357
403
 
358
- // Props diff (only meaningful for resource entries)
359
- const beforeProps = new Map(
360
- (before.props ?? []).map((p) => {
361
- const [nm, req] = splitProp(p);
362
- return [nm, req === "true"];
363
- }),
364
- );
365
- const afterProps = new Map(
366
- (after.props ?? []).map((p) => {
367
- const [nm, req] = splitProp(p);
368
- return [nm, req === "true"];
369
- }),
370
- );
404
+ // A rename is still breaking for the consumer: the old import no longer
405
+ // resolves, whether the entry is a resource or a property type. It is
406
+ // reported separately so the reader can tell a rename from a deletion.
407
+ const hasBreaking =
408
+ removed.length > 0 ||
409
+ renamed.length > 0 ||
410
+ changed.some(isBreakingChange);
371
411
 
372
- const removedProps = [...beforeProps.keys()].filter((k) => !afterProps.has(k));
373
- const addedProps = [...afterProps.keys()].filter((k) => !beforeProps.has(k));
374
- const nowRequired = [...beforeProps.keys()].filter(
375
- (k) => afterProps.has(k) && !beforeProps.get(k) && afterProps.get(k),
376
- );
377
- const nowOptional = [...beforeProps.keys()].filter(
378
- (k) => afterProps.has(k) && beforeProps.get(k) && !afterProps.get(k),
379
- );
412
+ if (hasBreaking) severity = "breaking";
380
413
 
381
- if (removedProps.length > 0) { change.removedProps = removedProps.sort(); hasChange = true; }
382
- if (addedProps.length > 0) { change.addedProps = addedProps.sort(); hasChange = true; }
383
- if (nowRequired.length > 0) { change.nowRequired = nowRequired.sort(); hasChange = true; }
384
- if (nowOptional.length > 0) { change.nowOptional = nowOptional.sort(); hasChange = true; }
385
-
386
- // Attrs diff
387
- const beforeAttrs = new Set(before.attrs ?? []);
388
- const afterAttrs = new Set(after.attrs ?? []);
389
- const removedAttrs = [...beforeAttrs].filter((a) => !afterAttrs.has(a));
390
- const addedAttrs = [...afterAttrs].filter((a) => !beforeAttrs.has(a));
391
- if (removedAttrs.length > 0) { change.removedAttrs = removedAttrs.sort(); hasChange = true; }
392
- if (addedAttrs.length > 0) { change.addedAttrs = addedAttrs.sort(); hasChange = true; }
393
-
394
- // createOnly diff
395
- const beforeCO = JSON.stringify((before.createOnly ?? []).sort());
396
- const afterCO = JSON.stringify((after.createOnly ?? []).sort());
397
- if (beforeCO !== afterCO) {
398
- change.createOnlyChanged = {
399
- before: before.createOnly ?? [],
400
- after: after.createOnly ?? [],
401
- };
402
- hasChange = true;
403
- }
414
+ return { added, changed, removed, renamed, severity };
415
+ }
404
416
 
405
- // taggable diff
406
- if (before.taggable !== after.taggable) {
407
- change.taggableChanged = { before: before.taggable, after: after.taggable };
408
- hasChange = true;
409
- }
417
+ /**
418
+ * Diff two entries that are (or are taken to be) the same thing. Returns
419
+ * `null` when nothing differs.
420
+ */
421
+ function diffEntries(before: SurfaceEntry, after: SurfaceEntry): EntryChanges | null {
422
+ const change: EntryChanges = {};
423
+ let hasChange = false;
410
424
 
411
- if (hasChange) changed.push(change);
425
+ if (before.resourceType !== after.resourceType) {
426
+ change.resourceTypeChanged = { before: before.resourceType, after: after.resourceType };
427
+ hasChange = true;
412
428
  }
413
429
 
414
- // Severity
415
- let severity: ChangeSeverity = "none";
430
+ if (before.kind !== after.kind) {
431
+ change.kindChanged = { before: before.kind, after: after.kind };
432
+ hasChange = true;
433
+ }
416
434
 
417
- if (added.length > 0) {
418
- severity = "additive";
435
+ // Props diff (only meaningful for resource entries)
436
+ const beforeProps = new Map(
437
+ (before.props ?? []).map((p) => {
438
+ const [nm, req] = splitProp(p);
439
+ return [nm, req === "true"];
440
+ }),
441
+ );
442
+ const afterProps = new Map(
443
+ (after.props ?? []).map((p) => {
444
+ const [nm, req] = splitProp(p);
445
+ return [nm, req === "true"];
446
+ }),
447
+ );
448
+
449
+ const removedProps = [...beforeProps.keys()].filter((k) => !afterProps.has(k));
450
+ const addedProps = [...afterProps.keys()].filter((k) => !beforeProps.has(k));
451
+ const nowRequired = [...beforeProps.keys()].filter(
452
+ (k) => afterProps.has(k) && !beforeProps.get(k) && afterProps.get(k),
453
+ );
454
+ const nowOptional = [...beforeProps.keys()].filter(
455
+ (k) => afterProps.has(k) && beforeProps.get(k) && !afterProps.get(k),
456
+ );
457
+
458
+ if (removedProps.length > 0) { change.removedProps = removedProps.sort(); hasChange = true; }
459
+ if (addedProps.length > 0) { change.addedProps = addedProps.sort(); hasChange = true; }
460
+ if (nowRequired.length > 0) { change.nowRequired = nowRequired.sort(); hasChange = true; }
461
+ if (nowOptional.length > 0) { change.nowOptional = nowOptional.sort(); hasChange = true; }
462
+
463
+ // Attrs diff
464
+ const beforeAttrs = new Set(before.attrs ?? []);
465
+ const afterAttrs = new Set(after.attrs ?? []);
466
+ const removedAttrs = [...beforeAttrs].filter((a) => !afterAttrs.has(a));
467
+ const addedAttrs = [...afterAttrs].filter((a) => !beforeAttrs.has(a));
468
+ if (removedAttrs.length > 0) { change.removedAttrs = removedAttrs.sort(); hasChange = true; }
469
+ if (addedAttrs.length > 0) { change.addedAttrs = addedAttrs.sort(); hasChange = true; }
470
+
471
+ // createOnly diff
472
+ const beforeCO = JSON.stringify((before.createOnly ?? []).sort());
473
+ const afterCO = JSON.stringify((after.createOnly ?? []).sort());
474
+ if (beforeCO !== afterCO) {
475
+ change.createOnlyChanged = {
476
+ before: before.createOnly ?? [],
477
+ after: after.createOnly ?? [],
478
+ };
479
+ hasChange = true;
419
480
  }
420
481
 
421
- // Additive-only changes in the changed[] set
422
- const hasAdditiveChanges = changed.some((c) =>
482
+ // taggable diff
483
+ if (before.taggable !== after.taggable) {
484
+ change.taggableChanged = { before: before.taggable, after: after.taggable };
485
+ hasChange = true;
486
+ }
487
+
488
+ return hasChange ? change : null;
489
+ }
490
+
491
+ function isAdditiveChange(c: EntryChanges): boolean {
492
+ return (
423
493
  (c.addedProps?.length ?? 0) > 0 ||
424
494
  (c.nowOptional?.length ?? 0) > 0 ||
425
495
  (c.addedAttrs?.length ?? 0) > 0 ||
426
496
  // gaining tagging is additive
427
- (c.taggableChanged !== undefined && c.taggableChanged.after === true && c.taggableChanged.before !== true),
497
+ (c.taggableChanged !== undefined && c.taggableChanged.after === true && c.taggableChanged.before !== true)
498
+ );
499
+ }
500
+
501
+ function isBreakingChange(c: EntryChanges): boolean {
502
+ return (
503
+ c.resourceTypeChanged !== undefined ||
504
+ c.kindChanged !== undefined ||
505
+ (c.removedProps?.length ?? 0) > 0 ||
506
+ (c.nowRequired?.length ?? 0) > 0 ||
507
+ (c.removedAttrs?.length ?? 0) > 0 ||
508
+ c.createOnlyChanged !== undefined ||
509
+ // losing taggable is breaking
510
+ (c.taggableChanged !== undefined && c.taggableChanged.before === true && c.taggableChanged.after !== true)
428
511
  );
512
+ }
429
513
 
430
- if (hasAdditiveChanges && severity === "none") {
431
- severity = "additive";
514
+ // ── Rename pairing ───────────────────────────────────────────────────
515
+
516
+ interface RenamePair {
517
+ from: RemovedEntry;
518
+ to: AddedEntry;
519
+ }
520
+
521
+ /**
522
+ * Pair removed and added entries that share a kind and resourceType.
523
+ *
524
+ * One removal and one addition under a resourceType pair directly. When a
525
+ * resourceType has several of each (property types of one renamed parent
526
+ * resource, for instance), pairs are chosen greedily by name similarity and
527
+ * whatever is left over stays a plain removal or addition. Entries with an
528
+ * empty resourceType are never paired; there is nothing to key them on.
529
+ */
530
+ function pairRenames(removed: RemovedEntry[], added: AddedEntry[]): RenamePair[] {
531
+ const key = (e: SurfaceEntry): string => `${e.kind} ${e.resourceType}`;
532
+
533
+ const addedByKey = new Map<string, AddedEntry[]>();
534
+ for (const a of added) {
535
+ if (!a.entry.resourceType) continue;
536
+ const k = key(a.entry);
537
+ const bucket = addedByKey.get(k);
538
+ if (bucket) bucket.push(a);
539
+ else addedByKey.set(k, [a]);
432
540
  }
433
541
 
434
- const hasBreaking =
435
- removed.length > 0 ||
436
- changed.some(
437
- (c) =>
438
- c.resourceTypeChanged !== undefined ||
439
- c.kindChanged !== undefined ||
440
- (c.removedProps?.length ?? 0) > 0 ||
441
- (c.nowRequired?.length ?? 0) > 0 ||
442
- (c.removedAttrs?.length ?? 0) > 0 ||
443
- (c.createOnlyChanged !== undefined) ||
444
- // losing taggable is breaking
445
- (c.taggableChanged !== undefined && c.taggableChanged.before === true && c.taggableChanged.after !== true),
542
+ const removedByKey = new Map<string, RemovedEntry[]>();
543
+ for (const r of removed) {
544
+ if (!r.entry.resourceType) continue;
545
+ const k = key(r.entry);
546
+ if (!addedByKey.has(k)) continue;
547
+ const bucket = removedByKey.get(k);
548
+ if (bucket) bucket.push(r);
549
+ else removedByKey.set(k, [r]);
550
+ }
551
+
552
+ const pairs: RenamePair[] = [];
553
+ for (const [k, olds] of removedByKey) {
554
+ const news = addedByKey.get(k) ?? [];
555
+ if (olds.length === 1 && news.length === 1) {
556
+ pairs.push({ from: olds[0], to: news[0] });
557
+ continue;
558
+ }
559
+
560
+ // Ambiguous: rank every (old, new) combination by similarity and take
561
+ // the best remaining pair until one side runs out.
562
+ const candidates: Array<{ from: RemovedEntry; to: AddedEntry; score: number }> = [];
563
+ for (const from of olds) {
564
+ for (const to of news) {
565
+ candidates.push({ from, to, score: nameSimilarity(from.name, to.name) });
566
+ }
567
+ }
568
+ candidates.sort((a, b) =>
569
+ b.score - a.score ||
570
+ a.from.name.localeCompare(b.from.name) ||
571
+ a.to.name.localeCompare(b.to.name),
446
572
  );
573
+ const usedOld = new Set<string>();
574
+ const usedNew = new Set<string>();
575
+ for (const cand of candidates) {
576
+ if (usedOld.has(cand.from.name) || usedNew.has(cand.to.name)) continue;
577
+ usedOld.add(cand.from.name);
578
+ usedNew.add(cand.to.name);
579
+ pairs.push({ from: cand.from, to: cand.to });
580
+ if (usedOld.size === olds.length || usedNew.size === news.length) break;
581
+ }
582
+ }
447
583
 
448
- if (hasBreaking) severity = "breaking";
584
+ return pairs;
585
+ }
449
586
 
450
- return { added, changed, removed, severity };
587
+ /**
588
+ * Dice similarity over character bigrams, in [0, 1]. Enough to tell
589
+ * `Space` apart from `Space_Tag` when pairing the property types of a
590
+ * renamed parent; nothing heavier is needed.
591
+ */
592
+ function nameSimilarity(a: string, b: string): number {
593
+ if (a === b) return 1;
594
+ if (a.length < 2 || b.length < 2) return 0;
595
+ const bigrams = (s: string): Map<string, number> => {
596
+ const m = new Map<string, number>();
597
+ for (let i = 0; i < s.length - 1; i++) {
598
+ const bg = s.slice(i, i + 2);
599
+ m.set(bg, (m.get(bg) ?? 0) + 1);
600
+ }
601
+ return m;
602
+ };
603
+ const ba = bigrams(a);
604
+ const bb = bigrams(b);
605
+ let overlap = 0;
606
+ for (const [bg, n] of ba) {
607
+ const m = bb.get(bg);
608
+ if (m) overlap += Math.min(n, m);
609
+ }
610
+ return (2 * overlap) / (a.length - 1 + b.length - 1);
451
611
  }
452
612
 
453
613
  // ── Serialization ────────────────────────────────────────────────────
@@ -489,42 +649,19 @@ export function formatDelta(delta: SurfaceDelta): string {
489
649
  }
490
650
  }
491
651
 
652
+ if (delta.renamed.length > 0) {
653
+ lines.push(`Renamed (${delta.renamed.length}):`);
654
+ for (const r of delta.renamed) {
655
+ lines.push(` ~ ${r.from} -> ${r.to} [${r.entry.kind}] (${r.entry.resourceType})`);
656
+ if (r.changes) lines.push(...formatChanges(r.changes));
657
+ }
658
+ }
659
+
492
660
  if (delta.changed.length > 0) {
493
661
  lines.push(`Changed (${delta.changed.length}):`);
494
662
  for (const c of delta.changed) {
495
663
  lines.push(` ~ ${c.name}`);
496
- if (c.resourceTypeChanged) {
497
- lines.push(` resourceType: ${c.resourceTypeChanged.before} -> ${c.resourceTypeChanged.after}`);
498
- }
499
- if (c.kindChanged) {
500
- lines.push(` kind: ${c.kindChanged.before} -> ${c.kindChanged.after}`);
501
- }
502
- if (c.removedProps?.length) {
503
- lines.push(` removed props: ${c.removedProps.join(", ")}`);
504
- }
505
- if (c.nowRequired?.length) {
506
- lines.push(` now required: ${c.nowRequired.join(", ")}`);
507
- }
508
- if (c.addedProps?.length) {
509
- lines.push(` added props: ${c.addedProps.join(", ")}`);
510
- }
511
- if (c.nowOptional?.length) {
512
- lines.push(` now optional: ${c.nowOptional.join(", ")}`);
513
- }
514
- if (c.removedAttrs?.length) {
515
- lines.push(` removed attrs: ${c.removedAttrs.join(", ")}`);
516
- }
517
- if (c.addedAttrs?.length) {
518
- lines.push(` added attrs: ${c.addedAttrs.join(", ")}`);
519
- }
520
- if (c.createOnlyChanged) {
521
- const before = c.createOnlyChanged.before.join(", ") || "(none)";
522
- const after = c.createOnlyChanged.after.join(", ") || "(none)";
523
- lines.push(` createOnly: [${before}] -> [${after}]`);
524
- }
525
- if (c.taggableChanged) {
526
- lines.push(` taggable: ${c.taggableChanged.before ?? "(unset)"} -> ${c.taggableChanged.after ?? "(unset)"}`);
527
- }
664
+ lines.push(...formatChanges(c));
528
665
  }
529
666
  }
530
667
 
@@ -538,6 +675,44 @@ export function formatDelta(delta: SurfaceDelta): string {
538
675
  return lines.join("\n");
539
676
  }
540
677
 
678
+ /** Indented detail lines for one entry's structural changes. */
679
+ function formatChanges(c: EntryChanges): string[] {
680
+ const lines: string[] = [];
681
+ if (c.resourceTypeChanged) {
682
+ lines.push(` resourceType: ${c.resourceTypeChanged.before} -> ${c.resourceTypeChanged.after}`);
683
+ }
684
+ if (c.kindChanged) {
685
+ lines.push(` kind: ${c.kindChanged.before} -> ${c.kindChanged.after}`);
686
+ }
687
+ if (c.removedProps?.length) {
688
+ lines.push(` removed props: ${c.removedProps.join(", ")}`);
689
+ }
690
+ if (c.nowRequired?.length) {
691
+ lines.push(` now required: ${c.nowRequired.join(", ")}`);
692
+ }
693
+ if (c.addedProps?.length) {
694
+ lines.push(` added props: ${c.addedProps.join(", ")}`);
695
+ }
696
+ if (c.nowOptional?.length) {
697
+ lines.push(` now optional: ${c.nowOptional.join(", ")}`);
698
+ }
699
+ if (c.removedAttrs?.length) {
700
+ lines.push(` removed attrs: ${c.removedAttrs.join(", ")}`);
701
+ }
702
+ if (c.addedAttrs?.length) {
703
+ lines.push(` added attrs: ${c.addedAttrs.join(", ")}`);
704
+ }
705
+ if (c.createOnlyChanged) {
706
+ const before = c.createOnlyChanged.before.join(", ") || "(none)";
707
+ const after = c.createOnlyChanged.after.join(", ") || "(none)";
708
+ lines.push(` createOnly: [${before}] -> [${after}]`);
709
+ }
710
+ if (c.taggableChanged) {
711
+ lines.push(` taggable: ${c.taggableChanged.before ?? "(unset)"} -> ${c.taggableChanged.after ?? "(unset)"}`);
712
+ }
713
+ return lines;
714
+ }
715
+
541
716
  // ── Helpers ──────────────────────────────────────────────────────────
542
717
 
543
718
  function splitProp(encoded: string): [string, string] {
@@ -106,7 +106,7 @@ describe("surface snapshot gate (#1473)", () => {
106
106
  return dir;
107
107
  }
108
108
 
109
- const run = (basePath: string, checkSurfaceSnapshot: boolean, armed = true) =>
109
+ const run = (basePath: string, checkSurfaceSnapshot: boolean | "always", armed = true) =>
110
110
  validateLexiconArtifacts({
111
111
  lexiconJsonFilename: "lexicon-test.json",
112
112
  requiredNames: [],
@@ -141,8 +141,8 @@ describe("surface snapshot gate (#1473)", () => {
141
141
  });
142
142
 
143
143
  test("is off unless the lexicon opts in", async () => {
144
- // k8s and azure are adrift from their own baselines (#1475); switching
145
- // this on globally would block their releases.
144
+ // A lexicon with no baseline of its own, or one that generates from a
145
+ // moving upstream it has not pinned, must not be gated by default.
146
146
  const stale = JSON.stringify({ schemaVersion: 1, generatedAt: "2026-01-01T00:00:00.000Z", entries: {} });
147
147
  const result = await run(fixture({ snapshot: stale }), false);
148
148
  expect(result.checks.find((c) => c.name === "surface-matches-snapshot")).toBeUndefined();
@@ -158,6 +158,19 @@ describe("surface snapshot gate (#1473)", () => {
158
158
  expect(result.success).toBe(true);
159
159
  });
160
160
 
161
+ test("\"always\" runs outside a release — a pinned upstream cannot move on its own (#1475)", async () => {
162
+ // k8s and azure generate from immutable refs, so drift on a PR can only
163
+ // come from this repo and is exactly what should fail the build.
164
+ const stale = JSON.stringify({ schemaVersion: 1, generatedAt: "2026-01-01T00:00:00.000Z", entries: {} });
165
+ const result = await run(fixture({ snapshot: stale }), "always", false);
166
+ const check = result.checks.find((c) => c.name === "surface-matches-snapshot");
167
+ expect(check?.ok).toBe(false);
168
+ expect(result.success).toBe(false);
169
+
170
+ const ok = await run(fixture({ snapshot: await matchingSnapshot() }), "always", false);
171
+ expect(ok.checks.find((c) => c.name === "surface-matches-snapshot")?.ok).toBe(true);
172
+ });
173
+
161
174
  test("is skipped for a lexicon with no committed snapshot", async () => {
162
175
  // A new lexicon before its first baseline must still be able to build.
163
176
  const result = await run(fixture(), true);
@@ -62,10 +62,18 @@ export interface LexiconValidationConfig {
62
62
  * What must never happen is *publishing* a surface nobody reviewed. That is
63
63
  * a release-time property, so it is checked at release time.
64
64
  *
65
- * Opt-in per lexicon because k8s and azure are currently adrift from their
66
- * own baselines (393 and 483 entries, #1475).
65
+ * `"always"` drops the env half (#1475). It is for a lexicon whose upstream
66
+ * is pinned to an immutable ref — k8s generates from a kubernetes release
67
+ * tag plus vendored CRDs, azure from a commit sha of the
68
+ * resource-manager-schemas repo — so a fresh `generate` on a PR is
69
+ * deterministic and the only way the surface can move is a change in this
70
+ * repo. For those, drift on a PR is exactly the thing to fail on: the CRD
71
+ * batches #1319/#1320/#1321 left the k8s baseline 393 entries behind, and
72
+ * the #1144 pin left azure 483 behind, because nothing compared the two
73
+ * until a release was attempted. Never use `"always"` for a lexicon that
74
+ * fetches a moving upstream.
67
75
  */
68
- checkSurfaceSnapshot?: boolean;
76
+ checkSurfaceSnapshot?: boolean | "always";
69
77
  /** Environment to read {@link RELEASE_GATE_ENV} from. Defaults to `process.env`; overridden in tests. */
70
78
  env?: NodeJS.ProcessEnv;
71
79
  /** Path to the generated directory (defaults to basePath/src/generated) */
@@ -194,12 +202,14 @@ export async function validateLexiconArtifacts(config: LexiconValidationConfig):
194
202
  // skipped for a lexicon with no committed snapshot, which is the case for a
195
203
  // new lexicon before its first baseline.
196
204
  const snapshotPath = join(config.basePath, "surface.snapshot.json");
197
- const releaseGate = config.checkSurfaceSnapshot && (config.env ?? process.env)[RELEASE_GATE_ENV] === "1";
198
- if (releaseGate && lexiconData && existsSync(snapshotPath) && existsSync(dtsPath)) {
205
+ const surfaceGate =
206
+ config.checkSurfaceSnapshot === "always" ||
207
+ (config.checkSurfaceSnapshot === true && (config.env ?? process.env)[RELEASE_GATE_ENV] === "1");
208
+ if (surfaceGate && lexiconData && existsSync(snapshotPath) && existsSync(dtsPath)) {
199
209
  try {
200
210
  const fresh = extractSurface(readFileSync(lexiconPath, "utf-8"), readFileSync(dtsPath, "utf-8"));
201
211
  const delta = diffSurface(parseSnapshot(readFileSync(snapshotPath, "utf-8")), fresh);
202
- const moved = delta.added.length + delta.removed.length + delta.changed.length;
212
+ const moved = delta.added.length + delta.removed.length + delta.renamed.length + delta.changed.length;
203
213
  checks.push(
204
214
  moved === 0
205
215
  ? { name: "surface-matches-snapshot", ok: true }
@@ -208,7 +218,7 @@ export async function validateLexiconArtifacts(config: LexiconValidationConfig):
208
218
  ok: false,
209
219
  error:
210
220
  `The generated API differs from the reviewed surface.snapshot.json ` +
211
- `(${delta.added.length} added, ${delta.removed.length} removed, ${delta.changed.length} changed). ` +
221
+ `(${delta.added.length} added, ${delta.removed.length} removed, ${delta.renamed.length} renamed, ${delta.changed.length} changed). ` +
212
222
  `Accept it deliberately with \`chant dev surface-diff <lexicon> --update-snapshot --bump\`, ` +
213
223
  `never as a side effect of a release.\n${formatDelta(delta)}`,
214
224
  },
@@ -42,7 +42,7 @@ import type { DriverStepRecord } from "./driver";
42
42
  * `extract-config-bom` likewise *describes* an artifact rather than promoting
43
43
  * one. Those must not trigger a release record (#665) — a release means "we
44
44
  * promoted this by digest to a location", not "we hashed some bytes". The
45
- * driver's own `findPublishOutput` stays looser (uri/digest/key) because it
45
+ * driver's own `collectComponentOutputs` stays looser (uri/digest/key) because it
46
46
  * only feeds `@<component>.publish.*` wiring, where a false positive is harmless.
47
47
  */
48
48
  function isPromotedArtifact(output: unknown): output is { uri: string; digest?: string } {
@@ -44,7 +44,7 @@ import type { Capability } from "./capability";
44
44
  export interface CapabilityManifest {
45
45
  /** Package/plugin name (e.g. "aws", "gcp"), not a capability `kind`. */
46
46
  name: string;
47
- /** Plugin package version (semver). */
47
+ /** The plugin package's version (semver), read from its `package.json`. */
48
48
  version: string;
49
49
  /** Minimum/compatible chant core version, checked the same way as `LexiconManifest.chantVersion` (see ../lexicon-manifest.ts's `checkVersionCompatibility`). */
50
50
  chantVersion?: string;
@@ -69,7 +69,13 @@ export interface CapabilityPlugin {
69
69
  /** Human-readable plugin/package name (e.g. "aws", "gcp"), not a capability `kind`. */
70
70
  readonly name: string;
71
71
 
72
- /** Plugin package version (semver), mirrors `LexiconManifest.version`. */
72
+ /**
73
+ * The plugin package's version (semver), read from its `package.json`
74
+ * (chant #1505). Mirrors `LexiconManifest.version`. Informational — nothing
75
+ * gates on it. The built-in plugins expose it as a lazy getter over
76
+ * `ownPackageVersion`, so the read happens on first access and never at
77
+ * module scope.
78
+ */
73
79
  readonly version: string;
74
80
 
75
81
  /** Return every `Capability` this plugin contributes, keyed for registration by its own `kind`. */
@@ -124,8 +130,23 @@ export function isCapabilityPlugin(value: unknown): value is CapabilityPlugin {
124
130
  *
125
131
  * Returns `"0.0.0"` when no versioned `package.json` is found — a visible
126
132
  * sentinel rather than a guess; nothing gates on the field.
133
+ *
134
+ * Call it lazily (from a `get version()` accessor, as the built-in plugins
135
+ * do), not at module scope: the read touches the filesystem, and a plugin
136
+ * module must stay importable where `fs` is absent (workerd, chant #1081).
137
+ * The result is cached per module URL, so the walk happens once.
127
138
  */
128
139
  export function ownPackageVersion(moduleUrl: string): string {
140
+ const cached = ownVersionCache.get(moduleUrl);
141
+ if (cached !== undefined) return cached;
142
+ const found = findPackageVersion(moduleUrl);
143
+ ownVersionCache.set(moduleUrl, found);
144
+ return found;
145
+ }
146
+
147
+ const ownVersionCache = new Map<string, string>();
148
+
149
+ function findPackageVersion(moduleUrl: string): string {
129
150
  let dir = dirname(fileURLToPath(moduleUrl));
130
151
  for (;;) {
131
152
  try {