@intentius/chant 0.38.0 → 0.41.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/apply.d.ts +171 -0
- package/dist/apply.d.ts.map +1 -0
- package/dist/build.d.ts +21 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/run-client.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +30 -1
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +20 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/codegen/naming.d.ts +48 -1
- package/dist/codegen/naming.d.ts.map +1 -1
- package/dist/codegen/registry.d.ts +23 -0
- package/dist/codegen/registry.d.ts.map +1 -0
- package/dist/codegen/validate.d.ts +31 -0
- package/dist/codegen/validate.d.ts.map +1 -1
- package/dist/components/sandbox/driver.d.ts.map +1 -1
- package/dist/composite.d.ts +23 -4
- package/dist/composite.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +11 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/graph-declared.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +17 -3
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-refs.d.ts +24 -0
- package/dist/graph-refs.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/kubectl-context.d.ts.map +1 -1
- package/dist/lexicon-config.d.ts +61 -0
- package/dist/lexicon-config.d.ts.map +1 -0
- package/dist/lexicon.d.ts +19 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/deep-diff.d.ts +11 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -1
- package/dist/lifecycle/digest.d.ts.map +1 -1
- package/dist/lifecycle/identity.d.ts +52 -0
- package/dist/lifecycle/identity.d.ts.map +1 -0
- package/dist/lifecycle/observe.d.ts +5 -0
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/replay.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +30 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/managed-fields.d.ts +11 -0
- package/dist/managed-fields.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/apply.test.ts +169 -0
- package/src/apply.ts +249 -0
- package/src/build.ts +24 -0
- package/src/cli/commands/build.ts +10 -0
- package/src/cli/commands/check-lexicon.ts +20 -1
- package/src/cli/commands/doctor.test.ts +45 -0
- package/src/cli/commands/doctor.ts +40 -0
- package/src/cli/handlers/lifecycle.ts +10 -0
- package/src/cli/handlers/run-client.ts +3 -1
- package/src/cli/handlers/search-kind.test.ts +45 -0
- package/src/cli/handlers/search.ts +102 -4
- package/src/cli/main.ts +32 -10
- package/src/cli/param-flag-scope.test.ts +69 -0
- package/src/cli/plugins.test.ts +33 -1
- package/src/cli/plugins.ts +55 -0
- package/src/codegen/naming.test.ts +129 -0
- package/src/codegen/naming.ts +72 -1
- package/src/codegen/registry.test.ts +56 -0
- package/src/codegen/registry.ts +69 -0
- package/src/codegen/validate.test.ts +86 -0
- package/src/codegen/validate.ts +74 -0
- package/src/components/SPRAWL-VALIDATION.md +5 -5
- package/src/components/sandbox/driver.test.ts +27 -0
- package/src/components/sandbox/driver.ts +12 -0
- package/src/composite.ts +33 -4
- package/src/deep-observation.ts +11 -0
- package/src/discovery/index.ts +59 -0
- package/src/discovery/params-cjs-warning.test.ts +75 -0
- package/src/discovery/sandbox/driver.test.ts +34 -0
- package/src/discovery/sandbox/driver.ts +19 -0
- package/src/graph-declared.test.ts +86 -0
- package/src/graph-declared.ts +14 -2
- package/src/graph-ir.ts +32 -8
- package/src/graph-refs.test.ts +56 -0
- package/src/graph-refs.ts +37 -1
- package/src/index.ts +1 -0
- package/src/kubectl-context.ts +4 -1
- package/src/lexicon-config.test.ts +111 -0
- package/src/lexicon-config.ts +92 -0
- package/src/lexicon.ts +20 -0
- package/src/lifecycle/deep-diff.test.ts +48 -1
- package/src/lifecycle/deep-diff.ts +16 -0
- package/src/lifecycle/digest.test.ts +81 -0
- package/src/lifecycle/digest.ts +34 -3
- package/src/lifecycle/identity.test.ts +39 -0
- package/src/lifecycle/identity.ts +61 -0
- package/src/lifecycle/observe.test.ts +75 -1
- package/src/lifecycle/observe.ts +28 -2
- package/src/lifecycle/replay.test.ts +251 -0
- package/src/lifecycle/replay.ts +67 -19
- package/src/lifecycle/types.ts +26 -0
- package/src/managed-fields.test.ts +50 -0
- package/src/managed-fields.ts +25 -6
- package/src/meta/peer-deps.test.ts +111 -14
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
NOT_ATTEMPTED_REASONS,
|
|
4
|
+
isNotAttemptedReason,
|
|
5
|
+
isApplyResult,
|
|
6
|
+
applyResult,
|
|
7
|
+
normalizeApply,
|
|
8
|
+
notAttemptedAll,
|
|
9
|
+
applyRefKey,
|
|
10
|
+
overlappingRefs,
|
|
11
|
+
unaccountedRefs,
|
|
12
|
+
type ApplyRef,
|
|
13
|
+
} from "./apply";
|
|
14
|
+
|
|
15
|
+
const ref = (kind: string, name: string): ApplyRef => ({ kind, name });
|
|
16
|
+
|
|
17
|
+
describe("NotAttemptedReason is total (#1446)", () => {
|
|
18
|
+
test("every reason is recognised, and nothing else is", () => {
|
|
19
|
+
for (const r of NOT_ATTEMPTED_REASONS) expect(isNotAttemptedReason(r)).toBe(true);
|
|
20
|
+
expect(isNotAttemptedReason("skipped")).toBe(false);
|
|
21
|
+
expect(isNotAttemptedReason("")).toBe(false);
|
|
22
|
+
expect(isNotAttemptedReason(undefined)).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// The write-side peer of UnobservedReason. Free-form strings are what let the
|
|
26
|
+
// gcp skip say "no mapper for kind X" to stdout and nothing to the caller.
|
|
27
|
+
test("the prune-side reason exists, because it is a different fact", () => {
|
|
28
|
+
expect(NOT_ATTEMPTED_REASONS).toContain("not-prunable");
|
|
29
|
+
expect(NOT_ATTEMPTED_REASONS).toContain("unsupported-kind");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("the envelope discriminates itself (#1446)", () => {
|
|
34
|
+
test("isApplyResult recognises the versioned shape only", () => {
|
|
35
|
+
expect(isApplyResult(applyResult([]))).toBe(true);
|
|
36
|
+
expect(isApplyResult({ applied: [], pruned: [] })).toBe(false);
|
|
37
|
+
expect(isApplyResult({ apply: "v2" })).toBe(false);
|
|
38
|
+
expect(isApplyResult(null)).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("applyResult omits empty buckets rather than emitting empty arrays", () => {
|
|
42
|
+
expect(applyResult([{ ...ref("Bucket", "a"), action: "created" }])).toEqual({
|
|
43
|
+
apply: "v1",
|
|
44
|
+
applied: [{ kind: "Bucket", name: "a", action: "created" }],
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("normalizeApply (#1446)", () => {
|
|
50
|
+
test("an un-migrated applier's shape means 'everything I was handed, I attempted'", () => {
|
|
51
|
+
// The compatibility path. Nothing is invented on its behalf — an empty
|
|
52
|
+
// notAttempted is exactly the claim that shape implicitly makes.
|
|
53
|
+
const n = normalizeApply({ applied: [{ ...ref("K", "a"), action: "created" }] });
|
|
54
|
+
expect(n.notAttempted).toEqual([]);
|
|
55
|
+
expect(n.pruned).toEqual([]);
|
|
56
|
+
expect(n.applied).toHaveLength(1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("the envelope's notAttempted survives normalization", () => {
|
|
60
|
+
const n = normalizeApply(
|
|
61
|
+
applyResult([], [], [{ ...ref("SQLInstance", "db"), reason: "unsupported-kind" }]),
|
|
62
|
+
);
|
|
63
|
+
expect(n.notAttempted).toEqual([{ kind: "SQLInstance", name: "db", reason: "unsupported-kind" }]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// The read side has the same rule for the same reason: returning nothing must
|
|
67
|
+
// not be a way to claim success over work that never happened.
|
|
68
|
+
test("undefined normalizes to empty, so 'I could not' must be said explicitly", () => {
|
|
69
|
+
expect(normalizeApply(undefined)).toEqual({ applied: [], pruned: [], notAttempted: [] });
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("notAttemptedAll (#1446)", () => {
|
|
74
|
+
test("marks a whole plan with one reason, for the run-level failure", () => {
|
|
75
|
+
const out = notAttemptedAll([ref("K", "a"), ref("K", "b")], "no-credentials", "no token");
|
|
76
|
+
expect(out).toEqual([
|
|
77
|
+
{ kind: "K", name: "a", reason: "no-credentials", detail: "no token" },
|
|
78
|
+
{ kind: "K", name: "b", reason: "no-credentials", detail: "no token" },
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("omits detail when none is given", () => {
|
|
83
|
+
expect(notAttemptedAll([ref("K", "a")], "no-binding")).toEqual([
|
|
84
|
+
{ kind: "K", name: "a", reason: "no-binding" },
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("the three buckets are disjoint (#1446)", () => {
|
|
90
|
+
test("no overlap is the contract", () => {
|
|
91
|
+
expect(
|
|
92
|
+
overlappingRefs({
|
|
93
|
+
applied: [{ ...ref("K", "a"), action: "created" }],
|
|
94
|
+
pruned: [{ ...ref("K", "b"), deleted: true }],
|
|
95
|
+
notAttempted: [{ ...ref("K", "c"), reason: "unsupported-kind" }],
|
|
96
|
+
}),
|
|
97
|
+
).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// A resource cannot be both written and skipped. An applier reporting that has
|
|
101
|
+
// a bug the return shape would otherwise hide.
|
|
102
|
+
test("the same resource in two buckets is reported", () => {
|
|
103
|
+
expect(
|
|
104
|
+
overlappingRefs({
|
|
105
|
+
applied: [{ ...ref("K", "a"), action: "created" }],
|
|
106
|
+
pruned: [],
|
|
107
|
+
notAttempted: [{ ...ref("K", "a"), reason: "filtered" }],
|
|
108
|
+
}),
|
|
109
|
+
).toEqual(["K/a"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("same name, different kind, is not an overlap", () => {
|
|
113
|
+
expect(
|
|
114
|
+
overlappingRefs({
|
|
115
|
+
applied: [{ ...ref("Bucket", "x"), action: "created" }],
|
|
116
|
+
pruned: [{ ...ref("Topic", "x"), deleted: true }],
|
|
117
|
+
notAttempted: [],
|
|
118
|
+
}),
|
|
119
|
+
).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("nothing in the plan is dropped (#1446)", () => {
|
|
124
|
+
// The suite's central assertion, and the one gcp failed before #1447: the
|
|
125
|
+
// unmapped kind was in the plan, in no bucket, and the result looked complete.
|
|
126
|
+
test("a plan entry in no bucket is reported", () => {
|
|
127
|
+
const plan = [ref("Bucket", "mapped"), ref("SQLInstance", "unmapped")];
|
|
128
|
+
const dropped = unaccountedRefs(plan, {
|
|
129
|
+
applied: [{ ...ref("Bucket", "mapped"), action: "created" }],
|
|
130
|
+
pruned: [],
|
|
131
|
+
notAttempted: [],
|
|
132
|
+
});
|
|
133
|
+
expect(dropped).toEqual(["SQLInstance/unmapped"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("the same plan accounted for in notAttempted passes", () => {
|
|
137
|
+
const plan = [ref("Bucket", "mapped"), ref("SQLInstance", "unmapped")];
|
|
138
|
+
expect(
|
|
139
|
+
unaccountedRefs(plan, {
|
|
140
|
+
applied: [{ ...ref("Bucket", "mapped"), action: "created" }],
|
|
141
|
+
pruned: [],
|
|
142
|
+
notAttempted: [{ ...ref("SQLInstance", "unmapped"), reason: "unsupported-kind" }],
|
|
143
|
+
}),
|
|
144
|
+
).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("an empty plan is trivially accounted for", () => {
|
|
148
|
+
expect(unaccountedRefs([], { applied: [], pruned: [], notAttempted: [] })).toEqual([]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Prune deletes things the plan does not contain, by definition — so a pruned
|
|
152
|
+
// resource being outside the plan is correct, not a drop.
|
|
153
|
+
test("a pruned resource outside the plan is not a drop", () => {
|
|
154
|
+
expect(
|
|
155
|
+
unaccountedRefs([ref("Bucket", "keep")], {
|
|
156
|
+
applied: [{ ...ref("Bucket", "keep"), action: "unchanged" }],
|
|
157
|
+
pruned: [{ ...ref("Bucket", "orphan"), deleted: true }],
|
|
158
|
+
notAttempted: [],
|
|
159
|
+
}),
|
|
160
|
+
).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("applyRefKey", () => {
|
|
165
|
+
test("separates resources by kind and name", () => {
|
|
166
|
+
expect(applyRefKey(ref("Bucket", "x"))).toBe("Bucket/x");
|
|
167
|
+
expect(applyRefKey(ref("Topic", "x"))).not.toBe(applyRefKey(ref("Bucket", "x")));
|
|
168
|
+
});
|
|
169
|
+
});
|
package/src/apply.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The apply contract (#1446) — what a lexicon's applier is allowed to mean.
|
|
3
|
+
*
|
|
4
|
+
* The peer of `./observation.ts`, and it exists for the same reason on the other
|
|
5
|
+
* side of the lifecycle.
|
|
6
|
+
*
|
|
7
|
+
* The read path had two ways to return nothing for a declared entity and no way
|
|
8
|
+
* to tell them apart — "the provider says it is absent" versus "I never looked"
|
|
9
|
+
* — and the second was classifying as `create`. #1089 made that a tri-state and
|
|
10
|
+
* #1201 built a conformance suite so a lexicon could not pass by returning a
|
|
11
|
+
* well-shaped result that still proposed a create.
|
|
12
|
+
*
|
|
13
|
+
* The write path had the identical hole. An applier could skip a resource and
|
|
14
|
+
* report nothing:
|
|
15
|
+
*
|
|
16
|
+
* const mapper = MAPPERS[r.kind];
|
|
17
|
+
* if (!mapper) {
|
|
18
|
+
* console.log(`skip: no mapper for kind ${r.kind}`);
|
|
19
|
+
* continue; // never appears in `applied`, or anywhere
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* A caller receiving `{ applied: [...] }` could not distinguish a complete apply
|
|
23
|
+
* from one that dropped half the manifest, and `ApplyOp` had nothing to gate on.
|
|
24
|
+
* A `console.log` on stdout is not a signal in a result, the same way a warn on
|
|
25
|
+
* stderr was not a signal in a change set (#1447 was the concrete bug; #1457
|
|
26
|
+
* found the same shape in two more appliers).
|
|
27
|
+
*
|
|
28
|
+
* The contract is a tri-state, per resource in the plan:
|
|
29
|
+
*
|
|
30
|
+
* - **APPLIED** — the provider was called and converged. Carries the action
|
|
31
|
+
* (`created` / `updated` / `unchanged`) and the physical id when resolved.
|
|
32
|
+
* - **PRUNED** — owned, no longer declared, deleted.
|
|
33
|
+
* - **NOT-ATTEMPTED** — carrying a total {@link NotAttemptedReason}. Never
|
|
34
|
+
* silent, never inferred from absence.
|
|
35
|
+
*
|
|
36
|
+
* The three are **disjoint and total**: every resource the applier was given
|
|
37
|
+
* appears in exactly one. That is the assertion the conformance suite exists to
|
|
38
|
+
* make, and the one the gcp skip failed.
|
|
39
|
+
*
|
|
40
|
+
* Compatibility: an applier may keep returning its own shape. The envelope is
|
|
41
|
+
* discriminated by its literal `apply: "v1"` field, the same mechanism
|
|
42
|
+
* `observation: "v1"` uses, so an un-migrated applier normalizes to "everything
|
|
43
|
+
* I was handed, I attempted" and nothing breaks. Reporting NOT-ATTEMPTED
|
|
44
|
+
* requires the envelope — which is the point: you cannot claim the guarantee
|
|
45
|
+
* without adopting the shape that can express its absence.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Why a resource in the plan was not attempted. Total: an applier that skips a
|
|
50
|
+
* resource must pick one of these, and consumers may switch exhaustively.
|
|
51
|
+
*
|
|
52
|
+
* - `unsupported-kind` — the lexicon has no mapper/writer for this type. The
|
|
53
|
+
* resource is declared and simply was not written.
|
|
54
|
+
* - `no-credentials` — no usable credentials/authorization for the target.
|
|
55
|
+
* - `no-binding` — the environment resolves to no concrete target (no cluster
|
|
56
|
+
* context, no subscription, no resource group, no endpoint).
|
|
57
|
+
* - `dependency-failed` — an upstream resource in the same run failed, so this
|
|
58
|
+
* one was never reached. Distinct from a failure of its own.
|
|
59
|
+
* - `filtered` — reached but deliberately withheld by a caller-requested scope.
|
|
60
|
+
* - `not-prunable` — an owned orphan of a kind the applier cannot enumerate or
|
|
61
|
+
* delete. The prune-side shape: not "there was nothing to prune" but "I could
|
|
62
|
+
* not look for anything to prune here".
|
|
63
|
+
*/
|
|
64
|
+
export type NotAttemptedReason =
|
|
65
|
+
| "unsupported-kind"
|
|
66
|
+
| "no-credentials"
|
|
67
|
+
| "no-binding"
|
|
68
|
+
| "dependency-failed"
|
|
69
|
+
| "filtered"
|
|
70
|
+
| "not-prunable";
|
|
71
|
+
|
|
72
|
+
/** Every legal {@link NotAttemptedReason}, for validation and conformance checks. */
|
|
73
|
+
export const NOT_ATTEMPTED_REASONS: readonly NotAttemptedReason[] = [
|
|
74
|
+
"unsupported-kind",
|
|
75
|
+
"no-credentials",
|
|
76
|
+
"no-binding",
|
|
77
|
+
"dependency-failed",
|
|
78
|
+
"filtered",
|
|
79
|
+
"not-prunable",
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/** True when `value` is a legal {@link NotAttemptedReason}. */
|
|
83
|
+
export function isNotAttemptedReason(value: unknown): value is NotAttemptedReason {
|
|
84
|
+
return typeof value === "string" && (NOT_ATTEMPTED_REASONS as readonly string[]).includes(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What an apply did to a resource that it did reach. */
|
|
88
|
+
export type AppliedAction = "created" | "updated" | "unchanged";
|
|
89
|
+
|
|
90
|
+
/** Every legal {@link AppliedAction}. */
|
|
91
|
+
export const APPLIED_ACTIONS: readonly AppliedAction[] = ["created", "updated", "unchanged"];
|
|
92
|
+
|
|
93
|
+
/** How a resource is named across appliers whose native vocabularies differ. */
|
|
94
|
+
export interface ApplyRef {
|
|
95
|
+
/**
|
|
96
|
+
* The provider's own type name — a CNRM `kind`, an ARM `type`, a
|
|
97
|
+
* CloudFormation resource type, a Fly entity class. Kept verbatim rather than
|
|
98
|
+
* normalized into a chant vocabulary, so an applier stays true to its target.
|
|
99
|
+
*/
|
|
100
|
+
kind: string;
|
|
101
|
+
/** The resource's name within its scope. */
|
|
102
|
+
name: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** APPLIED — the provider was called and the resource converged. */
|
|
106
|
+
export interface AppliedResource extends ApplyRef {
|
|
107
|
+
action: AppliedAction;
|
|
108
|
+
/** Provider-assigned identifier, when the response carried one. */
|
|
109
|
+
physicalId?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** PRUNED — owned, no longer declared, deleted. */
|
|
113
|
+
export interface PrunedResource extends ApplyRef {
|
|
114
|
+
/**
|
|
115
|
+
* False when the delete was a no-op because the resource was already gone.
|
|
116
|
+
* Still PRUNED: the applier looked, decided, and acted.
|
|
117
|
+
*/
|
|
118
|
+
deleted: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** NOT-ATTEMPTED — no provider call was made, and why. */
|
|
122
|
+
export interface NotAttemptedResource extends ApplyRef {
|
|
123
|
+
reason: NotAttemptedReason;
|
|
124
|
+
/** Detail for the operator: the status and body, the missing binding, the unsupported kind. */
|
|
125
|
+
detail?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The apply envelope. Explicitly versioned: `apply: "v1"`, which discriminates
|
|
130
|
+
* it from an applier's own return shape.
|
|
131
|
+
*/
|
|
132
|
+
export interface ApplyResult {
|
|
133
|
+
/** Discriminant + wire version. */
|
|
134
|
+
readonly apply: "v1";
|
|
135
|
+
applied: AppliedResource[];
|
|
136
|
+
pruned?: PrunedResource[];
|
|
137
|
+
/** Omit or leave empty when everything in the plan was attempted. */
|
|
138
|
+
notAttempted?: NotAttemptedResource[];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Normalized form every consumer works with. All three arrays always present. */
|
|
142
|
+
export interface NormalizedApply {
|
|
143
|
+
applied: AppliedResource[];
|
|
144
|
+
pruned: PrunedResource[];
|
|
145
|
+
notAttempted: NotAttemptedResource[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** True when `value` is the versioned {@link ApplyResult} envelope. */
|
|
149
|
+
export function isApplyResult(value: unknown): value is ApplyResult {
|
|
150
|
+
return typeof value === "object" && value !== null && (value as { apply?: unknown }).apply === "v1";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Build an {@link ApplyResult}. Appliers use this rather than writing the
|
|
155
|
+
* discriminant by hand.
|
|
156
|
+
*/
|
|
157
|
+
export function applyResult(
|
|
158
|
+
applied: AppliedResource[],
|
|
159
|
+
pruned?: PrunedResource[],
|
|
160
|
+
notAttempted?: NotAttemptedResource[],
|
|
161
|
+
): ApplyResult {
|
|
162
|
+
return {
|
|
163
|
+
apply: "v1",
|
|
164
|
+
applied,
|
|
165
|
+
...(pruned && pruned.length > 0 ? { pruned } : {}),
|
|
166
|
+
...(notAttempted && notAttempted.length > 0 ? { notAttempted } : {}),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Normalize an applier's return value.
|
|
172
|
+
*
|
|
173
|
+
* A non-envelope value normalizes to "everything I was handed, I attempted" —
|
|
174
|
+
* an empty `notAttempted`. That is the compatibility path, and it is also
|
|
175
|
+
* exactly the claim an un-migrated applier is implicitly making, so nothing is
|
|
176
|
+
* invented on its behalf.
|
|
177
|
+
*
|
|
178
|
+
* `undefined` normalizes to three empty arrays. An applier that means "I applied
|
|
179
|
+
* nothing because I could not" must say so with {@link notAttemptedAll} rather
|
|
180
|
+
* than returning nothing, for the same reason `unobservedAll` exists on the read
|
|
181
|
+
* side.
|
|
182
|
+
*/
|
|
183
|
+
export function normalizeApply(value: ApplyResult | Partial<NormalizedApply> | undefined): NormalizedApply {
|
|
184
|
+
if (!value) return { applied: [], pruned: [], notAttempted: [] };
|
|
185
|
+
return {
|
|
186
|
+
applied: value.applied ?? [],
|
|
187
|
+
pruned: value.pruned ?? [],
|
|
188
|
+
notAttempted: value.notAttempted ?? [],
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Mark every named resource NOT-ATTEMPTED with one reason — the whole-run
|
|
194
|
+
* failure case (no credentials, no binding, the transport never came up). Core
|
|
195
|
+
* applies this when an applier throws, so a thrown apply degrades to an honest
|
|
196
|
+
* "did not attempt" per resource instead of an empty result that reads as a
|
|
197
|
+
* successful no-op.
|
|
198
|
+
*/
|
|
199
|
+
export function notAttemptedAll(
|
|
200
|
+
refs: Iterable<ApplyRef>,
|
|
201
|
+
reason: NotAttemptedReason,
|
|
202
|
+
detail?: string,
|
|
203
|
+
): NotAttemptedResource[] {
|
|
204
|
+
return [...refs].map((ref) => ({ ...ref, reason, ...(detail ? { detail } : {}) }));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A resource's identity within one apply, for disjointness checks. */
|
|
208
|
+
export function applyRefKey(ref: ApplyRef): string {
|
|
209
|
+
return `${ref.kind}/${ref.name}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Every resource that appears in more than one bucket. Empty is the contract;
|
|
214
|
+
* a non-empty result means the applier both wrote and skipped the same
|
|
215
|
+
* resource, which is not a state that can be true.
|
|
216
|
+
*
|
|
217
|
+
* Pruned is checked against the other two but not against itself: an applier
|
|
218
|
+
* may legitimately apply a resource and prune a different, same-named one only
|
|
219
|
+
* if their kinds differ, and {@link applyRefKey} already separates those.
|
|
220
|
+
*/
|
|
221
|
+
export function overlappingRefs(result: NormalizedApply): string[] {
|
|
222
|
+
const seen = new Map<string, Set<string>>();
|
|
223
|
+
const note = (bucket: string, refs: ApplyRef[]): void => {
|
|
224
|
+
for (const ref of refs) {
|
|
225
|
+
const key = applyRefKey(ref);
|
|
226
|
+
(seen.get(key) ?? seen.set(key, new Set()).get(key)!).add(bucket);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
note("applied", result.applied);
|
|
230
|
+
note("pruned", result.pruned);
|
|
231
|
+
note("notAttempted", result.notAttempted);
|
|
232
|
+
return [...seen.entries()].filter(([, buckets]) => buckets.size > 1).map(([key]) => key);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Resources in `plan` that the result accounts for in no bucket — the silent
|
|
237
|
+
* drop this contract exists to make impossible.
|
|
238
|
+
*
|
|
239
|
+
* The conformance suite's central assertion. Before #1447, gcp's applier failed
|
|
240
|
+
* exactly this: an unmapped kind was in the plan, in no bucket, and the result
|
|
241
|
+
* looked complete.
|
|
242
|
+
*/
|
|
243
|
+
export function unaccountedRefs(plan: Iterable<ApplyRef>, result: NormalizedApply): string[] {
|
|
244
|
+
const accounted = new Set<string>();
|
|
245
|
+
for (const bucket of [result.applied, result.pruned, result.notAttempted]) {
|
|
246
|
+
for (const ref of bucket) accounted.add(applyRefKey(ref));
|
|
247
|
+
}
|
|
248
|
+
return [...plan].map(applyRefKey).filter((key) => !accounted.has(key));
|
|
249
|
+
}
|
package/src/build.ts
CHANGED
|
@@ -185,6 +185,14 @@ export interface BuildOptions {
|
|
|
185
185
|
*/
|
|
186
186
|
lexicons?: readonly string[];
|
|
187
187
|
|
|
188
|
+
/**
|
|
189
|
+
* chant #1442 — lexicon name → the version of the plugin that served it.
|
|
190
|
+
* Recorded on {@link BuildResult.lexiconVersions} so a build digest can say
|
|
191
|
+
* WHAT interpreted the declarations, not only what was declared. The CLI
|
|
192
|
+
* populates this from `options.plugins`; `build()` only carries it.
|
|
193
|
+
*/
|
|
194
|
+
lexiconVersions?: Readonly<Record<string, string>>;
|
|
195
|
+
|
|
188
196
|
/**
|
|
189
197
|
* chant #1045 Phase 2 — opt-in: run-fallback files (or, when {@link fold}
|
|
190
198
|
* isn't set, every file) execute together, isolated, in one sandboxed
|
|
@@ -230,6 +238,21 @@ export interface BuildResult {
|
|
|
230
238
|
*/
|
|
231
239
|
foldDecisions: FoldDecision[];
|
|
232
240
|
|
|
241
|
+
/**
|
|
242
|
+
* chant #1442 — lexicon name → the version of the plugin that served this
|
|
243
|
+
* build, passed through verbatim from {@link BuildOptions.lexiconVersions}.
|
|
244
|
+
*
|
|
245
|
+
* The other half of what a build digest needs. `hashProps` fingerprints the
|
|
246
|
+
* declaration; this records what turned it into output. A lexicon is a
|
|
247
|
+
* generated artifact pinned to an upstream spec, so a bump can change
|
|
248
|
+
* emitted output with no source change at all — and without this the two
|
|
249
|
+
* builds are indistinguishable.
|
|
250
|
+
*
|
|
251
|
+
* Empty when the caller supplied no plugins (`build()` used as a library,
|
|
252
|
+
* and most tests).
|
|
253
|
+
*/
|
|
254
|
+
lexiconVersions: Record<string, string>;
|
|
255
|
+
|
|
233
256
|
/**
|
|
234
257
|
* This build's resolved build-time parameters (#1064) — the build
|
|
235
258
|
* provenance record for `params.<name>` values, alongside the existing
|
|
@@ -717,6 +740,7 @@ async function buildFromDiscoveryResult(
|
|
|
717
740
|
manifest,
|
|
718
741
|
sourceFileCount: discoveryResult.sourceFiles.length,
|
|
719
742
|
foldDecisions: discoveryResult.foldDecisions,
|
|
743
|
+
lexiconVersions: { ...(options?.lexiconVersions ?? {}) },
|
|
720
744
|
buildParams: options?.buildParams ?? [],
|
|
721
745
|
};
|
|
722
746
|
}
|
|
@@ -3,6 +3,7 @@ import { loadChantConfigUpward, resolveOwnershipMarker, resolveFoldEnabled, reso
|
|
|
3
3
|
import { resolveCliBuildParams } from "../build-params-cli";
|
|
4
4
|
import type { Serializer, SerializerResult } from "../../serializer";
|
|
5
5
|
import type { LexiconPlugin } from "../../lexicon";
|
|
6
|
+
import { resolveLexiconVersions } from "../plugins";
|
|
6
7
|
import { runPostSynthChecks } from "../../lint/post-synth";
|
|
7
8
|
import { applyConfiguredSeverity } from "../../lint/config";
|
|
8
9
|
import { loadPolicyChecks } from "../../lint/policy";
|
|
@@ -242,6 +243,14 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
242
243
|
// ../plugins.ts and fold-import.ts's `lexiconPackageName`.
|
|
243
244
|
const lexicons = options.plugins?.map((plugin) => plugin.name) ?? [];
|
|
244
245
|
|
|
246
|
+
// chant #1442 — which lexicon VERSION interpreted each declaration. The
|
|
247
|
+
// declaration is fingerprinted by `hashProps`; the thing that turned it into
|
|
248
|
+
// output was not recorded at all, so a lexicon bump that changed emitted
|
|
249
|
+
// output left the build digest identical. Captured here because this is the
|
|
250
|
+
// layer that knows which lexicons were loaded — `build()` is given only
|
|
251
|
+
// their names.
|
|
252
|
+
const lexiconVersions = resolveLexiconVersions(lexicons);
|
|
253
|
+
|
|
245
254
|
// Run the build
|
|
246
255
|
const result = await build(infraPath, options.serializers, undefined, {
|
|
247
256
|
ownership,
|
|
@@ -250,6 +259,7 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
250
259
|
sandbox,
|
|
251
260
|
intrinsics,
|
|
252
261
|
lexicons,
|
|
262
|
+
lexiconVersions,
|
|
253
263
|
buildParams: paramsResolution.provenance,
|
|
254
264
|
});
|
|
255
265
|
|
|
@@ -3,7 +3,7 @@ import { join, basename } from "path";
|
|
|
3
3
|
import { auditIntrinsics } from "./check-lexicon-intrinsics";
|
|
4
4
|
import { checkExamplesBuild } from "./check-lexicon-examples";
|
|
5
5
|
import { auditDocsReachability } from "./check-lexicon-docs";
|
|
6
|
-
import { auditMcpNames } from "./check-lexicon-mcp";
|
|
6
|
+
import { auditMcpNames, lexiconNameFor } from "./check-lexicon-mcp";
|
|
7
7
|
import { loadLexiconFromDir, registers, safeList } from "./check-lexicon-plugin";
|
|
8
8
|
import { RULE_CATALOG } from "../../audit/catalog";
|
|
9
9
|
|
|
@@ -448,6 +448,25 @@ export async function checkLexicon(dir: string): Promise<CheckResult> {
|
|
|
448
448
|
: "no marker channel — every verdict must be unknown",
|
|
449
449
|
});
|
|
450
450
|
|
|
451
|
+
// #1344 — a lexicon that reads its own `chant.config.ts` namespace should
|
|
452
|
+
// declare its shape, or a typo inside that namespace is accepted and silently
|
|
453
|
+
// ignored: `forgejo: { runnerLabel: … }` left the dialect on its defaults with
|
|
454
|
+
// nothing said. Source scan, because the read happens deep in a serializer
|
|
455
|
+
// rather than anywhere the plugin object can be asked.
|
|
456
|
+
const lexiconName = lexiconNameFor(dir);
|
|
457
|
+
const readsOwnNamespace = findFiles(join(dir, "src"), (n) => n.endsWith(".ts") && !n.endsWith(".test.ts"))
|
|
458
|
+
.some((file) => new RegExp(`config\\s*\\??\\.\\s*${lexiconName}\\b`).test(readOr(file)));
|
|
459
|
+
items.push({
|
|
460
|
+
name: "Declares a configSchema if it reads its own config namespace",
|
|
461
|
+
tier: 2,
|
|
462
|
+
pass: !readsOwnNamespace || plugin?.configSchema !== undefined,
|
|
463
|
+
detail: readsOwnNamespace
|
|
464
|
+
? plugin?.configSchema
|
|
465
|
+
? `config.${lexiconName} is declared and validated`
|
|
466
|
+
: `reads config.${lexiconName} but declares no schema — unknown keys there are silently ignored`
|
|
467
|
+
: "reads no config namespace of its own",
|
|
468
|
+
});
|
|
469
|
+
|
|
451
470
|
const compositeFiles = listTsFiles(join(dir, "src/composites"), ["index.ts"]);
|
|
452
471
|
items.push({
|
|
453
472
|
name: "At least 1 composite in src/composites/",
|
|
@@ -351,3 +351,48 @@ describe("doctorCommand", () => {
|
|
|
351
351
|
});
|
|
352
352
|
});
|
|
353
353
|
});
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* #1421 — the sibling of the `tsconfig-paths` check. Both are project settings
|
|
357
|
+
* that silently break runtime module resolution; this one makes build
|
|
358
|
+
* parameters read as empty in project source on the run path, so declarations
|
|
359
|
+
* conditioned on them take their default branch while chant reports success.
|
|
360
|
+
*/
|
|
361
|
+
describe("package-type-module (#1421)", () => {
|
|
362
|
+
const withPkg = async (pkg: object | undefined, assert: (check: { status: string; message?: string } | undefined) => void): Promise<void> => {
|
|
363
|
+
await withTestDir(async (testDir) => {
|
|
364
|
+
if (pkg) writeFileSync(join(testDir, "package.json"), JSON.stringify(pkg));
|
|
365
|
+
const report = await doctorCommand(testDir);
|
|
366
|
+
assert(report.checks.find((c) => c.name === "package-type-module"));
|
|
367
|
+
});
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
test('passes for "type": "module"', async () => {
|
|
371
|
+
await withPkg({ name: "p", type: "module" }, (check) => {
|
|
372
|
+
expect(check?.status).toBe("pass");
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('warns for "type": "commonjs", saying what breaks', async () => {
|
|
377
|
+
await withPkg({ name: "p", type: "commonjs" }, (check) => {
|
|
378
|
+
expect(check?.status).toBe("warn");
|
|
379
|
+
expect(check?.message).toContain('"type": "commonjs"');
|
|
380
|
+
expect(check?.message).toContain("build parameters");
|
|
381
|
+
expect(check?.message).toContain('Set "type": "module"');
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// The easier one to miss: no `type` field is CommonJS too.
|
|
386
|
+
test("warns when no type field is declared at all", async () => {
|
|
387
|
+
await withPkg({ name: "p" }, (check) => {
|
|
388
|
+
expect(check?.status).toBe("warn");
|
|
389
|
+
expect(check?.message).toContain("no `type` field");
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("says nothing when there is no package.json to judge", async () => {
|
|
394
|
+
await withPkg(undefined, (check) => {
|
|
395
|
+
expect(check).toBeUndefined();
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
});
|
|
@@ -169,6 +169,46 @@ export async function doctorCommand(path: string): Promise<DoctorReport> {
|
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
// Check 8b: package.json declares "type": "module" (#1421)
|
|
173
|
+
//
|
|
174
|
+
// Sibling of the tsconfig-paths check above, and filed for the same reason:
|
|
175
|
+
// both are project settings that silently break runtime module resolution.
|
|
176
|
+
//
|
|
177
|
+
// chant's core is ESM. When the project is CJS — `"type": "commonjs"`, or no
|
|
178
|
+
// `type` field at all — tsx loads project source through the CommonJS
|
|
179
|
+
// transform, so the project's `require` of `params.ts` and core's `import` of
|
|
180
|
+
// it produce two separate module records. `setBuildParams` mutates one object
|
|
181
|
+
// in place; project source reads the other, and sees `{}`.
|
|
182
|
+
//
|
|
183
|
+
// The result is a silent wrong answer, not a failure: chant prints
|
|
184
|
+
// `[param] tier = "prod" (cli)` and then emits the graph for the default. It
|
|
185
|
+
// hits `chant graph` (always the run path) and `chant build --no-fold`; plain
|
|
186
|
+
// `chant build` escapes only because folding substitutes parameters
|
|
187
|
+
// statically and never reads the shared object.
|
|
188
|
+
const projectPkgPath = join(projectPath, "package.json");
|
|
189
|
+
if (existsSync(projectPkgPath)) {
|
|
190
|
+
try {
|
|
191
|
+
const pkg = JSON.parse(readFileSync(projectPkgPath, "utf-8")) as { type?: string };
|
|
192
|
+
if (pkg.type === "module") {
|
|
193
|
+
checks.push({ name: "package-type-module", status: "pass" });
|
|
194
|
+
} else {
|
|
195
|
+
const found = pkg.type ? `"type": "${pkg.type}"` : "no `type` field";
|
|
196
|
+
checks.push({
|
|
197
|
+
name: "package-type-module",
|
|
198
|
+
status: "warn",
|
|
199
|
+
message:
|
|
200
|
+
`package.json has ${found} — chant is ESM, and a CommonJS project reads build ` +
|
|
201
|
+
`parameters as empty on the run path (\`chant graph\`, \`chant build --no-fold\`). ` +
|
|
202
|
+
`Declarations conditioned on \`params.<name>\` silently take their default branch. ` +
|
|
203
|
+
`Set "type": "module".`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
} catch (e) {
|
|
207
|
+
debug("project package.json parse failed:", e);
|
|
208
|
+
checks.push({ name: "package-type-module", status: "warn", message: "Could not parse package.json" });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
172
212
|
// Check 9: .mcp.json exists and has chant entry
|
|
173
213
|
const mcpPath = join(projectPath, ".mcp.json");
|
|
174
214
|
if (!existsSync(mcpPath)) {
|
|
@@ -597,6 +597,16 @@ async function runLifecycleDiffDigest(args: DigestDiffArgs): Promise<number> {
|
|
|
597
597
|
for (const name of diff.unchanged) {
|
|
598
598
|
console.log(name.padEnd(20) + "unchanged".padEnd(12) + (currentDigest.resources[name]?.type ?? ""));
|
|
599
599
|
}
|
|
600
|
+
|
|
601
|
+
// chant #1442 — a lexicon bump can change emitted output with no source
|
|
602
|
+
// change, so every resource above can read "unchanged" while the build is
|
|
603
|
+
// not the same build. Printed under the table rather than as a row: no
|
|
604
|
+
// resource's declaration moved, the thing that interpreted them did.
|
|
605
|
+
for (const change of diff.lexiconVersionChanges) {
|
|
606
|
+
const from = change.previous ?? "(absent)";
|
|
607
|
+
const to = change.current ?? "(absent)";
|
|
608
|
+
console.log(`\nlexicon ${change.lexicon}: ${from} → ${to}`);
|
|
609
|
+
}
|
|
600
610
|
}
|
|
601
611
|
|
|
602
612
|
return 0;
|
|
@@ -116,7 +116,9 @@ export function resolveProfile(
|
|
|
116
116
|
config: Record<string, unknown>,
|
|
117
117
|
profileName?: string,
|
|
118
118
|
): WorkerProfile {
|
|
119
|
-
|
|
119
|
+
// #1344 — the temporal lexicon declares this namespace and core validates it
|
|
120
|
+
// at load, so this reads a checked value rather than asserting one.
|
|
121
|
+
const temporal = (config as { temporal?: Record<string, unknown> }).temporal;
|
|
120
122
|
if (!temporal?.profiles) {
|
|
121
123
|
throw new Error(
|
|
122
124
|
'No temporal.profiles found in chant.config.ts. Add a profile to use `chant run`.',
|