memoir-cli 3.10.2 → 3.11.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/bin/memoir.js CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  import { autopushCommand } from '../src/commands/autopush.js';
35
35
  import { whyCommand } from '../src/commands/why.js';
36
36
  import { autoRefreshCommand } from '../src/commands/auto-refresh.js';
37
+ import { validateCommand } from '../src/commands/validate.js';
37
38
  import { hooksInstallCommand, hooksUninstallCommand, hooksStatusCommand } from '../src/commands/hooks.js';
38
39
  import { capture as track, telemetryCommand } from '../src/telemetry.js';
39
40
  import { createRequire } from 'module';
@@ -268,6 +269,19 @@ program
268
269
  } catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
269
270
  });
270
271
 
272
+ program
273
+ .command('validate [paths...]')
274
+ .description('Check session state and memory entry files against the memoir format spec (docs/SPEC.md)')
275
+ .option('--strict', 'Treat warnings as failures')
276
+ .action(async (paths, options) => {
277
+ try {
278
+ await validateCommand(paths || [], options);
279
+ } catch (err) {
280
+ console.error(chalk.red('\n✖ Error:'), err.message);
281
+ process.exit(1);
282
+ }
283
+ });
284
+
271
285
  program
272
286
  .command('doctor')
273
287
  .alias('diagnose')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "3.10.2",
3
+ "version": "3.11.0",
4
4
  "mcpName": "io.github.camgitt/memoir",
5
5
  "description": "Private, portable AI memory: synced across every coding tool and machine, end-to-end encrypted, free. One memory for Claude Code, Cursor, Copilot, Gemini + more — MCP-native, zero-knowledge, open source.",
6
6
  "main": "src/index.js",
