dsh-context-memory 0.7.1 → 0.9.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/lib/guard.js CHANGED
@@ -9,131 +9,597 @@
9
9
  *
10
10
  * Recognition is narrow on purpose. Only statement shapes whose checkable predicate is
11
11
  * unambiguous get a guard; everything else stays uncheckable and says so. Emitting an
12
- * approximate command would produce a check that exits 0 while verifying nothing, and
13
- * every later "verified" would be a lie.
12
+ * approximate check would produce one that passes while verifying nothing, and every
13
+ * later "verified" would be a lie.
14
14
  *
15
15
  * There is deliberately **no** shape that runs a command taken from memory text. A
16
- * memory can be written by the agent itself, so treating its content as an executable
17
- * command is an injection path — the templates below only ever interpolate quoted file
18
- * paths and literal search strings.
16
+ * memory can be written by the agent itself, so treating its content as executable is
17
+ * an injection path. That is why the checks below are evaluated in-process against the
18
+ * filesystem rather than handed to a shell: the statement supplies operands (a path, a
19
+ * search string) and never a program, an argument list, or shell syntax.
20
+ *
21
+ * **Why not a shell command.** This module used to compile each shape into a shell
22
+ * command string and run it with `spawnSync(..., { shell: true })`, branching on
23
+ * `process.platform`. That design made the central question — did the check run at
24
+ * all? — undecidable on Windows. A guard whose program is missing and a guard whose
25
+ * predicate is simply false both exit 1 through cmd.exe, and cmd.exe's own diagnostic
26
+ * is a localized resource string: on a zh-CN host it arrives as GBK bytes reading
27
+ * 不是内部或外部命令…, which decodes to mojibake under utf8, so neither an English nor
28
+ * a Chinese pattern could reliably tell the two apart. Any classifier built on "what
29
+ * did the shell say" is a classifier that answers differently per host language.
30
+ *
31
+ * The predicates are all filesystem facts. Four of them are about a file's *text*
32
+ * (`exists` / `absent` / `contains` / `lacks`); four are about a file's *structure*
33
+ * (`depends` / `script` / `option` / `lineLimit`), answered by `JSON.parse` and a line
34
+ * count. Answered directly, they need no shell, no locale, no platform branch, and no
35
+ * quoting layer — and the distinction this module exists to protect becomes a fact about
36
+ * a return value instead of a guess about text.
37
+ *
38
+ * The structured predicates exist because the text ones cannot answer structural
39
+ * questions honestly: `contains "tsx" package.json` passes when `tsx` appears in a
40
+ * description or a script string, so it would report `passed` while verifying nothing.
41
+ * Parsing is still in-process, and the memory still supplies only operands.
19
42
  *
20
43
  * @module context-memory/guard
21
44
  */
22
- import { spawnSync } from 'node:child_process';
45
+ import { closeSync, openSync, readSync, statSync } from 'node:fs';
46
+ import { isAbsolute, resolve } from 'node:path';
47
+ import { StringDecoder } from 'node:string_decoder';
48
+ import { stripLeadingFillers } from './identity.js';
49
+ /**
50
+ * Every guard kind this version can evaluate.
51
+ *
52
+ * This list is the single definition. `memory.ts` drops a guard whose kind is not in it
53
+ * (a log written by a newer version), and the exhaustiveness assertion below turns
54
+ * "added a kind to the union but forgot to teach the evaluator" into a compile error
55
+ * rather than a runtime fallthrough into whichever branch happens to come last.
56
+ */
57
+ export const GUARD_KINDS = [
58
+ 'exists',
59
+ 'absent',
60
+ 'contains',
61
+ 'lacks',
62
+ 'depends',
63
+ 'script',
64
+ 'option',
65
+ 'lineLimit',
66
+ ];
67
+ const _everyKindIsListed = true;
68
+ void _everyKindIsListed;
23
69
  /** Enough output to diagnose, small enough to keep inline in the event log. */
24
70
  const OUTPUT_LIMIT = 2000;
71
+ /** How much of a file to scan for a containment check before giving up. */
72
+ const SCAN_LIMIT = 8 * 1024 * 1024;
25
73
  /**
26
- * Compile a memory statement into an executable check.
74
+ * Compile a memory statement into a check.
27
75
  *
28
- * Commands are emitted for the platform in use. `process.platform` rather than a
29
- * hardcoded POSIX spelling: `test -e` on Windows would leave every compiled memory
30
- * permanently unrunnable, so verification would report on nothing while looking busy.
76
+ * The returned predicate names operands, never a program: `{ kind: 'exists', path }`
77
+ * says what must be true, and `runGuard` decides it with `fs`. Nothing from the
78
+ * statement text is ever executed, so a memory cannot smuggle in an injection.
79
+ *
80
+ * Operands are stored as written. Resolution against the project directory happens at
81
+ * run time, so a memory stays meaningful if the project is moved or the check is
82
+ * evaluated from a different working directory.
31
83
  */
