dsh-context-memory 0.7.0 → 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/CHANGELOG.md +324 -0
- package/README.md +151 -24
- package/icon.svg +16 -0
- package/lib/candidates.d.ts +71 -0
- package/lib/candidates.js +150 -0
- package/lib/capture.d.ts +21 -0
- package/lib/capture.js +86 -0
- package/lib/cli.js +88 -9
- package/lib/client/index.d.ts +17 -23
- package/lib/client/locales.d.ts +11 -0
- package/lib/client/locales.js +22 -0
- package/lib/client/view.d.ts +8 -1
- package/lib/client/view.js +18 -3
- package/lib/guard.d.ts +92 -30
- package/lib/guard.js +539 -73
- package/lib/identity.d.ts +16 -5
- package/lib/identity.js +36 -18
- package/lib/index.d.ts +33 -2
- package/lib/index.js +173 -11
- package/lib/memory.d.ts +131 -9
- package/lib/memory.js +296 -46
- package/lib/memory.web.js +58 -7
- package/lib/memory.web.js.map +2 -2
- package/lib/recall.d.ts +46 -7
- package/lib/recall.js +119 -12
- package/lib/routes.d.ts +1 -0
- package/lib/routes.js +50 -1
- package/lib/scope.d.ts +45 -0
- package/lib/scope.js +75 -0
- package/lib/store.d.ts +25 -2
- package/lib/store.js +45 -7
- package/lib/tools.d.ts +44 -0
- package/lib/tools.js +241 -0
- package/lib/types.d.ts +182 -3
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +25 -14
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
|
|
13
|
-
*
|
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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 {
|
|
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
|
|
74
|
+
* Compile a memory statement into a check.
|
|
27
75
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
91
|
-
*
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
*
|
|
180
|
+
* "package.json script `build` runs `tsc`" / "there is no `build` script".
|
|
98
181
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
|
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
|
}
|