@intentius/chant 0.72.2 → 0.72.4

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 (56) hide show
  1. package/dist/cli/commands/doctor.d.ts.map +1 -1
  2. package/dist/cli/commands/init.d.ts +1 -1
  3. package/dist/cli/commands/init.d.ts.map +1 -1
  4. package/dist/cli/commands/update.d.ts.map +1 -1
  5. package/dist/cli/handlers/init.d.ts.map +1 -1
  6. package/dist/cli/handlers/operator.d.ts +16 -0
  7. package/dist/cli/handlers/operator.d.ts.map +1 -1
  8. package/dist/cli/main.d.ts.map +1 -1
  9. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  10. package/dist/cli/mcp/server.d.ts +10 -0
  11. package/dist/cli/mcp/server.d.ts.map +1 -1
  12. package/dist/cli/mcp-config.d.ts +47 -0
  13. package/dist/cli/mcp-config.d.ts.map +1 -0
  14. package/dist/cli/registry.d.ts +12 -0
  15. package/dist/cli/registry.d.ts.map +1 -1
  16. package/dist/discovery/fold-import.d.ts +24 -1
  17. package/dist/discovery/fold-import.d.ts.map +1 -1
  18. package/dist/discovery/sandbox/fork.d.ts +0 -5
  19. package/dist/discovery/sandbox/fork.d.ts.map +1 -1
  20. package/dist/lifecycle/gate-ledger.d.ts +29 -0
  21. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  22. package/dist/lifecycle/gate-origin.d.ts +77 -0
  23. package/dist/lifecycle/gate-origin.d.ts.map +1 -0
  24. package/dist/observation.d.ts +0 -1
  25. package/dist/observation.d.ts.map +1 -1
  26. package/dist/op/activities/converge.d.ts +16 -0
  27. package/dist/op/activities/converge.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/src/audit/core.ts +1 -1
  30. package/src/cli/commands/doctor.ts +18 -6
  31. package/src/cli/commands/init.ts +9 -68
  32. package/src/cli/commands/update.ts +9 -0
  33. package/src/cli/handlers/init.ts +1 -0
  34. package/src/cli/handlers/operator.ts +48 -1
  35. package/src/cli/main.ts +9 -0
  36. package/src/cli/mcp/docs-parity.test.ts +133 -0
  37. package/src/cli/mcp/op-approve-origin.test.ts +70 -0
  38. package/src/cli/mcp/op-tools.ts +18 -4
  39. package/src/cli/mcp/server.ts +16 -1
  40. package/src/cli/mcp-config.test.ts +168 -0
  41. package/src/cli/mcp-config.ts +76 -0
  42. package/src/cli/registry.ts +12 -0
  43. package/src/discovery/fold-executing-mode.test.ts +115 -0
  44. package/src/discovery/fold-import.ts +87 -14
  45. package/src/discovery/fold-no-invoke-declared.test.ts +118 -0
  46. package/src/discovery/sandbox/fork-diagnostic.test.ts +117 -0
  47. package/src/discovery/sandbox/fork.ts +92 -6
  48. package/src/graph-ir.ts +1 -1
  49. package/src/graph-layout.ts +0 -0
  50. package/src/lifecycle/gate-ledger.ts +39 -1
  51. package/src/lifecycle/gate-origin.test.ts +93 -0
  52. package/src/lifecycle/gate-origin.ts +113 -0
  53. package/src/meta/source-is-text.test.ts +72 -0
  54. package/src/observation.ts +27 -8
  55. package/src/op/activities/converge-push.test.ts +101 -0
  56. package/src/op/activities/converge.ts +31 -1
