@quolu/lattice 0.64.2 → 0.64.4
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/package.json +1 -1
- package/src/todo-source-line-eol.mjs +20 -0
- package/src/todo-store-worktree-eol.mjs +46 -17
- package/src/todo-store.mjs +13 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.64.
|
|
3
|
+
"version": "0.64.4",
|
|
4
4
|
"description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Quo / クオ at kitepon.dev",
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const CARRIAGE_RETURN = 0x0d;
|
|
4
|
+
const CR_BYTES = Buffer.from([CARRIAGE_RETURN]);
|
|
5
|
+
|
|
6
|
+
function digest(bytes) {
|
|
7
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Gitのcheckout EOL変換だけを同一source lineとして扱う。
|
|
12
|
+
* 呼出側はLFで分割済みなので、差になり得る行末CR一byteだけを往復させる。
|
|
13
|
+
*/
|
|
14
|
+
export function matchesTodoSourceLineDigest(lineBytes, expectedDigest) {
|
|
15
|
+
if (digest(lineBytes) === expectedDigest) return true;
|
|
16
|
+
if (lineBytes.at(-1) === CARRIAGE_RETURN) {
|
|
17
|
+
return digest(lineBytes.subarray(0, -1)) === expectedDigest;
|
|
18
|
+
}
|
|
19
|
+
return digest(Buffer.concat([lineBytes, CR_BYTES])) === expectedDigest;
|
|
20
|
+
}
|
|
@@ -106,13 +106,30 @@ async function prepareProtection(repoRoot) {
|
|
|
106
106
|
return { absolute, create: false };
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
function stagedArtifactRefs(repoRoot, refs) {
|
|
110
|
+
const staged = new Set();
|
|
111
|
+
for (let offset = 0; offset < refs.length; offset += INDEX_REFRESH_BATCH_SIZE) {
|
|
112
|
+
const batch = refs.slice(offset, offset + INDEX_REFRESH_BATCH_SIZE);
|
|
113
|
+
const result = gitSpawnSync(['diff-index', '--cached', '--name-only', '-z', 'HEAD', '--', ...batch], {
|
|
114
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
115
|
+
});
|
|
116
|
+
if (result.error || result.signal || result.status !== 0) {
|
|
117
|
+
fail('git_index_artifacts_unreadable', { refs: batch, status: result.status ?? null });
|
|
118
|
+
}
|
|
119
|
+
for (const ref of (result.stdout ?? '').split('\0')) {
|
|
120
|
+
if (ref !== '') staged.add(ref.replaceAll('\\', '/'));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return staged;
|
|
124
|
+
}
|
|
125
|
+
|
|
109
126
|
function refreshGitIndex(repoRoot, refs) {
|
|
110
127
|
for (let offset = 0; offset < refs.length; offset += INDEX_REFRESH_BATCH_SIZE) {
|
|
111
128
|
const batch = refs.slice(offset, offset + INDEX_REFRESH_BATCH_SIZE);
|
|
112
|
-
const refresh = gitSpawnSync(['
|
|
129
|
+
const refresh = gitSpawnSync(['update-index', '--refresh', '--', ...batch], {
|
|
113
130
|
cwd: repoRoot, stdio: 'ignore',
|
|
114
131
|
});
|
|
115
|
-
//
|
|
132
|
+
// 対象は呼出前にindex blob=HEADを実証済み。update-index --refreshで内容をstageせずstatだけを揃える。
|
|
116
133
|
if (refresh.error || refresh.signal || ![0, 1].includes(refresh.status)) {
|
|
117
134
|
fail('git_index_refresh_failed', { refs: batch, status: refresh.status ?? null });
|
|
118
135
|
}
|
|
@@ -132,52 +149,64 @@ function refreshGitIndex(repoRoot, refs) {
|
|
|
132
149
|
export async function repairTodoStoreWorktreeEol({ repoRoot }) {
|
|
133
150
|
const protection = await prepareProtection(repoRoot);
|
|
134
151
|
const refs = await collectArtifactRefs(repoRoot);
|
|
135
|
-
const
|
|
152
|
+
const stagedRefs = stagedArtifactRefs(repoRoot, refs);
|
|
153
|
+
const artifacts = [];
|
|
136
154
|
for (const ref of refs) {
|
|
137
155
|
const absolute = path.join(repoRoot, ref);
|
|
138
156
|
const stats = await safeRegularFileState(repoRoot, ref);
|
|
139
157
|
const bytes = await readFile(absolute);
|
|
140
158
|
const normalized = normalizePureCrlf(bytes, ref);
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
159
|
+
artifacts.push({
|
|
160
|
+
ref, absolute, sourceBytes: bytes, bytes: normalized ?? bytes, mode: stats.mode,
|
|
161
|
+
converted: normalized !== null,
|
|
144
162
|
});
|
|
145
163
|
}
|
|
146
164
|
|
|
147
165
|
let headArtifacts = [];
|
|
148
166
|
let indexArtifacts = [];
|
|
149
|
-
if (
|
|
150
|
-
const maxBodyBytes = Math.max(...
|
|
167
|
+
if (artifacts.length > 0) {
|
|
168
|
+
const maxBodyBytes = Math.max(...artifacts.map(({ bytes }) => bytes.length));
|
|
151
169
|
try {
|
|
152
|
-
headArtifacts = gitCatFileBatch(
|
|
170
|
+
headArtifacts = gitCatFileBatch(artifacts.map(({ ref }) => `HEAD:${ref}`), {
|
|
153
171
|
cwd: repoRoot, maxBodyBytes,
|
|
154
172
|
});
|
|
155
173
|
} catch (error) {
|
|
156
174
|
fail('git_head_artifacts_unreadable', { message: error.message });
|
|
157
175
|
}
|
|
158
176
|
try {
|
|
159
|
-
indexArtifacts = gitCatFileBatch(
|
|
177
|
+
indexArtifacts = gitCatFileBatch(artifacts.map(({ ref }) => `:${ref}`), {
|
|
160
178
|
cwd: repoRoot, maxBodyBytes,
|
|
161
179
|
});
|
|
162
180
|
} catch (error) {
|
|
163
181
|
fail('git_index_artifacts_unreadable', { message: error.message });
|
|
164
182
|
}
|
|
165
183
|
}
|
|
166
|
-
|
|
184
|
+
const converted = [];
|
|
185
|
+
const refreshRefs = [];
|
|
186
|
+
for (const [index, artifact] of artifacts.entries()) {
|
|
167
187
|
const head = headArtifacts[index];
|
|
168
|
-
if (head?.type !== 'blob' || !head.bytes.equals(artifact.bytes)) {
|
|
169
|
-
fail('artifact_not_pure_checkout_conversion', { ref: artifact.ref });
|
|
170
|
-
}
|
|
171
188
|
const staged = indexArtifacts[index];
|
|
172
|
-
if (
|
|
173
|
-
|
|
189
|
+
if (artifact.converted) {
|
|
190
|
+
if (head?.type !== 'blob' || !head.bytes.equals(artifact.bytes)) {
|
|
191
|
+
fail('artifact_not_pure_checkout_conversion', { ref: artifact.ref });
|
|
192
|
+
}
|
|
193
|
+
if (stagedRefs.has(artifact.ref)
|
|
194
|
+
|| staged?.type !== 'blob' || !staged.bytes.equals(head.bytes)) {
|
|
195
|
+
fail('artifact_staged_change_present', { ref: artifact.ref });
|
|
196
|
+
}
|
|
197
|
+
converted.push(artifact);
|
|
198
|
+
}
|
|
199
|
+
if (!stagedRefs.has(artifact.ref) && head?.type === 'blob' && staged?.type === 'blob'
|
|
200
|
+
&& staged.bytes.equals(head.bytes) && artifact.bytes.equals(head.bytes)) {
|
|
201
|
+
refreshRefs.push(artifact.ref);
|
|
174
202
|
}
|
|
175
203
|
}
|
|
176
204
|
|
|
177
205
|
for (const artifact of converted) {
|
|
178
206
|
await atomicReplace({ repoRoot, ...artifact });
|
|
179
207
|
}
|
|
180
|
-
|
|
208
|
+
// 0.64.2がLF書換後のstat refreshで止まった部分状態も、再実行だけで回復させる。
|
|
209
|
+
refreshGitIndex(repoRoot, refreshRefs);
|
|
181
210
|
if (protection.create) {
|
|
182
211
|
await assertSafeDirectoryChain(repoRoot, path.posix.dirname(ATTRIBUTES_REF));
|
|
183
212
|
await writeFile(protection.absolute, EOL_PROTECTION, { flag: 'wx' });
|
package/src/todo-store.mjs
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
} from './todo-structure-git-adapter.mjs';
|
|
45
45
|
import { sha256Bytes, verifyLinearHashChain } from './hash-chain.mjs';
|
|
46
46
|
import { gitCatFileBatch, gitSync } from './git-process.mjs';
|
|
47
|
+
import { matchesTodoSourceLineDigest } from './todo-source-line-eol.mjs';
|
|
47
48
|
import {
|
|
48
49
|
parseTodoSourceRef,
|
|
49
50
|
todoCutoverArchiveSourceRef,
|
|
@@ -1455,7 +1456,8 @@ function verifyPlanNarrativeAnchors(repoRoot, plan, trustedPlan = null, cache =
|
|
|
1455
1456
|
&& canonicalizeTodoArtifact(previous) === canonicalizeTodoArtifact(anchor)) continue;
|
|
1456
1457
|
try {
|
|
1457
1458
|
const line = pinnedSourceLine(repoRoot, anchor, cache);
|
|
1458
|
-
if (
|
|
1459
|
+
if (!matchesTodoSourceLineDigest(line, anchor.source_line_digest)
|
|
1460
|
+
|| markdownCheckboxState(line) === null) {
|
|
1459
1461
|
throw new Error('anchor mismatch');
|
|
1460
1462
|
}
|
|
1461
1463
|
} catch {
|
|
@@ -3039,7 +3041,7 @@ async function sourceItemBytes(repoRoot, sourceRef) {
|
|
|
3039
3041
|
async function verifyRevisionSources(repoRoot, inventory) {
|
|
3040
3042
|
for (const entry of [...inventory.active, ...inventory.excluded_tombstones]) {
|
|
3041
3043
|
const line = await sourceItemBytes(repoRoot, entry.source_ref);
|
|
3042
|
-
if (
|
|
3044
|
+
if (!matchesTodoSourceLineDigest(line, entry.source_digest)) {
|
|
3043
3045
|
fail('RECONCILIATION_INCOMPLETE', 'source_digest_mismatch', { source_ref: entry.source_ref });
|
|
3044
3046
|
}
|
|
3045
3047
|
if (markdownCheckboxState(line) === null) {
|
|
@@ -3396,9 +3398,9 @@ async function buildPhaseV3SourceReceipt(repoRoot, revision, transactionRef) {
|
|
|
3396
3398
|
archive_ref: archiveRef, replacement: operation.live_replacement,
|
|
3397
3399
|
staged_source_bytes_digest: operation.source_digest,
|
|
3398
3400
|
published_source_bytes_digest: sha256Bytes(publishedBytes),
|
|
3399
|
-
archived_source_bytes_digest:
|
|
3401
|
+
archived_source_bytes_digest: operation.source_digest, entry_digest: '' };
|
|
3400
3402
|
if (entry.published_source_bytes_digest !== sha256Bytes(expectedPublishedBytes)
|
|
3401
|
-
||
|
|
3403
|
+
|| !matchesTodoSourceLineDigest(archivedBytes, operation.source_digest)) {
|
|
3402
3404
|
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'source_receipt_bytes_mismatch');
|
|
3403
3405
|
}
|
|
3404
3406
|
entry.entry_digest = todoSelfDigest(entry, 'entry_digest');
|
|
@@ -3472,10 +3474,11 @@ async function verifyPhaseV3SourceReceipt(repoRoot, revision, receipt) {
|
|
|
3472
3474
|
await safeRepoFile(repoRoot, archive.path);
|
|
3473
3475
|
const publishedBytes = await sourceItemBytes(repoRoot, entry.published_ref);
|
|
3474
3476
|
const archivedBytes = await sourceItemBytes(repoRoot, entry.archive_ref);
|
|
3475
|
-
if (
|
|
3476
|
-
||
|
|
3477
|
-
||
|
|
3478
|
-
revision.source_cutover_batch.operations[index], archivedBytes)
|
|
3477
|
+
if (!matchesTodoSourceLineDigest(publishedBytes, entry.published_source_bytes_digest)
|
|
3478
|
+
|| !matchesTodoSourceLineDigest(archivedBytes, entry.archived_source_bytes_digest)
|
|
3479
|
+
|| !matchesTodoSourceLineDigest(phaseV3PublishedSourceBytes(
|
|
3480
|
+
revision.source_cutover_batch.operations[index], archivedBytes),
|
|
3481
|
+
entry.published_source_bytes_digest)) return false;
|
|
3479
3482
|
}
|
|
3480
3483
|
return true;
|
|
3481
3484
|
}
|
|
@@ -3796,7 +3799,7 @@ async function buildSourceCutoverImages(repoRoot, revision) {
|
|
|
3796
3799
|
if (line === undefined) fail('RECONCILIATION_INCOMPLETE', 'source_line_missing', {
|
|
3797
3800
|
source_ref: operation.source_ref,
|
|
3798
3801
|
});
|
|
3799
|
-
if (
|
|
3802
|
+
if (!matchesTodoSourceLineDigest(line, operation.source_digest)) {
|
|
3800
3803
|
fail('RECONCILIATION_INCOMPLETE', 'source_digest_mismatch', { source_ref: operation.source_ref });
|
|
3801
3804
|
}
|
|
3802
3805
|
if (markdownCheckboxState(line) === null) {
|
|
@@ -3904,7 +3907,7 @@ async function loadSourceCutoverStage(repoRoot, transaction, revision) {
|
|
|
3904
3907
|
const source = parseTodoSourceRef(operation.source_ref);
|
|
3905
3908
|
if (source.path !== file.ref) continue;
|
|
3906
3909
|
const line = lines[source.line - 1];
|
|
3907
|
-
if (line === undefined ||
|
|
3910
|
+
if (line === undefined || !matchesTodoSourceLineDigest(line.bytes, operation.source_digest)
|
|
3908
3911
|
|| markdownCheckboxState(line.bytes) === null
|
|
3909
3912
|
|| !liveReplacementPreservesListStructure(line.bytes, operation.live_replacement)) {
|
|
3910
3913
|
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'source_cutover_stage_invalid');
|