32
- export function compileGuard(text, platform = process.platform) {
33
- const normalized = text.trim().replace(/\s+/g, ' ');
34
- const posix = platform !== 'win32';
35
- const operand = (value) => (posix ? shellQuote(value) : `"${value.replace(/"/g, '\\"')}"`);
84
+ export function compileGuard(text) {
85
+ // Discourse padding in front of the statement is dropped before matching, and by the
86
+ // same function `normaliseSubject` uses: every pattern below is anchored at `^`, so a
87
+ // statement the user prefixed with "note that" would otherwise get the subject of a
88
+ // checkable claim and no check to go with it. The `distill` path already removes the
89
+ // verb, which is exactly why this gap only showed up on direct writes.
90
+ const normalized = stripLeadingFillers(text.trim().replace(/\s+/g, ' '));
91
+ // ── Structured predicates ─
92
+ //
93
+ // Checked before the text predicates because they are strictly more specific: "the file
94
+ // package.json contains tsx" is a text claim anyone can make, while "package.json
95
+ // depends on tsx" is a claim about a *key*. If the text branch ran first, a statement
96
+ // about a dependency would compile into a substring search that passes whenever the
97
+ // package name appears anywhere in the file — including inside a script string — and
98
+ // that check would report `passed` while verifying nothing.
99
+ const dependency = compileDependency(normalized);
100
+ if (dependency)
101
+ return dependency;
102
+ const script = compileScript(normalized);
103
+ if (script)
104
+ return script;
105
+ const option = compileOption(normalized);
106
+ if (option)
107
+ return option;
108
+ const lines = compileLineLimit(normalized);
109
+ if (lines)
110
+ return lines;
36
111
  // "the file X must not contain Y" — checked before the positive form so that the
37
112
  // negated sentence is never read as an assertion that Y is present.
38
113
  const fileLacks = /^(?:the )?(?:file|path) `?([^\s`]+)`? (?:must not|should not|never) (?:contain|mention|include) `?([^\s`]+)`?$/i.exec(normalized);
39
114
  if (fileLacks?.[1] && fileLacks[2]) {
40
- const [file, needle] = [fileLacks[1], fileLacks[2]];
41
- // A quote inside the operand cannot survive cmd.exe's own quoting layer; refusing
42
- // beats emitting a command whose meaning differs from the sentence.
43
- if (!posix && needle.includes('"'))
44
- return undefined;
45
- return {
46
- command: posix
47
- ? `! grep -qF ${shellQuote(needle)} ${shellQuote(file)}`
48
- : `findstr /c:${operand(needle)} ${operand(file)} >nul 2>nul && exit 1`,
49
- source: 'template',
50
- };
115
+ return { kind: 'lacks', path: fileLacks[1], needle: fileLacks[2], source: 'template' };
51
116
  }
52
117
  // "the file X contains Y" / "X mentions Y"
53
118
  const fileHas = /^(?:the )?(?:file|path) `?([^\s`]+)`? (?:contains|mentions|includes) `?([^\s`]+)`?$/i.exec(normalized);
54
119
  if (fileHas?.[1] && fileHas[2]) {
55
- const [file, needle] = [fileHas[1], fileHas[2]];
56
- if (!posix && needle.includes('"'))
57
- return undefined;
58
- return {
59
- command: posix
60
- ? `grep -qF ${shellQuote(needle)} ${shellQuote(file)}`
61
- : `findstr /c:${operand(needle)} ${operand(file)} >nul`,
62
- source: 'template',
63
- };
120
+ return { kind: 'contains', path: fileHas[1], needle: fileHas[2], source: 'template' };
64
121
  }
65
122
  // "the file X exists" / "X exists" / "X is at <path>" is the same predicate.
66
123
  const fileExists = /^(?:the )?(?:file|path) `?([^\s`]+)`? (?:exists|is present)$/i.exec(normalized)
67
124
  ?? /^`?([^\s`]+)`? exists$/i.exec(normalized);
68
125
  if (fileExists?.[1]) {
69
- const target = operand(fileExists[1]);
70
- return {
71
- command: posix ? `test -f ${target}` : `cmd /c if not exist ${target} exit 1`,
72
- source: 'template',
73
- };
126
+ return { kind: 'exists', path: fileExists[1], source: 'template' };
74
127
  }
75
128
  // "there is no file X" / "never commit X"
76
129
  const fileAbsent = /^(?:there is no|there are no|no) (?:file )?`?([^\s`]+)`?$/i.exec(normalized)
77
130
  ?? /^(?:never|do not|don't) (?:create|add|commit|write|touch) (?:the )?(?:file )?`?([^\s`]+)`?$/i.exec(normalized);
78
131
  if (fileAbsent?.[1]) {
79
- const target = operand(fileAbsent[1]);
80
- return {
81
- command: posix ? `! test -e ${target}` : `cmd /c if exist ${target} exit 1`,
82
- source: 'template',
83
- };
132
+ return { kind: 'absent', path: fileAbsent[1], source: 'template' };
84
133
  }
85
134
  return undefined;
86
135
  }
87
136
  /**
88
- * Quote a string so a POSIX shell treats it as one literal argument.
137
+ * Read a manifest as JSON, or report why it could not be read.
138
+ *
139
+ * Nothing here is executed and nothing is resolved: a `package.json` is read as data, so
140
+ * a malformed one is `broken` (the check could not be carried out), never `violated`
141
+ * (the memory is wrong).
142
+ */
143
+ function readJson(path, cwd) {
144
+ const target = resolveOperand(path, cwd);
145
+ const scan = readForScan(target);
146
+ if ('error' in scan)
147
+ return { error: `cannot read ${target}: ${scan.error}` };
148
+ try {
149
+ return { value: JSON.parse(scan.text) };
150
+ }
151
+ catch (error) {
152
+ return { error: `cannot parse ${target}: ${error instanceof Error ? error.message : String(error)}` };
153
+ }
154
+ }
155
+ /** A plain object, or nothing — used to walk a parsed manifest without casting. */
156
+ function asRecord(value) {
157
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
158
+ return undefined;
159
+ return value;
160
+ }
161
+ /**
162
+ * "package.json depends on X" / "X is a dev dependency".
89
163
  *
90
- * Single quotes make every character literal; the only character that cannot appear
91
- * inside them is the quote itself, escaped by closing, escaping, and reopening.
164
+ * A dependency claim is about a key, not a substring: the whole reason nobody could
165
+ * verify "we use tsx" is that `grep tsx package.json` passes when `tsx` appears in a
166
+ * script, a description, or a `files` entry. `runGuard` reads all four dependency maps.
92
167
  */
93
- export function shellQuote(value) {
94
- return `'${value.replace(/'/g, `'\\''`)}'`;
168
+ function compileDependency(text) {
169
+ const negated = /^(?:the )?package\.json (?:does not|doesn't|no longer) depend on `?([^\s`]+)`?$/i.exec(text)
170
+ ?? /^`?([^\s`]+)`? is not a dependency$/i.exec(text);
171
+ if (negated?.[1])
172
+ return { kind: 'depends', name: negated[1], absent: true, source: 'template' };
173
+ const plain = /^(?:the )?package\.json depends on `?([^\s`]+)`?$/i.exec(text)
174
+ ?? /^`?([^\s`]+)`? is a (?:dev )?dependency$/i.exec(text);
175
+ if (plain?.[1])
176
+ return { kind: 'depends', name: plain[1], source: 'template' };
177
+ return undefined;
95
178
  }
96
179
  /**
97
- * Run one guard to completion.
180
+ * "package.json script `build` runs `tsc`" / "there is no `build` script".
98
181
  *
99
- * `spawnSync` with a shell so compound commands work as written. No timeout: a guard
100
- * that hangs is a hang, and killing it after N seconds would turn "slow" into an
101
- * outcome that varies with machine load.
182
+ * The command is matched exactly as written. A prefix match would make `build runs tsc`
183
+ * pass on `tsc --noEmit`, which is a different command; the statement has to be as
184
+ * specific as the check.
102
185
  */
103
- export function runGuard(id, guard, cwd, now = new Date()) {
104
- const at = now.toISOString();
105
- let proc;
186
+ function compileScript(text) {
187
+ const negated = /^(?:the )?package\.json has no `?([^\s`]+)`? script$/i.exec(text)
188
+ ?? /^there is no `?([^\s`]+)`? (?:npm )?script$/i.exec(text);
189
+ if (negated?.[1])
190
+ return { kind: 'script', name: negated[1], absent: true, source: 'template' };
191
+ const withCmd = /^(?:the )?package\.json script `?([^\s`]+)`? (?:runs|is|equals) `?([^`]+?)`?$/i.exec(text);
192
+ if (withCmd?.[1] && withCmd[2]) {
193
+ return { kind: 'script', name: withCmd[1], cmd: withCmd[2], source: 'template' };
194
+ }
195
+ const bare = /^(?:the )?package\.json has (?:a |an )?`?([^\s`]+)`? script$/i.exec(text);
196
+ if (bare?.[1])
197
+ return { kind: 'script', name: bare[1], source: 'template' };
198
+ return undefined;
199
+ }
200
+ /**
201
+ * "tsconfig enables `strict`" / "tsconfig does not enable `strict`".
202
+ *
203
+ * Only JSON config files (`tsconfig` / `jsconfig`, with or without the `.json`), and only
204
+ * options that are single keys. A dotted path would be a second grammar for the same
205
+ * feature; if a memory needs a nested option, the statement is not specific enough to
206
+ * check yet.
207
+ *
208
+ * `tsconfig` is canonicalised to `tsconfig.json` here rather than at run time, so the
209
+ * guard that lands in the log names the file it will actually be checked against. A
210
+ * guard saying `path: "tsconfig"` would describe itself in terms no file has.
211
+ */
212
+ function canonicalConfig(name) {
213
+ return name.endsWith('.json') ? name : `${name}.json`;
214
+ }
215
+ function compileOption(text) {
216
+ const negated = /^(?:the )?(?:file )?`?(tsconfig|jsconfig)(?:\.json)?`? (?:does not|doesn't|no longer) (?:enable|set) `?([^\s`]+)`?$/i.exec(text)
217
+ ?? /^(?:the )?(?:file )?`?(tsconfig|jsconfig)(?:\.json)?`? has `?([^\s`]+)`? disabled$/i.exec(text);
218
+ if (negated?.[1] && negated[2]) {
219
+ // `value: true` with `absent` is the negative of "the option is on" — so an option
220
+ // that is missing *and* one that is explicitly `false` both satisfy it. Without the
221
+ // value the predicate would degrade to "the key is missing", and "has strict
222
+ // disabled" would then be violated by the perfectly correct `"strict": false`.
223
+ return { kind: 'option', path: canonicalConfig(negated[1]), option: negated[2], value: true, absent: true, source: 'template' };
224
+ }
225
+ const enabled = /^(?:the )?(?:file )?`?(tsconfig|jsconfig)(?:\.json)?`? (?:enables|sets) `?([^\s`]+)`?$/i.exec(text)
226
+ ?? /^(?:the )?(?:file )?`?(tsconfig|jsconfig)(?:\.json)?`? has `?([^\s`]+)`? enabled$/i.exec(text);
227
+ if (enabled?.[1] && enabled[2]) {
228
+ return { kind: 'option', path: canonicalConfig(enabled[1]), option: enabled[2], value: true, source: 'template' };
229
+ }
230
+ return undefined;
231
+ }
232
+ /**
233
+ * "the file X has at most N lines" / "at least N lines".
234
+ *
235
+ * Counted, not guessed: the scan helpers below refuse files above the scan limit, which
236
+ * keeps a memory about a log file from turning every re-check into a full read.
237
+ */
238
+ function compileLineLimit(text) {
239
+ const at = /^(?:the )?(?:file|path) `?([^\s`]+)`? has at (most|least) (\d+) lines?$/i.exec(text);
240
+ if (!(at?.[1] && at[2] && at[3]))
241
+ return undefined;
242
+ const count = Number(at[3]);
243
+ if (!Number.isSafeInteger(count) || count < 0)
244
+ return undefined;
245
+ return at[2].toLowerCase() === 'most'
246
+ ? { kind: 'lineLimit', path: at[1], max: count, source: 'template' }
247
+ : { kind: 'lineLimit', path: at[1], min: count, source: 'template' };
248
+ }
249
+ /**
250
+ * A human-readable rendering of a check, for the CLI and the log.
251
+ *
252
+ * Deliberately not a shell command: printing one would invite the reader to run it,
253
+ * which is the interface this module just removed.
254
+ */
255
+ export function describeGuard(guard) {
256
+ switch (guard.kind) {
257
+ case 'exists': return `path "${guard.path}" exists`;
258
+ case 'absent': return `path "${guard.path}" does not exist`;
259
+ case 'contains': return `file "${guard.path}" contains "${guard.needle}"`;
260
+ case 'lacks': return `file "${guard.path}" does not contain "${guard.needle}"`;
261
+ case 'depends': return guard.absent === true
262
+ ? `package.json does not depend on "${guard.name}"`
263
+ : `package.json depends on "${guard.name}"`;
264
+ case 'script':
265
+ if (guard.absent === true)
266
+ return `package.json has no "${guard.name}" script`;
267
+ return guard.cmd === undefined
268
+ ? `package.json has a "${guard.name}" script`
269
+ : `package.json script "${guard.name}" is "${guard.cmd}"`;
270
+ case 'option':
271
+ // "set to false" and "not set" are different facts, and the second is what the
272
+ // negated predicate actually tests, so the description must not collapse them.
273
+ if (guard.absent === true) {
274
+ return guard.value === true
275
+ ? `"${guard.path}" does not enable "${guard.option}"`
276
+ : `"${guard.path}" does not set "${guard.option}" to ${JSON.stringify(guard.value)}`;
277
+ }
278
+ if (guard.value === undefined)
279
+ return `"${guard.path}" sets "${guard.option}"`;
280
+ return guard.value === true
281
+ ? `"${guard.path}" enables "${guard.option}"`
282
+ : `"${guard.path}" sets "${guard.option}" to false`;
283
+ case 'lineLimit':
284
+ if (guard.max !== undefined)
285
+ return `file "${guard.path}" has at most ${guard.max} lines`;
286
+ if (guard.min !== undefined)
287
+ return `file "${guard.path}" has at least ${guard.min} lines`;
288
+ return `file "${guard.path}" has a line count`;
289
+ }
290
+ }
291
+ /**
292
+ * Why a check contradicted a memory, in one line.
293
+ *
294
+ * `runGuard` reports a reason when the check could not run, but a check that ran and came
295
+ * back false has nothing to report beyond its own verdict — `exists` and `absent` return
296
+ * an empty `output`. Answering "contradicted" with no detail leaves the reader unable to
297
+ * tell which path or which string was actually tested, so this falls back to the check's
298
+ * own description: it names the operand the memory no longer matches, which is the fact
299
+ * that made the memory wrong.
300
+ */
301
+ export function describeContradiction(check, output) {
302
+ const first = output?.trim().split('\n')[0];
303
+ if (first !== undefined && first.length > 0)
304
+ return first;
305
+ return check === undefined ? 'its check' : describeGuard(check);
306
+ }
307
+ /**
308
+ * Resolve a statement's operand against the project directory.
309
+ *
310
+ * Absolute operands are kept as written. Anything else is relative to the project the
311
+ * memory was recorded in, because that is the directory the statement was talking
312
+ * about — resolving against `process.cwd()` instead would make the same memory mean
313
+ * different things depending on where the command happened to be invoked.
314
+ */
315
+ export function resolveOperand(path, cwd) {
316
+ return isAbsolute(path) ? path : resolve(cwd, path);
317
+ }
318
+ /** Size of one read when scanning a file. Fixed, never derived from the file's size. */
319
+ const SCAN_CHUNK = 64 * 1024;
320
+ /**
321
+ * Open a file for scanning, or report why it cannot be scanned.
322
+ *
323
+ * A containment claim about a huge file cannot be answered cheaply and honestly; saying
324
+ * so keeps it out of the violated bucket where it does not belong.
325
+ */
326
+ function openForScan(path) {
327
+ try {
328
+ const stats = statSync(path);
329
+ if (stats.isDirectory())
330
+ return { error: `${path} is a directory` };
331
+ if (stats.size > SCAN_LIMIT)
332
+ return { error: `${path} is larger than ${SCAN_LIMIT} bytes` };
333
+ return { fd: openSync(path, 'r') };
334
+ }
335
+ catch (error) {
336
+ return { error: error instanceof Error ? error.message : String(error) };
337
+ }
338
+ }
339
+ /**
340
+ * Walk a file in fixed-size chunks, handing each decoded piece to `sink`.
341
+ *
342
+ * A fixed window rather than one buffer sized by `stat`: an 8 MB file used to mean an
343
+ * 8 MB allocation per check, and `verify` runs every check in the log, so the cost
344
+ * multiplied by the number of memories. `StringDecoder` carries a multi-byte character
345
+ * split across a chunk boundary rather than emitting a replacement character, which
346
+ * would make a `contains` check disagree with a plain read of the same file.
347
+ */
348
+ function scanChunks(path, sink) {
349
+ const opened = openForScan(path);
350
+ if ('error' in opened)
351
+ return opened;
352
+ const buffer = Buffer.allocUnsafe(SCAN_CHUNK);
353
+ const decoder = new StringDecoder('utf8');
106
354
  try {
107
- proc = spawnSync(guard.command, { cwd, shell: true, encoding: 'utf8' });
355
+ for (;;) {
356
+ const read = readSync(opened.fd, buffer, 0, buffer.length, null);
357
+ if (read === 0) {
358
+ const tail = decoder.end();
359
+ if (tail.length > 0)
360
+ sink(tail);
361
+ return undefined;
362
+ }
363
+ sink(decoder.write(buffer.subarray(0, read)));
364
+ }
108
365
  }
109
366
  catch (error) {
110
- return { id, outcome: 'broken', exitCode: null, output: String(error), at };
367
+ return { error: error instanceof Error ? error.message : String(error) };
368
+ }
369
+ finally {
370
+ closeSync(opened.fd);
371
+ }
372
+ }
373
+ /** Read a file as text, or report why it could not be read. */
374
+ function readForScan(path) {
375
+ const parts = [];
376
+ const failure = scanChunks(path, (chunk) => { parts.push(chunk); });
377
+ return failure ?? { text: parts.join('') };
378
+ }
379
+ /**
380
+ * Whether a file's text contains `needle`.
381
+ *
382
+ * Answered from the stream, so a containment check never holds the file in memory. The
383
+ * carry keeps a match straddling a chunk seam from being missed: a needle can only
384
+ * cross the seam if it starts within `needle.length - 1` characters of it.
385
+ */
386
+ function containsInFile(path, needle) {
387
+ const carryLength = Math.max(0, needle.length - 1);
388
+ let carry = '';
389
+ let found = false;
390
+ const failure = scanChunks(path, (chunk) => {
391
+ if (found)
392
+ return;
393
+ const window = carry + chunk;
394
+ if (window.includes(needle)) {
395
+ found = true;
396
+ carry = '';
397
+ return;
398
+ }
399
+ carry = carryLength === 0 ? '' : window.slice(-carryLength);
400
+ });
401
+ return failure ?? { found };
402
+ }
403
+ /**
404
+ * Count a file's lines.
405
+ *
406
+ * A trailing newline is not a line. `wc -l` would count it, but the statement "this file
407
+ * has at most 500 lines" is about content, and an editor reports the content count — so
408
+ * the two would disagree on every file that ends with a newline, which is most of them.
409
+ */
410
+ function countFileLines(path) {
411
+ let newlines = 0;
412
+ let lastChar = '';
413
+ const failure = scanChunks(path, (chunk) => {
414
+ if (chunk.length === 0)
415
+ return;
416
+ newlines += chunk.split('\n').length - 1;
417
+ lastChar = chunk.slice(-1);
418
+ });
419
+ if (failure !== undefined)
420
+ return failure;
421
+ // An empty file has no lines; otherwise a final newline terminates the last line
422
+ // rather than starting a new one.
423
+ if (lastChar === '')
424
+ return { lines: 0 };
425
+ return { lines: lastChar === '\n' ? newlines : newlines + 1 };
426
+ }
427
+ /** Where a dependency name may be declared. A claim is satisfied by any of them. */
428
+ const DEPENDENCY_MAPS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
429
+ /**
430
+ * The value of `option` in a parsed config file, and whether it was found.
431
+ *
432
+ * Two places are consulted, in this order: the top level, then `compilerOptions`. The
433
+ * second is not decoration — `strict` lives inside `compilerOptions` in every real
434
+ * `tsconfig.json`, so a top-level-only lookup would report a perfectly correct memory as
435
+ * `violated`, and a false contradiction is the one outcome this module must not produce.
436
+ * Top level wins when both exist, so the rule stays "the file's own outermost statement".
437
+ */
438
+ function optionAt(record, option) {
439
+ if (Object.prototype.hasOwnProperty.call(record, option))
440
+ return { found: true, value: record[option] };
441
+ const nested = asRecord(record.compilerOptions);
442
+ if (nested !== undefined && Object.prototype.hasOwnProperty.call(nested, option)) {
443
+ return { found: true, value: nested[option] };
444
+ }
445
+ return { found: false, value: undefined };
446
+ }
447
+ /**
448
+ * Evaluate one check and say what happened.
449
+ *
450
+ * The three-way split is the whole point. `broken` covers "the check could not be
451
+ * carried out" — an operand that resolves to nothing, a path that is a directory where
452
+ * a file was named, a file too large to scan, a manifest that will not parse — and is
453
+ * never evidence about the memory. `violated` is reserved for a check that ran and came
454
+ * back false.
455
+ *
456
+ * No exit code is invented here. There is no child process, so there is no status to
457
+ * report; `exitCode` is null and `output` carries the reason. The one place a caller
458
+ * needs a process-style signal — `ContextMemory.exitCode` — derives it from the summary,
459
+ * counting only `violated`.
460
+ */
461
+ export function runGuard(id, guard, cwd, now = new Date()) {
462
+ const at = now.toISOString();
463
+ const verdict = (outcome, output = '') => ({ id, outcome, exitCode: null, output: output.slice(0, OUTPUT_LIMIT), at });
464
+ if (guard.kind === 'exists' || guard.kind === 'absent') {
465
+ const target = resolveOperand(guard.path, cwd);
466
+ let present;
467
+ try {
468
+ // `stat` follows symlinks, so a dangling link counts as absent — which is what
469
+ // "the file X exists" means to someone about to open it.
470
+ present = statSync(target) !== undefined;
471
+ }
472
+ catch (error) {
473
+ const code = error.code;
474
+ // ENOENT/ENOTDIR are answers ("no"), not failures to answer. Anything else —
475
+ // EACCES, ELOOP, ENAMETOOLONG — means the check itself could not be trusted.
476
+ if (code === 'ENOENT' || code === 'ENOTDIR')
477
+ present = false;
478
+ else
479
+ return verdict('broken', `cannot inspect ${target}: ${error.message}`);
480
+ }
481
+ if (guard.kind === 'exists')
482
+ return verdict(present ? 'passed' : 'violated', present ? '' : `${target} does not exist`);
483
+ return verdict(present ? 'violated' : 'passed', present ? `${target} exists` : '');
484
+ }
485
+ if (guard.kind === 'contains' || guard.kind === 'lacks') {
486
+ const target = resolveOperand(guard.path, cwd);
487
+ const scan = containsInFile(target, guard.needle);
488
+ if ('error' in scan)
489
+ return verdict('broken', `cannot read ${target}: ${scan.error}`);
490
+ const found = scan.found;
491
+ if (guard.kind === 'contains') {
492
+ return verdict(found ? 'passed' : 'violated', found ? '' : `${target} does not contain "${guard.needle}"`);
493
+ }
494
+ return verdict(found ? 'violated' : 'passed', found ? `${target} contains "${guard.needle}"` : '');
111
495
  }
112
- const output = `${proc.stdout ?? ''}${proc.stderr ?? ''}`.slice(0, OUTPUT_LIMIT);
113
- if (proc.error) {
114
- return { id, outcome: 'broken', exitCode: null, output: `${output}${proc.error.message}`.slice(0, OUTPUT_LIMIT), at };
496
+ if (guard.kind === 'depends') {
497
+ const manifest = readJson('package.json', cwd);
498
+ if ('error' in manifest)
499
+ return verdict('broken', manifest.error);
500
+ const record = asRecord(manifest.value);
501
+ // A manifest that parses to something other than an object cannot answer a question
502
+ // about its keys, and saying `violated` would blame the memory for a broken file.
503
+ if (record === undefined)
504
+ return verdict('broken', `${resolveOperand('package.json', cwd)} is not a JSON object`);
505
+ const declaredIn = DEPENDENCY_MAPS.filter((map) => {
506
+ const deps = asRecord(record[map]);
507
+ return deps !== undefined && Object.prototype.hasOwnProperty.call(deps, guard.name);
508
+ });
509
+ const present = declaredIn.length > 0;
510
+ if (guard.absent !== true) {
511
+ return verdict(present ? 'passed' : 'violated', present ? '' : `${guard.name} is in none of ${DEPENDENCY_MAPS.join(', ')}`);
512
+ }
513
+ return verdict(present ? 'violated' : 'passed', present ? `${guard.name} is declared in ${declaredIn.join(', ')}` : '');
115
514
  }
116
- const code = proc.status;
117
- if (code === null) {
118
- return { id, outcome: 'broken', exitCode: null, output: output || 'terminated by signal', at };
515
+ if (guard.kind === 'script') {
516
+ const manifest = readJson('package.json', cwd);
517
+ if ('error' in manifest)
518
+ return verdict('broken', manifest.error);
519
+ const record = asRecord(manifest.value);
520
+ if (record === undefined)
521
+ return verdict('broken', `${resolveOperand('package.json', cwd)} is not a JSON object`);
522
+ const scripts = asRecord(record.scripts);
523
+ const present = scripts !== undefined && Object.prototype.hasOwnProperty.call(scripts, guard.name);
524
+ if (guard.absent === true) {
525
+ return verdict(present ? 'violated' : 'passed', present ? `scripts has "${guard.name}"` : '');
526
+ }
527
+ if (!present)
528
+ return verdict('violated', `scripts has no "${guard.name}"`);
529
+ if (guard.cmd === undefined)
530
+ return verdict('passed');
531
+ // Exact equality, not a prefix or a substring: `build runs tsc` must not pass on
532
+ // `tsc --noEmit`, which is a different command with a different meaning.
533
+ const actual = scripts[guard.name];
534
+ const runs = typeof actual === 'string' ? actual.trim() : undefined;
535
+ if (runs === guard.cmd.trim())
536
+ return verdict('passed');
537
+ return verdict('violated', `scripts.${guard.name} is ${JSON.stringify(actual ?? null)}, not ${JSON.stringify(guard.cmd)}`);
119
538
  }
120
- return { id, outcome: classify(code, output), exitCode: code, output, at };
539
+ if (guard.kind === 'option') {
540
+ const config = readJson(guard.path, cwd);
541
+ if ('error' in config)
542
+ return verdict('broken', config.error);
543
+ const record = asRecord(config.value);
544
+ if (record === undefined)
545
+ return verdict('broken', `${resolveOperand(guard.path, cwd)} is not a JSON object`);
546
+ const { found, value } = optionAt(record, guard.option);
547
+ // Without an expected value the statement is only "the option is set", so presence
548
+ // is the whole question. With one, absence and inequality are both violations: the
549
+ // memory said `strict` is on and it is either missing or off.
550
+ const satisfied = guard.value === undefined ? found : found && value === guard.value;
551
+ if (guard.absent !== true) {
552
+ return verdict(satisfied ? 'passed' : 'violated', satisfied ? '' : found
553
+ ? `"${guard.option}" is ${JSON.stringify(value)}`
554
+ : `"${guard.option}" is not set in ${guard.path}`);
555
+ }
556
+ return verdict(satisfied ? 'violated' : 'passed', satisfied ? `"${guard.option}" is ${JSON.stringify(value)} in ${guard.path}` : '');
557
+ }
558
+ // lineLimit
559
+ if (guard.kind === 'lineLimit') {
560
+ if (guard.max === undefined && guard.min === undefined) {
561
+ // A bound that states no bound cannot be evaluated. Passing it would be a check that
562
+ // verifies nothing while reporting `passed`, which is the one outcome to avoid.
563
+ return verdict('broken', 'the check states neither a maximum nor a minimum line count');
564
+ }
565
+ const target = resolveOperand(guard.path, cwd);
566
+ const scan = countFileLines(target);
567
+ if ('error' in scan)
568
+ return verdict('broken', `cannot read ${target}: ${scan.error}`);
569
+ const lines = scan.lines;
570
+ const withinMax = guard.max === undefined || lines <= guard.max;
571
+ const withinMin = guard.min === undefined || lines >= guard.min;
572
+ if (withinMax && withinMin)
573
+ return verdict('passed');
574
+ const expected = [
575
+ guard.max === undefined ? undefined : `at most ${guard.max}`,
576
+ guard.min === undefined ? undefined : `at least ${guard.min}`,
577
+ ].filter((part) => part !== undefined).join(' and ');
578
+ return verdict('violated', `${target} has ${lines} lines, not ${expected}`);
579
+ }
580
+ // Unreachable while the dispatch above covers every member of `Guard`. The assignment is
581
+ // the assertion: add a kind to the union and forget a branch, and this stops compiling
582
+ // with the missing kind named in the error.
583
+ const unhandled = guard;
584
+ return verdict('broken', `no evaluator for guard kind ${JSON.stringify(unhandled.kind)}`);
121
585
  }
122
586
  /**
123
587
  * Classify an exit code into an outcome.
124
588
  *
125
- * Two conventions mean "the command does not exist": POSIX shells use 127, cmd.exe
126
- * uses 9009. Both are recognised, along with the text shells print instead of a
127
- * distinctive code when the failure happens inside the shell. Without this, a
128
- * misspelled path would look like a memory being wrong about the world.
589
+ * Kept for callers holding a status they did not produce themselves — the CLI reports
590
+ * on log entries written by older versions of this plugin, which recorded a child
591
+ * process's status. 127 is the POSIX convention for "command not found" and 9009 is
592
+ * cmd.exe's, and both mean the check never ran.
593
+ *
594
+ * The textual fallback is gone along with the shell. It could only ever have worked on
595
+ * an English host, and a classifier that reads differently depending on the system
596
+ * language is not a classifier. `runGuard` no longer spawns anything, so no new entry
597
+ * can carry a status that needs interpreting here.
129
598
  */
130
- export function classify(code, output = '') {
599
+ export function classify(code) {
131
600
  if (code === 0)
132
601
  return 'passed';
133
602
  if (code === 127 || code === 9009)
134
603
  return 'broken';
135
- if (/command not found|is not recognized|No such file or directory|cannot open/i.test(output)) {
136
- return 'broken';
137
- }
138
604
  return 'violated';
139
605
  }