@@ -0,0 +1,72 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ /**
7
+ * Every shipped source file is text a text tool can read.
8
+ *
9
+ * chant#2280 — `graph-ir.ts` carried three literal NUL bytes inside a template
10
+ * literal, used as separators in a composite key. The value was right and the
11
+ * code worked; what broke was everything that reads the file as text. ripgrep
12
+ * classifies a file containing NUL as binary, so it answers `binary file
13
+ * matches (found a NUL byte around offset 32631)` instead of the matching
14
+ * lines, and a repo-wide search does not list the file at all. 911 lines were
15
+ * invisible to every grep-driven search of this repository for as long as the
16
+ * bytes were there.
17
+ *
18
+ * That is worse than a silent bug, because it is a silent bug in the tool you
19
+ * would use to find bugs. Any audit, any "I searched the codebase", any
20
+ * refactor that greps for a symbol defined in that file quietly skipped it and
21
+ * reported success.
22
+ *
23
+ * The fix is not to stop using NUL as a separator — it is a good one, being the
24
+ * character that cannot appear in an identifier or an attribute name. It is to
25
+ * write it as an escape, four ASCII characters in the file and the same single
26
+ * code unit at runtime.
27
+ */
28
+ const SRC = fileURLToPath(new URL("../", import.meta.url));
29
+
30
+ /** Every TypeScript file under core's src, tests and fixtures included. */
31
+ function sourceFiles(dir: string): string[] {
32
+ return readdirSync(dir).flatMap((entry) => {
33
+ const path = join(dir, entry);
34
+ if (statSync(path).isDirectory()) return entry === "node_modules" ? [] : sourceFiles(path);
35
+ return /\.(ts|mts|cts)$/.test(entry) ? [path] : [];
36
+ });
37
+ }
38
+
39
+ describe("source files are text, not binary (chant#2280)", () => {
40
+ test("no shipped source file contains a NUL byte", () => {
41
+ const offenders: string[] = [];
42
+ for (const file of sourceFiles(SRC)) {
43
+ const buf = readFileSync(file);
44
+ const at = buf.indexOf(0);
45
+ if (at !== -1) offenders.push(`${relative(SRC, file)} (first at byte ${at})`);
46
+ }
47
+
48
+ expect(
49
+ offenders,
50
+ "a NUL byte makes ripgrep treat the file as binary, so it reports a " +
51
+ "binary-file-matches line instead of the matches and a repo-wide search omits " +
52
+ "the file entirely. Write the character as a unicode escape instead — same " +
53
+ "value at runtime, plain ASCII in the file.",
54
+ ).toEqual([]);
55
+ });
56
+
57
+ test("the composite edge key still separates on NUL, which is the point of using it", () => {
58
+ // Guards the fix rather than the file: the escape must denote the same
59
+ // character the literal byte did, or the separator silently becomes
60
+ // something that CAN occur in an identifier and distinct edges collide.
61
+ const source = readFileSync(join(SRC, "graph-ir.ts"), "utf8");
62
+ const key = source.split("\n").find((l) => l.includes("const key = `${edge.from}"));
63
+ expect(key, "the composite edge key moved; check its separator is still NUL").toBeDefined();
64
+ expect(key).toContain("\\u0000");
65
+
66
+ // And the escape really is the NUL character, not a look-alike. Built with
67
+ // fromCharCode so this file stays plain ASCII and does not fail its own gate.
68
+ const nul = String.fromCharCode(0);
69
+ expect(`a${nul}b`.charCodeAt(1)).toBe(0);
70
+ expect(`a${nul}b`.split(nul)).toEqual(["a", "b"]);
71
+ });
72
+ });
@@ -57,14 +57,33 @@ export type UnobservedReason =
57
57
  | "unsupported-kind"
58
58
  | "filtered";
59
59
 
