@event4u/agent-config 17.3.0 → 17.4.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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +164 -342
- package/CREDITS.md +2 -2
- package/README.md +1 -1
- package/dist/agent-src/skills/code-intelligence/SKILL.md +3 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +2 -2
- package/dist/cli-delegate/{chunk-DHSSP7V3.js → chunk-5AYJGUA3.js} +0 -2
- package/dist/cli-delegate/chunk-DIFZBP4G.js +1336 -0
- package/dist/cli-delegate/{chunk-MT2WOOCY.js → chunk-WY5ZZPGL.js} +2 -2
- package/dist/cli-delegate/{chunk-ORJPBER2.js → chunk-X6LZ7W5A.js} +73 -10
- package/dist/cli-delegate/cmd_conformance.js +175 -183
- package/dist/cli-delegate/cmd_doctor.js +5 -5
- package/dist/cli-delegate/cmd_doctor_neighbours.js +3 -3
- package/dist/cli-delegate/cmd_explain.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +2 -2
- package/dist/cli-delegate/cmd_update.js +24 -13
- package/dist/cli-delegate/cmd_upgrade.js +2 -2
- package/dist/cli-delegate/dist-25AZAHOP.js +3240 -0
- package/dist/cli-delegate/dist-P7MFPMF3.js +411 -0
- package/dist/cli-delegate/dist-XAS5ZLQB.js +6 -0
- package/dist/cli-delegate/{upgrade_unattended-VVL6JLSX.js → upgrade_unattended-QCWOKTUN.js} +178 -163
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +3 -3
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/code_graph_refresh.chunk.cjs +56 -27
- package/dist/hooks/code_graph_refresh.chunk.cjs.sha256 +1 -1
- package/dist/hooks/dispatch.js +982 -929
- package/dist/hooks/dispatch.sha256 +1 -1
- package/dist/hooks/egress_engine.chunk.cjs +56 -6
- package/dist/hooks/egress_engine.chunk.cjs.sha256 +1 -1
- package/dist/hooks/update_notice.chunk.cjs +914 -0
- package/dist/hooks/update_notice.chunk.cjs.sha256 +1 -0
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +71259 -66437
- package/dist/scripts/condense.js +9 -6
- package/package.json +4 -1
- package/src/config/evaluator-budgets.json +9 -8
- package/src/config/gate-violation-baselines.json +2 -2
- package/src/config/hook-bundle-budget.json +31 -6
- package/src/config/packed-binary-manifest.json +9 -0
- package/src/config/sigstore/npm_keys.json +50 -0
- package/src/config/sigstore/trust-material.json +9 -0
- package/src/config/sigstore/trusted_root.json +126 -0
- package/src/config/skill-body-budget.json +7 -1
- package/src/scripts/_cli/cmd_update.ts +48 -20
- package/src/scripts/_cli/cmd_upgrade.ts +7 -1
- package/src/scripts/_cli/upgrade_unattended.ts +90 -76
- package/src/scripts/_dispatch.bash +3 -2
- package/src/scripts/_lib/confusables.ts +0 -19
- package/src/scripts/_lib/egress_normalize.ts +53 -23
- package/src/scripts/_lib/egress_text.ts +5 -3
- package/src/scripts/_lib/egress_token.ts +7 -5
- package/src/scripts/_lib/gate_collector_experimental.ts +633 -0
- package/src/scripts/_lib/git_common_dir.ts +10 -0
- package/src/scripts/_lib/graph_feeder_record.ts +3 -2
- package/src/scripts/_lib/install_reach_checks.ts +32 -2
- package/src/scripts/_lib/lineage_backfill.ts +136 -0
- package/src/scripts/_lib/lineage_ledger.ts +211 -0
- package/src/scripts/_lib/lineage_map.ts +55 -0
- package/src/scripts/_lib/lineage_query.ts +140 -0
- package/src/scripts/_lib/lineage_seed.ts +116 -0
- package/src/scripts/_lib/lineage_watch.ts +249 -0
- package/src/scripts/_lib/link_crypto.ts +8 -4
- package/src/scripts/_lib/production_reach.ts +3 -3
- package/src/scripts/_lib/project_registry.ts +227 -0
- package/src/scripts/_lib/provenance_verify.ts +14 -242
- package/src/scripts/_lib/sigstore_provenance.ts +177 -0
- package/src/scripts/_lib/source_id.ts +48 -0
- package/src/scripts/_lib/source_shape.ts +4 -0
- package/src/scripts/_lib/unattended_gates.ts +6 -1
- package/src/scripts/advisories.ts +26 -10
- package/src/scripts/ai_council/council_egress.ts +5 -6
- package/src/scripts/bench_graph_feeder_latency.ts +10 -3
- package/src/scripts/check_dependency_floors.ts +7 -0
- package/src/scripts/check_egress_mutations.ts +1 -0
- package/src/scripts/code_graph/build.ts +67 -3
- package/src/scripts/code_graph/cli.ts +131 -28
- package/src/scripts/code_graph/conventions_default.ts +20 -1
- package/src/scripts/code_graph/detect.ts +5 -5
- package/src/scripts/code_graph/estate_cli.ts +135 -0
- package/src/scripts/code_graph/estate_index.ts +394 -0
- package/src/scripts/code_graph/estate_manifests.ts +223 -0
- package/src/scripts/code_graph/estate_map.ts +141 -0
- package/src/scripts/code_graph/extract.ts +4 -1
- package/src/scripts/code_graph/extract_more.ts +137 -2
- package/src/scripts/code_graph/graph_budget.ts +158 -0
- package/src/scripts/code_graph/graph_home.ts +80 -0
- package/src/scripts/code_graph/lineage_cli.ts +315 -0
- package/src/scripts/code_graph/map.ts +22 -13
- package/src/scripts/code_graph/map_model.ts +67 -12
- package/src/scripts/code_graph/map_verify.ts +68 -5
- package/src/scripts/code_graph/map_viewer/viewer.css +12 -0
- package/src/scripts/code_graph/map_viewer/viewer.js +45 -15
- package/src/scripts/code_graph/project_ref.ts +25 -0
- package/src/scripts/code_graph/projects_cli.ts +254 -0
- package/src/scripts/code_graph/providers/catalog.ts +14 -5
- package/src/scripts/code_graph/providers/django.ts +52 -9
- package/src/scripts/code_graph/providers/index.ts +2 -0
- package/src/scripts/code_graph/providers/rails.ts +204 -0
- package/src/scripts/code_graph/providers/spring.ts +38 -1
- package/src/scripts/code_graph/query.ts +9 -9
- package/src/scripts/code_graph/refresh_lock.ts +12 -8
- package/src/scripts/code_graph/repo_identity.ts +169 -0
- package/src/scripts/code_graph/sqlite_store.ts +22 -25
- package/src/scripts/code_graph/types.ts +6 -2
- package/src/scripts/condense.ts +6 -6
- package/src/scripts/hook_chunks.mjs +7 -0
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +20 -17
- package/src/scripts/hooks/code_graph_refresh_hook.ts +10 -7
- package/src/scripts/hooks/dispatch_hook.ts +6 -7
- package/src/scripts/hooks/host_semantics.ts +72 -0
- package/src/scripts/hooks/lazy_concern.ts +4 -0
- package/src/scripts/hooks/user_notice.ts +105 -0
- package/src/scripts/lint_harvest_provenance.ts +137 -11
- package/src/scripts/mcp_server/consumer_tool_catalog.json +69 -15
- package/src/scripts/mcp_server/graph_tools.ts +161 -41
- package/src/scripts/network_sources.ts +24 -6
- package/src/scripts/schemas/provenance-mechanism.schema.json +46 -0
- package/src/scripts/update_notice_hook.ts +75 -7
- package/src/vendor/grammars/README.md +7 -6
- package/src/vendor/grammars/tree-sitter-ruby.wasm +0 -0
- /package/dist/cli-delegate/{chunk-FKYZOWVD.js → chunk-QBAFP67O.js} +0 -0
- /package/dist/cli-delegate/{chunk-BMGUGSY6.js → chunk-TQG6UMPC.js} +0 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EXPERIMENTAL per-aspect gate collector. Not a normative schema, not a gate,
|
|
3
|
+
* wired into no CI job. Its result version is `experimental-1` precisely
|
|
4
|
+
* so that nothing mistakes it for a supported producer contract.
|
|
5
|
+
*
|
|
6
|
+
* Three dimensions are kept apart, plus preflight:
|
|
7
|
+
*
|
|
8
|
+
* - **preflight** — configuration state decided before a producer starts
|
|
9
|
+
* (an expired compatibility row, a dirty tree). A refused producer is
|
|
10
|
+
* `not-attempted`; it is never a protocol outcome.
|
|
11
|
+
* - **collection outcome** — how reading fd 3 ended, decided by
|
|
12
|
+
* {@link collectorReduce}.
|
|
13
|
+
* - **protocol outcome** — what the accepted bytes say, decided by
|
|
14
|
+
* {@link parseStream}.
|
|
15
|
+
* - **validator health** — whether the validator itself handled every
|
|
16
|
+
* producer, decided by {@link assessRun} and {@link writeSummary}.
|
|
17
|
+
*
|
|
18
|
+
* The intended accepted-byte boundary. fd 3 is a pipe the validator owns.
|
|
19
|
+
* Bytes are accepted while the collector is `collecting`. At the first terminal
|
|
20
|
+
* event (producer exit, deadline) the collector moves to `draining`, the driver
|
|
21
|
+
* reads the pipe synchronously until a read returns nothing or throws, then
|
|
22
|
+
* moves to `stopped`; later bytes are counted as ignored, never parsed. The
|
|
23
|
+
* reducer is deterministic for a given event sequence; the driver that produces
|
|
24
|
+
* the sequence is not: the synchronous read goes through Node's undocumented
|
|
25
|
+
* `_handle.fd`, treats every read error as the end of the drain, and has never
|
|
26
|
+
* been exercised against a byte that was still buffered at `exit`. Byte and
|
|
27
|
+
* line limits are capacities: exactly the limit finishes normally, the next
|
|
28
|
+
* byte is an overflow, and the accepted prefix is exactly the capacity.
|
|
29
|
+
*
|
|
30
|
+
* Two policies this slice chose and states rather than hides. Without a
|
|
31
|
+
* compatibility row, a non-zero exit next to a complete stream is the
|
|
32
|
+
* diagnostic `process-nonzero`, not a failure, because the stream is the
|
|
33
|
+
* result and many checks exit 1 to say "found something" which the records
|
|
34
|
+
* already report. In report-only mode an `invalid` or `incomplete` producer
|
|
35
|
+
* under a healthy validator leaves both exits at 0: the operational exit
|
|
36
|
+
* reports the validator, not the producers.
|
|
37
|
+
*
|
|
38
|
+
* What none of this gives: fd 3 is a bearer capability, so a descendant that
|
|
39
|
+
* inherited it and wrote before the stop is indistinguishable from the
|
|
40
|
+
* producer, and a schema-valid `pass` is not evidence the producer ran the
|
|
41
|
+
* check it names. The claim is framing and bounded collection, not provenance
|
|
42
|
+
* and not truthfulness.
|
|
43
|
+
*/
|
|
44
|
+
import { spawn } from 'node:child_process';
|
|
45
|
+
import * as fs from 'node:fs';
|
|
46
|
+
|
|
47
|
+
export const RESULT_VERSION = 'experimental-1';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The mode contract. Report-only is the only mode: it never produces an
|
|
51
|
+
* enforcement verdict, its evaluation exit is always 0, and its operational
|
|
52
|
+
* exit reports validator health alone (0 healthy, 2 degraded, 1 failed). An
|
|
53
|
+
* enforcing mode needs a new mode value, separately reviewed code, and its own
|
|
54
|
+
* contract version; {@link requireMode} refuses anything else instead of
|
|
55
|
+
* defaulting.
|
|
56
|
+
*/
|
|
57
|
+
export const MODE_CONTRACT = 'report-only/experimental-1';
|
|
58
|
+
export type Mode = 'report-only';
|
|
59
|
+
|
|
60
|
+
export function requireMode(mode: unknown): Mode {
|
|
61
|
+
if (mode === 'report-only') return mode;
|
|
62
|
+
throw new Error(`unsupported validator mode ${JSON.stringify(mode)}: only 'report-only' exists`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface Limits {
|
|
66
|
+
readonly wallMs: number;
|
|
67
|
+
readonly maxBytes: number;
|
|
68
|
+
readonly maxLines: number;
|
|
69
|
+
readonly killGraceMs: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface CompatRow {
|
|
73
|
+
/** ISO date; the row stops applying after this day. */
|
|
74
|
+
readonly expires: string;
|
|
75
|
+
readonly exitAllPass: number;
|
|
76
|
+
readonly exitOtherwise: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ProducerRow {
|
|
80
|
+
readonly id: string;
|
|
81
|
+
readonly argv: readonly string[];
|
|
82
|
+
readonly aspects: readonly string[];
|
|
83
|
+
readonly compat?: CompatRow;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type CollectionOutcome =
|
|
87
|
+
| { kind: 'exited'; code: number }
|
|
88
|
+
| { kind: 'signalled'; signal: string }
|
|
89
|
+
| { kind: 'timed-out' }
|
|
90
|
+
| { kind: 'output-limited'; limit: 'bytes' | 'lines' }
|
|
91
|
+
| { kind: 'not-started'; reason: string };
|
|
92
|
+
|
|
93
|
+
export type CollectorEvent =
|
|
94
|
+
| { type: 'spawned' }
|
|
95
|
+
| { type: 'spawn-error'; code: string }
|
|
96
|
+
| { type: 'data'; bytes: Buffer }
|
|
97
|
+
| { type: 'exit'; code: number | null; signal: string | null }
|
|
98
|
+
| { type: 'deadline' }
|
|
99
|
+
| { type: 'drain-complete' };
|
|
100
|
+
|
|
101
|
+
export interface CollectorModel {
|
|
102
|
+
readonly phase: 'starting' | 'collecting' | 'draining' | 'stopped';
|
|
103
|
+
readonly limits: Limits;
|
|
104
|
+
readonly accepted: readonly Buffer[];
|
|
105
|
+
readonly bytes: number;
|
|
106
|
+
readonly lines: number;
|
|
107
|
+
readonly outcome: CollectionOutcome | null;
|
|
108
|
+
readonly ignoredAfterStop: number;
|
|
109
|
+
readonly events: readonly string[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function initialCollector(limits: Limits): CollectorModel {
|
|
113
|
+
return { phase: 'starting', limits, accepted: [], bytes: 0, lines: 0, outcome: null, ignoredAfterStop: 0, events: [] };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Both limits are capacities: a stream of exactly `maxBytes` bytes or exactly
|
|
118
|
+
* `maxLines` lines finishes normally, and only a byte beyond capacity is an
|
|
119
|
+
* overflow. The accepted prefix is then exactly the capacity.
|
|
120
|
+
*/
|
|
121
|
+
function acceptChunk(m: CollectorModel, chunk: Buffer): CollectorModel {
|
|
122
|
+
if (chunk.length > 0 && m.lines >= m.limits.maxLines) {
|
|
123
|
+
return {
|
|
124
|
+
...m,
|
|
125
|
+
phase: 'stopped',
|
|
126
|
+
outcome: m.outcome ?? { kind: 'output-limited', limit: 'lines' },
|
|
127
|
+
ignoredAfterStop: m.ignoredAfterStop + chunk.length,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const byteRoom = m.limits.maxBytes - m.bytes;
|
|
131
|
+
let take = Math.min(chunk.length, byteRoom);
|
|
132
|
+
let limit: 'bytes' | 'lines' | null = chunk.length > byteRoom ? 'bytes' : null;
|
|
133
|
+
let lines = m.lines;
|
|
134
|
+
for (let i = 0; i < take; i++) {
|
|
135
|
+
if (chunk[i] !== 0x0a) continue;
|
|
136
|
+
lines++;
|
|
137
|
+
if (lines === m.limits.maxLines && i + 1 < chunk.length) {
|
|
138
|
+
take = i + 1;
|
|
139
|
+
limit = 'lines';
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const next: CollectorModel = {
|
|
144
|
+
...m,
|
|
145
|
+
accepted: take > 0 ? [...m.accepted, chunk.subarray(0, take)] : m.accepted,
|
|
146
|
+
bytes: m.bytes + take,
|
|
147
|
+
lines,
|
|
148
|
+
ignoredAfterStop: m.ignoredAfterStop + (chunk.length - take),
|
|
149
|
+
};
|
|
150
|
+
if (limit === null) return next;
|
|
151
|
+
return { ...next, phase: 'stopped', outcome: m.outcome ?? { kind: 'output-limited', limit } };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The collector as a pure transition function; the driver only feeds it events. */
|
|
155
|
+
export function collectorReduce(m: CollectorModel, e: CollectorEvent): CollectorModel {
|
|
156
|
+
const note = (s: string): CollectorModel => ({ ...m, events: [...m.events, s] });
|
|
157
|
+
switch (e.type) {
|
|
158
|
+
case 'spawned':
|
|
159
|
+
return m.phase === 'starting' ? { ...m, phase: 'collecting' } : note('spawned-out-of-order');
|
|
160
|
+
case 'spawn-error':
|
|
161
|
+
if (m.phase !== 'starting') return note(`spawn-error-after-start:${e.code}`);
|
|
162
|
+
return { ...m, phase: 'stopped', outcome: { kind: 'not-started', reason: e.code } };
|
|
163
|
+
case 'data':
|
|
164
|
+
if (m.phase === 'collecting' || m.phase === 'draining') return acceptChunk(m, e.bytes);
|
|
165
|
+
return { ...m, ignoredAfterStop: m.ignoredAfterStop + e.bytes.length };
|
|
166
|
+
case 'exit': {
|
|
167
|
+
if (m.phase !== 'collecting') return note('exit-after-terminal');
|
|
168
|
+
const outcome: CollectionOutcome =
|
|
169
|
+
e.signal !== null ? { kind: 'signalled', signal: e.signal } : { kind: 'exited', code: e.code ?? -1 };
|
|
170
|
+
return { ...m, phase: 'draining', outcome };
|
|
171
|
+
}
|
|
172
|
+
case 'deadline':
|
|
173
|
+
if (m.phase !== 'collecting') return note('deadline-after-terminal');
|
|
174
|
+
return { ...m, phase: 'draining', outcome: { kind: 'timed-out' } };
|
|
175
|
+
case 'drain-complete':
|
|
176
|
+
return m.phase === 'draining' ? { ...m, phase: 'stopped' } : note('drain-complete-out-of-order');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface Collection {
|
|
181
|
+
readonly outcome: CollectionOutcome;
|
|
182
|
+
readonly bytes: Buffer;
|
|
183
|
+
/** The process's own exit, which may arrive after a timeout or a limit stopped collection. */
|
|
184
|
+
readonly observedExit: { code: number | null; signal: string | null } | null;
|
|
185
|
+
readonly ignoredAfterStop: number;
|
|
186
|
+
readonly cleanup: { readonly groupSignalled: boolean; readonly groupAliveAfterKill: boolean };
|
|
187
|
+
readonly events: readonly string[];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface CollectOptions {
|
|
191
|
+
readonly limits: Limits;
|
|
192
|
+
readonly env?: Record<string, string>;
|
|
193
|
+
readonly cwd?: string;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The environment a producer sees: recorded pass-throughs plus the caller's explicit additions. */
|
|
197
|
+
function producerEnv(extra: Record<string, string> = {}): Record<string, string> {
|
|
198
|
+
const env: Record<string, string> = { TZ: 'UTC', LANG: 'C.UTF-8' };
|
|
199
|
+
for (const k of ['PATH', 'HOME', 'TMPDIR', 'CI']) {
|
|
200
|
+
const v = process.env[k];
|
|
201
|
+
if (v !== undefined) env[k] = v;
|
|
202
|
+
}
|
|
203
|
+
return { ...env, ...extra };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function groupAlive(pid: number): boolean {
|
|
207
|
+
try {
|
|
208
|
+
process.kill(-pid, 0);
|
|
209
|
+
return true;
|
|
210
|
+
} catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
216
|
+
|
|
217
|
+
/** Read the pipe until it would block; returns the chunks in arrival order. */
|
|
218
|
+
function drainSync(stream: NodeJS.ReadableStream & { _handle?: { fd?: number } }): Buffer[] {
|
|
219
|
+
const out: Buffer[] = [];
|
|
220
|
+
stream.pause();
|
|
221
|
+
for (let c = stream.read() as Buffer | null; c !== null; c = stream.read() as Buffer | null) out.push(c);
|
|
222
|
+
const fd = stream._handle?.fd;
|
|
223
|
+
if (typeof fd !== 'number' || fd < 0) return out;
|
|
224
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
225
|
+
for (;;) {
|
|
226
|
+
let n: number;
|
|
227
|
+
try {
|
|
228
|
+
n = fs.readSync(fd, buf, 0, buf.length, null);
|
|
229
|
+
} catch {
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
if (n === 0) break;
|
|
233
|
+
out.push(Buffer.from(buf.subarray(0, n)));
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Run one producer and collect fd 3 under the state machine. */
|
|
239
|
+
export async function collect(row: ProducerRow, opts: CollectOptions): Promise<Collection> {
|
|
240
|
+
const [cmd, ...args] = row.argv;
|
|
241
|
+
let m = initialCollector(opts.limits);
|
|
242
|
+
let observedExit: Collection['observedExit'] = null;
|
|
243
|
+
if (cmd === undefined) {
|
|
244
|
+
m = collectorReduce(m, { type: 'spawn-error', code: 'EMPTY_ARGV' });
|
|
245
|
+
return finish(m, null, { groupSignalled: false, groupAliveAfterKill: false });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const child = spawn(cmd, args, {
|
|
249
|
+
detached: true,
|
|
250
|
+
cwd: opts.cwd,
|
|
251
|
+
env: producerEnv(opts.env),
|
|
252
|
+
stdio: ['ignore', 'ignore', 'ignore', 'pipe'],
|
|
253
|
+
});
|
|
254
|
+
const pipe = child.stdio[3] as (NodeJS.ReadableStream & { _handle?: { fd?: number }; destroy(): void }) | null;
|
|
255
|
+
|
|
256
|
+
const stopped = new Promise<void>((resolve) => {
|
|
257
|
+
let deadline: NodeJS.Timeout | undefined;
|
|
258
|
+
const settle = () => {
|
|
259
|
+
if (m.phase !== 'stopped') return;
|
|
260
|
+
if (deadline) clearTimeout(deadline);
|
|
261
|
+
pipe?.destroy();
|
|
262
|
+
resolve();
|
|
263
|
+
};
|
|
264
|
+
const stopNow = () => {
|
|
265
|
+
if (m.phase !== 'draining') return settle();
|
|
266
|
+
if (pipe) for (const c of drainSync(pipe)) m = collectorReduce(m, { type: 'data', bytes: c });
|
|
267
|
+
m = collectorReduce(m, { type: 'drain-complete' });
|
|
268
|
+
settle();
|
|
269
|
+
};
|
|
270
|
+
child.once('error', (err: NodeJS.ErrnoException) => {
|
|
271
|
+
m = collectorReduce(m, { type: 'spawn-error', code: err.code ?? 'SPAWN_ERROR' });
|
|
272
|
+
settle();
|
|
273
|
+
});
|
|
274
|
+
child.once('spawn', () => {
|
|
275
|
+
m = collectorReduce(m, { type: 'spawned' });
|
|
276
|
+
deadline = setTimeout(() => {
|
|
277
|
+
m = collectorReduce(m, { type: 'deadline' });
|
|
278
|
+
stopNow();
|
|
279
|
+
}, opts.limits.wallMs);
|
|
280
|
+
});
|
|
281
|
+
pipe?.on('data', (b: Buffer) => {
|
|
282
|
+
m = collectorReduce(m, { type: 'data', bytes: b });
|
|
283
|
+
settle();
|
|
284
|
+
});
|
|
285
|
+
pipe?.on('error', () => {});
|
|
286
|
+
child.once('exit', (code, signal) => {
|
|
287
|
+
observedExit = { code, signal };
|
|
288
|
+
m = collectorReduce(m, { type: 'exit', code, signal });
|
|
289
|
+
stopNow();
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
await stopped;
|
|
293
|
+
|
|
294
|
+
let cleanup = { groupSignalled: false, groupAliveAfterKill: false };
|
|
295
|
+
if (child.pid !== undefined && m.outcome?.kind !== 'not-started') {
|
|
296
|
+
cleanup = await killGroup(child.pid, opts.limits.killGraceMs);
|
|
297
|
+
}
|
|
298
|
+
return finish(m, observedExit, cleanup);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function killGroup(pid: number, graceMs: number): Promise<Collection['cleanup']> {
|
|
302
|
+
if (!groupAlive(pid)) return { groupSignalled: false, groupAliveAfterKill: false };
|
|
303
|
+
try {
|
|
304
|
+
process.kill(-pid, 'SIGTERM');
|
|
305
|
+
} catch {
|
|
306
|
+
return { groupSignalled: false, groupAliveAfterKill: groupAlive(pid) };
|
|
307
|
+
}
|
|
308
|
+
await sleep(graceMs);
|
|
309
|
+
if (groupAlive(pid)) {
|
|
310
|
+
try {
|
|
311
|
+
process.kill(-pid, 'SIGKILL');
|
|
312
|
+
} catch {
|
|
313
|
+
/* the group exited between the check and the signal */
|
|
314
|
+
}
|
|
315
|
+
await sleep(20);
|
|
316
|
+
}
|
|
317
|
+
return { groupSignalled: true, groupAliveAfterKill: groupAlive(pid) };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function finish(m: CollectorModel, observedExit: Collection['observedExit'], cleanup: Collection['cleanup']): Collection {
|
|
321
|
+
return {
|
|
322
|
+
outcome: m.outcome ?? { kind: 'not-started', reason: 'NO_TERMINAL_EVENT' },
|
|
323
|
+
bytes: Buffer.concat(m.accepted),
|
|
324
|
+
observedExit,
|
|
325
|
+
ignoredAfterStop: m.ignoredAfterStop,
|
|
326
|
+
cleanup,
|
|
327
|
+
events: m.events,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export type Evaluation = 'pass' | 'fail' | 'expired' | 'not-evaluated';
|
|
332
|
+
const EVALUATIONS: readonly string[] = ['pass', 'fail', 'expired', 'not-evaluated'];
|
|
333
|
+
const NOT_EVALUATED_REASONS: readonly string[] = ['evidence-missing', 'prerequisite-unavailable', 'invalid-input', 'unsupported-version'];
|
|
334
|
+
const RECORD_FIELDS = new Set(['v', 'aspect', 'evaluation', 'reason', 'detail']);
|
|
335
|
+
const TERMINATOR_FIELDS = new Set(['v', 'end', 'aspects']);
|
|
336
|
+
const DETAIL_MAX = 512;
|
|
337
|
+
|
|
338
|
+
export interface AspectRecord {
|
|
339
|
+
readonly aspect: string;
|
|
340
|
+
readonly evaluation: Evaluation;
|
|
341
|
+
readonly reason?: string;
|
|
342
|
+
readonly detail?: string;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export type ProtocolOutcome = 'complete' | 'incomplete' | 'invalid' | 'absent';
|
|
346
|
+
|
|
347
|
+
export interface ProtocolAssessment {
|
|
348
|
+
readonly outcome: ProtocolOutcome;
|
|
349
|
+
/** The primary reason; `null` only for `complete`. */
|
|
350
|
+
readonly reason: string | null;
|
|
351
|
+
/** Every finding, primary or not, so combinations stay visible. */
|
|
352
|
+
readonly diagnostics: readonly string[];
|
|
353
|
+
readonly records: readonly AspectRecord[];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Primary-outcome precedence. Earlier entries win. Every `invalid` reason
|
|
358
|
+
* outranks every `incomplete` reason: a malformed or contradictory stream is
|
|
359
|
+
* reported as such even when it was also cut short. Within each outcome a
|
|
360
|
+
* collection cause comes first, because it explains the content findings that
|
|
361
|
+
* follow from it: a stream stopped at its byte limit was not read to the end,
|
|
362
|
+
* and a timed-out producer never wrote its terminator.
|
|
363
|
+
*/
|
|
364
|
+
const PRECEDENCE: readonly (readonly [string, ProtocolOutcome])[] = [
|
|
365
|
+
['not-started', 'absent'],
|
|
366
|
+
['output-limit', 'invalid'],
|
|
367
|
+
['malformed', 'invalid'],
|
|
368
|
+
['mixed-version', 'invalid'],
|
|
369
|
+
['unsupported-version', 'invalid'],
|
|
370
|
+
['schema', 'invalid'],
|
|
371
|
+
['duplicate-aspect', 'invalid'],
|
|
372
|
+
['foreign-aspect', 'invalid'],
|
|
373
|
+
['framing', 'invalid'],
|
|
374
|
+
['exit-contradiction', 'invalid'],
|
|
375
|
+
['timed-out', 'incomplete'],
|
|
376
|
+
['signalled', 'incomplete'],
|
|
377
|
+
['unterminated-line', 'incomplete'],
|
|
378
|
+
['no-terminator', 'incomplete'],
|
|
379
|
+
['count-mismatch', 'incomplete'],
|
|
380
|
+
['missing-aspect', 'incomplete'],
|
|
381
|
+
];
|
|
382
|
+
|
|
383
|
+
function isPlainObject(x: unknown): x is Record<string, unknown> {
|
|
384
|
+
return typeof x === 'object' && x !== null && !Array.isArray(x);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Framing: LF is the only delimiter. One CR immediately before the LF is part
|
|
389
|
+
* of the delimiter. An empty line is malformed. Bytes after the last LF are an
|
|
390
|
+
* unterminated line, which is never parsed. Text must be valid UTF-8.
|
|
391
|
+
*/
|
|
392
|
+
function splitLines(bytes: Buffer, diag: Set<string>): string[] {
|
|
393
|
+
let text: string;
|
|
394
|
+
try {
|
|
395
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
396
|
+
} catch {
|
|
397
|
+
diag.add('malformed');
|
|
398
|
+
return [];
|
|
399
|
+
}
|
|
400
|
+
const parts = text.split('\n');
|
|
401
|
+
const tail = parts.pop() ?? '';
|
|
402
|
+
if (tail.length > 0) diag.add('unterminated-line');
|
|
403
|
+
return parts.map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function parseStream(collection: Collection, row: ProducerRow): ProtocolAssessment {
|
|
407
|
+
const diag = new Set<string>();
|
|
408
|
+
if (collection.outcome.kind === 'not-started') diag.add('not-started');
|
|
409
|
+
if (collection.outcome.kind === 'output-limited') diag.add('output-limit');
|
|
410
|
+
if (collection.outcome.kind === 'timed-out') diag.add('timed-out');
|
|
411
|
+
if (collection.outcome.kind === 'signalled') diag.add('signalled');
|
|
412
|
+
|
|
413
|
+
const lines = splitLines(collection.bytes, diag);
|
|
414
|
+
const values: unknown[] = [];
|
|
415
|
+
for (const l of lines) {
|
|
416
|
+
if (l.trim() === '') {
|
|
417
|
+
diag.add('malformed');
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
values.push(JSON.parse(l));
|
|
422
|
+
} catch {
|
|
423
|
+
diag.add('malformed');
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const versions = new Set(values.filter(isPlainObject).map((v) => v.v));
|
|
428
|
+
if (versions.size > 1) diag.add('mixed-version');
|
|
429
|
+
else if (versions.size === 1 && !versions.has(RESULT_VERSION)) diag.add('unsupported-version');
|
|
430
|
+
|
|
431
|
+
const records: AspectRecord[] = [];
|
|
432
|
+
const seen = new Set<string>();
|
|
433
|
+
let terminator: { aspects: number } | null = null;
|
|
434
|
+
for (const v of values) {
|
|
435
|
+
if (!isPlainObject(v)) {
|
|
436
|
+
diag.add('schema');
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (v.end === true) {
|
|
440
|
+
const okShape = [...Object.keys(v)].every((k) => TERMINATOR_FIELDS.has(k)) && Number.isInteger(v.aspects);
|
|
441
|
+
if (!okShape) diag.add('schema');
|
|
442
|
+
if (terminator !== null) diag.add('framing');
|
|
443
|
+
terminator = { aspects: Number(v.aspects) };
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (terminator !== null) diag.add('framing');
|
|
447
|
+
const fieldsOk = [...Object.keys(v)].every((k) => RECORD_FIELDS.has(k));
|
|
448
|
+
const aspectOk = typeof v.aspect === 'string' && v.aspect.length > 0;
|
|
449
|
+
const evalOk = typeof v.evaluation === 'string' && EVALUATIONS.includes(v.evaluation);
|
|
450
|
+
const reasonOk =
|
|
451
|
+
v.evaluation === 'not-evaluated'
|
|
452
|
+
? typeof v.reason === 'string' && NOT_EVALUATED_REASONS.includes(v.reason)
|
|
453
|
+
: v.reason === undefined;
|
|
454
|
+
const detailOk = v.detail === undefined || (typeof v.detail === 'string' && Buffer.byteLength(v.detail) <= DETAIL_MAX);
|
|
455
|
+
if (!fieldsOk || !aspectOk || !evalOk || !reasonOk || !detailOk) {
|
|
456
|
+
diag.add('schema');
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
const aspect = v.aspect as string;
|
|
460
|
+
if (seen.has(aspect)) diag.add('duplicate-aspect');
|
|
461
|
+
seen.add(aspect);
|
|
462
|
+
if (!row.aspects.includes(aspect)) diag.add('foreign-aspect');
|
|
463
|
+
records.push({
|
|
464
|
+
aspect,
|
|
465
|
+
evaluation: v.evaluation as Evaluation,
|
|
466
|
+
...(v.reason === undefined ? {} : { reason: v.reason as string }),
|
|
467
|
+
...(v.detail === undefined ? {} : { detail: v.detail as string }),
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (terminator === null) diag.add('no-terminator');
|
|
472
|
+
else if (terminator.aspects !== records.length) diag.add('count-mismatch');
|
|
473
|
+
if (row.aspects.some((a) => !seen.has(a))) diag.add('missing-aspect');
|
|
474
|
+
|
|
475
|
+
const structurallyValid = ![...diag].some((d) => PRECEDENCE.find(([r]) => r === d)?.[1] === 'invalid');
|
|
476
|
+
if (collection.outcome.kind === 'exited' && structurallyValid && terminator !== null) {
|
|
477
|
+
const code = collection.outcome.code;
|
|
478
|
+
if (row.compat !== undefined) {
|
|
479
|
+
const expected = records.every((r) => r.evaluation === 'pass') ? row.compat.exitAllPass : row.compat.exitOtherwise;
|
|
480
|
+
if (expected !== code) diag.add('exit-contradiction');
|
|
481
|
+
} else if (code !== 0) {
|
|
482
|
+
diag.add('process-nonzero');
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const primary = PRECEDENCE.find(([r]) => diag.has(r));
|
|
487
|
+
return {
|
|
488
|
+
outcome: primary ? primary[1] : 'complete',
|
|
489
|
+
reason: primary ? primary[0] : null,
|
|
490
|
+
diagnostics: [...diag].sort(),
|
|
491
|
+
records,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export type AspectResult =
|
|
496
|
+
| { aspect: string; evaluation: Exclude<Evaluation, 'not-evaluated'> }
|
|
497
|
+
| { aspect: string; evaluation: 'not-evaluated'; reason: string };
|
|
498
|
+
|
|
499
|
+
/** Per registered aspect. A producer that is not `complete` contributes no pass at all. */
|
|
500
|
+
export function aspectResults(p: ProtocolAssessment, row: ProducerRow): AspectResult[] {
|
|
501
|
+
return row.aspects.map((aspect) => {
|
|
502
|
+
if (p.outcome !== 'complete') return { aspect, evaluation: 'not-evaluated', reason: p.reason ?? p.outcome };
|
|
503
|
+
const r = p.records.find((x) => x.aspect === aspect);
|
|
504
|
+
if (r === undefined) return { aspect, evaluation: 'not-evaluated', reason: 'missing-aspect' };
|
|
505
|
+
if (r.evaluation === 'not-evaluated') return { aspect, evaluation: 'not-evaluated', reason: r.reason ?? 'unspecified' };
|
|
506
|
+
return { aspect, evaluation: r.evaluation };
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export interface PreflightContext {
|
|
511
|
+
readonly today: string;
|
|
512
|
+
readonly treeClean: boolean;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function preflight(row: ProducerRow, ctx: PreflightContext): { ok: true } | { ok: false; reason: string } {
|
|
516
|
+
if (row.compat !== undefined && row.compat.expires < ctx.today) return { ok: false, reason: 'expired-compat-row' };
|
|
517
|
+
if (!ctx.treeClean) return { ok: false, reason: 'dirty-tree' };
|
|
518
|
+
return { ok: true };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export type ProducerStatus = ProtocolOutcome | 'not-attempted' | 'unassessed';
|
|
522
|
+
|
|
523
|
+
export interface ProducerReport {
|
|
524
|
+
readonly id: string;
|
|
525
|
+
readonly status: ProducerStatus;
|
|
526
|
+
readonly reason: string | null;
|
|
527
|
+
readonly collection?: Collection;
|
|
528
|
+
readonly protocol?: ProtocolAssessment;
|
|
529
|
+
readonly aspects?: readonly AspectResult[];
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export interface AssessOptions extends CollectOptions {
|
|
533
|
+
readonly preflight: PreflightContext;
|
|
534
|
+
/** Injection point for fault tests; defaults to {@link parseStream}. */
|
|
535
|
+
readonly parse?: (c: Collection, r: ProducerRow) => ProtocolAssessment;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export async function assessProducer(row: ProducerRow, opts: AssessOptions): Promise<ProducerReport> {
|
|
539
|
+
const pf = preflight(row, opts.preflight);
|
|
540
|
+
if (!pf.ok) return { id: row.id, status: 'not-attempted', reason: pf.reason };
|
|
541
|
+
let collection: Collection;
|
|
542
|
+
try {
|
|
543
|
+
collection = await collect(row, opts);
|
|
544
|
+
} catch (err) {
|
|
545
|
+
return { id: row.id, status: 'unassessed', reason: `validator-fault:${(err as Error).message}` };
|
|
546
|
+
}
|
|
547
|
+
try {
|
|
548
|
+
const protocol = (opts.parse ?? parseStream)(collection, row);
|
|
549
|
+
return { id: row.id, status: protocol.outcome, reason: protocol.reason, collection, protocol, aspects: aspectResults(protocol, row) };
|
|
550
|
+
} catch (err) {
|
|
551
|
+
return { id: row.id, status: 'unassessed', reason: `validator-fault:${(err as Error).message}`, collection };
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export type Health = 'healthy' | 'degraded' | 'failed';
|
|
556
|
+
export type RunStatus = 'complete' | 'incomplete' | 'invalid' | 'degraded' | 'failed';
|
|
557
|
+
|
|
558
|
+
export interface RunReport {
|
|
559
|
+
readonly version: typeof RESULT_VERSION;
|
|
560
|
+
readonly mode: Mode;
|
|
561
|
+
readonly contract: typeof MODE_CONTRACT;
|
|
562
|
+
readonly health: Health;
|
|
563
|
+
readonly status: RunStatus;
|
|
564
|
+
readonly producers: readonly ProducerReport[];
|
|
565
|
+
/** Always 0: report-only enforces nothing. */
|
|
566
|
+
readonly evaluationExit: 0;
|
|
567
|
+
/** Validator health only: 0 healthy, 2 degraded, 1 failed. */
|
|
568
|
+
readonly operationalExit: 0 | 1 | 2;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const PRODUCER_STATUSES: readonly string[] = ['complete', 'incomplete', 'invalid', 'absent', 'not-attempted', 'unassessed'];
|
|
572
|
+
|
|
573
|
+
function runStatus(health: Health, producers: readonly ProducerReport[]): RunStatus {
|
|
574
|
+
if (health === 'failed') return 'failed';
|
|
575
|
+
if (producers.some((p) => p.status === 'invalid')) return 'invalid';
|
|
576
|
+
if (health === 'degraded') return 'degraded';
|
|
577
|
+
if (producers.some((p) => p.status !== 'complete')) return 'incomplete';
|
|
578
|
+
return 'complete';
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const OPERATIONAL_EXIT: Record<Health, 0 | 1 | 2> = { healthy: 0, degraded: 2, failed: 1 };
|
|
582
|
+
const HEALTH_VALUES: readonly string[] = Object.keys(OPERATIONAL_EXIT);
|
|
583
|
+
|
|
584
|
+
export function assessRun(producers: readonly ProducerReport[], health: Health = 'healthy', mode: unknown = 'report-only'): RunReport {
|
|
585
|
+
const m = requireMode(mode);
|
|
586
|
+
if (!HEALTH_VALUES.includes(health)) throw new Error(`unknown health ${JSON.stringify(health)}`);
|
|
587
|
+
for (const p of producers) {
|
|
588
|
+
if (!PRODUCER_STATUSES.includes(p.status)) throw new Error(`unknown producer status ${JSON.stringify(p.status)} for ${p.id}`);
|
|
589
|
+
}
|
|
590
|
+
const h: Health = health === 'failed' ? 'failed' : producers.some((p) => p.status === 'unassessed') ? 'degraded' : health;
|
|
591
|
+
return {
|
|
592
|
+
version: RESULT_VERSION,
|
|
593
|
+
mode: m,
|
|
594
|
+
contract: MODE_CONTRACT,
|
|
595
|
+
health: h,
|
|
596
|
+
status: runStatus(h, producers),
|
|
597
|
+
producers,
|
|
598
|
+
evaluationExit: 0,
|
|
599
|
+
operationalExit: OPERATIONAL_EXIT[h],
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function summaryLines(run: RunReport): string[] {
|
|
604
|
+
const producerLines = run.producers.map((p) =>
|
|
605
|
+
JSON.stringify({
|
|
606
|
+
kind: 'producer',
|
|
607
|
+
id: p.id,
|
|
608
|
+
status: p.status,
|
|
609
|
+
reason: p.reason,
|
|
610
|
+
collection: p.collection ? { outcome: p.collection.outcome, ignoredAfterStop: p.collection.ignoredAfterStop, cleanup: p.collection.cleanup } : null,
|
|
611
|
+
diagnostics: p.protocol?.diagnostics ?? [],
|
|
612
|
+
aspects: p.aspects ?? [],
|
|
613
|
+
}),
|
|
614
|
+
);
|
|
615
|
+
const runLine = JSON.stringify({ kind: 'run', version: run.version, mode: run.mode, contract: run.contract, health: run.health, status: run.status, operationalExit: run.operationalExit });
|
|
616
|
+
return [...producerLines, runLine];
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Write the summary as JSON lines, then read it back. Any failure on that path
|
|
621
|
+
* is validator health `failed`, which is the one case the operational exit
|
|
622
|
+
* reports, and the run returned says so.
|
|
623
|
+
*/
|
|
624
|
+
export function writeSummary(file: string, run: RunReport): { ok: boolean; run: RunReport } {
|
|
625
|
+
const body = summaryLines(run).join('\n') + '\n';
|
|
626
|
+
try {
|
|
627
|
+
fs.writeFileSync(file, body);
|
|
628
|
+
if (fs.readFileSync(file, 'utf8') !== body) throw new Error('read-back mismatch');
|
|
629
|
+
return { ok: true, run };
|
|
630
|
+
} catch {
|
|
631
|
+
return { ok: false, run: assessRun(run.producers, 'failed', run.mode) };
|
|
632
|
+
}
|
|
633
|
+
}
|
|
@@ -207,6 +207,16 @@ function discover_root(start: string): string | null {
|
|
|
207
207
|
}
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* The work tree that contains `target`, or null when none does. `target` need
|
|
212
|
+
* not exist yet — an output path is judged by the directories above it. Used to
|
|
213
|
+
* refuse writing private, user-global data (a decrypted view, a machine index)
|
|
214
|
+
* into a repository.
|
|
215
|
+
*/
|
|
216
|
+
export function enclosingWorkTree(target: string): string | null {
|
|
217
|
+
return discover_root(target);
|
|
218
|
+
}
|
|
219
|
+
|
|
210
220
|
/**
|
|
211
221
|
* Is `project_root`'s `.git` a linked-worktree pointer file rather than a
|
|
212
222
|
* directory? Returns `null` when there is no `.git` entry at all.
|
|
@@ -49,7 +49,8 @@
|
|
|
49
49
|
import * as fs from 'node:fs';
|
|
50
50
|
import * as path from 'node:path';
|
|
51
51
|
|
|
52
|
-
import { detectSources, type GraphState,
|
|
52
|
+
import { detectSources, type GraphState, pickSource } from '../code_graph/detect.js';
|
|
53
|
+
import { projectHome } from '../code_graph/graph_home.js';
|
|
53
54
|
import { loadGraph } from '../code_graph/query.js';
|
|
54
55
|
import { untested } from '../code_graph/verbs.js';
|
|
55
56
|
|
|
@@ -288,7 +289,7 @@ export function graphUntestedVerdict(
|
|
|
288
289
|
// there is no usable source is cost for nothing.
|
|
289
290
|
let picked;
|
|
290
291
|
try {
|
|
291
|
-
picked = pickSource(detectSources(root,
|
|
292
|
+
picked = pickSource(detectSources(root, projectHome(root).cachePath));
|
|
292
293
|
} catch {
|
|
293
294
|
return none;
|
|
294
295
|
}
|