@intentius/chant 0.46.0 → 0.49.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 (183) hide show
  1. package/dist/audit/core.d.ts +21 -3
  2. package/dist/audit/core.d.ts.map +1 -1
  3. package/dist/audit/discover.d.ts +3 -2
  4. package/dist/audit/discover.d.ts.map +1 -1
  5. package/dist/audit/rules-doc.d.ts.map +1 -1
  6. package/dist/build.d.ts +3 -3
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/cli/commands/build.d.ts.map +1 -1
  9. package/dist/cli/commands/lint.d.ts.map +1 -1
  10. package/dist/cli/handlers/lifecycle.d.ts +1 -1
  11. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  12. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  13. package/dist/cli/mcp/tools/explain.d.ts +6 -0
  14. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  15. package/dist/cli/plugins.d.ts +1 -1
  16. package/dist/cli/plugins.d.ts.map +1 -1
  17. package/dist/cli/reporters/stylish.d.ts +15 -1
  18. package/dist/cli/reporters/stylish.d.ts.map +1 -1
  19. package/dist/components/auto-release.d.ts +4 -0
  20. package/dist/components/auto-release.d.ts.map +1 -1
  21. package/dist/components/starter-plugin.d.ts +2 -0
  22. package/dist/components/starter-plugin.d.ts.map +1 -1
  23. package/dist/components/verbs/ensure-secret.d.ts +50 -0
  24. package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
  25. package/dist/components/verbs/index.d.ts +8 -0
  26. package/dist/components/verbs/index.d.ts.map +1 -1
  27. package/dist/components/verbs/r2-sync.d.ts +76 -0
  28. package/dist/components/verbs/r2-sync.d.ts.map +1 -0
  29. package/dist/components/verbs/wrangler.d.ts +108 -0
  30. package/dist/components/verbs/wrangler.d.ts.map +1 -0
  31. package/dist/config.d.ts +26 -0
  32. package/dist/config.d.ts.map +1 -1
  33. package/dist/deep-observation.d.ts +14 -0
  34. package/dist/deep-observation.d.ts.map +1 -1
  35. package/dist/effect-receipt.d.ts +177 -0
  36. package/dist/effect-receipt.d.ts.map +1 -0
  37. package/dist/fold/subset.d.ts +15 -2
  38. package/dist/fold/subset.d.ts.map +1 -1
  39. package/dist/index.d.ts +4 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/lexicon.d.ts +44 -3
  42. package/dist/lexicon.d.ts.map +1 -1
  43. package/dist/lifecycle/change-set.d.ts +33 -5
  44. package/dist/lifecycle/change-set.d.ts.map +1 -1
  45. package/dist/lifecycle/index.d.ts +2 -0
  46. package/dist/lifecycle/index.d.ts.map +1 -1
  47. package/dist/lifecycle/observation-baseline.d.ts +21 -3
  48. package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
  49. package/dist/lifecycle/receipt-plan.d.ts +62 -0
  50. package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
  51. package/dist/lifecycle/release-ledger.d.ts +20 -0
  52. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  53. package/dist/lifecycle/teardown.d.ts +6 -4
  54. package/dist/lifecycle/teardown.d.ts.map +1 -1
  55. package/dist/lifecycle/unobserved-gate.d.ts +67 -0
  56. package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
  57. package/dist/lint/knowledge-checks.d.ts +48 -0
  58. package/dist/lint/knowledge-checks.d.ts.map +1 -0
  59. package/dist/lint/output-checks.d.ts +5 -0
  60. package/dist/lint/output-checks.d.ts.map +1 -0
  61. package/dist/lint/pipeline-change-gate.d.ts +101 -0
  62. package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
  63. package/dist/lint/post-synth.d.ts +12 -0
  64. package/dist/lint/post-synth.d.ts.map +1 -1
  65. package/dist/lint/receipt-checks.d.ts +9 -0
  66. package/dist/lint/receipt-checks.d.ts.map +1 -0
  67. package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
  68. package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
  69. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
  70. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
  71. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  72. package/dist/lint/rules/index.d.ts +3 -1
  73. package/dist/lint/rules/index.d.ts.map +1 -1
  74. package/dist/okf-read.d.ts +78 -0
  75. package/dist/okf-read.d.ts.map +1 -0
  76. package/dist/op/builders.d.ts +98 -1
  77. package/dist/op/builders.d.ts.map +1 -1
  78. package/dist/op/index.d.ts +4 -2
  79. package/dist/op/index.d.ts.map +1 -1
  80. package/dist/op/local-executor.d.ts +2 -1
  81. package/dist/op/local-executor.d.ts.map +1 -1
  82. package/dist/op/receipt-store.d.ts +138 -0
  83. package/dist/op/receipt-store.d.ts.map +1 -0
  84. package/dist/op/types.d.ts +31 -1
  85. package/dist/op/types.d.ts.map +1 -1
  86. package/dist/secret-materialization.d.ts +138 -0
  87. package/dist/secret-materialization.d.ts.map +1 -0
  88. package/dist/secret-provenance.d.ts +218 -0
  89. package/dist/secret-provenance.d.ts.map +1 -0
  90. package/dist/serializer.d.ts +11 -0
  91. package/dist/serializer.d.ts.map +1 -1
  92. package/dist/yaml.d.ts.map +1 -1
  93. package/package.json +4 -1
  94. package/src/audit/core.test.ts +57 -0
  95. package/src/audit/core.ts +0 -0
  96. package/src/audit/detect-bundle.test.ts +1 -1
  97. package/src/audit/discover.test.ts +24 -0
  98. package/src/audit/discover.ts +11 -2
  99. package/src/audit/rules-doc.ts +11 -1
  100. package/src/build.test.ts +41 -0
  101. package/src/build.ts +34 -6
  102. package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
  103. package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
  104. package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
  105. package/src/cli/commands/audit.test.ts +53 -0
  106. package/src/cli/commands/audit.ts +1 -1
  107. package/src/cli/commands/build.test.ts +80 -0
  108. package/src/cli/commands/build.ts +106 -8
  109. package/src/cli/commands/lint.ts +15 -3
  110. package/src/cli/handlers/explain.test.ts +70 -1
  111. package/src/cli/handlers/graph.ts +2 -2
  112. package/src/cli/handlers/lifecycle.test.ts +115 -1
  113. package/src/cli/handlers/lifecycle.ts +84 -11
  114. package/src/cli/mcp/resource-handlers.ts +38 -1
  115. package/src/cli/mcp/server.test.ts +58 -1
  116. package/src/cli/mcp/tools/explain.ts +51 -2
  117. package/src/cli/plugins.ts +4 -2
  118. package/src/cli/reporters/stylish.test.ts +154 -0
  119. package/src/cli/reporters/stylish.ts +154 -33
  120. package/src/components/auto-release.ts +6 -0
  121. package/src/components/registry.test.ts +7 -2
  122. package/src/components/starter-plugin.ts +17 -0
  123. package/src/components/verbs/ensure-secret.test.ts +130 -0
  124. package/src/components/verbs/ensure-secret.ts +79 -0
  125. package/src/components/verbs/index.ts +8 -0
  126. package/src/components/verbs/r2-sync.test.ts +107 -0
  127. package/src/components/verbs/r2-sync.ts +124 -0
  128. package/src/components/verbs/wrangler.test.ts +170 -0
  129. package/src/components/verbs/wrangler.ts +241 -0
  130. package/src/config.test.ts +15 -0
  131. package/src/config.ts +30 -0
  132. package/src/deep-observation.test.ts +19 -0
  133. package/src/deep-observation.ts +17 -0
  134. package/src/effect-receipt-exclusion.test.ts +190 -0
  135. package/src/effect-receipt.test.ts +419 -0
  136. package/src/effect-receipt.ts +412 -0
  137. package/src/fold/subset.test.ts +26 -0
  138. package/src/fold/subset.ts +45 -19
  139. package/src/index.ts +4 -0
  140. package/src/lexicon.ts +48 -3
  141. package/src/lifecycle/change-set.ts +46 -7
  142. package/src/lifecycle/index.ts +2 -0
  143. package/src/lifecycle/observation-baseline.test.ts +46 -0
  144. package/src/lifecycle/observation-baseline.ts +33 -1
  145. package/src/lifecycle/receipt-plan.test.ts +250 -0
  146. package/src/lifecycle/receipt-plan.ts +249 -0
  147. package/src/lifecycle/release-ledger.ts +20 -0
  148. package/src/lifecycle/teardown.test.ts +31 -0
  149. package/src/lifecycle/teardown.ts +6 -4
  150. package/src/lifecycle/unobserved-gate.test.ts +109 -0
  151. package/src/lifecycle/unobserved-gate.ts +102 -0
  152. package/src/lint/knowledge-checks.test.ts +80 -0
  153. package/src/lint/knowledge-checks.ts +74 -0
  154. package/src/lint/output-checks.test.ts +85 -0
  155. package/src/lint/output-checks.ts +99 -0
  156. package/src/lint/pipeline-change-gate.test.ts +144 -0
  157. package/src/lint/pipeline-change-gate.ts +153 -0
  158. package/src/lint/post-synth.ts +15 -0
  159. package/src/lint/receipt-checks.test.ts +101 -0
  160. package/src/lint/receipt-checks.ts +93 -0
  161. package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
  162. package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
  163. package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
  164. package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
  165. package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
  166. package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
  167. package/src/lint/rules/index.ts +7 -1
  168. package/src/okf-read.test.ts +149 -0
  169. package/src/okf-read.ts +197 -0
  170. package/src/op/builders.ts +139 -1
  171. package/src/op/effect-step.test.ts +311 -0
  172. package/src/op/index.ts +10 -3
  173. package/src/op/local-executor.ts +172 -25
  174. package/src/op/op.test.ts +25 -2
  175. package/src/op/receipt-store.ts +211 -0
  176. package/src/op/types.ts +33 -1
  177. package/src/secret-materialization.test.ts +199 -0
  178. package/src/secret-materialization.ts +235 -0
  179. package/src/secret-provenance.test.ts +388 -0
  180. package/src/secret-provenance.ts +475 -0
  181. package/src/serializer.ts +12 -0
  182. package/src/yaml.test.ts +88 -0
  183. package/src/yaml.ts +76 -6
