@intentius/chant 0.37.0 → 0.38.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.
Files changed (62) hide show
  1. package/dist/cli/commands/check-lexicon-mcp.d.ts +44 -0
  2. package/dist/cli/commands/check-lexicon-mcp.d.ts.map +1 -0
  3. package/dist/cli/commands/check-lexicon-plugin.d.ts +57 -0
  4. package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -0
  5. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  6. package/dist/cli/handlers/emulator.d.ts.map +1 -1
  7. package/dist/cli/handlers/graph.d.ts.map +1 -1
  8. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  9. package/dist/cli/mcp/server.d.ts +26 -2
  10. package/dist/cli/mcp/server.d.ts.map +1 -1
  11. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  12. package/dist/codegen/docs-sections.d.ts.map +1 -1
  13. package/dist/codegen/docs-sidebar.d.ts.map +1 -1
  14. package/dist/codegen/docs.d.ts +11 -0
  15. package/dist/codegen/docs.d.ts.map +1 -1
  16. package/dist/lexicon.d.ts +66 -37
  17. package/dist/lexicon.d.ts.map +1 -1
  18. package/dist/live-endpoint.d.ts +21 -22
  19. package/dist/live-endpoint.d.ts.map +1 -1
  20. package/dist/op/emulator-freshness.d.ts +44 -0
  21. package/dist/op/emulator-freshness.d.ts.map +1 -0
  22. package/dist/op/emulator-lifecycle.d.ts +36 -0
  23. package/dist/op/emulator-lifecycle.d.ts.map +1 -1
  24. package/dist/op/index.d.ts +4 -2
  25. package/dist/op/index.d.ts.map +1 -1
  26. package/dist/ownership.d.ts +33 -0
  27. package/dist/ownership.d.ts.map +1 -1
  28. package/dist/serializer.d.ts +15 -0
  29. package/dist/serializer.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/audit/catalog.test.ts +58 -6
  32. package/src/cli/commands/check-lexicon-doc-drift.test.ts +73 -0
  33. package/src/cli/commands/check-lexicon-mcp.test.ts +93 -0
  34. package/src/cli/commands/check-lexicon-mcp.ts +103 -0
  35. package/src/cli/commands/check-lexicon-plugin.test.ts +149 -0
  36. package/src/cli/commands/check-lexicon-plugin.ts +115 -0
  37. package/src/cli/commands/check-lexicon.ts +157 -26
  38. package/src/cli/handlers/components.test.ts +17 -0
  39. package/src/cli/handlers/components.ts +1 -1
  40. package/src/cli/handlers/emulator.ts +12 -8
  41. package/src/cli/handlers/graph.test.ts +71 -12
  42. package/src/cli/handlers/graph.ts +46 -5
  43. package/src/cli/handlers/lifecycle.test.ts +25 -4
  44. package/src/cli/handlers/lifecycle.ts +9 -3
  45. package/src/cli/mcp/server.test.ts +82 -0
  46. package/src/cli/mcp/server.ts +40 -5
  47. package/src/codegen/docs-rule-scanning.ts +12 -3
  48. package/src/codegen/docs-sections.ts +5 -3
  49. package/src/codegen/docs-sidebar.ts +8 -2
  50. package/src/codegen/docs.ts +19 -0
  51. package/src/lexicon-doc-coverage.test.ts +128 -0
  52. package/src/lexicon-seams.test.ts +113 -0
  53. package/src/lexicon.ts +68 -38
  54. package/src/live-endpoint.test.ts +51 -12
  55. package/src/live-endpoint.ts +32 -33
  56. package/src/op/emulator-declaration.test.ts +63 -0
  57. package/src/op/emulator-freshness.test.ts +135 -0
  58. package/src/op/emulator-freshness.ts +102 -0
  59. package/src/op/emulator-lifecycle.ts +49 -0
  60. package/src/op/index.ts +4 -2
  61. package/src/ownership.ts +41 -0
  62. package/src/serializer.ts +16 -0
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Generalized from `lexicons/fly/src/emulator-freshness.test.ts` (#1345), which
3
+ * covered fly's two pins. The check now reads every lexicon's declared spec.
4
+ */
5
+
6
+ import { describe, test, expect } from "vitest";
7
+ import { compare, parseVersion, latestRelease, checkFreshness, formatResult, unpinned } from "./emulator-freshness";
8
+ import type { EmulatorSpec } from "./emulator-lifecycle";
9
+
10
+ const spec = (over: Partial<EmulatorSpec> = {}): EmulatorSpec => ({
11
+ name: "chant-x",
12
+ image: "org/x:1.0.0",
13
+ containerPort: 1,
14
+ healthPath: "/h",
15
+ ...over,
16
+ });
17
+
18
+ describe("parseVersion", () => {
19
+ test("reads the tag off an image ref", () => {
20
+ expect(parseVersion("ghcr.io/intentius/mudflaps:0.4.1")).toBe("0.4.1");
21
+ });
22
+
23
+ test("strips a leading v", () => {
24
+ expect(parseVersion("floci/floci:v1.5.34")).toBe("1.5.34");
25
+ });
26
+
27
+ test("a registry port is not a tag", () => {
28
+ // `localhost:5000/floci` has a colon before the last slash.
29
+ expect(parseVersion("localhost:5000/floci")).toBe("");
30
+ });
31
+
32
+ test("an untagged ref has no version", () => {
33
+ expect(parseVersion("floci/floci")).toBe("");
34
+ });
35
+ });
36
+
37
+ describe("compare", () => {
38
+ test("behind when latest is newer", () => {
39
+ expect(compare("x", "0.4.1", "0.5.0").behind).toBe(true);
40
+ });
41
+
42
+ test("current when equal", () => {
43
+ expect(compare("x", "1.5.34", "1.5.34").behind).toBe(false);
44
+ });
45
+
46
+ test("not behind when the pin is ahead of the latest release", () => {
47
+ expect(compare("x", "2.0.0", "1.9.9").behind).toBe(false);
48
+ });
49
+
50
+ test("compares numerically, not lexically", () => {
51
+ // "10" < "9" as strings; the whole point of the parse.
52
+ expect(compare("x", "1.9.0", "1.10.0").behind).toBe(true);
53
+ });
54
+
55
+ test("a shorter version is padded with zeros", () => {
56
+ expect(compare("x", "1.5", "1.5.1").behind).toBe(true);
57
+ expect(compare("x", "1.5", "1.5.0").behind).toBe(false);
58
+ });
59
+
60
+ test("normalizes the v prefix out of the report", () => {
61
+ expect(compare("x", "v1.0.0", "v1.0.1")).toMatchObject({ pinned: "1.0.0", latest: "1.0.1" });
62
+ });
63
+ });
64
+
65
+ describe("unpinned", () => {
66
+ test("flags a floating tag", () => {
67
+ expect(unpinned([spec({ image: "floci/floci:latest" })]).map((s) => s.name)).toEqual(["chant-x"]);
68
+ });
69
+
70
+ test("flags an untagged image", () => {
71
+ expect(unpinned([spec({ image: "floci/floci" })])).toHaveLength(1);
72
+ });
73
+
74
+ test("a pinned image is not flagged", () => {
75
+ expect(unpinned([spec()])).toEqual([]);
76
+ });
77
+ });
78
+
79
+ describe("checkFreshness", () => {
80
+ const fakeFetch = (tag: string) =>
81
+ (async () => ({ ok: true, json: async () => ({ tag_name: tag }) })) as unknown as typeof fetch;
82
+
83
+ test("checks each spec that declares an upstream", async () => {
84
+ const results = await checkFreshness(
85
+ [spec({ name: "a", upstream: { repo: "o/a" } }), spec({ name: "b", upstream: { repo: "o/b" } })],
86
+ fakeFetch("1.0.0"),
87
+ );
88
+ expect(results.map((r) => r.name)).toEqual(["a", "b"]);
89
+ });
90
+
91
+ test("skips a spec with no upstream — a locally built image has no release feed", async () => {
92
+ expect(await checkFreshness([spec()], fakeFetch("9.9.9"))).toEqual([]);
93
+ });
94
+
95
+ test("skips a spec whose image carries no version", async () => {
96
+ expect(
97
+ await checkFreshness([spec({ image: "floci/floci", upstream: { repo: "o/a" } })], fakeFetch("1.0.0")),
98
+ ).toEqual([]);
99
+ });
100
+
101
+ test("reports behind when the release is newer than the pin", async () => {
102
+ const [result] = await checkFreshness(
103
+ [spec({ image: "org/x:1.0.0", upstream: { repo: "o/x" } })],
104
+ fakeFetch("1.2.0"),
105
+ );
106
+ expect(result).toMatchObject({ pinned: "1.0.0", latest: "1.2.0", behind: true });
107
+ });
108
+ });
109
+
110
+ describe("latestRelease", () => {
111
+ test("returns the tag name", async () => {
112
+ const fetchImpl = (async () => ({ ok: true, json: async () => ({ tag_name: "v2.1.0" }) })) as unknown as typeof fetch;
113
+ expect(await latestRelease("o/r", fetchImpl)).toBe("v2.1.0");
114
+ });
115
+
116
+ test("throws on a non-ok response rather than reporting a bogus version", async () => {
117
+ const fetchImpl = (async () => ({ ok: false, status: 404 })) as unknown as typeof fetch;
118
+ await expect(latestRelease("o/r", fetchImpl)).rejects.toThrow("HTTP 404");
119
+ });
120
+
121
+ test("throws when the release has no tag", async () => {
122
+ const fetchImpl = (async () => ({ ok: true, json: async () => ({}) })) as unknown as typeof fetch;
123
+ await expect(latestRelease("o/r", fetchImpl)).rejects.toThrow("no tag_name");
124
+ });
125
+ });
126
+
127
+ describe("formatResult", () => {
128
+ test("marks a behind pin", () => {
129
+ expect(formatResult(compare("chant-floci", "1.0.0", "1.1.0"))).toContain("behind");
130
+ });
131
+
132
+ test("marks a current pin", () => {
133
+ expect(formatResult(compare("chant-floci", "1.1.0", "1.1.0"))).toContain("current");
134
+ });
135
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * How far behind an emulator's pinned image is (#1345, generalizing #808 T2).
3
+ *
4
+ * This began as `lexicons/fly/src/emulator-freshness.ts`, checking fly's two
5
+ * pins against their GitHub releases. The other three emulators — Floci for aws,
6
+ * floci-az, floci-gcp — ran `:latest`, so there was nothing to be behind and
7
+ * nothing to check: an image could change underneath a passing local test suite
8
+ * with no record in the repo of what moved.
9
+ *
10
+ * With the pin and its upstream declared on {@link EmulatorSpec}, the check
11
+ * covers every emulator any lexicon ships, and a new one is included by
12
+ * declaring `upstream` rather than by editing a list here.
13
+ *
14
+ * Advisory, never gating. Per the bump policy (#808) a pin moves when a
15
+ * consuming test needs the newer emulator, not because a release happened.
16
+ */
17
+
18
+ import type { EmulatorSpec } from "./emulator-lifecycle";
19
+
20
+ export interface FreshnessResult {
21
+ /** The emulator's container name, e.g. `chant-mudflaps`. */
22
+ name: string;
23
+ /** Pinned version, no leading `v`. */
24
+ pinned: string;
25
+ /** Latest released version, no leading `v`. */
26
+ latest: string;
27
+ /** True when the latest release is newer than the pin. */
28
+ behind: boolean;
29
+ }
30
+
31
+ /** The version tag of an image ref (`ghcr.io/x/mudflaps:0.3.1` → `0.3.1`). */
32
+ export function parseVersion(image: string): string {
33
+ const lastColon = image.lastIndexOf(":");
34
+ const lastSlash = image.lastIndexOf("/");
35
+ // A registry port (`localhost:5000/x`) is not a tag.
36
+ if (lastColon < 0 || lastColon < lastSlash) return "";
37
+ return image.slice(lastColon + 1).replace(/^v/, "");
38
+ }
39
+
40
+ function toParts(version: string): number[] {
41
+ return version.replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
42
+ }
43
+
44
+ /** Compare pinned against latest as dotted numeric versions. */
45
+ export function compare(name: string, pinned: string, latest: string): FreshnessResult {
46
+ const p = toParts(pinned);
47
+ const l = toParts(latest);
48
+ let behind = false;
49
+ for (let i = 0; i < Math.max(p.length, l.length); i++) {
50
+ const a = p[i] ?? 0;
51
+ const b = l[i] ?? 0;
52
+ if (b > a) {
53
+ behind = true;
54
+ break;
55
+ }
56
+ if (b < a) break;
57
+ }
58
+ return { name, pinned: pinned.replace(/^v/, ""), latest: latest.replace(/^v/, ""), behind };
59
+ }
60
+
61
+ /** The latest release tag for a repo, via the GitHub REST API. */
62
+ export async function latestRelease(repo: string, fetchImpl: typeof fetch = fetch): Promise<string> {
63
+ const headers: Record<string, string> = { Accept: "application/vnd.github+json" };
64
+ if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
65
+ const res = await fetchImpl(`https://api.github.com/repos/${repo}/releases/latest`, { headers });
66
+ if (!res.ok) throw new Error(`releases/latest ${repo}: HTTP ${res.status}`);
67
+ const body = (await res.json()) as { tag_name?: string };
68
+ if (!body.tag_name) throw new Error(`releases/latest ${repo}: no tag_name`);
69
+ return body.tag_name;
70
+ }
71
+
72
+ /**
73
+ * Check every spec that declares an upstream. A spec without one is skipped
74
+ * rather than reported — an emulator built from a local image has no release
75
+ * feed to be behind.
76
+ */
77
+ export async function checkFreshness(
78
+ specs: readonly EmulatorSpec[],
79
+ fetchImpl: typeof fetch = fetch,
80
+ ): Promise<FreshnessResult[]> {
81
+ const pinned = specs.filter((s) => s.upstream?.repo && parseVersion(s.image));
82
+ return Promise.all(
83
+ pinned.map(async (s) =>
84
+ compare(s.name, parseVersion(s.image), await latestRelease(s.upstream!.repo, fetchImpl)),
85
+ ),
86
+ );
87
+ }
88
+
89
+ /** One-line human summary of a result. */
90
+ export function formatResult(r: FreshnessResult): string {
91
+ return r.behind
92
+ ? `⚠ ${r.name}: pinned ${r.pinned}, latest ${r.latest} — behind`
93
+ : `✓ ${r.name}: pinned ${r.pinned} is current (latest ${r.latest})`;
94
+ }
95
+
96
+ /** Emulator images that carry no version tag — `:latest` or none at all. */
97
+ export function unpinned(specs: readonly EmulatorSpec[]): EmulatorSpec[] {
98
+ return specs.filter((s) => {
99
+ const version = parseVersion(s.image);
100
+ return version === "" || version === "latest";
101
+ });
102
+ }
@@ -22,6 +22,20 @@ export interface EmulatorSpec {
22
22
  ready?: (healthBody: string) => boolean;
23
23
  /** Extra `docker run` args inserted before the image (e.g. a socket mount). */
24
24
  runArgs?: readonly string[];
25
+ /**
26
+ * Where {@link image} is published, so how far behind the pin is can be
27
+ * answered (#1345).
28
+ *
29
+ * fly pinned its two emulators and tracked their freshness; aws, azure and gcp
30
+ * ran `floci/*:latest`, which is the drift a pin exists to stop — a local test
31
+ * suite that passes today and fails tomorrow because an image moved underneath
32
+ * it, with nothing in the repo recording what changed. Declaring the upstream
33
+ * makes the check general instead of one lexicon's private tooling.
34
+ */
35
+ upstream?: {
36
+ /** `owner/repo` whose latest GitHub release names the current version. */
37
+ repo: string;
38
+ };
25
39
  }
26
40
 
27
41
  /**
@@ -36,6 +50,41 @@ export interface EmulatorCapability {
36
50
  env(endpoint: string): Record<string, string>;
37
51
  }
38
52
 
53
+ /**
54
+ * What a plugin declares: one emulator, or several (#1345).
55
+ *
56
+ * fly ships two — mudflaps for the Machines API and spritzer for Sprites — and
57
+ * a single-spec field could describe only one of them, so both stayed
58
+ * unreachable from `chant emulator` while the repo's docs presented them as
59
+ * first-class local targets.
60
+ */
61
+ export type EmulatorDeclaration = EmulatorCapability | readonly EmulatorCapability[];
62
+
63
+ /** Every emulator a plugin declares, normalized to a list. */
64
+ export function emulatorsOf(declaration: EmulatorDeclaration | undefined): readonly EmulatorCapability[] {
65
+ if (!declaration) return [];
66
+ return Array.isArray(declaration) ? declaration : [declaration as EmulatorCapability];
67
+ }
68
+
69
+ /** Sentinel that cannot collide with a real endpoint or a credential value. */
70
+ const ENDPOINT_PROBE = "chant-endpoint-probe://0";
71
+
72
+ /**
73
+ * The env vars whose value *is* the endpoint, as opposed to the credentials and
74
+ * region an emulator also needs (#1345).
75
+ *
76
+ * Derived by asking `env()` for a sentinel and keeping the keys that carry it,
77
+ * so a lexicon states the mapping once in the place it already states it. The
78
+ * alternative — `LEXICON_ENDPOINT_ENV_VAR`, a hand-maintained map in core — held
79
+ * two of the four lexicons that have one, and its module doc asserted azure had
80
+ * none while `describe-resources.ts` read `AZURE_ENDPOINT_URL` on every call.
81
+ */
82
+ export function endpointEnvVars(capability: EmulatorCapability): string[] {
83
+ return Object.entries(capability.env(ENDPOINT_PROBE))
84
+ .filter(([, value]) => value === ENDPOINT_PROBE)
85
+ .map(([key]) => key);
86
+ }
87
+
39
88
  /** Per-call overrides for {@link EmulatorLifecycle.up} / `runCommand`. */
40
89
  export interface EmulatorUpArgs {
41
90
  name?: string;
package/src/op/index.ts CHANGED
@@ -9,8 +9,10 @@ export { Op, phase, activity, gate, build, kubectlApply, helmInstall, waitForSta
9
9
  spritesUp, spritesDown } from "./builders";
10
10
  export { OpResource } from "./resource";
11
11
  export { safeHeartbeat, sleep } from "./activity-runtime";
12
- export { emulatorLifecycle } from "./emulator-lifecycle";
13
- export type { EmulatorSpec, EmulatorCapability, EmulatorUpArgs, EmulatorLifecycle } from "./emulator-lifecycle";
12
+ export { emulatorLifecycle, emulatorsOf, endpointEnvVars } from "./emulator-lifecycle";
13
+ export type { EmulatorSpec, EmulatorCapability, EmulatorDeclaration, EmulatorUpArgs, EmulatorLifecycle } from "./emulator-lifecycle";
14
+ export { checkFreshness, compare, formatResult, latestRelease, parseVersion, unpinned } from "./emulator-freshness";
15
+ export type { FreshnessResult } from "./emulator-freshness";
14
16
  export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep } from "./types";
15
17
  export { discoverOps } from "./discover";
16
18
  export type { DiscoveredOp, OpDiscoveryResult } from "./discover";
package/src/ownership.ts CHANGED
@@ -128,3 +128,44 @@ export function readOwnership(
128
128
  env: typeof env === "string" ? env : undefined,
129
129
  };
130
130
  }
131
+
132
+ /**
133
+ * A read path that can resolve an ownership verdict from the marker (#1348).
134
+ *
135
+ * Per-path rather than per-lexicon because the answer genuinely differs by
136
+ * path: aws stamps tags at synthesis and reads them on the deep observation and
137
+ * on live export, but `describeResources` is sourced from
138
+ * `describe-stack-resources`, which returns no tags at all — so an `owned: true`
139
+ * thin read against aws can only answer `unknown`.
140
+ */
141
+ export type OwnershipReadPath = "describeResources" | "observeResourcesDeep" | "exportResources";
142
+
143
+ /**
144
+ * Where a lexicon can stamp and read chant's ownership marker (#1348).
145
+ *
146
+ * `ResourceMetadata.ownership` documents an obligation — a lexicon with no
147
+ * marker channel on a path must stamp `unknown` rather than degrade silently,
148
+ * because the change set never escalates `unknown` to a delete — and that
149
+ * obligation had no type, no declaration, and no check. A caller could not
150
+ * learn whether `owned: true` was answerable except by asking and reading a
151
+ * warning on stderr afterwards, which is invisible to `lifecycle plan`, which
152
+ * is where the wrong delete gets proposed.
153
+ *
154
+ * Absent means the lexicon has no marker channel at all: every verdict it
155
+ * returns must be `unknown`. Declaring one is a claim the conformance suite
156
+ * checks — on a declared path, verdicts must be `owned` or `foreign`.
157
+ */
158
+ export interface OwnershipChannel {
159
+ /** The provider-native keys this lexicon stamps into. */
160
+ readonly keys: ChannelKeys;
161
+ /** The read paths that resolve a verdict from the marker. */
162
+ readonly reads: readonly OwnershipReadPath[];
163
+ }
164
+
165
+ /** Whether this lexicon resolves a real verdict on `path`, or can only say `unknown`. */
166
+ export function resolvesOwnershipOn(
167
+ channel: OwnershipChannel | undefined,
168
+ path: OwnershipReadPath,
169
+ ): boolean {
170
+ return channel?.reads.includes(path) ?? false;
171
+ }
package/src/serializer.ts CHANGED
@@ -52,6 +52,22 @@ export interface Serializer {
52
52
  */
53
53
  rulePrefix: string;
54
54
 
55
+ /**
56
+ * Further id families this lexicon owns, beyond {@link rulePrefix} (#1349).
57
+ *
58
+ * The prefix exists so ids do not collide when several lexicons are loaded
59
+ * together — forgejo wraps github's rules as `WFJ-GHA0xx` for exactly that
60
+ * reason. It was declared and checked by nothing, and k8s quietly shipped
61
+ * five `ARGO0xx` checks outside its own `WK8`.
62
+ *
63
+ * A second family is sometimes right: Argo CD is a distinct product surface
64
+ * that happens to be covered by the k8s lexicon, and renaming published ids
65
+ * would break every `chant-disable ARGO001` in the wild. Declaring it keeps
66
+ * the collision guarantee while allowing the split — an undeclared family is
67
+ * a tier-1 failure.
68
+ */
69
+ extraRulePrefixes?: readonly string[];
70
+
55
71
  /**
56
72
  * Serializes the entities to a string representation
57
73
  * @param entities - Map of entity name to Declarable entity