@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.
- package/dist/audit/core.d.ts +21 -3
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +3 -2
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/build.d.ts +3 -3
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/mcp/tools/explain.d.ts +6 -0
- package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +1 -1
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/reporters/stylish.d.ts +15 -1
- package/dist/cli/reporters/stylish.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +4 -0
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/starter-plugin.d.ts +2 -0
- package/dist/components/starter-plugin.d.ts.map +1 -1
- package/dist/components/verbs/ensure-secret.d.ts +50 -0
- package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
- package/dist/components/verbs/index.d.ts +8 -0
- package/dist/components/verbs/index.d.ts.map +1 -1
- package/dist/components/verbs/r2-sync.d.ts +76 -0
- package/dist/components/verbs/r2-sync.d.ts.map +1 -0
- package/dist/components/verbs/wrangler.d.ts +108 -0
- package/dist/components/verbs/wrangler.d.ts.map +1 -0
- package/dist/config.d.ts +26 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +14 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/effect-receipt.d.ts +177 -0
- package/dist/effect-receipt.d.ts.map +1 -0
- package/dist/fold/subset.d.ts +15 -2
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +44 -3
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +33 -5
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/observation-baseline.d.ts +21 -3
- package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
- package/dist/lifecycle/receipt-plan.d.ts +62 -0
- package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +20 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/teardown.d.ts +6 -4
- package/dist/lifecycle/teardown.d.ts.map +1 -1
- package/dist/lifecycle/unobserved-gate.d.ts +67 -0
- package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
- package/dist/lint/knowledge-checks.d.ts +48 -0
- package/dist/lint/knowledge-checks.d.ts.map +1 -0
- package/dist/lint/output-checks.d.ts +5 -0
- package/dist/lint/output-checks.d.ts.map +1 -0
- package/dist/lint/pipeline-change-gate.d.ts +101 -0
- package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
- package/dist/lint/post-synth.d.ts +12 -0
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/lint/receipt-checks.d.ts +9 -0
- package/dist/lint/receipt-checks.d.ts.map +1 -0
- package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
- package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
- package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
- package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
- package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
- package/dist/lint/rules/index.d.ts +3 -1
- package/dist/lint/rules/index.d.ts.map +1 -1
- package/dist/okf-read.d.ts +78 -0
- package/dist/okf-read.d.ts.map +1 -0
- package/dist/op/builders.d.ts +98 -1
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +2 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/receipt-store.d.ts +138 -0
- package/dist/op/receipt-store.d.ts.map +1 -0
- package/dist/op/types.d.ts +31 -1
- package/dist/op/types.d.ts.map +1 -1
- package/dist/secret-materialization.d.ts +138 -0
- package/dist/secret-materialization.d.ts.map +1 -0
- package/dist/secret-provenance.d.ts +218 -0
- package/dist/secret-provenance.d.ts.map +1 -0
- package/dist/serializer.d.ts +11 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/audit/core.test.ts +57 -0
- package/src/audit/core.ts +0 -0
- package/src/audit/detect-bundle.test.ts +1 -1
- package/src/audit/discover.test.ts +24 -0
- package/src/audit/discover.ts +11 -2
- package/src/audit/rules-doc.ts +11 -1
- package/src/build.test.ts +41 -0
- package/src/build.ts +34 -6
- package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
- package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
- package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
- package/src/cli/commands/audit.test.ts +53 -0
- package/src/cli/commands/audit.ts +1 -1
- package/src/cli/commands/build.test.ts +80 -0
- package/src/cli/commands/build.ts +106 -8
- package/src/cli/commands/lint.ts +15 -3
- package/src/cli/handlers/explain.test.ts +70 -1
- package/src/cli/handlers/graph.ts +2 -2
- package/src/cli/handlers/lifecycle.test.ts +115 -1
- package/src/cli/handlers/lifecycle.ts +84 -11
- package/src/cli/mcp/resource-handlers.ts +38 -1
- package/src/cli/mcp/server.test.ts +58 -1
- package/src/cli/mcp/tools/explain.ts +51 -2
- package/src/cli/plugins.ts +4 -2
- package/src/cli/reporters/stylish.test.ts +154 -0
- package/src/cli/reporters/stylish.ts +154 -33
- package/src/components/auto-release.ts +6 -0
- package/src/components/registry.test.ts +7 -2
- package/src/components/starter-plugin.ts +17 -0
- package/src/components/verbs/ensure-secret.test.ts +130 -0
- package/src/components/verbs/ensure-secret.ts +79 -0
- package/src/components/verbs/index.ts +8 -0
- package/src/components/verbs/r2-sync.test.ts +107 -0
- package/src/components/verbs/r2-sync.ts +124 -0
- package/src/components/verbs/wrangler.test.ts +170 -0
- package/src/components/verbs/wrangler.ts +241 -0
- package/src/config.test.ts +15 -0
- package/src/config.ts +30 -0
- package/src/deep-observation.test.ts +19 -0
- package/src/deep-observation.ts +17 -0
- package/src/effect-receipt-exclusion.test.ts +190 -0
- package/src/effect-receipt.test.ts +419 -0
- package/src/effect-receipt.ts +412 -0
- package/src/fold/subset.test.ts +26 -0
- package/src/fold/subset.ts +45 -19
- package/src/index.ts +4 -0
- package/src/lexicon.ts +48 -3
- package/src/lifecycle/change-set.ts +46 -7
- package/src/lifecycle/index.ts +2 -0
- package/src/lifecycle/observation-baseline.test.ts +46 -0
- package/src/lifecycle/observation-baseline.ts +33 -1
- package/src/lifecycle/receipt-plan.test.ts +250 -0
- package/src/lifecycle/receipt-plan.ts +249 -0
- package/src/lifecycle/release-ledger.ts +20 -0
- package/src/lifecycle/teardown.test.ts +31 -0
- package/src/lifecycle/teardown.ts +6 -4
- package/src/lifecycle/unobserved-gate.test.ts +109 -0
- package/src/lifecycle/unobserved-gate.ts +102 -0
- package/src/lint/knowledge-checks.test.ts +80 -0
- package/src/lint/knowledge-checks.ts +74 -0
- package/src/lint/output-checks.test.ts +85 -0
- package/src/lint/output-checks.ts +99 -0
- package/src/lint/pipeline-change-gate.test.ts +144 -0
- package/src/lint/pipeline-change-gate.ts +153 -0
- package/src/lint/post-synth.ts +15 -0
- package/src/lint/receipt-checks.test.ts +101 -0
- package/src/lint/receipt-checks.ts +93 -0
- package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
- package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
- package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
- package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
- package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
- package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
- package/src/lint/rules/index.ts +7 -1
- package/src/okf-read.test.ts +149 -0
- package/src/okf-read.ts +197 -0
- package/src/op/builders.ts +139 -1
- package/src/op/effect-step.test.ts +311 -0
- package/src/op/index.ts +10 -3
- package/src/op/local-executor.ts +172 -25
- package/src/op/op.test.ts +25 -2
- package/src/op/receipt-store.ts +211 -0
- package/src/op/types.ts +33 -1
- package/src/secret-materialization.test.ts +199 -0
- package/src/secret-materialization.ts +235 -0
- package/src/secret-provenance.test.ts +388 -0
- package/src/secret-provenance.ts +475 -0
- package/src/serializer.ts +12 -0
- package/src/yaml.test.ts +88 -0
- package/src/yaml.ts +76 -6
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Receipt planning (#1832, epic #1703) — the plan half of the effect-receipt
|
|
3
|
+
* contract.
|
|
4
|
+
*
|
|
5
|
+
* A receipt is declared, diffed, and observed like any resource, but it is
|
|
6
|
+
* OBSERVE-ONLY to the generic apply path: the `effect()` step (#1834) is the
|
|
7
|
+
* sole writer, on success, last (epic decision log, item 3). The plan's job is
|
|
8
|
+
* therefore not "create the receipt" — it is "say whether the effect will
|
|
9
|
+
* fire". This module compares each declared receipt's live value against its
|
|
10
|
+
* resolved expectation and classifies:
|
|
11
|
+
*
|
|
12
|
+
* - absent or differing → an `effect` entry ("effect will fire") — including
|
|
13
|
+
* the crash-between-effect-and-write case, where a stale or missing receipt
|
|
14
|
+
* re-proposes the fire on the next plan. That re-proposal is the whole
|
|
15
|
+
* at-least-once guarantee; anything that stamps the receipt on the generic
|
|
16
|
+
* path silently converts it into never.
|
|
17
|
+
* - equal → `noop` — the effect has run for these inputs; the plan is clean.
|
|
18
|
+
* - unobservable → `unobserved`, loudly. A receipt nobody could read is a
|
|
19
|
+
* hole in the plan, never a clean row.
|
|
20
|
+
* - a reference input that cannot resolve at plan time → an `effect` entry
|
|
21
|
+
* with an "unresolved input" note, never a guessed digest. The effect step
|
|
22
|
+
* resolves again at run and decides there.
|
|
23
|
+
*
|
|
24
|
+
* A receipt name is also stripped out of whatever the generic change-set
|
|
25
|
+
* classification proposed for it ({@link mergeReceiptEntries}): a receipt is
|
|
26
|
+
* never a `create`, never an `update`, and never a `delete`/`adopt` prune
|
|
27
|
+
* candidate — the entries built here are the only rows a receipt gets.
|
|
28
|
+
*
|
|
29
|
+
* Pure: no I/O. Live state arrives as {@link ReceiptReading}s the caller
|
|
30
|
+
* built from its observations; reference inputs resolve through the
|
|
31
|
+
* caller-supplied resolver ({@link observedValueResolver} builds one over the
|
|
32
|
+
* plan's merged observed values).
|
|
33
|
+
*/
|
|
34
|
+
import { AttrRef } from "../attrref";
|
|
35
|
+
import {
|
|
36
|
+
EXISTENCE_EXPECTATION,
|
|
37
|
+
resolveReceiptExpectation,
|
|
38
|
+
type EffectReceiptDeclaration,
|
|
39
|
+
type ReceiptInputResolver,
|
|
40
|
+
} from "../effect-receipt";
|
|
41
|
+
import type { ResourceMetadata } from "../lexicon";
|
|
42
|
+
import type { UnobservedReason } from "../observation";
|
|
43
|
+
import type { ChangeSet, ChangeSetEntry } from "./change-set";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The attribute a materialized receipt's live value is read from. A lexicon
|
|
47
|
+
* receipt row's observation (#1835) maps the stored value onto
|
|
48
|
+
* `attributes[RECEIPT_VALUE_ATTRIBUTE]`; `Value` is accepted as a fallback for
|
|
49
|
+
* observations that keep the provider's own casing (SSM's `Value`).
|
|
50
|
+
*/
|
|
51
|
+
export const RECEIPT_VALUE_ATTRIBUTE = "value";
|
|
52
|
+
|
|
53
|
+
/** Read a live receipt's stored value off an observation's attributes. */
|
|
54
|
+
export function readReceiptValue(attributes: Record<string, unknown> | undefined): unknown {
|
|
55
|
+
if (!attributes) return undefined;
|
|
56
|
+
if (RECEIPT_VALUE_ATTRIBUTE in attributes) return attributes[RECEIPT_VALUE_ATTRIBUTE];
|
|
57
|
+
return attributes["Value"];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What the plan's observation pass learned about one declared receipt.
|
|
62
|
+
* Deliberately tri-state, the same shape the change set rests on (#1089):
|
|
63
|
+
* `observed: false` means "nobody looked", which is a hole — never absence.
|
|
64
|
+
* A receipt with no reading at all means no loaded lexicon even claims it.
|
|
65
|
+
*/
|
|
66
|
+
export interface ReceiptReading {
|
|
67
|
+
/** The lexicon actually looked. `false` → the receipt is a plan hole. */
|
|
68
|
+
observed: boolean;
|
|
69
|
+
/** Observed present in the live system. Meaningful only when `observed`. */
|
|
70
|
+
present: boolean;
|
|
71
|
+
/** The live receipt's stored value ({@link readReceiptValue}). */
|
|
72
|
+
value?: unknown;
|
|
73
|
+
/** Live resource type, when reported. */
|
|
74
|
+
type?: string;
|
|
75
|
+
/** Provider-assigned physical id, when reported. */
|
|
76
|
+
physicalId?: string;
|
|
77
|
+
/** The lexicon whose observation produced this reading. */
|
|
78
|
+
lexicon?: string;
|
|
79
|
+
/** Why the read did not happen, when `observed: false`. */
|
|
80
|
+
unobservedReason?: UnobservedReason;
|
|
81
|
+
unobservedDetail?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A {@link ReceiptInputResolver} over the plan's merged observed values: an
|
|
86
|
+
* attr-ref resolves to the referenced entity's observed attribute. Anything
|
|
87
|
+
* that is not among the observed values throws — which `planReceipts` renders
|
|
88
|
+
* as an effect-will-fire entry with an "unresolved input" note rather than a
|
|
89
|
+
* guessed expectation.
|
|
90
|
+
*/
|
|
91
|
+
export function observedValueResolver(
|
|
92
|
+
resources: Readonly<Record<string, ResourceMetadata>>,
|
|
93
|
+
): ReceiptInputResolver {
|
|
94
|
+
return (ref, path) => {
|
|
95
|
+
if (ref instanceof AttrRef) {
|
|
96
|
+
const entity = ref.getLogicalName();
|
|
97
|
+
if (!entity) {
|
|
98
|
+
throw new Error(`unresolved input at ${path}: attr-ref carries no logical name`);
|
|
99
|
+
}
|
|
100
|
+
const attrs = resources[entity]?.attributes;
|
|
101
|
+
if (!attrs || !(ref.attribute in attrs)) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`unresolved input at ${path}: ${entity}.${ref.attribute} is not among the observed values`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return attrs[ref.attribute];
|
|
107
|
+
}
|
|
108
|
+
throw new Error(`unresolved input at ${path}: reference cannot resolve at plan time`);
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Classify every declared receipt into its change-set entry. See the module
|
|
114
|
+
* doc for the classification; entries come back sorted by name.
|
|
115
|
+
*/
|
|
116
|
+
export function planReceipts(
|
|
117
|
+
receipts: ReadonlyMap<string, EffectReceiptDeclaration>,
|
|
118
|
+
readings: ReadonlyMap<string, ReceiptReading>,
|
|
119
|
+
resolver: ReceiptInputResolver,
|
|
120
|
+
): ChangeSetEntry[] {
|
|
121
|
+
const entries: ChangeSetEntry[] = [];
|
|
122
|
+
|
|
123
|
+
for (const [name, receipt] of receipts) {
|
|
124
|
+
const reading = readings.get(name);
|
|
125
|
+
const base = {
|
|
126
|
+
name,
|
|
127
|
+
type: reading?.type ?? receipt.entityType,
|
|
128
|
+
...(reading?.lexicon ? { lexicon: reading.lexicon } : {}),
|
|
129
|
+
...(reading?.physicalId ? { physicalId: reading.physicalId } : {}),
|
|
130
|
+
effect: receipt.effect,
|
|
131
|
+
ownership: "unknown" as const,
|
|
132
|
+
};
|
|
133
|
+
const evidence = {
|
|
134
|
+
declared: true,
|
|
135
|
+
inSnapshot: false,
|
|
136
|
+
live: reading?.present ?? false,
|
|
137
|
+
observed: reading?.observed ?? false,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
if (!reading) {
|
|
141
|
+
// No loaded lexicon materializes or observes this receipt — a hole,
|
|
142
|
+
// said loudly, never a silently clean row (#1089's tri-state).
|
|
143
|
+
entries.push({
|
|
144
|
+
...base,
|
|
145
|
+
action: "unobserved",
|
|
146
|
+
evidence,
|
|
147
|
+
unobservedReason: "unsupported-kind",
|
|
148
|
+
unobservedDetail:
|
|
149
|
+
`no loaded lexicon materializes or observes this receipt — ` +
|
|
150
|
+
`whether effect "${receipt.effect}" has run is unknown, not clean`,
|
|
151
|
+
});
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!reading.observed) {
|
|
156
|
+
entries.push({
|
|
157
|
+
...base,
|
|
158
|
+
action: "unobserved",
|
|
159
|
+
evidence,
|
|
160
|
+
unobservedReason: reading.unobservedReason ?? "read-failed",
|
|
161
|
+
...(reading.unobservedDetail ? { unobservedDetail: reading.unobservedDetail } : {}),
|
|
162
|
+
});
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Resolve the expectation. A reference that cannot resolve at plan time
|
|
167
|
+
// is never guessed around: the fire is proposed with the note, and the
|
|
168
|
+
// effect step resolves again at run (epic decision log, item 5).
|
|
169
|
+
let expectation: string | undefined;
|
|
170
|
+
let unresolved: string | undefined;
|
|
171
|
+
try {
|
|
172
|
+
expectation = resolveReceiptExpectation(receipt, resolver);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
unresolved = err instanceof Error ? err.message : String(err);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!reading.present) {
|
|
178
|
+
entries.push({
|
|
179
|
+
...base,
|
|
180
|
+
action: "effect",
|
|
181
|
+
evidence,
|
|
182
|
+
effectReason: "receipt-absent",
|
|
183
|
+
effectDetail:
|
|
184
|
+
`receipt confirmed absent — no run of "${receipt.effect}" is recorded` +
|
|
185
|
+
(unresolved ? ` (${unresolved})` : ""),
|
|
186
|
+
});
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (expectation === undefined) {
|
|
191
|
+
entries.push({
|
|
192
|
+
...base,
|
|
193
|
+
action: "effect",
|
|
194
|
+
evidence,
|
|
195
|
+
effectReason: "unresolved-input",
|
|
196
|
+
effectDetail: unresolved,
|
|
197
|
+
});
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (reading.value === expectation) {
|
|
202
|
+
entries.push({ ...base, action: "noop", evidence });
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
entries.push({
|
|
207
|
+
...base,
|
|
208
|
+
action: "effect",
|
|
209
|
+
evidence,
|
|
210
|
+
effectReason: "receipt-stale",
|
|
211
|
+
effectDetail:
|
|
212
|
+
receipt.flavor === "existence"
|
|
213
|
+
? `live value ${fmtValue(reading.value)} is not the existence marker "${EXISTENCE_EXPECTATION}"`
|
|
214
|
+
: `live value ${fmtValue(reading.value)} differs from expected ${expectation}`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
219
|
+
return entries;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Replace whatever the generic classification proposed for the receipts with
|
|
224
|
+
* the receipt entries built by {@link planReceipts}, in place.
|
|
225
|
+
*
|
|
226
|
+
* This is the plan-side write-exclusion (#1832): the generic change set,
|
|
227
|
+
* knowing nothing about receipts, classifies a declared-but-absent receipt as
|
|
228
|
+
* `create` and an undeclared-but-live one as `delete`/`adopt` — proposals the
|
|
229
|
+
* generic apply path must never act on for a receipt, and prune candidacy a
|
|
230
|
+
* receipt must never have. Every entry named like a receipt is dropped and
|
|
231
|
+
* the receipt's own entries stand in.
|
|
232
|
+
*/
|
|
233
|
+
export function mergeReceiptEntries(
|
|
234
|
+
cs: ChangeSet,
|
|
235
|
+
receipts: ReadonlyMap<string, EffectReceiptDeclaration>,
|
|
236
|
+
receiptEntries: ChangeSetEntry[],
|
|
237
|
+
): ChangeSet {
|
|
238
|
+
if (receipts.size === 0) return cs;
|
|
239
|
+
cs.entries = cs.entries.filter((e) => !receipts.has(e.name));
|
|
240
|
+
cs.entries.push(...receiptEntries);
|
|
241
|
+
cs.entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
242
|
+
return cs;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function fmtValue(v: unknown): string {
|
|
246
|
+
if (v === undefined) return "<unset>";
|
|
247
|
+
const s = typeof v === "string" ? v : JSON.stringify(v);
|
|
248
|
+
return s.length > 72 ? `${s.slice(0, 69)}...` : s;
|
|
249
|
+
}
|
|
@@ -78,6 +78,26 @@ export interface ReleaseRecord {
|
|
|
78
78
|
approver?: string;
|
|
79
79
|
/** Optional: the archive's own manifest digest (../components/verbs/build-archive.ts's `manifestDigest`), when the caller has it — lets a reader recover full build contents/provenance, not just the promoted image digest. */
|
|
80
80
|
manifestDigest?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Optional: set when the deploy's capability-profile assertion (chant
|
|
83
|
+
* #1244, helm lexicon) was deliberately overridden — carries the named
|
|
84
|
+
* divergences that were bypassed (declared vs live), so the ledger shows
|
|
85
|
+
* this release knowingly skewed from its declared profile. Absent for a
|
|
86
|
+
* deploy whose target matched, and for deploys with no declared profile.
|
|
87
|
+
*/
|
|
88
|
+
profileOverride?: string;
|
|
89
|
+
/**
|
|
90
|
+
* Optional: the deploy's input-side digest, when `digest` is a
|
|
91
|
+
* rendered-content identity rather than an input identity. A pinned helm
|
|
92
|
+
* deploy (chant #1242) records the render's `contentDigest` as `digest` —
|
|
93
|
+
* what this cluster actually received — and carries the input digest
|
|
94
|
+
* (chart, chart version, resolved values, capability facts; chant #1243)
|
|
95
|
+
* here, because profiles are per cluster: two environments legitimately
|
|
96
|
+
* render to different bytes, so cross-environment "is prod running what
|
|
97
|
+
* staging tested" joins on this field while `digest` proves the exact
|
|
98
|
+
* bytes each cluster got. Absent when `digest` is already input-side.
|
|
99
|
+
*/
|
|
100
|
+
inputDigest?: string;
|
|
81
101
|
}
|
|
82
102
|
|
|
83
103
|
/** Required, non-empty-string fields every `ReleaseRecord` must carry. */
|
|
@@ -281,6 +281,37 @@ describe("executeTeardown — the execution half (#1222)", () => {
|
|
|
281
281
|
expect(report.unimplemented).toEqual([]);
|
|
282
282
|
});
|
|
283
283
|
|
|
284
|
+
test("a retained outcome (#1830) passes through as the loud keep — reported, never retried, never counted failed", async () => {
|
|
285
|
+
const candidateSecret = { name: "prod/master-key", type: "K8s::Core::Secret", marker: { stack: "shop", env: "dev" } };
|
|
286
|
+
const execute = vi.fn(async (): Promise<TeardownExecution> => ({
|
|
287
|
+
outcomes: [
|
|
288
|
+
{ name: "prod/master-key", outcome: "retained", detail: "generated-once secret — deliberately kept, never swept" },
|
|
289
|
+
],
|
|
290
|
+
}));
|
|
291
|
+
const report = await executeTeardown({
|
|
292
|
+
environment: "dev",
|
|
293
|
+
stack: "shop",
|
|
294
|
+
plugins: [
|
|
295
|
+
createMockPlugin({
|
|
296
|
+
name: "k8s",
|
|
297
|
+
teardownOwned: async () => ({ candidates: [candidateSecret] }),
|
|
298
|
+
executeTeardown: execute,
|
|
299
|
+
}),
|
|
300
|
+
],
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// One pass only: retained is a verdict, not a failure to retry.
|
|
304
|
+
expect(execute).toHaveBeenCalledTimes(1);
|
|
305
|
+
expect(report.outcomes).toEqual([
|
|
306
|
+
{
|
|
307
|
+
lexicon: "k8s",
|
|
308
|
+
...candidateSecret,
|
|
309
|
+
outcome: "retained",
|
|
310
|
+
detail: "generated-once secret — deliberately kept, never swept",
|
|
311
|
+
},
|
|
312
|
+
]);
|
|
313
|
+
});
|
|
314
|
+
|
|
284
315
|
test("reuses a handed-in plan instead of re-reading", async () => {
|
|
285
316
|
const teardownOwned = vi.fn();
|
|
286
317
|
const execute = vi.fn(async (): Promise<TeardownExecution> => ({
|
|
@@ -191,11 +191,13 @@ export async function planTeardown(opts: PlanTeardownOptions): Promise<TeardownP
|
|
|
191
191
|
export interface TeardownOutcomeEntry extends TeardownPlanEntry {
|
|
192
192
|
/**
|
|
193
193
|
* `skipped` is core's verdict for a candidate whose lexicon implements no
|
|
194
|
-
* `executeTeardown` yet; the
|
|
195
|
-
* {@link TeardownOutcome}).
|
|
194
|
+
* `executeTeardown` yet; the others come from the lexicon (see
|
|
195
|
+
* {@link TeardownOutcome}). `retained` is the loud keep (#1365 decision 5):
|
|
196
|
+
* owned, no longer declared, deliberately not deleted — a `generated-once`
|
|
197
|
+
* secret survives every sweep and says so with a row.
|
|
196
198
|
*/
|
|
197
|
-
outcome: "deleted" | "failed" | "not-prunable" | "skipped";
|
|
198
|
-
/** The error for `failed`, the reason for `not-prunable`/`skipped`. */
|
|
199
|
+
outcome: "deleted" | "failed" | "not-prunable" | "retained" | "skipped";
|
|
200
|
+
/** The error for `failed`, the reason for `not-prunable`/`retained`/`skipped`. */
|
|
199
201
|
detail?: string;
|
|
200
202
|
/** True when this final outcome came from the bounded retry pass. */
|
|
201
203
|
retried?: boolean;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { buildChangeSet } from "./change-set";
|
|
3
|
+
import { evaluateUnobservedGate } from "./unobserved-gate";
|
|
4
|
+
import type { ChangeSet } from "./change-set";
|
|
5
|
+
import type { UnobservedReason } from "../observation";
|
|
6
|
+
|
|
7
|
+
const cleanChangeSet = (env = "prod"): ChangeSet =>
|
|
8
|
+
buildChangeSet(env, {
|
|
9
|
+
declared: new Set(["bucket"]),
|
|
10
|
+
observedNow: { bucket: { type: "Fake::Bucket", status: "OK" } },
|
|
11
|
+
observedThen: undefined,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const unobservedChangeSet = (reason: UnobservedReason, detail?: string): ChangeSet =>
|
|
15
|
+
buildChangeSet("prod", {
|
|
16
|
+
declared: new Set(["queue"]),
|
|
17
|
+
observedNow: {},
|
|
18
|
+
observedThen: undefined,
|
|
19
|
+
unobserved: { queue: { reason, ...(detail ? { detail } : {}) } },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("evaluateUnobservedGate (#1568)", () => {
|
|
23
|
+
test("a clean change set passes under every policy", () => {
|
|
24
|
+
const cs = cleanChangeSet();
|
|
25
|
+
expect(evaluateUnobservedGate(cs)).toEqual({ pass: true, escalate: false, findings: [] });
|
|
26
|
+
expect(evaluateUnobservedGate(cs, "escalate")).toEqual({ pass: true, escalate: false, findings: [] });
|
|
27
|
+
expect(evaluateUnobservedGate(cs, { allow: [] })).toEqual({ pass: true, escalate: false, findings: [] });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("default policy (no argument) refuses a plan with an unobserved entity", () => {
|
|
31
|
+
const cs = unobservedChangeSet("no-credentials", "aws sts get-caller-identity: access denied");
|
|
32
|
+
const verdict = evaluateUnobservedGate(cs);
|
|
33
|
+
expect(verdict.pass).toBe(false);
|
|
34
|
+
expect(verdict.escalate).toBe(false);
|
|
35
|
+
expect(verdict.findings).toEqual([
|
|
36
|
+
{ name: "queue", reason: "no-credentials", detail: "aws sts get-caller-identity: access denied" },
|
|
37
|
+
]);
|
|
38
|
+
expect(verdict.detail).toContain("queue");
|
|
39
|
+
expect(verdict.detail).toContain("no credentials");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('explicit "refuse" behaves the same as the default', () => {
|
|
43
|
+
const cs = unobservedChangeSet("read-failed");
|
|
44
|
+
expect(evaluateUnobservedGate(cs, "refuse").pass).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('"escalate" does not fail the gate but flags escalate with the findings', () => {
|
|
48
|
+
const cs = unobservedChangeSet("no-binding");
|
|
49
|
+
const verdict = evaluateUnobservedGate(cs, "escalate");
|
|
50
|
+
expect(verdict.pass).toBe(true);
|
|
51
|
+
expect(verdict.escalate).toBe(true);
|
|
52
|
+
expect(verdict.findings).toHaveLength(1);
|
|
53
|
+
expect(verdict.findings[0].reason).toBe("no-binding");
|
|
54
|
+
expect(verdict.detail).toBeDefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("an allowed reason passes cleanly and is not counted as a finding", () => {
|
|
58
|
+
const cs = unobservedChangeSet("filtered");
|
|
59
|
+
const verdict = evaluateUnobservedGate(cs, { allow: ["filtered"] });
|
|
60
|
+
expect(verdict).toEqual({ pass: true, escalate: false, findings: [] });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a reason outside the allow list still refuses", () => {
|
|
64
|
+
const cs = unobservedChangeSet("no-credentials");
|
|
65
|
+
const verdict = evaluateUnobservedGate(cs, { allow: ["filtered"] });
|
|
66
|
+
expect(verdict.pass).toBe(false);
|
|
67
|
+
expect(verdict.findings[0].reason).toBe("no-credentials");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("a mixed change set with one allowed and one unallowed reason only reports the unallowed one", () => {
|
|
71
|
+
const cs = buildChangeSet("prod", {
|
|
72
|
+
declared: new Set(["a", "b"]),
|
|
73
|
+
observedNow: {},
|
|
74
|
+
observedThen: undefined,
|
|
75
|
+
unobserved: {
|
|
76
|
+
a: { reason: "filtered" },
|
|
77
|
+
b: { reason: "unsupported-kind" },
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
const verdict = evaluateUnobservedGate(cs, { allow: ["filtered"] });
|
|
81
|
+
expect(verdict.pass).toBe(false);
|
|
82
|
+
expect(verdict.findings).toEqual([{ name: "b", reason: "unsupported-kind" }]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("never escalates or refuses on create/update/delete/adopt/noop — only `unobserved` counts", () => {
|
|
86
|
+
const cs = buildChangeSet("prod", {
|
|
87
|
+
declared: new Set(["created", "updated"]),
|
|
88
|
+
observedNow: {
|
|
89
|
+
updated: { type: "Fake::Resource", status: "DRIFTED" },
|
|
90
|
+
orphan: { type: "Fake::Resource", status: "OK" },
|
|
91
|
+
},
|
|
92
|
+
observedThen: { updated: { type: "Fake::Resource", status: "OK" } },
|
|
93
|
+
});
|
|
94
|
+
expect(evaluateUnobservedGate(cs)).toEqual({ pass: true, escalate: false, findings: [] });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("carries type and detail through into the finding when present", () => {
|
|
98
|
+
const cs = buildChangeSet("prod", {
|
|
99
|
+
declared: new Set(["disk"]),
|
|
100
|
+
observedNow: {},
|
|
101
|
+
observedThen: undefined,
|
|
102
|
+
unobserved: { disk: { reason: "unsupported-kind", detail: "no describe support for this kind yet", type: "Fake::Disk" } },
|
|
103
|
+
});
|
|
104
|
+
const verdict = evaluateUnobservedGate(cs);
|
|
105
|
+
expect(verdict.findings).toEqual([
|
|
106
|
+
{ name: "disk", type: "Fake::Disk", reason: "unsupported-kind", detail: "no describe support for this kind yet" },
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The unobserved gate predicate (#1568): a plan containing unobserved
|
|
3
|
+
* entities must not pass as clean.
|
|
4
|
+
*
|
|
5
|
+
* `buildChangeSet` (./change-set.ts) already refuses to guess when a lexicon
|
|
6
|
+
* could not look (#1089) — entries land as `action: "unobserved"`, carrying a
|
|
7
|
+
* total `unobservedReason` (`read-failed`, `no-credentials`, `no-binding`,
|
|
8
|
+
* `unsupported-kind`, `filtered`; see ../observation.ts). But nothing
|
|
9
|
+
* downstream is *required* to look: a gate that checks lint-clean,
|
|
10
|
+
* owned-only, and a blast-radius budget can walk right past a change set
|
|
11
|
+
* whose live read partially failed and call it clean, because none of those
|
|
12
|
+
* checks reads `unobserved` at all. A gate over an incomplete read is a gate
|
|
13
|
+
* over a guess.
|
|
14
|
+
*
|
|
15
|
+
* This is a reusable predicate, not a runtime — evaluate a `ChangeSet`
|
|
16
|
+
* against a policy and get back a verdict; the caller (a `lint.policies`
|
|
17
|
+
* check routed through `policyGate`, an Op step, #1487's gate-as-fact once it
|
|
18
|
+
* lands) decides what to do with a refusal (throw, diagnostic, a durable
|
|
19
|
+
* gated-run fact). Same instinct #1484 already adopts for `unknown` in the
|
|
20
|
+
* operating loop ("`unknown` never remediates"), surfaced here as something
|
|
21
|
+
* any gate can declare rather than a property of one runtime.
|
|
22
|
+
*/
|
|
23
|
+
import type { ChangeSet } from "./change-set";
|
|
24
|
+
import { unobservedReasonText, type UnobservedReason } from "../observation";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How the gate treats the change set's unobserved set.
|
|
28
|
+
*
|
|
29
|
+
* - `"refuse"` (the default) — any unobserved entry not covered by `allow`
|
|
30
|
+
* fails the gate outright. Fail-closed, the right default for a policy set
|
|
31
|
+
* aimed at agent-proposed change sets: a hole must never launder into a
|
|
32
|
+
* clean verdict just because nobody configured otherwise.
|
|
33
|
+
* - `"escalate"` — the gate does not fail here; instead the verdict reports
|
|
34
|
+
* `escalate: true` so a caller can route the plan to a stricter gate class
|
|
35
|
+
* rather than the ordinary path (the same shape of routing decision #1569
|
|
36
|
+
* makes for pipeline changes).
|
|
37
|
+
* - `{ allow: [...] }` — the listed reasons are tolerated (e.g. `"filtered"`,
|
|
38
|
+
* intentional in a scoped run) and never fail or escalate the gate; any
|
|
39
|
+
* reason NOT in the list still refuses. `no-credentials` — a hole that must
|
|
40
|
+
* never launder into a clean verdict — should never appear in an `allow`
|
|
41
|
+
* list for a policy aimed at agent-proposed change sets.
|
|
42
|
+
*/
|
|
43
|
+
export type UnobservedGatePolicy = "refuse" | "escalate" | { allow: UnobservedReason[] };
|
|
44
|
+
|
|
45
|
+
/** One unobserved hole the gate found, carried into the refusal. */
|
|
46
|
+
export interface UnobservedGateFinding {
|
|
47
|
+
name: string;
|
|
48
|
+
type?: string;
|
|
49
|
+
reason: UnobservedReason;
|
|
50
|
+
detail?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface UnobservedGateVerdict {
|
|
54
|
+
/** False when the policy is `"refuse"` (default) and an unallowed unobserved entry exists. */
|
|
55
|
+
pass: boolean;
|
|
56
|
+
/** True when the policy is `"escalate"` and an unallowed unobserved entry exists — route to a stricter gate instead of failing here. */
|
|
57
|
+
escalate: boolean;
|
|
58
|
+
/** Every unobserved entry the policy did not allow. Empty when `pass` is true and `escalate` is false. */
|
|
59
|
+
findings: UnobservedGateFinding[];
|
|
60
|
+
/** Human-readable summary carrying the reasons into a refusal — the message a caller can surface as-is. Undefined when `findings` is empty. */
|
|
61
|
+
detail?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Evaluate a `ChangeSet`'s unobserved set against `policy`. Pure — reads only
|
|
66
|
+
* `cs.entries`, no I/O. Defaults to `"refuse"`.
|
|
67
|
+
*/
|
|
68
|
+
export function evaluateUnobservedGate(
|
|
69
|
+
cs: ChangeSet,
|
|
70
|
+
policy: UnobservedGatePolicy = "refuse",
|
|
71
|
+
): UnobservedGateVerdict {
|
|
72
|
+
const allowed = typeof policy === "object" ? new Set(policy.allow) : undefined;
|
|
73
|
+
|
|
74
|
+
const findings: UnobservedGateFinding[] = [];
|
|
75
|
+
for (const e of cs.entries) {
|
|
76
|
+
if (e.action !== "unobserved" || e.unobservedReason === undefined) continue;
|
|
77
|
+
if (allowed?.has(e.unobservedReason)) continue;
|
|
78
|
+
findings.push({
|
|
79
|
+
name: e.name,
|
|
80
|
+
...(e.type ? { type: e.type } : {}),
|
|
81
|
+
reason: e.unobservedReason,
|
|
82
|
+
...(e.unobservedDetail ? { detail: e.unobservedDetail } : {}),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (findings.length === 0) {
|
|
87
|
+
return { pass: true, escalate: false, findings: [] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const detail =
|
|
91
|
+
`plan contains ${findings.length} unobserved ${findings.length === 1 ? "entity" : "entities"} — ` +
|
|
92
|
+
findings
|
|
93
|
+
.map((f) => `${f.name}${f.type ? ` (${f.type})` : ""}: ${unobservedReasonText(f.reason)}${f.detail ? ` (${f.detail})` : ""}`)
|
|
94
|
+
.join(", ");
|
|
95
|
+
|
|
96
|
+
if (policy === "escalate") {
|
|
97
|
+
return { pass: true, escalate: true, findings, detail };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// "refuse" (default) and `{ allow }` both fail closed on anything not allowed.
|
|
101
|
+
return { pass: false, escalate: false, findings, detail };
|
|
102
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { coreKnowledgeChecks, STALE_KNOWLEDGE_BINDING_CHECK_ID } from "./knowledge-checks";
|
|
3
|
+
import { runPostSynthChecks } from "./post-synth";
|
|
4
|
+
import { DECLARABLE_MARKER, type Declarable } from "../declarable";
|
|
5
|
+
import type { OkfBundle, OkfConcept } from "../okf-read";
|
|
6
|
+
|
|
7
|
+
function entity(): Declarable {
|
|
8
|
+
return { [DECLARABLE_MARKER]: true, lexicon: "aws", entityType: "AWS::S3::Bucket" } as unknown as Declarable;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function concept(overrides: Partial<OkfConcept> = {}): OkfConcept {
|
|
12
|
+
return {
|
|
13
|
+
path: "decisions/example.md",
|
|
14
|
+
type: "decision",
|
|
15
|
+
binds: [],
|
|
16
|
+
frontmatter: {},
|
|
17
|
+
body: "",
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function run(bundle: OkfBundle, entities: Map<string, Declarable>) {
|
|
23
|
+
return runPostSynthChecks(coreKnowledgeChecks(bundle), {
|
|
24
|
+
outputs: new Map(),
|
|
25
|
+
entities,
|
|
26
|
+
warnings: [],
|
|
27
|
+
errors: [],
|
|
28
|
+
sourceFileCount: 1,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("COR026: stale knowledge binding (#1865)", () => {
|
|
33
|
+
test("check id", () => {
|
|
34
|
+
expect(STALE_KNOWLEDGE_BINDING_CHECK_ID).toBe("COR026");
|
|
35
|
+
expect(coreKnowledgeChecks({ concepts: [] }).map((c) => c.id)).toContain("COR026");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("fires once per unresolved binding, naming the concept path and the unresolved name", () => {
|
|
39
|
+
const bundle: OkfBundle = {
|
|
40
|
+
concepts: [concept({ path: "decisions/ghost.md", title: "Stale", binds: ["ghostBucket"] })],
|
|
41
|
+
};
|
|
42
|
+
const diags = run(bundle, new Map());
|
|
43
|
+
expect(diags).toHaveLength(1);
|
|
44
|
+
expect(diags[0].checkId).toBe("COR026");
|
|
45
|
+
expect(diags[0].severity).toBe("warning");
|
|
46
|
+
expect(diags[0].message).toContain("decisions/ghost.md");
|
|
47
|
+
expect(diags[0].message).toContain("ghostBucket");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("fires one diagnostic per unresolved name when a concept binds several", () => {
|
|
51
|
+
const bundle: OkfBundle = {
|
|
52
|
+
concepts: [concept({ binds: ["ghostOne", "ghostTwo"] })],
|
|
53
|
+
};
|
|
54
|
+
const diags = run(bundle, new Map());
|
|
55
|
+
expect(diags).toHaveLength(2);
|
|
56
|
+
expect(diags.map((d) => d.message).join("\n")).toContain("ghostOne");
|
|
57
|
+
expect(diags.map((d) => d.message).join("\n")).toContain("ghostTwo");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("does not fire when the bound name resolves to a discovered entity", () => {
|
|
61
|
+
const bundle: OkfBundle = { concepts: [concept({ binds: ["realBucket"] })] };
|
|
62
|
+
const entities = new Map<string, Declarable>([["realBucket", entity()]]);
|
|
63
|
+
expect(run(bundle, entities)).toHaveLength(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("does not fire for a concept with no binds at all — orphaned knowledge is legitimate", () => {
|
|
67
|
+
const bundle: OkfBundle = { concepts: [concept({ binds: [] })] };
|
|
68
|
+
expect(run(bundle, new Map())).toHaveLength(0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("does not fire for an empty bundle (no knowledge directory)", () => {
|
|
72
|
+
expect(run({ concepts: [] }, new Map())).toHaveLength(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("build and synthesis are unaffected — severity is always warning, never error", () => {
|
|
76
|
+
const bundle: OkfBundle = { concepts: [concept({ binds: ["ghost"] })] };
|
|
77
|
+
const diags = run(bundle, new Map());
|
|
78
|
+
expect(diags.every((d) => d.severity === "warning")).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
});
|