@quolu/lattice 0.28.0 → 0.30.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 +148 -25
- package/README.ja.md +251 -0
- package/README.md +174 -127
- package/package.json +24 -5
- package/src/bridge-daemon.mjs +18 -6
- package/src/runtime-cli.mjs +285 -1
- package/src/runtime-control-store.mjs +30 -0
- package/src/runtime-decision-verifier.mjs +3 -0
- package/src/runtime-diff-observer.mjs +8 -1
- package/src/runtime-engine.mjs +5 -0
- package/src/runtime-io-sentinel.mjs +384 -0
- package/src/runtime-managed-supervisor.mjs +26 -25
- package/src/runtime-scripted-adapter-controller.mjs +15 -2
- package/src/runtime-scripted-worktree.mjs +104 -0
- package/src/runtime-socket-owner.mjs +125 -0
- package/src/todo-cli.mjs +14 -5
- package/src/todo-independence-guidance.mjs +62 -0
- package/src/todo-store.mjs +39 -0
- package/src/witness-scaffold.mjs +60 -11
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* unix domain socketの所有関係をOS非依存に観測する。
|
|
3
|
+
*
|
|
4
|
+
* 管理runtimeは「そのPIDが本当にそのcontrol socketを持っているか」を確かめてから接続する。
|
|
5
|
+
* この検査は成りすましたsocketへ繋がないための要であり、緩めない。
|
|
6
|
+
*
|
|
7
|
+
* 実装は`/usr/sbin/lsof`をhard-codeしていた。**これはmacOS専用のpathで、Linuxでは管理runtime
|
|
8
|
+
* が丸ごと動かなかった**(CIで実測:実daemonを起動するtestが軒並み`socket owner観測失敗`で
|
|
9
|
+
* 落ちていた)。移植性の欠陥であってtestの都合ではない。
|
|
10
|
+
*
|
|
11
|
+
* Linuxには`/proc`という一次情報があるので、外部commandを介さずに同じことが分かる。
|
|
12
|
+
* `/proc/<pid>/fd/*`のsymlinkが`socket:[inode]`を指し、`/proc/net/unix`がinodeとpathを結ぶ。
|
|
13
|
+
* lsofを持たない最小構成のcontainerでも動くので、Linuxでは`/proc`を優先する。
|
|
14
|
+
*
|
|
15
|
+
* どちらの手段でも観測できない環境では、**観測できたことにしない**。呼び出し側がfail closed
|
|
16
|
+
* するために、例外を投げるか空を返す(「所有していない」ではなく「分からない」を返さない設計に
|
|
17
|
+
* するため、判定は必ず呼び出し側の`some(...)`で行う)。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execFile } from 'node:child_process';
|
|
21
|
+
import { readdir, readFile, readlink } from 'node:fs/promises';
|
|
22
|
+
import { promisify } from 'node:util';
|
|
23
|
+
|
|
24
|
+
const execFileAsync = promisify(execFile);
|
|
25
|
+
|
|
26
|
+
/** lsofの絶対path候補。PATHは引かない——探索経路を広げるとsocket検査の前提が緩む。 */
|
|
27
|
+
const LSOF_PATHS = Object.freeze(['/usr/sbin/lsof', '/usr/bin/lsof', '/bin/lsof']);
|
|
28
|
+
|
|
29
|
+
const SOCKET_INODE = /^socket:\[(\d+)\]$/u;
|
|
30
|
+
|
|
31
|
+
async function runLsof(args) {
|
|
32
|
+
let lastError = new Error('lsof not found');
|
|
33
|
+
for (const binary of LSOF_PATHS) {
|
|
34
|
+
try {
|
|
35
|
+
const { stdout } = await execFileAsync(binary, args, { encoding: 'utf8' });
|
|
36
|
+
return stdout;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
// ENOENTは「その場所に無い」だけなので次の候補へ。
|
|
39
|
+
if (error?.code === 'ENOENT') { lastError = error; continue; }
|
|
40
|
+
// lsofは該当が無いとexit 1を返す。これは観測の失敗ではなく「所有者なし」である。
|
|
41
|
+
// ここを潰すと、誰も掴んでいないstale socketが観測失敗として扱われ、正常な後片付けが
|
|
42
|
+
// 止まる(元の呼び出し側も同じ区別をしていた)。
|
|
43
|
+
if (error?.code === 1 && String(error?.stdout ?? '').trim() === '') return '';
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
throw lastError;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `/proc/<pid>/fd`からunix socketのinodeを集める。Linux専用。 */
|
|
51
|
+
async function procSocketInodes(pid) {
|
|
52
|
+
const entries = await readdir(`/proc/${pid}/fd`);
|
|
53
|
+
const inodes = new Set();
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
let target;
|
|
56
|
+
try { target = await readlink(`/proc/${pid}/fd/${entry}`); } catch { continue; }
|
|
57
|
+
const matched = SOCKET_INODE.exec(target);
|
|
58
|
+
if (matched !== null) inodes.add(matched[1]);
|
|
59
|
+
}
|
|
60
|
+
return inodes;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `/proc/net/unix`を読み、inode -> pathの対応を返す。 */
|
|
64
|
+
async function procUnixSocketPaths() {
|
|
65
|
+
const text = await readFile('/proc/net/unix', 'utf8');
|
|
66
|
+
const byInode = new Map();
|
|
67
|
+
for (const line of text.split('\n').slice(1)) {
|
|
68
|
+
// Num RefCount Protocol Flags Type St Inode Path
|
|
69
|
+
const fields = line.trim().split(/\s+/u);
|
|
70
|
+
if (fields.length < 8) continue;
|
|
71
|
+
const inode = fields[6];
|
|
72
|
+
const socketPath = fields.slice(7).join(' ');
|
|
73
|
+
if (socketPath.length === 0) continue;
|
|
74
|
+
if (!byInode.has(inode)) byInode.set(inode, []);
|
|
75
|
+
byInode.get(inode).push(socketPath);
|
|
76
|
+
}
|
|
77
|
+
return byInode;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 指定PIDが開いているunix socketのpath一覧を返す。
|
|
82
|
+
*
|
|
83
|
+
* @returns {Promise<string[]>} 観測できたpath。観測手段が無ければthrowする。
|
|
84
|
+
*/
|
|
85
|
+
export async function socketPathsOwnedByPid(pid) {
|
|
86
|
+
if (process.platform === 'linux') {
|
|
87
|
+
const inodes = await procSocketInodes(pid);
|
|
88
|
+
if (inodes.size === 0) return [];
|
|
89
|
+
const byInode = await procUnixSocketPaths();
|
|
90
|
+
return [...inodes].flatMap((inode) => byInode.get(inode) ?? []);
|
|
91
|
+
}
|
|
92
|
+
const stdout = await runLsof(['-a', '-p', String(pid), '-U', '-F', 'fn']);
|
|
93
|
+
return stdout.split('\n')
|
|
94
|
+
.filter((line) => line.startsWith('n'))
|
|
95
|
+
.map((line) => line.slice(1));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 指定socket pathを開いているPID一覧を返す。
|
|
100
|
+
*
|
|
101
|
+
* @returns {Promise<number[]>} 観測できたPID。観測手段が無ければthrowする。
|
|
102
|
+
*/
|
|
103
|
+
export async function pidsOwningSocketPath(socketPath) {
|
|
104
|
+
if (process.platform === 'linux') {
|
|
105
|
+
const byInode = await procUnixSocketPaths();
|
|
106
|
+
const wanted = new Set();
|
|
107
|
+
for (const [inode, paths] of byInode) {
|
|
108
|
+
if (paths.includes(socketPath)) wanted.add(inode);
|
|
109
|
+
}
|
|
110
|
+
if (wanted.size === 0) return [];
|
|
111
|
+
const pids = new Set();
|
|
112
|
+
for (const entry of await readdir('/proc')) {
|
|
113
|
+
if (!/^\d+$/u.test(entry)) continue;
|
|
114
|
+
let inodes;
|
|
115
|
+
// 他ユーザーのprocessは読めない。読めないものを「所有していない」へ丸めるが、
|
|
116
|
+
// これはlsofが権限不足で黙るのと同じ性質であり、判定は所有を**肯定**する側でのみ使う。
|
|
117
|
+
try { inodes = await procSocketInodes(entry); } catch { continue; }
|
|
118
|
+
if ([...wanted].some((inode) => inodes.has(inode))) pids.add(Number(entry));
|
|
119
|
+
}
|
|
120
|
+
return [...pids];
|
|
121
|
+
}
|
|
122
|
+
const stdout = await runLsof(['-t', socketPath]);
|
|
123
|
+
return stdout.split('\n').map((line) => line.trim())
|
|
124
|
+
.filter((line) => /^\d+$/u.test(line)).map(Number);
|
|
125
|
+
}
|
package/src/todo-cli.mjs
CHANGED
|
@@ -74,6 +74,7 @@ import {
|
|
|
74
74
|
} from './todo-independence.mjs';
|
|
75
75
|
import {
|
|
76
76
|
selectIndependenceGuidance,
|
|
77
|
+
selectWitnessScaffoldGuidance,
|
|
77
78
|
selectSeamProposalGuidance,
|
|
78
79
|
} from './todo-independence-guidance.mjs';
|
|
79
80
|
import {
|
|
@@ -964,18 +965,26 @@ async function witnessScaffold({ repoRoot, planKey, inputRef }) {
|
|
|
964
965
|
}
|
|
965
966
|
const { queries, paths } = buildWitnessObservationQuerySet(draft);
|
|
966
967
|
const collected = await collectSensorEvidence({ cwd: repoRoot, querySet: { queries } });
|
|
967
|
-
const
|
|
968
|
+
const observationByPath = {};
|
|
968
969
|
queries.forEach((query, index) => {
|
|
969
970
|
if (query.operation !== 'affected') return;
|
|
970
971
|
const entry = collected.outcomes[index]?.targets?.[0];
|
|
971
972
|
// 観測できていないものを空配列へ丸めない。丸めるとdriftでcompileが落ちる。
|
|
972
|
-
|
|
973
|
-
|
|
973
|
+
// 不存在(absent)は観測**できている**——fsのlstat結果である。未観測と混ぜると、
|
|
974
|
+
// 創作境界を宣言したToDoが「まだ確かめていない」側へ落ちる(ADR 0136)。
|
|
975
|
+
if (!Array.isArray(entry?.data?.affectedTests) || !Array.isArray(entry?.data?.changedFiles)) return;
|
|
976
|
+
observationByPath[query.target] = {
|
|
977
|
+
state: entry.path_state === 'absent' ? 'absent' : 'present',
|
|
978
|
+
affectedTests: [...entry.data.affectedTests],
|
|
979
|
+
changedFiles: [...entry.data.changedFiles],
|
|
980
|
+
};
|
|
974
981
|
});
|
|
975
|
-
const { witnessSet, reasons } = buildWitnessSet({ draft,
|
|
982
|
+
const { witnessSet, reasons } = buildWitnessSet({ draft, observationByPath });
|
|
976
983
|
if (witnessSet === null) {
|
|
984
|
+
// 理由コードは具体的なのに次の一手が汎用だと、何をどう直すのかが伝わらない。
|
|
985
|
+
const guidance = selectWitnessScaffoldGuidance(reasons);
|
|
977
986
|
throw new TodoStoreError('WITNESS_SCAFFOLD_INCOMPLETE', 'witness_scaffold_incomplete', undefined, {
|
|
978
|
-
reasons, next_action:
|
|
987
|
+
reasons, guidance, next_action: guidance.next_action,
|
|
979
988
|
});
|
|
980
989
|
}
|
|
981
990
|
const ref = todoWitnessRef(planKey);
|
|
@@ -232,3 +232,65 @@ export const TODO_INDEPENDENCE_WORKFLOW = Object.freeze([
|
|
|
232
232
|
'3. 読む: lattice todo independence --plan <key> --json(sensorを引かず、記録とHEAD照合だけで返る)',
|
|
233
233
|
'4. 追従する: plan改訂後は lattice todo independence witness migrate --plan <key> で宣言を写してから再compileする',
|
|
234
234
|
]);
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 宣言の下書きが受理されなかった時の案内(`witness scaffold`)。
|
|
238
|
+
*
|
|
239
|
+
* 理由コードは具体的なのに次の一手が「宣言を直して再実行」のままだと、何をどう直すのかが
|
|
240
|
+
* 伝わらない。一番必要な瞬間——機械が「作れませんでした」と言った瞬間——に解決法を知らせない
|
|
241
|
+
* のは、ADR 0130が禁じたものそのものである。
|
|
242
|
+
*/
|
|
243
|
+
const SCAFFOLD_CATALOG = Object.freeze({
|
|
244
|
+
path_absent_declare_creates: {
|
|
245
|
+
message: '宣言したpathは不在と観測できている。そのToDoが作るなら、owns entryを{ path, creates: true }(下書きv2)にして再実行する。作らないなら、宣言しているpathが正しいか確かめる。',
|
|
246
|
+
next_action: 'declare_creates_then_retry',
|
|
247
|
+
},
|
|
248
|
+
creates_path_present: {
|
|
249
|
+
message: '創作を宣言したpathは既に存在する。作るのでなく変更するなら、creates宣言を外してpath名だけにする。',
|
|
250
|
+
next_action: 'drop_creates_then_retry',
|
|
251
|
+
},
|
|
252
|
+
creates_unverified: {
|
|
253
|
+
message: '創作宣言の裏付けが取れない。sensorの観測が「対象1件・依存test無し」の形になっていない。sensor syncで索引を取り直してから再実行する。',
|
|
254
|
+
next_action: 'sync_sensor_then_retry',
|
|
255
|
+
},
|
|
256
|
+
affected_tests_unobserved: {
|
|
257
|
+
message: '所有pathのaffected観測が取れていない。sensor syncで索引へ収載してから再実行する。',
|
|
258
|
+
next_action: 'sync_sensor_then_retry',
|
|
259
|
+
},
|
|
260
|
+
multiple_owned_paths_unsupported: {
|
|
261
|
+
message: 'affected_testsは宣言とfresh観測をbinding単位でexact比較するため、1 ToDoが複数pathを所有する宣言は今の契約で表現できない。ToDoを分けるか、所有を1 pathに絞る。',
|
|
262
|
+
next_action: 'split_todo_or_narrow_owns',
|
|
263
|
+
},
|
|
264
|
+
anchor_outside_owned: {
|
|
265
|
+
message: 'concern_anchorのwithinが、自分の所有pathを指していない。所有していない資源の内側に担当を主張できない。',
|
|
266
|
+
next_action: 'align_anchor_with_owns',
|
|
267
|
+
},
|
|
268
|
+
owns_empty: {
|
|
269
|
+
message: 'ownsが空のToDoがある。何を所有するかは判定の起点なので、道具が補わない。',
|
|
270
|
+
next_action: 'declare_owns_then_retry',
|
|
271
|
+
},
|
|
272
|
+
draft_invalid: {
|
|
273
|
+
message: '下書きがlattice.todo_witness_draft契約を満たしていない。creates宣言を使うならschemaをv2にする。',
|
|
274
|
+
next_action: 'fix_draft_schema_then_retry',
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 理由の集合から、最も行動を要する案内を1つ選ぶ。
|
|
280
|
+
*
|
|
281
|
+
* 並べると読み手はどれから手を付けるか決められない。「形が壊れている」を最優先にし、
|
|
282
|
+
* 以下、宣言の直し方が具体的なものほど上へ置く。
|
|
283
|
+
*/
|
|
284
|
+
export function selectWitnessScaffoldGuidance(reasons = []) {
|
|
285
|
+
const codes = reasons.map((reason) => String(reason).split(':')[0]);
|
|
286
|
+
const order = [
|
|
287
|
+
'draft_invalid', 'owns_empty', 'multiple_owned_paths_unsupported', 'anchor_outside_owned',
|
|
288
|
+
'path_absent_declare_creates', 'creates_path_present', 'creates_unverified',
|
|
289
|
+
'affected_tests_unobserved',
|
|
290
|
+
];
|
|
291
|
+
const code = order.find((candidate) => codes.includes(candidate));
|
|
292
|
+
if (code === undefined) {
|
|
293
|
+
return { code: 'witness_scaffold_incomplete', message: '下書きから宣言を組めなかった。', next_action: 'resolve_declaration_then_retry' };
|
|
294
|
+
}
|
|
295
|
+
return { code, ...SCAFFOLD_CATALOG[code] };
|
|
296
|
+
}
|
package/src/todo-store.mjs
CHANGED
|
@@ -696,6 +696,34 @@ function validateMergedTransition(store, member, event) {
|
|
|
696
696
|
}
|
|
697
697
|
}
|
|
698
698
|
|
|
699
|
+
/**
|
|
700
|
+
* refsから辿れるobject idの集合。repositoryごとに1度だけ数え、以後は使い回す。
|
|
701
|
+
*
|
|
702
|
+
* 到達可能性を見ないと、手元にだけ在るdangling blobを「検証済み」と読んでしまう。
|
|
703
|
+
*/
|
|
704
|
+
const reachableObjectCache = new Map();
|
|
705
|
+
function reachableObjects(absoluteRepo) {
|
|
706
|
+
const cached = reachableObjectCache.get(absoluteRepo);
|
|
707
|
+
if (cached !== undefined) return cached;
|
|
708
|
+
let set;
|
|
709
|
+
try {
|
|
710
|
+
const stdout = execFileSync('git', ['rev-list', '--objects', '--all'],
|
|
711
|
+
{ cwd: absoluteRepo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
712
|
+
maxBuffer: 256 * 1024 * 1024 });
|
|
713
|
+
set = new Set();
|
|
714
|
+
for (const line of stdout.split('\n')) {
|
|
715
|
+
const oid = line.slice(0, 40);
|
|
716
|
+
if (oid.length === 40) set.add(oid);
|
|
717
|
+
}
|
|
718
|
+
} catch {
|
|
719
|
+
// 数えられない環境では到達可能性で落とさない。判定できないことを「到達不能」へ丸めると、
|
|
720
|
+
// 既存recordが一斉に読めなくなる。ここは厳しさより「誤って否定しない」を採る。
|
|
721
|
+
set = { has: () => true };
|
|
722
|
+
}
|
|
723
|
+
reachableObjectCache.set(absoluteRepo, set);
|
|
724
|
+
return set;
|
|
725
|
+
}
|
|
726
|
+
|
|
699
727
|
function evidenceVerifier(manifest, repoRoot, hard) {
|
|
700
728
|
const repositories = new Map(manifest.repositories.map((repo) => [repo.repo_id, repo.path]));
|
|
701
729
|
return (descriptor) => {
|
|
@@ -706,6 +734,17 @@ function evidenceVerifier(manifest, repoRoot, hard) {
|
|
|
706
734
|
try {
|
|
707
735
|
const type = execFileSync('git', ['cat-file', '-t', descriptor.git_blob_oid], { cwd: absoluteRepo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
708
736
|
if (type !== 'blob') throw new Error('not blob');
|
|
737
|
+
// **objectが在ることと、cloneした人が読めることは別である。** commitやtagから辿れない
|
|
738
|
+
// dangling blobでも`cat-file`は通るので、手元では検証済みに見えるのに、fresh cloneでは
|
|
739
|
+
// 誰も確かめられない証拠が残る。実際に16件そうなっており、公開CIで初めて露見した。
|
|
740
|
+
// 手元とCIで判定が食い違う状態を残さない。
|
|
741
|
+
//
|
|
742
|
+
// ただし**書き込み時には要求しない**。証拠文書は同じ変更でcommitするのが普通の流れで、
|
|
743
|
+
// `todo done`の時点ではindexに在るだけである。そこで拒むと正常な運用が止まる。
|
|
744
|
+
// 「記録はできるが、commitするまでverifyは通らない」が正しい強さである。
|
|
745
|
+
if (!hard && !reachableObjects(absoluteRepo).has(descriptor.git_blob_oid)) {
|
|
746
|
+
throw new Error('blob unreachable from refs');
|
|
747
|
+
}
|
|
709
748
|
const bytes = execFileSync('git', ['cat-file', 'blob', descriptor.git_blob_oid], { cwd: absoluteRepo, encoding: 'buffer', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: TODO_LIMITS.narrativeSectionBytes + 1 });
|
|
710
749
|
if (sha256Bytes(bytes) !== descriptor.content_digest) throw new Error('digest mismatch');
|
|
711
750
|
return true;
|
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: [],
|