@mmnto/cli 1.105.0 → 1.106.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/commands/mail-degraded-e4.test.d.ts +43 -0
- package/dist/commands/mail-degraded-e4.test.d.ts.map +1 -0
- package/dist/commands/mail-degraded-e4.test.js +317 -0
- package/dist/commands/mail-degraded-e4.test.js.map +1 -0
- package/dist/commands/run-compiled-rules.d.ts.map +1 -1
- package/dist/commands/run-compiled-rules.js +137 -5
- package/dist/commands/run-compiled-rules.js.map +1 -1
- package/dist/commands/run-compiled-rules.test.js +359 -1
- package/dist/commands/run-compiled-rules.test.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prop 309 P2 degraded-mode test pack — entry E4 (induced-failure).
|
|
3
|
+
*
|
|
4
|
+
* Boundary (verbatim scope): ECL/mail poll completeness. Induced faults →
|
|
5
|
+
* required behavior: "basename collision · own-broadcast exclusion · glob
|
|
6
|
+
* failure → surfaced or LOUDLY accounted, never silent narrowing."
|
|
7
|
+
*
|
|
8
|
+
* Pack contract — every induced-failure test carries three assertions:
|
|
9
|
+
* 1. The accounting fires (degraded output announces itself — never output
|
|
10
|
+
* indistinguishable from a clean poll).
|
|
11
|
+
* 2. The backstop throws. `totem mail` is a READ-ONLY path, so per
|
|
12
|
+
* ADR-115 § 2 the requirement is a DEGRADED/UNVERIFIED-style envelope in
|
|
13
|
+
* rendered output — the structured `warnings` channel (rendered as
|
|
14
|
+
* `Warning: …` lines by `formatTextResult`) IS that envelope — not a
|
|
15
|
+
* nonzero exit. Where this pack pins an exit code it is pinning the
|
|
16
|
+
* deliberate exit-0-with-envelope contract, never blessing silence.
|
|
17
|
+
* 3. Recovery/control: a positive control (healthy fixture poll renders
|
|
18
|
+
* everything it should) AND fault-removed-returns-to-green.
|
|
19
|
+
*
|
|
20
|
+
* Framing defects:
|
|
21
|
+
* - mmnto-ai/totem#2462 — "silent mail-suppression class": the own-broadcast
|
|
22
|
+
* exclusion at mail.ts (`if (opts.includeProcessed !== true && toLower ===
|
|
23
|
+
* 'broadcast' && selfLower.has(senderSeat)) continue;`) assumes SELF is one
|
|
24
|
+
* seat; post-dirs-union the default poll resolves ALL resident seats, so a
|
|
25
|
+
* resident sender's broadcast is suppressed from every seat's default
|
|
26
|
+
* render — silently, no accounting line.
|
|
27
|
+
* - mmnto-ai/totem#2509 — the accounting-line gap: the default render gives
|
|
28
|
+
* no account of items dropped by the exclusion; the required fix is a
|
|
29
|
+
* rendered accounting line when suppression drops files mark-absent for
|
|
30
|
+
* ≥1 resident non-sender seat. NOT yet implemented — the required-behavior
|
|
31
|
+
* assertion below is `test.fails` with this issue ref (repo rule:
|
|
32
|
+
* suppressions carry a ticket-ref comment), written against the REQUIRED
|
|
33
|
+
* behavior so it EXECUTES every run and flips the moment the fix lands
|
|
34
|
+
* (vitest 4 dropped `test.fixme`; `test.fails` is the in-repo vitest's
|
|
35
|
+
* expected-fail primitive — green while the defect stands, loudly red
|
|
36
|
+
* once the assertions pass and the marker must be removed).
|
|
37
|
+
*
|
|
38
|
+
* Filesystem-driven, hermetic (fresh tmp workspace per test), same fixture
|
|
39
|
+
* idiom as mail.test.ts. Assertions target the structured `MailPollResult`
|
|
40
|
+
* (the durable hook contract); the text formatter is human-only.
|
|
41
|
+
*/
|
|
42
|
+
export {};
|
|
43
|
+
//# sourceMappingURL=mail-degraded-e4.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mail-degraded-e4.test.d.ts","sourceRoot":"","sources":["../../src/commands/mail-degraded-e4.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG"}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prop 309 P2 degraded-mode test pack — entry E4 (induced-failure).
|
|
3
|
+
*
|
|
4
|
+
* Boundary (verbatim scope): ECL/mail poll completeness. Induced faults →
|
|
5
|
+
* required behavior: "basename collision · own-broadcast exclusion · glob
|
|
6
|
+
* failure → surfaced or LOUDLY accounted, never silent narrowing."
|
|
7
|
+
*
|
|
8
|
+
* Pack contract — every induced-failure test carries three assertions:
|
|
9
|
+
* 1. The accounting fires (degraded output announces itself — never output
|
|
10
|
+
* indistinguishable from a clean poll).
|
|
11
|
+
* 2. The backstop throws. `totem mail` is a READ-ONLY path, so per
|
|
12
|
+
* ADR-115 § 2 the requirement is a DEGRADED/UNVERIFIED-style envelope in
|
|
13
|
+
* rendered output — the structured `warnings` channel (rendered as
|
|
14
|
+
* `Warning: …` lines by `formatTextResult`) IS that envelope — not a
|
|
15
|
+
* nonzero exit. Where this pack pins an exit code it is pinning the
|
|
16
|
+
* deliberate exit-0-with-envelope contract, never blessing silence.
|
|
17
|
+
* 3. Recovery/control: a positive control (healthy fixture poll renders
|
|
18
|
+
* everything it should) AND fault-removed-returns-to-green.
|
|
19
|
+
*
|
|
20
|
+
* Framing defects:
|
|
21
|
+
* - mmnto-ai/totem#2462 — "silent mail-suppression class": the own-broadcast
|
|
22
|
+
* exclusion at mail.ts (`if (opts.includeProcessed !== true && toLower ===
|
|
23
|
+
* 'broadcast' && selfLower.has(senderSeat)) continue;`) assumes SELF is one
|
|
24
|
+
* seat; post-dirs-union the default poll resolves ALL resident seats, so a
|
|
25
|
+
* resident sender's broadcast is suppressed from every seat's default
|
|
26
|
+
* render — silently, no accounting line.
|
|
27
|
+
* - mmnto-ai/totem#2509 — the accounting-line gap: the default render gives
|
|
28
|
+
* no account of items dropped by the exclusion; the required fix is a
|
|
29
|
+
* rendered accounting line when suppression drops files mark-absent for
|
|
30
|
+
* ≥1 resident non-sender seat. NOT yet implemented — the required-behavior
|
|
31
|
+
* assertion below is `test.fails` with this issue ref (repo rule:
|
|
32
|
+
* suppressions carry a ticket-ref comment), written against the REQUIRED
|
|
33
|
+
* behavior so it EXECUTES every run and flips the moment the fix lands
|
|
34
|
+
* (vitest 4 dropped `test.fixme`; `test.fails` is the in-repo vitest's
|
|
35
|
+
* expected-fail primitive — green while the defect stands, loudly red
|
|
36
|
+
* once the assertions pass and the marker must be removed).
|
|
37
|
+
*
|
|
38
|
+
* Filesystem-driven, hermetic (fresh tmp workspace per test), same fixture
|
|
39
|
+
* idiom as mail.test.ts. Assertions target the structured `MailPollResult`
|
|
40
|
+
* (the durable hook contract); the text formatter is human-only.
|
|
41
|
+
*/
|
|
42
|
+
import * as os from 'node:os';
|
|
43
|
+
import * as path from 'node:path';
|
|
44
|
+
import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest';
|
|
45
|
+
// Mock node:fs so spyOn works in ESM — same pattern as core's session-id.test.ts.
|
|
46
|
+
// Passthrough spread: every call behaves identically to real fs until a test
|
|
47
|
+
// installs a targeted spy (and restores it in `finally`).
|
|
48
|
+
vi.mock('node:fs', async () => {
|
|
49
|
+
const actual = await vi.importActual('node:fs');
|
|
50
|
+
return { ...actual, default: actual };
|
|
51
|
+
});
|
|
52
|
+
import * as fs from 'node:fs';
|
|
53
|
+
import { cleanTmpDir } from '../test-utils.js';
|
|
54
|
+
import { pollMail, resolveMailExitCode } from './mail.js';
|
|
55
|
+
// ─── Fixture helpers ────────────────────────────────────
|
|
56
|
+
let tmpRoot;
|
|
57
|
+
let workspace;
|
|
58
|
+
function mkDir(p) {
|
|
59
|
+
fs.mkdirSync(p, { recursive: true });
|
|
60
|
+
return p;
|
|
61
|
+
}
|
|
62
|
+
/** `<repo>/.totem/orchestration/<senderAgent>/outbox/<file>.md` — same shape as mail.test.ts. */
|
|
63
|
+
function writeOutbox(repo, senderAgent, files) {
|
|
64
|
+
const outbox = path.join(workspace, repo, '.totem', 'orchestration', senderAgent, 'outbox');
|
|
65
|
+
mkDir(outbox);
|
|
66
|
+
for (const f of files) {
|
|
67
|
+
const content = [
|
|
68
|
+
'---',
|
|
69
|
+
`from: ${f.from ?? senderAgent}`,
|
|
70
|
+
`to: ${f.to}`,
|
|
71
|
+
`date: ${f.date ?? '2026-07-20T1700Z'}`,
|
|
72
|
+
`subject: ${f.subject ?? '(no subject)'}`,
|
|
73
|
+
'---',
|
|
74
|
+
'',
|
|
75
|
+
'Body text.',
|
|
76
|
+
'',
|
|
77
|
+
].join('\n');
|
|
78
|
+
fs.writeFileSync(path.join(outbox, f.name), content, 'utf-8');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The `totem` basename pre-resolves to [totem-claude, totem-gemini] via the
|
|
83
|
+
* cohort map; materializing the seat dirs puts resolution on the
|
|
84
|
+
* post-dirs-union DEFAULT path (source 'dirs', no env override) — the exact
|
|
85
|
+
* multi-seat condition of mmnto-ai/totem#2462: the default poll resolves ALL
|
|
86
|
+
* resident seats, and the own-broadcast exclusion assumes SELF is one seat.
|
|
87
|
+
*/
|
|
88
|
+
function selfRepoRootWithSeats(seats) {
|
|
89
|
+
const root = mkDir(path.join(workspace, 'totem'));
|
|
90
|
+
// The #2312 walk-up needs a `.totem` marker to anchor the repo root on.
|
|
91
|
+
const orchDir = mkDir(path.join(root, '.totem', 'orchestration'));
|
|
92
|
+
for (const seat of seats)
|
|
93
|
+
mkDir(path.join(orchDir, seat));
|
|
94
|
+
return root;
|
|
95
|
+
}
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-mail-e4-'));
|
|
98
|
+
workspace = mkDir(path.join(tmpRoot, 'workspace'));
|
|
99
|
+
});
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
cleanTmpDir(tmpRoot);
|
|
102
|
+
});
|
|
103
|
+
// ─── Fault 1: basename collision ────────────────────────
|
|
104
|
+
// Two seats converge on one addressed-inbound basename. Required: surface
|
|
105
|
+
// both or loudly account — never silently narrow/merge/drop one.
|
|
106
|
+
describe('E4 fault 1 — basename collision is surfaced AND loudly accounted', () => {
|
|
107
|
+
const NAME = '2026-07-20T1717Z-totem-claude-round-reply.md';
|
|
108
|
+
it('both colliding dispatches surface AND the collision warning names every seat path (regression lock)', () => {
|
|
109
|
+
// Cross-repo variant: distinct outbox-owner seats in distinct repos.
|
|
110
|
+
writeOutbox('totem-strategy', 'strategy-claude', [{ name: NAME, to: 'totem-claude' }]);
|
|
111
|
+
writeOutbox('liquid-city', 'lc-claude', [{ name: NAME, to: 'totem-claude' }]);
|
|
112
|
+
const result = pollMail({ repoRoot: selfRepoRootWithSeats([]), workspace, env: {} });
|
|
113
|
+
// Pack 1 — the accounting fires: the degraded envelope announces itself.
|
|
114
|
+
const collisionWarnings = result.warnings.filter((w) => w.startsWith('cross-sender basename collision'));
|
|
115
|
+
expect(collisionWarnings).toHaveLength(1);
|
|
116
|
+
expect(collisionWarnings[0]).toContain(NAME);
|
|
117
|
+
expect(collisionWarnings[0]).toContain('totem-strategy/strategy-claude');
|
|
118
|
+
expect(collisionWarnings[0]).toContain('liquid-city/lc-claude');
|
|
119
|
+
// Pack 2 — backstop for a read-only path (ADR-115 § 2): no silent
|
|
120
|
+
// narrowing — BOTH dispatches render; the warnings envelope (not an exit
|
|
121
|
+
// code) is the loud announcement. Load-bearing downstream: the same
|
|
122
|
+
// warnings channel reds the `ecl-gc --compact` A2.2 completeness gate
|
|
123
|
+
// (`warnings.length === 0` arm, mmnto-ai/totem#2309).
|
|
124
|
+
expect(result.mail).toHaveLength(2);
|
|
125
|
+
expect(result.mail.map((m) => m.file)).toEqual([NAME, NAME]);
|
|
126
|
+
});
|
|
127
|
+
it('positive control + fault-removed: distinct basenames render clean with zero warnings', () => {
|
|
128
|
+
writeOutbox('totem-strategy', 'strategy-claude', [
|
|
129
|
+
{ name: '2026-07-20T1717Z-totem-claude-round-reply.md', to: 'totem-claude' },
|
|
130
|
+
]);
|
|
131
|
+
writeOutbox('liquid-city', 'lc-claude', [
|
|
132
|
+
{ name: '2026-07-20T1718Z-totem-claude-lane-note.md', to: 'totem-claude' },
|
|
133
|
+
]);
|
|
134
|
+
const result = pollMail({ repoRoot: selfRepoRootWithSeats([]), workspace, env: {} });
|
|
135
|
+
expect(result.mail).toHaveLength(2);
|
|
136
|
+
expect(result.warnings).toEqual([]);
|
|
137
|
+
expect(resolveMailExitCode(result)).toBe(0);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
// ─── Fault 2: own-broadcast exclusion ───────────────────
|
|
141
|
+
// A broadcast from a RESIDENT sender seat, unread for a non-sender resident
|
|
142
|
+
// seat. Required: render it, or print an accounting line naming the
|
|
143
|
+
// suppression (mmnto-ai/totem#2509 — unimplemented today).
|
|
144
|
+
describe('E4 fault 2 — own-broadcast suppression is never silent (mmnto-ai/totem#2462, #2509)', () => {
|
|
145
|
+
const BCAST = '2026-07-20T2326Z-broadcast-cohort-note.md';
|
|
146
|
+
const RESIDENT_SEATS = ['totem-claude', 'totem-gemini'];
|
|
147
|
+
/** Default-resolution poll (no env override) over the two-seat fixture. */
|
|
148
|
+
function pollTwoSeat(opts = {}) {
|
|
149
|
+
return pollMail({
|
|
150
|
+
repoRoot: selfRepoRootWithSeats(RESIDENT_SEATS),
|
|
151
|
+
workspace,
|
|
152
|
+
env: {},
|
|
153
|
+
...opts,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
// RED ROW — required behavior, unimplemented. `test.fails` carries the
|
|
157
|
+
// ticket ref per the repo rule (suppressions need a ticket-ref comment);
|
|
158
|
+
// the body is written against the REQUIRED behavior so it EXECUTES every
|
|
159
|
+
// run (an expected failure while #2509 stands) and flips the run loudly
|
|
160
|
+
// red the moment the fix lands and the marker must be removed. (vitest 4
|
|
161
|
+
// dropped `test.fixme`; `test.fails` is this repo's vitest's expected-fail
|
|
162
|
+
// primitive.)
|
|
163
|
+
//
|
|
164
|
+
// Today's actual behavior (regression-documented, NOT locked): mail.ts's
|
|
165
|
+
// own-broadcast exclusion hits `continue` with no warning and no accounting
|
|
166
|
+
// line, so the default two-seat poll returns `mail: []`, `warnings: []` —
|
|
167
|
+
// rendered as "No unread mail addressed to totem-claude, totem-gemini or
|
|
168
|
+
// broadcast." That is the #2462 silent-suppression class: the dispatch is
|
|
169
|
+
// mark-absent for the resident NON-sender seat (totem-gemini), yet the
|
|
170
|
+
// render is indistinguishable from a genuinely clean inbox.
|
|
171
|
+
test.fails('REQUIRED (mmnto-ai/totem#2509): a resident-sender broadcast unread for a non-sender seat is rendered OR loudly accounted — never silently dropped', () => {
|
|
172
|
+
writeOutbox('totem', 'totem-claude', [
|
|
173
|
+
{ name: BCAST, to: 'broadcast', subject: 'cohort note' },
|
|
174
|
+
]);
|
|
175
|
+
// No sanity assertion in this body, deliberately: `test.fails` is
|
|
176
|
+
// satisfied by ANY failure, so an extra assertion here would let an
|
|
177
|
+
// unrelated seat-resolution regression keep the marker green without
|
|
178
|
+
// the #2509 assertion ever running (CR round-1 catch). The two-seat
|
|
179
|
+
// resolution is asserted by the positive control below on the same
|
|
180
|
+
// fixture shape, where a regression fails loudly as a real failure.
|
|
181
|
+
const result = pollTwoSeat();
|
|
182
|
+
// Pack 1 + 2 — accounting fires / degraded envelope: the poll must
|
|
183
|
+
// either render the dispatch (to the non-sender seat) or name the
|
|
184
|
+
// suppression in the warnings channel — the read-only path's
|
|
185
|
+
// DEGRADED-style announcement per ADR-115 § 2. The exact accounting
|
|
186
|
+
// wording is #2509's to define; the assertion keys on the suppressed
|
|
187
|
+
// basename so any honest accounting line satisfies it.
|
|
188
|
+
const renderedToNonSender = result.mail.some((m) => m.file === BCAST);
|
|
189
|
+
const accounted = result.warnings.some((w) => w.includes(BCAST));
|
|
190
|
+
expect(renderedToNonSender || accounted).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
it('positive control: a NON-resident sender broadcast surfaces to the same two-seat default poll', () => {
|
|
193
|
+
// Healthy-fixture control: the same poll renders everything it should
|
|
194
|
+
// when the sender is NOT a resident seat (the exclusion arm cannot fire).
|
|
195
|
+
writeOutbox('totem-strategy', 'strategy-claude', [
|
|
196
|
+
{ name: BCAST, to: 'broadcast', subject: 'theirs' },
|
|
197
|
+
]);
|
|
198
|
+
const result = pollTwoSeat();
|
|
199
|
+
expect(result.selfAgents.agents).toEqual(['totem-claude', 'totem-gemini']);
|
|
200
|
+
expect(result.mail).toHaveLength(1);
|
|
201
|
+
expect(result.mail[0].file).toBe(BCAST);
|
|
202
|
+
expect(result.warnings).toEqual([]);
|
|
203
|
+
});
|
|
204
|
+
it('fault-removed control: removing the sender from the resident set surfaces the identical broadcast', () => {
|
|
205
|
+
// Same dispatch, same fixture — but SELF is restricted to the non-sender
|
|
206
|
+
// seat, so the exclusion arm (`selfLower.has(senderSeat)`) cannot fire.
|
|
207
|
+
// Proves the dispatch is deliverable and the suppression is specifically
|
|
208
|
+
// the resident-sender arm, not a fixture artifact.
|
|
209
|
+
writeOutbox('totem', 'totem-claude', [{ name: BCAST, to: 'broadcast' }]);
|
|
210
|
+
const result = pollMail({
|
|
211
|
+
repoRoot: selfRepoRootWithSeats(RESIDENT_SEATS),
|
|
212
|
+
workspace,
|
|
213
|
+
env: { TOTEM_SELF_AGENT: 'totem-gemini' },
|
|
214
|
+
});
|
|
215
|
+
expect(result.mail).toHaveLength(1);
|
|
216
|
+
expect(result.mail[0].file).toBe(BCAST);
|
|
217
|
+
});
|
|
218
|
+
it('over-correction guard: a single-seat poll where SELF IS the sender still excludes its own broadcast (mmnto-ai/totem#2364, intended)', () => {
|
|
219
|
+
// The #2509 fix must not over-correct into surfacing a seat's OWN
|
|
220
|
+
// broadcast back to the sender itself — that exclusion is the shipped
|
|
221
|
+
// #2364 behavior. Lock it so the accounting-line fix stays scoped to
|
|
222
|
+
// non-sender seats.
|
|
223
|
+
writeOutbox('totem', 'totem-claude', [{ name: BCAST, to: 'broadcast' }]);
|
|
224
|
+
const result = pollMail({
|
|
225
|
+
repoRoot: selfRepoRootWithSeats(RESIDENT_SEATS),
|
|
226
|
+
workspace,
|
|
227
|
+
env: { TOTEM_SELF_AGENT: 'totem-claude' },
|
|
228
|
+
});
|
|
229
|
+
expect(result.mail).toEqual([]);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
// ─── Fault 3: glob/scan failure ─────────────────────────
|
|
233
|
+
// The scan resolves zero repos/dirs while dispatches exist on disk.
|
|
234
|
+
// Required: refuse a silently-clean inbox over an incomplete scan — announce
|
|
235
|
+
// the degradation.
|
|
236
|
+
describe('E4 fault 3 — scan failure announces itself, never a silent clean inbox', () => {
|
|
237
|
+
const MAIL_A = '2026-07-20T1800Z-totem-claude-from-strategy.md';
|
|
238
|
+
const MAIL_B = '2026-07-20T1801Z-totem-claude-from-lc.md';
|
|
239
|
+
function writeHealthyFixture() {
|
|
240
|
+
writeOutbox('totem-strategy', 'strategy-claude', [{ name: MAIL_A, to: 'totem-claude' }]);
|
|
241
|
+
writeOutbox('liquid-city', 'lc-claude', [{ name: MAIL_B, to: 'totem-claude' }]);
|
|
242
|
+
}
|
|
243
|
+
it('workspace scan root unresolvable (ENOTDIR) with dispatches on disk → degraded envelope, empty mail, exit 0', () => {
|
|
244
|
+
writeHealthyFixture();
|
|
245
|
+
// Positive control (pack 3): the healthy fixture renders everything.
|
|
246
|
+
const healthy = pollMail({ repoRoot: selfRepoRootWithSeats([]), workspace, env: {} });
|
|
247
|
+
expect(healthy.mail).toHaveLength(2);
|
|
248
|
+
expect(healthy.warnings).toEqual([]);
|
|
249
|
+
// Induce: point the scan root at a FILE — fs.existsSync passes,
|
|
250
|
+
// readdirSync throws ENOTDIR, enumerateOutboxes resolves ZERO repos while
|
|
251
|
+
// the dispatches exist on disk under the real workspace. No mocks: the
|
|
252
|
+
// real errno drives the real catch arm.
|
|
253
|
+
const brokenRoot = path.join(tmpRoot, 'workspace-as-file');
|
|
254
|
+
fs.writeFileSync(brokenRoot, 'not a directory', 'utf-8');
|
|
255
|
+
const degraded = pollMail({
|
|
256
|
+
repoRoot: selfRepoRootWithSeats([]),
|
|
257
|
+
workspace: brokenRoot,
|
|
258
|
+
env: {},
|
|
259
|
+
});
|
|
260
|
+
// Pack 1 — the accounting fires: the scan failure is named in the
|
|
261
|
+
// warnings channel, so the rendered output carries `Warning: workspace
|
|
262
|
+
// scan failed: …` lines and is distinguishable from a clean poll.
|
|
263
|
+
expect(degraded.warnings.some((w) => w.startsWith('workspace scan failed'))).toBe(true);
|
|
264
|
+
// Pack 2 — backstop for a read-only path (ADR-115 § 2): the envelope is
|
|
265
|
+
// the announcement; the exit code stays 0 by the deliberate
|
|
266
|
+
// `resolveMailExitCode` contract (only an UNRESOLVED self is exit 2 — a
|
|
267
|
+
// scan failure still resolved self, and the warnings channel is the
|
|
268
|
+
// DEGRADED envelope). Pinning exit 0 here pins that contract, not the
|
|
269
|
+
// silence. The poll also asserts nothing it cannot back: no mail entries
|
|
270
|
+
// are fabricated over the incomplete scan.
|
|
271
|
+
expect(degraded.mail).toEqual([]);
|
|
272
|
+
expect(degraded.scanned).toBe(0);
|
|
273
|
+
expect(resolveMailExitCode(degraded)).toBe(0);
|
|
274
|
+
// Fault-removed-returns-to-green (pack 3): repoint at the healthy scan
|
|
275
|
+
// root and the same fixture polls clean.
|
|
276
|
+
const recovered = pollMail({ repoRoot: selfRepoRootWithSeats([]), workspace, env: {} });
|
|
277
|
+
expect(recovered.mail).toHaveLength(2);
|
|
278
|
+
expect(recovered.warnings).toEqual([]);
|
|
279
|
+
});
|
|
280
|
+
it('one repo orchestration scan fails (EACCES) → failure named, healthy sibling still surfaces; fault-removed returns to green', () => {
|
|
281
|
+
writeHealthyFixture();
|
|
282
|
+
// Induce: targeted readdirSync failure on ONE repo's orchestration dir —
|
|
283
|
+
// the exact path mail.ts scans (`<workspace>/totem-strategy/.totem/
|
|
284
|
+
// orchestration`). Path-conditional passthrough keeps every other fs call
|
|
285
|
+
// real. (fs spy pattern: core session-id.test.ts.)
|
|
286
|
+
const brokenMarker = path.join('totem-strategy', '.totem', 'orchestration');
|
|
287
|
+
const realReaddirSync = fs.readdirSync;
|
|
288
|
+
const spy = vi.spyOn(fs, 'readdirSync').mockImplementation((target, options) => {
|
|
289
|
+
if (String(target).endsWith(brokenMarker)) {
|
|
290
|
+
const err = new Error(`EACCES: permission denied, scandir '${String(target)}'`);
|
|
291
|
+
err.code = 'EACCES';
|
|
292
|
+
throw err;
|
|
293
|
+
}
|
|
294
|
+
return realReaddirSync(target, options);
|
|
295
|
+
});
|
|
296
|
+
try {
|
|
297
|
+
const degraded = pollMail({ repoRoot: selfRepoRootWithSeats([]), workspace, env: {} });
|
|
298
|
+
// Pack 1 — the accounting fires: the failed repo is NAMED.
|
|
299
|
+
expect(degraded.warnings.some((w) => w.startsWith('orchestration scan failed (totem-strategy)'))).toBe(true);
|
|
300
|
+
// Pack 2 — backstop: skip-don't-abort, never silent narrowing — the
|
|
301
|
+
// healthy sibling repo's dispatch still surfaces, and the failed repo's
|
|
302
|
+
// absence is announced (above) rather than folded into a fake-clean
|
|
303
|
+
// inbox. Exit stays 0 per the read-only degraded-envelope contract.
|
|
304
|
+
expect(degraded.mail.map((m) => m.file)).toEqual([MAIL_B]);
|
|
305
|
+
expect(resolveMailExitCode(degraded)).toBe(0);
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
spy.mockRestore();
|
|
309
|
+
}
|
|
310
|
+
// Pack 3 — fault-removed-returns-to-green: with the scan healthy again,
|
|
311
|
+
// BOTH dispatches surface with zero warnings.
|
|
312
|
+
const recovered = pollMail({ repoRoot: selfRepoRootWithSeats([]), workspace, env: {} });
|
|
313
|
+
expect(recovered.mail.map((m) => m.file).sort()).toEqual([MAIL_A, MAIL_B].sort());
|
|
314
|
+
expect(recovered.warnings).toEqual([]);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
//# sourceMappingURL=mail-degraded-e4.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mail-degraded-e4.test.js","sourceRoot":"","sources":["../../src/commands/mail-degraded-e4.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"run-compiled-rules.d.ts","sourceRoot":"","sources":["../../src/commands/run-compiled-rules.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,YAAY,EAEZ,kBAAkB,EAClB,WAAW,EACX,WAAW,EACX,YAAY,EACZ,SAAS,EACV,MAAM,cAAc,CAAC;AAEtB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAIhD;;;;;;;;;;GAUG;AACH,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,oEAAoE;IACpE,QAAQ,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,WAAW,CAAC;CACnB;AAED,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,YAAY,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,yEAAyE;IACzE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;;OAQG;IACH,MAAM,CAAC,EAAE,IAAI,CAAC,WAAW,EAAE,SAAS,CAAC,CAAC;IACtC,iDAAiD;IACjD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,WAAW,CAAC;IAC/B;;;;;;;;;OASG;IACH,YAAY,CAAC,EAAE,WAAW,CAAC;CAC5B;AAED,MAAM,WAAW,sBAAsB;IACrC,UAAU,EAAE,SAAS,EAAE,CAAC;IACxB,6EAA6E;IAC7E,QAAQ,EAAE,YAAY,EAAE,CAAC;IACzB,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf;;;;;OAKG;IACH,aAAa,EAAE,kBAAkB,EAAE,CAAC;IACpC;;;;OAIG;IACH,gBAAgB,EAAE,sBAAsB,EAAE,CAAC;CAC5C;
|
|
1
|
+
{"version":3,"file":"run-compiled-rules.d.ts","sourceRoot":"","sources":["../../src/commands/run-compiled-rules.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,YAAY,EAEZ,kBAAkB,EAClB,WAAW,EACX,WAAW,EACX,YAAY,EACZ,SAAS,EACV,MAAM,cAAc,CAAC;AAEtB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAIhD;;;;;;;;;;GAUG;AACH,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,oEAAoE;IACpE,QAAQ,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,WAAW,CAAC;CACnB;AAED,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,YAAY,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,yEAAyE;IACzE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;;OAQG;IACH,MAAM,CAAC,EAAE,IAAI,CAAC,WAAW,EAAE,SAAS,CAAC,CAAC;IACtC,iDAAiD;IACjD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,WAAW,CAAC;IAC/B;;;;;;;;;OASG;IACH,YAAY,CAAC,EAAE,WAAW,CAAC;CAC5B;AAED,MAAM,WAAW,sBAAsB;IACrC,UAAU,EAAE,SAAS,EAAE,CAAC;IACxB,6EAA6E;IAC7E,QAAQ,EAAE,YAAY,EAAE,CAAC;IACzB,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf;;;;;OAKG;IACH,aAAa,EAAE,kBAAkB,EAAE,CAAC;IACpC;;;;OAIG;IACH,gBAAgB,EAAE,sBAAsB,EAAE,CAAC;CAC5C;AA6OD;;;GAGG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,sBAAsB,CAAC,CAwoBjC"}
|
|
@@ -82,6 +82,130 @@ async function countLessonCorpusFiles(config, projectRoot) {
|
|
|
82
82
|
}
|
|
83
83
|
return matched.size;
|
|
84
84
|
}
|
|
85
|
+
// ─── Target-mismatch derivation (mmnto-ai/totem-strategy#971, Prop 309 Class 7) ─
|
|
86
|
+
/**
|
|
87
|
+
* Hard-error when a loaded AST rule can never execute because NONE of its
|
|
88
|
+
* declared `fileGlobs` targets an extension the engine has a registered
|
|
89
|
+
* Tree-sitter language for (mmnto-ai/totem-strategy#971, Prop 309 Class 7).
|
|
90
|
+
*
|
|
91
|
+
* The failure this closes: `resolveAstGrepLangs` (ast-grep-query.ts) silently
|
|
92
|
+
* falls back to `Lang.Tsx` when no glob resolves, so a target-mismatched rule
|
|
93
|
+
* looks healthy at load. It then detonates at RUN time — `rule-engine.ts`'s
|
|
94
|
+
* language check throws `TotemParseError` the first time a diff happens to
|
|
95
|
+
* contain a file the rule's globs match, and that throw escapes the per-file
|
|
96
|
+
* loop and aborts the WHOLE lint. The observed specimen was an `ast-grep` rule
|
|
97
|
+
* whose four globs all pointed at `.json` files, which no language registration
|
|
98
|
+
* covers. Diff-dependent detonation is the worst property: the gate is green on
|
|
99
|
+
* every run until the one run where it isn't.
|
|
100
|
+
*
|
|
101
|
+
* The guard converts that into a deterministic load-time failure. It runs on
|
|
102
|
+
* EVERY invocation regardless of the diff, and — unlike the corpus-bearing gate
|
|
103
|
+
* above — it is deliberately NOT gated on `options.config`. That opt-out exists
|
|
104
|
+
* because zero-rules is genuinely ambiguous (empty-corpus repo vs disarmed gate)
|
|
105
|
+
* and the config is the only discriminator. Target-mismatch is unambiguous from
|
|
106
|
+
* the manifest alone: no discriminator is needed, so there is nothing to opt out
|
|
107
|
+
* of and `shield estimate` gets the same protection. The one escape is the
|
|
108
|
+
* pre-existing `--ast-parse-mode lenient` operator flag (see the branch below),
|
|
109
|
+
* which is opt-in and leaves the strict default fail-closed.
|
|
110
|
+
*
|
|
111
|
+
* Scope matches the abort seam exactly. `rule-engine.ts` searches the
|
|
112
|
+
* `allAstRules` union (Tree-sitter `'ast'` ∪ `'ast-grep'`) for a rule whose
|
|
113
|
+
* globs claim the unparseable file, so BOTH engines are validated here.
|
|
114
|
+
*
|
|
115
|
+
* Two shapes stay permitted:
|
|
116
|
+
*
|
|
117
|
+
* - **Globless rules** (`fileGlobs` absent or empty). The documented
|
|
118
|
+
* `Lang.Tsx` fallback is their intended behavior — many pre-1.16 rules ship
|
|
119
|
+
* unscoped — and `rule-engine.ts` only throws for a rule with non-empty
|
|
120
|
+
* `fileGlobs`, so a globless rule cannot arm the landmine.
|
|
121
|
+
* - **Extensionless positive globs** — a recursive `src/**`, a bare-star
|
|
122
|
+
* leaf, or a brace-set leaf like `.{ts,json}` (glob literals are omitted
|
|
123
|
+
* here because a star-slash pair would close this comment).
|
|
124
|
+
* `TRAILING_EXT_RE` extracts nothing from these, but at run time they can
|
|
125
|
+
* and do match `.ts` files and dispatch correctly. Absence of a trailing
|
|
126
|
+
* extension is absence of evidence, not proof of mismatch — treating it as
|
|
127
|
+
* unresolvable would hard-error legitimately broad rules. A rule is only
|
|
128
|
+
* condemned when EVERY positive glob names a concrete extension and NOT ONE
|
|
129
|
+
* of those extensions resolves.
|
|
130
|
+
*
|
|
131
|
+
* Lifecycle status needs no filtering here: `loadCompiledRules` has already
|
|
132
|
+
* dropped `archived` / `untested-against-codebase` / `pending-verification`
|
|
133
|
+
* (compiler.ts), so an archived specimen never reaches this function — which is
|
|
134
|
+
* exactly why archiving is the offered remedy.
|
|
135
|
+
*
|
|
136
|
+
* Pack-contributed languages are honored: CLI commands call `bootstrapEngine`
|
|
137
|
+
* (→ `loadInstalledPacks`) before this point, so a pack that registers `.rs`
|
|
138
|
+
* has already populated the registry when the check reads it.
|
|
139
|
+
*/
|
|
140
|
+
async function assertRulesCanTargetTheirGlobs(rules, astParseMode, onWarn) {
|
|
141
|
+
const { extensionToLanguage, registeredExtensions, sanitizeForTerminal, TotemError, TRAILING_EXT_RE, } = await import('@mmnto/totem');
|
|
142
|
+
const offenders = [];
|
|
143
|
+
for (const rule of rules) {
|
|
144
|
+
if (rule.engine !== 'ast-grep' && rule.engine !== 'ast')
|
|
145
|
+
continue;
|
|
146
|
+
const globs = rule.fileGlobs;
|
|
147
|
+
if (!globs || globs.length === 0)
|
|
148
|
+
continue;
|
|
149
|
+
// Negations describe files the rule must NOT match, so they never carry a
|
|
150
|
+
// dispatch target — same skip `resolveAstGrepLangs` performs.
|
|
151
|
+
const positive = globs.filter((g) => typeof g === 'string' && !g.startsWith('!'));
|
|
152
|
+
// An all-negation glob list matches every file (see the zero-match
|
|
153
|
+
// derivation below, where `positive.length === 0` matches unconditionally),
|
|
154
|
+
// so it necessarily reaches resolvable files. Permitted.
|
|
155
|
+
if (positive.length === 0)
|
|
156
|
+
continue;
|
|
157
|
+
const extensions = [];
|
|
158
|
+
let resolvable = false;
|
|
159
|
+
for (const glob of positive) {
|
|
160
|
+
const match = glob.match(TRAILING_EXT_RE);
|
|
161
|
+
if (!match) {
|
|
162
|
+
// Extensionless positive glob — unprovable, therefore permitted.
|
|
163
|
+
resolvable = true;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
const ext = `.${match[1].toLowerCase()}`;
|
|
167
|
+
if (extensionToLanguage(ext)) {
|
|
168
|
+
resolvable = true;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
if (!extensions.includes(ext))
|
|
172
|
+
extensions.push(ext);
|
|
173
|
+
}
|
|
174
|
+
if (!resolvable)
|
|
175
|
+
offenders.push({ rule, globs: positive, extensions });
|
|
176
|
+
}
|
|
177
|
+
if (offenders.length === 0)
|
|
178
|
+
return;
|
|
179
|
+
// Deterministic detail line per offender — corpus order, every field the
|
|
180
|
+
// operator needs to act without opening the manifest.
|
|
181
|
+
const detail = offenders
|
|
182
|
+
.map((o) => `'${o.rule.lessonHash}' (${o.rule.lessonHeading}) engine '${o.rule.engine}' fileGlobs [${o.globs.join(', ')}] → unregistered extension(s) ${o.extensions.join(', ')}`)
|
|
183
|
+
.join('; ');
|
|
184
|
+
// Manifest-derived fields (lessonHash, lessonHeading, fileGlobs) are
|
|
185
|
+
// repo-controlled text reaching operator terminals and CI logs — sanitize the
|
|
186
|
+
// whole rendered summary so a corrupted or hostile manifest cannot inject
|
|
187
|
+
// control sequences through the guard's own report. The remedy line is
|
|
188
|
+
// engine-derived only and needs no sanitizing.
|
|
189
|
+
const summary = sanitizeForTerminal(`${offenders.length} active AST rule(s) declare fileGlobs whose extensions have no registered Tree-sitter language, so they can never match and instead abort the entire lint on first contact with a file they claim — ${detail}.`);
|
|
190
|
+
// Three remedies, deliberately un-ranked: the manifest alone cannot tell a
|
|
191
|
+
// permanently-broken rule (`.json`, which no pack will ever register) from an
|
|
192
|
+
// incomplete environment (`.rs`, which a pack provides but which has not been
|
|
193
|
+
// installed/synced here). Leading with "archive it" would misdiagnose the
|
|
194
|
+
// second case, which `rule-engine.ts`'s STALE_MANIFEST path handles well.
|
|
195
|
+
const remedy = `Install or sync the pack that provides the language ('totem sync --packs-only'), archive each rule (set status: 'archived'), or correct its fileGlobs/engine so at least one positive glob targets a registered extension. Registered now: ${registeredExtensions().join(', ')}.`;
|
|
196
|
+
if (astParseMode === 'lenient') {
|
|
197
|
+
// Honor the operator escape (mmnto-ai/totem#1982). `--ast-parse-mode lenient`
|
|
198
|
+
// already means "an AST language the engine cannot parse degrades this run
|
|
199
|
+
// instead of failing it", and a target-mismatched rule is exactly that class
|
|
200
|
+
// — it simply fails at load rather than at dispatch. Without this branch the
|
|
201
|
+
// guard would REMOVE the only escape for a repo whose rules target a pack
|
|
202
|
+
// language it has not installed, leaving the operator no way to lint at all.
|
|
203
|
+
// The rules stay inert either way, so lenient loses no enforcement it had.
|
|
204
|
+
onWarn(`AST rules target-mismatched (--ast-parse-mode lenient): ${summary} ${remedy}`);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
throw new TotemError('LINT_LESSONS_FAILED', `Enforcement disarmed: ${summary}`, remedy);
|
|
208
|
+
}
|
|
85
209
|
// ─── Core logic ─────────────────────────────────────
|
|
86
210
|
/**
|
|
87
211
|
* Compiled-rules execution engine used by `totem lint`. Loads rules, extracts additions, enriches
|
|
@@ -163,6 +287,17 @@ export async function runCompiledRules(options) {
|
|
|
163
287
|
log.info(tag, `No compiled rules at ${totemDir}/${COMPILED_RULES_FILE} — skipping (empty-corpus repo). Run 'totem lesson compile' once you have lessons.`);
|
|
164
288
|
return emptyResult;
|
|
165
289
|
}
|
|
290
|
+
// mmnto-ai/totem#1982. Resolved here rather than at the AST dispatch site
|
|
291
|
+
// because the rule-load guard below shares the mode (CLI flag > env > strict).
|
|
292
|
+
const effectiveAstParseMode = astParseMode ??
|
|
293
|
+
// totem-context: reading Node's process.env (cleaned by the runtime), not parsing a custom .env file; CRLF/quote-stripping rule doesn't apply.
|
|
294
|
+
(process.env['TOTEM_LINT_AST_PARSE_MODE'] === 'lenient' ? 'lenient' : 'strict');
|
|
295
|
+
// Rule-LOAD validation (mmnto-ai/totem-strategy#971, Prop 309 Class 7). Runs
|
|
296
|
+
// before any diff is touched so a rule whose engine cannot process ANY of its
|
|
297
|
+
// declared globs fails on every run, not on the unlucky run whose diff happens
|
|
298
|
+
// to contain a file it claims. Ordered AFTER the corpus-bearing block so a
|
|
299
|
+
// missing/unloadable manifest still reports its own (more fundamental) cause.
|
|
300
|
+
await assertRulesCanTargetTheirGlobs(rules, effectiveAstParseMode, (msg) => log.warn(tag, msg));
|
|
166
301
|
log.info(tag, `Running ${rules.length} rules (zero LLM)...`);
|
|
167
302
|
// Extract additions, exclude compiled rules file, export targets, and binary files
|
|
168
303
|
const BINARY_EXTENSIONS = new Set([
|
|
@@ -297,11 +432,8 @@ export async function runCompiledRules(options) {
|
|
|
297
432
|
const astRules = rules.filter((r) => r.engine === 'ast' || r.engine === 'ast-grep');
|
|
298
433
|
let astViolations = [];
|
|
299
434
|
const astParseFailures = [];
|
|
300
|
-
// mmnto-ai/totem#1982
|
|
301
|
-
//
|
|
302
|
-
const effectiveAstParseMode = astParseMode ??
|
|
303
|
-
// totem-context: reading Node's process.env (cleaned by the runtime), not parsing a custom .env file; CRLF/quote-stripping rule doesn't apply.
|
|
304
|
-
(process.env['TOTEM_LINT_AST_PARSE_MODE'] === 'lenient' ? 'lenient' : 'strict');
|
|
435
|
+
// `effectiveAstParseMode` (mmnto-ai/totem#1982) is resolved above the rule-load
|
|
436
|
+
// guard, which shares the same operator escape.
|
|
305
437
|
if (astRules.length > 0) {
|
|
306
438
|
log.dim(tag, `Running ${astRules.length} AST rule(s)...`);
|
|
307
439
|
try {
|