@quolu/lattice 0.27.0 → 0.29.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/package.json +5 -3
- package/src/isolation-runner.mjs +21 -2
- package/src/seam-apply.mjs +62 -19
- package/src/seam-commit-shared.mjs +22 -0
- package/src/seam-commit-transform.mjs +81 -0
- package/src/seam-commit.mjs +2 -82
- package/src/seam-ref.mjs +33 -0
- package/src/seam-rewrite.mjs +19 -1
- package/src/todo-cli.mjs +20 -6
- package/src/witness-scaffold.mjs +60 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "Lattice — phase-aware TODO graph compiler and conflict-aware orchestration runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -60,8 +60,10 @@
|
|
|
60
60
|
"test": "node scripts/run-product-tests.mjs",
|
|
61
61
|
"test:sensor": "npm --prefix sensor test",
|
|
62
62
|
"check": "node scripts/check-syntax.mjs",
|
|
63
|
-
"ci": "npm run test && npm run test:sensor && npm run check && npm run check:cli-surface && npm run verify:todo-store",
|
|
63
|
+
"ci": "npm run test && npm run test:sensor && npm run check && npm run check:cli-surface && npm run check:open-questions && npm run check:reachability && npm run verify:todo-store",
|
|
64
64
|
"verify:todo-store": "node bin/lattice.mjs todo verify --json",
|
|
65
|
-
"check:cli-surface": "node scripts/verify-cli-surface.mjs"
|
|
65
|
+
"check:cli-surface": "node scripts/verify-cli-surface.mjs",
|
|
66
|
+
"check:open-questions": "node scripts/verify-open-questions.mjs",
|
|
67
|
+
"check:reachability": "node scripts/verify-product-reachability.mjs"
|
|
66
68
|
}
|
|
67
69
|
}
|
package/src/isolation-runner.mjs
CHANGED
|
@@ -311,7 +311,15 @@ export async function runIsolatedTransform({ repoRoot, baseRef, allowedPaths, tr
|
|
|
311
311
|
const mountedEntries = mounts.map(({ entry }) => entry);
|
|
312
312
|
|
|
313
313
|
const sourceState = await captureSourceState(repoRoot);
|
|
314
|
-
if (sourceState.visibleStatus.length > 0)
|
|
314
|
+
if (sourceState.visibleStatus.length > 0) {
|
|
315
|
+
// どのpathが汚しているかを言わないと、呼び出したAIは何を片付ければよいか分からない。
|
|
316
|
+
// 実運用で、このコマンドへ渡す入力fileそのものが木を汚して詰まった。
|
|
317
|
+
// visibleStatusはNUL区切りのBufferである。行として扱うと空文字が並ぶ。
|
|
318
|
+
const entries = statusPaths(sourceState.visibleStatus).slice(0, 20);
|
|
319
|
+
throw new Error('source repository must be clean :: '
|
|
320
|
+
+ `${entries.join(' | ')} :: 変換は既知のbaseに対して測る。commitするか、`
|
|
321
|
+
+ 'gitが無視するpathへ退避してから再実行する');
|
|
322
|
+
}
|
|
315
323
|
const baseSha = (await run('git', ['rev-parse', '--verify', `${baseRef}^{commit}`], { cwd: repoRoot })).stdout.toString('utf8').trim();
|
|
316
324
|
const worktreePath = await mkdtemp(path.join(os.tmpdir(), 'lattice-isolated-transform-'));
|
|
317
325
|
let added = false;
|
|
@@ -349,7 +357,18 @@ export async function runIsolatedTransform({ repoRoot, baseRef, allowedPaths, tr
|
|
|
349
357
|
verifications.push(verificationReceipt(verifier, verification, 'passed'));
|
|
350
358
|
} catch (error) {
|
|
351
359
|
verifications.push(verificationReceipt(verifier, error, 'failed'));
|
|
352
|
-
|
|
360
|
+
// どのverifierがなぜ落ちたかを載せる。commandだけ返すと、五条件の棄却理由が
|
|
361
|
+
// 「focused testが落ちた」で止まり、原因を追う手段が無くなる。
|
|
362
|
+
// stdout/stderrはBufferで、空でもtruthyになる。`||`で繋ぐとstdoutへ落ちない。
|
|
363
|
+
// node --testは失敗をstdoutへ書くので、それを取り落とすと理由が消える。
|
|
364
|
+
const streams = [error?.stderr, error?.stdout]
|
|
365
|
+
.map((value) => (value === undefined ? '' : String(value)))
|
|
366
|
+
.filter((value) => value.trim().length > 0);
|
|
367
|
+
const detail = (streams[0] ?? '')
|
|
368
|
+
.split('\n').filter((line) => line.trim().length > 0).slice(-6).join(' | ').slice(0, 600);
|
|
369
|
+
throw new Error(`verifier failed (${error.signal ?? error.code}):`
|
|
370
|
+
+ ` ${[verifier.command, ...verifier.args].join(' ')}`
|
|
371
|
+
+ (detail.length > 0 ? ` :: ${detail}` : ''));
|
|
353
372
|
}
|
|
354
373
|
await assertSnapshotUnchanged(worktreePath, baseSha, allowedPaths, snapshot, 'verifier', mountedEntries);
|
|
355
374
|
}
|
package/src/seam-apply.mjs
CHANGED
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
* その理由である。採用された変換を本ツリーへ着地させるのは別工程が持つ。
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { execFile } from 'node:child_process';
|
|
8
|
+
import { execFile, spawnSync } from 'node:child_process';
|
|
9
9
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { promisify } from 'node:util';
|
|
12
12
|
|
|
13
13
|
import { runIsolatedTransform } from './isolation-runner.mjs';
|
|
14
14
|
import { collectSensorEvidence } from './sensor-adapter.mjs';
|
|
15
|
+
import { invokeSensorCli } from './sensor-runtime.mjs';
|
|
15
16
|
import { buildSeamDerivationQuerySet, deriveBoundedSeamCandidate } from './seam-derivation.mjs';
|
|
16
17
|
import { planSeamRewrite } from './seam-rewrite.mjs';
|
|
17
18
|
import {
|
|
@@ -68,27 +69,54 @@ async function deriveWithClosure({ cwd, deriveOnce }) {
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
/** 宣言symbolの行範囲。移す範囲を決めるので、原path上のexact一致だけを採る。 */
|
|
72
|
+
const SYMBOL_LOOKUP_LIMIT = 500;
|
|
73
|
+
|
|
74
|
+
/** gitに載らないbuild成果物のうち、focused testが要るものを実在する時だけ張る。 */
|
|
75
|
+
async function buildOutputMounts(repoRoot) {
|
|
76
|
+
const { access } = await import('node:fs/promises');
|
|
77
|
+
const mounts = [];
|
|
78
|
+
for (const entry of ['sensor/dist']) {
|
|
79
|
+
try {
|
|
80
|
+
await access(path.join(repoRoot, entry));
|
|
81
|
+
mounts.push({ entry, target: path.join(repoRoot, entry) });
|
|
82
|
+
} catch { /* 無ければ張らない。無い環境ではそのtestも同梱sensorを要求しない。 */ }
|
|
83
|
+
}
|
|
84
|
+
return mounts;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 変換対象symbolの行範囲を、対象file限定で読む。
|
|
89
|
+
*
|
|
90
|
+
* witness evidenceの共通経路(`collectSensorEvidence`)で名前を引くと、sensor CLIの既定
|
|
91
|
+
* `--limit 10`で打ち切られる。同名symbolが多いprojectでは対象fileの定義が窓の外へ落ち、
|
|
92
|
+
* **実在するsymbolを「範囲なし」と誤報して正当な変換を棄却する**。実測では`GIT_SHA1`が
|
|
93
|
+
* 17 fileにあり、名前順で先頭10件に入らなかった`src/seam-commit.mjs`の定義が返らなかった。
|
|
94
|
+
*
|
|
95
|
+
* よってここは共通経路を使わず、明示limitで引く。limitに達した結果は打ち切りの疑いがある
|
|
96
|
+
* ので、`missing`ではなく`truncated`として区別して返す——観測の欠落を「無い」へ丸めない。
|
|
97
|
+
*/
|
|
71
98
|
export async function readSymbolExtents({ cwd, sourcePath, symbols }) {
|
|
72
|
-
const querySet = {
|
|
73
|
-
queries: [
|
|
74
|
-
{ id: 'seam-extent-status', operation: 'status' },
|
|
75
|
-
...symbols.map((symbol, index) => ({
|
|
76
|
-
id: `seam-extent-${String(index).padStart(3, '0')}`, operation: 'query', target: symbol,
|
|
77
|
-
})),
|
|
78
|
-
],
|
|
79
|
-
};
|
|
80
|
-
const collected = await collectSensorEvidence({ cwd, querySet });
|
|
81
99
|
const extents = {};
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
100
|
+
const truncated = [];
|
|
101
|
+
for (const symbol of [...new Set(symbols)]) {
|
|
102
|
+
const result = invokeSensorCli(
|
|
103
|
+
(command, args, options) => spawnSync(command, args, options),
|
|
104
|
+
['query', symbol, '--path', '.', '--limit', String(SYMBOL_LOOKUP_LIMIT), '--json'],
|
|
105
|
+
{ cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 },
|
|
106
|
+
);
|
|
107
|
+
if (result.status !== 0) continue;
|
|
108
|
+
let parsed;
|
|
109
|
+
try { parsed = JSON.parse(result.stdout); } catch { continue; }
|
|
110
|
+
const entries = Array.isArray(parsed) ? parsed : parsed?.data ?? parsed?.results ?? [];
|
|
111
|
+
for (const entry of entries) {
|
|
85
112
|
const node = nodeOf(entry);
|
|
86
|
-
if (node === null || node.name !==
|
|
113
|
+
if (node === null || node.name !== symbol || node.filePath !== sourcePath) continue;
|
|
87
114
|
if (!Number.isSafeInteger(node.startLine) || !Number.isSafeInteger(node.endLine)) continue;
|
|
88
|
-
extents[
|
|
115
|
+
extents[symbol] = { startLine: node.startLine, endLine: node.endLine };
|
|
89
116
|
}
|
|
90
|
-
|
|
91
|
-
|
|
117
|
+
if (extents[symbol] === undefined && entries.length >= SYMBOL_LOOKUP_LIMIT) truncated.push(symbol);
|
|
118
|
+
}
|
|
119
|
+
return { extents, truncated: [...new Set(truncated)].sort(compareText) };
|
|
92
120
|
}
|
|
93
121
|
|
|
94
122
|
async function runIn(worktreePath, command, args) {
|
|
@@ -272,11 +300,21 @@ export async function applySeamConflict({
|
|
|
272
300
|
|
|
273
301
|
const { readFile } = await import('node:fs/promises');
|
|
274
302
|
const beforeText = await readFile(path.join(repoRoot, sourcePath), 'utf8');
|
|
275
|
-
const
|
|
303
|
+
const lookup = await readSymbolExtents({
|
|
276
304
|
cwd: repoRoot, sourcePath,
|
|
277
305
|
symbols: candidate.surfaces.flatMap(({ symbols }) => symbols),
|
|
278
306
|
});
|
|
279
|
-
|
|
307
|
+
if (lookup.truncated.length > 0) {
|
|
308
|
+
// 打ち切りは「範囲が無い」ではない。誤った理由で棄却して原因を隠さない。
|
|
309
|
+
return {
|
|
310
|
+
outcome: outcome({ planKey, decision: 'rejected', candidate,
|
|
311
|
+
reasons: lookup.truncated.map((symbol) => `symbol_lookup_truncated:${symbol}`) }),
|
|
312
|
+
files: null,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const rewritten = planSeamRewrite({
|
|
316
|
+
sourceText: beforeText, candidate, symbolExtents: lookup.extents,
|
|
317
|
+
});
|
|
280
318
|
if (rewritten.files === null) {
|
|
281
319
|
return { outcome: outcome({ planKey, decision: 'rejected', reasons: rewritten.reasons, candidate }), files: null };
|
|
282
320
|
}
|
|
@@ -308,6 +346,11 @@ export async function applySeamConflict({
|
|
|
308
346
|
// 索引の書き先を使い捨てへ向ける。verifierも再indexもここへ書くので、本repoの
|
|
309
347
|
// 索引を触らせない。src/testへはrunnerがmountを拒否する。
|
|
310
348
|
{ entry: '.lattice/sensor', target: scratchLattice },
|
|
349
|
+
// build成果物はgitignoreされているので、どのcommitのworktreeにも存在しない。
|
|
350
|
+
// 張らないと、同梱sensorを起動するfocused testがすべてENOENTで落ち、
|
|
351
|
+
// focused_tests_passedが原理的に満たせなくなる(実測でこれに当たった)。
|
|
352
|
+
// 存在する時だけ張る——Latticeを依存として使うprojectはnode_modules側に持つ。
|
|
353
|
+
...await buildOutputMounts(repoRoot),
|
|
311
354
|
],
|
|
312
355
|
// 書き先を隠すのでなく、書かせない。verifierが常駐面を起こすとworktreeへ索引が
|
|
313
356
|
// 生まれ、変更を残さない規律に当たる。
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
|
|
5
|
+
export const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
export const GIT_SHA1 = /^[0-9a-f]{40}$/u;
|
|
8
|
+
|
|
9
|
+
export const SEAM_REF_PREFIX = 'refs/lattice/seam';
|
|
10
|
+
|
|
11
|
+
export async function git(args, cwd) {
|
|
12
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
13
|
+
cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024,
|
|
14
|
+
});
|
|
15
|
+
return stdout;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function safeRelative(target) {
|
|
19
|
+
return typeof target === 'string' && target.length > 0 && !target.includes('\0')
|
|
20
|
+
&& !path.posix.isAbsolute(target) && target === path.posix.normalize(target)
|
|
21
|
+
&& !target.split('/').includes('..');
|
|
22
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { GIT_SHA1, execFileAsync, git, safeRelative } from './seam-commit-shared.mjs';
|
|
5
|
+
import { seamRefFor } from './seam-ref.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 採用された変換をcommitへ確定し、そのshaを返す。
|
|
9
|
+
*
|
|
10
|
+
* 本repositoryの作業ツリーは触らない。使い捨てworktreeをbaseへ張り、変換後のfileを書き、
|
|
11
|
+
* detached HEADでcommitし、refへ繋いでからworktreeを畳む。commit objectはobject DBを共有する
|
|
12
|
+
* ので、worktreeを消してもshaは生き残る——refはそれをGCから守るためである。
|
|
13
|
+
*
|
|
14
|
+
* @param {object} options
|
|
15
|
+
* @param {string} options.repoRoot 本repository
|
|
16
|
+
* @param {string} options.baseSha 変換前のbase
|
|
17
|
+
* @param {object} options.files pathごとの変換後text
|
|
18
|
+
* @param {string} options.candidateId ref名に使う識別子
|
|
19
|
+
* @param {string} options.message commit message
|
|
20
|
+
* @returns {Promise<{commitSha: string, ref: string}>}
|
|
21
|
+
*/
|
|
22
|
+
export async function commitSeamTransform({
|
|
23
|
+
repoRoot, baseSha, files, candidateId, message,
|
|
24
|
+
} = {}) {
|
|
25
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) throw new TypeError('repoRootが不正');
|
|
26
|
+
if (!GIT_SHA1.test(baseSha ?? '')) throw new TypeError('baseShaが不正');
|
|
27
|
+
if (files === null || typeof files !== 'object' || Array.isArray(files)
|
|
28
|
+
|| Object.keys(files).length === 0) throw new TypeError('filesが不正');
|
|
29
|
+
const targets = Object.keys(files);
|
|
30
|
+
if (!targets.every(safeRelative)) throw new TypeError('変換後pathがrepo相対規律を満たさない');
|
|
31
|
+
if (typeof candidateId !== 'string' || !/^[0-9A-Za-z][\w.-]{0,127}$/u.test(candidateId)) {
|
|
32
|
+
throw new TypeError('candidateIdが不正');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const worktreeRoot = await mkdtemp(path.join(tmpdir(), 'lattice-seam-commit-'));
|
|
36
|
+
const worktreePath = path.join(worktreeRoot, 'tree');
|
|
37
|
+
let commitSha;
|
|
38
|
+
try {
|
|
39
|
+
await git(['worktree', 'add', '--detach', '--quiet', worktreePath, baseSha], repoRoot);
|
|
40
|
+
for (const [target, text] of Object.entries(files)) {
|
|
41
|
+
const absolute = path.join(worktreePath, target);
|
|
42
|
+
await mkdir(path.dirname(absolute), { recursive: true });
|
|
43
|
+
await writeFile(absolute, text);
|
|
44
|
+
}
|
|
45
|
+
await git(['add', '--', ...targets], worktreePath);
|
|
46
|
+
// 変換で1 byteも変わらなかったなら、確定すべき成果が無い。空commitで
|
|
47
|
+
// 「進んだ」ように見せない。
|
|
48
|
+
const staged = await git(['diff', '--cached', '--name-only'], worktreePath);
|
|
49
|
+
if (staged.trim() === '') throw new TypeError('変換後の差分が無い');
|
|
50
|
+
await git([
|
|
51
|
+
'-c', 'user.email=lattice@localhost', '-c', 'user.name=lattice',
|
|
52
|
+
'commit', '--quiet', '-m', message ?? `seam transform ${candidateId}`,
|
|
53
|
+
], worktreePath);
|
|
54
|
+
commitSha = (await git(['rev-parse', 'HEAD'], worktreePath)).trim();
|
|
55
|
+
if (!GIT_SHA1.test(commitSha)) throw new TypeError('commit shaが不正');
|
|
56
|
+
} finally {
|
|
57
|
+
await git(['worktree', 'remove', '--force', worktreePath], repoRoot).catch(() => {});
|
|
58
|
+
await rm(worktreeRoot, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// worktreeを畳んだ後にrefを張る。object DBは共有なのでshaは生きているが、
|
|
62
|
+
// refが無いとGCの対象になる。
|
|
63
|
+
const ref = seamRefFor(candidateId);
|
|
64
|
+
// 同じcandidateへ2回目の変換が来た時、黙って上書きすると1回目の証跡が消える。
|
|
65
|
+
// 連鎖は「前の変換を含むbaseの上で次を確定する」形でしか正しくならないので、
|
|
66
|
+
// 既存refが今回のbaseの祖先を指していなければ拒む(ADR 0142 / ADR 0141 OQ2)。
|
|
67
|
+
const existing = await git(['for-each-ref', '--format=%(objectname)', ref], repoRoot)
|
|
68
|
+
.then((stdout) => stdout.trim())
|
|
69
|
+
.catch(() => '');
|
|
70
|
+
if (GIT_SHA1.test(existing) && existing !== commitSha) {
|
|
71
|
+
const chained = await execFileAsync('git', ['merge-base', '--is-ancestor', existing, baseSha], {
|
|
72
|
+
cwd: repoRoot, encoding: 'utf8',
|
|
73
|
+
}).then(() => true).catch(() => false);
|
|
74
|
+
if (!chained) {
|
|
75
|
+
throw new Error(`seam ref ${ref} は既に ${existing} を指しており、今回のbaseはその子孫でない`
|
|
76
|
+
+ '。前の変換を含むbaseの上で確定するか、別のcandidate idを使う');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
await git(['update-ref', ref, commitSha], repoRoot);
|
|
80
|
+
return { commitSha, ref };
|
|
81
|
+
}
|
package/src/seam-commit.mjs
CHANGED
|
@@ -13,86 +13,6 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
|
13
13
|
import { tmpdir } from 'node:os';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { promisify } from 'node:util';
|
|
16
|
+
export { commitSeamTransform } from './seam-commit-transform.mjs';
|
|
17
|
+
export { seamRefFor } from './seam-ref.mjs';
|
|
16
18
|
|
|
17
|
-
const execFileAsync = promisify(execFile);
|
|
18
|
-
const GIT_SHA1 = /^[0-9a-f]{40}$/u;
|
|
19
|
-
const SEAM_REF_PREFIX = 'refs/lattice/seam';
|
|
20
|
-
|
|
21
|
-
async function git(args, cwd) {
|
|
22
|
-
const { stdout } = await execFileAsync('git', args, {
|
|
23
|
-
cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024,
|
|
24
|
-
});
|
|
25
|
-
return stdout;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** 変換の成果を指すref。branch名前空間へ置かないので、通常のbranch一覧には現れない。 */
|
|
29
|
-
export function seamRefFor(candidateId) {
|
|
30
|
-
return `${SEAM_REF_PREFIX}/${candidateId}`;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function safeRelative(target) {
|
|
34
|
-
return typeof target === 'string' && target.length > 0 && !target.includes('\0')
|
|
35
|
-
&& !path.posix.isAbsolute(target) && target === path.posix.normalize(target)
|
|
36
|
-
&& !target.split('/').includes('..');
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* 採用された変換をcommitへ確定し、そのshaを返す。
|
|
41
|
-
*
|
|
42
|
-
* 本repositoryの作業ツリーは触らない。使い捨てworktreeをbaseへ張り、変換後のfileを書き、
|
|
43
|
-
* detached HEADでcommitし、refへ繋いでからworktreeを畳む。commit objectはobject DBを共有する
|
|
44
|
-
* ので、worktreeを消してもshaは生き残る——refはそれをGCから守るためである。
|
|
45
|
-
*
|
|
46
|
-
* @param {object} options
|
|
47
|
-
* @param {string} options.repoRoot 本repository
|
|
48
|
-
* @param {string} options.baseSha 変換前のbase
|
|
49
|
-
* @param {object} options.files pathごとの変換後text
|
|
50
|
-
* @param {string} options.candidateId ref名に使う識別子
|
|
51
|
-
* @param {string} options.message commit message
|
|
52
|
-
* @returns {Promise<{commitSha: string, ref: string}>}
|
|
53
|
-
*/
|
|
54
|
-
export async function commitSeamTransform({
|
|
55
|
-
repoRoot, baseSha, files, candidateId, message,
|
|
56
|
-
} = {}) {
|
|
57
|
-
if (typeof repoRoot !== 'string' || repoRoot.length === 0) throw new TypeError('repoRootが不正');
|
|
58
|
-
if (!GIT_SHA1.test(baseSha ?? '')) throw new TypeError('baseShaが不正');
|
|
59
|
-
if (files === null || typeof files !== 'object' || Array.isArray(files)
|
|
60
|
-
|| Object.keys(files).length === 0) throw new TypeError('filesが不正');
|
|
61
|
-
const targets = Object.keys(files);
|
|
62
|
-
if (!targets.every(safeRelative)) throw new TypeError('変換後pathがrepo相対規律を満たさない');
|
|
63
|
-
if (typeof candidateId !== 'string' || !/^[0-9A-Za-z][\w.-]{0,127}$/u.test(candidateId)) {
|
|
64
|
-
throw new TypeError('candidateIdが不正');
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const worktreeRoot = await mkdtemp(path.join(tmpdir(), 'lattice-seam-commit-'));
|
|
68
|
-
const worktreePath = path.join(worktreeRoot, 'tree');
|
|
69
|
-
let commitSha;
|
|
70
|
-
try {
|
|
71
|
-
await git(['worktree', 'add', '--detach', '--quiet', worktreePath, baseSha], repoRoot);
|
|
72
|
-
for (const [target, text] of Object.entries(files)) {
|
|
73
|
-
const absolute = path.join(worktreePath, target);
|
|
74
|
-
await mkdir(path.dirname(absolute), { recursive: true });
|
|
75
|
-
await writeFile(absolute, text);
|
|
76
|
-
}
|
|
77
|
-
await git(['add', '--', ...targets], worktreePath);
|
|
78
|
-
// 変換で1 byteも変わらなかったなら、確定すべき成果が無い。空commitで
|
|
79
|
-
// 「進んだ」ように見せない。
|
|
80
|
-
const staged = await git(['diff', '--cached', '--name-only'], worktreePath);
|
|
81
|
-
if (staged.trim() === '') throw new TypeError('変換後の差分が無い');
|
|
82
|
-
await git([
|
|
83
|
-
'-c', 'user.email=lattice@localhost', '-c', 'user.name=lattice',
|
|
84
|
-
'commit', '--quiet', '-m', message ?? `seam transform ${candidateId}`,
|
|
85
|
-
], worktreePath);
|
|
86
|
-
commitSha = (await git(['rev-parse', 'HEAD'], worktreePath)).trim();
|
|
87
|
-
if (!GIT_SHA1.test(commitSha)) throw new TypeError('commit shaが不正');
|
|
88
|
-
} finally {
|
|
89
|
-
await git(['worktree', 'remove', '--force', worktreePath], repoRoot).catch(() => {});
|
|
90
|
-
await rm(worktreeRoot, { recursive: true, force: true });
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// worktreeを畳んだ後にrefを張る。object DBは共有なのでshaは生きているが、
|
|
94
|
-
// refが無いとGCの対象になる。
|
|
95
|
-
const ref = seamRefFor(candidateId);
|
|
96
|
-
await git(['update-ref', ref, commitSha], repoRoot);
|
|
97
|
-
return { commitSha, ref };
|
|
98
|
-
}
|
package/src/seam-ref.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { SEAM_REF_PREFIX, git } from './seam-commit-shared.mjs';
|
|
2
|
+
|
|
3
|
+
/** 変換の成果を指すref。branch名前空間へ置かないので、通常のbranch一覧には現れない。 */
|
|
4
|
+
export function seamRefFor(candidateId) {
|
|
5
|
+
return `${SEAM_REF_PREFIX}/${candidateId}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 確定済みseam refを列挙する(ADR 0142 / ADR 0141 OQ1)。
|
|
10
|
+
*
|
|
11
|
+
* **自動では消さない。** このrefが指すのは「五条件を通って受理された変換の実体」であり、
|
|
12
|
+
* どの版がどの競合をどう解いたかを後から辿れる唯一の資源である。runが閉じたら消す設計も
|
|
13
|
+
* 検討したが、それは証跡を寿命付きにするということで、記録を所有するという製品の役目と
|
|
14
|
+
* 衝突する。消すかどうかは所有者の裁定に委ね、道具は「何が在るか」を見せる側だけを持つ。
|
|
15
|
+
*
|
|
16
|
+
* @returns {Promise<Array<{ref: string, candidate_id: string, commit_sha: string}>>}
|
|
17
|
+
*/
|
|
18
|
+
export async function listSeamRefs({ repoRoot } = {}) {
|
|
19
|
+
let stdout;
|
|
20
|
+
try {
|
|
21
|
+
stdout = await git(['for-each-ref', '--format=%(refname) %(objectname)', SEAM_REF_PREFIX], repoRoot);
|
|
22
|
+
} catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
return stdout.split('\n')
|
|
26
|
+
.map((line) => line.trim())
|
|
27
|
+
.filter((line) => line.length > 0)
|
|
28
|
+
.map((line) => {
|
|
29
|
+
const [ref, commitSha] = line.split(' ');
|
|
30
|
+
return { ref, candidate_id: ref.slice(`${SEAM_REF_PREFIX}/`.length), commit_sha: commitSha };
|
|
31
|
+
})
|
|
32
|
+
.sort((left, right) => (left.ref < right.ref ? -1 : left.ref > right.ref ? 1 : 0));
|
|
33
|
+
}
|
package/src/seam-rewrite.mjs
CHANGED
|
@@ -85,6 +85,13 @@ function exportedBlock(raw) {
|
|
|
85
85
|
return parts.join('\n');
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/** 原pathでexport宣言だったか。移動先でexportを足したかではなく、元の姿を見る。 */
|
|
89
|
+
function wasExported(raw) {
|
|
90
|
+
const declaration = raw.split('\n')
|
|
91
|
+
.find((line) => !COMMENT_LINE.test(line) && line.trim() !== '');
|
|
92
|
+
return declaration !== undefined && /^\s*export\s/u.test(declaration);
|
|
93
|
+
}
|
|
94
|
+
|
|
88
95
|
function relativeSpecifier(fromPath, toPath) {
|
|
89
96
|
const fromDir = fromPath.slice(0, fromPath.lastIndexOf('/') + 1);
|
|
90
97
|
return toPath.startsWith(fromDir) ? `./${toPath.slice(fromDir.length)}` : `./${toPath}`;
|
|
@@ -138,10 +145,17 @@ export function planSeamRewrite({ sourceText, candidate, symbolExtents } = {}) {
|
|
|
138
145
|
|
|
139
146
|
const bodyByPath = new Map();
|
|
140
147
|
const removal = new Set();
|
|
148
|
+
// 原pathでexportされていたsymbolは、移した先から残余面が再exportする。
|
|
149
|
+
// しないと原pathをimportしている全fileが壊れ、外部挙動同等性が原理的に満たせない。
|
|
150
|
+
const reExportByPath = new Map();
|
|
141
151
|
for (const block of blocks) {
|
|
142
152
|
const raw = lines.slice(block.start - 1, block.end).join('\n');
|
|
143
153
|
if (!bodyByPath.has(block.path)) bodyByPath.set(block.path, []);
|
|
144
154
|
bodyByPath.get(block.path).push(exportedBlock(raw));
|
|
155
|
+
if (wasExported(raw)) {
|
|
156
|
+
if (!reExportByPath.has(block.path)) reExportByPath.set(block.path, []);
|
|
157
|
+
reExportByPath.get(block.path).push(block.symbol);
|
|
158
|
+
}
|
|
145
159
|
for (let line = block.start; line <= block.end; line += 1) removal.add(line);
|
|
146
160
|
}
|
|
147
161
|
|
|
@@ -175,7 +189,11 @@ export function planSeamRewrite({ sourceText, candidate, symbolExtents } = {}) {
|
|
|
175
189
|
const residualBody = keptBody.join('\n').replace(/\n{3,}/gu, '\n\n').replace(/\n+$/u, '');
|
|
176
190
|
const residualCross = importsFor(residual.path, residualBody)
|
|
177
191
|
.filter((statement) => !keptHeader.join('\n').includes(statement));
|
|
178
|
-
|
|
192
|
+
const reExports = [...reExportByPath.entries()]
|
|
193
|
+
.sort(([left], [right]) => compareText(left, right))
|
|
194
|
+
.map(([targetPath, names]) => `export { ${[...names].sort(compareText).join(', ')} }`
|
|
195
|
+
+ ` from '${relativeSpecifier(residual.path, targetPath)}';`);
|
|
196
|
+
files[residual.path] = `${[...keptHeader, ...residualCross, ...reExports].join('\n')}\n${residualBody}\n`
|
|
179
197
|
.replace(/^\n+/u, '');
|
|
180
198
|
|
|
181
199
|
return { files, reasons: [] };
|
package/src/todo-cli.mjs
CHANGED
|
@@ -276,7 +276,7 @@ async function readEvidenceInput(repoRoot, inputRef) {
|
|
|
276
276
|
});
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
-
async function readJsonInput(repoRoot, inputRef, { validate, invalidCode }) {
|
|
279
|
+
async function readJsonInput(repoRoot, inputRef, { validate, invalidCode, expected = null }) {
|
|
280
280
|
if (!isTodoRef(inputRef)) {
|
|
281
281
|
throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_outside_repo', undefined, { input_ref: inputRef });
|
|
282
282
|
}
|
|
@@ -321,7 +321,10 @@ async function readJsonInput(repoRoot, inputRef, { validate, invalidCode }) {
|
|
|
321
321
|
throw new TodoStoreError('INVALID_JSON', 'json_parse_failed');
|
|
322
322
|
}
|
|
323
323
|
if (!validate(descriptor)) {
|
|
324
|
-
|
|
324
|
+
// 「schema_invalid」だけを返すと、呼び出したAIは何をどう直せばよいか分からない。
|
|
325
|
+
// 期待する形を渡されている入口は、それをそのまま返す(ADR 0130の案内規律)。
|
|
326
|
+
throw new TodoStoreError(invalidCode, 'schema_invalid', undefined,
|
|
327
|
+
expected === null ? undefined : { expected });
|
|
325
328
|
}
|
|
326
329
|
return descriptor;
|
|
327
330
|
}
|
|
@@ -961,15 +964,21 @@ async function witnessScaffold({ repoRoot, planKey, inputRef }) {
|
|
|
961
964
|
}
|
|
962
965
|
const { queries, paths } = buildWitnessObservationQuerySet(draft);
|
|
963
966
|
const collected = await collectSensorEvidence({ cwd: repoRoot, querySet: { queries } });
|
|
964
|
-
const
|
|
967
|
+
const observationByPath = {};
|
|
965
968
|
queries.forEach((query, index) => {
|
|
966
969
|
if (query.operation !== 'affected') return;
|
|
967
970
|
const entry = collected.outcomes[index]?.targets?.[0];
|
|
968
971
|
// 観測できていないものを空配列へ丸めない。丸めるとdriftでcompileが落ちる。
|
|
969
|
-
|
|
970
|
-
|
|
972
|
+
// 不存在(absent)は観測**できている**——fsのlstat結果である。未観測と混ぜると、
|
|
973
|
+
// 創作境界を宣言したToDoが「まだ確かめていない」側へ落ちる(ADR 0136)。
|
|
974
|
+
if (!Array.isArray(entry?.data?.affectedTests) || !Array.isArray(entry?.data?.changedFiles)) return;
|
|
975
|
+
observationByPath[query.target] = {
|
|
976
|
+
state: entry.path_state === 'absent' ? 'absent' : 'present',
|
|
977
|
+
affectedTests: [...entry.data.affectedTests],
|
|
978
|
+
changedFiles: [...entry.data.changedFiles],
|
|
979
|
+
};
|
|
971
980
|
});
|
|
972
|
-
const { witnessSet, reasons } = buildWitnessSet({ draft,
|
|
981
|
+
const { witnessSet, reasons } = buildWitnessSet({ draft, observationByPath });
|
|
973
982
|
if (witnessSet === null) {
|
|
974
983
|
throw new TodoStoreError('WITNESS_SCAFFOLD_INCOMPLETE', 'witness_scaffold_incomplete', undefined, {
|
|
975
984
|
reasons, next_action: 'resolve_declaration_then_retry',
|
|
@@ -1006,6 +1015,11 @@ async function readSeamPathNames(repoRoot, inputRef) {
|
|
|
1006
1015
|
&& Object.entries(candidate.names)
|
|
1007
1016
|
.every(([key, target]) => isTodoIdentifier(key) && isTodoRef(target)),
|
|
1008
1017
|
invalidCode: 'SEAM_PATH_NAMES_INVALID',
|
|
1018
|
+
expected: {
|
|
1019
|
+
schema: 'lattice.seam_path_names.v1',
|
|
1020
|
+
shape: '{ "schema": "lattice.seam_path_names.v1", "names": { "<task_id>": "<repo相対path>" } }',
|
|
1021
|
+
note: '所有面はtask_idごとに、共有面は"shared"というkeyで名前を与える',
|
|
1022
|
+
},
|
|
1009
1023
|
});
|
|
1010
1024
|
return value.names;
|
|
1011
1025
|
}
|
package/src/witness-scaffold.mjs
CHANGED
|
@@ -18,6 +18,34 @@ const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
|
18
18
|
const sortedUnique = (values) => [...new Set(values)].sort(compareText);
|
|
19
19
|
|
|
20
20
|
export const WITNESS_DRAFT_SCHEMA = 'lattice.todo_witness_draft.v1';
|
|
21
|
+
/** 創作宣言を書ける版。v1はstringのownsだけを受け、`creates`を表現できない。 */
|
|
22
|
+
export const WITNESS_DRAFT_SCHEMA_V2 = 'lattice.todo_witness_draft.v2';
|
|
23
|
+
const DRAFT_SCHEMAS = Object.freeze([WITNESS_DRAFT_SCHEMA, WITNESS_DRAFT_SCHEMA_V2]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* ownsの1件を正規化する。
|
|
27
|
+
*
|
|
28
|
+
* v2では`{ path, creates: true }`を書ける。まだ存在しないpathを所有するToDo——新module・
|
|
29
|
+
* 新doc・新testの追加——は、これが無いと道具で宣言を作れない(ADR 0136)。
|
|
30
|
+
*/
|
|
31
|
+
function ownEntry(value, { allowCreates }) {
|
|
32
|
+
if (typeof value === 'string') return isTodoRef(value) ? { target: value, creates: false } : null;
|
|
33
|
+
if (!allowCreates || value === null || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
34
|
+
const keys = Object.keys(value).sort();
|
|
35
|
+
if (keys.length !== 2 || keys[0] !== 'creates' || keys[1] !== 'path') return null;
|
|
36
|
+
if (!isTodoRef(value.path) || value.creates !== true) return null;
|
|
37
|
+
// prefix形(末尾/)はaffectedがunresolvedを返すので、file単位に限る(ADR 0136)。
|
|
38
|
+
if (value.path.endsWith('/')) return null;
|
|
39
|
+
return { target: value.path, creates: true };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 下書きの1 taskが宣言する所有を正規化する。1件でも形が壊れていればnull。 */
|
|
43
|
+
export function draftOwnEntries(task, schema) {
|
|
44
|
+
const allowCreates = schema === WITNESS_DRAFT_SCHEMA_V2;
|
|
45
|
+
if (!Array.isArray(task?.owns)) return null;
|
|
46
|
+
const entries = task.owns.map((own) => ownEntry(own, { allowCreates }));
|
|
47
|
+
return entries.some((entry) => entry === null) ? null : entries;
|
|
48
|
+
}
|
|
21
49
|
|
|
22
50
|
function reject(reasons) {
|
|
23
51
|
return { witnessSet: null, queries: [], reasons: sortedUnique(reasons) };
|
|
@@ -26,7 +54,7 @@ function reject(reasons) {
|
|
|
26
54
|
/** 下書きの形。AIが書く欄だけを持ち、観測で埋まる欄は持たない。 */
|
|
27
55
|
export function validateWitnessDraft(value) {
|
|
28
56
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
29
|
-
if (value.schema
|
|
57
|
+
if (!DRAFT_SCHEMAS.includes(value.schema)) return false;
|
|
30
58
|
if (!isTodoIdentifier(value.project_id) || !isTodoIdentifier(value.plan_key)) return false;
|
|
31
59
|
if (value.capacity === null || typeof value.capacity !== 'object'
|
|
32
60
|
|| !Number.isSafeInteger(value.capacity.executors) || value.capacity.executors < 1) return false;
|
|
@@ -35,7 +63,7 @@ export function validateWitnessDraft(value) {
|
|
|
35
63
|
if (entries.length === 0) return false;
|
|
36
64
|
return entries.every(([taskId, task]) => isTodoIdentifier(taskId)
|
|
37
65
|
&& task !== null && typeof task === 'object' && !Array.isArray(task)
|
|
38
|
-
&&
|
|
66
|
+
&& draftOwnEntries(task, value.schema) !== null
|
|
39
67
|
&& (task.reads === undefined || (Array.isArray(task.reads) && task.reads.every(isTodoRef)))
|
|
40
68
|
&& (task.unknowns === undefined || (Array.isArray(task.unknowns)
|
|
41
69
|
&& task.unknowns.every((entry) => entry !== null && typeof entry === 'object'
|
|
@@ -52,7 +80,8 @@ function queryIdFor(index) {
|
|
|
52
80
|
|
|
53
81
|
/** 下書きから、観測に要るquery setを組む。所有pathごとに1つのaffected queryを引く。 */
|
|
54
82
|
export function buildWitnessObservationQuerySet(draft) {
|
|
55
|
-
const paths = sortedUnique(Object.values(draft.tasks)
|
|
83
|
+
const paths = sortedUnique(Object.values(draft.tasks)
|
|
84
|
+
.flatMap((task) => (draftOwnEntries(task, draft.schema) ?? []).map(({ target }) => target)));
|
|
56
85
|
return {
|
|
57
86
|
queries: [
|
|
58
87
|
{ id: 'witness-status', operation: 'status' },
|
|
@@ -69,9 +98,11 @@ export function buildWitnessObservationQuerySet(draft) {
|
|
|
69
98
|
*
|
|
70
99
|
* @param {object} options
|
|
71
100
|
* @param {object} options.draft `lattice.todo_witness_draft.v1`
|
|
72
|
-
* @param {object} options.
|
|
101
|
+
* @param {object} options.observationByPath 所有pathごとのfresh観測
|
|
102
|
+
* `{ state: 'absent'|'present', affectedTests: string[], changedFiles: string[] }`。
|
|
103
|
+
* 観測できていないpathは**欄そのものを置かない**——空で置くと不在と区別できない。
|
|
73
104
|
*/
|
|
74
|
-
export function buildWitnessSet({ draft,
|
|
105
|
+
export function buildWitnessSet({ draft, observationByPath } = {}) {
|
|
75
106
|
if (!validateWitnessDraft(draft)) return reject(['draft_invalid']);
|
|
76
107
|
const { paths } = buildWitnessObservationQuerySet(draft);
|
|
77
108
|
const queryIdByPath = new Map(paths.map((target, index) => [target, queryIdFor(index)]));
|
|
@@ -79,21 +110,39 @@ export function buildWitnessSet({ draft, affectedTestsByPath } = {}) {
|
|
|
79
110
|
const reasons = [];
|
|
80
111
|
const manualWitness = {};
|
|
81
112
|
for (const [taskId, task] of Object.entries(draft.tasks).sort(([left], [right]) => compareText(left, right))) {
|
|
82
|
-
const
|
|
113
|
+
const entries = draftOwnEntries(task, draft.schema) ?? [];
|
|
114
|
+
const owns = [...new Map(entries.map((entry) => [entry.target, entry])).values()]
|
|
115
|
+
.sort((left, right) => compareText(left.target, right.target));
|
|
83
116
|
if (owns.length === 0) { reasons.push(`owns_empty:${taskId}`); continue; }
|
|
84
117
|
// affected_testsは宣言とfresh観測をbinding単位でexact比較する。複数pathを所有すると
|
|
85
118
|
// 観測集合が一致しない限り必ず落ちるので、今の契約では表現できない(2026-07-27の実測)。
|
|
86
119
|
if (owns.length > 1) { reasons.push(`multiple_owned_paths_unsupported:${taskId}`); continue; }
|
|
87
|
-
const [
|
|
88
|
-
const
|
|
120
|
+
const [own] = owns;
|
|
121
|
+
const target = own.target;
|
|
122
|
+
const observed = observationByPath?.[target];
|
|
89
123
|
// 観測できていないことを空配列へ丸めない。丸めるとdriftでcompileが落ちる。
|
|
90
|
-
if (
|
|
124
|
+
if (observed === undefined) { reasons.push(`affected_tests_unobserved:${target}`); continue; }
|
|
125
|
+
if (own.creates) {
|
|
126
|
+
// 宣言が実態と合っているかを確かめるのが道具の役目である。front endが要求する形
|
|
127
|
+
// (fresh absent・blast radiusが空・changedFilesが対象1件)をここで満たしておかないと、
|
|
128
|
+
// 通る宣言を作ったつもりでcompileで落ちる(ADR 0136)。
|
|
129
|
+
if (observed.state !== 'absent') { reasons.push(`creates_path_present:${target}`); continue; }
|
|
130
|
+
if (observed.affectedTests.length !== 0
|
|
131
|
+
|| observed.changedFiles.length !== 1
|
|
132
|
+
|| observed.changedFiles[0] !== target) {
|
|
133
|
+
reasons.push(`creates_unverified:${target}`); continue;
|
|
134
|
+
}
|
|
135
|
+
} else if (observed.state === 'absent') {
|
|
136
|
+
// 不存在のpathを黙って通さない。作るつもりならそう宣言する、が次の一手である。
|
|
137
|
+
reasons.push(`path_absent_declare_creates:${target}`); continue;
|
|
138
|
+
}
|
|
139
|
+
const affected = own.creates ? [] : observed.affectedTests;
|
|
91
140
|
for (const anchor of task.concern_anchors ?? []) {
|
|
92
141
|
// `within`は自分が所有している資源に限る。所有していない資源の内側に担当を主張させない。
|
|
93
|
-
if (
|
|
142
|
+
if (anchor.within !== target) reasons.push(`anchor_outside_owned:${taskId}:${anchor.within}`);
|
|
94
143
|
}
|
|
95
144
|
manualWitness[taskId] = {
|
|
96
|
-
owns: [{ kind: 'path', target }],
|
|
145
|
+
owns: [own.creates ? { kind: 'path', target, creates: true } : { kind: 'path', target }],
|
|
97
146
|
reads: sortedUnique(task.reads ?? []),
|
|
98
147
|
writes: [target],
|
|
99
148
|
resources: [],
|