@@ -18,6 +18,8 @@ import {
18
18
  import { computeBuildDigest, diffDigests } from "../../lifecycle/digest";
19
19
  import { diffLive, diffLiveArtifacts, diffSnapshots, type LiveDiffResult, type LiveArtifactDiffResult, type SnapshotDiffResult } from "../../lifecycle/live-diff";
20
20
  import { buildChangeSet, renderChangeSet, gitlabMrReport, summarize, type ChangeSet } from "../../lifecycle/change-set";
21
+ import { mergeReceiptEntries, observedValueResolver, planReceipts, readReceiptValue, type ReceiptReading } from "../../lifecycle/receipt-plan";
22
+ import { collectEffectReceipts, isEffectReceipt } from "../../effect-receipt";
21
23
  import {
22
24
  formatUnobserved,
23
25
  mergeObservations,
@@ -358,6 +360,10 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
358
360
  // committed set. Absent is the normal state (nothing accepted yet).
359
361
  const baseline = args.live ? await readObservationBaseline(environment) : null;
360
362
  const accepted: Record<string, DeviationToAccept[]> = {};
363
+ // Effect receipts across every target's build (#1833) — `--update-baseline`
364
+ // refuses to accept drift on them (the effect step is their only writer),
365
+ // and recognition is marker-based so materialized lexicon rows are caught.
366
+ const receiptEntities = new Set<string>();
361
367
 
362
368
  // #1166 — an environment can declare its own endpoint (a local emulator like
363
369
  // Floci), so `--live` is self-sufficient even when the ambient shell never
@@ -430,6 +436,9 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
430
436
  for (const [lexicon, deviations] of Object.entries(r.toAccept)) {
431
437
  (accepted[lexicon] ??= []).push(...deviations);
432
438
  }
439
+ if (args.updateBaseline) {
440
+ for (const name of collectEffectReceipts(buildResult.entities).keys()) receiptEntities.add(name);
441
+ }
433
442
  if (json) {
434
443
  if (target.stack) perStackJson[target.stack] = r.byLexicon;
435
444
  else combinedLexiconsJson = r.byLexicon;
@@ -443,7 +452,7 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
443
452
  // accepted, so it stops re-alerting. Runs before the summary lines so the
444
453
  // "no drift" verdict below still describes the run that produced it.
445
454
  if (args.live && args.updateBaseline) {
446
- await recordAcceptedBaseline(environment, baseline, accepted, json);
455
+ await recordAcceptedBaseline(environment, baseline, accepted, json, receiptEntities);
447
456
  }
448
457
 
449
458
  if (args.live) {
@@ -495,6 +504,7 @@ async function recordAcceptedBaseline(
495
504
  existing: ObservationBaseline | null,
496
505
  accepted: Record<string, DeviationToAccept[]>,
497
506
  json: boolean,
507
+ receipts?: ReadonlySet<string>,
498
508
  ): Promise<void> {
499
509
  const total = Object.values(accepted).reduce((n, d) => n + d.length, 0);
500
510
  if (total === 0) {
@@ -505,11 +515,14 @@ async function recordAcceptedBaseline(
505
515
  }
506
516
  return;
507
517
  }
508
- let next = existing ?? emptyBaseline(environment);
509
- for (const [lexicon, deviations] of Object.entries(accepted)) {
510
- next = acceptDeviations(next, lexicon, deviations);
511
- }
512
518
  try {
519
+ // `acceptDeviations` throws on an effect-receipt deviation (#1833) —
520
+ // inside the try so the refusal reaches the operator as a formatted
521
+ // error, with no baseline written at all.
522
+ let next = existing ?? emptyBaseline(environment);
523
+ for (const [lexicon, deviations] of Object.entries(accepted)) {
524
+ next = acceptDeviations(next, lexicon, deviations, { receipts });
525
+ }
513
526
  await writeObservationBaseline(next);
514
527
  const pushed = await pushLifecycle();
515
528
  if (!json) {
@@ -520,7 +533,7 @@ async function recordAcceptedBaseline(
520
533
  }
521
534
  } catch (err) {
522
535
  console.error(formatError({
523
- message: `--update-baseline: could not write the baseline — ${err instanceof Error ? err.message : String(err)}`,
536
+ message: `--update-baseline: baseline not updated — ${err instanceof Error ? err.message : String(err)}`,
524
537
  }));
525
538
  }
526
539
  }
@@ -1126,6 +1139,15 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
1126
1139
  const merged: ChangeSet = { env: environment, entries: [] };
1127
1140
  let checked = 0;
1128
1141
 
1142
+ // Effect receipts (#1832): declared, diffed, and observed like any resource,
1143
+ // but observe-only to the generic apply path — the plan compares live value
1144
+ // to resolved expectation and proposes the fire, never a create/update.
1145
+ const receipts = collectEffectReceipts(buildResult.entities);
1146
+ const receiptReadings = new Map<string, ReceiptReading>();
1147
+ // Every lexicon's observed resources merged, for resolving a receipt's
1148
+ // reference inputs against observed values / stack outputs at plan time.
1149
+ const allObservedResources: Record<string, ResourceMetadata> = {};
1150
+
1129
1151
  // #1166 — same self-sufficiency as `chant graph --live`: an environment can
1130
1152
  // declare its own endpoint, applied here unless the ambient shell already
1131
1153
  // set it. `chant lifecycle plan` is always a live read (no `--live` flag of
@@ -1156,11 +1178,14 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
1156
1178
  const declared = new Set<string>();
1157
1179
  const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
1158
1180
  for (const [name, entity] of buildResult.entities) {
1159
- if (entity.lexicon === lexiconName && isResourceDeclarable(entity)) {
1181
+ // A receipt has no `props` payload of its own but is declared, diffed,
1182
+ // and observed like any resource (#1832) — it joins the declared axis
1183
+ // so its lexicon's observation can confirm presence or absence.
1184
+ if (entity.lexicon === lexiconName && (isResourceDeclarable(entity) || isEffectReceipt(entity))) {
1160
1185
  declared.add(name);
1161
1186
  entities.set(name, {
1162
1187
  entityType: entity.entityType,
1163
- props: (entity.props != null ? entity.props : {}) as Record<string, unknown>,
1188
+ props: (isResourceDeclarable(entity) && entity.props != null ? entity.props : {}) as Record<string, unknown>,
1164
1189
  });
1165
1190
  }
1166
1191
  }
@@ -1185,6 +1210,37 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
1185
1210
  ...(args.namespace ? { namespace: args.namespace } : {}),
1186
1211
  });
1187
1212
 
1213
+ Object.assign(allObservedResources, observed.resources);
1214
+ for (const [receiptName, receipt] of receipts) {
1215
+ if (receipt.lexicon !== lexiconName) continue;
1216
+ const live = observed.resources[receiptName];
1217
+ const hole = observed.unobserved[receiptName];
1218
+ if (live) {
1219
+ receiptReadings.set(receiptName, {
1220
+ observed: true,
1221
+ present: true,
1222
+ value: readReceiptValue(live.attributes),
1223
+ type: live.type,
1224
+ ...(live.physicalId ? { physicalId: live.physicalId } : {}),
1225
+ lexicon: lexiconName,
1226
+ });
1227
+ } else if (hole) {
1228
+ receiptReadings.set(receiptName, {
1229
+ observed: false,
1230
+ present: false,
1231
+ lexicon: lexiconName,
1232
+ ...(hole.type ? { type: hole.type } : {}),
1233
+ unobservedReason: hole.reason,
1234
+ ...(hole.detail ? { unobservedDetail: hole.detail } : {}),
1235
+ });
1236
+ } else {
1237
+ // Neither returned nor named unobserved: the lexicon looked and
1238
+ // confirmed the receipt absent — the same claim the change set
1239
+ // reads off a bare observation (#1089).
1240
+ receiptReadings.set(receiptName, { observed: true, present: false, lexicon: lexiconName });
1241
+ }
1242
+ }
1243
+
1188
1244
  const content = await readSnapshot(environment, lexiconName);
1189
1245
  const observedThen = content ? (JSON.parse(content) as LifecycleSnapshot).resources : undefined;
1190
1246
 
@@ -1214,6 +1270,16 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
1214
1270
  return 1;
1215
1271
  }
1216
1272
 
1273
+ // Receipt classification (#1832): live value vs resolved expectation.
1274
+ // Absent/stale proposes the fire; unobservable is a loud hole; whatever the
1275
+ // generic classification proposed for a receipt (a create, a delete) is
1276
+ // replaced — the effect step is the sole writer, and a receipt is never a
1277
+ // prune candidate.
1278
+ if (receipts.size > 0) {
1279
+ const receiptEntries = planReceipts(receipts, receiptReadings, observedValueResolver(allObservedResources));
1280
+ mergeReceiptEntries(merged, receipts, receiptEntries);
1281
+ }
1282
+
1217
1283
  merged.entries.sort((a, b) => a.name.localeCompare(b.name));
1218
1284
 
1219
1285
  // Say it on stderr too, so `--json` and `--report gitlab-mr` consumers (whose
@@ -1250,7 +1316,7 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
1250
1316
  * with the requested env identity. Stateless — live markers only, no build, no
1251
1317
  * snapshot. Without `--yes` nothing is deleted; with it the planned set is
1252
1318
  * executed per lexicon with one bounded retry pass over failures, and every
1253
- * candidate's outcome is reported (deleted / failed / not-prunable / skipped —
1319
+ * candidate's outcome is reported (deleted / failed / not-prunable / retained / skipped —
1254
1320
  * never silence). A production-like environment name additionally requires an
1255
1321
  * interactive confirmation, or `--confirm-prod` non-interactively.
1256
1322
  */
@@ -1410,10 +1476,11 @@ export async function runLifecycleTeardown(ctx: CommandContext): Promise<number>
1410
1476
  }
1411
1477
  }
