@intentius/chant 0.34.0 → 0.37.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/commands/check-lexicon-docs.d.ts +42 -0
- package/dist/cli/commands/check-lexicon-docs.d.ts.map +1 -0
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.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/codegen/docs.d.ts +16 -0
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/codegen/fetch.d.ts +1 -1
- package/dist/codegen/fetch.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +0 -10
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/lifecycle/rollback.d.ts +18 -0
- package/dist/lifecycle/rollback.d.ts.map +1 -1
- package/dist/lifecycle/status.d.ts +53 -0
- package/dist/lifecycle/status.d.ts.map +1 -1
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/check-lexicon-docs.test.ts +90 -0
- package/src/cli/commands/check-lexicon-docs.ts +71 -0
- package/src/cli/commands/check-lexicon.ts +15 -0
- package/src/cli/handlers/components.ts +54 -3
- package/src/cli/handlers/graph.test.ts +61 -0
- package/src/cli/handlers/lifecycle.ts +8 -1
- package/src/cli/main.ts +2 -0
- package/src/cli/registry.ts +2 -0
- package/src/codegen/docs-sections.ts +1 -1
- package/src/codegen/docs.ts +122 -5
- package/src/codegen/fetch.test.ts +24 -5
- package/src/codegen/fetch.ts +19 -1
- package/src/codegen/publish-order.test.ts +133 -0
- package/src/deep-observation.test.ts +151 -0
- package/src/deep-observation.ts +66 -2
- package/src/lifecycle/rollback.test.ts +78 -1
- package/src/lifecycle/rollback.ts +41 -2
- package/src/lifecycle/status.test.ts +90 -5
- package/src/lifecycle/status.ts +85 -2
- package/src/yaml.test.ts +89 -0
- package/src/yaml.ts +66 -9
|
@@ -84,6 +84,54 @@ describe("normalizeDeepProperties", () => {
|
|
|
84
84
|
expect(out).toEqual({ Name: "n" });
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
+
// A container the rules emptied is not a container the source declared empty.
|
|
88
|
+
// Keeping the husk turns a suppressed default into drift-shaped noise —
|
|
89
|
+
// `SecurityGroupEgress[#{}]: <undeclared> → {}` was the case that found this.
|
|
90
|
+
test("an object whose every field was pruned is dropped, not left as {}", () => {
|
|
91
|
+
const hooks: DeepNormalizationHooks = { prune: (n) => n.key === "CidrIp" || n.key === "IpProtocol" };
|
|
92
|
+
const out = normalizeDeepProperties(
|
|
93
|
+
{ Egress: [{ CidrIp: "0.0.0.0/0", IpProtocol: "-1" }], Name: "n" },
|
|
94
|
+
{ entityType: "T", side: "live", hooks },
|
|
95
|
+
);
|
|
96
|
+
expect(out).toEqual({ Name: "n" });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("an object the source declared empty survives", () => {
|
|
100
|
+
const hooks: DeepNormalizationHooks = { prune: () => false };
|
|
101
|
+
const out = normalizeDeepProperties(
|
|
102
|
+
{ Spec: {}, Items: [], Name: "n" },
|
|
103
|
+
{ entityType: "T", side: "live", hooks },
|
|
104
|
+
);
|
|
105
|
+
expect(out).toEqual({ Spec: {}, Items: [], Name: "n" });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("a partly pruned object keeps what survived", () => {
|
|
109
|
+
const hooks: DeepNormalizationHooks = { prune: (n) => n.key === "Arn" };
|
|
110
|
+
const out = normalizeDeepProperties(
|
|
111
|
+
{ Role: { Arn: "arn:…", Path: "/" } },
|
|
112
|
+
{ entityType: "T", side: "live", hooks },
|
|
113
|
+
);
|
|
114
|
+
expect(out).toEqual({ Role: { Path: "/" } });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("emptiness propagates up as far as the pruning reaches", () => {
|
|
118
|
+
const hooks: DeepNormalizationHooks = { prune: (n) => n.key === "Gone" };
|
|
119
|
+
const out = normalizeDeepProperties(
|
|
120
|
+
{ Outer: { Inner: { Gone: 1 } }, Name: "n" },
|
|
121
|
+
{ entityType: "T", side: "live", hooks },
|
|
122
|
+
);
|
|
123
|
+
expect(out).toEqual({ Name: "n" });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("an array keeps the elements pruning did not empty", () => {
|
|
127
|
+
const hooks: DeepNormalizationHooks = { prune: (n) => n.key === "Default" };
|
|
128
|
+
const out = normalizeDeepProperties(
|
|
129
|
+
{ Rules: [{ Default: true }, { Port: 443 }] },
|
|
130
|
+
{ entityType: "T", side: "live", hooks },
|
|
131
|
+
);
|
|
132
|
+
expect(out).toEqual({ Rules: [{ Port: 443 }] });
|
|
133
|
+
});
|
|
134
|
+
|
|
87
135
|
test("hooks see an index-erased pattern alongside the exact path", () => {
|
|
88
136
|
const seen: Array<[string, string]> = [];
|
|
89
137
|
const hooks: DeepNormalizationHooks = {
|
|
@@ -232,3 +280,106 @@ describe("deepValueEqual", () => {
|
|
|
232
280
|
expect(deepValueEqual(1, 1)).toBe(true);
|
|
233
281
|
});
|
|
234
282
|
});
|
|
283
|
+
|
|
284
|
+
// #1314 — a nested property authored through a lexicon's generated constructor
|
|
285
|
+
// is authored data, not an opaque class instance. Collapsing it to UNRESOLVED
|
|
286
|
+
// left the declared side empty while the live side held the real value, so
|
|
287
|
+
// every field of it reported `<undeclared>` on a clean apply.
|
|
288
|
+
describe("normalizeDeepProperties — property-kind declarables (#1314)", () => {
|
|
289
|
+
/** Shaped like a generated property constructor's instance. */
|
|
290
|
+
const propertyDeclarable = (entityType: string, props: Record<string, unknown>) => ({
|
|
291
|
+
entityType,
|
|
292
|
+
kind: "property",
|
|
293
|
+
props,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("unwraps a property-kind declarable to its authored props", () => {
|
|
297
|
+
const out = normalizeDeepProperties(
|
|
298
|
+
{
|
|
299
|
+
GroupDescription: "sg",
|
|
300
|
+
SecurityGroupIngress: [
|
|
301
|
+
propertyDeclarable("AWS::EC2::SecurityGroup.Ingress", {
|
|
302
|
+
IpProtocol: "tcp",
|
|
303
|
+
FromPort: 443,
|
|
304
|
+
ToPort: 443,
|
|
305
|
+
CidrIp: "10.42.0.0/16",
|
|
306
|
+
}),
|
|
307
|
+
],
|
|
308
|
+
},
|
|
309
|
+
{ entityType: "AWS::EC2::SecurityGroup", side: "declared" },
|
|
310
|
+
);
|
|
311
|
+
expect(out).toEqual({
|
|
312
|
+
GroupDescription: "sg",
|
|
313
|
+
SecurityGroupIngress: [{ CidrIp: "10.42.0.0/16", FromPort: 443, IpProtocol: "tcp", ToPort: 443 }],
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("produces the same tree as the equivalent plain object — the two authoring forms must not differ", () => {
|
|
318
|
+
const viaConstructor = normalizeDeepProperties(
|
|
319
|
+
{ Ingress: [propertyDeclarable("T.Ingress", { IpProtocol: "tcp", FromPort: 443 })] },
|
|
320
|
+
{ entityType: "T", side: "declared" },
|
|
321
|
+
);
|
|
322
|
+
const viaLiteral = normalizeDeepProperties(
|
|
323
|
+
{ Ingress: [{ IpProtocol: "tcp", FromPort: 443 }] },
|
|
324
|
+
{ entityType: "T", side: "declared" },
|
|
325
|
+
);
|
|
326
|
+
expect(viaConstructor).toEqual(viaLiteral);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("unwraps nested property declarables all the way down", () => {
|
|
330
|
+
const out = normalizeDeepProperties(
|
|
331
|
+
{
|
|
332
|
+
Logging: propertyDeclarable("T.Logging", {
|
|
333
|
+
CloudWatch: propertyDeclarable("T.CloudWatch", { Enabled: true, LogGroup: "g" }),
|
|
334
|
+
}),
|
|
335
|
+
},
|
|
336
|
+
{ entityType: "T", side: "declared" },
|
|
337
|
+
);
|
|
338
|
+
expect(out).toEqual({ Logging: { CloudWatch: { Enabled: true, LogGroup: "g" } } });
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("still collapses a RESOURCE-kind declarable — it is a reference with no source-side value", () => {
|
|
342
|
+
// A class instance, as a lexicon actually constructs one: a resource-kind
|
|
343
|
+
// declarable in another resource's props is a Ref, and there is nothing on
|
|
344
|
+
// the source side to compare a live value against.
|
|
345
|
+
class VpcDeclarable {
|
|
346
|
+
readonly entityType = "AWS::EC2::VPC";
|
|
347
|
+
readonly kind = "resource";
|
|
348
|
+
readonly props = { CidrBlock: "10.0.0.0/16" };
|
|
349
|
+
}
|
|
350
|
+
const out = normalizeDeepProperties(
|
|
351
|
+
{ VpcId: new VpcDeclarable() },
|
|
352
|
+
{ entityType: "AWS::EC2::SecurityGroup", side: "declared" },
|
|
353
|
+
);
|
|
354
|
+
expect(out).toEqual({ VpcId: UNRESOLVED });
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("unwraps a property-kind declarable that is a class instance, which is how a lexicon builds one", () => {
|
|
358
|
+
class IngressDeclarable {
|
|
359
|
+
readonly entityType = "AWS::EC2::SecurityGroup.Ingress";
|
|
360
|
+
readonly kind = "property";
|
|
361
|
+
constructor(readonly props: Record<string, unknown>) {}
|
|
362
|
+
}
|
|
363
|
+
const out = normalizeDeepProperties(
|
|
364
|
+
{ Ingress: [new IngressDeclarable({ IpProtocol: "tcp", FromPort: 443 })] },
|
|
365
|
+
{ entityType: "AWS::EC2::SecurityGroup", side: "declared" },
|
|
366
|
+
);
|
|
367
|
+
expect(out).toEqual({ Ingress: [{ FromPort: 443, IpProtocol: "tcp" }] });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("still collapses a genuine class instance, which is what the branch is for", () => {
|
|
371
|
+
class Sub {
|
|
372
|
+
constructor(readonly template: string) {}
|
|
373
|
+
}
|
|
374
|
+
const out = normalizeDeepProperties({ Name: new Sub("${AWS::StackName}-x") }, { entityType: "T", side: "declared" });
|
|
375
|
+
expect(out).toEqual({ Name: UNRESOLVED });
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("masks a secret inside an unwrapped property declarable, same as in a plain object", () => {
|
|
379
|
+
const out = normalizeDeepProperties(
|
|
380
|
+
{ Auth: propertyDeclarable("T.Auth", { Username: "u", Password: "hunter2" }) },
|
|
381
|
+
{ entityType: "T", side: "declared" },
|
|
382
|
+
);
|
|
383
|
+
expect(out).toEqual({ Auth: { Password: MASKED, Username: "u" } });
|
|
384
|
+
});
|
|
385
|
+
});
|
package/src/deep-observation.ts
CHANGED
|
@@ -324,6 +324,36 @@ export function deepPathSet(tree: Record<string, unknown>): Set<string> {
|
|
|
324
324
|
* on every read; array order is canonicalized only where the lexicon says the
|
|
325
325
|
* array is a set, because list order often *is* semantic.
|
|
326
326
|
*/
|
|
327
|
+
/**
|
|
328
|
+
* A container whose every member the rules pruned.
|
|
329
|
+
*
|
|
330
|
+
* The distinction it carries is between a value that was empty in the source
|
|
331
|
+
* and one this pass emptied: `{}` a lexicon actually declared is a fact worth
|
|
332
|
+
* diffing, while `{}` left behind after both of a rule's fields were subtracted
|
|
333
|
+
* as provider defaults is a husk. Reporting the husk turns a suppressed default
|
|
334
|
+
* into `SecurityGroupEgress[#{}]: <undeclared> → {}` — noise wearing the shape
|
|
335
|
+
* of drift, which is the one thing the noise rules exist to prevent.
|
|
336
|
+
*
|
|
337
|
+
* Module-private: it never leaves this function's recursion.
|
|
338
|
+
*/
|
|
339
|
+
const EMPTIED = Symbol("emptied-by-pruning");
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* A property-kind Declarable — a nested property authored through a lexicon's
|
|
343
|
+
* generated constructor rather than as a plain object (#1314).
|
|
344
|
+
*
|
|
345
|
+
* Duck-typed on the same three facts `declarable.ts` defines it by
|
|
346
|
+
* (`entityType`, `kind === "property"`, `props`) rather than imported, keeping
|
|
347
|
+
* this module free of a dependency on the authoring types it only ever
|
|
348
|
+
* inspects. A resource-kind Declarable deliberately does not match.
|
|
349
|
+
*/
|
|
350
|
+
function isPropertyDeclarableValue(value: unknown): boolean {
|
|
351
|
+
if (typeof value !== "object" || value === null) return false;
|
|
352
|
+
const v = value as { entityType?: unknown; kind?: unknown; props?: unknown };
|
|
353
|
+
return typeof v.entityType === "string" && v.kind === "property" && typeof v.props === "object" && v.props !== null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
|
|
327
357
|
export function normalizeDeepProperties(
|
|
328
358
|
tree: Record<string, unknown>,
|
|
329
359
|
options: NormalizeDeepOptions,
|
|
@@ -351,27 +381,61 @@ export function normalizeDeepProperties(
|
|
|
351
381
|
if (isSensitiveKey(key)) return MASKED;
|
|
352
382
|
if (isJsonPrimitive(value)) return value;
|
|
353
383
|
|
|
384
|
+
// A PROPERTY-kind Declarable is authored data wearing a class, not an
|
|
385
|
+
// opaque instance (#1314). The generated property constructors —
|
|
386
|
+
// `SecurityGroup_Ingress`, `Role_Policy`, `MicrovmImage_Logging` — are the
|
|
387
|
+
// typed way to write a nested property, and the serializer inlines their
|
|
388
|
+
// `.props` (serializer-walker.ts `visitor.propertyDeclarable`). The
|
|
389
|
+
// declared tree has to do the same, or the two sides disagree about a
|
|
390
|
+
// property the author did write: without this the declared side held
|
|
391
|
+
// UNRESOLVED while the live side held the real rule, so every one of its
|
|
392
|
+
// fields reported `<undeclared> → <value>` on a clean apply, and the noise
|
|
393
|
+
// scaled with how strictly a project typed its properties.
|
|
394
|
+
//
|
|
395
|
+
// Checked ahead of the array/object branches rather than in the non-JSON
|
|
396
|
+
// fallback below, so it holds whether the declarable arrives as a class
|
|
397
|
+
// instance (what a lexicon constructs) or as an equivalent plain object.
|
|
398
|
+
//
|
|
399
|
+
// Deliberately property-kind only. A RESOURCE-kind Declarable in another
|
|
400
|
+
// resource's props is a reference, which has no source-side value to
|
|
401
|
+
// compare against a live one, so UNRESOLVED stays correct for it — and a
|
|
402
|
+
// resource-kind instance falls through to that fallback unchanged.
|
|
403
|
+
if (isPropertyDeclarableValue(value)) {
|
|
404
|
+
return normalizeValue((value as { props?: unknown }).props ?? {}, path, pattern, key);
|
|
405
|
+
}
|
|
406
|
+
|
|
354
407
|
if (Array.isArray(value)) {
|
|
355
408
|
const elements: unknown[] = [];
|
|
356
409
|
for (let i = 0; i < value.length; i++) {
|
|
357
410
|
const elPath = joinIndex(path, i);
|
|
358
411
|
const elPattern = joinPattern(pattern);
|
|
359
412
|
if (prune(elPath, elPattern, String(i), value[i])) continue;
|
|
360
|
-
|
|
413
|
+
const element = normalizeValue(value[i], elPath, elPattern, String(i));
|
|
414
|
+
if (element === EMPTIED) continue;
|
|
415
|
+
elements.push(element);
|
|
361
416
|
}
|
|
417
|
+
// An array that had elements and has none left was emptied by pruning,
|
|
418
|
+
// not declared empty. Reporting `[]` for it is reporting the husk of a
|
|
419
|
+
// value the rules just decided was noise.
|
|
420
|
+
if (elements.length === 0 && value.length > 0) return EMPTIED;
|
|
362
421
|
return orderElements(elements, path, pattern);
|
|
363
422
|
}
|
|
364
423
|
|
|
365
424
|
if (isPlainObject(value)) {
|
|
366
425
|
const out: Record<string, unknown> = {};
|
|
426
|
+
let had = 0;
|
|
367
427
|
for (const childKey of Object.keys(value).sort()) {
|
|
368
428
|
const childPath = joinPath(path, childKey);
|
|
369
429
|
const childPattern = joinPath(pattern, childKey);
|
|
370
430
|
const childValue = value[childKey];
|
|
371
431
|
if (childValue === undefined) continue;
|
|
432
|
+
had += 1;
|
|
372
433
|
if (prune(childPath, childPattern, childKey, childValue)) continue;
|
|
373
|
-
|
|
434
|
+
const child = normalizeValue(childValue, childPath, childPattern, childKey);
|
|
435
|
+
if (child === EMPTIED) continue;
|
|
436
|
+
out[childKey] = child;
|
|
374
437
|
}
|
|
438
|
+
if (Object.keys(out).length === 0 && had > 0) return EMPTIED;
|
|
375
439
|
return out;
|
|
376
440
|
}
|
|
377
441
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { rollbackBranchName, rollbackTitle, rollbackBody } from "./rollback";
|
|
2
|
+
import { rollbackBranchName, rollbackTitle, rollbackBody, rollbackToRevision } from "./rollback";
|
|
3
3
|
|
|
4
4
|
describe("rollback helpers (#873)", () => {
|
|
5
5
|
test("branch name includes env and short ref", () => {
|
|
@@ -23,3 +23,80 @@ describe("rollback helpers (#873)", () => {
|
|
|
23
23
|
expect(body).toMatch(/approval gate|Sync|apply/i);
|
|
24
24
|
});
|
|
25
25
|
});
|
|
26
|
+
|
|
27
|
+
// A dry run has to work where the PR path cannot: no remote, no `gh`, and the
|
|
28
|
+
// repository left exactly as it was found. That is what makes chant#1208's
|
|
29
|
+
// round-trip demonstrable offline instead of only asserting the noop case.
|
|
30
|
+
describe("rollbackToRevision --dry-run", () => {
|
|
31
|
+
const git = async (args: string[], cwd: string) => {
|
|
32
|
+
const { promisify } = await import("node:util");
|
|
33
|
+
const { execFile } = await import("node:child_process");
|
|
34
|
+
return promisify(execFile)("git", args, { cwd });
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** A throwaway repo with two commits and NO remote configured. */
|
|
38
|
+
async function repoWithTwoCommits(): Promise<{ dir: string; base: string }> {
|
|
39
|
+
const { mkdtempSync, mkdirSync, writeFileSync } = await import("node:fs");
|
|
40
|
+
const { tmpdir } = await import("node:os");
|
|
41
|
+
const { join } = await import("node:path");
|
|
42
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-rollback-test-"));
|
|
43
|
+
mkdirSync(join(dir, "src"));
|
|
44
|
+
await git(["init", "-q"], dir);
|
|
45
|
+
await git(["config", "user.email", "t@example.com"], dir);
|
|
46
|
+
await git(["config", "user.name", "t"], dir);
|
|
47
|
+
writeFileSync(join(dir, "src", "main.ts"), "export const a = 1;\n");
|
|
48
|
+
await git(["add", "-A"], dir);
|
|
49
|
+
await git(["commit", "-qm", "v1"], dir);
|
|
50
|
+
const base = (await git(["rev-parse", "HEAD"], dir)).stdout.trim();
|
|
51
|
+
writeFileSync(join(dir, "src", "main.ts"), "export const a = 2;\n");
|
|
52
|
+
await git(["add", "-A"], dir);
|
|
53
|
+
await git(["commit", "-qm", "v2"], dir);
|
|
54
|
+
return { dir, base };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test("returns the delta without a remote, and leaves no branch behind", async () => {
|
|
58
|
+
const { dir, base } = await repoWithTwoCommits();
|
|
59
|
+
const before = (await git(["branch", "--format=%(refname:short)"], dir)).stdout.trim();
|
|
60
|
+
|
|
61
|
+
const result = await rollbackToRevision({ ref: base, env: "local", sourceDir: "src", cwd: dir, dryRun: true });
|
|
62
|
+
|
|
63
|
+
expect(result.noop).toBe(false);
|
|
64
|
+
expect(result.prUrl).toBeUndefined();
|
|
65
|
+
expect(result.diff).toContain("-export const a = 2;");
|
|
66
|
+
expect(result.diff).toContain("+export const a = 1;");
|
|
67
|
+
|
|
68
|
+
// Nothing persisted: same branches as before, and the working tree still
|
|
69
|
+
// holds the NEW content — a dry run reports, it does not roll back.
|
|
70
|
+
const after = (await git(["branch", "--format=%(refname:short)"], dir)).stdout.trim();
|
|
71
|
+
expect(after).toBe(before);
|
|
72
|
+
const { readFileSync } = await import("node:fs");
|
|
73
|
+
const { join } = await import("node:path");
|
|
74
|
+
expect(readFileSync(join(dir, "src", "main.ts"), "utf8")).toContain("a = 2");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("removes a file added since the ref — a per-path checkout leaves it, and that read as noop (#1327)", async () => {
|
|
78
|
+
// The shape a reconcile produces: `chant import --from <env>` writes NEW
|
|
79
|
+
// files rather than editing the authored ones, so the whole difference from
|
|
80
|
+
// the pre-reconcile revision is additions. `git checkout <ref> -- <dir>`
|
|
81
|
+
// never touches those, so rollback used to report "nothing to roll back".
|
|
82
|
+
const { dir, base } = await repoWithTwoCommits();
|
|
83
|
+
const { writeFileSync } = await import("node:fs");
|
|
84
|
+
const { join } = await import("node:path");
|
|
85
|
+
writeFileSync(join(dir, "src", "generated.ts"), "export const added = true;\n");
|
|
86
|
+
await git(["add", "-A"], dir);
|
|
87
|
+
await git(["commit", "-qm", "reconciled: a new file"], dir);
|
|
88
|
+
|
|
89
|
+
const result = await rollbackToRevision({ ref: base, env: "local", sourceDir: "src", cwd: dir, dryRun: true });
|
|
90
|
+
|
|
91
|
+
expect(result.noop).toBe(false);
|
|
92
|
+
expect(result.diff).toContain("src/generated.ts");
|
|
93
|
+
expect(result.diff).toContain("deleted file");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("still reports noop when the source already matches the ref", async () => {
|
|
97
|
+
const { dir } = await repoWithTwoCommits();
|
|
98
|
+
const result = await rollbackToRevision({ ref: "HEAD", env: "local", sourceDir: "src", cwd: dir, dryRun: true });
|
|
99
|
+
expect(result.noop).toBe(true);
|
|
100
|
+
expect(result.diff).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -40,19 +40,37 @@ export interface RollbackResult {
|
|
|
40
40
|
noop: boolean;
|
|
41
41
|
branch?: string;
|
|
42
42
|
prUrl?: string;
|
|
43
|
+
/**
|
|
44
|
+
* The rollback delta as a unified diff — present only for a `dryRun`, where
|
|
45
|
+
* it is the whole point: the PR body's diff is what a reviewer would act on,
|
|
46
|
+
* so producing it without a PR is what makes the delta inspectable offline.
|
|
47
|
+
*/
|
|
48
|
+
diff?: string;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
/**
|
|
46
52
|
* Open a rollback PR restoring `sourceDir` to `ref`. Isolated worktree; the
|
|
47
53
|
* caller's branch is untouched. Throws on an unknown ref or a git/gh failure.
|
|
54
|
+
*
|
|
55
|
+
* `dryRun` computes the same delta and returns it as a diff without pushing a
|
|
56
|
+
* branch or opening a PR, leaving the repository exactly as it found it.
|
|
57
|
+
*
|
|
58
|
+
* That mode exists because the PR path needs a GitHub remote and an
|
|
59
|
+
* authenticated `gh` — reasonable for the operator flow this was built for
|
|
60
|
+
* (#873), and impossible for a hermetic acceptance run. chant#1208's CC
|
|
61
|
+
* round-trip has to demonstrate rollback offline, on an emulator, with no
|
|
62
|
+
* remote in the picture; without this it could only assert the `noop` case,
|
|
63
|
+
* which exercises none of the interesting work.
|
|
48
64
|
*/
|
|
49
65
|
export async function rollbackToRevision(opts: {
|
|
50
66
|
ref: string;
|
|
51
67
|
env: string | undefined;
|
|
52
68
|
sourceDir: string;
|
|
53
69
|
cwd: string;
|
|
70
|
+
/** Compute and return the delta; open no PR, push nothing, leave no branch. */
|
|
71
|
+
dryRun?: boolean;
|
|
54
72
|
}): Promise<RollbackResult> {
|
|
55
|
-
const { ref, env, sourceDir, cwd } = opts;
|
|
73
|
+
const { ref, env, sourceDir, cwd, dryRun } = opts;
|
|
56
74
|
const git = (args: string[], wd: string): Promise<{ stdout: string }> => execFileAsync("git", args, { cwd: wd });
|
|
57
75
|
|
|
58
76
|
const repoRoot = (await git(["rev-parse", "--show-toplevel"], cwd)).stdout.trim();
|
|
@@ -64,11 +82,28 @@ export async function rollbackToRevision(opts: {
|
|
|
64
82
|
|
|
65
83
|
await git(["worktree", "add", wt, "-b", branch, "HEAD"], repoRoot);
|
|
66
84
|
try {
|
|
67
|
-
// Restore
|
|
85
|
+
// Restore the source tree to the target revision (stages the changes).
|
|
86
|
+
//
|
|
87
|
+
// `git checkout <ref> -- <dir>` alone is a PER-PATH checkout, not a tree
|
|
88
|
+
// replacement: it restores the paths that exist at `ref` and leaves
|
|
89
|
+
// everything else untouched, so a file added since `ref` survives. That
|
|
90
|
+
// made rollback report "nothing to roll back" whenever the only difference
|
|
91
|
+
// was added files (#1327) — which is exactly what a reconcile produces,
|
|
92
|
+
// since `chant import --from <env>` writes NEW files rather than editing
|
|
93
|
+
// the authored ones. Clearing the directory first makes this a real
|
|
94
|
+
// restore, so the deletions show up in the delta.
|
|
95
|
+
await git(["rm", "-rq", "--ignore-unmatch", "--", sourceDir], wt);
|
|
68
96
|
await git(["checkout", ref, "--", sourceDir], wt);
|
|
69
97
|
const staged = (await git(["status", "--porcelain", "--", sourceDir], wt)).stdout.trim();
|
|
70
98
|
if (!staged) return { noop: true };
|
|
71
99
|
|
|
100
|
+
// The delta, before committing: `git diff` against the index shows exactly
|
|
101
|
+
// what restoring the source to `ref` changes. A dry run stops here.
|
|
102
|
+
if (dryRun) {
|
|
103
|
+
const { stdout } = await git(["diff", "--cached", "--", sourceDir], wt);
|
|
104
|
+
return { noop: false, branch, diff: stdout };
|
|
105
|
+
}
|
|
106
|
+
|
|
72
107
|
await git(["commit", "-m", rollbackTitle(env, ref)], wt);
|
|
73
108
|
await git(["push", "-u", "origin", branch], wt);
|
|
74
109
|
const { stdout } = await execFileAsync(
|
|
@@ -79,5 +114,9 @@ export async function rollbackToRevision(opts: {
|
|
|
79
114
|
return { noop: false, branch, prUrl: stdout.trim() };
|
|
80
115
|
} finally {
|
|
81
116
|
await git(["worktree", "remove", "--force", wt], repoRoot).catch(() => {});
|
|
117
|
+
// Removing the worktree leaves its branch behind. For the PR path that is
|
|
118
|
+
// correct — the branch is the PR. A dry run must leave nothing, or a repo
|
|
119
|
+
// accumulates a `chant/rollback-*` branch per inspection.
|
|
120
|
+
if (dryRun) await git(["branch", "-D", branch], repoRoot).catch(() => {});
|
|
82
121
|
}
|
|
83
122
|
}
|
|
@@ -37,8 +37,18 @@ describe("status", () => {
|
|
|
37
37
|
],
|
|
38
38
|
};
|
|
39
39
|
const evidence = liveEvidenceFromChangeSet(cs);
|
|
40
|
-
expect(evidence.get("search-service")).toEqual({
|
|
41
|
-
|
|
40
|
+
expect(evidence.get("search-service")).toEqual({
|
|
41
|
+
live: true,
|
|
42
|
+
action: "noop",
|
|
43
|
+
ownership: "owned",
|
|
44
|
+
rollup: { total: 1, present: 1, absent: 0, unobserved: 0 },
|
|
45
|
+
});
|
|
46
|
+
expect(evidence.get("orphan-thing")).toEqual({
|
|
47
|
+
live: true,
|
|
48
|
+
action: "adopt",
|
|
49
|
+
ownership: "foreign",
|
|
50
|
+
rollup: { total: 1, present: 1, absent: 0, unobserved: 0 },
|
|
51
|
+
});
|
|
42
52
|
});
|
|
43
53
|
|
|
44
54
|
// #598: a component's name need not equal the live entity/resource name
|
|
@@ -54,7 +64,15 @@ describe("status", () => {
|
|
|
54
64
|
};
|
|
55
65
|
const mapping: LiveNameMapping = new Map([["search-svc", ["search-service-v2"]]]);
|
|
56
66
|
const evidence = liveEvidenceFromChangeSet(cs, mapping);
|
|
57
|
-
|
|
67
|
+
// The merged verdict, plus the per-resource counts it collapsed
|
|
68
|
+
// (behold#98) — a consumer with no deploy object to read paints from
|
|
69
|
+
// those rather than from a CloudFormation stack.
|
|
70
|
+
expect(evidence.get("search-svc")).toEqual({
|
|
71
|
+
live: true,
|
|
72
|
+
action: "noop",
|
|
73
|
+
ownership: "owned",
|
|
74
|
+
rollup: { total: 1, present: 1, absent: 0, unobserved: 0 },
|
|
75
|
+
});
|
|
58
76
|
// The live entity's own name is no longer a separate top-level key once
|
|
59
77
|
// it's claimed by an explicit mapping's component.
|
|
60
78
|
});
|
|
@@ -68,7 +86,14 @@ describe("status", () => {
|
|
|
68
86
|
};
|
|
69
87
|
const mapping: LiveNameMapping = new Map([["some-other-component", ["renamed-thing"]]]);
|
|
70
88
|
const evidence = liveEvidenceFromChangeSet(cs, mapping);
|
|
71
|
-
|
|
89
|
+
// A rollup of one: the identity join still reports the shape, so a
|
|
90
|
+
// consumer never branches on whether a mapping was configured.
|
|
91
|
+
expect(evidence.get("search-service")).toEqual({
|
|
92
|
+
live: true,
|
|
93
|
+
action: "noop",
|
|
94
|
+
ownership: "owned",
|
|
95
|
+
rollup: { total: 1, present: 1, absent: 0, unobserved: 0 },
|
|
96
|
+
});
|
|
72
97
|
});
|
|
73
98
|
|
|
74
99
|
test("aggregates evidence across several live names owned by one component", () => {
|
|
@@ -82,7 +107,12 @@ describe("status", () => {
|
|
|
82
107
|
const mapping: LiveNameMapping = new Map([["neo4j-cluster", ["cluster-node-1", "cluster-node-2"]]]);
|
|
83
108
|
const evidence = liveEvidenceFromChangeSet(cs, mapping);
|
|
84
109
|
// Drift on any owned entity surfaces as drift for the component.
|
|
85
|
-
expect(evidence.get("neo4j-cluster")).toEqual({
|
|
110
|
+
expect(evidence.get("neo4j-cluster")).toEqual({
|
|
111
|
+
live: true,
|
|
112
|
+
action: "update",
|
|
113
|
+
ownership: "owned",
|
|
114
|
+
rollup: { total: 2, present: 2, absent: 0, unobserved: 0 },
|
|
115
|
+
});
|
|
86
116
|
});
|
|
87
117
|
|
|
88
118
|
test("a mapped component with none of its live names observed has no evidence entry", () => {
|
|
@@ -135,6 +165,39 @@ describe("status", () => {
|
|
|
135
165
|
expect(rows[0].reconciliation).toBe("drifted");
|
|
136
166
|
});
|
|
137
167
|
|
|
168
|
+
// behold#98 — floci-az and floci-gcp have no deploy object, so `stack` is
|
|
169
|
+
// absent and a renderer has nothing provider-native to colour from. The
|
|
170
|
+
// rollup is the substrate-neutral source for the same job.
|
|
171
|
+
test("surfaces a resource rollup for a component with no deploy object", () => {
|
|
172
|
+
const liveEvidence = new Map<string, LiveComponentEvidence>([
|
|
173
|
+
[
|
|
174
|
+
"search-service",
|
|
175
|
+
{ live: true, ownership: "owned", rollup: { total: 4, present: 3, absent: 0, unobserved: 1 } },
|
|
176
|
+
],
|
|
177
|
+
]);
|
|
178
|
+
const rows = reconcileStatus("prod", [record()], { liveEvidence });
|
|
179
|
+
expect(rows[0].resources).toEqual({ total: 4, present: 3, absent: 0, unobserved: 1 });
|
|
180
|
+
// No CloudFormation stack to enrich from, and the row is still paintable.
|
|
181
|
+
expect(rows[0].stack).toBeUndefined();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("a rollup and a stack coexist — the stack stays the richer enrichment where it exists", () => {
|
|
185
|
+
const liveEvidence = new Map<string, LiveComponentEvidence>([
|
|
186
|
+
[
|
|
187
|
+
"search-service",
|
|
188
|
+
{
|
|
189
|
+
live: true,
|
|
190
|
+
ownership: "owned",
|
|
191
|
+
stack: { name: "app-prod-search", status: "CREATE_COMPLETE", healthy: true },
|
|
192
|
+
rollup: { total: 2, present: 2, absent: 0, unobserved: 0 },
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
]);
|
|
196
|
+
const rows = reconcileStatus("prod", [record()], { liveEvidence });
|
|
197
|
+
expect(rows[0].resources).toEqual({ total: 2, present: 2, absent: 0, unobserved: 0 });
|
|
198
|
+
expect(rows[0].stack?.healthy).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
138
201
|
test("surfaces machine-readable live + stack status when observed (#57 hardening)", () => {
|
|
139
202
|
const liveEvidence = new Map<string, LiveComponentEvidence>([
|
|
140
203
|
["search-service", { live: true, ownership: "owned", stack: { name: "app-prod-search", status: "CREATE_COMPLETE", healthy: true } }],
|
|
@@ -395,6 +458,28 @@ describe("status", () => {
|
|
|
395
458
|
expect(merged.get("a")!.unobserved).toBeUndefined();
|
|
396
459
|
expect(merged.get("b")!.unobserved?.reason).toBe("read-failed");
|
|
397
460
|
});
|
|
461
|
+
|
|
462
|
+
test("the change-set rollup survives the stack overlay (behold#100)", () => {
|
|
463
|
+
// The merge rebuilds the evidence object field by field, so a field it
|
|
464
|
+
// does not name is dropped. `describeStackStatus` reports a stack, not
|
|
465
|
+
// per-resource counts, so the supplement never carries a rollup — and
|
|
466
|
+
// dropping the base's meant AWS, the only substrate with a stack
|
|
467
|
+
// observer, was the one substrate whose rows lost the #1300 counts.
|
|
468
|
+
const rollup = { total: 10, present: 10, absent: 0, unobserved: 0 };
|
|
469
|
+
const base = new Map<string, LiveComponentEvidence>([["cc-canonical", { live: true, ownership: "owned", rollup }]]);
|
|
470
|
+
const supplement = new Map<string, LiveComponentEvidence>([
|
|
471
|
+
["cc-canonical", { live: true, ownership: "owned", stack: { name: "cc-canonical", status: "CREATE_COMPLETE", healthy: true } }],
|
|
472
|
+
]);
|
|
473
|
+
const merged = mergeLiveEvidence(base, supplement);
|
|
474
|
+
expect(merged.get("cc-canonical")!.rollup).toEqual(rollup);
|
|
475
|
+
expect(merged.get("cc-canonical")!.stack?.status).toBe("CREATE_COMPLETE");
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("no rollup on either side leaves the field absent rather than undefined", () => {
|
|
479
|
+
const base = new Map<string, LiveComponentEvidence>([["c", { live: true }]]);
|
|
480
|
+
const supplement = new Map<string, LiveComponentEvidence>([["c", { live: true, ownership: "owned" }]]);
|
|
481
|
+
expect(mergeLiveEvidence(base, supplement).get("c")).not.toHaveProperty("rollup");
|
|
482
|
+
});
|
|
398
483
|
});
|
|
399
484
|
|
|
400
485
|
// ── The observation tri-state reaches the status join (#1089) ─────────────
|