@@ -371,6 +371,18 @@ export async function pushCommand(options = {}) {
371
371
  let shouldEncrypt = config.encrypt;
372
372
 
373
373
  if (shouldEncrypt === undefined) {
374
+ if (background || !process.stdin.isTTY) {
375
+ // First push with nobody to ask (the detached autopush hook, CI, a
376
+ // pipe). Any inquirer prompt here dies or hangs against ignored
377
+ // stdio. Encrypt if MEMOIR_PASSPHRASE makes that possible;
378
+ // otherwise push unencrypted THIS ONCE without persisting the
379
+ // choice — a backup beats no backup, and the next interactive push
380
+ // still gets the real question (default Yes).
381
+ shouldEncrypt = Boolean(process.env.MEMOIR_PASSPHRASE);
382
+ if (!shouldEncrypt) {
383
+ config.encrypt = undefined; // do not let the fallthrough persist "off"
384
+ }
385
+ } else {
374
386
  // First push since encryption was added — ask once and save preference
375
387
  spinner.stop();
376
388
  const { wantEncrypt } = await inquirer.prompt([{
@@ -380,6 +392,7 @@ export async function pushCommand(options = {}) {
380
392
  default: true
381
393
  }]);
382
394
  shouldEncrypt = wantEncrypt;
395
+ }
383
396
 
384
397
  // Save to config so we don't ask again
385
398
  try {
@@ -401,14 +414,19 @@ export async function pushCommand(options = {}) {
401
414
  }
402
415
 
403
416
  if (shouldEncrypt) {
404
- spinner.stop();
405
- const { passphrase } = await inquirer.prompt([{
406
- type: 'password',
407
- name: 'passphrase',
408
- message: '🔒 Encryption passphrase:',
409
- mask: '*',
410
- validate: (input) => input.length >= 6 ? true : 'Passphrase must be at least 6 characters'
411
- }]);
417
+ // Headless pushes can supply the passphrase via env; interactive
418
+ // pushes are asked as before.
419
+ let passphrase = process.env.MEMOIR_PASSPHRASE || '';
420
+ if (!passphrase || passphrase.length < 6) {
421
+ spinner.stop();
422
+ ({ passphrase } = await inquirer.prompt([{
423
+ type: 'password',
424
+ name: 'passphrase',
425
+ message: '🔒 Encryption passphrase:',
426
+ mask: '*',
427
+ validate: (input) => input.length >= 6 ? true : 'Passphrase must be at least 6 characters'
428
+ }]));
429
+ }
412
430
  spinner.start(chalk.gray('Deriving encryption key...'));
413
431
 
414
432
  encryptedDir = path.join(os.tmpdir(), `memoir-encrypted-${Date.now()}`);
@@ -0,0 +1,407 @@
1
+ // `memoir validate` — structural conformance checks for the memoir format.
2
+ //
3
+ // docs/SPEC.md (v0.1 draft) is the normative text; schema/*.schema.json is
4
+ // the machine-readable mirror. The checks here are hand-rolled on purpose:
5
+ // no JSON Schema engine dependency for a format this small, and the few
6
+ // checks that matter (required fields, date sanity, tombstone invariants)
7
+ // stay readable as plain code.
8
+ //
9
+ // memoir validate validate the live session.json
10
+ // memoir validate <file.md> ... validate entry files
11
+ // memoir validate <dir> validate every *.md under dir
12
+ // (+ its session.json, if present)
13
+ // memoir validate --strict warnings count as failures
14
+ //
15
+ // Severity model:
16
+ // error — violates a MUST in SPEC.md; the file fails.
17
+ // warning — violates a SHOULD, or a legacy (pre-v0.1) dialect that
18
+ // readers must tolerate but writers must not emit.
19
+ // Exit code is 1 when any error occurred (or any warning, with --strict).
20
+
21
+ import chalk from 'chalk';
22
+ import fs from 'fs-extra';
23
+ import path from 'path';
24
+ import { paths } from '../session/state.js';
25
+ import { SCHEMA_VERSION } from '../session/migrations.js';
26
+
27
+ const ENTRY_TYPES = ['fact', 'preference', 'decision', 'lesson', 'goal', 'next_action'];
28
+
29
+ // Pre-v0.1 type vocabulary (SPEC.md Appendix A) — accepted with a warning.
30
+ const LEGACY_TYPES = {
31
+ user: 'preference',
32
+ feedback: 'lesson',
33
+ reference: 'fact',
34
+ project: null, // dossier — no atomic mapping; read as opaque legacy entry
35
+ };
36
+
37
+ // Frontmatter date-ish fields, checked for ISO 8601 shape when present.
38
+ const DATE_FIELDS = ['created', 'updated', 'date', 'set_on', 'added', 'done_at', 'hidden_at', 'last_fired'];
39
+
40
+ // ── Frontmatter parsing (restricted YAML subset per SPEC.md 3.1) ──
41
+ //
42
+ // Supports: scalar `key: value`, one level of nested mapping, and simple
43
+ // `- item` string lists. That is all the format allows in frontmatter, so
44
+ // that is all this parses. Not a general YAML parser and not meant to be.
45
+
46
+ function parseScalar(raw) {
47
+ let v = String(raw).trim();
48
+ if (
49
+ (v.startsWith('"') && v.endsWith('"') && v.length >= 2) ||
50
+ (v.startsWith("'") && v.endsWith("'") && v.length >= 2)
51
+ ) {
52
+ v = v.slice(1, -1);
53
+ }
54
+ if (v === 'true') return true;
55
+ if (v === 'false') return false;
56
+ if (/^-?\d+$/.test(v)) return parseInt(v, 10);
57
+ return v;
58
+ }
59
+
60
+ export function parseFrontmatter(raw) {
61
+ const lines = String(raw).split(/\r?\n/);
62
+ if ((lines[0] || '').trim() !== '---') {
63
+ return { present: false, fields: {}, body: raw, error: null };
64
+ }
65
+ let end = -1;
66
+ for (let i = 1; i < lines.length; i++) {
67
+ if (lines[i].trim() === '---') { end = i; break; }
68
+ }
69
+ if (end === -1) {
70
+ return { present: true, fields: {}, body: raw, error: 'unterminated frontmatter (no closing ---)' };
71
+ }
72
+
73
+ const fields = {};
74
+ let openKey = null; // most recent top-level key whose value may nest
75
+ for (let i = 1; i < end; i++) {
76
+ const line = lines[i];
77
+ const trimmed = line.trim();
78
+ if (!trimmed || trimmed.startsWith('#')) continue;
79
+
80
+ if (/^\s/.test(line) && openKey) {
81
+ // One level of nesting under openKey: nested map entry or list item.
82
+ if (trimmed.startsWith('- ')) {
83
+ if (!Array.isArray(fields[openKey])) fields[openKey] = [];
84
+ fields[openKey].push(parseScalar(trimmed.slice(2)));
85
+ } else {
86
+ const m = trimmed.match(/^([^:]+):\s*(.*)$/);
87
+ if (m) {
88
+ if (typeof fields[openKey] !== 'object' || fields[openKey] === null || Array.isArray(fields[openKey])) {
89
+ fields[openKey] = {};
90
+ }
91
+ fields[openKey][m[1].trim()] = parseScalar(m[2]);
92
+ }
93
+ }
94
+ continue;
95
+ }
96
+
97
+ const m = line.match(/^([^:\s][^:]*):\s*(.*)$/);
98
+ if (!m) continue; // tolerate lines we don't understand — validation reports, not parsing, is the job
99
+ const key = m[1].trim();
100
+ if (m[2] === '') {
101
+ openKey = key;
102
+ fields[key] = '';
103
+ } else {
104
+ openKey = null;
105
+ fields[key] = parseScalar(m[2]);
106
+ }
107
+ }
108
+
109
+ return { present: true, fields, body: lines.slice(end + 1).join('\n'), error: null };
110
+ }
111
+
112
+ // ── Shared checks ────────────────────────────────────────────────
113
+
114
+ function isIsoDateString(v) {
115
+ return typeof v === 'string' && /^\d{4}-\d{2}-\d{2}/.test(v) && !Number.isNaN(new Date(v).getTime());
116
+ }
117
+
118
+ // ── Entry file validation (SPEC.md section 3, entry.schema.json) ──
119
+
120
+ export function validateEntry(raw) {
121
+ const errors = [];
122
+ const warnings = [];
123
+ const { present, fields, body, error } = parseFrontmatter(raw);
124
+
125
+ if (!present) {
126
+ warnings.push('no frontmatter — legacy bare-markdown entry (SPEC.md Appendix A); readers treat as opaque, writers must not emit this');
127
+ return { errors, warnings };
128
+ }
129
+ if (error) {
130
+ errors.push(error);
131
+ return { errors, warnings };
132
+ }
133
+
134
+ // Resolve type across dialects: canonical top-level `type`, legacy `metadata.type`.
135
+ let type = fields.type;
136
+ if (type == null && fields.metadata && typeof fields.metadata === 'object' && fields.metadata.type != null) {
137
+ type = fields.metadata.type;
138
+ warnings.push('legacy dialect: type nested under metadata (canonical: top-level `type`)');
139
+ }
140
+
141
+ if (!fields.name || typeof fields.name !== 'string' || !String(fields.name).trim()) {
142
+ errors.push('missing required field: name');
143
+ }
144
+ if (!fields.description) {
145
+ warnings.push('missing description (SHOULD) — entry is invisible in indexes and pickers');
146
+ }
147
+
148
+ if (type == null) {
149
+ errors.push(`missing required field: type (one of: ${ENTRY_TYPES.join(', ')})`);
150
+ } else if (ENTRY_TYPES.includes(type)) {
151
+ validateCanonicalType(type, fields, body, errors, warnings);
152
+ } else if (type in LEGACY_TYPES) {
153
+ const mapped = LEGACY_TYPES[type];
154
+ warnings.push(`legacy type "${type}"${mapped ? ` (reads as ${mapped})` : ' (dossier — no atomic mapping)'} — canonical types: ${ENTRY_TYPES.join(', ')}`);
155
+ if (type === 'feedback' && !/\*\*How to apply:?\*\*/i.test(body)) {
156
+ warnings.push('legacy feedback entry without a **How to apply:** body section — lesson has no application rule');
157
+ }
158
+ } else {
159
+ errors.push(`unknown type "${type}" — canonical: ${ENTRY_TYPES.join(', ')}; legacy (read-only): ${Object.keys(LEGACY_TYPES).join(', ')}`);
160
+ }
161
+
162
+ for (const f of DATE_FIELDS) {
163
+ if (fields[f] != null && !isIsoDateString(fields[f])) {
164
+ errors.push(`field ${f} is not an ISO 8601 date: ${JSON.stringify(fields[f])}`);
165
+ }
166
+ }
167
+ if (fields.schema_version != null && (!Number.isInteger(fields.schema_version) || fields.schema_version < 1)) {
168
+ errors.push(`schema_version must be a positive integer, got ${JSON.stringify(fields.schema_version)}`);
169
+ }
170
+ if (Number.isInteger(fields.schema_version) && fields.schema_version > 1) {
171
+ warnings.push(`schema_version ${fields.schema_version} is newer than this build understands (v1) — forward-version rule applies: quarantine + degrade, never guess (SPEC.md section 6)`);
172
+ }
173
+
174
+ return { errors, warnings };
175
+ }
176
+
177
+ function validateCanonicalType(type, fields, body, errors, warnings) {
178
+ if (type === 'decision') {
179
+ if (!fields.why) warnings.push('decision without a why (required-encouraged) — a decision without a why is half a decision');
180
+ if (!fields.rejected) warnings.push('decision without a rejected alternative (required-encouraged)');
181
+ if (fields.hidden === true && !fields.hidden_at) {
182
+ errors.push('hidden: true without hidden_at — tombstones must carry when they were set (SPEC.md 5.3.1)');
183
+ }
184
+ } else if (type === 'lesson') {
185
+ if (!fields.trigger) {
186
+ errors.push('lesson missing required field: trigger — when does this lesson apply?');
187
+ }
188
+ if (!fields.how_to_apply) {
189
+ if (/\*\*How to apply:?\*\*/i.test(body)) {
190
+ warnings.push('how_to_apply lives in the body (**How to apply:** section) — accepted for legacy entries; canonical form is the frontmatter key');
191
+ } else {
192
+ errors.push('lesson missing required field: how_to_apply — without an application rule it is an anecdote, not a lesson');
193
+ }
194
+ }
195
+ if (fields.fired_count != null && (!Number.isInteger(fields.fired_count) || fields.fired_count < 0)) {
196
+ errors.push(`fired_count must be a non-negative integer, got ${JSON.stringify(fields.fired_count)}`);
197
+ }
198
+ } else if (type === 'next_action') {
199
+ if (!fields.added) {
200
+ errors.push('next_action missing required field: added — completion semantics (done_at vs added) are undefined without it');
201
+ }
202
+ if (isIsoDateString(fields.added) && isIsoDateString(fields.done_at) && new Date(fields.done_at) < new Date(fields.added)) {
203
+ warnings.push('done_at is earlier than added — this action was completed before it existed');
204
+ }
205
+ } else if (type === 'preference') {
206
+ if (fields.scope != null && !['global', 'project'].includes(fields.scope)) {
207
+ warnings.push(`preference scope should be "global" or "project", got ${JSON.stringify(fields.scope)}`);
208
+ }
209
+ }
210
+ // fact, goal: no extra required fields.
211
+ }
212
+
213
+ // ── Session file validation (SPEC.md section 4, session.schema.json) ──
214
+
215
+ function checkListItems(list, listName, dateField, errors, warnings) {
216
+ if (!Array.isArray(list)) {
217
+ errors.push(`current.${listName} must be an array`);
218
+ return;
219
+ }
220
+ list.forEach((item, i) => {
221
+ const at = `current.${listName}[${i}]`;
222
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
223
+ errors.push(`${at} must be an object`);
224
+ return;
225
+ }
226
+ if (typeof item.text !== 'string' || !item.text.trim()) {
227
+ errors.push(`${at} missing required field: text (the merge identity key)`);
228
+ }
229
+ if (item[dateField] != null && !isIsoDateString(item[dateField])) {
230
+ errors.push(`${at}.${dateField} is not an ISO 8601 date-time: ${JSON.stringify(item[dateField])}`);
231
+ }
232
+ if (item[dateField] == null) {
233
+ warnings.push(`${at} missing ${dateField} — merges treat a missing date as the epoch (always loses newest-wins)`);
234
+ }
235
+ });
236
+ }
237
+
238
+ export function validateSessionObject(obj) {
239
+ const errors = [];
240
+ const warnings = [];
241
+
242
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
243
+ errors.push('session must be a JSON object');
244
+ return { errors, warnings };
245
+ }
246
+
247
+ if (!Number.isInteger(obj.version) || obj.version < 1) {
248
+ errors.push(`version must be a positive integer, got ${JSON.stringify(obj.version)}`);
249
+ } else if (obj.version > SCHEMA_VERSION) {
250
+ warnings.push(`version ${obj.version} is newer than this build understands (v${SCHEMA_VERSION}) — forward-version rule applies: quarantine + degrade, never guess (SPEC.md section 6)`);
251
+ }
252
+
253
+ for (const f of ['created_at', 'updated_at']) {
254
+ if (obj[f] == null) errors.push(`missing required field: ${f}`);
255
+ else if (!isIsoDateString(obj[f])) errors.push(`${f} is not an ISO 8601 date-time: ${JSON.stringify(obj[f])}`);
256
+ }
257
+
258
+ if (!obj.machines || typeof obj.machines !== 'object' || Array.isArray(obj.machines)) {
259
+ errors.push('missing required field: machines (object of machine-uuid -> { label, last_seen })');
260
+ } else {
261
+ for (const [id, m] of Object.entries(obj.machines)) {
262
+ if (!m || typeof m !== 'object') { errors.push(`machines["${id}"] must be an object`); continue; }
263
+ if (typeof m.label !== 'string') errors.push(`machines["${id}"] missing required field: label`);
264
+ if (!isIsoDateString(m.last_seen)) errors.push(`machines["${id}"].last_seen is not an ISO 8601 date-time`);
265
+ }
266
+ }
267
+
268
+ const cur = obj.current;
269
+ if (!cur || typeof cur !== 'object' || Array.isArray(cur)) {
270
+ errors.push('missing required field: current');
271
+ } else {
272
+ checkListItems(cur.goals, 'goals', 'set_on', errors, warnings);
273
+ checkListItems(cur.next_actions, 'next_actions', 'added', errors, warnings);
274
+ checkListItems(cur.open_questions, 'open_questions', 'asked', errors, warnings);
275
+ checkListItems(cur.decisions, 'decisions', 'date', errors, warnings);
276
+ (Array.isArray(cur.decisions) ? cur.decisions : []).forEach((d, i) => {
277
+ if (d && d.hidden === true && !isIsoDateString(d.hidden_at)) {
278
+ errors.push(`current.decisions[${i}] hidden: true without a valid hidden_at (SPEC.md 5.3.1)`);
279
+ }
280
+ });
281
+ // completed_actions is optional (absent = empty), but when present its
282
+ // tombstones must be well-formed or the temporal merge rule breaks.
283
+ if (cur.completed_actions != null) {
284
+ if (!Array.isArray(cur.completed_actions)) {
285
+ errors.push('current.completed_actions must be an array when present');
286
+ } else {
287
+ cur.completed_actions.forEach((t, i) => {
288
+ const at = `current.completed_actions[${i}]`;
289
+ if (!t || typeof t !== 'object') { errors.push(`${at} must be an object`); return; }
290
+ if (typeof t.text !== 'string' || !t.text.trim()) errors.push(`${at} missing required field: text`);
291
+ if (!isIsoDateString(t.done_at)) errors.push(`${at} missing/invalid required field: done_at (the tombstone is meaningless without it)`);
292
+ });
293
+ }
294
+ }
295
+ }
296
+
297
+ if (!Array.isArray(obj.history)) {
298
+ errors.push('missing required field: history (array)');
299
+ } else {
300
+ obj.history.forEach((h, i) => {
301
+ if (!h || typeof h !== 'object') { errors.push(`history[${i}] must be an object`); return; }
302
+ if (!isIsoDateString(h.date)) errors.push(`history[${i}] missing/invalid required field: date`);
303
+ if (h.files_touched != null && !Array.isArray(h.files_touched)) errors.push(`history[${i}].files_touched must be an array`);
304
+ if (h.duration_min != null && typeof h.duration_min !== 'number') errors.push(`history[${i}].duration_min must be a number or null`);
305
+ });
306
+ }
307
+
308
+ return { errors, warnings };
309
+ }
310
+
311
+ // ── File collection ──────────────────────────────────────────────
312
+
313
+ async function collectTargets(args) {
314
+ // Returns [{ path, kind: 'entry' | 'session' }]. No args: the live session.json.
315
+ if (!args.length) {
316
+ return [{ path: paths.session, kind: 'session' }];
317
+ }
318
+ const targets = [];
319
+ for (const arg of args) {
320
+ const p = path.resolve(arg);
321
+ let stat;
322
+ try { stat = await fs.stat(p); }
323
+ catch { targets.push({ path: p, kind: 'missing' }); continue; }
324
+ if (stat.isDirectory()) {
325
+ const sessionPath = path.join(p, 'session.json');
326
+ if (await fs.pathExists(sessionPath)) targets.push({ path: sessionPath, kind: 'session' });
327
+ const stack = [p];
328
+ while (stack.length) {
329
+ const dir = stack.pop();
330
+ let entries = [];
331
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { continue; }
332
+ for (const e of entries) {
333
+ if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
334
+ const full = path.join(dir, e.name);
335
+ if (e.isDirectory()) stack.push(full);
336
+ else if (e.name.endsWith('.md')) targets.push({ path: full, kind: 'entry' });
337
+ }
338
+ }
339
+ } else if (p.endsWith('.json')) {
340
+ targets.push({ path: p, kind: 'session' });
341
+ } else {
342
+ targets.push({ path: p, kind: 'entry' });
343
+ }
344
+ }
345
+ return targets;
346
+ }
347
+
348
+ // ── Command ──────────────────────────────────────────────────────
349
+
350
+ export async function validateCommand(args = [], options = {}) {
351
+ const targets = await collectTargets(args);
352
+
353
+ console.log('\n' + chalk.bold.white(' memoir validate') + chalk.gray(' — format v0.1 draft (docs/SPEC.md)') + '\n');
354
+
355
+ let checked = 0;
356
+ let failed = 0;
357
+ let totalWarnings = 0;
358
+
359
+ for (const target of targets) {
360
+ const rel = target.path.startsWith(process.cwd()) ? path.relative(process.cwd(), target.path) : target.path;
361
+
362
+ if (target.kind === 'missing') {
363
+ failed++;
364
+ console.log(chalk.red(' ✖ ') + chalk.white(rel));
365
+ console.log(chalk.red(' error ') + 'file not found');
366
+ continue;
367
+ }
368
+
369
+ checked++;
370
+ let result;
371
+ if (target.kind === 'session') {
372
+ let parsed;
373
+ try {
374
+ parsed = JSON.parse(await fs.readFile(target.path, 'utf8'));
375
+ } catch (err) {
376
+ result = { errors: [`unparseable JSON: ${err.message}`], warnings: [] };
377
+ }
378
+ if (!result) result = validateSessionObject(parsed);
379
+ } else {
380
+ let raw;
381
+ try {
382
+ raw = await fs.readFile(target.path, 'utf8');
383
+ } catch (err) {
384
+ result = { errors: [`unreadable: ${err.message}`], warnings: [] };
385
+ }
386
+ if (!result) result = validateEntry(raw);
387
+ }
388
+
389
+ const { errors, warnings } = result;
390
+ totalWarnings += warnings.length;
391
+ const fails = errors.length > 0 || (options.strict && warnings.length > 0);
392
+ if (fails) failed++;
393
+
394
+ const mark = fails ? chalk.red(' ✖ ') : warnings.length ? chalk.yellow(' ⚠ ') : chalk.green(' ✔ ');
395
+ console.log(mark + chalk.white(rel));
396
+ for (const e of errors) console.log(chalk.red(' error ') + e);
397
+ for (const w of warnings) console.log(chalk.yellow(' warning ') + w);
398
+ }
399
+
400
+ const parts = [`${checked} file${checked !== 1 ? 's' : ''} checked`];
401
+ parts.push(failed > 0 ? chalk.red(`${failed} failed`) : chalk.green('all passed'));
402
+ if (totalWarnings > 0) parts.push(chalk.yellow(`${totalWarnings} warning${totalWarnings !== 1 ? 's' : ''}`));
403
+ console.log('\n ' + parts.join(chalk.gray(' · ')) + '\n');
404
+
405
+ if (failed > 0) process.exitCode = 1;
406
+ return { checked, failed, warnings: totalWarnings };
407
+ }