60
- /** Every legal {@link UnobservedReason}, for validation and conformance checks. */
61
- export const UNOBSERVED_REASONS: readonly UnobservedReason[] = [
62
- "read-failed",
63
- "no-credentials",
64
- "no-binding",
65
- "unsupported-kind",
66
- "filtered",
67
- ];
60
+ /**
61
+ * Every legal {@link UnobservedReason}, for validation and conformance checks.
62
+ *
63
+ * Derived from a witness keyed off the union rather than written out beside it
64
+ * (chant#2366). A hand-maintained array is only ever checked for holding legal
65
+ * members, never for holding ALL of them, so a reason added to the type left
66
+ * the array silently short — `tsc` clean, every observation test green, and a
67
+ * value the type permits that `observation-conformance.ts` refuses. A lexicon
68
+ * could construct a value its own suite rejected.
69
+ *
70
+ * Keying a `Record` off the union makes the omission a compile error at the
71
+ * point of the omission. This is the construction #2365 applied to all four
72
+ * closed sets in `./behaviour.ts`; `BEHAVIOUR_UNPREDICTED_REASONS` derives from
73
+ * this very type, so that module was already protected against a change here
74
+ * while this module was not.
75
+ */
76
+ const UNOBSERVED_REASON_WITNESS: Record<UnobservedReason, true> = {
77
+ "read-failed": true,
78
+ "no-credentials": true,
79
+ "no-binding": true,
80
+ "unsupported-kind": true,
81
+ filtered: true,
82
+ };
83
+
84
+ export const UNOBSERVED_REASONS: readonly UnobservedReason[] = Object.keys(
85
+ UNOBSERVED_REASON_WITNESS,
86
+ ) as UnobservedReason[];
68
87
 
69
88
  /** True when `value` is a legal {@link UnobservedReason}. */
