@intentius/chant 0.38.0 → 0.39.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/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/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/registry.d.ts +23 -0
- package/dist/codegen/registry.d.ts.map +1 -0
- 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/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/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/build.ts +24 -0
- package/src/cli/commands/build.ts +10 -0
- package/src/cli/commands/check-lexicon.ts +20 -1
- 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/registry.test.ts +56 -0
- package/src/codegen/registry.ts +69 -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/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/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
package/src/lifecycle/replay.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { readEnvironmentSnapshots } from "./git";
|
|
18
|
+
import { unqualifiedKey } from "./identity";
|
|
18
19
|
import type { LiveObservation } from "../graph-ir";
|
|
19
20
|
import type { LifecycleSnapshot } from "./types";
|
|
20
21
|
|
|
@@ -72,16 +73,35 @@ export async function replaySnapshots(
|
|
|
72
73
|
// account-level: the default security group three stacks each recorded is one
|
|
73
74
|
// group, not three. Managed resources are stack-qualified below and cannot
|
|
74
75
|
// collide, so only the unqualified ones need this.
|
|
76
|
+
//
|
|
77
|
+
// "Account-level" is the part that needed qualifying (#1416): a resource that
|
|
78
|
+
// records a region is regional, and two regions' copies are two resources
|
|
79
|
+
// however equal their ids look. `unqualifiedKey` keys those by region, so the
|
|
80
|
+
// set below still collapses one region's resource seen from two stacks and no
|
|
81
|
+
// longer collapses two regions'.
|
|
75
82
|
const seenUnqualified = new Set<string>();
|
|
83
|
+
// One recorded stack is one region, so there is nothing to merge and nothing
|
|
84
|
+
// to disambiguate — its ids stay exactly what they were recorded as, which is
|
|
85
|
+
// also what `chant search --live` gives that project.
|
|
86
|
+
const merging = stored.size > 1;
|
|
76
87
|
// A stack's snapshot could only exclude what THAT stack manages, so a stack
|
|
77
88
|
// declaring no security groups reported the neighbouring stack's as ambient.
|
|
78
89
|
// The union is only knowable here, with every snapshot in hand.
|
|
79
90
|
const managedPhysicalIds = new Set<string>();
|
|
91
|
+
// Where a managed resource ends up, by physical id. A duplicate is dropped
|
|
92
|
+
// below rather than rendered twice, and anything that pointed at the
|
|
93
|
+
// duplicate has to be re-pointed at the survivor — dropping the node alone
|
|
94
|
+
// would take its edges with it, since `buildLiveGraphIr` discards an edge
|
|
95
|
+
// whose endpoints were not both observed.
|
|
96
|
+
const managedKeyOf = new Map<string, string>();
|
|
80
97
|
for (const content of stored.values()) {
|
|
81
98
|
const snap = JSON.parse(content) as LifecycleSnapshot;
|
|
82
|
-
|
|
99
|
+
const snapStack = snap.stack;
|
|
100
|
+
const snapQualifies = snapStack !== undefined && scopedStacks.has(snapStack);
|
|
101
|
+
for (const [id, meta] of Object.entries(snap.resources ?? {})) {
|
|
83
102
|
if (!meta.ambient && !meta.referencedBy?.length && meta.physicalId) {
|
|
84
103
|
managedPhysicalIds.add(meta.physicalId);
|
|
104
|
+
managedKeyOf.set(meta.physicalId, snapQualifies ? `${snapStack}::${id}` : id);
|
|
85
105
|
}
|
|
86
106
|
}
|
|
87
107
|
}
|
|
@@ -97,32 +117,60 @@ export async function replaySnapshots(
|
|
|
97
117
|
// collide, and none of them join the declared canvas, which qualifies.
|
|
98
118
|
const stack = snapshot.stack;
|
|
99
119
|
const qualify = stack !== undefined && scopedStacks.has(stack);
|
|
100
|
-
// Dependencies (#1273) are keyed by physical
|
|
101
|
-
// default VPC's route table is one
|
|
102
|
-
//
|
|
103
|
-
// edges into it.
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
120
|
+
// Dependencies (#1273) and ambient resources (#1278) are keyed by physical
|
|
121
|
+
// id and are NOT stack-qualified: the default VPC's route table is one
|
|
122
|
+
// resource however many stacks route through it, and qualifying it would
|
|
123
|
+
// split it per stack and break the edges into it.
|
|
124
|
+
const unqualifiedByNature = (meta: { referencedBy?: string[]; ambient?: boolean }): boolean =>
|
|
125
|
+
Boolean(meta.ambient) || Boolean(meta.referencedBy && meta.referencedBy.length > 0);
|
|
126
|
+
// Region qualification (#1416) applies to exactly those, and never to a
|
|
127
|
+
// managed resource: an unscoped project's declared canvas joins on bare
|
|
128
|
+
// ids, so re-keying `web` to `us-east-1::web` would unjoin it from its own
|
|
129
|
+
// declaration.
|
|
130
|
+
const keyOf = (id: string, meta: { referencedBy?: string[]; ambient?: boolean; attributes?: Record<string, unknown> }): string => {
|
|
131
|
+
if (unqualifiedByNature(meta)) return merging ? unqualifiedKey(id, meta) : id;
|
|
132
|
+
return qualify ? `${stack}::${id}` : id;
|
|
133
|
+
};
|
|
108
134
|
const resources: Record<string, (typeof snapshot.resources)[string]> = {};
|
|
135
|
+
// Duplicates dropped here, and where they went, so edges can follow.
|
|
136
|
+
const merged = new Map<string, string>();
|
|
109
137
|
for (const [id, meta] of Object.entries(snapshot.resources ?? {})) {
|
|
110
|
-
const key =
|
|
111
|
-
if (
|
|
112
|
-
// Ambient means "nothing manages this"
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
138
|
+
const key = keyOf(id, meta);
|
|
139
|
+
if (!qualify || unqualifiedByNature(meta)) {
|
|
140
|
+
// Ambient means "nothing manages this", and being referenced means "the
|
|
141
|
+
// estate reaches this" — neither stops it being managed. When a stack
|
|
142
|
+
// manages the same physical resource, this entry is that resource seen
|
|
143
|
+
// from outside, and rendering both inflates any count over it.
|
|
144
|
+
//
|
|
145
|
+
// The dependency half of that was missing, and it was not cosmetic: a
|
|
146
|
+
// subnet declared by its stack was also recorded as `subnet-9af06b90`
|
|
147
|
+
// with `referencedBy`, so an estate with 13 subnets replayed as 16 and
|
|
148
|
+
// "which subnets have no network interfaces" answered 11 where the
|
|
149
|
+
// truth was 8. The three extra were occupied subnets whose second copy
|
|
150
|
+
// had no interface pointing at it — a duplicate reads as empty, because
|
|
151
|
+
// the edges resolved to the other one.
|
|
152
|
+
const duplicate = Boolean(meta.ambient) || Boolean(meta.referencedBy && meta.referencedBy.length > 0);
|
|
153
|
+
if (duplicate && meta.physicalId && managedPhysicalIds.has(meta.physicalId)) {
|
|
154
|
+
const survivor = managedKeyOf.get(meta.physicalId);
|
|
155
|
+
if (survivor && survivor !== key) merged.set(id, survivor);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
// Unqualified: one resource per key, so first sighting wins and the
|
|
159
|
+
// rest are the same resource seen again from another stack's snapshot.
|
|
160
|
+
// The key carries the region when there is one, so "the same resource"
|
|
161
|
+
// no longer spans regions.
|
|
162
|
+
if (seenUnqualified.has(key)) continue;
|
|
163
|
+
seenUnqualified.add(key);
|
|
119
164
|
}
|
|
120
165
|
resources[key] = meta;
|
|
121
166
|
}
|
|
122
167
|
const known = new Set(Object.keys(snapshot.resources ?? {}));
|
|
123
168
|
const requalify = (id: string): string => {
|
|
169
|
+
// A dropped duplicate's edges belong to the resource that survived it.
|
|
170
|
+
const survivor = merged.get(id);
|
|
171
|
+
if (survivor) return survivor;
|
|
124
172
|
const meta = (snapshot.resources ?? {})[id];
|
|
125
|
-
return known.has(id) && meta ?
|
|
173
|
+
return known.has(id) && meta ? keyOf(id, meta) : id;
|
|
126
174
|
};
|
|
127
175
|
const edges = (snapshot.edges ?? []).map((e) => ({ ...e, from: requalify(e.from), to: requalify(e.to) }));
|
|
128
176
|
observations.push({
|
package/src/lifecycle/types.ts
CHANGED
|
@@ -97,6 +97,18 @@ export interface ResourceDigest {
|
|
|
97
97
|
export interface BuildDigest {
|
|
98
98
|
/** Per-resource digest keyed by logical name */
|
|
99
99
|
resources: Record<string, ResourceDigest>;
|
|
100
|
+
/**
|
|
101
|
+
* chant #1442 — lexicon name → version that produced this build.
|
|
102
|
+
*
|
|
103
|
+
* Recorded once per lexicon rather than on every {@link ResourceDigest},
|
|
104
|
+
* which would repeat one string across every resource of a stack for no
|
|
105
|
+
* added information.
|
|
106
|
+
*
|
|
107
|
+
* Optional because a digest read back from an older snapshot will not have
|
|
108
|
+
* it. Absent and empty are different: absent means "recorded before this
|
|
109
|
+
* existed", empty means "recorded, and no plugins were loaded".
|
|
110
|
+
*/
|
|
111
|
+
lexiconVersions?: Record<string, string>;
|
|
100
112
|
/** Resource-level dependency graph */
|
|
101
113
|
dependencies: Record<string, string[]>;
|
|
102
114
|
/** Cross-lexicon output bridges from BuildManifest */
|
|
@@ -117,4 +129,18 @@ export interface DigestDiff {
|
|
|
117
129
|
changed: string[];
|
|
118
130
|
/** Resources where propsHash matches */
|
|
119
131
|
unchanged: string[];
|
|
132
|
+
/**
|
|
133
|
+
* chant #1442 — lexicons whose VERSION moved between the two digests, even
|
|
134
|
+
* where every resource is unchanged.
|
|
135
|
+
*
|
|
136
|
+
* A build whose only difference is the lexicon that emitted it is a real
|
|
137
|
+
* difference: the lexicon is a generated artifact pinned to an upstream
|
|
138
|
+
* spec, so a bump can change output with no source change. Reported
|
|
139
|
+
* separately from `changed` because no resource's declaration moved — the
|
|
140
|
+
* interpreter did.
|
|
141
|
+
*
|
|
142
|
+
* Empty when neither digest recorded versions, so a comparison against a
|
|
143
|
+
* pre-#1442 snapshot reports nothing rather than inventing a change.
|
|
144
|
+
*/
|
|
145
|
+
lexiconVersionChanges: Array<{ lexicon: string; previous?: string; current?: string }>;
|
|
120
146
|
}
|
|
@@ -155,6 +155,11 @@ describe("pruneByOwnership — the shared three-question rule", () => {
|
|
|
155
155
|
chantOwned: new Set(["metadata.labels.tier"]),
|
|
156
156
|
foreignOwned: new Set(["spec.replicas", "metadata.annotations.noise"]),
|
|
157
157
|
foreignContested: new Set(["spec.replicas"]),
|
|
158
|
+
owners: new Map([
|
|
159
|
+
["metadata.labels.tier", "chant"],
|
|
160
|
+
["spec.replicas", "hpa-controller"],
|
|
161
|
+
["metadata.annotations.noise", "kube-controller-manager"],
|
|
162
|
+
]),
|
|
158
163
|
};
|
|
159
164
|
|
|
160
165
|
test("never prunes the declared side", () => {
|
|
@@ -177,3 +182,48 @@ describe("pruneByOwnership — the shared three-question rule", () => {
|
|
|
177
182
|
expect(pruneByOwnership(node({ path: "spec.selector", pattern: "spec.selector" }), sets)).toBe(false);
|
|
178
183
|
});
|
|
179
184
|
});
|
|
185
|
+
|
|
186
|
+
// #1189 — the three sets answer "which category owns this path". A reader needs
|
|
187
|
+
// the other question: `hpa-controller` and `kubectl-client-side-apply` are the
|
|
188
|
+
// same category and mean opposite things to an operator.
|
|
189
|
+
describe("buildOwnershipSets — owning manager per path (#1189)", () => {
|
|
190
|
+
const entries = [
|
|
191
|
+
{ manager: "chant", operation: "Apply", fieldsV1: { "f:metadata": { "f:labels": { "f:tier": {} } } } },
|
|
192
|
+
{ manager: "hpa-controller", operation: "Apply", fieldsV1: { "f:spec": { "f:replicas": {} } } },
|
|
193
|
+
{
|
|
194
|
+
manager: "kubectl-client-side-apply",
|
|
195
|
+
operation: "Update",
|
|
196
|
+
fieldsV1: { "f:spec": { "f:template": { "f:spec": { "f:containers": {} } } } },
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
test("records which manager owns each path", () => {
|
|
201
|
+
const sets = buildOwnershipSets(
|
|
202
|
+
entries,
|
|
203
|
+
{ metadata: { labels: { tier: "web" } }, spec: { replicas: 3, template: { spec: { containers: [] } } } },
|
|
204
|
+
{ metadata: { labels: { tier: "web" } }, spec: { replicas: 2 } },
|
|
205
|
+
(m) => m === "chant",
|
|
206
|
+
);
|
|
207
|
+
expect(sets.owners.get("metadata.labels.tier")).toBe("chant");
|
|
208
|
+
expect(sets.owners.get("spec.replicas")).toBe("hpa-controller");
|
|
209
|
+
expect(sets.owners.get("spec.template.spec.containers")).toBe("kubectl-client-side-apply");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("the categories are unchanged by recording owners", () => {
|
|
213
|
+
const sets = buildOwnershipSets(
|
|
214
|
+
entries,
|
|
215
|
+
{ metadata: { labels: { tier: "web" } }, spec: { replicas: 3, template: { spec: { containers: [] } } } },
|
|
216
|
+
{ metadata: { labels: { tier: "web" } }, spec: { replicas: 2 } },
|
|
217
|
+
(m) => m === "chant",
|
|
218
|
+
);
|
|
219
|
+
expect(sets.chantOwned.has("metadata.labels.tier")).toBe(true);
|
|
220
|
+
expect(sets.foreignOwned.has("spec.replicas")).toBe(true);
|
|
221
|
+
// Declared AND foreign-owned — contested, so still diffable.
|
|
222
|
+
expect(sets.foreignContested.has("spec.replicas")).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("is empty when the object carries no managedFields at all", () => {
|
|
226
|
+
const sets = buildOwnershipSets([], { spec: {} }, { spec: {} }, (m) => m === "chant");
|
|
227
|
+
expect(sets.owners.size).toBe(0);
|
|
228
|
+
});
|
|
229
|
+
});
|
package/src/managed-fields.ts
CHANGED
|
@@ -144,6 +144,17 @@ export interface OwnershipSets {
|
|
|
144
144
|
foreignOwned: ReadonlySet<string>;
|
|
145
145
|
/** The subset of `foreignOwned` where the declared manifest also sets the path — drift-relevant despite foreign ownership. */
|
|
146
146
|
foreignContested: ReadonlySet<string>;
|
|
147
|
+
/**
|
|
148
|
+
* Path → the name of the manager that owns it (#1189).
|
|
149
|
+
*
|
|
150
|
+
* The three sets above answer *which category* owns a path, which is all the
|
|
151
|
+
* prune rule needs. A reader needs the other question — "owned by
|
|
152
|
+
* `kubectl-client-side-apply`" and "owned by `hpa-controller`" are the same
|
|
153
|
+
* category and mean very different things to an operator. Last writer wins
|
|
154
|
+
* where several managers touch one path, matching what the API server itself
|
|
155
|
+
* reports.
|
|
156
|
+
*/
|
|
157
|
+
owners: ReadonlyMap<string, string>;
|
|
147
158
|
}
|
|
148
159
|
|
|
149
160
|
function sameJson(a: unknown, b: unknown): boolean {
|
|
@@ -188,6 +199,9 @@ function walkOwnership(
|
|
|
188
199
|
path: string,
|
|
189
200
|
owned: Set<string>,
|
|
190
201
|
contested: Set<string>,
|
|
202
|
+
/** Records path → manager as it walks (#1189); omitted by callers that only need the sets. */
|
|
203
|
+
owners?: Map<string, string>,
|
|
204
|
+
manager?: string,
|
|
191
205
|
): void {
|
|
192
206
|
if (fieldsNode === null || typeof fieldsNode !== "object" || Array.isArray(fieldsNode)) return;
|
|
193
207
|
|
|
@@ -196,6 +210,7 @@ function walkOwnership(
|
|
|
196
210
|
if (path !== "") {
|
|
197
211
|
owned.add(path);
|
|
198
212
|
if (declaredNode !== undefined) contested.add(path);
|
|
213
|
+
if (owners && manager) owners.set(path, manager);
|
|
199
214
|
}
|
|
200
215
|
continue;
|
|
201
216
|
}
|
|
@@ -208,7 +223,8 @@ function walkOwnership(
|
|
|
208
223
|
const childPath = joinField(path, name);
|
|
209
224
|
owned.add(childPath);
|
|
210
225
|
if (childDeclared !== undefined) contested.add(childPath);
|
|
211
|
-
|
|
226
|
+
if (owners && manager) owners.set(childPath, manager);
|
|
227
|
+
walkOwnership(child, childLive, childDeclared, childPath, owned, contested, owners, manager);
|
|
212
228
|
continue;
|
|
213
229
|
}
|
|
214
230
|
|
|
@@ -220,7 +236,8 @@ function walkOwnership(
|
|
|
220
236
|
const childPath = joinIndex(path, idx);
|
|
221
237
|
owned.add(childPath);
|
|
222
238
|
if (childDeclared !== undefined) contested.add(childPath);
|
|
223
|
-
|
|
239
|
+
if (owners && manager) owners.set(childPath, manager);
|
|
240
|
+
walkOwnership(child, childLive, childDeclared, childPath, owned, contested, owners, manager);
|
|
224
241
|
continue;
|
|
225
242
|
}
|
|
226
243
|
|
|
@@ -255,7 +272,8 @@ function walkOwnership(
|
|
|
255
272
|
const childPath = joinIndex(path, liveIdx);
|
|
256
273
|
owned.add(childPath);
|
|
257
274
|
if (childDeclared !== undefined) contested.add(childPath);
|
|
258
|
-
|
|
275
|
+
if (owners && manager) owners.set(childPath, manager);
|
|
276
|
+
walkOwnership(child, childLive, childDeclared, childPath, owned, contested, owners, manager);
|
|
259
277
|
continue;
|
|
260
278
|
}
|
|
261
279
|
// An unrecognized prefix (a future fieldsV1 encoding) — skip.
|
|
@@ -286,6 +304,7 @@ export function buildOwnershipSets(
|
|
|
286
304
|
const chantOwned = new Set<string>();
|
|
287
305
|
const foreignOwned = new Set<string>();
|
|
288
306
|
const foreignContested = new Set<string>();
|
|
307
|
+
const owners = new Map<string, string>();
|
|
289
308
|
|
|
290
309
|
for (const entry of entries) {
|
|
291
310
|
if (typeof entry.manager !== "string" || entry.manager.length === 0) continue;
|
|
@@ -294,13 +313,13 @@ export function buildOwnershipSets(
|
|
|
294
313
|
if (isChantManager(entry.manager)) {
|
|
295
314
|
// Chant-owned paths are always diffable, regardless of who else is
|
|
296
315
|
// involved — "contested" only matters for a *foreign* owner.
|
|
297
|
-
walkOwnership(entry.fieldsV1, liveRoot, declaredRoot, "", chantOwned, new Set());
|
|
316
|
+
walkOwnership(entry.fieldsV1, liveRoot, declaredRoot, "", chantOwned, new Set(), owners, entry.manager);
|
|
298
317
|
} else {
|
|
299
|
-
walkOwnership(entry.fieldsV1, liveRoot, declaredRoot, "", foreignOwned, foreignContested);
|
|
318
|
+
walkOwnership(entry.fieldsV1, liveRoot, declaredRoot, "", foreignOwned, foreignContested, owners, entry.manager);
|
|
300
319
|
}
|
|
301
320
|
}
|
|
302
321
|
|
|
303
|
-
return { chantOwned, foreignOwned, foreignContested };
|
|
322
|
+
return { chantOwned, foreignOwned, foreignContested, owners };
|
|
304
323
|
}
|
|
305
324
|
|
|
306
325
|
/**
|
|
@@ -4,26 +4,123 @@ import { fileURLToPath } from "url";
|
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* The lexicon packages
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* The lexicon packages declare `@intentius/*` peers, and a range frozen at an
|
|
8
|
+
* old version makes a clean `npm install` fail — they were once stuck at
|
|
9
|
+
* `^0.1.0` while shipping at 0.7.0 (#411). This locks the invariant so it
|
|
10
|
+
* cannot drift again.
|
|
11
|
+
*
|
|
12
|
+
* The range must track the version of the package that PROVIDES the peer, not
|
|
13
|
+
* core's version for every peer alike. Two providers exist: core backs
|
|
14
|
+
* `@intentius/chant`, and `lexicons/github` backs
|
|
15
|
+
* `@intentius/chant-lexicon-github` (forgejo and gitlab both peer on it).
|
|
16
|
+
* `just release-lexicon` writes exactly that — `^$core` for the core peer,
|
|
17
|
+
* `^$github_lexicon` for the github one — because a single-lexicon release
|
|
18
|
+
* moves one package without moving the others, so pinning every peer to core
|
|
19
|
+
* would demand a version that was never published.
|
|
20
|
+
*
|
|
21
|
+
* Asserting against core for both passed only while the whole workspace
|
|
22
|
+
* happened to sit on one version. `just release-lexicon github patch` alone is
|
|
23
|
+
* enough to separate them, and the old assertion then failed on a tree the
|
|
24
|
+
* release recipe had written correctly.
|
|
11
25
|
*/
|
|
12
26
|
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
|
13
|
-
|
|
14
|
-
|
|
27
|
+
|
|
28
|
+
function versionOf(packageDir: string): string {
|
|
29
|
+
return JSON.parse(readFileSync(join(repoRoot, packageDir, "package.json"), "utf-8")).version as string;
|
|
30
|
+
}
|
|
15
31
|
|
|
16
32
|
const lexiconsDir = join(repoRoot, "lexicons");
|
|
17
33
|
const lexicons = readdirSync(lexiconsDir).filter((name) => existsSync(join(lexiconsDir, name, "package.json")));
|
|
18
34
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
35
|
+
/** Which workspace package publishes each `@intentius/*` peer a lexicon can declare. */
|
|
36
|
+
const providerDir: Record<string, string> = {
|
|
37
|
+
"@intentius/chant": join("packages", "core"),
|
|
38
|
+
"@intentius/chant-lexicon-github": join("lexicons", "github"),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function intentiusPeersOf(lexicon: string): [string, string][] {
|
|
42
|
+
const pkg = JSON.parse(readFileSync(join(lexiconsDir, lexicon, "package.json"), "utf-8"));
|
|
43
|
+
const peers: Record<string, string> = pkg.peerDependencies ?? {};
|
|
44
|
+
// Third-party peers (typescript) aren't ours to track.
|
|
45
|
+
return Object.entries(peers).filter(([dep]) => dep.startsWith("@intentius/"));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("lexicon peerDependencies track the version of the package providing them", () => {
|
|
49
|
+
test.each(lexicons)("%s pins @intentius/* peers to their provider's version", (lexicon) => {
|
|
50
|
+
for (const [dep, range] of intentiusPeersOf(lexicon)) {
|
|
51
|
+
const dir = providerDir[dep];
|
|
52
|
+
expect(
|
|
53
|
+
dir,
|
|
54
|
+
`${lexicon} peers ${dep}, which no workspace package is registered as providing — ` +
|
|
55
|
+
`add it to providerDir so its range is checked`,
|
|
56
|
+
).toBeDefined();
|
|
57
|
+
|
|
58
|
+
expect(range, `${lexicon}: peer ${dep} should be ^${versionOf(dir)}`).toBe(`^${versionOf(dir)}`);
|
|
27
59
|
}
|
|
28
60
|
});
|
|
61
|
+
|
|
62
|
+
test("every @intentius/* peer declared anywhere has a registered provider", () => {
|
|
63
|
+
// Guards the map itself: a new cross-lexicon peer added without a
|
|
64
|
+
// providerDir entry would be reported per-lexicon above, but only if that
|
|
65
|
+
// lexicon is reached — this states the whole set in one place.
|
|
66
|
+
const declared = new Set(lexicons.flatMap((l) => intentiusPeersOf(l).map(([dep]) => dep)));
|
|
67
|
+
expect([...declared].sort()).toEqual(Object.keys(providerDir).sort());
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The rule itself, over fixtures rather than the real tree — the tree sits on
|
|
74
|
+
* one version today, so it cannot demonstrate the skew this exists to handle.
|
|
75
|
+
*/
|
|
76
|
+
function rangeViolations(
|
|
77
|
+
versions: Record<string, string>,
|
|
78
|
+
peers: Record<string, Record<string, string>>,
|
|
79
|
+
): string[] {
|
|
80
|
+
const out: string[] = [];
|
|
81
|
+
for (const [lexicon, declared] of Object.entries(peers)) {
|
|
82
|
+
for (const [dep, range] of Object.entries(declared)) {
|
|
83
|
+
if (!dep.startsWith("@intentius/")) continue;
|
|
84
|
+
if (range !== `^${versions[dep]}`) out.push(`${lexicon}:${dep}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe("the provider-aware rule, over fixtures", () => {
|
|
91
|
+
// `just release-lexicon github patch`: github moves, core does not, and the
|
|
92
|
+
// recipe rewrites forgejo's github peer to the new github version.
|
|
93
|
+
const skewed = { "@intentius/chant": "0.38.0", "@intentius/chant-lexicon-github": "0.38.1" };
|
|
94
|
+
|
|
95
|
+
test("accepts a provider ahead of core when its dependents track it", () => {
|
|
96
|
+
expect(
|
|
97
|
+
rangeViolations(skewed, {
|
|
98
|
+
forgejo: { "@intentius/chant": "^0.38.0", "@intentius/chant-lexicon-github": "^0.38.1" },
|
|
99
|
+
}),
|
|
100
|
+
).toEqual([]);
|
|
101
|
+
// This is the case the old core-for-everything assertion rejected: it
|
|
102
|
+
// demanded ^0.38.0 for the github peer, on a tree release-lexicon wrote.
|
|
103
|
+
expect(`^${skewed["@intentius/chant-lexicon-github"]}`).not.toBe(`^${skewed["@intentius/chant"]}`);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("still catches a range left behind by its provider", () => {
|
|
107
|
+
expect(
|
|
108
|
+
rangeViolations(skewed, {
|
|
109
|
+
forgejo: { "@intentius/chant": "^0.38.0", "@intentius/chant-lexicon-github": "^0.38.0" },
|
|
110
|
+
}),
|
|
111
|
+
).toEqual(["forgejo:@intentius/chant-lexicon-github"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("catches the #411 case — every range frozen at an old version", () => {
|
|
115
|
+
expect(
|
|
116
|
+
rangeViolations(skewed, {
|
|
117
|
+
aws: { "@intentius/chant": "^0.1.0" },
|
|
118
|
+
k8s: { "@intentius/chant": "^0.1.0" },
|
|
119
|
+
}),
|
|
120
|
+
).toEqual(["aws:@intentius/chant", "k8s:@intentius/chant"]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("ignores third-party peers", () => {
|
|
124
|
+
expect(rangeViolations(skewed, { aws: { typescript: ">=5.0.0" } })).toEqual([]);
|
|
125
|
+
});
|
|
29
126
|
});
|