@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.64.2",
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(['add', '--refresh', '--', ...batch], {
129
+ const refresh = gitSpawnSync(['update-index', '--refresh', '--', ...batch], {
113
130
  cwd: repoRoot, stdio: 'ignore',
114
131
  });
115
- // git add --refresh は内容をstageせずstat cacheだけを更新する。対象pathは下のdiff-filesで検証する。
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 converted = [];
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
- if (normalized === null) continue;
142
- converted.push({
143
- ref, absolute, sourceBytes: bytes, bytes: normalized, mode: stats.mode,
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 (converted.length > 0) {
150
- const maxBodyBytes = Math.max(...converted.map(({ bytes }) => bytes.length));
167
+ if (artifacts.length > 0) {
168
+ const maxBodyBytes = Math.max(...artifacts.map(({ bytes }) => bytes.length));
151
169
  try {
152
- headArtifacts = gitCatFileBatch(converted.map(({ ref }) => `HEAD:${ref}`), {
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(converted.map(({ ref }) => `:${ref}`), {
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
- for (const [index, artifact] of converted.entries()) {
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 (staged?.type !== 'blob' || !staged.bytes.equals(head.bytes)) {
173
- fail('artifact_staged_change_present', { ref: artifact.ref });
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
- refreshGitIndex(repoRoot, converted.map(({ ref }) => ref));
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' });
@@ -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 (sha256Bytes(line) !== anchor.source_line_digest || markdownCheckboxState(line) === null) {
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 (sha256Bytes(line) !== entry.source_digest) {
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: sha256Bytes(archivedBytes), entry_digest: '' };
3401
+ archived_source_bytes_digest: operation.source_digest, entry_digest: '' };
3400
3402
  if (entry.published_source_bytes_digest !== sha256Bytes(expectedPublishedBytes)
3401
- || entry.archived_source_bytes_digest !== operation.source_digest) {
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 (sha256Bytes(publishedBytes) !== entry.published_source_bytes_digest
3476
- || sha256Bytes(archivedBytes) !== entry.archived_source_bytes_digest
3477
- || entry.published_source_bytes_digest !== sha256Bytes(phaseV3PublishedSourceBytes(
3478
- revision.source_cutover_batch.operations[index], archivedBytes))) return false;
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 (sha256Bytes(line) !== operation.source_digest) {
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 || sha256Bytes(line.bytes) !== operation.source_digest
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');