70
89
  export function isUnobservedReason(value: unknown): value is UnobservedReason {
@@ -0,0 +1,101 @@
1
+ import { describe, test, expect, vi, beforeEach } from "vitest";
2
+
3
+ /**
4
+ * chant#2337 — a converge tick says whether its record reached the remote.
5
+ *
6
+ * `convergeTick` ended with `await pushLifecycle().catch(() => undefined)`, the
7
+ * last instance of the idiom #2310 was filed about. The append is local-first
8
+ * and always lands, so the tick's own result was correct either way — what was
9
+ * missing is whether anyone else can see it.
10
+ *
11
+ * Softer than the gate's version of the same bug, which #2336 fixed: a lost
12
+ * tick record is an informational log rather than an approval, so nobody is
13
+ * left waiting on a fact they cannot see. It is still not success, and a
14
+ * converge loop reporting a clean tick while its ledger never leaves the
15
+ * machine is telling an operator something untrue.
16
+ */
17
+ const execMock = vi.fn();
18
+ vi.mock("node:child_process", () => ({
19
+ exec: (cmd: string, _opts: unknown, cb: (e: Error | null, r: { stdout: string; stderr: string }) => void) =>
20
+ cb(null, { stdout: execMock(cmd) as string, stderr: "" }),
21
+ }));
22
+
23
+ const pushLifecycle = vi.fn();
24
+ vi.mock("../../lifecycle/git", () => ({
25
+ fetchLifecycle: vi.fn(async () => undefined),
26
+ pushLifecycle: (...args: unknown[]) => pushLifecycle(...args) as Promise<boolean>,
27
+ }));
28
+
29
+ vi.mock("../../lifecycle/converge-ledger", async (orig) => {
30
+ const actual = (await orig()) as Record<string, unknown>;
31
+ return {
32
+ ...actual,
33
+ readConvergeLedger: vi.fn(async () => ({ records: [] })),
34
+ appendConvergeRecord: vi.fn(async (record: Record<string, unknown>) => ({
35
+ record: { ...record, id: "tick-1" },
36
+ })),
37
+ };
38
+ });
39
+
40
+ const { convergeTick } = await import("./converge");
41
+
42
+ /** A tick with nothing to do: no drift, no rules, so only the ledger write matters. */
43
+ async function tick(): Promise<Awaited<ReturnType<typeof convergeTick>>> {
44
+ return convergeTick({ opName: "demo", env: "prod", rules: [], dial: "report" } as never);
45
+ }
46
+
47
+ describe("a converge tick reports its push outcome (chant#2337)", () => {
48
+ beforeEach(() => {
49
+ vi.clearAllMocks();
50
+ execMock.mockImplementation((cmd: string) =>
51
+ cmd.includes("lifecycle plan")
52
+ ? JSON.stringify({ env: "prod", entries: [] })
53
+ : JSON.stringify([]),
54
+ );
55
+ });
56
+
57
+ test("a push that lands reports pushed, with no warning", async () => {
58
+ pushLifecycle.mockResolvedValue(true);
59
+
60
+ const result = await tick();
61
+
62
+ expect(result.pushed).toBe(true);
63
+ expect(result.pushWarning).toBeUndefined();
64
+ });
65
+
66
+ test("a REJECTED push is reported, not swallowed", async () => {
67
+ // Red before chant#2337: `.catch(() => undefined)` meant the tick returned
68
+ // the same shape whether or not the record left the machine.
69
+ pushLifecycle.mockRejectedValue(new Error("remote rejected: chant/lifecycle has moved"));
70
+
71
+ const result = await tick();
72
+
73
+ expect(result.pushed).toBe(false);
74
+ expect(result.pushWarning).toContain("remote rejected");
75
+ });
76
+
77
+ test("no remote configured is reported too, and says which it is", async () => {
78
+ // `pushLifecycle` returns false rather than throwing when there is no
79
+ // remote. That is not an error, but it is not a push either, and the two
80
+ // reasons are worth telling apart in the warning.
81
+ pushLifecycle.mockResolvedValue(false);
82
+
83
+ const result = await tick();
84
+
85
+ expect(result.pushed).toBe(false);
86
+ expect(result.pushWarning).toMatch(/no remote/i);
87
+ });
88
+
89
+ test("the tick's own findings are unaffected by a failed push", async () => {
90
+ // The append is local-first and always lands. A push failure must not make
91
+ // the tick misreport what it observed, or the fix would have traded one
92
+ // wrong answer for another.
93
+ pushLifecycle.mockRejectedValue(new Error("nope"));
94
+
95
+ const result = await tick();
96
+
97
+ expect(result.id).toBe("tick-1");
98
+ expect(result.drifted).toBe(false);
99
+ expect(typeof result.log).toBe("string");
100
+ });
101
+ });
@@ -104,6 +104,22 @@ export interface ConvergeTickResult {
104
104
  gated: number;
105
105
  /** The one human-readable summary line this tick produced. */
106
106
  log: string;
107
+ /**
108
+ * Whether this tick's ledger record reached the remote (chant#2337).
109
+ *
110
+ * The append is local-first and always lands, so the tick's own result is
111
+ * correct either way — this says whether anyone else can see it. Before
112
+ * this, the push was `.catch(() => undefined)` and the tick reported success
113
+ * whether or not the record left the machine, which is the last instance of
114
+ * the idiom #2310 was filed about.
115
+ *
116
+ * Softer than the gate's version of the same bug (#2336): a lost tick record
117
+ * is an informational log rather than an approval, so nobody is stranded
118
+ * waiting on a fact they cannot see. It is still not success.
119
+ */
120
+ pushed: boolean;
121
+ /** Why the record did not reach the remote, when `pushed` is false. */
122
+ pushWarning?: string;
107
123
  }
108
124
 
109
125
  function shellQuote(s: string): string {
@@ -442,7 +458,19 @@ export async function convergeTick(args: ConvergeTickArgs, signal?: AbortSignal)
442
458
  },
443
459
  log,
444
460
  });
445
- await pushLifecycle().catch(() => undefined);
461
+ // chant#2337 — reported rather than swallowed, the way #2336 does it for the
462
+ // gate. A push that did not land leaves a correct local record nobody else
463
+ // can read, and the caller is the only one positioned to say so.
464
+ let pushed = false;
465
+ let pushWarning: string | undefined;
466
+ try {
467
+ pushed = await pushLifecycle();
468
+ if (!pushed) {
469
+ pushWarning = "no remote is configured for chant/lifecycle — the tick record was recorded locally only";
470
+ }
471
+ } catch (err) {
472
+ pushWarning = err instanceof Error ? err.message : String(err);
473
+ }
446
474
 
447
475
  return {
448
476
  id: record.id,
@@ -455,5 +483,7 @@ export async function convergeTick(args: ConvergeTickArgs, signal?: AbortSignal)
455
483
  unobserved: record.summary.unobserved,
456
484
  adopted: record.summary.adopted,
457
485
  log: record.log,
486
+ pushed,
487
+ ...(pushWarning !== undefined ? { pushWarning } : {}),
458
488
  };
459
489
  }