1412
1478
 
1413
- const counts = { deleted: 0, failed: 0, "not-prunable": 0, skipped: 0 };
1479
+ const counts = { deleted: 0, failed: 0, "not-prunable": 0, retained: 0, skipped: 0 };
1414
1480
  for (const o of report.outcomes) counts[o.outcome]++;
1415
1481
  console.log(
1416
- `\n${counts.deleted} deleted, ${counts.failed} failed, ${counts["not-prunable"]} not prunable, ${counts.skipped} skipped`,
1482
+ `\n${counts.deleted} deleted, ${counts.failed} failed, ${counts["not-prunable"]} not prunable, ` +
1483
+ `${counts.retained} retained, ${counts.skipped} skipped`,
1417
1484
  );
1418
1485
 
1419
1486
  if (report.unimplemented.length > 0) {
@@ -1421,6 +1488,12 @@ export async function runLifecycleTeardown(ctx: CommandContext): Promise<number>
1421
1488
  message: `Not executed (no teardown execution in these lexicons yet): ${report.unimplemented.join(", ")} — their candidates are reported as skipped, not deleted.`,
1422
1489
  }));
1423
1490
  }
1491
+ if (counts.retained > 0) {
1492
+ console.error(formatWarning({
1493
+ message: `${counts.retained} resource(s) retained — owned by this env but deliberately kept (generated-once secrets are never swept). ` +
1494
+ `The environment is NOT clean while they exist; delete them explicitly (e.g. kubectl delete) if you mean to.`,
1495
+ }));
1496
+ }
1424
1497
  if (plan.holes.length > 0) {
1425
1498
  console.error(formatWarning({
1426
1499
  message: "This teardown ran over an incomplete plan (holes above) — the environment cannot be called clean.",
@@ -1,10 +1,13 @@
1
- import { resolve } from "node:path";
1
+ import { resolve, join, dirname } from "node:path";
2
+ import { readFile } from "node:fs/promises";
2
3
  import type { ResourceDefinition } from "./types";
3
4
  import { getContext } from "./resources/context";
4
5
  import { readSnapshot, readEnvironmentSnapshots } from "../../lifecycle/git";
5
6
  import { discoverOps } from "../../op/discover";
6
7
  import { makeTemporalClient } from "../handlers/run";
7
8
  import { resolveWorkflowId } from "../handlers/run-client";
9
+ import { loadOkfBundle } from "../../okf-read";
10
+ import { loadChantConfigUpward, resolveKnowledgeDir } from "../../config";
8
11
 
9
12
  type PluginResourceEntry = { definition: ResourceDefinition; handler: () => Promise<string> };
10
13
 
@@ -42,6 +45,12 @@ export const coreResourceDefinitions: ResourceDefinition[] = [
42
45
  description: "Latest run state for a named Op",
43
46
  mimeType: "application/json",
44
47
  },
48
+ {
49
+ uri: "chant://knowledge",
50
+ name: "Knowledge bundle",
51
+ description: "The project's OKF knowledge bundle (#1864, #1059): index.md and every authored concept, empty when no bundle exists",
52
+ mimeType: "application/json",
53
+ },
45
54
  {
46
55
  uri: "chant://state/{environment}",
47
56
  name: "State (all lexicons)",
@@ -122,6 +131,34 @@ export async function handleResourcesRead(
122
131
  };
123
132
  }
124
133
 
134
+ // Authored knowledge (#1867, #1864, design #1059): same bundle the CLI's
135
+ // `chant explain` reads, over MCP — the resource path has no local
136
+ // filesystem, so this is the only way an MCP client sees the `knowledge/`
137
+ // directory's contents at all. Resolved from cwd, matching every other
138
+ // path-less resource here (`chant://ops`, `chant://state/...`). A missing
139
+ // bundle is not an error — `loadOkfBundle` already treats it as empty, and
140
+ // a missing `index.md` reads as `index: null` rather than throwing.
141
+ if (uri === "chant://knowledge") {
142
+ const cwd = resolve(".");
143
+ const loaded = await loadChantConfigUpward(cwd);
144
+ const projectRoot = loaded.configPath ? dirname(loaded.configPath) : cwd;
145
+ const dir = resolveKnowledgeDir(loaded.config, projectRoot);
146
+ const bundle = await loadOkfBundle(dir);
147
+ let index: string | null = null;
148
+ try {
149
+ index = await readFile(join(dir, "index.md"), "utf8");
150
+ } catch {
151
+ index = null;
152
+ }
153
+ return {
154
+ contents: [{
155
+ uri,
156
+ mimeType: "application/json",
157
+ text: JSON.stringify({ dir, index, concepts: bundle.concepts }, null, 2),
158
+ }],
159
+ };
160
+ }
161
+
125
162
  // Op resources
126
163
  if (uri === "chant://ops") {
127
164
  const { ops } = await discoverOps();
@@ -590,6 +590,7 @@ describe("McpServer", () => {
590
590
  expect(uris).toContain("chant://ops");
591
591
  expect(uris).toContain("chant://ops/{name}/runs");
592
592
  expect(uris).toContain("chant://ops/{name}/runs/latest");
593
+ expect(uris).toContain("chant://knowledge");
593
594
 
594
595
  // Each resource has required fields
595
596
  for (const resource of result.resources) {
@@ -735,6 +736,62 @@ describe("McpServer", () => {
735
736
  expect(data.error).toBeDefined();
736
737
  });
737
738
  });
739
+
740
+ describe("chant://knowledge", () => {
741
+ test("empty gracefully when no bundle exists", async () => {
742
+ const originalCwd = process.cwd();
743
+ process.chdir(testDir);
744
+ try {
745
+ const response = await server.handleRequest({
746
+ jsonrpc: "2.0",
747
+ id: 1,
748
+ method: "resources/read",
749
+ params: { uri: "chant://knowledge" },
750
+ });
751
+ expect(response.error).toBeUndefined();
752
+ const result = response.result as { contents: Array<{ text: string; mimeType: string }> };
753
+ expect(result.contents[0].mimeType).toBe("application/json");
754
+ const data = JSON.parse(result.contents[0].text);
755
+ expect(data.index).toBeNull();
756
+ expect(data.concepts).toEqual([]);
757
+ } finally {
758
+ process.chdir(originalCwd);
759
+ }
760
+ });
761
+
762
+ test("serves the bundle index and concepts when present", async () => {
763
+ const originalCwd = process.cwd();
764
+ process.chdir(testDir);
765
+ try {
766
+ await mkdir(join(testDir, "knowledge", "decisions"), { recursive: true });
767
+ await writeFile(join(testDir, "knowledge", "index.md"), "# Knowledge index\n");
768
+ await writeFile(
769
+ join(testDir, "knowledge", "decisions", "public-assets.md"),
770
+ "---\ntype: decision\ntitle: Public assets\nbinds: bucket\n---\nBody text.\n",
771
+ );
772
+
773
+ const response = await server.handleRequest({
774
+ jsonrpc: "2.0",
775
+ id: 1,
776
+ method: "resources/read",
777
+ params: { uri: "chant://knowledge" },
778
+ });
779
+ expect(response.error).toBeUndefined();
780
+ const result = response.result as { contents: Array<{ text: string }> };
781
+ const data = JSON.parse(result.contents[0].text);
782
+ expect(data.index).toBe("# Knowledge index\n");
783
+ expect(data.concepts).toHaveLength(1);
784
+ expect(data.concepts[0]).toMatchObject({
785
+ path: "decisions/public-assets.md",
786
+ type: "decision",
787
+ title: "Public assets",
788
+ binds: ["bucket"],
789
+ });
790
+ } finally {
791
+ process.chdir(originalCwd);
792
+ }
793
+ });
794
+ });
738
795
  });
739
796
 
740
797
  // -----------------------------------------------------------------------
@@ -1157,7 +1214,7 @@ describe("McpServer", () => {
1157
1214
 
1158
1215
  const resourcesRes = await s.handleRequest({ jsonrpc: "2.0", id: 3, method: "resources/list" });
1159
1216
  const resources = (resourcesRes.result as { resources: Array<{ uri: string }> }).resources;
1160
- expect(resources).toHaveLength(7);
1217
+ expect(resources).toHaveLength(8);
1161
1218
  });
1162
1219
 
1163
1220
  test("server with empty plugins array works", async () => {
@@ -1,6 +1,19 @@
1
- import { resolve } from "path";
1
+ import { resolve, dirname } from "path";
2
2
  import { discover } from "../../../discovery/index";
3
3
  import { buildOkfBundle, OKF_VERSION } from "../../../okf";
4
+ import { loadOkfBundle, bindConcepts, type OkfConcept } from "../../../okf-read";
5
+ import { loadChantConfigUpward, resolveKnowledgeDir } from "../../../config";
6
+
7
+ /** One bound concept as surfaced in an explain knowledge section — the trio the #1867 design calls for, not the full {@link OkfConcept}. */
8
+ export interface ExplainKnowledgeEntry {
9
+ type: string;
10
+ title?: string;
11
+ path: string;
12
+ }
13
+
14
+ function toKnowledgeEntry(concept: OkfConcept): ExplainKnowledgeEntry {
15
+ return { type: concept.type, title: concept.title, path: concept.path };
16
+ }
4
17
 
5
18
  /**
6
19
  * Explain tool definition for MCP
@@ -46,6 +59,25 @@ export async function handleExplain(params: Record<string, unknown>): Promise<un
46
59
  };
47
60
  }
48
61
 
62
+ // Authored knowledge (#1867, design #1059): bind the project's OKF bundle
63
+ // (if any) to the discovered entities and carry each bound concept's
64
+ // type/title/path into the summary. A project with no `knowledge/` yet
65
+ // binds nothing — `bound` is empty — so the section is omitted below
66
+ // rather than rendered empty; this is the CLI and MCP tool's one shared
67
+ // code path, so both surfaces match by construction.
68
+ // `loadChantConfigUpward` walks up from `infraPath` (chant #1117) — a
69
+ // `chant explain src/<stack>` two levels below `chant.config.ts` must find
70
+ // the same `knowledge/` a `chant explain .` at the root would, matching
71
+ // `build.ts`'s `configDir` derivation for the same reason.
72
+ const loaded = await loadChantConfigUpward(infraPath);
73
+ const projectRoot = loaded.configPath ? dirname(loaded.configPath) : infraPath;
74
+ const knowledgeDir = resolveKnowledgeDir(loaded.config, projectRoot);
75
+ const bundle = await loadOkfBundle(knowledgeDir);
76
+ const { bound } = bindConcepts(bundle, result.entities);
77
+ const knowledge = new Map<string, ExplainKnowledgeEntry[]>(
78
+ Array.from(bound.entries()).map(([name, concepts]) => [name, concepts.map(toKnowledgeEntry)]),
79
+ );
80
+
49
81
  // Group entities by lexicon and kind
50
82
  const byLexicon = new Map<string, { resources: string[]; properties: string[] }>();
51
83
 
@@ -89,7 +121,11 @@ export async function handleExplain(params: Record<string, unknown>): Promise<un
89
121
  };
90
122
 
91
123
  if (format === "json") {
92
- return summary;
124
+ // Omitted entirely when no entity bound a concept — "cleanly absent" per
125
+ // #1867's AC, not an empty `{}`.
126
+ return knowledge.size > 0
127
+ ? { ...summary, knowledge: Object.fromEntries(knowledge) }
128
+ : summary;
93
129
  }
94
130
 
95
131
  // Markdown format
@@ -133,6 +169,19 @@ export async function handleExplain(params: Record<string, unknown>): Promise<un
133
169
  lines.push("");
134
170
  }
135
171
 
172
+ if (knowledge.size > 0) {
173
+ lines.push("## Knowledge");
174
+ lines.push("");
175
+ for (const [name, entries] of knowledge) {
176
+ lines.push(`### \`${name}\``);
177
+ for (const entry of entries) {
178
+ const title = entry.title ? `${entry.title} ` : "";
179
+ lines.push(`- ${entry.type || "concept"}: ${title}(\`${entry.path}\`)`);
180
+ }
181
+ lines.push("");
182
+ }
183
+ }
184
+
136
185
  if (result.errors.length > 0) {
137
186
  lines.push("## Errors");
138
187
  lines.push("");
@@ -106,10 +106,12 @@ export function collectBuildRootContributors(
106
106
  plugins: readonly LexiconPlugin[] | undefined,
107
107
  config: Record<string, unknown>,
108
108
  projectRoot: string,
109
- ): Array<() => Promise<import("../lexicon").BuildRootContribution>> {
109
+ ): Array<import("../lexicon").BuildRootContributor> {
110
110
  return (plugins ?? [])
111
111
  .filter((plugin) => typeof plugin.buildRoots === "function")
112
- .map((plugin) => () => plugin.buildRoots!({ projectRoot, config }));
112
+ // `entities` is not bindable here discovery has not run — so the merge
113
+ // hands it in when it calls the closure (#1828 / SOPS provenance).
114
+ .map((plugin) => (ctx) => plugin.buildRoots!({ projectRoot, config, entities: ctx?.entities }));
113
115
  }
114
116
 
115
117
  /**
@@ -1,6 +1,17 @@
1
1
  import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
2
  import { formatStylish, formatSummary, formatJson, formatSarif } from "./stylish";
3
3
  import type { LintDiagnostic, LintRule } from "../../lint/rule";
4
+ import type { OkfBundle, OkfConcept } from "../../okf-read";
5
+
6
+ function concept(overrides: Partial<OkfConcept> & { path: string }): OkfConcept {
7
+ return {
8
+ type: "decision",
9
+ binds: [],
10
+ frontmatter: {},
11
+ body: "",
12
+ ...overrides,
13
+ };
14
+ }
4
15
 
5
16
  describe("formatStylish", () => {
6
17
  const originalNoColor = process.env.NO_COLOR;
@@ -123,6 +134,149 @@ describe("formatStylish", () => {
123
134
  });
124
135
  });
125
136
 
137
+ describe("formatStylish suppressed section (#1866, design #1059)", () => {
138
+ beforeEach(() => {
139
+ process.env.NO_COLOR = "1";
140
+ });
141
+ afterEach(() => {
142
+ delete process.env.NO_COLOR;
143
+ });
144
+
145
+ const suppressedDiag: LintDiagnostic & { reason?: string } = {
146
+ file: "src/storage.ts",
147
+ line: 12,
148
+ column: 1,
149
+ ruleId: "AWS021",
150
+ severity: "warning",
151
+ message: "Bucket ACL is public-read",
152
+ };
153
+
154
+ test("no section when there are no suppressed diagnostics", () => {
155
+ const result = formatStylish([], []);
156
+ expect(result).not.toContain("Suppressed");
157
+ });
158
+
159
+ test("renders a Suppressed section when suppressed diagnostics exist", () => {
160
+ const result = formatStylish([], [{ ...suppressedDiag, reason: "backwards compat" }]);
161
+
162
+ expect(result).toContain("Suppressed");
163
+ expect(result).toContain("src/storage.ts");
164
+ expect(result).toContain("AWS021");
165
+ expect(result).toContain("backwards compat");
166
+ });
167
+
168
+ test("renders the section even when there are no active diagnostics", () => {
169
+ const result = formatStylish([], [suppressedDiag]);
170
+ expect(result).toContain("Suppressed");
171
+ // No active errors/warnings were counted from a suppressed diagnostic
172
+ expect(result).toContain("No problems found");
173
+ });
174
+
175
+ test("an okf: citation resolves to the concept's title and bundle-relative path", () => {
176
+ const bundle: OkfBundle = {
177
+ concepts: [
178
+ concept({
179
+ path: "decisions/public-assets.md",
180
+ title: "Public asset bucket stays world-readable",
181
+ }),
182
+ ],
183
+ };
184
+
185
+ const result = formatStylish(
186
+ [],
187
+ [{ ...suppressedDiag, reason: "okf:/decisions/public-assets.md" }],
188
+ bundle,
189
+ );
190
+
191
+ expect(result).toContain("Public asset bucket stays world-readable");
192
+ expect(result).toContain("decisions/public-assets.md");
193
+ expect(result).not.toContain("okf:/decisions/public-assets.md");
194
+ });
195
+
196
+ test("also resolves a citation without a leading slash", () => {
197
+ const bundle: OkfBundle = {
198
+ concepts: [concept({ path: "decisions/public-assets.md", title: "Public assets" })],
199
+ };
200
+
201
+ const result = formatStylish(
202
+ [],
203
+ [{ ...suppressedDiag, reason: "okf:decisions/public-assets.md" }],
204
+ bundle,
205
+ );
206
+
207
+ expect(result).toContain("Public assets");
208
+ });
209
+
210
+ test("an unresolvable citation prints the raw reason with a warning", () => {
211
+ const bundle: OkfBundle = { concepts: [] };
212
+
213
+ const result = formatStylish(
214
+ [],
215
+ [{ ...suppressedDiag, reason: "okf:/decisions/does-not-exist.md" }],
216
+ bundle,
217
+ );
218
+
219
+ expect(result).toContain("okf:/decisions/does-not-exist.md");
220
+ expect(result).toContain("unresolved okf citation");
221
+ });
222
+
223
+ test("an okf: citation with no bundle loaded prints the raw reason with a warning", () => {
224
+ const result = formatStylish([], [{ ...suppressedDiag, reason: "okf:/decisions/public-assets.md" }]);
225
+
226
+ expect(result).toContain("okf:/decisions/public-assets.md");
227
+ expect(result).toContain("unresolved okf citation");
228
+ });
229
+
230
+ test("a plain (non-okf) reason renders as-is, unaffected by a loaded bundle", () => {
231
+ const bundle: OkfBundle = {
232
+ concepts: [concept({ path: "decisions/public-assets.md", title: "Public assets" })],
233
+ };
234
+
235
+ const result = formatStylish([], [{ ...suppressedDiag, reason: "grandfathered, see #412" }], bundle);
236
+
237
+ expect(result).toContain("grandfathered, see #412");
238
+ expect(result).not.toContain("Public assets");
239
+ });
240
+
241
+ test("a suppressed diagnostic with no reason still renders", () => {
242
+ const result = formatStylish([], [suppressedDiag]);
243
+ expect(result).toContain("AWS021");
244
+ expect(result).toContain("suppressed");
245
+ });
246
+
247
+ test("active diagnostics and the suppressed section coexist", () => {
248
+ const active: LintDiagnostic = {
249
+ file: "src/other.ts",
250
+ line: 1,
251
+ column: 1,
252
+ ruleId: "COR001",
253
+ severity: "error",
254
+ message: "Active problem",
255
+ };
256
+
257
+ const result = formatStylish([active], [{ ...suppressedDiag, reason: "okf:/decisions/public-assets.md" }], {
258
+ concepts: [concept({ path: "decisions/public-assets.md", title: "Public assets" })],
259
+ });
260
+
261
+ expect(result).toContain("src/other.ts");
262
+ expect(result).toContain("Active problem");
263
+ expect(result).toContain("1 error");
264
+ expect(result).toContain("Suppressed");
265
+ expect(result).toContain("Public assets");
266
+ });
267
+
268
+ test("a title-less resolved concept falls back to its path as the label", () => {
269
+ const bundle: OkfBundle = {
270
+ concepts: [concept({ path: "decisions/public-assets.md" })],
271
+ };
272
+
273
+ const result = formatStylish([], [{ ...suppressedDiag, reason: "okf:/decisions/public-assets.md" }], bundle);
274
+
275
+ expect(result).toContain("decisions/public-assets.md");
276
+ expect(result).not.toContain("unresolved");
277
+ });
278
+ });
279
+
126
280
  describe("formatSummary", () => {
127
281
  beforeEach(() => {
128
282
  process.env.NO_COLOR = "1";