@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.
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/handlers/init.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +16 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +10 -0
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp-config.d.ts +47 -0
- package/dist/cli/mcp-config.d.ts.map +1 -0
- package/dist/cli/registry.d.ts +12 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +24 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +0 -5
- package/dist/discovery/sandbox/fork.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +29 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-origin.d.ts +77 -0
- package/dist/lifecycle/gate-origin.d.ts.map +1 -0
- package/dist/observation.d.ts +0 -1
- package/dist/observation.d.ts.map +1 -1
- package/dist/op/activities/converge.d.ts +16 -0
- package/dist/op/activities/converge.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/core.ts +1 -1
- package/src/cli/commands/doctor.ts +18 -6
- package/src/cli/commands/init.ts +9 -68
- package/src/cli/commands/update.ts +9 -0
- package/src/cli/handlers/init.ts +1 -0
- package/src/cli/handlers/operator.ts +48 -1
- package/src/cli/main.ts +9 -0
- package/src/cli/mcp/docs-parity.test.ts +133 -0
- package/src/cli/mcp/op-approve-origin.test.ts +70 -0
- package/src/cli/mcp/op-tools.ts +18 -4
- package/src/cli/mcp/server.ts +16 -1
- package/src/cli/mcp-config.test.ts +168 -0
- package/src/cli/mcp-config.ts +76 -0
- package/src/cli/registry.ts +12 -0
- package/src/discovery/fold-executing-mode.test.ts +115 -0
- package/src/discovery/fold-import.ts +87 -14
- package/src/discovery/fold-no-invoke-declared.test.ts +118 -0
- package/src/discovery/sandbox/fork-diagnostic.test.ts +117 -0
- package/src/discovery/sandbox/fork.ts +92 -6
- package/src/graph-ir.ts +1 -1
- package/src/graph-layout.ts +0 -0
- package/src/lifecycle/gate-ledger.ts +39 -1
- package/src/lifecycle/gate-origin.test.ts +93 -0
- package/src/lifecycle/gate-origin.ts +113 -0
- package/src/meta/source-is-text.test.ts +72 -0
- package/src/observation.ts +27 -8
- package/src/op/activities/converge-push.test.ts +101 -0
- 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
|
+
});
|
package/src/observation.ts
CHANGED
|
@@ -57,14 +57,33 @@ export type UnobservedReason =
|
|
|
57
57
|
| "unsupported-kind"
|
|
58
58
|
| "filtered";
|
|
59
59
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
}
|