@intentius/chant 0.51.0 → 0.52.1
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/build-options.d.ts +68 -0
- package/dist/cli/build-options.d.ts.map +1 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts +0 -21
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/lexicon.d.ts +10 -9
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/assert-live.d.ts +2 -3
- package/dist/lifecycle/assert-live.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +23 -0
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lint/policy.d.ts +16 -0
- package/dist/lint/policy.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +15 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/ownership.d.ts +5 -4
- package/dist/ownership.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/build-options.test.ts +101 -0
- package/src/cli/build-options.ts +109 -0
- package/src/cli/commands/build.ts +24 -53
- package/src/cli/handlers/lifecycle.test.ts +19 -0
- package/src/cli/handlers/lifecycle.ts +14 -7
- package/src/cli/handlers/run.test.ts +96 -0
- package/src/cli/handlers/run.ts +37 -1
- package/src/cli/main.ts +2 -0
- package/src/discovery/fold-import.test.ts +102 -0
- package/src/discovery/fold-import.ts +54 -4
- package/src/lexicon.ts +10 -9
- package/src/lifecycle/assert-live.ts +2 -3
- package/src/lifecycle/change-set.test.ts +144 -1
- package/src/lifecycle/change-set.ts +133 -11
- package/src/lint/policy-build-parity.test.ts +232 -0
- package/src/lint/policy.ts +51 -6
- package/src/op/local-executor.ts +28 -0
- package/src/ownership.ts +5 -4
package/src/lexicon.ts
CHANGED
|
@@ -1163,10 +1163,10 @@ export interface LexiconPlugin {
|
|
|
1163
1163
|
* where the wrong delete gets proposed.
|
|
1164
1164
|
*
|
|
1165
1165
|
* Declared per read path, because the answer differs by path. aws stamps tags
|
|
1166
|
-
* at synthesis and reads them on the deep observation and on
|
|
1167
|
-
* while its `describeResources` is sourced from
|
|
1168
|
-
* which returns no tags — so
|
|
1169
|
-
*
|
|
1166
|
+
* at synthesis and reads them per resource on the deep observation and on
|
|
1167
|
+
* live export, while its `describeResources` is sourced from
|
|
1168
|
+
* `describe-stack-resources`, which returns no per-resource tags — so that
|
|
1169
|
+
* path resolves the verdict from the stack's own tags (#1998).
|
|
1170
1170
|
*
|
|
1171
1171
|
* Absent means no channel anywhere: every verdict must be `unknown`.
|
|
1172
1172
|
*/
|
|
@@ -1417,11 +1417,12 @@ export interface ResourceMetadata {
|
|
|
1417
1417
|
*
|
|
1418
1418
|
* Set only when the live model actually carries the channel and the
|
|
1419
1419
|
* managed-by marker is present — an absent channel means an absent field,
|
|
1420
|
-
* never a guess.
|
|
1421
|
-
* `describe-stack-resources`, which returns no
|
|
1422
|
-
*
|
|
1423
|
-
*
|
|
1424
|
-
* {@link ownership} verdict on paths that do not
|
|
1420
|
+
* never a guess. The granularity is whatever the channel reads at: aws's thin
|
|
1421
|
+
* read is sourced from `describe-stack-resources`, which returns no
|
|
1422
|
+
* per-resource tags, so it reads the STACK's own tags and every member of a
|
|
1423
|
+
* marked stack carries that stack's identity (#1998). A populated `marker`
|
|
1424
|
+
* does not by itself imply an {@link ownership} verdict on paths that do not
|
|
1425
|
+
* declare a marker channel.
|
|
1425
1426
|
*/
|
|
1426
1427
|
marker?: OwnershipMarker;
|
|
1427
1428
|
/**
|
|
@@ -15,9 +15,8 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Marker verification is best-effort by the same logic {@link
|
|
17
17
|
* ResourceMetadata.marker}'s own contract states: a lexicon with no marker
|
|
18
|
-
* channel on this read path
|
|
19
|
-
* "
|
|
20
|
-
* "foreign". Enforcing a match whenever the channel exists — a present
|
|
18
|
+
* channel on this read path reports no marker at all, which is not the same
|
|
19
|
+
* claim as "foreign". Enforcing a match whenever the channel exists — a present
|
|
21
20
|
* mismatch, or `ownership: "foreign"` with no marker to show — catches the
|
|
22
21
|
* case the harness cares about (a same-named leftover from another env);
|
|
23
22
|
* an absent channel is passed through unverified rather than making every
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
buildChangeSet,
|
|
4
|
+
renderChangeSet,
|
|
5
|
+
renderChangeSetMarkdown,
|
|
6
|
+
summarize,
|
|
7
|
+
gitlabMrReport,
|
|
8
|
+
unobservedPlanNotice,
|
|
9
|
+
type ChangeSet,
|
|
10
|
+
type ChangeSetEntry,
|
|
11
|
+
} from "./change-set";
|
|
3
12
|
import type { ResourceMetadata } from "../lexicon";
|
|
4
13
|
|
|
5
14
|
const meta = (over: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
|
|
@@ -446,3 +455,137 @@ describe("buildChangeSet: not-observed is not absent (#1089)", () => {
|
|
|
446
455
|
expect(gitlabMrReport(cs)).toEqual({ create: 0, update: 0, delete: 0 });
|
|
447
456
|
});
|
|
448
457
|
});
|
|
458
|
+
|
|
459
|
+
// ── Markdown report (#1983) ──────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
function entry(overrides: Partial<ChangeSetEntry> = {}): ChangeSetEntry {
|
|
462
|
+
return {
|
|
463
|
+
name: "db",
|
|
464
|
+
type: "AWS::RDS::DBInstance",
|
|
465
|
+
lexicon: "aws",
|
|
466
|
+
action: "update",
|
|
467
|
+
evidence: { declared: true, inSnapshot: true, live: true, observed: true },
|
|
468
|
+
ownership: "unknown",
|
|
469
|
+
...overrides,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function set(entries: ChangeSetEntry[]): ChangeSet {
|
|
474
|
+
return { env: "prod", entries };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
describe("unobservedPlanNotice (#1983)", () => {
|
|
478
|
+
test("empty when nothing is unobserved", () => {
|
|
479
|
+
expect(unobservedPlanNotice(set([entry({ action: "noop" })]))).toEqual([]);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("one notice naming the count, matching the CLI's stderr wording", () => {
|
|
483
|
+
const cs = set([
|
|
484
|
+
entry({ name: "a", action: "unobserved", unobservedReason: "no-binding" }),
|
|
485
|
+
entry({ name: "b", action: "unobserved", unobservedReason: "read-failed" }),
|
|
486
|
+
entry({ name: "c", action: "noop" }),
|
|
487
|
+
]);
|
|
488
|
+
expect(unobservedPlanNotice(cs)).toEqual([
|
|
489
|
+
"2 declared entity(ies) could not be observed — no create/update/delete is proposed for them. This plan is incomplete, not clean.",
|
|
490
|
+
]);
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
describe("renderChangeSetMarkdown (#1983)", () => {
|
|
495
|
+
test("empty plan: header only, no ANSI, deterministic", () => {
|
|
496
|
+
const out = renderChangeSetMarkdown(set([]));
|
|
497
|
+
expect(out).toBe(
|
|
498
|
+
"## Plan for `prod`\n\n0 create, 0 update, 0 effect, 0 delete, 0 adopt, 0 runtime, 0 noop, 0 unobserved\n",
|
|
499
|
+
);
|
|
500
|
+
expect(out).not.toMatch(/\x1b\[/);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("counts header and grouped sections, attributed to lexicon", () => {
|
|
504
|
+
const cs = set([
|
|
505
|
+
entry({ name: "new-bucket", type: "S3::Bucket", lexicon: "aws", action: "create", evidence: { declared: true, inSnapshot: false, live: false, observed: true } }),
|
|
506
|
+
entry({ name: "web", type: "K8s::Apps::Deployment", lexicon: "k8s", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true } }),
|
|
507
|
+
]);
|
|
508
|
+
const out = renderChangeSetMarkdown(cs);
|
|
509
|
+
expect(out).toContain("## Plan for `prod`");
|
|
510
|
+
expect(out).toContain("1 create, 0 update, 0 effect, 0 delete, 0 adopt, 0 runtime, 1 noop, 0 unobserved");
|
|
511
|
+
expect(out).toContain("### CREATE");
|
|
512
|
+
expect(out).toContain("- `new-bucket` (S3::Bucket) `aws`");
|
|
513
|
+
expect(out).toContain("### NOOP");
|
|
514
|
+
expect(out).toContain("- `web` (K8s::Apps::Deployment) `k8s`");
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("deltas render in a fenced block, forced path marked", () => {
|
|
518
|
+
const cs = set([
|
|
519
|
+
entry({
|
|
520
|
+
deltas: [
|
|
521
|
+
{ path: "attributes.DBInstanceIdentifier", oldValue: "app-db", newValue: "app-db-2" },
|
|
522
|
+
{ path: "attributes.AllocatedStorage", oldValue: 20, newValue: 40 },
|
|
523
|
+
],
|
|
524
|
+
disruption: "destroy",
|
|
525
|
+
disruptionBecause: ["attributes.DBInstanceIdentifier"],
|
|
526
|
+
disruptionDetail: "DBInstanceIdentifier is create-only",
|
|
527
|
+
}),
|
|
528
|
+
]);
|
|
529
|
+
const out = renderChangeSetMarkdown(cs);
|
|
530
|
+
expect(out).toContain("**destroy**: DBInstanceIdentifier is create-only");
|
|
531
|
+
expect(out).toContain("```");
|
|
532
|
+
expect(out).toContain("! attributes.DBInstanceIdentifier: app-db → app-db-2");
|
|
533
|
+
expect(out).toContain("attributes.AllocatedStorage: 20 → 40");
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("disruption count rides the header, same as the human render", () => {
|
|
537
|
+
const cs = set([entry({ disruption: "in-place" }), entry({ name: "web", disruption: "destroy" })]);
|
|
538
|
+
const out = renderChangeSetMarkdown(cs);
|
|
539
|
+
expect(out).toContain("**Disruption:** 1 in-place, 1 destroy");
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("unobserved renders both the prominent notice and its own section, same wording as the human render", () => {
|
|
543
|
+
const cs = set([
|
|
544
|
+
entry({ name: "crd-widget", action: "unobserved", unobservedReason: "no-binding", unobservedDetail: "no kubectl context for prod" }),
|
|
545
|
+
]);
|
|
546
|
+
const out = renderChangeSetMarkdown(cs);
|
|
547
|
+
expect(out).toContain(
|
|
548
|
+
"> **1 declared entity(ies) could not be observed — no create/update/delete is proposed for them. This plan is incomplete, not clean.**",
|
|
549
|
+
);
|
|
550
|
+
expect(out).toContain("### UNOBSERVED (declared; chant could not read live state — no action proposed)");
|
|
551
|
+
expect(out).toContain("- `crd-widget`");
|
|
552
|
+
expect(out).toContain("no binding for this environment");
|
|
553
|
+
expect(out).toContain("no kubectl context for prod");
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("a clean plan (no holes) carries no unobserved notice at all", () => {
|
|
557
|
+
const out = renderChangeSetMarkdown(set([entry({ action: "noop" })]));
|
|
558
|
+
expect(out).not.toContain("could not be observed");
|
|
559
|
+
expect(out).not.toContain("UNOBSERVED");
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("effect entries render as their own line, no deltas", () => {
|
|
563
|
+
const cs = set([
|
|
564
|
+
entry({
|
|
565
|
+
name: "receipt-x",
|
|
566
|
+
action: "effect",
|
|
567
|
+
effect: "seed-admin",
|
|
568
|
+
effectReason: "receipt-absent",
|
|
569
|
+
effectDetail: "no receipt recorded yet",
|
|
570
|
+
deltas: undefined,
|
|
571
|
+
}),
|
|
572
|
+
]);
|
|
573
|
+
const out = renderChangeSetMarkdown(cs);
|
|
574
|
+
expect(out).toContain("- effect will fire: `seed-admin` — receipt `receipt-x`");
|
|
575
|
+
expect(out).toContain("no receipt recorded yet");
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("a group past the fold threshold collapses into <details>, a small one does not", () => {
|
|
579
|
+
const many = Array.from({ length: 25 }, (_, i) =>
|
|
580
|
+
entry({ name: `bucket-${i}`, action: "create", evidence: { declared: true, inSnapshot: false, live: false, observed: true }, deltas: undefined }),
|
|
581
|
+
);
|
|
582
|
+
const out = renderChangeSetMarkdown(set(many));
|
|
583
|
+
expect(out).toContain("<details><summary>25 entries — click to expand</summary>");
|
|
584
|
+
expect(out).toContain("</details>");
|
|
585
|
+
expect(out).toContain("- `bucket-0`");
|
|
586
|
+
expect(out).toContain("- `bucket-24`");
|
|
587
|
+
|
|
588
|
+
const few = renderChangeSetMarkdown(set(many.slice(0, 3)));
|
|
589
|
+
expect(few).not.toContain("<details>");
|
|
590
|
+
});
|
|
591
|
+
});
|
|
@@ -325,6 +325,45 @@ export function gitlabMrReport(cs: ChangeSet): GitlabMrReport {
|
|
|
325
325
|
return { create: counts.create, update: counts.update, delete: counts.delete };
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Warning a plan should print when it carries a hole — a declared entity the
|
|
330
|
+
* lexicon could not observe (#1089). The `--json` and `--report gitlab-mr`
|
|
331
|
+
* shapes have no column for `unobserved`, so both the CLI (on stderr) and the
|
|
332
|
+
* `markdown` report (in the body, since a reviewer never sees a job's stderr)
|
|
333
|
+
* read this same wording rather than drifting apart. Empty when the plan has
|
|
334
|
+
* no hole. Same discipline as {@link disruptionNotices} in `./disruption`.
|
|
335
|
+
*/
|
|
336
|
+
export function unobservedPlanNotice(cs: ChangeSet): string[] {
|
|
337
|
+
const count = summarize(cs).unobserved;
|
|
338
|
+
return count > 0
|
|
339
|
+
? [
|
|
340
|
+
`${count} declared entity(ies) could not be observed — no create/update/delete is proposed for them. This plan is incomplete, not clean.`,
|
|
341
|
+
]
|
|
342
|
+
: [];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Section heading text for one action group — shared between {@link renderChangeSet}
|
|
347
|
+
* and {@link renderChangeSetMarkdown} so the wording never drifts between the
|
|
348
|
+
* terminal render and the reviewer-facing one.
|
|
349
|
+
*/
|
|
350
|
+
function actionSectionLabel(action: ChangeAction, hasDisruption: boolean): string {
|
|
351
|
+
switch (action) {
|
|
352
|
+
case "unobserved":
|
|
353
|
+
return "UNOBSERVED (declared; chant could not read live state — no action proposed)";
|
|
354
|
+
case "runtime":
|
|
355
|
+
return "RUNTIME (owned by a declared resource; not drift, never a delete/adopt candidate)";
|
|
356
|
+
case "effect":
|
|
357
|
+
return "EFFECT (receipt absent or stale; the effect step fires — the generic apply never writes a receipt)";
|
|
358
|
+
case "update":
|
|
359
|
+
return hasDisruption
|
|
360
|
+
? "UPDATE (disruption from the lexicon that owns the spec; unknown means nobody could say, not that it is safe)"
|
|
361
|
+
: "UPDATE";
|
|
362
|
+
default:
|
|
363
|
+
return action.toUpperCase();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
328
367
|
/** Human-readable render of a change set. Pure — returns a string. */
|
|
329
368
|
export function renderChangeSet(cs: ChangeSet): string {
|
|
330
369
|
const counts = summarize(cs);
|
|
@@ -344,17 +383,7 @@ export function renderChangeSet(cs: ChangeSet): string {
|
|
|
344
383
|
for (const action of ACTION_ORDER) {
|
|
345
384
|
const group = cs.entries.filter((e) => e.action === action);
|
|
346
385
|
if (group.length === 0) continue;
|
|
347
|
-
lines.push(
|
|
348
|
-
action === "unobserved"
|
|
349
|
-
? "\nUNOBSERVED (declared; chant could not read live state — no action proposed):"
|
|
350
|
-
: action === "runtime"
|
|
351
|
-
? "\nRUNTIME (owned by a declared resource; not drift, never a delete/adopt candidate):"
|
|
352
|
-
: action === "effect"
|
|
353
|
-
? "\nEFFECT (receipt absent or stale; the effect step fires — the generic apply never writes a receipt):"
|
|
354
|
-
: action === "update" && disruptionParts.length > 0
|
|
355
|
-
? "\nUPDATE (disruption from the lexicon that owns the spec; unknown means nobody could say, not that it is safe):"
|
|
356
|
-
: `\n${action.toUpperCase()}:`,
|
|
357
|
-
);
|
|
386
|
+
lines.push(`\n${actionSectionLabel(action, disruptionParts.length > 0)}:`);
|
|
358
387
|
for (const e of group) {
|
|
359
388
|
if (e.action === "effect") {
|
|
360
389
|
lines.push(
|
|
@@ -381,6 +410,99 @@ export function renderChangeSet(cs: ChangeSet): string {
|
|
|
381
410
|
return lines.join("\n");
|
|
382
411
|
}
|
|
383
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Entries beyond this in one action group are collapsed into a `<details>`
|
|
415
|
+
* block in {@link renderChangeSetMarkdown} — a group's worth of rows is fine
|
|
416
|
+
* to read inline, a hundred-entry plan is not (#1983).
|
|
417
|
+
*/
|
|
418
|
+
const MARKDOWN_FOLD_THRESHOLD = 20;
|
|
419
|
+
|
|
420
|
+
/** One entry's markdown, mirroring the rows {@link renderChangeSet} prints. */
|
|
421
|
+
function renderMarkdownEntry(e: ChangeSetEntry): string[] {
|
|
422
|
+
if (e.action === "effect") {
|
|
423
|
+
return [
|
|
424
|
+
`- effect will fire: \`${e.effect ?? e.name}\` — receipt \`${e.name}\`${e.type ? ` (${e.type})` : ""}` +
|
|
425
|
+
`${e.effectDetail ? ` — ${e.effectDetail}` : ""}`,
|
|
426
|
+
];
|
|
427
|
+
}
|
|
428
|
+
const lexicon = e.lexicon ? ` \`${e.lexicon}\`` : "";
|
|
429
|
+
const own = e.ownership === "unknown" ? "" : ` [${e.ownership}]`;
|
|
430
|
+
const why = e.unobservedReason
|
|
431
|
+
? ` — ${unobservedReasonText(e.unobservedReason)}${e.unobservedDetail ? `: ${e.unobservedDetail}` : ""}`
|
|
432
|
+
: "";
|
|
433
|
+
const owner = e.runtimeOwner ? ` — owned by \`${e.runtimeOwner}\`` : "";
|
|
434
|
+
// Bolded rather than the plain-text render's bare " — destroy: ..." — a
|
|
435
|
+
// reviewer scanning a wall of "update" rows should see disruption without
|
|
436
|
+
// reading every one (#1665).
|
|
437
|
+
const disruption = e.disruption
|
|
438
|
+
? ` — **${e.disruption}**${e.disruptionDetail ? `: ${e.disruptionDetail}` : ""}`
|
|
439
|
+
: "";
|
|
440
|
+
const lines = [`- \`${e.name}\`${e.type ? ` (${e.type})` : ""}${lexicon}${own}${why}${owner}${disruption}`];
|
|
441
|
+
|
|
442
|
+
if (e.deltas && e.deltas.length > 0) {
|
|
443
|
+
const forced = new Set(e.disruptionBecause ?? []);
|
|
444
|
+
lines.push(" ```");
|
|
445
|
+
for (const d of e.deltas) {
|
|
446
|
+
lines.push(` ${forced.has(d.path) ? "! " : " "}${d.path}: ${fmt(d.oldValue)} → ${fmt(d.newValue)}`);
|
|
447
|
+
}
|
|
448
|
+
lines.push(" ```");
|
|
449
|
+
}
|
|
450
|
+
return lines;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Markdown projection of a change set (#1983) — sized for a PR/MR comment
|
|
455
|
+
* rather than a terminal. A scannable counts header, entries grouped by
|
|
456
|
+
* action and attributed to their lexicon, deltas in a fenced block, and any
|
|
457
|
+
* group past {@link MARKDOWN_FOLD_THRESHOLD} folded into a `<details>` so a
|
|
458
|
+
* large plan doesn't bury the comment. Pure — no ANSI, no I/O — and
|
|
459
|
+
* deterministic: same entry ordering as {@link renderChangeSet}.
|
|
460
|
+
*
|
|
461
|
+
* Both a hole (#1089) and an expensive verdict (#1665) are surfaced IN the
|
|
462
|
+
* body, not left to stderr the way `--json` and `--report gitlab-mr` leave
|
|
463
|
+
* them: a reviewer reading a comment never sees the job's log, so a plan
|
|
464
|
+
* carrying either must not read as clean.
|
|
465
|
+
*/
|
|
466
|
+
export function renderChangeSetMarkdown(cs: ChangeSet): string {
|
|
467
|
+
const counts = summarize(cs);
|
|
468
|
+
const lines: string[] = [`## Plan for \`${cs.env}\``, "", ACTION_ORDER.map((a) => `${counts[a]} ${a}`).join(", ")];
|
|
469
|
+
|
|
470
|
+
const disruption = summarizeDisruption(cs);
|
|
471
|
+
const disruptionParts = (Object.entries(disruption) as Array<[Disruption, number]>)
|
|
472
|
+
.filter(([, n]) => n > 0)
|
|
473
|
+
.map(([level, n]) => `${n} ${level}`);
|
|
474
|
+
if (disruptionParts.length > 0) {
|
|
475
|
+
lines.push("", `**Disruption:** ${disruptionParts.join(", ")}`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
for (const notice of unobservedPlanNotice(cs)) {
|
|
479
|
+
lines.push("", `> **${notice}**`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
for (const action of ACTION_ORDER) {
|
|
483
|
+
const group = cs.entries.filter((e) => e.action === action);
|
|
484
|
+
if (group.length === 0) continue;
|
|
485
|
+
|
|
486
|
+
lines.push("", `### ${actionSectionLabel(action, disruptionParts.length > 0)}`);
|
|
487
|
+
|
|
488
|
+
const body = group.flatMap((e) => renderMarkdownEntry(e));
|
|
489
|
+
if (group.length > MARKDOWN_FOLD_THRESHOLD) {
|
|
490
|
+
lines.push(
|
|
491
|
+
"",
|
|
492
|
+
`<details><summary>${group.length} entries — click to expand</summary>`,
|
|
493
|
+
"",
|
|
494
|
+
...body,
|
|
495
|
+
"",
|
|
496
|
+
"</details>",
|
|
497
|
+
);
|
|
498
|
+
} else {
|
|
499
|
+
lines.push("", ...body);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
504
|
+
}
|
|
505
|
+
|
|
384
506
|
function fmt(v: unknown): string {
|
|
385
507
|
if (v === undefined) return "<unset>";
|
|
386
508
|
if (typeof v === "string") return v.length > 60 ? v.slice(0, 57) + "..." : v;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chant #2002 — the `policyGate` Op step must gate on the build `chant build`
|
|
3
|
+
* produces, not on a different one.
|
|
4
|
+
*
|
|
5
|
+
* `evaluateProjectPolicies` used to assemble `build()`'s options itself, two of
|
|
6
|
+
* them against `buildCommand`'s nine. It built with fold off (the path #1134
|
|
7
|
+
* retired as the default), without the project's config (so serializers lost
|
|
8
|
+
* their lexicon-scoped dialect settings) and without `buildRoots` (so
|
|
9
|
+
* config-declared roots contributed nothing) — a gate could pass a build `chant
|
|
10
|
+
* build` fails. Both callers now assemble through
|
|
11
|
+
* `../cli/build-options.ts`'s `resolveProjectBuildOptions`, and the first test
|
|
12
|
+
* below fails if either ever grows an option the other does not get.
|
|
13
|
+
*
|
|
14
|
+
* `build()` is wrapped rather than replaced: every assertion here is about a
|
|
15
|
+
* real build of a real fixture, and the wrapper only records what each caller
|
|
16
|
+
* asked for.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
|
|
19
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import type { BuildOptions, BuildResult } from "../build";
|
|
23
|
+
|
|
24
|
+
const recorder = vi.hoisted(() => ({
|
|
25
|
+
options: [] as (BuildOptions | undefined)[],
|
|
26
|
+
results: [] as BuildResult[],
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock("../build", async (importOriginal) => {
|
|
30
|
+
const actual = await importOriginal<typeof import("../build")>();
|
|
31
|
+
return {
|
|
32
|
+
...actual,
|
|
33
|
+
build: async (...args: Parameters<typeof actual.build>) => {
|
|
34
|
+
recorder.options.push(args[3]);
|
|
35
|
+
const result = await actual.build(...args);
|
|
36
|
+
recorder.results.push(result);
|
|
37
|
+
return result;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const { evaluateProjectPolicies } = await import("./policy");
|
|
43
|
+
const { buildCommand } = await import("../cli/commands/build");
|
|
44
|
+
const { resolveProjectLexicons, loadPlugins } = await import("../cli/plugins");
|
|
45
|
+
|
|
46
|
+
/** Run `chant build` over a fixture the way `cli/main.ts` drives it. */
|
|
47
|
+
async function runChantBuild(dir: string): Promise<{ options: BuildOptions; result: BuildResult }> {
|
|
48
|
+
const plugins = await loadPlugins(await resolveProjectLexicons(dir));
|
|
49
|
+
recorder.options.length = 0;
|
|
50
|
+
recorder.results.length = 0;
|
|
51
|
+
const outcome = await buildCommand({
|
|
52
|
+
path: dir,
|
|
53
|
+
format: "json",
|
|
54
|
+
output: join(dir, "out", "build.json"),
|
|
55
|
+
serializers: plugins.map((p) => p.serializer),
|
|
56
|
+
plugins,
|
|
57
|
+
});
|
|
58
|
+
expect(outcome.errors).toEqual([]);
|
|
59
|
+
return { options: recorder.options[0]!, result: recorder.results[0]! };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Run the `policyGate` step's entry point over the same fixture. */
|
|
63
|
+
async function runPolicyGate(dir: string): Promise<{ options: BuildOptions; result: BuildResult }> {
|
|
64
|
+
recorder.options.length = 0;
|
|
65
|
+
recorder.results.length = 0;
|
|
66
|
+
await evaluateProjectPolicies({ path: dir });
|
|
67
|
+
return { options: recorder.options[0]!, result: recorder.results[0]! };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Build-root contributors are freshly bound closures — compare their count. */
|
|
71
|
+
function comparable(options: BuildOptions): Record<string, unknown> {
|
|
72
|
+
return { ...options, buildRoots: options.buildRoots?.length ?? 0 };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const TRIVIAL_POLICY =
|
|
76
|
+
`export const check = {\n` +
|
|
77
|
+
` id: "ORG-PARITY",\n` +
|
|
78
|
+
` description: "records nothing; the build is what is under test",\n` +
|
|
79
|
+
` check: () => [],\n` +
|
|
80
|
+
`};\n`;
|
|
81
|
+
|
|
82
|
+
describe("policyGate builds the same project chant build does (chant #2002)", () => {
|
|
83
|
+
let testDir: string;
|
|
84
|
+
|
|
85
|
+
beforeEach(async () => {
|
|
86
|
+
testDir = join(tmpdir(), `chant-policy-parity-${Date.now()}-${Math.random()}`);
|
|
87
|
+
await mkdir(join(testDir, "policies"), { recursive: true });
|
|
88
|
+
await writeFile(join(testDir, "policies", "org.ts"), TRIVIAL_POLICY);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterEach(async () => {
|
|
92
|
+
await rm(testDir, { recursive: true, force: true });
|
|
93
|
+
vi.restoreAllMocks();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A forgejo project: `forgejo.runnerLabels` is read by the serializer off
|
|
98
|
+
* `SerializeContext.config` (lexicons/forgejo/src/serializer.ts), so a build
|
|
99
|
+
* that drops `config` emits the default label instead of the project's.
|
|
100
|
+
*/
|
|
101
|
+
async function writeForgejoProject(): Promise<void> {
|
|
102
|
+
await writeFile(
|
|
103
|
+
join(testDir, "chant.config.ts"),
|
|
104
|
+
`export default {\n` +
|
|
105
|
+
` lexicons: ["forgejo"],\n` +
|
|
106
|
+
` forgejo: { runnerLabels: { "ubuntu-latest": "self-hosted-arm64" } },\n` +
|
|
107
|
+
` lint: { policies: ["policies/org.ts"] },\n` +
|
|
108
|
+
`};\n`,
|
|
109
|
+
);
|
|
110
|
+
await writeFile(
|
|
111
|
+
join(testDir, "ci.ts"),
|
|
112
|
+
`import { Workflow, Job, Step } from "@intentius/chant-lexicon-github";\n` +
|
|
113
|
+
`export const workflow = new Workflow({ name: "CI", on: { push: {} } });\n` +
|
|
114
|
+
`export const buildJob = new Job({\n` +
|
|
115
|
+
` "runs-on": "ubuntu-latest",\n` +
|
|
116
|
+
` steps: [new Step({ name: "Build", run: "npm run build" })],\n` +
|
|
117
|
+
`});\n`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
test("both callers hand build() the identical option set", async () => {
|
|
122
|
+
await writeForgejoProject();
|
|
123
|
+
|
|
124
|
+
const cli = await runChantBuild(testDir);
|
|
125
|
+
const gate = await runPolicyGate(testDir);
|
|
126
|
+
|
|
127
|
+
// The drift this issue is about is an option one caller passes and the
|
|
128
|
+
// other does not, so the key sets are asserted on their own — a new option
|
|
129
|
+
// added at one call site fails here even if its value happens to match.
|
|
130
|
+
expect(Object.keys(gate.options).sort()).toEqual(Object.keys(cli.options).sort());
|
|
131
|
+
expect(comparable(gate.options)).toEqual(comparable(cli.options));
|
|
132
|
+
expect(gate.options.fold).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("the project's lexicon-scoped dialect settings reach the gate's serializers", async () => {
|
|
136
|
+
await writeForgejoProject();
|
|
137
|
+
|
|
138
|
+
const cli = await runChantBuild(testDir);
|
|
139
|
+
const gate = await runPolicyGate(testDir);
|
|
140
|
+
|
|
141
|
+
expect([...gate.result.outputs.entries()]).toEqual([...cli.result.outputs.entries()]);
|
|
142
|
+
const emitted = JSON.stringify([...gate.result.outputs.values()]);
|
|
143
|
+
// The project's own label, not `DEFAULT_RUNNER_LABELS`' "docker" — which is
|
|
144
|
+
// what the gate emitted while it built without `config`.
|
|
145
|
+
expect(emitted).toContain("self-hosted-arm64");
|
|
146
|
+
expect(emitted).not.toContain("docker");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("entities from a config-declared build root are in the set the gate sees", async () => {
|
|
150
|
+
await writeFile(
|
|
151
|
+
join(testDir, "chant.config.ts"),
|
|
152
|
+
`export default { lexicons: ["cedar"], lint: { policies: ["policies/org.ts"] } };\n`,
|
|
153
|
+
);
|
|
154
|
+
// cedar's buildRoots hook contributes the project's schema as an entity
|
|
155
|
+
// (lexicons/cedar/src/schema-artifact.ts). Without `buildRoots` the gate
|
|
156
|
+
// never saw it, so a policy over it could not fail.
|
|
157
|
+
await writeFile(join(testDir, "schema.cedarschema"), `entity User;\n`);
|
|
158
|
+
|
|
159
|
+
const cli = await runChantBuild(testDir);
|
|
160
|
+
const gate = await runPolicyGate(testDir);
|
|
161
|
+
|
|
162
|
+
expect([...gate.result.entities.keys()].sort()).toEqual([...cli.result.entities.keys()].sort());
|
|
163
|
+
expect([...gate.result.entities.keys()]).toContain("cedarSchema");
|
|
164
|
+
expect([...gate.result.outputs.entries()]).toEqual([...cli.result.outputs.entries()]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("a foldable source module does not execute during the gate's build", async () => {
|
|
168
|
+
delete process.env.CHANT_2002_SOURCE_RAN;
|
|
169
|
+
await writeFile(
|
|
170
|
+
join(testDir, "chant.config.ts"),
|
|
171
|
+
`export default { lexicons: ["k8s"], lint: { policies: ["policies/org.ts"] } };\n`,
|
|
172
|
+
);
|
|
173
|
+
await writeFile(
|
|
174
|
+
join(testDir, "ns.ts"),
|
|
175
|
+
// Module-level: runs if and only if the file is imported. Fold reads the
|
|
176
|
+
// constructor call statically and never imports the module.
|
|
177
|
+
`process.env.CHANT_2002_SOURCE_RAN = "1";\n` +
|
|
178
|
+
`import { Namespace } from "@intentius/chant-lexicon-k8s";\n` +
|
|
179
|
+
`export const ns = new Namespace({ metadata: { name: "demo" } });\n`,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const gate = await runPolicyGate(testDir);
|
|
183
|
+
|
|
184
|
+
expect(gate.options.fold).toBe(true);
|
|
185
|
+
expect(gate.result.foldDecisions.find((d) => d.file.endsWith("ns.ts"))?.mode).toBe("fold");
|
|
186
|
+
expect(process.env.CHANT_2002_SOURCE_RAN).toBeUndefined();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("build.fold: false is honoured too — the config decides, not the gate", async () => {
|
|
190
|
+
delete process.env.CHANT_2002_SOURCE_RAN;
|
|
191
|
+
await writeFile(
|
|
192
|
+
join(testDir, "chant.config.ts"),
|
|
193
|
+
`export default { lexicons: ["k8s"], build: { fold: false }, lint: { policies: ["policies/org.ts"] } };\n`,
|
|
194
|
+
);
|
|
195
|
+
// No lexicon import: with fold off every discovered file is imported, and
|
|
196
|
+
// this fixture lives outside the repo where a bare `@intentius/*`
|
|
197
|
+
// specifier does not resolve. The marker is all this test needs.
|
|
198
|
+
await writeFile(join(testDir, "marker.ts"), `process.env.CHANT_2002_SOURCE_RAN = "1";\nexport const value = 1;\n`);
|
|
199
|
+
|
|
200
|
+
const gate = await runPolicyGate(testDir);
|
|
201
|
+
|
|
202
|
+
expect(gate.options.fold).toBe(false);
|
|
203
|
+
expect(process.env.CHANT_2002_SOURCE_RAN).toBe("1");
|
|
204
|
+
delete process.env.CHANT_2002_SOURCE_RAN;
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("build.sandbox: true is reported as a divergence rather than silently ignored", async () => {
|
|
208
|
+
await writeFile(
|
|
209
|
+
join(testDir, "chant.config.ts"),
|
|
210
|
+
`export default { lexicons: ["k8s"], build: { sandbox: true }, lint: { policies: ["policies/org.ts"] } };\n`,
|
|
211
|
+
);
|
|
212
|
+
const stderr: string[] = [];
|
|
213
|
+
vi.spyOn(console, "error").mockImplementation((s: string) => { stderr.push(s); });
|
|
214
|
+
|
|
215
|
+
const evaluation = await evaluateProjectPolicies({ path: testDir });
|
|
216
|
+
|
|
217
|
+
expect(evaluation.warnings).toHaveLength(1);
|
|
218
|
+
expect(evaluation.warnings[0]).toContain("build.sandbox is enabled");
|
|
219
|
+
expect(stderr.join("\n")).toContain("build.sandbox is enabled");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("no sandbox opt-in, no warning", async () => {
|
|
223
|
+
await writeFile(
|
|
224
|
+
join(testDir, "chant.config.ts"),
|
|
225
|
+
`export default { lexicons: ["k8s"], lint: { policies: ["policies/org.ts"] } };\n`,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const evaluation = await evaluateProjectPolicies({ path: testDir });
|
|
229
|
+
|
|
230
|
+
expect(evaluation.warnings).toEqual([]);
|
|
231
|
+
});
|
|
232
|
+
});
|