lucid-mem 0.5.0 → 0.6.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/README.md +1 -1
- package/lib/cli.mjs +4 -0
- package/lib/commands.mjs +7 -18
- package/lib/coverage.mjs +181 -0
- package/lib/install.mjs +13 -0
- package/package.json +1 -1
- package/test/context.test.mjs +4 -4
- package/test/coverage.test.mjs +71 -0
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ Memory lives in `~/agent-memory` — a real git repo you can read, diff, and poi
|
|
|
34
34
|
|
|
35
35
|
## Multi-repo memory
|
|
36
36
|
|
|
37
|
-
One memory repo serves every project. `projects/<name>-<hash>.md` files key off the repo's origin remote, so clones share memory and same-named repos don't collide. Inside a known repo, `amr context` is project-scoped — the project's latest 25 plus linked siblings (up to 8 projects). Outside one, it shows global bullets plus the latest 25 pooled across all projects. `private`
|
|
37
|
+
One memory repo serves every project. `projects/<name>-<hash>.md` files key off the repo's origin remote, so clones share memory and same-named repos don't collide. Inside a known repo, `amr context` is project-scoped — the project's latest 25 plus linked siblings (up to 8 projects). Outside one, it shows global bullets plus the latest 25 pooled across all projects. `private` and `noglobal` policies keep a project out of the pooled digest (links still reach private projects — your link graph is your own curation).
|
|
38
38
|
|
|
39
39
|
## MCP
|
|
40
40
|
|
package/lib/cli.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { cmdDiscover } from './discover.mjs';
|
|
19
19
|
import { cmdDream } from './dream.mjs';
|
|
20
20
|
import { cmdHarvest } from './harvest.mjs';
|
|
21
|
+
import { cmdCoverage } from './coverage.mjs';
|
|
21
22
|
import { cmdDoctor, cmdInstall, cmdUninstall } from './install.mjs';
|
|
22
23
|
import { cmdServe } from './serve.mjs';
|
|
23
24
|
|
|
@@ -41,6 +42,7 @@ usage: amr <command> [args] [flags]
|
|
|
41
42
|
dream [--days N] [--fix] [--compact] [--keep N] [--json] memory health; --fix cleans up, --compact archives overflow; --spin plays the animation
|
|
42
43
|
harvest [--days N] [--json] [--extract] [--max N] [--min C] list transcripts; --extract streams normalized user messages
|
|
43
44
|
discover [--days N] [--min N] [--json] rank repos by transcript mentions; shows memory coverage + suggested links
|
|
45
|
+
coverage [--days N] [--json] [--no-log] save-rate metric: durable transcript signals vs memory repo
|
|
44
46
|
install [--host a,b|all] wire hooks + rules + MCP into detected agent hosts
|
|
45
47
|
uninstall [--host a,b|all] remove amr hooks + rules + MCP entries
|
|
46
48
|
serve stdio MCP server (memory_context/add/search/update)
|
|
@@ -131,6 +133,8 @@ export function main(argv) {
|
|
|
131
133
|
return cmdHarvest(flags);
|
|
132
134
|
case 'discover':
|
|
133
135
|
return cmdDiscover(flags);
|
|
136
|
+
case 'coverage':
|
|
137
|
+
return cmdCoverage(flags);
|
|
134
138
|
case 'install':
|
|
135
139
|
return cmdInstall(flags);
|
|
136
140
|
case 'uninstall':
|
package/lib/commands.mjs
CHANGED
|
@@ -91,9 +91,9 @@ export function cmdContext(flags) {
|
|
|
91
91
|
|
|
92
92
|
// Attach list: the current project, then its config links
|
|
93
93
|
// (transitive, deduped, max 8 total) and any --also names.
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
94
|
+
// Links are the owner's curation — private projects attach fine
|
|
95
|
+
// through them. `private` only keeps a project out of the pooled
|
|
96
|
+
// outside digest, the one unlinked exposure path.
|
|
97
97
|
const bySlug = new Map(listProjects(home).map((p) => [p.slug, p]));
|
|
98
98
|
const attach = [];
|
|
99
99
|
const seen = new Set();
|
|
@@ -103,17 +103,16 @@ export function cmdContext(flags) {
|
|
|
103
103
|
attach.push({ slug, name: bySlug.get(slug)?.name || slug, linked });
|
|
104
104
|
};
|
|
105
105
|
if (proj) push(proj.slug, false);
|
|
106
|
-
const queue = (entry?.links || []).map((k) => ({ k
|
|
106
|
+
const queue = (entry?.links || []).map((k) => ({ k }));
|
|
107
107
|
if (flags.also)
|
|
108
108
|
queue.push(
|
|
109
109
|
...String(flags.also)
|
|
110
110
|
.split(',')
|
|
111
|
-
.map((k) => ({ k
|
|
111
|
+
.map((k) => ({ k }))
|
|
112
112
|
);
|
|
113
|
-
const refused = [];
|
|
114
113
|
const unknown = [];
|
|
115
114
|
while (queue.length && attach.length < 8) {
|
|
116
|
-
const { k: raw
|
|
115
|
+
const { k: raw } = queue.shift();
|
|
117
116
|
const key = String(raw).trim().toLowerCase();
|
|
118
117
|
if (!key) continue;
|
|
119
118
|
const slug = resolveProjectSlug(home, key);
|
|
@@ -123,13 +122,8 @@ export function cmdContext(flags) {
|
|
|
123
122
|
}
|
|
124
123
|
if (seen.has(slug)) continue;
|
|
125
124
|
const e = projectEntry(cfg, { slug, name: key });
|
|
126
|
-
if (e?.private && via === 'transitive') {
|
|
127
|
-
refused.push(key);
|
|
128
|
-
continue;
|
|
129
|
-
}
|
|
130
125
|
push(slug, true);
|
|
131
|
-
if (e?.links)
|
|
132
|
-
queue.push(...e.links.map((l) => ({ k: l, via: 'transitive' })));
|
|
126
|
+
if (e?.links) queue.push(...e.links.map((l) => ({ k: l })));
|
|
133
127
|
}
|
|
134
128
|
|
|
135
129
|
const lines = [`<agent-memory repo="${home}">`];
|
|
@@ -165,11 +159,6 @@ export function cmdContext(flags) {
|
|
|
165
159
|
lines.push('', '## Recent project memory', recent.join('\n'));
|
|
166
160
|
}
|
|
167
161
|
if (indexLinks.length) lines.push('', '## Index', indexLinks.join('\n'));
|
|
168
|
-
if (refused.length)
|
|
169
|
-
lines.push(
|
|
170
|
-
'',
|
|
171
|
-
`<!-- ${refused.length} linked project(s) omitted by private policy: ${refused.join(', ')} -->`
|
|
172
|
-
);
|
|
173
162
|
if (unknown.length)
|
|
174
163
|
lines.push(
|
|
175
164
|
'',
|
package/lib/coverage.mjs
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { commit, ensureRepo, numFlag, withLock } from './core.mjs';
|
|
4
|
+
import { collectSessions, extractMessages } from './harvest.mjs';
|
|
5
|
+
|
|
6
|
+
const out = (s = '') => process.stdout.write(s + '\n');
|
|
7
|
+
|
|
8
|
+
// What "should have been saved" looks like in a user message: durable
|
|
9
|
+
// preferences, decisions, confirmations, and root-cause findings.
|
|
10
|
+
// STRONG markers alone qualify; WEAK markers only in short messages
|
|
11
|
+
// (long dumps happen to contain them by accident).
|
|
12
|
+
const STRONG =
|
|
13
|
+
/\b(prefer|always|never|remember|note that|decided|instead of|rather than|default to|don['’]?t use|do not use|we use|sticking with|confirmed|turns out|workaround|the fix was|root cause|deprecated)\b/i;
|
|
14
|
+
const WEAK = /\b(shipped|released|pinned|going with)\b/i;
|
|
15
|
+
|
|
16
|
+
// Non-user prose that rides the user role in transcripts: tool errors,
|
|
17
|
+
// context summaries, file/code dumps, listings, test output, diffs,
|
|
18
|
+
// annotations, agent-monologue steps (antigravity mixes them into the
|
|
19
|
+
// user slot).
|
|
20
|
+
const DUMP =
|
|
21
|
+
/^(error:|context summary|#|```|\{|\[|\/|\d+\s|\d+[:|]|[-\w./]+ total \d+|total \d+ |diff |--- |@@ |index |[MDAR] \S+\.\w+|response annotations|base directory)/i;
|
|
22
|
+
const heavyMarkup = (t) =>
|
|
23
|
+
(t.match(/[{}"|#*<>_=[\]\\`]/g) || []).length / t.length > 0.12;
|
|
24
|
+
const FILE_REFS =
|
|
25
|
+
/\b[\w.-]+\.(?:go|tsx?|mjs|jsx?|cs|json|ya?ml|py|rs|cpp|c|h|png|jpe?g|dll|exe|log|toml|sql|ps1|sh)\b/g;
|
|
26
|
+
// Source-code density: several of () ; {} // => == ++ --- is a paste
|
|
27
|
+
// of code, not a user statement.
|
|
28
|
+
const isCodey = (t) =>
|
|
29
|
+
(t.match(/[{}();]|\/\/|=>|={2,}|[+-]{3,}/g) || []).length >= 4;
|
|
30
|
+
const isDump = (t) =>
|
|
31
|
+
DUMP.test(t) ||
|
|
32
|
+
heavyMarkup(t) ||
|
|
33
|
+
isCodey(t) ||
|
|
34
|
+
/[drwx-]{10}/.test(t) ||
|
|
35
|
+
/(\d+ (files?|tests?) (passed|failed))|passed \(\d+\)|RUN v\d|✓/.test(t) ||
|
|
36
|
+
/^(the user (is|asked|wants)|we (need|should|can|must|will)\b)/i.test(t) ||
|
|
37
|
+
(t.match(FILE_REFS) || []).length >= 3;
|
|
38
|
+
const isDurable = (t) =>
|
|
39
|
+
!isDump(t) &&
|
|
40
|
+
(STRONG.test(t) || (t.length < 200 && WEAK.test(t)));
|
|
41
|
+
|
|
42
|
+
const STOP = new Set(
|
|
43
|
+
'the a an and or but with this that from into your you for are was were have has been not all can will would could should about when what which there their they them then than also just like over under more some any each other such only very make made does done here need want keep lets'.split(
|
|
44
|
+
' '
|
|
45
|
+
)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Most distinctive terms first: code tokens and long words carry the
|
|
49
|
+
// signal that a memory entry would share.
|
|
50
|
+
const terms = (text) =>
|
|
51
|
+
[
|
|
52
|
+
...new Set(
|
|
53
|
+
text
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.replace(/[^\p{L}\p{N}_.\/-]+/gu, ' ')
|
|
56
|
+
.split(/\s+/)
|
|
57
|
+
.filter((w) => w.length >= 4 && !STOP.has(w))
|
|
58
|
+
),
|
|
59
|
+
]
|
|
60
|
+
.sort((a, b) => b.length - a.length)
|
|
61
|
+
.slice(0, 12);
|
|
62
|
+
|
|
63
|
+
const normWords = (t) =>
|
|
64
|
+
t
|
|
65
|
+
.toLowerCase()
|
|
66
|
+
.replace(/[^\p{L}\p{N}_.\/-]+/gu, ' ')
|
|
67
|
+
.split(/\s+/)
|
|
68
|
+
.filter(Boolean);
|
|
69
|
+
|
|
70
|
+
const corpusText = (home) => {
|
|
71
|
+
const parts = [];
|
|
72
|
+
(function walk(dir) {
|
|
73
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
74
|
+
if (e.name === '.git') continue;
|
|
75
|
+
const p = path.join(dir, e.name);
|
|
76
|
+
if (e.isDirectory()) walk(p);
|
|
77
|
+
else if (e.name.endsWith('.md'))
|
|
78
|
+
parts.push(normWords(fs.readFileSync(p, 'utf8')).join(' '));
|
|
79
|
+
}
|
|
80
|
+
})(home);
|
|
81
|
+
return ` ${parts.join(' ')} `;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// A durable signal counts as covered when a 4-word run of its content
|
|
85
|
+
// appears verbatim in the corpus (near-verbatim save), or when ≥35% of
|
|
86
|
+
// its distinctive terms appear (paraphrased save). Term hits are
|
|
87
|
+
// prefix-aware at 6 chars so prefer matches prefers/preference.
|
|
88
|
+
const covered = (text, corpus) => {
|
|
89
|
+
const content = normWords(text).filter(
|
|
90
|
+
(w) => w.length >= 4 && !STOP.has(w)
|
|
91
|
+
);
|
|
92
|
+
for (let i = 0; i + 4 <= content.length; i++)
|
|
93
|
+
if (corpus.includes(` ${content.slice(i, i + 4).join(' ')} `))
|
|
94
|
+
return true;
|
|
95
|
+
const t = terms(text);
|
|
96
|
+
if (!t.length) return false;
|
|
97
|
+
const hit = (w) =>
|
|
98
|
+
corpus.includes(` ${w} `) ||
|
|
99
|
+
corpus.includes(` ${w.slice(0, Math.min(6, w.length))}`);
|
|
100
|
+
const hits = t.filter(hit).length;
|
|
101
|
+
return hits / t.length >= 0.35;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export function cmdCoverage(flags) {
|
|
105
|
+
const home = ensureRepo();
|
|
106
|
+
const days = numFlag(flags.days ?? '7', 7);
|
|
107
|
+
const since = flags.since
|
|
108
|
+
? new Date(flags.since).getTime()
|
|
109
|
+
: Date.now() - days * 864e5;
|
|
110
|
+
const max = numFlag(flags.max || '400', 400);
|
|
111
|
+
|
|
112
|
+
const found = collectSessions(since);
|
|
113
|
+
const msgs = extractMessages(found, { max, since });
|
|
114
|
+
const corpus = corpusText(home);
|
|
115
|
+
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
const durable = [];
|
|
118
|
+
for (const m of msgs) {
|
|
119
|
+
const key = m.text.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
120
|
+
if (seen.has(key) || !isDurable(m.text)) continue;
|
|
121
|
+
seen.add(key);
|
|
122
|
+
durable.push({ ...m, covered: covered(m.text, corpus) });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const hits = durable.filter((d) => d.covered).length;
|
|
126
|
+
const missed = durable.filter((d) => !d.covered);
|
|
127
|
+
const rate = durable.length ? Math.round((hits / durable.length) * 100) : null;
|
|
128
|
+
|
|
129
|
+
if (flags.json)
|
|
130
|
+
return out(
|
|
131
|
+
JSON.stringify(
|
|
132
|
+
{
|
|
133
|
+
days,
|
|
134
|
+
transcripts: found.length,
|
|
135
|
+
messages: msgs.length,
|
|
136
|
+
durable: durable.length,
|
|
137
|
+
covered: hits,
|
|
138
|
+
rate,
|
|
139
|
+
missed: missed.map((m) => ({
|
|
140
|
+
host: m.host,
|
|
141
|
+
date: m.mtime.slice(0, 10),
|
|
142
|
+
text: m.text.slice(0, 300),
|
|
143
|
+
})),
|
|
144
|
+
},
|
|
145
|
+
null,
|
|
146
|
+
2
|
|
147
|
+
)
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
if (!durable.length)
|
|
151
|
+
return out(`no durable signals found in ${found.length} transcript(s), ${days}d`);
|
|
152
|
+
out(
|
|
153
|
+
`coverage (${days}d): ${hits}/${durable.length} durable signals covered — save rate ${rate}%`
|
|
154
|
+
);
|
|
155
|
+
if (missed.length) {
|
|
156
|
+
out('missed:');
|
|
157
|
+
for (const m of missed.slice(0, 20))
|
|
158
|
+
out(` [${m.host}] ${m.mtime.slice(0, 10)} ${m.text.slice(0, 140)}`);
|
|
159
|
+
if (missed.length > 20) out(` … +${missed.length - 20} more`);
|
|
160
|
+
}
|
|
161
|
+
out(
|
|
162
|
+
'note: user-stated signals only; facts the agent learned silently are not observable from transcripts'
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Trend line: append this run so the metric has history.
|
|
166
|
+
if (!flags['no-log']) {
|
|
167
|
+
const log = path.join(home, 'dreams', 'coverage.jsonl');
|
|
168
|
+
const entry = {
|
|
169
|
+
ts: new Date().toISOString(),
|
|
170
|
+
days,
|
|
171
|
+
durable: durable.length,
|
|
172
|
+
covered: hits,
|
|
173
|
+
rate,
|
|
174
|
+
};
|
|
175
|
+
withLock(home, () => {
|
|
176
|
+
fs.mkdirSync(path.dirname(log), { recursive: true });
|
|
177
|
+
fs.appendFileSync(log, JSON.stringify(entry) + '\n');
|
|
178
|
+
commit(home, ['dreams/coverage.jsonl'], `Coverage ${rate}% (${hits}/${durable.length})`);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
package/lib/install.mjs
CHANGED
|
@@ -737,6 +737,19 @@ export function cmdDoctor() {
|
|
|
737
737
|
}
|
|
738
738
|
if (dead.length)
|
|
739
739
|
out(`config : ${dead.length} unresolvable project key(s): ${dead.join(', ')}`);
|
|
740
|
+
try {
|
|
741
|
+
const cov = fs
|
|
742
|
+
.readFileSync(path.join(homeDir, 'dreams', 'coverage.jsonl'), 'utf8')
|
|
743
|
+
.trim()
|
|
744
|
+
.split('\n')
|
|
745
|
+
.filter(Boolean)
|
|
746
|
+
.map((l) => JSON.parse(l));
|
|
747
|
+
const last = cov[cov.length - 1];
|
|
748
|
+
if (last?.rate != null)
|
|
749
|
+
out(
|
|
750
|
+
`save rate : ${last.rate}% (${last.covered}/${last.durable} durable signals, ${last.days}d, ${last.ts.slice(0, 10)})`
|
|
751
|
+
);
|
|
752
|
+
} catch {}
|
|
740
753
|
const missingPeers = (cfg.extraRepos || []).filter(
|
|
741
754
|
(p) =>
|
|
742
755
|
!fs.existsSync(
|
package/package.json
CHANGED
package/test/context.test.mjs
CHANGED
|
@@ -28,7 +28,7 @@ test('context in a repo shows its project memory', () => {
|
|
|
28
28
|
assert.match(r.stdout, /app uses redis cache/);
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
test('link attaches
|
|
31
|
+
test('link attaches siblings and private transitively (links are owner curation)', () => {
|
|
32
32
|
const home = tmpdir();
|
|
33
33
|
const base = tmpdir();
|
|
34
34
|
const repoA = initRepo(path.join(base, 'a'), 'https://github.com/acme/a.git');
|
|
@@ -45,13 +45,13 @@ test('link attaches a sibling project transitively; private blocks transitive on
|
|
|
45
45
|
const inB = run(['context'], { amrHome: home, cwd: repoB });
|
|
46
46
|
assert.match(inB.stdout, /a uses sqlite/);
|
|
47
47
|
|
|
48
|
-
// c links to b; a is private ->
|
|
48
|
+
// c links to b; a is private -> still attaches transitively, since
|
|
49
|
+
// the link chain is the owner's own curation
|
|
49
50
|
run(['link', 'b'], { amrHome: home, cwd: repoC });
|
|
50
51
|
run(['policy', 'a', 'private', 'true'], { amrHome: home, cwd: repoA });
|
|
51
52
|
const inC = run(['context'], { amrHome: home, cwd: repoC });
|
|
52
53
|
assert.match(inC.stdout, /b uses kafka/);
|
|
53
|
-
assert.match(inC.stdout, /
|
|
54
|
-
assert.ok(!inC.stdout.includes('a uses sqlite'));
|
|
54
|
+
assert.match(inC.stdout, /a uses sqlite/);
|
|
55
55
|
|
|
56
56
|
// explicit --also still attaches the private project
|
|
57
57
|
const explicit = run(['context', '--also', 'a'], { amrHome: home, cwd: repoC });
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { run, tmpdir } from './helpers.mjs';
|
|
6
|
+
|
|
7
|
+
function setup() {
|
|
8
|
+
const amrHome = tmpdir();
|
|
9
|
+
const fakeHome = tmpdir();
|
|
10
|
+
assert.equal(run(['ensure'], { amrHome, fakeHome }).status, 0);
|
|
11
|
+
return { amrHome, fakeHome };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function claudeJsonl(fakeHome, lines) {
|
|
15
|
+
const dir = path.join(fakeHome, '.claude', 'projects', 'projx');
|
|
16
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
17
|
+
const file = path.join(dir, 'sess.jsonl');
|
|
18
|
+
fs.writeFileSync(
|
|
19
|
+
file,
|
|
20
|
+
lines
|
|
21
|
+
.map((text) =>
|
|
22
|
+
JSON.stringify({ type: 'user', message: { content: text } })
|
|
23
|
+
)
|
|
24
|
+
.join('\n')
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('coverage reports save rate and lists missed durable signals', () => {
|
|
29
|
+
const { amrHome, fakeHome } = setup();
|
|
30
|
+
run(['add', 'prefers tabs over spaces'], { amrHome, fakeHome });
|
|
31
|
+
claudeJsonl(fakeHome, [
|
|
32
|
+
// saved: near-verbatim in corpus
|
|
33
|
+
'I prefer tabs over spaces everywhere, remember that',
|
|
34
|
+
// durable but never saved
|
|
35
|
+
'we decided the cache TTL should always be 300 seconds',
|
|
36
|
+
// a code dump that merely contains a marker word — excluded
|
|
37
|
+
'const x = () => { if (a) { return b; } }; // never skip this check',
|
|
38
|
+
// ordinary chatter — not durable
|
|
39
|
+
'looks good, ship it',
|
|
40
|
+
]);
|
|
41
|
+
const r = run(['coverage', '--days', '7', '--no-log'], {
|
|
42
|
+
amrHome,
|
|
43
|
+
fakeHome,
|
|
44
|
+
});
|
|
45
|
+
assert.equal(r.status, 0, r.stderr);
|
|
46
|
+
assert.match(r.stdout, /1\/2 durable signals covered — save rate 50%/);
|
|
47
|
+
assert.match(r.stdout, /cache TTL should always be 300/);
|
|
48
|
+
assert.ok(!r.stdout.includes('never skip this check'));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('coverage logs a trend line by default; --no-log skips it', () => {
|
|
52
|
+
const { amrHome, fakeHome } = setup();
|
|
53
|
+
claudeJsonl(fakeHome, ['we decided the db migration runs weekly']);
|
|
54
|
+
const log = path.join(amrHome, 'dreams', 'coverage.jsonl');
|
|
55
|
+
run(['coverage', '--no-log'], { amrHome, fakeHome });
|
|
56
|
+
assert.ok(!fs.existsSync(log));
|
|
57
|
+
const r = run(['coverage'], { amrHome, fakeHome });
|
|
58
|
+
assert.equal(r.status, 0, r.stderr);
|
|
59
|
+
const entry = JSON.parse(readLines(log).at(-1));
|
|
60
|
+
assert.equal(entry.durable, 1);
|
|
61
|
+
assert.equal(entry.covered, 0);
|
|
62
|
+
assert.equal(entry.rate, 0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
function readLines(file) {
|
|
66
|
+
return fs
|
|
67
|
+
.readFileSync(file, 'utf8')
|
|
68
|
+
.trim()
|
|
69
|
+
.split('\n')
|
|
70
|
+
.filter(Boolean);
|
|
71
|
+
}
|