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 +14 -0
- package/package.json +1 -1
- package/src/commands/push.js +26 -8
- package/src/commands/validate.js +407 -0
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.
|
|
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",
|
package/src/commands/push.js
CHANGED
|
@@ -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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
+
}
|