@intentius/chant 0.62.0 → 0.64.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/cli/handlers/operator.d.ts +13 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +3 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/driver-output.d.ts.map +1 -1
- package/dist/components/driver.d.ts +12 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +12 -0
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +10 -0
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +36 -2
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +61 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +1 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/plan-digest.d.ts +33 -0
- package/dist/lifecycle/plan-digest.d.ts.map +1 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/op/activities/lexicon-upgrade.d.ts +14 -2
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/lifecycle.d.ts +27 -0
- package/dist/op/activities/lifecycle.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +196 -27
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/builders.d.ts +6 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/composites/apply-op.d.ts +6 -0
- package/dist/op/composites/apply-op.d.ts.map +1 -1
- package/dist/op/composites/reconcile-op.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +16 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +104 -13
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +3 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +17 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +8 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/types.d.ts +19 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
- package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
- package/dist/terraform/graph.d.ts +18 -2
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/parse.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +7 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/operator.test.ts +130 -0
- package/src/cli/handlers/operator.ts +56 -2
- package/src/cli/handlers/run.ts +19 -0
- package/src/cli/main.ts +2 -0
- package/src/cli/registry.ts +2 -0
- package/src/components/cli-support.ts +29 -4
- package/src/components/driver-output.ts +10 -0
- package/src/components/driver.test.ts +31 -0
- package/src/components/driver.ts +54 -8
- package/src/discovery/fold-import.test.ts +55 -0
- package/src/discovery/fold-import.ts +12 -0
- package/src/fold/fold.test.ts +152 -0
- package/src/fold/fold.ts +112 -2
- package/src/fold/subset-doc-parity.test.ts +35 -1
- package/src/fold/subset-public-export.test.ts +27 -0
- package/src/fold/subset.ts +36 -2
- package/src/index.ts +5 -0
- package/src/lifecycle/gate-ledger.test.ts +133 -1
- package/src/lifecycle/gate-ledger.ts +108 -0
- package/src/lifecycle/index.ts +1 -0
- package/src/lifecycle/plan-digest.test.ts +49 -0
- package/src/lifecycle/plan-digest.ts +86 -0
- package/src/lifecycle/run-ledger.ts +1 -0
- package/src/op/activities/lexicon-upgrade.test.ts +24 -12
- package/src/op/activities/lexicon-upgrade.ts +19 -3
- package/src/op/activities/lifecycle.ts +51 -2
- package/src/op/activities/reconcile.test.ts +512 -26
- package/src/op/activities/reconcile.ts +307 -34
- package/src/op/builders.ts +7 -1
- package/src/op/composites/apply-op.ts +16 -0
- package/src/op/composites/composites.test.ts +15 -2
- package/src/op/composites/reconcile-op.test.ts +18 -0
- package/src/op/composites/reconcile-op.ts +7 -1
- package/src/op/gate-summary.test.ts +33 -0
- package/src/op/gate-summary.ts +31 -0
- package/src/op/gate.test.ts +111 -1
- package/src/op/gate.ts +181 -23
- package/src/op/index.ts +5 -2
- package/src/op/local-executor.test.ts +226 -3
- package/src/op/local-executor.ts +61 -12
- package/src/op/local-output.test.ts +38 -0
- package/src/op/local-output.ts +24 -1
- package/src/op/op-ir.test.ts +22 -0
- package/src/op/op-ir.ts +9 -0
- package/src/op/runtime.ts +2 -0
- package/src/op/types.ts +19 -0
- package/src/terraform/__fixtures__/build-graph.ts +42 -0
- package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
- package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
- package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
- package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
- package/src/terraform/graph.test.ts +148 -1
- package/src/terraform/graph.ts +144 -6
- package/src/terraform/parse.ts +4 -1
- package/src/terraform/types.ts +7 -0
package/src/fold/fold.ts
CHANGED
|
@@ -21,6 +21,16 @@ import { isFoldableHelperName } from "./foldable-helpers";
|
|
|
21
21
|
/**
|
|
22
22
|
* fold — static AST value reducer (chant #1026/#1021/#1024, part of epic #1019)
|
|
23
23
|
*
|
|
24
|
+
* Implements judgment J1 (`F-Eval-*`, `spec/judgments.md`) and the value domain
|
|
25
|
+
* (`F-Val-*`, `spec/values.md`) of the TypeScript-as-Data specification at
|
|
26
|
+
* https://github.com/INTENTIUS/typescript-as-data, which is normative for the
|
|
27
|
+
* subset since INTENTIUS/typescript-as-data#33. Each branch of {@link fold}
|
|
28
|
+
* below is one F-Eval rule — the identifier branch is F-Eval-Ident, the
|
|
29
|
+
* property-access branch F-Eval-Member (its numbered steps match), the call
|
|
30
|
+
* branch F-Eval-CallHelper / CallIntrinsic / CallLocal / CallEager / CallMethod
|
|
31
|
+
* in that order — and the envelope types here are F-Val-Domain's cases.
|
|
32
|
+
* Subset changes go spec-first; see subset.ts's module doc for the process.
|
|
33
|
+
*
|
|
24
34
|
* Reduces a single-file TypeScript expression AST to a value with NO
|
|
25
35
|
* module execution. The node-kind/operator/key subset it covers — literals,
|
|
26
36
|
* template interpolation, object/array literals (incl. spread), `const`
|
|
@@ -784,6 +794,87 @@ function attrRefOnFoldedResource(
|
|
|
784
794
|
);
|
|
785
795
|
}
|
|
786
796
|
|
|
797
|
+
/**
|
|
798
|
+
* chant #2328 — the marker an optional-chain link leaves behind when it
|
|
799
|
+
* short-circuits, carried up the rest of the chain and unwrapped to
|
|
800
|
+
* `undefined` at its end.
|
|
801
|
+
*
|
|
802
|
+
* `a?.b` on a nullish `a` is DEFINED to be `undefined` in JavaScript, and so
|
|
803
|
+
* is every link after it: `a?.b.c` is `undefined` too, not a `TypeError` on
|
|
804
|
+
* `undefined.c`. A non-optional `a.b` on a nullish `a` throws. Both shapes
|
|
805
|
+
* reach the property-access branch below with the same nullish object, so
|
|
806
|
+
* telling them apart needs the answer to "did an EARLIER link short-circuit?"
|
|
807
|
+
* — which a plain `undefined` return cannot carry, because a genuine
|
|
808
|
+
* `undefined` looks identical: `({}).b.c` is `undefined` at the first link
|
|
809
|
+
* and running it DOES throw at the second.
|
|
810
|
+
*
|
|
811
|
+
* This marker carries it. {@link shortCircuited} produces it only where
|
|
812
|
+
* {@link continuesOptionalChain} says the parent node is the next link of the
|
|
813
|
+
* same chain, so the parent always consumes it, and turns it back into
|
|
814
|
+
* `undefined` at the last link — which is where JavaScript's own
|
|
815
|
+
* short-circuit lands. It never escapes {@link fold}'s return to a caller.
|
|
816
|
+
*/
|
|
817
|
+
const CHAIN_SHORT_CIRCUIT = Symbol("chant.fold.optional-chain-short-circuit");
|
|
818
|
+
|
|
819
|
+
/** True when `value` is the {@link CHAIN_SHORT_CIRCUIT} marker. */
|
|
820
|
+
function isChainShortCircuit(value: FoldedValue): boolean {
|
|
821
|
+
return (value as unknown) === CHAIN_SHORT_CIRCUIT;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* The value a chain that short-circuited at or before `node` has AT `node`:
|
|
826
|
+
* the {@link CHAIN_SHORT_CIRCUIT} marker while another link follows,
|
|
827
|
+
* `undefined` once `node` is the last one.
|
|
828
|
+
*/
|
|
829
|
+
function shortCircuited(node: ts.Expression): FoldedValue {
|
|
830
|
+
return continuesOptionalChain(node) ? (CHAIN_SHORT_CIRCUIT as unknown as FoldedValue) : undefined;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* True when `node`'s parent is the next link of the SAME optional chain, so a
|
|
835
|
+
* short-circuit at `node` must keep travelling rather than becoming
|
|
836
|
+
* `undefined` here.
|
|
837
|
+
*
|
|
838
|
+
* TypeScript flags every access/call node after a `?.` as part of that chain
|
|
839
|
+
* and stops flagging at the first construct that ends it, so this needs no
|
|
840
|
+
* bookkeeping of its own: `(a?.b).c` — where the parentheses end the chain
|
|
841
|
+
* and running really does throw on `.c` — is not a continuation, and neither
|
|
842
|
+
* is `(a?.b as X).c`. A `NonNullChain` (`a?.b!.c`) is transparent, exactly as
|
|
843
|
+
* {@link fold}'s own non-null unwrapping is.
|
|
844
|
+
*/
|
|
845
|
+
function continuesOptionalChain(node: ts.Node): boolean {
|
|
846
|
+
const parent: ts.Node | undefined = node.parent;
|
|
847
|
+
if (parent === undefined) return false;
|
|
848
|
+
if (ts.isNonNullExpression(parent) && ts.isOptionalChain(parent)) return continuesOptionalChain(parent);
|
|
849
|
+
return (
|
|
850
|
+
(ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent) || ts.isCallExpression(parent)) &&
|
|
851
|
+
parent.expression === node &&
|
|
852
|
+
ts.isOptionalChain(parent)
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* chant #2328 — a property or element read whose object folded to `null` or
|
|
858
|
+
* `undefined`. Folding it to `undefined` (what both branches did from #1026
|
|
859
|
+
* until now) drops the property from the output and lets the build carry on,
|
|
860
|
+
* while RUNNING the same expression throws `TypeError: Cannot read properties
|
|
861
|
+
* of undefined` — the fold/run disagreement #1535 already ruled unacceptable
|
|
862
|
+
* one branch over, where an attribute read on a resource envelope folded away
|
|
863
|
+
* and a trust policy shipped as `Principal: {}`.
|
|
864
|
+
*
|
|
865
|
+
* So it refuses, and the file falls back to run — where the real `TypeError`
|
|
866
|
+
* happens at the line that caused it, naming the property the way it would
|
|
867
|
+
* without `--fold` at all. The usual cause is a typo in a nested path
|
|
868
|
+
* (`cfg.nett.vpcId`); a genuinely optional read says so with `?.`, which
|
|
869
|
+
* short-circuits above instead of reaching this message.
|
|
870
|
+
*/
|
|
871
|
+
function nullishAccessMessage(member: string, obj: null | undefined): string {
|
|
872
|
+
return (
|
|
873
|
+
`property "${member}" read on ${String(obj)} is not foldable — running this expression throws a TypeError, ` +
|
|
874
|
+
`so the file falls back to run (write \`?.\` if the value is genuinely optional)`
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
|
|
787
878
|
/**
|
|
788
879
|
* True when `node` is an identifier, or a dotted/bracketed access chain
|
|
789
880
|
* rooted at an identifier, that neither `consts` nor `externals` can resolve
|
|
@@ -1074,7 +1165,14 @@ export function fold(
|
|
|
1074
1165
|
};
|
|
1075
1166
|
}
|
|
1076
1167
|
const obj = fold(node.expression, consts, intrinsics, externals);
|
|
1077
|
-
|
|
1168
|
+
// chant #2328 — see {@link nullishAccessMessage}. An earlier `?.` that
|
|
1169
|
+
// short-circuited carries the whole chain to `undefined`; a `?.` here does
|
|
1170
|
+
// the same for a nullish object; a plain `.` on one is the refusal.
|
|
1171
|
+
if (isChainShortCircuit(obj)) return shortCircuited(node);
|
|
1172
|
+
if (obj === null || obj === undefined) {
|
|
1173
|
+
if (node.questionDotToken) return shortCircuited(node);
|
|
1174
|
+
throw foldError(node, nullishAccessMessage(node.name.text, obj));
|
|
1175
|
+
}
|
|
1078
1176
|
if (isFoldedResource(obj)) return attrRefOnFoldedResource(node, node.name.text);
|
|
1079
1177
|
return (obj as { [key: string]: FoldedValue })[node.name.text];
|
|
1080
1178
|
}
|
|
@@ -1085,7 +1183,13 @@ export function fold(
|
|
|
1085
1183
|
return { __attrRef: { entity: node.expression.text, attribute: key } };
|
|
1086
1184
|
}
|
|
1087
1185
|
const obj = fold(node.expression, consts, intrinsics, externals);
|
|
1088
|
-
|
|
1186
|
+
// chant #2328 — identical to the property-access branch above; `a?.["k"]`
|
|
1187
|
+
// is the bracketed spelling of the same short-circuit.
|
|
1188
|
+
if (isChainShortCircuit(obj)) return shortCircuited(node);
|
|
1189
|
+
if (obj === null || obj === undefined) {
|
|
1190
|
+
if (node.questionDotToken) return shortCircuited(node);
|
|
1191
|
+
throw foldError(node, nullishAccessMessage(key, obj));
|
|
1192
|
+
}
|
|
1089
1193
|
if (isFoldedResource(obj)) return attrRefOnFoldedResource(node, key);
|
|
1090
1194
|
return (obj as { [key: string]: FoldedValue })[key];
|
|
1091
1195
|
}
|
|
@@ -1310,7 +1414,13 @@ export function fold(
|
|
|
1310
1414
|
if (ts.isPropertyAccessExpression(node.expression)) {
|
|
1311
1415
|
const methodName = node.expression.name.text;
|
|
1312
1416
|
const receiver = fold(node.expression.expression, consts, intrinsics, externals);
|
|
1417
|
+
// chant #2328 — a call is a link of an optional chain like any other:
|
|
1418
|
+
// `a?.b()` and `a?.b.c()` on a nullish `a` are `undefined` in
|
|
1419
|
+
// JavaScript, not a call on nothing. Only a non-optional receiver keeps
|
|
1420
|
+
// the refusal this branch has made since #1966.
|
|
1421
|
+
if (isChainShortCircuit(receiver)) return shortCircuited(node);
|
|
1313
1422
|
if (receiver === null || receiver === undefined) {
|
|
1423
|
+
if (node.expression.questionDotToken) return shortCircuited(node);
|
|
1314
1424
|
throw foldError(node, `cannot call ".${methodName}(...)" on ${String(receiver)}`);
|
|
1315
1425
|
}
|
|
1316
1426
|
if (isFoldSymbolicEnvelope(receiver)) {
|
|
@@ -3,7 +3,7 @@ import * as ts from "typescript";
|
|
|
3
3
|
import { readFileSync } from "fs";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
5
|
import { join } from "path";
|
|
6
|
-
import { collectConsts } from "./fold";
|
|
6
|
+
import { collectConsts, fold, FoldError } from "./fold";
|
|
7
7
|
import { findSubsetViolation } from "./subset";
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -50,6 +50,20 @@ import { findSubsetViolation } from "./subset";
|
|
|
50
50
|
* module boundary, or not at all (subset.ts's module doc, point 1) — a
|
|
51
51
|
* documented, intentional asymmetry, not something this guard can
|
|
52
52
|
* usefully narrow further without a binding resolver of its own.
|
|
53
|
+
*
|
|
54
|
+
* One doc claim below is checked against `fold()` instead of
|
|
55
|
+
* `findSubsetViolation` — chant #2306. "Unregistered tagged template
|
|
56
|
+
* intrinsics" is a claim `findSubsetViolation` structurally cannot decide:
|
|
57
|
+
* subset.ts treats every tagged template's interior as shape-valid
|
|
58
|
+
* regardless of tag registration (its own module doc explains why — EVL has
|
|
59
|
+
* no intrinsic registry at lint time), so registered vs. unregistered is
|
|
60
|
+
* indistinguishable at that layer no matter which heading the claim sits
|
|
61
|
+
* under. The registry check lives in `fold()` (`foldTaggedTemplate`), so
|
|
62
|
+
* that is what this one test calls instead. This is also why the doc bullet
|
|
63
|
+
* survived as long as it did in unfenced prose (chant #2306's own issue
|
|
64
|
+
* comment): moving it under a `###` heading with a fenced block, the shape
|
|
65
|
+
* every other case here uses, was necessary but not sufficient — the
|
|
66
|
+
* fixture still has to be run through the right function.
|
|
53
67
|
*/
|
|
54
68
|
|
|
55
69
|
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
|
@@ -208,3 +222,23 @@ describe("subset-doc-parity — unsupported patterns in typescript-as-data.mdx c
|
|
|
208
222
|
expect(findSubsetViolation(resourceArg(consts, "store"))).toBeDefined();
|
|
209
223
|
});
|
|
210
224
|
});
|
|
225
|
+
|
|
226
|
+
describe("subset-doc-parity — fold()-decided claim in typescript-as-data.mdx (#2306)", () => {
|
|
227
|
+
test("Unregistered tagged template intrinsics", () => {
|
|
228
|
+
// findSubsetViolation cannot decide this one — see this file's module
|
|
229
|
+
// doc. Fold the doc's own example with an empty intrinsics list (no tag
|
|
230
|
+
// registered at all, the same as no lexicon opting `unknownTag` in) and
|
|
231
|
+
// check the real rejection, `foldTaggedTemplate` in ./fold.ts, fires.
|
|
232
|
+
const consts = parseConsts(extractFencedBlock("Unregistered tagged template intrinsics"));
|
|
233
|
+
const arg = resourceArg(consts, "store");
|
|
234
|
+
let error: unknown;
|
|
235
|
+
try {
|
|
236
|
+
fold(arg, consts, []);
|
|
237
|
+
} catch (e) {
|
|
238
|
+
error = e;
|
|
239
|
+
}
|
|
240
|
+
expect(error).toBeInstanceOf(FoldError);
|
|
241
|
+
expect((error as FoldError).message).toContain("unregistered tagged template intrinsic");
|
|
242
|
+
expect((error as FoldError).message).toContain("unknownTag");
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import * as ts from "typescript";
|
|
3
|
+
import * as chant from "../index";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The shape classifier is part of the public entry so a conformance adapter
|
|
7
|
+
* (INTENTIUS/typescript-as-data#11) and downstream tooling can ask "will this
|
|
8
|
+
* fold?" without running a fold. This pins the export and its two answers.
|
|
9
|
+
*/
|
|
10
|
+
describe("findSubsetViolation is exported from the package entry", () => {
|
|
11
|
+
const initializerOf = (src: string) => {
|
|
12
|
+
const sf = ts.createSourceFile("x.ts", src, ts.ScriptTarget.Latest, true);
|
|
13
|
+
return (sf.statements[0] as ts.VariableStatement).declarationList.declarations[0].initializer!;
|
|
14
|
+
};
|
|
15
|
+
test("is a function on the public namespace", () => {
|
|
16
|
+
expect(typeof chant.findSubsetViolation).toBe("function");
|
|
17
|
+
expect(typeof chant.checkObjectMember).toBe("function");
|
|
18
|
+
});
|
|
19
|
+
test("classifies a call as EVL001 and a literal as clean", () => {
|
|
20
|
+
const call = chant.findSubsetViolation(initializerOf("export const x = getId();"));
|
|
21
|
+
expect(call?.ruleId).toBe("EVL001");
|
|
22
|
+
expect(chant.findSubsetViolation(initializerOf('export const x = "ok";'))).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
test("classifies a dynamic element-access key as EVL003", () => {
|
|
25
|
+
expect(chant.findSubsetViolation(initializerOf("export const x = cfg[key];"))?.ruleId).toBe("EVL003");
|
|
26
|
+
});
|
|
27
|
+
});
|
package/src/fold/subset.ts
CHANGED
|
@@ -3,8 +3,32 @@ import { isFoldableHelperName } from "./foldable-helpers";
|
|
|
3
3
|
import { intrinsicCallFolds, intrinsicCallFoldsEagerly, type IntrinsicDef } from "../lexicon";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* subset — the
|
|
7
|
-
*
|
|
6
|
+
* subset — chant's implementation of the SHAPE layer of the TypeScript-as-Data
|
|
7
|
+
* specification (chant #1024, epic #1019).
|
|
8
|
+
*
|
|
9
|
+
* ## The specification is normative; this file implements it
|
|
10
|
+
*
|
|
11
|
+
* Since INTENTIUS/typescript-as-data#33 (2026-09-10) the subset is defined by
|
|
12
|
+
* the specification at https://github.com/INTENTIUS/typescript-as-data, not by
|
|
13
|
+
* this file. The rules this module implements are the `S-*` productions of
|
|
14
|
+
* `spec/grammar.md` §2 — S-Unwrap, S-Literal, S-Ident, S-Template, S-Tagged,
|
|
15
|
+
* S-Object (S-Prop / S-Shorthand / S-SpreadProp), S-Array, S-Member, S-Index,
|
|
16
|
+
* S-Unary, S-Binary, S-Conditional, S-New, the S-Call forms (S-CallHelper,
|
|
17
|
+
* S-CallIntrinsic, S-CallEager, S-CallMethod, S-CompositeStep) and S-Reject —
|
|
18
|
+
* and the direction rule `F-Direction` of `spec/divergence.md`: this classifier
|
|
19
|
+
* may accept what `fold()` rejects, never the reverse, outside the two named
|
|
20
|
+
* exceptions F-Exc-Lazy and F-Exc-Registry. The "environment-dependent
|
|
21
|
+
* exceptions" enumerated below are `spec/divergence.md`'s F-Div-* rows, kept
|
|
22
|
+
* here as implementation notes on WHY each resolution is out of this module's
|
|
23
|
+
* reach.
|
|
24
|
+
*
|
|
25
|
+
* Changing the subset goes spec-first: propose and land the rule there (with a
|
|
26
|
+
* fixture), then implement it here citing the identifier, then release. The
|
|
27
|
+
* provisional path for a change needed before the rule can be written: land it
|
|
28
|
+
* with the affected rule marked PROVISIONAL in this doc naming the spec issue;
|
|
29
|
+
* a provisional marker may survive at most one release, and the docs may not
|
|
30
|
+
* describe the change as supported until the rule exists. See
|
|
31
|
+
* `spec/README.md` "Ownership" and chant #2354.
|
|
8
32
|
*
|
|
9
33
|
* `fold()` ({@link "./fold"}, the enforcement layer — a construct outside
|
|
10
34
|
* this subset simply has no case there) and EVL001/EVL003
|
|
@@ -44,6 +68,16 @@ import { intrinsicCallFolds, intrinsicCallFoldsEagerly, type IntrinsicDef } from
|
|
|
44
68
|
* re-folding the initializer would construct a duplicate of a resource
|
|
45
69
|
* discovery has already registered. Shape cannot see the difference, and
|
|
46
70
|
* the rejection is a fall-back-to-run, never a wrong value.
|
|
71
|
+
*
|
|
72
|
+
* chant #2328 adds another, in the same direction and for the same
|
|
73
|
+
* reason: a property or element read whose OBJECT resolves to `null` or
|
|
74
|
+
* `undefined` (`cfg.nett.vpcId`, a typo in a nested path). Running it
|
|
75
|
+
* throws a `TypeError`, so `fold()` refuses rather than answering
|
|
76
|
+
* `undefined` and letting the build carry on with the property dropped.
|
|
77
|
+
* What the object resolves to is exactly the resolution this module
|
|
78
|
+
* does not do, so `cfg.nett.vpcId` stays shape-valid here — and a
|
|
79
|
+
* genuinely optional `cfg.net?.vpcId`, which folds to `undefined`
|
|
80
|
+
* because JavaScript defines it that way, is shape-valid in both.
|
|
47
81
|
* 2. Tagged-template *tag registration* — needs a lexicon's intrinsics
|
|
48
82
|
* manifest, which isn't available to a syntax-only lint rule. `fold()`
|
|
49
83
|
* alone checks it; this module treats any tag name as shape-valid and
|
package/src/index.ts
CHANGED
|
@@ -39,6 +39,11 @@ export * from "./graph-layout";
|
|
|
39
39
|
export * from "./graph-lens";
|
|
40
40
|
export * from "./detectLexicon";
|
|
41
41
|
export * from "./fold/fold";
|
|
42
|
+
// The shape classifier is the predicate downstream tooling asks "will this
|
|
43
|
+
// fold?" of without running a fold (subset.ts module doc, point 2c), and the
|
|
44
|
+
// half of the fold subset a conformance adapter needs that `fold()` alone
|
|
45
|
+
// does not expose. INTENTIUS/typescript-as-data#11.
|
|
46
|
+
export { findSubsetViolation, checkObjectMember, type SubsetViolation, type SubsetRuleId } from "./fold/subset";
|
|
42
47
|
export * from "./lint/parser";
|
|
43
48
|
export * from "./lint/rule";
|
|
44
49
|
export * from "./lint/rules";
|
|
@@ -6,7 +6,8 @@ import { join } from "node:path";
|
|
|
6
6
|
import {
|
|
7
7
|
appendGateResolution, appendPendingGate, readGateResolutions, readGateLedger,
|
|
8
8
|
latestResolutionSince, latestPendingGate, isPendingGateExpired,
|
|
9
|
-
resolveApprovalUrl, isApprovalUrl,
|
|
9
|
+
resolveApprovalUrl, isApprovalUrl, latestResolutionForPlan,
|
|
10
|
+
type PendingGateRecord, type GateResolutionRecord,
|
|
10
11
|
} from "./gate-ledger";
|
|
11
12
|
import { readBlobFromPath, writeBlobToPath } from "./git";
|
|
12
13
|
|
|
@@ -253,3 +254,134 @@ describe("lifecycle/gate-ledger — pending facts (#2119)", () => {
|
|
|
253
254
|
});
|
|
254
255
|
});
|
|
255
256
|
});
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* #2300. `latestResolutionSince` asks "is there a newer approval", which a run
|
|
260
|
+
* answers yes to however much has changed since it was written. That is the
|
|
261
|
+
* rule INTENTIUS/choudoufu#1026 measured applying a renamed resource without
|
|
262
|
+
* complaint. `latestResolutionForPlan` asks "is there an approval of *this*",
|
|
263
|
+
* with recency demoted from the criterion to the tiebreak.
|
|
264
|
+
*/
|
|
265
|
+
describe("latestResolutionForPlan (#2300)", () => {
|
|
266
|
+
const EPOCH = new Date(0).toISOString();
|
|
267
|
+
const PLAN_A = `sha256:${"a".repeat(64)}`;
|
|
268
|
+
const PLAN_B = `sha256:${"b".repeat(64)}`;
|
|
269
|
+
|
|
270
|
+
const resolution = (over: Partial<GateResolutionRecord>): GateResolutionRecord => ({
|
|
271
|
+
version: 1, op: "live-apply", gate: "approve-live-apply", resolvedBy: "alex",
|
|
272
|
+
timestamp: "2026-01-02T00:00:00.000Z", ...over,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("a resolution for this plan answers the gate", () => {
|
|
276
|
+
const found = latestResolutionForPlan([resolution({ planDigest: PLAN_A })], "approve-live-apply", EPOCH, PLAN_A);
|
|
277
|
+
expect(found.resolution?.resolvedBy).toBe("alex");
|
|
278
|
+
expect(found.mismatched).toBeUndefined();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("a resolution for another plan does not, and comes back named", () => {
|
|
282
|
+
const found = latestResolutionForPlan([resolution({ planDigest: PLAN_B })], "approve-live-apply", EPOCH, PLAN_A);
|
|
283
|
+
expect(found.resolution).toBeUndefined();
|
|
284
|
+
expect(found.mismatched?.planDigest).toBe(PLAN_B);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// The migration, and the safe reading of it: a record with no digest proves
|
|
288
|
+
// someone approved something, and nothing about what.
|
|
289
|
+
test("a resolution written before #2300 never matches a plan-bound gate", () => {
|
|
290
|
+
const found = latestResolutionForPlan([resolution({})], "approve-live-apply", EPOCH, PLAN_A);
|
|
291
|
+
expect(found.resolution).toBeUndefined();
|
|
292
|
+
expect(found.mismatched).toBeDefined();
|
|
293
|
+
expect(found.mismatched?.planDigest).toBeUndefined();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Recency is the tiebreak, not the criterion: a newer approval of the wrong
|
|
297
|
+
// plan does not shadow an older approval of the right one.
|
|
298
|
+
test("an older resolution for this plan beats a newer one for another", () => {
|
|
299
|
+
const found = latestResolutionForPlan(
|
|
300
|
+
[
|
|
301
|
+
resolution({ planDigest: PLAN_A, resolvedBy: "right", timestamp: "2026-01-02T00:00:00.000Z" }),
|
|
302
|
+
resolution({ planDigest: PLAN_B, resolvedBy: "wrong", timestamp: "2026-01-09T00:00:00.000Z" }),
|
|
303
|
+
],
|
|
304
|
+
"approve-live-apply", EPOCH, PLAN_A,
|
|
305
|
+
);
|
|
306
|
+
expect(found.resolution?.resolvedBy).toBe("right");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("among several for this plan, the newest wins", () => {
|
|
310
|
+
const found = latestResolutionForPlan(
|
|
311
|
+
[
|
|
312
|
+
resolution({ planDigest: PLAN_A, resolvedBy: "first", timestamp: "2026-01-02T00:00:00.000Z" }),
|
|
313
|
+
resolution({ planDigest: PLAN_A, resolvedBy: "second", timestamp: "2026-01-03T00:00:00.000Z" }),
|
|
314
|
+
],
|
|
315
|
+
"approve-live-apply", EPOCH, PLAN_A,
|
|
316
|
+
);
|
|
317
|
+
expect(found.resolution?.resolvedBy).toBe("second");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("the staleness rule still applies — a resolution older than the pending fact is no answer to it", () => {
|
|
321
|
+
const found = latestResolutionForPlan(
|
|
322
|
+
[resolution({ planDigest: PLAN_A, timestamp: "2026-01-01T00:00:00.000Z" })],
|
|
323
|
+
"approve-live-apply", "2026-01-05T00:00:00.000Z", PLAN_A,
|
|
324
|
+
);
|
|
325
|
+
expect(found.resolution).toBeUndefined();
|
|
326
|
+
expect(found.mismatched).toBeUndefined();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("a gate that binds no plan decides exactly as latestResolutionSince does", () => {
|
|
330
|
+
const records = [resolution({}), resolution({ resolvedBy: "newer", timestamp: "2026-01-04T00:00:00.000Z" })];
|
|
331
|
+
expect(latestResolutionForPlan(records, "approve-live-apply", EPOCH, undefined).resolution?.resolvedBy)
|
|
332
|
+
.toBe(latestResolutionSince(records, "approve-live-apply", EPOCH)?.resolvedBy);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("another gate's resolutions are not read as this one's", () => {
|
|
336
|
+
const found = latestResolutionForPlan(
|
|
337
|
+
[resolution({ gate: "approve-live-adopt", planDigest: PLAN_A })],
|
|
338
|
+
"approve-live-apply", EPOCH, PLAN_A,
|
|
339
|
+
);
|
|
340
|
+
expect(found.resolution).toBeUndefined();
|
|
341
|
+
expect(found.mismatched).toBeUndefined();
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* A `planDigest` that is present but not a string is a malformed line, not a
|
|
347
|
+
* line with a field to ignore (#2300) — ignoring it would demote a plan-bound
|
|
348
|
+
* record to a digest-less one, which is the shape a plan-bound gate refuses.
|
|
349
|
+
*/
|
|
350
|
+
describe("readGateLedger — a corrupted planDigest is malformed (#2300)", () => {
|
|
351
|
+
test("a non-string planDigest is counted, not read as an approval of nothing", async () => {
|
|
352
|
+
await withTestDir(async (dir) => {
|
|
353
|
+
await initRepo(dir);
|
|
354
|
+
await writeBlobToPath(
|
|
355
|
+
"_gates", "live-apply.jsonl",
|
|
356
|
+
JSON.stringify({
|
|
357
|
+
version: 1, op: "live-apply", gate: "g", resolvedBy: "alex",
|
|
358
|
+
timestamp: "2026-01-01T00:00:00.000Z", planDigest: { sha: "…" },
|
|
359
|
+
}),
|
|
360
|
+
"hand-written",
|
|
361
|
+
{ cwd: dir },
|
|
362
|
+
);
|
|
363
|
+
const { resolutions, malformed } = await readGateLedger("live-apply", { cwd: dir });
|
|
364
|
+
expect(malformed).toBe(1);
|
|
365
|
+
expect(resolutions).toEqual([]);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("a string planDigest round-trips onto both kinds of record", async () => {
|
|
370
|
+
await withTestDir(async (dir) => {
|
|
371
|
+
await initRepo(dir);
|
|
372
|
+
const digest = `sha256:${"a".repeat(64)}`;
|
|
373
|
+
await appendPendingGate(
|
|
374
|
+
{ op: "live-apply", gate: "g", timestamp: "2026-01-01T00:00:00.000Z", expiresAt: "2026-01-03T00:00:00.000Z", planDigest: digest },
|
|
375
|
+
{ cwd: dir },
|
|
376
|
+
);
|
|
377
|
+
await appendGateResolution(
|
|
378
|
+
{ op: "live-apply", gate: "g", resolvedBy: "alex", timestamp: "2026-01-02T00:00:00.000Z", planDigest: digest },
|
|
379
|
+
{ cwd: dir },
|
|
380
|
+
);
|
|
381
|
+
const { resolutions, pending, malformed } = await readGateLedger("live-apply", { cwd: dir });
|
|
382
|
+
expect(malformed).toBe(0);
|
|
383
|
+
expect(pending[0].planDigest).toBe(digest);
|
|
384
|
+
expect(resolutions[0].planDigest).toBe(digest);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
});
|
|
@@ -22,6 +22,13 @@
|
|
|
22
22
|
* `chant approve` locally can write one, the same trust boundary a local
|
|
23
23
|
* commit already has.
|
|
24
24
|
*
|
|
25
|
+
* Since #2300 both halves also carry a plan identity (`./plan-digest.ts`): a
|
|
26
|
+
* run records the plan it reached the gate with, `chant approve` records the
|
|
27
|
+
* plan it approves, and {@link latestResolutionForPlan} matches on that
|
|
28
|
+
* rather than on recency alone. Before it, an approval authorised the next
|
|
29
|
+
* run of an op rather than the plan its approver had read
|
|
30
|
+
* (INTENTIUS/choudoufu#1026).
|
|
31
|
+
*
|
|
25
32
|
* Since #2119 the file carries both halves of the loop. A `gate` step the
|
|
26
33
|
* local executor reaches (`../op/local-executor.ts`) appends a
|
|
27
34
|
* {@link PendingGateRecord} and ends that run with status `gated`; `chant
|
|
@@ -119,6 +126,18 @@ export interface GateResolutionRecord {
|
|
|
119
126
|
* URL — see {@link isApprovalUrl}.
|
|
120
127
|
*/
|
|
121
128
|
url?: string;
|
|
129
|
+
/**
|
|
130
|
+
* The plan this approval is for (#2300) — `computePlanDigest`'s output
|
|
131
|
+
* (`./plan-digest.ts`), normally copied off the {@link PendingGateRecord}
|
|
132
|
+
* this resolution answers.
|
|
133
|
+
*
|
|
134
|
+
* Absent on every resolution written before #2300, and on one written for a
|
|
135
|
+
* gate that binds no plan. Absent is not a wildcard: {@link
|
|
136
|
+
* latestResolutionForPlan} refuses a digest-less resolution against a
|
|
137
|
+
* plan-bound gate rather than letting it through, because the only thing
|
|
138
|
+
* such a record proves is that somebody approved *something*.
|
|
139
|
+
*/
|
|
140
|
+
planDigest?: string;
|
|
122
141
|
}
|
|
123
142
|
|
|
124
143
|
export type GateResolutionInput = Omit<GateResolutionRecord, "version" | "kind">;
|
|
@@ -154,6 +173,16 @@ export interface PendingGateRecord {
|
|
|
154
173
|
expiresAt: string;
|
|
155
174
|
/** The address approval happens at, when the run knew one — see {@link resolveApprovalUrl}. */
|
|
156
175
|
url?: string;
|
|
176
|
+
/**
|
|
177
|
+
* The plan the run reached this gate with (#2300) — `computePlanDigest`'s
|
|
178
|
+
* output (`./plan-digest.ts`). This is what `chant approve` copies onto the
|
|
179
|
+
* resolution by default, so approving the standing fact approves the plan
|
|
180
|
+
* the approver was shown rather than the next run's.
|
|
181
|
+
*
|
|
182
|
+
* Absent when the gate binds no plan (a component gate, an authored `gate`
|
|
183
|
+
* step with no `plan`), which is the shape every gate had before #2300.
|
|
184
|
+
*/
|
|
185
|
+
planDigest?: string;
|
|
157
186
|
}
|
|
158
187
|
|
|
159
188
|
export type PendingGateInput = Omit<PendingGateRecord, "version" | "kind">;
|
|
@@ -262,6 +291,16 @@ export async function readGateLedger(
|
|
|
262
291
|
malformed++;
|
|
263
292
|
continue;
|
|
264
293
|
}
|
|
294
|
+
// #2300: a `planDigest` that is present but not a string is a
|
|
295
|
+
// malformed line, not a line with a field to ignore. Ignoring it would
|
|
296
|
+
// silently demote a plan-bound record to a digest-less one, which is
|
|
297
|
+
// the shape `latestResolutionForPlan` refuses — a corrupted approval
|
|
298
|
+
// must not read as an approval of anything at all.
|
|
299
|
+
const rawDigest: unknown = (parsed as { planDigest?: unknown }).planDigest;
|
|
300
|
+
if (rawDigest !== undefined && typeof rawDigest !== "string") {
|
|
301
|
+
malformed++;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
265
304
|
if (parsed.kind === "pending") {
|
|
266
305
|
if (typeof parsed.expiresAt !== "string") {
|
|
267
306
|
malformed++;
|
|
@@ -319,3 +358,72 @@ export function latestResolutionSince(
|
|
|
319
358
|
}
|
|
320
359
|
return latest;
|
|
321
360
|
}
|
|
361
|
+
|
|
362
|
+
/** What {@link latestResolutionForPlan} found for the plan a run is holding. */
|
|
363
|
+
export interface PlanBoundResolution {
|
|
364
|
+
/** The resolution that answers this gate for this plan. Absent when nothing does. */
|
|
365
|
+
resolution?: GateResolutionRecord;
|
|
366
|
+
/**
|
|
367
|
+
* Present instead of {@link PlanBoundResolution.resolution} when a
|
|
368
|
+
* resolution stands for this gate but for a different plan — the newest
|
|
369
|
+
* such record, so a refusal can name who approved what and when. Its
|
|
370
|
+
* `planDigest` is `undefined` for a record written before #2300.
|
|
371
|
+
*/
|
|
372
|
+
mismatched?: GateResolutionRecord;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The resolution that answers `gate` for the plan `planDigest` identifies
|
|
377
|
+
* (#2300).
|
|
378
|
+
*
|
|
379
|
+
* The criterion is the digest; recency is only the tiebreak between several
|
|
380
|
+
* resolutions that all match it. That inversion is the whole point of the
|
|
381
|
+
* issue: {@link latestResolutionSince} asks "is there a newer approval",
|
|
382
|
+
* which a run answers yes to no matter what has changed since, and this asks
|
|
383
|
+
* "is there an approval of *this*".
|
|
384
|
+
*
|
|
385
|
+
* - `planDigest` `undefined` — the gate binds no plan (a component gate, a
|
|
386
|
+
* `gate` step authored with no `plan`). Falls straight through to
|
|
387
|
+
* {@link latestResolutionSince}: gates that never claimed to bind a plan
|
|
388
|
+
* behave exactly as they did before #2300.
|
|
389
|
+
* - A resolution whose `planDigest` equals `planDigest` answers the gate.
|
|
390
|
+
* - A resolution for a different plan does not, and comes back as
|
|
391
|
+
* `mismatched` so the caller can name both digests.
|
|
392
|
+
* - A resolution with no `planDigest` at all — every record written before
|
|
393
|
+
* #2300 — does not either, and comes back as `mismatched` with an absent
|
|
394
|
+
* `planDigest`. This is the safe reading of the migration: such a record
|
|
395
|
+
* proves someone approved something, and nothing about what. Accepting it
|
|
396
|
+
* once would silently apply the very change this check exists to catch, on
|
|
397
|
+
* exactly the estates that have been running longest. The cost is one
|
|
398
|
+
* further `chant approve` per gate after the upgrade, which the refusal
|
|
399
|
+
* says out loud.
|
|
400
|
+
*/
|
|
401
|
+
export function latestResolutionForPlan(
|
|
402
|
+
records: GateResolutionRecord[],
|
|
403
|
+
gate: string,
|
|
404
|
+
sinceIso: string,
|
|
405
|
+
planDigest: string | undefined,
|
|
406
|
+
): PlanBoundResolution {
|
|
407
|
+
if (planDigest === undefined) {
|
|
408
|
+
const resolution = latestResolutionSince(records, gate, sinceIso);
|
|
409
|
+
return resolution ? { resolution } : {};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const since = new Date(sinceIso).getTime();
|
|
413
|
+
let matched: GateResolutionRecord | undefined;
|
|
414
|
+
let mismatched: GateResolutionRecord | undefined;
|
|
415
|
+
for (const r of records) {
|
|
416
|
+
if (r.gate !== gate) continue;
|
|
417
|
+
if (new Date(r.timestamp).getTime() < since) continue;
|
|
418
|
+
const newest = (best: GateResolutionRecord | undefined) =>
|
|
419
|
+
!best || new Date(r.timestamp).getTime() >= new Date(best.timestamp).getTime();
|
|
420
|
+
if (r.planDigest === planDigest) {
|
|
421
|
+
if (newest(matched)) matched = r;
|
|
422
|
+
} else if (newest(mismatched)) {
|
|
423
|
+
mismatched = r;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (matched) return { resolution: matched };
|
|
428
|
+
return mismatched ? { mismatched } : {};
|
|
429
|
+
}
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { computePlanDigest, isPlanDigest, describePlanDigest } from "./plan-digest";
|
|
3
|
+
|
|
4
|
+
describe("computePlanDigest", () => {
|
|
5
|
+
test("the same change set digests the same, whatever order its keys arrived in", () => {
|
|
6
|
+
const a = computePlanDigest("terraform-plan", { address: "aws_s3_bucket.a", actions: ["create"] });
|
|
7
|
+
const b = computePlanDigest("terraform-plan", { actions: ["create"], address: "aws_s3_bucket.a" });
|
|
8
|
+
expect(a).toBe(b);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("a changed address changes it", () => {
|
|
12
|
+
expect(computePlanDigest("terraform-plan", { address: "aws_s3_bucket.a" })).not.toBe(
|
|
13
|
+
computePlanDigest("terraform-plan", { address: "aws_s3_bucket.b" }),
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// The kind is hashed alongside the subject so a gate bound to a terraform
|
|
18
|
+
// plan cannot be satisfied by another kind of plan that happened to
|
|
19
|
+
// serialize identically.
|
|
20
|
+
test("two kinds of plan over identical data do not collide", () => {
|
|
21
|
+
expect(computePlanDigest("terraform-plan", { x: 1 })).not.toBe(
|
|
22
|
+
computePlanDigest("lifecycle-diff", { x: 1 }),
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("it is a sha256 digest, in the shape isPlanDigest accepts", () => {
|
|
27
|
+
const digest = computePlanDigest("terraform-plan", {});
|
|
28
|
+
expect(digest).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
29
|
+
expect(isPlanDigest(digest)).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("isPlanDigest", () => {
|
|
34
|
+
test("refuses everything a copy-paste or a path could be", () => {
|
|
35
|
+
expect(isPlanDigest("chant.tfplan")).toBe(false);
|
|
36
|
+
expect(isPlanDigest(`sha256:${"a".repeat(63)}`)).toBe(false);
|
|
37
|
+
expect(isPlanDigest("a".repeat(64))).toBe(false);
|
|
38
|
+
expect(isPlanDigest(`sha256:${"A".repeat(64)}`)).toBe(false);
|
|
39
|
+
expect(isPlanDigest(undefined)).toBe(false);
|
|
40
|
+
expect(isPlanDigest(12)).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("describePlanDigest", () => {
|
|
45
|
+
test("an absent digest reads as the pre-#2300 record it is, never as undefined", () => {
|
|
46
|
+
expect(describePlanDigest(undefined)).toBe("(none — recorded before plan-bound gates)");
|
|
47
|
+
expect(describePlanDigest("sha256:abc")).toBe("sha256:abc");
|
|
48
|
+
});
|
|
49
|
+
});
|