@intentius/chant 0.49.0 → 0.50.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/audit/catalog.d.ts +13 -3
- package/dist/audit/catalog.d.ts.map +1 -1
- package/dist/audit/core.d.ts +9 -0
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +6 -0
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/fetch.d.ts.map +1 -1
- package/dist/audit/report-html.d.ts.map +1 -1
- package/dist/audit/report-model.d.ts +6 -0
- package/dist/audit/report-model.d.ts.map +1 -1
- package/dist/audit/report.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/audit/secrets.d.ts +95 -0
- package/dist/audit/secrets.d.ts.map +1 -0
- package/dist/audit/wrangler.d.ts +33 -0
- package/dist/audit/wrangler.d.ts.map +1 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +7 -0
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts +23 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts +31 -0
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +11 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +32 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -0
- package/dist/cli/handlers/scenario.d.ts +39 -0
- package/dist/cli/handlers/scenario.d.ts.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +35 -2
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/types.d.ts +29 -1
- package/dist/cli/mcp/types.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +14 -2
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
- package/dist/components/capability.d.ts +17 -2
- package/dist/components/capability.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +7 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/component.d.ts +15 -0
- package/dist/components/component.d.ts.map +1 -1
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/verbs/index.d.ts +6 -1
- package/dist/components/verbs/index.d.ts.map +1 -1
- package/dist/components/verbs/run-agent.d.ts +499 -0
- package/dist/components/verbs/run-agent.d.ts.map +1 -0
- package/dist/components/verbs/sign.d.ts +30 -0
- package/dist/components/verbs/sign.d.ts.map +1 -1
- package/dist/composite.d.ts +6 -1
- package/dist/composite.d.ts.map +1 -1
- package/dist/discovery/collect.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +15 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/fold-rank.d.ts +66 -0
- package/dist/discovery/fold-rank.d.ts.map +1 -0
- package/dist/discovery/index.d.ts +15 -0
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/param-deps.d.ts +17 -0
- package/dist/discovery/param-deps.d.ts.map +1 -0
- package/dist/fold/fold.d.ts +55 -2
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +21 -14
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lexicon-schema.d.ts +2 -0
- package/dist/lexicon-schema.d.ts.map +1 -1
- package/dist/lexicon.d.ts +93 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/converge-ledger.d.ts +90 -0
- package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
- package/dist/lifecycle/deep-diff.d.ts +18 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.d.ts +9 -1
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +33 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
- package/dist/lifecycle/git.d.ts +145 -21
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +4 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/lease.d.ts +113 -0
- package/dist/lifecycle/lease.d.ts.map +1 -0
- package/dist/lifecycle/scenario-eval.d.ts +42 -0
- package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
- package/dist/lifecycle/scenario.d.ts +163 -0
- package/dist/lifecycle/scenario.d.ts.map +1 -0
- package/dist/lifecycle/symptoms.d.ts +63 -0
- package/dist/lifecycle/symptoms.d.ts.map +1 -0
- package/dist/lint/output-docs.d.ts +94 -0
- package/dist/lint/output-docs.d.ts.map +1 -0
- package/dist/lint/post-synth.d.ts +29 -0
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
- package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
- package/dist/lsp/lexicon-providers.d.ts +7 -0
- package/dist/lsp/lexicon-providers.d.ts.map +1 -1
- package/dist/op/activity-contract.d.ts +139 -0
- package/dist/op/activity-contract.d.ts.map +1 -0
- package/dist/op/builders.d.ts +42 -2
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/converge-rule.d.ts +161 -0
- package/dist/op/converge-rule.d.ts.map +1 -0
- package/dist/op/generate-pipeline.d.ts +39 -0
- package/dist/op/generate-pipeline.d.ts.map +1 -0
- package/dist/op/index.d.ts +14 -0
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/op-verb-class.d.ts +42 -0
- package/dist/op/op-verb-class.d.ts.map +1 -0
- package/dist/op/operator.d.ts +128 -0
- package/dist/op/operator.d.ts.map +1 -0
- package/dist/op/step-output-ref.d.ts +187 -0
- package/dist/op/step-output-ref.d.ts.map +1 -0
- package/dist/op/types.d.ts +18 -1
- package/dist/op/types.d.ts.map +1 -1
- package/dist/provenance.d.ts +73 -3
- package/dist/provenance.d.ts.map +1 -1
- package/dist/runtime-adapter.d.ts +7 -1
- package/dist/runtime-adapter.d.ts.map +1 -1
- package/dist/serializer.d.ts +18 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/toml.d.ts +40 -5
- package/dist/toml.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/catalog.test.ts +1 -1
- package/src/audit/catalog.ts +75 -3
- package/src/audit/core.ts +9 -0
- package/src/audit/discover.ts +29 -2
- package/src/audit/fetch.test.ts +216 -3
- package/src/audit/fetch.ts +270 -59
- package/src/audit/report-html.ts +5 -2
- package/src/audit/report-model.ts +9 -0
- package/src/audit/report.test.ts +22 -0
- package/src/audit/report.ts +3 -2
- package/src/audit/rules-doc.ts +2 -0
- package/src/audit/secrets.test.ts +303 -0
- package/src/audit/secrets.ts +406 -0
- package/src/audit/wrangler.test.ts +230 -0
- package/src/audit/wrangler.ts +290 -0
- package/src/build.ts +8 -3
- package/src/cli/command-group.ts +1 -1
- package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
- package/src/cli/commands/audit.test.ts +215 -1
- package/src/cli/commands/audit.ts +86 -17
- package/src/cli/commands/build.test.ts +167 -2
- package/src/cli/commands/build.ts +114 -23
- package/src/cli/handlers/build.ts +2 -0
- package/src/cli/handlers/components.test.ts +199 -1
- package/src/cli/handlers/components.ts +160 -3
- package/src/cli/handlers/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +10 -1
- package/src/cli/handlers/lifecycle.ts +12 -4
- package/src/cli/handlers/operator.test.ts +255 -0
- package/src/cli/handlers/operator.ts +240 -0
- package/src/cli/handlers/scenario.test.ts +456 -0
- package/src/cli/handlers/scenario.ts +330 -0
- package/src/cli/main.test.ts +23 -0
- package/src/cli/main.ts +72 -1
- package/src/cli/mcp/server.test.ts +265 -2
- package/src/cli/mcp/server.ts +84 -7
- package/src/cli/mcp/types.ts +27 -1
- package/src/cli/registry.ts +14 -2
- package/src/codegen/docs-rule-scanning.test.ts +42 -0
- package/src/codegen/docs-rule-scanning.ts +25 -2
- package/src/components/README.md +7 -0
- package/src/components/capability.ts +17 -2
- package/src/components/cli-support.test.ts +17 -0
- package/src/components/cli-support.ts +13 -1
- package/src/components/component-schema.test.ts +32 -0
- package/src/components/component.schema.json +6 -0
- package/src/components/component.test.ts +21 -0
- package/src/components/component.ts +15 -0
- package/src/components/driver.ts +12 -4
- package/src/components/verbs/index.ts +6 -1
- package/src/components/verbs/run-agent.test.ts +683 -0
- package/src/components/verbs/run-agent.ts +786 -0
- package/src/components/verbs/sign.test.ts +19 -0
- package/src/components/verbs/sign.ts +34 -2
- package/src/composite.ts +31 -2
- package/src/discovery/collect.ts +11 -2
- package/src/discovery/fold-import.test.ts +54 -0
- package/src/discovery/fold-import.ts +178 -38
- package/src/discovery/fold-rank.test.ts +197 -0
- package/src/discovery/fold-rank.ts +346 -0
- package/src/discovery/index.ts +16 -1
- package/src/discovery/param-deps.test.ts +118 -0
- package/src/discovery/param-deps.ts +170 -0
- package/src/fold/fold.test.ts +6 -2
- package/src/fold/fold.ts +184 -3
- package/src/fold/subset.test.ts +82 -19
- package/src/fold/subset.ts +79 -41
- package/src/lexicon-schema.ts +3 -0
- package/src/lexicon.ts +103 -2
- package/src/lifecycle/converge-ledger.test.ts +199 -0
- package/src/lifecycle/converge-ledger.ts +179 -0
- package/src/lifecycle/deep-diff.test.ts +79 -1
- package/src/lifecycle/deep-diff.ts +23 -0
- package/src/lifecycle/deep-observe.ts +13 -2
- package/src/lifecycle/gate-ledger.test.ts +103 -0
- package/src/lifecycle/gate-ledger.ts +140 -0
- package/src/lifecycle/git.test.ts +430 -0
- package/src/lifecycle/git.ts +446 -84
- package/src/lifecycle/index.ts +4 -0
- package/src/lifecycle/lease.test.ts +343 -0
- package/src/lifecycle/lease.ts +270 -0
- package/src/lifecycle/scenario-eval.test.ts +199 -0
- package/src/lifecycle/scenario-eval.ts +158 -0
- package/src/lifecycle/scenario.test.ts +195 -0
- package/src/lifecycle/scenario.ts +321 -0
- package/src/lifecycle/symptoms.test.ts +116 -0
- package/src/lifecycle/symptoms.ts +126 -0
- package/src/lint/output-docs.test.ts +220 -0
- package/src/lint/output-docs.ts +204 -0
- package/src/lint/post-synth.test.ts +97 -0
- package/src/lint/post-synth.ts +45 -0
- package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
- package/src/lint/rules/comp/comp.test.ts +49 -1
- package/src/lint/rules/evl001-non-literal-expression.test.ts +8 -3
- package/src/lint/rules/evl001-non-literal-expression.ts +6 -6
- package/src/lsp/lexicon-providers.test.ts +44 -0
- package/src/lsp/lexicon-providers.ts +11 -1
- package/src/op/activity-contract.test.ts +180 -0
- package/src/op/activity-contract.ts +278 -0
- package/src/op/builders-exports.test.ts +17 -1
- package/src/op/builders.ts +59 -5
- package/src/op/converge-rule.test.ts +179 -0
- package/src/op/converge-rule.ts +311 -0
- package/src/op/generate-pipeline.test.ts +53 -0
- package/src/op/generate-pipeline.ts +99 -0
- package/src/op/index.ts +30 -0
- package/src/op/local-executor.test.ts +92 -0
- package/src/op/local-executor.ts +45 -9
- package/src/op/op-verb-class.test.ts +126 -0
- package/src/op/op-verb-class.ts +115 -0
- package/src/op/operator.test.ts +346 -0
- package/src/op/operator.ts +213 -0
- package/src/op/step-output-ref.test.ts +334 -0
- package/src/op/step-output-ref.ts +453 -0
- package/src/op/types.ts +18 -1
- package/src/provenance.test.ts +151 -4
- package/src/provenance.ts +118 -4
- package/src/runtime-adapter.ts +31 -10
- package/src/serializer.ts +18 -0
- package/src/toml.test.ts +157 -384
- package/src/toml.ts +371 -5
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { withTestDir } from "@intentius/chant-test-utils";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
appendConvergeRecord,
|
|
8
|
+
readConvergeLedger,
|
|
9
|
+
consecutiveRuleFires,
|
|
10
|
+
type ConvergeTickRecordInput,
|
|
11
|
+
type ConvergeTickRecord,
|
|
12
|
+
} from "./converge-ledger";
|
|
13
|
+
|
|
14
|
+
function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
|
|
15
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
16
|
+
return { stdout: r.stdout ?? "", exitCode: r.status ?? -1 };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function initRepo(dir: string): Promise<void> {
|
|
20
|
+
git(["init", "-q", "-b", "main"], dir);
|
|
21
|
+
git(["config", "user.email", "test@chant.dev"], dir);
|
|
22
|
+
git(["config", "user.name", "Test"], dir);
|
|
23
|
+
writeFileSync(join(dir, "README.md"), "fixture\n");
|
|
24
|
+
git(["add", "README.md"], dir);
|
|
25
|
+
git(["commit", "-q", "-m", "init"], dir);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeInput(overrides?: Partial<ConvergeTickRecordInput>): ConvergeTickRecordInput {
|
|
29
|
+
return {
|
|
30
|
+
op: "fountain-converge",
|
|
31
|
+
env: "staging",
|
|
32
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
33
|
+
firedRuleIds: [],
|
|
34
|
+
outcomes: [],
|
|
35
|
+
summary: { drifted: 0, remediated: 0, reported: 0, skippedBudget: 0, skippedFlap: 0, unobserved: 0, adopted: 0 },
|
|
36
|
+
log: "converge(staging): drifted=0 remediated=0 reported=0 skipped-budget=0 skipped-flap=0 unobserved=0 adopted=0",
|
|
37
|
+
...overrides,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("converge-ledger", () => {
|
|
42
|
+
describe("appendConvergeRecord / readConvergeLedger", () => {
|
|
43
|
+
test("round-trips a single record", async () => {
|
|
44
|
+
await withTestDir(async (dir) => {
|
|
45
|
+
await initRepo(dir);
|
|
46
|
+
const { record } = await appendConvergeRecord(makeInput(), { cwd: dir });
|
|
47
|
+
expect(record.version).toBe(1);
|
|
48
|
+
|
|
49
|
+
const { records, malformed } = await readConvergeLedger("staging", { cwd: dir });
|
|
50
|
+
expect(malformed).toBe(0);
|
|
51
|
+
expect(records).toEqual([record]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("appends without clobbering prior records", async () => {
|
|
56
|
+
await withTestDir(async (dir) => {
|
|
57
|
+
await initRepo(dir);
|
|
58
|
+
await appendConvergeRecord(makeInput({ timestamp: "2026-01-01T00:00:00.000Z" }), { cwd: dir });
|
|
59
|
+
await appendConvergeRecord(makeInput({ timestamp: "2026-01-01T00:10:00.000Z" }), { cwd: dir });
|
|
60
|
+
await appendConvergeRecord(makeInput({ timestamp: "2026-01-01T00:20:00.000Z" }), { cwd: dir });
|
|
61
|
+
|
|
62
|
+
const { records } = await readConvergeLedger("staging", { cwd: dir });
|
|
63
|
+
expect(records).toHaveLength(3);
|
|
64
|
+
expect(records.map((r) => r.timestamp)).toEqual([
|
|
65
|
+
"2026-01-01T00:00:00.000Z",
|
|
66
|
+
"2026-01-01T00:10:00.000Z",
|
|
67
|
+
"2026-01-01T00:20:00.000Z",
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("empty ledger for an environment with no records — never throws", async () => {
|
|
73
|
+
await withTestDir(async (dir) => {
|
|
74
|
+
await initRepo(dir);
|
|
75
|
+
const { records, malformed } = await readConvergeLedger("nowhere", { cwd: dir });
|
|
76
|
+
expect(records).toEqual([]);
|
|
77
|
+
expect(malformed).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("skips malformed lines but reports the count", async () => {
|
|
82
|
+
await withTestDir(async (dir) => {
|
|
83
|
+
await initRepo(dir);
|
|
84
|
+
await appendConvergeRecord(makeInput(), { cwd: dir });
|
|
85
|
+
// Directly corrupt the ledger by writing a second, non-JSON line via a
|
|
86
|
+
// second append whose "record" is actually malformed content — simulate
|
|
87
|
+
// by reading, then re-appending raw garbage through the same git path
|
|
88
|
+
// a hand-edited ledger would produce. We reuse appendConvergeRecord's
|
|
89
|
+
// underlying git plumbing indirectly by writing a second valid record,
|
|
90
|
+
// then asserting the reader tolerates an out-of-schema line mixed in
|
|
91
|
+
// at read time isn't directly expressible via the public API, so this
|
|
92
|
+
// test instead exercises the schema-validation branch: a record
|
|
93
|
+
// missing `firedRuleIds` is treated as malformed.
|
|
94
|
+
const { readBlobFromPath, writeBlobToPath } = await import("./git");
|
|
95
|
+
const existing = await readBlobFromPath("staging", "converge.jsonl", { cwd: dir });
|
|
96
|
+
const corrupted = `${existing}\n${JSON.stringify({ version: 1, op: "x", env: "staging" })}`;
|
|
97
|
+
await writeBlobToPath("staging", "converge.jsonl", corrupted, "corrupt", { cwd: dir });
|
|
98
|
+
|
|
99
|
+
const { records, malformed } = await readConvergeLedger("staging", { cwd: dir });
|
|
100
|
+
expect(records).toHaveLength(1);
|
|
101
|
+
expect(malformed).toBe(1);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("consecutiveRuleFires", () => {
|
|
107
|
+
function rec(firedRuleIds: string[]): ConvergeTickRecord {
|
|
108
|
+
return { ...makeInput({ firedRuleIds }), version: 1 };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
test("0 for an empty ledger", () => {
|
|
112
|
+
expect(consecutiveRuleFires([], "r1")).toBe(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("0 when the newest tick didn't fire it", () => {
|
|
116
|
+
const records = [rec(["r1"]), rec(["r1"]), rec([])];
|
|
117
|
+
expect(consecutiveRuleFires(records, "r1")).toBe(0);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("counts consecutive fires from the newest tick backward", () => {
|
|
121
|
+
const records = [rec([]), rec(["r1"]), rec(["r1"]), rec(["r1"])];
|
|
122
|
+
expect(consecutiveRuleFires(records, "r1")).toBe(3);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("stops counting at the first non-firing tick, scanning backward", () => {
|
|
126
|
+
const records = [rec(["r1"]), rec([]), rec(["r1"]), rec(["r1"])];
|
|
127
|
+
expect(consecutiveRuleFires(records, "r1")).toBe(2);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("only counts the named rule, ignoring others", () => {
|
|
131
|
+
const records = [rec(["r2"]), rec(["r1", "r2"])];
|
|
132
|
+
expect(consecutiveRuleFires(records, "r1")).toBe(1);
|
|
133
|
+
expect(consecutiveRuleFires(records, "r2")).toBe(2);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ── Gate-as-fact (#1485) ───────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
describe("gated outcome", () => {
|
|
140
|
+
test("round-trips a 'gated' outcome with its gateName, and a gated count in the summary", async () => {
|
|
141
|
+
await withTestDir(async (dir) => {
|
|
142
|
+
await initRepo(dir);
|
|
143
|
+
const { record } = await appendConvergeRecord(
|
|
144
|
+
makeInput({
|
|
145
|
+
firedRuleIds: ["drift-apply"],
|
|
146
|
+
outcomes: [
|
|
147
|
+
{
|
|
148
|
+
ruleId: "drift-apply",
|
|
149
|
+
action: "gated",
|
|
150
|
+
op: "fountain-apply",
|
|
151
|
+
gateName: "rollout-gate",
|
|
152
|
+
reason: 'dispatch of "fountain-apply" hit gate "rollout-gate"',
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
summary: { drifted: 1, remediated: 0, reported: 0, skippedBudget: 0, skippedFlap: 0, unobserved: 0, adopted: 0, gated: 1 },
|
|
156
|
+
}),
|
|
157
|
+
{ cwd: dir },
|
|
158
|
+
);
|
|
159
|
+
expect(record.outcomes[0]).toEqual({
|
|
160
|
+
ruleId: "drift-apply",
|
|
161
|
+
action: "gated",
|
|
162
|
+
op: "fountain-apply",
|
|
163
|
+
gateName: "rollout-gate",
|
|
164
|
+
reason: 'dispatch of "fountain-apply" hit gate "rollout-gate"',
|
|
165
|
+
});
|
|
166
|
+
expect(record.summary.gated).toBe(1);
|
|
167
|
+
|
|
168
|
+
const { records } = await readConvergeLedger("staging", { cwd: dir });
|
|
169
|
+
expect(records).toEqual([record]);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ── Concurrent local writers (#1485) ────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
describe("appendConvergeRecord retries on RefCASConflictError", () => {
|
|
177
|
+
test("a concurrent writer to a different env's file on the same orphan branch doesn't lose either write", async () => {
|
|
178
|
+
await withTestDir(async (dir) => {
|
|
179
|
+
await initRepo(dir);
|
|
180
|
+
// Two "operators" appending to two different environments' ledgers on
|
|
181
|
+
// the same orphan branch, interleaved so their internal git-plumbing
|
|
182
|
+
// reads race — this is exactly the scenario `writeBlobToPath`'s CAS
|
|
183
|
+
// guard (chant #1485) now protects against, and `appendConvergeRecord`
|
|
184
|
+
// retries through rather than surfacing to the caller.
|
|
185
|
+
const [a, b] = await Promise.all([
|
|
186
|
+
appendConvergeRecord(makeInput({ env: "staging", op: "staging-converge" }), { cwd: dir }),
|
|
187
|
+
appendConvergeRecord(makeInput({ env: "prod", op: "prod-converge" }), { cwd: dir }),
|
|
188
|
+
]);
|
|
189
|
+
expect(a.record.env).toBe("staging");
|
|
190
|
+
expect(b.record.env).toBe("prod");
|
|
191
|
+
|
|
192
|
+
const staging = await readConvergeLedger("staging", { cwd: dir });
|
|
193
|
+
const prod = await readConvergeLedger("prod", { cwd: dir });
|
|
194
|
+
expect(staging.records).toHaveLength(1);
|
|
195
|
+
expect(prod.records).toHaveLength(1);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converge ledger (#1484) — the append-only record of what a `ConvergeOp`
|
|
3
|
+
* tick did, one line per tick, at `<env>/converge.jsonl` on the
|
|
4
|
+
* `chant/lifecycle` orphan branch. Same storage shape as the release ledger
|
|
5
|
+
* (./release-ledger.ts): immutable, append-only JSONL, reusing
|
|
6
|
+
* `writeBlobToPath`/`readBlobFromPath` (./git.ts) directly rather than a new
|
|
7
|
+
* git-plumbing path — the exact reuse `appendReleaseRecordLine` already
|
|
8
|
+
* demonstrates for a second filename under the same env directory.
|
|
9
|
+
*
|
|
10
|
+
* This is also where flap-damping state lives (issue: "consecutive-fire
|
|
11
|
+
* counts go in the ledger ... not process memory, so damping survives worker
|
|
12
|
+
* restarts"): each record's `firedRuleIds` names every rule whose predicate
|
|
13
|
+
* matched this tick, independent of what action was actually taken (run,
|
|
14
|
+
* report, or skipped for budget/flap reasons) — {@link consecutiveRuleFires}
|
|
15
|
+
* walks the ledger backward from the newest record and counts how many in a
|
|
16
|
+
* row include a given rule id, stopping at the first tick where it didn't
|
|
17
|
+
* fire (the symptom cleared).
|
|
18
|
+
*/
|
|
19
|
+
import { sortedJsonReplacer } from "../utils";
|
|
20
|
+
import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
|
|
21
|
+
|
|
22
|
+
const FILENAME = "converge.jsonl";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read-modify-append retry budget for {@link appendConvergeRecord} (#1485).
|
|
26
|
+
* `writeBlobToPath`'s ref write is now CAS-guarded (./git.ts) — a conflict
|
|
27
|
+
* (another writer, e.g. a second operator ticking a different env on the
|
|
28
|
+
* same orphan branch, updated the branch tip between our read and our
|
|
29
|
+
* write) throws `RefCASConflictError` instead of silently clobbering. This
|
|
30
|
+
* many attempts, re-reading the ledger fresh each time, absorbs that race
|
|
31
|
+
* rather than surfacing it as a tick failure — the whole point of the CAS
|
|
32
|
+
* guard is correctness, not a new way for a tick to fail.
|
|
33
|
+
*/
|
|
34
|
+
const APPEND_RETRY_ATTEMPTS = 5;
|
|
35
|
+
|
|
36
|
+
/** One rule's outcome within a tick. */
|
|
37
|
+
export interface ConvergeRuleOutcome {
|
|
38
|
+
ruleId: string;
|
|
39
|
+
/**
|
|
40
|
+
* What actually happened for this fired rule this tick. `"gated"` (#1485)
|
|
41
|
+
* is gate-as-fact: the dispatched op's own run hit a gate it can't clear
|
|
42
|
+
* on the local executor, and the tick records that as a terminal, durable
|
|
43
|
+
* fact — `gateName` names the gate — rather than treating it as a
|
|
44
|
+
* dispatch failure. Resolution is out-of-band (`chant approve <op>
|
|
45
|
+
* <gate>`, ./gate-ledger.ts, or a merged PR); the tick itself never
|
|
46
|
+
* blocks waiting for it.
|
|
47
|
+
*/
|
|
48
|
+
action: "ran" | "reported" | "skipped-budget" | "skipped-flap" | "gated";
|
|
49
|
+
/** The dispatched Op name, for `action: "ran"` or `"gated"`. */
|
|
50
|
+
op?: string;
|
|
51
|
+
/** The gate's signal name, for `action: "gated"`. */
|
|
52
|
+
gateName?: string;
|
|
53
|
+
/** The report reason, for `action: "reported"` (including a flap-damped rule's forced report) — and the human-readable explanation for `action: "gated"`. */
|
|
54
|
+
reason?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One immutable converge-tick record. */
|
|
58
|
+
export interface ConvergeTickRecord {
|
|
59
|
+
/** Schema version, so an incompatible future shape is detected before being misread. */
|
|
60
|
+
version: 1;
|
|
61
|
+
/** The ConvergeOp's name (`OpConfig.name`). */
|
|
62
|
+
op: string;
|
|
63
|
+
env: string;
|
|
64
|
+
/** ISO-8601 timestamp, caller-supplied (same convention as `ReleaseRecord.timestamp` — library code never calls `Date.now()` internally). */
|
|
65
|
+
timestamp: string;
|
|
66
|
+
/** Every rule id whose predicate matched this tick, regardless of what action followed — the flap-damping input. */
|
|
67
|
+
firedRuleIds: string[];
|
|
68
|
+
/** Per-rule outcome, for every fired rule. */
|
|
69
|
+
outcomes: ConvergeRuleOutcome[];
|
|
70
|
+
/** Aggregate counts backing the tick's one log line. */
|
|
71
|
+
summary: {
|
|
72
|
+
drifted: number;
|
|
73
|
+
remediated: number;
|
|
74
|
+
reported: number;
|
|
75
|
+
skippedBudget: number;
|
|
76
|
+
skippedFlap: number;
|
|
77
|
+
unobserved: number;
|
|
78
|
+
adopted: number;
|
|
79
|
+
/** Rules whose dispatch hit a gate this tick (#1485) — a terminal, non-blocking fact; see `ConvergeRuleOutcome.action`'s doc. @default 0, so an older record without this field still reads as zero, not undefined. */
|
|
80
|
+
gated?: number;
|
|
81
|
+
};
|
|
82
|
+
/** The one human-readable log line this tick produced (issue: "one log line and one ledger record per tick"). */
|
|
83
|
+
log: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type ConvergeTickRecordInput = Omit<ConvergeTickRecord, "version">;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Append one immutable tick record. Does not push to the remote — call
|
|
90
|
+
* `pushLifecycle` (./git.ts) afterward, same two-step shape every other
|
|
91
|
+
* ledger write here uses.
|
|
92
|
+
*
|
|
93
|
+
* Retries the whole read-modify-write cycle (#1485) on `RefCASConflictError`
|
|
94
|
+
* — `writeBlobToPath`'s ref write is CAS-guarded, so a concurrent writer to
|
|
95
|
+
* a *different* env's file on the same orphan branch (two operators ticking
|
|
96
|
+
* two environments at once is the ordinary case, not an edge case) can lose
|
|
97
|
+
* the race and needs to re-read the branch tip and retry, not fail the
|
|
98
|
+
* tick. Each retry re-reads `existing` fresh, so it always appends onto
|
|
99
|
+
* whatever the other writer just committed rather than reintroducing a stale
|
|
100
|
+
* read. Exhausting the budget re-throws the conflict — a real, sustained
|
|
101
|
+
* pile-up of writers is a signal worth surfacing, not silently swallowing.
|
|
102
|
+
*
|
|
103
|
+
* The baseline read must be `readPathSha` + `readBlobBySha` rather than
|
|
104
|
+
* `readBlobFromPath`, so the exact sha `existing` came from can be passed as
|
|
105
|
+
* `expectPriorPathSha`. See `writeBlobToPath` (./git.ts) for the race that
|
|
106
|
+
* closes.
|
|
107
|
+
*/
|
|
108
|
+
export async function appendConvergeRecord(
|
|
109
|
+
input: ConvergeTickRecordInput,
|
|
110
|
+
opts?: { cwd?: string },
|
|
111
|
+
): Promise<{ commit: string; record: ConvergeTickRecord }> {
|
|
112
|
+
const record: ConvergeTickRecord = { version: 1, ...input };
|
|
113
|
+
const json = JSON.stringify(record, sortedJsonReplacer);
|
|
114
|
+
|
|
115
|
+
let lastErr: unknown;
|
|
116
|
+
for (let attempt = 1; attempt <= APPEND_RETRY_ATTEMPTS; attempt++) {
|
|
117
|
+
try {
|
|
118
|
+
const priorSha = await readPathSha(record.env, FILENAME, opts);
|
|
119
|
+
const existing = priorSha ? await readBlobBySha(priorSha, opts) : null;
|
|
120
|
+
const content = existing ? `${existing.replace(/\n$/, "")}\n${json}` : json;
|
|
121
|
+
const commit = await writeBlobToPath(record.env, FILENAME, content, "Converge tick record", {
|
|
122
|
+
...opts,
|
|
123
|
+
expectPriorPathSha: priorSha,
|
|
124
|
+
});
|
|
125
|
+
return { commit, record };
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (!(err instanceof RefCASConflictError)) throw err;
|
|
128
|
+
lastErr = err;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
throw lastErr;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Read every tick record for `environment`, oldest first. Malformed lines are skipped, not thrown on — a corrupted or hand-edited ledger degrades gracefully, the same stance `readReleaseLedger` takes. */
|
|
135
|
+
export async function readConvergeLedger(
|
|
136
|
+
environment: string,
|
|
137
|
+
opts?: { cwd?: string },
|
|
138
|
+
): Promise<{ records: ConvergeTickRecord[]; malformed: number }> {
|
|
139
|
+
const content = await readBlobFromPath(environment, FILENAME, opts);
|
|
140
|
+
if (!content) return { records: [], malformed: 0 };
|
|
141
|
+
|
|
142
|
+
const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
143
|
+
const records: ConvergeTickRecord[] = [];
|
|
144
|
+
let malformed = 0;
|
|
145
|
+
for (const line of lines) {
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(line) as Partial<ConvergeTickRecord>;
|
|
148
|
+
if (
|
|
149
|
+
parsed.version !== 1 ||
|
|
150
|
+
typeof parsed.op !== "string" ||
|
|
151
|
+
typeof parsed.env !== "string" ||
|
|
152
|
+
typeof parsed.timestamp !== "string" ||
|
|
153
|
+
!Array.isArray(parsed.firedRuleIds)
|
|
154
|
+
) {
|
|
155
|
+
malformed++;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
records.push(parsed as ConvergeTickRecord);
|
|
159
|
+
} catch {
|
|
160
|
+
malformed++;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { records, malformed };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* How many consecutive most-recent ticks (newest first) fired `ruleId`,
|
|
168
|
+
* stopping at the first tick where it did not — the count a rule's
|
|
169
|
+
* `flapThreshold` is compared against. `0` when the newest tick didn't fire
|
|
170
|
+
* it (including an empty ledger).
|
|
171
|
+
*/
|
|
172
|
+
export function consecutiveRuleFires(records: ConvergeTickRecord[], ruleId: string): number {
|
|
173
|
+
let count = 0;
|
|
174
|
+
for (let i = records.length - 1; i >= 0; i--) {
|
|
175
|
+
if (!records[i].firedRuleIds.includes(ruleId)) break;
|
|
176
|
+
count++;
|
|
177
|
+
}
|
|
178
|
+
return count;
|
|
179
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { countPropertyDrift, diffDeep } from "./deep-diff";
|
|
2
|
+
import { countPropertyDrift, diffDeep, type DeclaredDeepEntity } from "./deep-diff";
|
|
3
3
|
import { UNRESOLVED, type NormalizedDeepObservation } from "../deep-observation";
|
|
4
4
|
import type { BaselineLexicon } from "./observation-baseline";
|
|
5
5
|
|
|
@@ -202,3 +202,81 @@ describe("diffDeep — owning field manager (#1189)", () => {
|
|
|
202
202
|
expect(change).not.toHaveProperty("owner");
|
|
203
203
|
});
|
|
204
204
|
});
|
|
205
|
+
|
|
206
|
+
// #1443 — the declared-side counterpart of `owner`. Both sides of one
|
|
207
|
+
// comparison get precise attribution, which is what makes the report actionable.
|
|
208
|
+
describe("diffDeep — declared-side origin (#1443)", () => {
|
|
209
|
+
// Annotated, not `as const`: the annotation is what keeps `kind` narrowed to
|
|
210
|
+
// its literal, and it types the fixture as the production shape rather than
|
|
211
|
+
// whatever the literal happens to infer to.
|
|
212
|
+
const declared: Record<string, DeclaredDeepEntity> = {
|
|
213
|
+
web: {
|
|
214
|
+
type: "K8s::Apps::Deployment",
|
|
215
|
+
properties: { spec: { replicas: 2, template: { spec: { containers: [{ name: "app", image: "a:1" }] } } } },
|
|
216
|
+
pathOrigins: {
|
|
217
|
+
"": { kind: "composite", composite: "WebService", instance: "web" },
|
|
218
|
+
"spec.replicas": { kind: "build-param", params: ["tier"] },
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
test("reports the origin alongside the live owner", () => {
|
|
224
|
+
const result = diffDeep({
|
|
225
|
+
declared,
|
|
226
|
+
live: live({
|
|
227
|
+
web: {
|
|
228
|
+
type: "K8s::Apps::Deployment",
|
|
229
|
+
properties: { spec: { replicas: 5, template: { spec: { containers: [{ name: "app", image: "a:1" }] } } } },
|
|
230
|
+
fieldOwners: { "spec.replicas": "hpa-controller" },
|
|
231
|
+
},
|
|
232
|
+
}),
|
|
233
|
+
});
|
|
234
|
+
expect(result.drifted[0].changes[0]).toMatchObject({
|
|
235
|
+
path: "spec.replicas",
|
|
236
|
+
owner: "hpa-controller",
|
|
237
|
+
origin: { kind: "build-param", params: ["tier"] },
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("a path with no origin of its own inherits the nearest recorded ancestor", () => {
|
|
242
|
+
const result = diffDeep({
|
|
243
|
+
declared,
|
|
244
|
+
live: live({
|
|
245
|
+
web: {
|
|
246
|
+
type: "K8s::Apps::Deployment",
|
|
247
|
+
properties: { spec: { replicas: 2, template: { spec: { containers: [{ name: "app", image: "a:2" }] } } } },
|
|
248
|
+
},
|
|
249
|
+
}),
|
|
250
|
+
});
|
|
251
|
+
const change = result.drifted[0].changes.find((c) => c.path.includes("image"))!;
|
|
252
|
+
expect(change.origin).toEqual({ kind: "composite", composite: "WebService", instance: "web" });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("is absent on an undeclared path — nothing in source produced it", () => {
|
|
256
|
+
const result = diffDeep({
|
|
257
|
+
declared,
|
|
258
|
+
live: live({
|
|
259
|
+
web: {
|
|
260
|
+
type: "K8s::Apps::Deployment",
|
|
261
|
+
properties: {
|
|
262
|
+
spec: { replicas: 2, template: { spec: { containers: [{ name: "app", image: "a:1" }] } } },
|
|
263
|
+
status: { observed: 1 },
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
}),
|
|
267
|
+
});
|
|
268
|
+
const change = result.drifted[0].changes.find((c) => c.path === "status.observed")!;
|
|
269
|
+
expect(change.kind).toBe("undeclared");
|
|
270
|
+
expect(change).not.toHaveProperty("origin");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("is absent when the build recorded no path origins", () => {
|
|
274
|
+
// The run path, and a sandboxed child: absent means "could not record",
|
|
275
|
+
// not "nothing governs this field", so the key must not appear at all.
|
|
276
|
+
const result = diffDeep({
|
|
277
|
+
declared: { web: { type: "K8s::Apps::Deployment", properties: { spec: { replicas: 2 } } } },
|
|
278
|
+
live: live({ web: { type: "K8s::Apps::Deployment", properties: { spec: { replicas: 5 } } } }),
|
|
279
|
+
});
|
|
280
|
+
expect(result.drifted[0].changes[0]).not.toHaveProperty("origin");
|
|
281
|
+
});
|
|
282
|
+
});
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type DeepNormalizationHooks,
|
|
30
30
|
type NormalizedDeepObservation,
|
|
31
31
|
} from "../deep-observation";
|
|
32
|
+
import { originOfPath, type PathOrigin } from "../provenance";
|
|
32
33
|
import type { UnobservedResource } from "./live-diff";
|
|
33
34
|
import { acceptedDeviation, type BaselineLexicon } from "./observation-baseline";
|
|
34
35
|
|
|
@@ -70,6 +71,18 @@ export interface PropertyDrift {
|
|
|
70
71
|
* with no per-field ownership, which is every substrate but k8s.
|
|
71
72
|
*/
|
|
72
73
|
owner?: string;
|
|
74
|
+
/**
|
|
75
|
+
* What produced this path on the DECLARED side (#1443) — the counterpart of
|
|
76
|
+
* {@link owner}, and the reason the two are reported together: "owned live by
|
|
77
|
+
* `hpa-controller`, governed in source by the `tier` parameter" says where
|
|
78
|
+
* each half of a disagreement has to be fixed, which neither half says alone.
|
|
79
|
+
*
|
|
80
|
+
* Resolved by longest prefix from the entity's recorded path origins, so a
|
|
81
|
+
* field inside a keyed list element inherits the origin recorded for the
|
|
82
|
+
* list. Absent when the build recorded none — the run path, and a sandboxed
|
|
83
|
+
* child, have no expression to attribute (see `EntityProvenance.paths`).
|
|
84
|
+
*/
|
|
85
|
+
origin?: PathOrigin;
|
|
73
86
|
}
|
|
74
87
|
|
|
75
88
|
/** Property-level drift for one declared entity. */
|
|
@@ -100,6 +113,11 @@ export interface DeepDiffResult {
|
|
|
100
113
|
export interface DeclaredDeepEntity {
|
|
101
114
|
type: string;
|
|
102
115
|
properties: Record<string, unknown>;
|
|
116
|
+
/**
|
|
117
|
+
* The entity's recorded path origins (#1443), as `EntityProvenance.paths`.
|
|
118
|
+
* Omit for a build that recorded none.
|
|
119
|
+
*/
|
|
120
|
+
pathOrigins?: Record<string, PathOrigin>;
|
|
103
121
|
}
|
|
104
122
|
|
|
105
123
|
export interface DiffDeepInput {
|
|
@@ -188,12 +206,17 @@ export function diffDeep(input: DiffDeepInput): DeepDiffResult {
|
|
|
188
206
|
// meaningful for a path that exists live — an `absent` drift has no live
|
|
189
207
|
// field for anyone to own.
|
|
190
208
|
const owner = hasLive ? liveEntity.fieldOwners?.[path] : undefined;
|
|
209
|
+
// The declared-side counterpart (#1443). Only meaningful for a path
|
|
210
|
+
// source actually declares — an `undeclared` drift has no authored
|
|
211
|
+
// expression for anything to have produced.
|
|
212
|
+
const origin = hasDeclared ? originOfPath(declaredEntity.pathOrigins, path) : undefined;
|
|
191
213
|
const drift: PropertyDrift = {
|
|
192
214
|
path,
|
|
193
215
|
kind,
|
|
194
216
|
...(hasDeclared ? { declared: declaredValue } : {}),
|
|
195
217
|
...(hasLive ? { live: liveValue } : {}),
|
|
196
218
|
...(owner ? { owner } : {}),
|
|
219
|
+
...(origin ? { origin } : {}),
|
|
197
220
|
};
|
|
198
221
|
|
|
199
222
|
const acceptedEntry = acceptedDeviation(baseline, name, path);
|
|
@@ -27,11 +27,21 @@ import {
|
|
|
27
27
|
type NormalizedDeepObservation,
|
|
28
28
|
} from "../deep-observation";
|
|
29
29
|
import { unobservedAll, type UnobservedEntity } from "../observation";
|
|
30
|
+
import type { PathOrigin } from "../provenance";
|
|
30
31
|
import { diffDeep, type DeclaredDeepEntity, type DeepDiffResult } from "./deep-diff";
|
|
31
32
|
import type { BaselineLexicon } from "./observation-baseline";
|
|
32
33
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Declared entities for one lexicon, in the shape the observe paths pass around.
|
|
36
|
+
*
|
|
37
|
+
* `pathOrigins` (#1443) rides alongside `props` rather than being read off the
|
|
38
|
+
* entity, because callers rebuild this map as plain objects and the symbol-keyed
|
|
39
|
+
* provenance channel does not survive that copy.
|
|
40
|
+
*/
|
|
41
|
+
export type DeclaredEntities = Map<
|
|
42
|
+
string,
|
|
43
|
+
{ entityType: string; props: Record<string, unknown>; pathOrigins?: Record<string, PathOrigin> }
|
|
44
|
+
>;
|
|
35
45
|
|
|
36
46
|
export interface DeepObserveOptions {
|
|
37
47
|
environment: string;
|
|
@@ -138,6 +148,7 @@ export function diffDeepObservation(
|
|
|
138
148
|
hooks,
|
|
139
149
|
counterpartPaths: livePaths,
|
|
140
150
|
}),
|
|
151
|
+
...(entity.pathOrigins ? { pathOrigins: entity.pathOrigins } : {}),
|
|
141
152
|
};
|
|
142
153
|
if (liveEntity) {
|
|
143
154
|
normalizedLive[name] = {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { withTestDir } from "@intentius/chant-test-utils";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { appendGateResolution, readGateResolutions, latestResolutionSince } from "./gate-ledger";
|
|
7
|
+
import { readBlobFromPath } from "./git";
|
|
8
|
+
|
|
9
|
+
function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
|
|
10
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
11
|
+
return { stdout: r.stdout ?? "", exitCode: r.status ?? -1 };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function initRepo(dir: string): Promise<void> {
|
|
15
|
+
git(["init", "-q", "-b", "main"], dir);
|
|
16
|
+
git(["config", "user.email", "test@chant.dev"], dir);
|
|
17
|
+
git(["config", "user.name", "Test"], dir);
|
|
18
|
+
writeFileSync(join(dir, "README.md"), "fixture\n");
|
|
19
|
+
git(["add", "README.md"], dir);
|
|
20
|
+
git(["commit", "-q", "-m", "init"], dir);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("lifecycle/gate-ledger", () => {
|
|
24
|
+
test("appendGateResolution + readGateResolutions round-trip", async () => {
|
|
25
|
+
await withTestDir(async (dir) => {
|
|
26
|
+
await initRepo(dir);
|
|
27
|
+
const { record } = await appendGateResolution(
|
|
28
|
+
{ op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
29
|
+
{ cwd: dir },
|
|
30
|
+
);
|
|
31
|
+
expect(record.version).toBe(1);
|
|
32
|
+
|
|
33
|
+
const { records, malformed } = await readGateResolutions("fountain-apply", { cwd: dir });
|
|
34
|
+
expect(malformed).toBe(0);
|
|
35
|
+
expect(records).toEqual([record]);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("stores under a global _gates namespace, keyed by op — not per-environment", async () => {
|
|
40
|
+
await withTestDir(async (dir) => {
|
|
41
|
+
await initRepo(dir);
|
|
42
|
+
await appendGateResolution(
|
|
43
|
+
{ op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
44
|
+
{ cwd: dir },
|
|
45
|
+
);
|
|
46
|
+
const raw = await readBlobFromPath("_gates", "fountain-apply.jsonl", { cwd: dir });
|
|
47
|
+
expect(raw).toContain("rollout-gate");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("readGateResolutions returns [] for an op with no resolutions yet", async () => {
|
|
52
|
+
await withTestDir(async (dir) => {
|
|
53
|
+
await initRepo(dir);
|
|
54
|
+
expect(await readGateResolutions("no-such-op", { cwd: dir })).toEqual({ records: [], malformed: 0 });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("appends without clobbering — multiple gates/resolutions on the same op accumulate", async () => {
|
|
59
|
+
await withTestDir(async (dir) => {
|
|
60
|
+
await initRepo(dir);
|
|
61
|
+
await appendGateResolution(
|
|
62
|
+
{ op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
63
|
+
{ cwd: dir },
|
|
64
|
+
);
|
|
65
|
+
await appendGateResolution(
|
|
66
|
+
{ op: "fountain-apply", gate: "prod-gate", resolvedBy: "sam", timestamp: "2026-01-02T00:00:00.000Z", note: "https://github.com/x/y/pull/1" },
|
|
67
|
+
{ cwd: dir },
|
|
68
|
+
);
|
|
69
|
+
const { records } = await readGateResolutions("fountain-apply", { cwd: dir });
|
|
70
|
+
expect(records.map((r) => r.gate)).toEqual(["rollout-gate", "prod-gate"]);
|
|
71
|
+
expect(records[1].note).toBe("https://github.com/x/y/pull/1");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("latestResolutionSince", () => {
|
|
76
|
+
test("finds a resolution recorded after the gated tick's own timestamp", () => {
|
|
77
|
+
const records = [
|
|
78
|
+
{ version: 1 as const, op: "x", gate: "g1", resolvedBy: "a", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
79
|
+
{ version: 1 as const, op: "x", gate: "g1", resolvedBy: "b", timestamp: "2026-01-03T00:00:00.000Z" },
|
|
80
|
+
];
|
|
81
|
+
const found = latestResolutionSince(records, "g1", "2026-01-02T00:00:00.000Z");
|
|
82
|
+
expect(found?.resolvedBy).toBe("b");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("returns undefined when the only resolution predates the gated tick (a stale, superseded approval)", () => {
|
|
86
|
+
const records = [
|
|
87
|
+
{ version: 1 as const, op: "x", gate: "g1", resolvedBy: "a", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
88
|
+
];
|
|
89
|
+
expect(latestResolutionSince(records, "g1", "2026-01-02T00:00:00.000Z")).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("ignores resolutions for a different gate", () => {
|
|
93
|
+
const records = [
|
|
94
|
+
{ version: 1 as const, op: "x", gate: "other-gate", resolvedBy: "a", timestamp: "2026-01-05T00:00:00.000Z" },
|
|
95
|
+
];
|
|
96
|
+
expect(latestResolutionSince(records, "g1", "2026-01-01T00:00:00.000Z")).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("undefined when there are no resolutions at all", () => {
|
|
100
|
+
expect(latestResolutionSince([], "g1", "2026-01-01T00:00:00.000Z")).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|