yam-flow 2.8.1 → 2.8.2
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 +10 -0
- package/COMMANDS.md +7 -2
- package/README.md +5 -1
- package/dist/bin/yam.js +141 -38
- package/dist/lib/design-production.d.ts +6 -6
- package/dist/lib/external-updates.d.ts +6 -1
- package/dist/lib/external-updates.js +93 -6
- package/dist/lib/hook-readiness.d.ts +54 -0
- package/dist/lib/hook-readiness.js +259 -0
- package/dist/lib/memory-records.d.ts +29 -0
- package/dist/lib/memory-records.js +92 -0
- package/dist/lib/verification-closure.d.ts +1 -1
- package/package.json +6 -2
- package/src/bin/yam.ts +122 -35
- package/src/lib/external-updates.ts +87 -6
- package/src/lib/hook-readiness.ts +215 -0
- package/src/lib/memory-records.ts +109 -0
- package/yam.manifest.json +1 -1
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import fsp from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
|
|
8
|
+
const MAX_BYTES = 256 * 1024;
|
|
9
|
+
const sha = (text: string) => createHash('sha256').update(text).digest('hex');
|
|
10
|
+
|
|
11
|
+
/** Select an upgrade-stable alias only when it resolves to the running Node binary. */
|
|
12
|
+
export async function selectHookNode(executable = process.execPath, searchPath = process.env.PATH || '') {
|
|
13
|
+
const real = await fsp.realpath(executable);
|
|
14
|
+
const running = await fsp.stat(real);
|
|
15
|
+
const candidates = searchPath.split(path.delimiter).filter((dir) => path.isAbsolute(dir))
|
|
16
|
+
.map((dir) => path.join(dir, process.platform === 'win32' ? 'node.exe' : 'node'));
|
|
17
|
+
for (const candidate of candidates) {
|
|
18
|
+
try {
|
|
19
|
+
const canonical = await fsp.realpath(candidate);
|
|
20
|
+
const stat = await fsp.stat(canonical);
|
|
21
|
+
await fsp.access(candidate, fs.constants.X_OK);
|
|
22
|
+
if (stat.isFile() && stat.dev === running.dev && stat.ino === running.ino && canonical === real) {
|
|
23
|
+
return { executable: candidate, canonical, selection: 'verified_runtime_alias', dev: stat.dev, ino: stat.ino };
|
|
24
|
+
}
|
|
25
|
+
} catch { /* An unverified PATH candidate is never executed. */ }
|
|
26
|
+
}
|
|
27
|
+
return { executable: real, canonical: real, selection: 'current_runtime_fallback', dev: running.dev, ino: running.ino };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function summarizeHookDiscovery(value: unknown, cwd: string, commands: Record<string, string[]>, configPath?: string) {
|
|
31
|
+
const input = value as { data?: Array<{ cwd?: string; hooks?: Array<Record<string, unknown>>; errors?: unknown[]; warnings?: unknown[] }> };
|
|
32
|
+
if (!Array.isArray(input?.data)) return { state: 'unknown', reason: 'unsupported_response_schema', profiles: {} };
|
|
33
|
+
const entry = input.data.find((row) => row.cwd === cwd);
|
|
34
|
+
if (!entry || !Array.isArray(entry.hooks) || !Array.isArray(entry.errors) || !Array.isArray(entry.warnings)) {
|
|
35
|
+
return { state: 'unknown', reason: 'missing_cwd_or_unsupported_response', profiles: {} };
|
|
36
|
+
}
|
|
37
|
+
const profiles = Object.fromEntries(Object.entries(commands).map(([profile, expected]) => {
|
|
38
|
+
const matches = entry.hooks.filter((hook) => hook.handlerType === 'command' && expected.includes(String(hook.command))
|
|
39
|
+
&& (!configPath || hook.sourcePath === configPath));
|
|
40
|
+
const approved = matches.filter((hook) => hook.enabled === true && ['trusted', 'managed'].includes(String(hook.trustStatus)));
|
|
41
|
+
const expectedEvents = profile === 'study-note' ? ['userPromptSubmit', 'stop'] : ['userPromptSubmit'];
|
|
42
|
+
return [profile, { state: matches.length ? 'discovered' : 'not_discovered', enabled_trusted: approved.length,
|
|
43
|
+
eligible_event_coverage: expectedEvents.every((event) => approved.filter((hook) => hook.eventName === event).length === 1),
|
|
44
|
+
discovered_count: matches.length, trust_statuses: [...new Set(matches.map((hook) => String(hook.trustStatus)))],
|
|
45
|
+
execution_observed: false }];
|
|
46
|
+
}));
|
|
47
|
+
return { state: entry.errors.length ? 'partial' : 'queried', reason: entry.errors.length ? 'host_discovery_errors' : '',
|
|
48
|
+
profiles, warning_count: entry.warnings.length, error_count: entry.errors.length };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Opt-in discovery in a new app-server process, not the active desktop session. */
|
|
52
|
+
export async function probeHookDiscovery(options: { executable: string; cwd: string; commands: Record<string, string[]>; configPath?: string; timeoutMs?: number }) {
|
|
53
|
+
const timeoutMs = Math.min(Math.max(options.timeoutMs || 5000, 100), 10000);
|
|
54
|
+
return new Promise<Record<string, unknown>>((resolve) => {
|
|
55
|
+
let finished = false;
|
|
56
|
+
let stopping = false;
|
|
57
|
+
let failure = '';
|
|
58
|
+
let result: Record<string, unknown>;
|
|
59
|
+
let buffer = '';
|
|
60
|
+
let total = 0;
|
|
61
|
+
let timer: NodeJS.Timeout;
|
|
62
|
+
let killTimer: NodeJS.Timeout;
|
|
63
|
+
const child = spawn(options.executable, ['app-server'], { cwd: options.cwd, stdio: ['pipe', 'pipe', 'pipe'], shell: false });
|
|
64
|
+
const stop = (reason = '') => {
|
|
65
|
+
if (stopping) return;
|
|
66
|
+
stopping = true;
|
|
67
|
+
failure = reason;
|
|
68
|
+
child.stdin.end();
|
|
69
|
+
child.kill('SIGTERM');
|
|
70
|
+
killTimer = setTimeout(() => child.kill('SIGKILL'), 500);
|
|
71
|
+
};
|
|
72
|
+
const send = (value: unknown) => { if (!stopping) child.stdin.write(`${JSON.stringify(value)}\n`); };
|
|
73
|
+
child.stdin.on('error', () => stop('protocol_stdin_failed'));
|
|
74
|
+
child.on('error', () => { failure = 'host_unavailable'; });
|
|
75
|
+
child.stderr.on('data', (chunk) => { total += chunk.length; if (total > MAX_BYTES) stop('output_limit'); });
|
|
76
|
+
child.stdout.on('data', (chunk) => {
|
|
77
|
+
total += chunk.length;
|
|
78
|
+
if (total > MAX_BYTES) return stop('output_limit');
|
|
79
|
+
buffer += chunk.toString('utf8');
|
|
80
|
+
let index: number;
|
|
81
|
+
while (!stopping && (index = buffer.indexOf('\n')) >= 0) {
|
|
82
|
+
const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
|
|
83
|
+
let message;
|
|
84
|
+
try { message = JSON.parse(line); } catch { stop('invalid_protocol_json'); break; }
|
|
85
|
+
if (message.id === 0) {
|
|
86
|
+
if (message.error) { stop('initialization_rejected'); break; }
|
|
87
|
+
send({ method: 'initialized', params: {} });
|
|
88
|
+
send({ method: 'hooks/list', id: 1, params: { cwds: [options.cwd] } });
|
|
89
|
+
} else if (message.id === 1) {
|
|
90
|
+
if (message.error) { stop('hooks_list_unsupported_or_rejected'); break; }
|
|
91
|
+
result = summarizeHookDiscovery(message.result, options.cwd, options.commands, options.configPath);
|
|
92
|
+
stop();
|
|
93
|
+
} else if (message.id != null && message.method) {
|
|
94
|
+
stop('unexpected_server_request');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
child.on('close', (_code, signal) => {
|
|
99
|
+
if (finished) return;
|
|
100
|
+
finished = true;
|
|
101
|
+
clearTimeout(timer); clearTimeout(killTimer);
|
|
102
|
+
resolve({ ...(result || { state: 'unknown', profiles: {} }), ...(failure ? { state: 'unknown', reason: failure } : {}),
|
|
103
|
+
host_context: 'separate_app_server_probe', active_session_loaded: 'not_measured',
|
|
104
|
+
execution_observed: false, process_exit_observed: true, termination_signal: signal || null,
|
|
105
|
+
truth_status: 'partial', reason: failure || (result ? result.reason : 'host_exited_before_response') });
|
|
106
|
+
});
|
|
107
|
+
timer = setTimeout(() => stop('probe_timeout'), timeoutMs);
|
|
108
|
+
send({ method: 'initialize', id: 0, params: { clientInfo: { name: 'yam_hook_probe', version: '1' }, capabilities: { experimentalApi: true } } });
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function safeObservationDirectory(cwd: string, create: boolean) {
|
|
113
|
+
const root = await fsp.realpath(cwd);
|
|
114
|
+
let current = root;
|
|
115
|
+
const identities: Array<{ target: string; dev: number; ino: number }> = [];
|
|
116
|
+
for (const part of ['', '.yam', 'hooks']) {
|
|
117
|
+
if (part) current = path.join(current, part);
|
|
118
|
+
if (create && part) await fsp.mkdir(current, { mode: 0o700 }).catch((error) => { if (error.code !== 'EEXIST') throw error; });
|
|
119
|
+
const stat = await fsp.lstat(current);
|
|
120
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('unsafe_observation_directory');
|
|
121
|
+
identities.push({ target: current, dev: stat.dev, ino: stat.ino });
|
|
122
|
+
}
|
|
123
|
+
return { directory: current, identities };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function checkParents(identities: Array<{ target: string; dev: number; ino: number }>) {
|
|
127
|
+
for (const identity of identities) {
|
|
128
|
+
const stat = await fsp.lstat(identity.target);
|
|
129
|
+
if (stat.isSymbolicLink() || !stat.isDirectory() || stat.dev !== identity.dev || stat.ino !== identity.ino) throw new Error('observation_parent_changed');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function configurationDigest(cwd: string) {
|
|
134
|
+
const paths = [path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'hooks.json'), path.join(await fsp.realpath(cwd), '.codex', 'hooks.json')];
|
|
135
|
+
const values = [];
|
|
136
|
+
for (const target of paths) {
|
|
137
|
+
try {
|
|
138
|
+
const before = await fsp.lstat(target);
|
|
139
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_BYTES) throw new Error('unsafe_hook_configuration');
|
|
140
|
+
const handle = await fsp.open(target, fs.constants.O_RDONLY | (process.platform === 'win32' ? 0 : fs.constants.O_NOFOLLOW || 0));
|
|
141
|
+
try {
|
|
142
|
+
const stat = await handle.stat();
|
|
143
|
+
if (stat.dev !== before.dev || stat.ino !== before.ino || stat.size > MAX_BYTES) throw new Error('hook_configuration_changed');
|
|
144
|
+
const buffer = Buffer.alloc(MAX_BYTES + 1);
|
|
145
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
146
|
+
if (bytesRead > MAX_BYTES) throw new Error('hook_configuration_limit');
|
|
147
|
+
values.push(sha(buffer.subarray(0, bytesRead).toString('utf8')));
|
|
148
|
+
} finally { await handle.close(); }
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
151
|
+
values.push('absent');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return sha(JSON.stringify(values));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function recordHookObservation(cwd: string, profile: string, event: string, entrypoint: string) {
|
|
158
|
+
if (!['lite', 'study-note'].includes(profile) || !['UserPromptSubmit', 'Stop'].includes(event)) throw new Error('unsupported_observation');
|
|
159
|
+
const { directory, identities } = await safeObservationDirectory(cwd, true);
|
|
160
|
+
const value = { schema: 'yam.hook-observation.v1', profile, event, observed_at: new Date().toISOString(),
|
|
161
|
+
cwd_digest: sha(await fsp.realpath(cwd)), entrypoint_digest: sha(await fsp.readFile(entrypoint, 'utf8')),
|
|
162
|
+
configuration_digest: await configurationDigest(cwd),
|
|
163
|
+
host_authenticated: false, evidence_kind: 'local_entrypoint_invocation' };
|
|
164
|
+
const target = path.join(directory, `${profile}.json`);
|
|
165
|
+
const noFollow = process.platform === 'win32' ? 0 : fs.constants.O_NOFOLLOW || 0;
|
|
166
|
+
const before = await fsp.lstat(target).catch((error) => { if (error.code === 'ENOENT') return null; throw error; });
|
|
167
|
+
if (before && (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1)) throw new Error('unsafe_observation_file');
|
|
168
|
+
const handle = await fsp.open(target, fs.constants.O_WRONLY | noFollow | (before ? 0 : fs.constants.O_CREAT | fs.constants.O_EXCL), 0o600);
|
|
169
|
+
try {
|
|
170
|
+
const stat = await handle.stat();
|
|
171
|
+
const pathname = await fsp.lstat(target);
|
|
172
|
+
if (!stat.isFile() || stat.nlink !== 1 || pathname.isSymbolicLink() || pathname.dev !== stat.dev || pathname.ino !== stat.ino
|
|
173
|
+
|| (before && (before.dev !== stat.dev || before.ino !== stat.ino))) throw new Error('unsafe_observation_file');
|
|
174
|
+
await checkParents(identities);
|
|
175
|
+
await handle.chmod(0o600);
|
|
176
|
+
await handle.truncate(0);
|
|
177
|
+
await handle.writeFile(`${JSON.stringify(value)}\n`);
|
|
178
|
+
await checkParents(identities);
|
|
179
|
+
const after = await fsp.lstat(target);
|
|
180
|
+
if (after.isSymbolicLink() || after.dev !== stat.dev || after.ino !== stat.ino) throw new Error('observation_identity_changed');
|
|
181
|
+
} finally { await handle.close(); }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function inspectHookObservation(cwd: string, profile: string, entrypoint: string) {
|
|
185
|
+
try {
|
|
186
|
+
if (!['lite', 'study-note'].includes(profile)) throw new Error('unsupported_profile');
|
|
187
|
+
const { directory, identities } = await safeObservationDirectory(cwd, false);
|
|
188
|
+
const target = path.join(directory, `${profile}.json`);
|
|
189
|
+
const before = await fsp.lstat(target);
|
|
190
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > 4096 || before.nlink !== 1) throw new Error('unsafe_observation_file');
|
|
191
|
+
const handle = await fsp.open(target, fs.constants.O_RDONLY | (process.platform === 'win32' ? 0 : fs.constants.O_NOFOLLOW || 0));
|
|
192
|
+
let value;
|
|
193
|
+
try {
|
|
194
|
+
const stat = await handle.stat();
|
|
195
|
+
if (stat.dev !== before.dev || stat.ino !== before.ino || stat.size > 4096) throw new Error('observation_identity_changed');
|
|
196
|
+
const data = Buffer.alloc(4097); const { bytesRead } = await handle.read(data, 0, data.length, 0);
|
|
197
|
+
if (bytesRead > 4096) throw new Error('observation_limit');
|
|
198
|
+
value = JSON.parse(data.subarray(0, bytesRead).toString('utf8'));
|
|
199
|
+
await checkParents(identities);
|
|
200
|
+
} finally { await handle.close(); }
|
|
201
|
+
const age = Date.now() - Date.parse(value.observed_at);
|
|
202
|
+
if (value.schema !== 'yam.hook-observation.v1' || value.profile !== profile || !['UserPromptSubmit', 'Stop'].includes(value.event)
|
|
203
|
+
|| value.host_authenticated !== false || value.evidence_kind !== 'local_entrypoint_invocation'
|
|
204
|
+
|| !Number.isFinite(age) || age < 0 || age > 24 * 60 * 60 * 1000
|
|
205
|
+
|| value.cwd_digest !== sha(await fsp.realpath(cwd)) || value.configuration_digest !== await configurationDigest(cwd)
|
|
206
|
+
|| value.entrypoint_digest !== sha(await fsp.readFile(entrypoint, 'utf8'))) {
|
|
207
|
+
return { state: 'unknown', reason: 'stale_or_mismatched_observation', truth_status: 'partial', host_authenticated: false };
|
|
208
|
+
}
|
|
209
|
+
return { state: 'entrypoint_observed', observed_at: value.observed_at, event: value.event,
|
|
210
|
+
host_authenticated: false, truth_status: 'partial', reason: 'local_same_user_evidence_not_host_attestation' };
|
|
211
|
+
} catch (error) {
|
|
212
|
+
return { state: 'not_observed', reason: (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'no_observation' : 'observation_unreadable_or_unsafe',
|
|
213
|
+
host_authenticated: false, truth_status: 'partial' };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export type MemoryReadState = 'missing' | 'empty' | 'complete' | 'unreadable' | 'invalid' | 'incomplete';
|
|
5
|
+
export interface MemoryRecord {
|
|
6
|
+
schemaVersion: number;
|
|
7
|
+
id: string;
|
|
8
|
+
kind: string;
|
|
9
|
+
status: string;
|
|
10
|
+
summary: string;
|
|
11
|
+
evidence?: string;
|
|
12
|
+
action?: string;
|
|
13
|
+
source?: string;
|
|
14
|
+
createdAt?: string;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
export interface MemoryReadReport {
|
|
18
|
+
schema: 'yam.memory-read.v1';
|
|
19
|
+
state: MemoryReadState;
|
|
20
|
+
complete: boolean;
|
|
21
|
+
records: MemoryRecord[];
|
|
22
|
+
diagnostics: Array<{ operation: string; code: string; record_index?: number }>;
|
|
23
|
+
diagnostics_truncated: boolean;
|
|
24
|
+
}
|
|
25
|
+
export type MemoryReadIo = Pick<typeof fsp, 'access' | 'readdir' | 'readFile'>;
|
|
26
|
+
const kinds = new Set(['wrong_decision', 'repeat_mistake', 'direction_change', 'lesson', 'risk', 'command']);
|
|
27
|
+
const safeCodes = new Set(['ENOENT', 'EACCES', 'EPERM', 'EIO', 'ENOTDIR', 'EISDIR', 'EMFILE', 'ENFILE', 'ELOOP']);
|
|
28
|
+
|
|
29
|
+
function errorCode(error: unknown): string {
|
|
30
|
+
const code = (error as { code?: unknown })?.code;
|
|
31
|
+
return typeof code === 'string' && safeCodes.has(code) ? code : 'READ_FAILED';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isMemoryRecord(value: unknown): value is MemoryRecord {
|
|
35
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
36
|
+
const r = value as Record<string, unknown>;
|
|
37
|
+
return r.schemaVersion === 1 && typeof r.id === 'string' && r.id.length > 0
|
|
38
|
+
&& typeof r.kind === 'string' && kinds.has(r.kind)
|
|
39
|
+
&& (r.status === 'active' || r.status === 'resolved')
|
|
40
|
+
&& typeof r.summary === 'string' && r.summary.trim().length > 0
|
|
41
|
+
&& ['evidence', 'action', 'source', 'createdAt', 'resolvedAt', 'resolution'].every((key) => r[key] === undefined || typeof r[key] === 'string');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Diagnostics intentionally contain no filesystem paths, record text, or exception messages. */
|
|
45
|
+
export async function readMemoryDirectory(recordsDir: string, io: MemoryReadIo = fsp): Promise<MemoryReadReport> {
|
|
46
|
+
const report: MemoryReadReport = {
|
|
47
|
+
schema: 'yam.memory-read.v1', state: 'complete', complete: true,
|
|
48
|
+
records: [], diagnostics: [], diagnostics_truncated: false
|
|
49
|
+
};
|
|
50
|
+
const diagnose = (operation: string, code: string, record_index?: number) => {
|
|
51
|
+
if (report.diagnostics.length < 20) report.diagnostics.push({ operation, code, ...(record_index === undefined ? {} : { record_index }) });
|
|
52
|
+
else report.diagnostics_truncated = true;
|
|
53
|
+
};
|
|
54
|
+
try {
|
|
55
|
+
await io.access(recordsDir);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
const code = errorCode(error);
|
|
58
|
+
report.state = code === 'ENOENT' ? 'missing' : 'unreadable';
|
|
59
|
+
report.complete = false;
|
|
60
|
+
diagnose('access', code);
|
|
61
|
+
return report;
|
|
62
|
+
}
|
|
63
|
+
let entries;
|
|
64
|
+
try {
|
|
65
|
+
entries = await io.readdir(recordsDir, { withFileTypes: true });
|
|
66
|
+
} catch (error) {
|
|
67
|
+
// An existing directory disappearing mid-read is incomplete, not an empty memory.
|
|
68
|
+
report.state = errorCode(error) === 'ENOENT' ? 'incomplete' : 'unreadable';
|
|
69
|
+
report.complete = false;
|
|
70
|
+
diagnose('readdir', errorCode(error));
|
|
71
|
+
return report;
|
|
72
|
+
}
|
|
73
|
+
let invalid = 0;
|
|
74
|
+
let unreadable = 0;
|
|
75
|
+
const candidates = entries.filter((entry) => entry.name.endsWith('.json')).sort((a, b) => a.name.localeCompare(b.name));
|
|
76
|
+
for (const [index, entry] of candidates.entries()) {
|
|
77
|
+
if (!entry.isFile()) {
|
|
78
|
+
unreadable += 1;
|
|
79
|
+
diagnose('record_type', 'NOT_REGULAR_FILE', index);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
let text: string;
|
|
83
|
+
try {
|
|
84
|
+
text = await io.readFile(path.join(recordsDir, entry.name), 'utf8');
|
|
85
|
+
} catch (error) {
|
|
86
|
+
unreadable += 1;
|
|
87
|
+
diagnose('read', errorCode(error), index);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
let record: unknown;
|
|
91
|
+
try { record = JSON.parse(text); } catch {
|
|
92
|
+
invalid += 1;
|
|
93
|
+
diagnose('parse', 'INVALID_JSON', index);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!isMemoryRecord(record)) {
|
|
97
|
+
invalid += 1;
|
|
98
|
+
diagnose('validate', 'INVALID_RECORD', index);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
report.records.push(record);
|
|
102
|
+
}
|
|
103
|
+
report.records.sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')));
|
|
104
|
+
report.complete = invalid + unreadable === 0;
|
|
105
|
+
report.state = report.complete ? (report.records.length ? 'complete' : 'empty')
|
|
106
|
+
: report.records.length || (invalid && unreadable) ? 'incomplete'
|
|
107
|
+
: unreadable ? 'unreadable' : 'invalid';
|
|
108
|
+
return report;
|
|
109
|
+
}
|