@isparling/engram-cli 0.1.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/LICENSE +187 -0
- package/README.md +41 -0
- package/bin/engram +14 -0
- package/package.json +32 -0
- package/release/engram-release.ts +1493 -0
- package/src/atomicWrite.ts +80 -0
- package/src/candidate.ts +229 -0
- package/src/classify.ts +275 -0
- package/src/cli.ts +709 -0
- package/src/contentHash.ts +54 -0
- package/src/deepFreeze.ts +13 -0
- package/src/diff.ts +81 -0
- package/src/guardedRetrieval.ts +128 -0
- package/src/guardedRetrievalInternal.ts +321 -0
- package/src/knowledgeRecord.ts +256 -0
- package/src/knowledgeRetrieval.ts +564 -0
- package/src/knowledgeRollup.ts +479 -0
- package/src/knowledgeTransaction.ts +683 -0
- package/src/knowledgeTypes.ts +249 -0
- package/src/knowledgeValidation.ts +269 -0
- package/src/markdownRecord.ts +265 -0
- package/src/packLoader.ts +188 -0
- package/src/packTypes.ts +12 -0
- package/src/presentation.ts +673 -0
- package/src/qmdConfigGuard.ts +245 -0
- package/src/qmdRunner.ts +392 -0
- package/src/realPath.ts +47 -0
- package/src/spaceBinding.ts +37 -0
- package/src/spaceRegistry.ts +1139 -0
- package/src/submit.ts +228 -0
- package/src/symlinkGuard.ts +71 -0
- package/src/transactionLock.ts +188 -0
- package/src/types.ts +38 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Atomic file replacement that preserves the target's permission bits.
|
|
2
|
+
//
|
|
3
|
+
// Atomic write: temp file in the same directory as the
|
|
4
|
+
// target -> fsync the file descriptor -> rename -> fsync the containing
|
|
5
|
+
// directory. The rename is what makes the write atomic from the point of
|
|
6
|
+
// view of any reader; the two fsyncs are what make it durable across a
|
|
7
|
+
// crash rather than merely atomic in memory.
|
|
8
|
+
//
|
|
9
|
+
// The temp file's mode is set to match the target's existing mode before
|
|
10
|
+
// the rename, so a record that was e.g. 0600 doesn't silently become
|
|
11
|
+
// 0644 (open()'s default, subject to umask) after a routine edit.
|
|
12
|
+
//
|
|
13
|
+
// Concurrency note (explicitly out of scope, not solved here): the write
|
|
14
|
+
// path is single-writer. Two concurrent submitCandidate calls against the
|
|
15
|
+
// same record both read-plan-write independently; the second rename simply
|
|
16
|
+
// wins and the first writer's change is lost without conflict detection.
|
|
17
|
+
// That is accepted for the single-user core, not fixed.
|
|
18
|
+
|
|
19
|
+
import { open, rename, stat } from "node:fs/promises";
|
|
20
|
+
import { basename, dirname, join } from "node:path";
|
|
21
|
+
import { randomBytes } from "node:crypto";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Thrown when the temp file was written, fsynced, and successfully
|
|
25
|
+
* renamed over the target — the new content is live and readable — but
|
|
26
|
+
* the final fsync of the containing directory failed. The write is not
|
|
27
|
+
* rolled back (a valid write is never undone to make something else
|
|
28
|
+
* look healthy); callers should treat the record as committed but treat
|
|
29
|
+
* anything depending on this write's crash-durability, including the
|
|
30
|
+
* subsequent qmd refresh, as unsafe to attempt.
|
|
31
|
+
*/
|
|
32
|
+
export class AtomicWriteDirectorySyncError extends Error {
|
|
33
|
+
readonly cause: unknown;
|
|
34
|
+
|
|
35
|
+
constructor(message: string, cause: unknown) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = "AtomicWriteDirectorySyncError";
|
|
38
|
+
this.cause = cause;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function atomicWriteFile(targetPath: string, content: string): Promise<void> {
|
|
43
|
+
const dir = dirname(targetPath);
|
|
44
|
+
const tmpPath = join(dir, `.${basename(targetPath)}.tmp-${randomBytes(8).toString("hex")}`);
|
|
45
|
+
|
|
46
|
+
let targetMode: number | undefined;
|
|
47
|
+
try {
|
|
48
|
+
const targetStat = await stat(targetPath);
|
|
49
|
+
targetMode = targetStat.mode & 0o777;
|
|
50
|
+
} catch {
|
|
51
|
+
targetMode = undefined; // target does not exist yet; nothing to preserve
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const fileHandle = await open(tmpPath, "w");
|
|
55
|
+
try {
|
|
56
|
+
await fileHandle.writeFile(content, "utf8");
|
|
57
|
+
if (targetMode !== undefined) {
|
|
58
|
+
await fileHandle.chmod(targetMode);
|
|
59
|
+
}
|
|
60
|
+
await fileHandle.sync();
|
|
61
|
+
} finally {
|
|
62
|
+
await fileHandle.close();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await rename(tmpPath, targetPath);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const dirHandle = await open(dir, "r");
|
|
69
|
+
try {
|
|
70
|
+
await dirHandle.sync();
|
|
71
|
+
} finally {
|
|
72
|
+
await dirHandle.close();
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new AtomicWriteDirectorySyncError(
|
|
76
|
+
"record content was written and renamed into place, but fsync of the containing directory failed afterward; the write is not rolled back, but its crash-durability is unconfirmed",
|
|
77
|
+
error,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/candidate.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Candidate knowledge envelope validation and reshaping for the submission
|
|
2
|
+
// pipeline. Validated candidates are the single input a knowledge transaction
|
|
3
|
+
// accepts.
|
|
4
|
+
//
|
|
5
|
+
// A candidate is JSON: the target record id, a source tag, claims to add,
|
|
6
|
+
// evidence entries to append, and optionally claims/evidence to remove or
|
|
7
|
+
// rewrite, and optionally a frontmatter edit.
|
|
8
|
+
//
|
|
9
|
+
// Validation is hand-written (no schema library) and returns a discriminated
|
|
10
|
+
// Result instead of throwing. Validation is deliberately strict about unknown
|
|
11
|
+
// top-level fields: this is the only line of defense, alongside the id
|
|
12
|
+
// pattern in spaceBinding.ts, against a candidate trying to smuggle a
|
|
13
|
+
// pointer at another knowledge root or qmd collection through the payload.
|
|
14
|
+
//
|
|
15
|
+
// Every free-text field is also required to be a single line: no `\n` or
|
|
16
|
+
// `\r`. Without this, a value like `"\n## Evidence log\n\n- forged"` in
|
|
17
|
+
// add_claims would forge a second `## Evidence log` heading into the
|
|
18
|
+
// record body — markdownRecord.ts's parser would then either reject the
|
|
19
|
+
// resulting record outright (duplicate section) or, worse, silently
|
|
20
|
+
// collapse it on a later parse. Rejecting the newline at the input
|
|
21
|
+
// boundary is simpler and safer than trying to make the parser robust
|
|
22
|
+
// against arbitrary embedded structure.
|
|
23
|
+
|
|
24
|
+
import { err, ok, requireDefined, type Result } from "./types.ts";
|
|
25
|
+
|
|
26
|
+
export type EvidenceInput = { date: string; text: string };
|
|
27
|
+
export type TextRewrite = { from: string; to: string };
|
|
28
|
+
|
|
29
|
+
export type Candidate = {
|
|
30
|
+
target_id: string;
|
|
31
|
+
source: string;
|
|
32
|
+
add_claims: string[];
|
|
33
|
+
add_evidence: EvidenceInput[];
|
|
34
|
+
remove_claims: string[];
|
|
35
|
+
remove_evidence: string[];
|
|
36
|
+
rewrite_claims: TextRewrite[];
|
|
37
|
+
rewrite_evidence: TextRewrite[];
|
|
38
|
+
frontmatter: { title?: string } | null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const ALLOWED_TOP_LEVEL_KEYS = new Set([
|
|
42
|
+
"target_id",
|
|
43
|
+
"source",
|
|
44
|
+
"add_claims",
|
|
45
|
+
"add_evidence",
|
|
46
|
+
"remove_claims",
|
|
47
|
+
"remove_evidence",
|
|
48
|
+
"rewrite_claims",
|
|
49
|
+
"rewrite_evidence",
|
|
50
|
+
"frontmatter",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const RECORD_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
54
|
+
const EVIDENCE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
55
|
+
|
|
56
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
57
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
61
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function containsNewline(value: string): boolean {
|
|
65
|
+
return /[\r\n]/.test(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function validateSingleLineStringArray(value: unknown, fieldName: string, errors: string[]): string[] {
|
|
69
|
+
if (value === undefined) return [];
|
|
70
|
+
if (!Array.isArray(value)) {
|
|
71
|
+
errors.push(`${fieldName} must be an array of strings`);
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
const result: string[] = [];
|
|
75
|
+
value.forEach((item, index) => {
|
|
76
|
+
if (!isNonEmptyString(item)) {
|
|
77
|
+
errors.push(`${fieldName}[${index}] must be a non-empty string`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (containsNewline(item)) {
|
|
81
|
+
errors.push(`${fieldName}[${index}] must not contain newlines`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
result.push(item);
|
|
85
|
+
});
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function validateRewriteArray(value: unknown, fieldName: string, errors: string[]): TextRewrite[] {
|
|
90
|
+
if (value === undefined) return [];
|
|
91
|
+
if (!Array.isArray(value)) {
|
|
92
|
+
errors.push(`${fieldName} must be an array of {from, to} objects`);
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
const result: TextRewrite[] = [];
|
|
96
|
+
value.forEach((item, index) => {
|
|
97
|
+
if (!isPlainObject(item)) {
|
|
98
|
+
errors.push(`${fieldName}[${index}] must be an object with "from" and "to"`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const keys = Object.keys(item);
|
|
102
|
+
const unexpected = keys.filter((k) => k !== "from" && k !== "to");
|
|
103
|
+
if (unexpected.length > 0) {
|
|
104
|
+
errors.push(`${fieldName}[${index}] has unexpected field(s): ${unexpected.join(", ")}`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!isNonEmptyString(item.from) || !isNonEmptyString(item.to)) {
|
|
108
|
+
errors.push(`${fieldName}[${index}] must have non-empty string "from" and "to"`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (containsNewline(item.from) || containsNewline(item.to)) {
|
|
112
|
+
errors.push(`${fieldName}[${index}] "from"/"to" must not contain newlines`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
result.push({ from: item.from, to: item.to });
|
|
116
|
+
});
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function validateEvidenceArray(value: unknown, fieldName: string, errors: string[]): EvidenceInput[] {
|
|
121
|
+
if (value === undefined) return [];
|
|
122
|
+
if (!Array.isArray(value)) {
|
|
123
|
+
errors.push(`${fieldName} must be an array of {date, text} objects`);
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
const result: EvidenceInput[] = [];
|
|
127
|
+
value.forEach((item, index) => {
|
|
128
|
+
if (!isPlainObject(item)) {
|
|
129
|
+
errors.push(`${fieldName}[${index}] must be an object with "date" and "text"`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const keys = Object.keys(item);
|
|
133
|
+
const unexpected = keys.filter((k) => k !== "date" && k !== "text");
|
|
134
|
+
if (unexpected.length > 0) {
|
|
135
|
+
errors.push(`${fieldName}[${index}] has unexpected field(s): ${unexpected.join(", ")}`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (typeof item.date !== "string" || !EVIDENCE_DATE_PATTERN.test(item.date) || containsNewline(item.date)) {
|
|
139
|
+
errors.push(`${fieldName}[${index}].date must match YYYY-MM-DD`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (!isNonEmptyString(item.text)) {
|
|
143
|
+
errors.push(`${fieldName}[${index}].text must be a non-empty string`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (containsNewline(item.text)) {
|
|
147
|
+
errors.push(`${fieldName}[${index}].text must not contain newlines`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
result.push({ date: item.date, text: item.text });
|
|
151
|
+
});
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function validateCandidate(raw: unknown): Result<Candidate> {
|
|
156
|
+
const errors: string[] = [];
|
|
157
|
+
|
|
158
|
+
if (!isPlainObject(raw)) {
|
|
159
|
+
return err(["candidate must be a JSON object"]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const unexpectedKeys = Object.keys(raw).filter((k) => !ALLOWED_TOP_LEVEL_KEYS.has(k));
|
|
163
|
+
if (unexpectedKeys.length > 0) {
|
|
164
|
+
errors.push(`candidate has unexpected field(s): ${unexpectedKeys.join(", ")}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let targetId: string | undefined;
|
|
168
|
+
if (!isNonEmptyString(raw.target_id)) {
|
|
169
|
+
errors.push("target_id is required and must be a non-empty string");
|
|
170
|
+
} else if (!RECORD_ID_PATTERN.test(raw.target_id)) {
|
|
171
|
+
errors.push(`target_id must match ${RECORD_ID_PATTERN} (lowercase letters, digits, hyphens; no path separators)`);
|
|
172
|
+
} else {
|
|
173
|
+
targetId = raw.target_id;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let source: string | undefined;
|
|
177
|
+
if (!isNonEmptyString(raw.source)) {
|
|
178
|
+
errors.push("source is required and must be a non-empty string");
|
|
179
|
+
} else if (containsNewline(raw.source)) {
|
|
180
|
+
errors.push("source must not contain newlines");
|
|
181
|
+
} else {
|
|
182
|
+
source = raw.source;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const addClaims = validateSingleLineStringArray(raw.add_claims, "add_claims", errors);
|
|
186
|
+
const removeClaims = validateSingleLineStringArray(raw.remove_claims, "remove_claims", errors);
|
|
187
|
+
const addEvidence = validateEvidenceArray(raw.add_evidence, "add_evidence", errors);
|
|
188
|
+
const removeEvidence = validateSingleLineStringArray(raw.remove_evidence, "remove_evidence", errors);
|
|
189
|
+
const rewriteClaims = validateRewriteArray(raw.rewrite_claims, "rewrite_claims", errors);
|
|
190
|
+
const rewriteEvidence = validateRewriteArray(raw.rewrite_evidence, "rewrite_evidence", errors);
|
|
191
|
+
|
|
192
|
+
let frontmatter: { title?: string } | null = null;
|
|
193
|
+
if (raw.frontmatter !== undefined && raw.frontmatter !== null) {
|
|
194
|
+
if (!isPlainObject(raw.frontmatter)) {
|
|
195
|
+
errors.push("frontmatter must be an object");
|
|
196
|
+
} else {
|
|
197
|
+
const keys = Object.keys(raw.frontmatter);
|
|
198
|
+
const unexpected = keys.filter((k) => k !== "title");
|
|
199
|
+
if (unexpected.length > 0) {
|
|
200
|
+
errors.push(`frontmatter has unexpected field(s): ${unexpected.join(", ")}`);
|
|
201
|
+
}
|
|
202
|
+
if (raw.frontmatter.title !== undefined) {
|
|
203
|
+
if (!isNonEmptyString(raw.frontmatter.title)) {
|
|
204
|
+
errors.push("frontmatter.title must be a non-empty string");
|
|
205
|
+
} else if (containsNewline(raw.frontmatter.title)) {
|
|
206
|
+
errors.push("frontmatter.title must not contain newlines");
|
|
207
|
+
} else {
|
|
208
|
+
frontmatter = { title: raw.frontmatter.title };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (errors.length > 0) {
|
|
215
|
+
return err(errors);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return ok({
|
|
219
|
+
target_id: requireDefined(targetId, "target_id validated but not captured"),
|
|
220
|
+
source: requireDefined(source, "source validated but not captured"),
|
|
221
|
+
add_claims: addClaims,
|
|
222
|
+
add_evidence: addEvidence,
|
|
223
|
+
remove_claims: removeClaims,
|
|
224
|
+
remove_evidence: removeEvidence,
|
|
225
|
+
rewrite_claims: rewriteClaims,
|
|
226
|
+
rewrite_evidence: rewriteEvidence,
|
|
227
|
+
frontmatter,
|
|
228
|
+
});
|
|
229
|
+
}
|
package/src/classify.ts
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// Mutation classification: a planned delta is additive, non-additive, or
|
|
2
|
+
// no-change, derived from the assembled plan rather than from intent.
|
|
3
|
+
//
|
|
4
|
+
// Classifies a candidate against a parsed record and plans the resulting
|
|
5
|
+
// Markdown mutation.
|
|
6
|
+
//
|
|
7
|
+
// Classification rule: additive means only appending
|
|
8
|
+
// evidence and/or adding new claims. Non-additive means removing or
|
|
9
|
+
// rewriting an existing claim or evidence entry, or altering frontmatter.
|
|
10
|
+
//
|
|
11
|
+
// ASSUMPTION (documented, not litigated further — see harness/README.md):
|
|
12
|
+
// every commit mechanically stamps `updated` to the submission date. That
|
|
13
|
+
// stamp is not, by itself, a classification-relevant frontmatter change;
|
|
14
|
+
// only a candidate that explicitly requests a frontmatter edit (currently
|
|
15
|
+
// just `title`) makes the candidate non-additive. Otherwise a harmless
|
|
16
|
+
// bookkeeping timestamp would force every candidate through the approval
|
|
17
|
+
// gate, which defeats the additive path entirely.
|
|
18
|
+
//
|
|
19
|
+
// Every remove/rewrite directive is resolved against the ORIGINAL record
|
|
20
|
+
// state, not applied as a sequential pipeline: a rewrite whose `to` value
|
|
21
|
+
// happens to match some other original entry's text must not make that
|
|
22
|
+
// unrelated entry vanish just because a later step compares against
|
|
23
|
+
// already-mutated state. Directives that are individually unambiguous but
|
|
24
|
+
// jointly conflicting (the same original text named by both a remove and
|
|
25
|
+
// a rewrite, or by two different rewrites) are rejected outright rather
|
|
26
|
+
// than resolved by pipeline order, which is exactly what "order" was
|
|
27
|
+
// silently deciding before.
|
|
28
|
+
//
|
|
29
|
+
// Classification is NOT trusted from candidate directives alone. A
|
|
30
|
+
// candidate that only names add_claims/add_evidence LOOKS additive, but
|
|
31
|
+
// the actual planned mutation is what can destroy content — through a
|
|
32
|
+
// latent bug in this file, in withMutatedContent, or in the parser's
|
|
33
|
+
// round-trip fidelity (preamble loss was exactly such a bug: it discarded
|
|
34
|
+
// content that no candidate ever asked to touch, and would have kept
|
|
35
|
+
// classifying as additive forever, because the classification check never
|
|
36
|
+
// looked at the record). `mutationPreservesAllContent` compares the
|
|
37
|
+
// planned `after` against the pristine `before` directly — preamble,
|
|
38
|
+
// every "other" section, and every original claim/evidence entry must
|
|
39
|
+
// still be present — and classification is non-additive if EITHER the
|
|
40
|
+
// candidate declared a destructive directive OR the actual plan drops or
|
|
41
|
+
// alters anything, whichever fires. This makes classification a property
|
|
42
|
+
// of the mutation, not a report of intent.
|
|
43
|
+
|
|
44
|
+
import type { Candidate, TextRewrite } from "./candidate.ts";
|
|
45
|
+
import { renderUnifiedDiff } from "./diff.ts";
|
|
46
|
+
import {
|
|
47
|
+
getActiveClaims,
|
|
48
|
+
getEvidenceLog,
|
|
49
|
+
withMutatedContent,
|
|
50
|
+
type OtherSection,
|
|
51
|
+
type ParsedRecord,
|
|
52
|
+
} from "./markdownRecord.ts";
|
|
53
|
+
import { serializeRecord } from "./markdownRecord.ts";
|
|
54
|
+
import { err, ok, type Result } from "./types.ts";
|
|
55
|
+
|
|
56
|
+
export type Classification = "additive" | "non-additive";
|
|
57
|
+
|
|
58
|
+
export type MutationPlan = {
|
|
59
|
+
classification: Classification;
|
|
60
|
+
before: ParsedRecord;
|
|
61
|
+
after: ParsedRecord;
|
|
62
|
+
beforeText: string;
|
|
63
|
+
afterText: string;
|
|
64
|
+
diff: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolves remove/rewrite/add directives for a single flat text list
|
|
69
|
+
* (active claims, or evidence log entries) against its original state.
|
|
70
|
+
* Returns errors for: a remove/rewrite target that doesn't exist in the
|
|
71
|
+
* original list, two rewrite directives naming the same original text, or
|
|
72
|
+
* an original text named by both a remove and a rewrite directive.
|
|
73
|
+
*/
|
|
74
|
+
function resolveTextList(
|
|
75
|
+
original: string[],
|
|
76
|
+
removeList: string[],
|
|
77
|
+
rewriteList: TextRewrite[],
|
|
78
|
+
additions: string[],
|
|
79
|
+
fieldLabel: string,
|
|
80
|
+
): Result<string[]> {
|
|
81
|
+
const errors: string[] = [];
|
|
82
|
+
const originalSet = new Set(original);
|
|
83
|
+
|
|
84
|
+
for (const text of removeList) {
|
|
85
|
+
if (!originalSet.has(text)) {
|
|
86
|
+
errors.push(`remove target not found in ${fieldLabel}: ${JSON.stringify(text)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const rewriteFromCounts = new Map<string, number>();
|
|
91
|
+
for (const rewrite of rewriteList) {
|
|
92
|
+
if (!originalSet.has(rewrite.from)) {
|
|
93
|
+
errors.push(`rewrite target not found in ${fieldLabel}: ${JSON.stringify(rewrite.from)}`);
|
|
94
|
+
}
|
|
95
|
+
rewriteFromCounts.set(rewrite.from, (rewriteFromCounts.get(rewrite.from) ?? 0) + 1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const [from, count] of rewriteFromCounts) {
|
|
99
|
+
if (count > 1) {
|
|
100
|
+
errors.push(`${fieldLabel} has ${count} conflicting rewrite directives for the same original text: ${JSON.stringify(from)}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const removeSet = new Set(removeList);
|
|
105
|
+
for (const from of rewriteFromCounts.keys()) {
|
|
106
|
+
if (removeSet.has(from)) {
|
|
107
|
+
errors.push(`${fieldLabel} text is targeted by both a remove and a rewrite directive: ${JSON.stringify(from)}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (errors.length > 0) {
|
|
112
|
+
return err(errors);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const rewriteMap = new Map(rewriteList.map((rewrite) => [rewrite.from, rewrite.to]));
|
|
116
|
+
const resolved: string[] = [];
|
|
117
|
+
for (const item of original) {
|
|
118
|
+
if (removeSet.has(item)) continue;
|
|
119
|
+
const rewritten = rewriteMap.get(item);
|
|
120
|
+
resolved.push(rewritten !== undefined ? rewritten : item);
|
|
121
|
+
}
|
|
122
|
+
for (const addition of additions) {
|
|
123
|
+
resolved.push(addition);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return ok(resolved);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function arraysEqual(a: string[], b: string[]): boolean {
|
|
130
|
+
if (a.length !== b.length) return false;
|
|
131
|
+
for (let i = 0; i < a.length; i++) {
|
|
132
|
+
if (a[i] !== b[i]) return false;
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** before ⊆ after as a multiset: every original entry (counting
|
|
138
|
+
* duplicates) must still appear in after at least as many times. A
|
|
139
|
+
* rewrite or removal makes the original text's count drop, which this
|
|
140
|
+
* catches regardless of what the candidate's directives claimed to do;
|
|
141
|
+
* pure additions only ever raise counts, so they always pass. */
|
|
142
|
+
function isTextMultisetSubset(before: string[], after: string[]): boolean {
|
|
143
|
+
const remaining = new Map<string, number>();
|
|
144
|
+
for (const item of after) {
|
|
145
|
+
remaining.set(item, (remaining.get(item) ?? 0) + 1);
|
|
146
|
+
}
|
|
147
|
+
for (const item of before) {
|
|
148
|
+
const count = remaining.get(item) ?? 0;
|
|
149
|
+
if (count <= 0) return false;
|
|
150
|
+
remaining.set(item, count - 1);
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* True iff `after` preserves everything `before` had: the same id and
|
|
157
|
+
* title (the mechanical `updated` bump is expected and excluded), the
|
|
158
|
+
* same preamble, every "other" section unchanged in place, and every
|
|
159
|
+
* original active-claims/evidence-log entry still present at least as
|
|
160
|
+
* many times as it originally appeared. False means the plan destroys or
|
|
161
|
+
* alters pre-existing content, independent of what the candidate declared.
|
|
162
|
+
*
|
|
163
|
+
* Exported so this safety net can be unit-tested directly against
|
|
164
|
+
* synthetic before/after pairs. Under the current (correct)
|
|
165
|
+
* resolveTextList/withMutatedContent implementation, there is no
|
|
166
|
+
* candidate reachable through the public submitCandidate/planMutation API
|
|
167
|
+
* that makes this return false for a directive-declared-additive
|
|
168
|
+
* candidate — which is the point: it is a structural backstop against a
|
|
169
|
+
* FUTURE bug in this file or in markdownRecord.ts's round-trip fidelity
|
|
170
|
+
* (preamble loss was exactly such a bug), not a path exercised by valid
|
|
171
|
+
* input today.
|
|
172
|
+
*/
|
|
173
|
+
export function mutationPreservesAllContent(before: ParsedRecord, after: ParsedRecord): boolean {
|
|
174
|
+
if (before.frontmatter.id !== after.frontmatter.id) return false;
|
|
175
|
+
if (before.frontmatter.title !== after.frontmatter.title) return false;
|
|
176
|
+
|
|
177
|
+
if (!arraysEqual(before.preambleLines, after.preambleLines)) return false;
|
|
178
|
+
|
|
179
|
+
const beforeOthers = before.sections.filter((s): s is OtherSection => s.kind === "other");
|
|
180
|
+
const afterOthers = after.sections.filter((s): s is OtherSection => s.kind === "other");
|
|
181
|
+
if (beforeOthers.length !== afterOthers.length) return false;
|
|
182
|
+
for (let i = 0; i < beforeOthers.length; i++) {
|
|
183
|
+
const beforeSection = beforeOthers[i];
|
|
184
|
+
const afterSection = afterOthers[i];
|
|
185
|
+
if (beforeSection === undefined || afterSection === undefined) return false;
|
|
186
|
+
if (beforeSection.heading !== afterSection.heading) return false;
|
|
187
|
+
if (!arraysEqual(beforeSection.lines, afterSection.lines)) return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!isTextMultisetSubset(getActiveClaims(before), getActiveClaims(after))) return false;
|
|
191
|
+
if (!isTextMultisetSubset(getEvidenceLog(before), getEvidenceLog(after))) return false;
|
|
192
|
+
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export type ContentPreservationCheck = (before: ParsedRecord, after: ParsedRecord) => boolean;
|
|
197
|
+
|
|
198
|
+
export function planMutation(
|
|
199
|
+
recordText: string,
|
|
200
|
+
record: ParsedRecord,
|
|
201
|
+
candidate: Candidate,
|
|
202
|
+
submittedAt: string,
|
|
203
|
+
/** Testing seam, defaulting to the real mutationPreservesAllContent.
|
|
204
|
+
* Mirrors the spawnFn/writeRecord injection pattern used elsewhere:
|
|
205
|
+
* lets a test prove the classification WIRING itself combines this
|
|
206
|
+
* check with the directive-declared signal, independent of whether a
|
|
207
|
+
* real candidate can currently drive the real check to false. */
|
|
208
|
+
contentCheck: ContentPreservationCheck = mutationPreservesAllContent,
|
|
209
|
+
): Result<MutationPlan> {
|
|
210
|
+
const claimsResult = resolveTextList(
|
|
211
|
+
getActiveClaims(record),
|
|
212
|
+
candidate.remove_claims,
|
|
213
|
+
candidate.rewrite_claims,
|
|
214
|
+
candidate.add_claims.map((text) => `${text} [source: ${candidate.source}]`),
|
|
215
|
+
"active claims",
|
|
216
|
+
);
|
|
217
|
+
const evidenceResult = resolveTextList(
|
|
218
|
+
getEvidenceLog(record),
|
|
219
|
+
candidate.remove_evidence,
|
|
220
|
+
candidate.rewrite_evidence,
|
|
221
|
+
candidate.add_evidence.map((evidence) => `${evidence.date} — ${evidence.text} [source: ${candidate.source}]`),
|
|
222
|
+
"evidence log",
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const errors: string[] = [];
|
|
226
|
+
if (!claimsResult.ok) errors.push(...claimsResult.errors);
|
|
227
|
+
if (!evidenceResult.ok) errors.push(...evidenceResult.errors);
|
|
228
|
+
if (errors.length > 0) {
|
|
229
|
+
return err(errors);
|
|
230
|
+
}
|
|
231
|
+
// Narrowed by the checks above, but the checks live on separate Result
|
|
232
|
+
// values so TypeScript can't see that from `errors.length === 0` alone.
|
|
233
|
+
if (!claimsResult.ok || !evidenceResult.ok) {
|
|
234
|
+
return err(["internal invariant violated: resolved list marked ok=false without contributing errors"]);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const after = withMutatedContent(record, {
|
|
238
|
+
frontmatter: {
|
|
239
|
+
id: record.frontmatter.id,
|
|
240
|
+
title: candidate.frontmatter?.title ?? record.frontmatter.title,
|
|
241
|
+
updated: submittedAt,
|
|
242
|
+
},
|
|
243
|
+
activeClaims: claimsResult.value,
|
|
244
|
+
evidenceLog: evidenceResult.value,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const declaredNonAdditive =
|
|
248
|
+
candidate.remove_claims.length > 0 ||
|
|
249
|
+
candidate.remove_evidence.length > 0 ||
|
|
250
|
+
candidate.rewrite_claims.length > 0 ||
|
|
251
|
+
candidate.rewrite_evidence.length > 0 ||
|
|
252
|
+
candidate.frontmatter !== null;
|
|
253
|
+
|
|
254
|
+
// The authoritative signal: does the actual planned mutation drop or
|
|
255
|
+
// alter any pre-existing content? This can fire even when
|
|
256
|
+
// declaredNonAdditive is false — e.g. a parser/serializer round-trip
|
|
257
|
+
// bug that silently drops content the candidate never asked to touch —
|
|
258
|
+
// which is exactly the property "no commit may destroy content the
|
|
259
|
+
// candidate did not ask to change" requires.
|
|
260
|
+
const contentPreserved = contentCheck(record, after);
|
|
261
|
+
|
|
262
|
+
const classification: Classification = declaredNonAdditive || !contentPreserved ? "non-additive" : "additive";
|
|
263
|
+
|
|
264
|
+
const afterText = serializeRecord(after);
|
|
265
|
+
const diff = renderUnifiedDiff(record.frontmatter.id, recordText, afterText);
|
|
266
|
+
|
|
267
|
+
return ok({
|
|
268
|
+
classification,
|
|
269
|
+
before: record,
|
|
270
|
+
after,
|
|
271
|
+
beforeText: recordText,
|
|
272
|
+
afterText,
|
|
273
|
+
diff,
|
|
274
|
+
});
|
|
275
|
+
}
|