@north-light/crouter 0.3.244 → 0.3.246

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.
@@ -1,5 +1,6 @@
1
1
  import { type MemoryTarget } from '../../../core/memory-resolver.js';
2
2
  import { type DeliveryPayload, type DeliveryRecord, type GateOutcome } from '../../../core/substrate/plan.js';
3
+ import { type GateClause } from '../../../core/substrate/gate-explain.js';
3
4
  import { type Rung, type SurfaceEntry, type SurfaceEvent } from '../../../core/substrate/schema.js';
4
5
  import { type NodeConfigSubject } from '../../../core/substrate/subject-fields.js';
5
6
  export declare const resolveLeaf: import("../../../core/command.js").LeafDef;
@@ -45,11 +46,14 @@ export interface DocRow {
45
46
  * above the no-delivery floor — boot keeps rung-`none` winners in the plan so
46
47
  * the catalog can still count them. */
47
48
  export declare function delivers(record: DeliveryRecord): boolean;
48
- export declare function docRow(record: DeliveryRecord, includeAll: boolean): DocRow;
49
- export declare function gateText(gate: GateOutcome): string;
49
+ export declare function docRow(record: DeliveryRecord, includeAll: boolean, subject: NodeConfigSubject | null): DocRow;
50
+ /** A gate verdict as one cell: `pass`, or `fail:` plus the clauses that failed
51
+ * — the planner's generic sentence only when the explainer cannot decompose
52
+ * the predicate. */
53
+ export declare function gateText(gate: GateOutcome, clauses: readonly GateClause[]): string;
50
54
  export declare function entryText(entry: SurfaceEntry | null): string | null;
51
55
  /** Why a considered document delivers nothing, in the order the planner applies
52
56
  * its checks — the first failing one is the answer an engineer acts on. */
53
- export declare function exclusionReason(record: DeliveryRecord): string;
57
+ export declare function exclusionReason(record: DeliveryRecord, subject: NodeConfigSubject | null): string;
54
58
  export declare function describeSubject(subject: Record<string, unknown>): string;
55
59
  export declare function cell(v: unknown): string;
@@ -16,6 +16,7 @@ import { envNodeId, envProfileId } from '../../../shared/env.js';
16
16
  import { realpathOrSelf } from '../../../core/fs-utils.js';
17
17
  import { ambientMemoryTarget, resolveMemoryDocForTarget, } from '../../../core/memory-resolver.js';
18
18
  import { planDelivery, } from '../../../core/substrate/plan.js';
19
+ import { explainRecordGate, formatClause } from '../../../core/substrate/gate-explain.js';
19
20
  import { emptyContextExposureState, exposureTarget } from '../../../core/substrate/injected-store.js';
20
21
  import { renderPreferencesForSubject, renderKnowledgeForSubject } from '../../../core/substrate/render.js';
21
22
  import { memoryReadDocBlocks, renderOnCommandDocsForSubject, renderOnReadDocsForSubject, renderPreCommandDocsForSubject, renderWorkspaceOpenDocsForSubject, } from '../../../core/substrate/on-read.js';
@@ -193,7 +194,7 @@ function planSection(snapshot, event, payload, granularity, includeAll) {
193
194
  const plan = planDelivery(snapshot.subject, snapshot.target, event, payload);
194
195
  if (granularity === 'summary')
195
196
  return { event, counts: countsOf(plan) };
196
- const docs = plan.docs.filter((r) => includeAll || delivers(r)).map((r) => docRow(r, includeAll));
197
+ const docs = plan.docs.filter((r) => includeAll || delivers(r)).map((r) => docRow(r, includeAll, plan.subject));
197
198
  return { event, docs };
198
199
  }
199
200
  /** A document delivers when it won its canonical name AND resolved to a rung
@@ -202,7 +203,7 @@ function planSection(snapshot, event, payload, granularity, includeAll) {
202
203
  export function delivers(record) {
203
204
  return record.winner && rungAtLeast(record.finalRung, 'name');
204
205
  }
205
- export function docRow(record, includeAll) {
206
+ export function docRow(record, includeAll, subject) {
206
207
  const row = {
207
208
  name: record.name,
208
209
  kind: record.kind,
@@ -214,16 +215,22 @@ export function docRow(record, includeAll) {
214
215
  final_rung: record.finalRung,
215
216
  winner: record.winner,
216
217
  shadowed_by: record.shadowedBy === null ? null : `${record.shadowedBy.scope}:${record.shadowedBy.path}`,
217
- doc_gate: gateText(record.docGate),
218
- entry_gate: record.entryGate === null ? null : gateText(record.entryGate),
218
+ doc_gate: gateText(record.docGate, explainRecordGate(record, 'doc', subject)),
219
+ entry_gate: record.entryGate === null ? null : gateText(record.entryGate, explainRecordGate(record, 'entry', subject)),
219
220
  matched_entry: entryText(record.matchedEntry),
220
221
  };
221
222
  if (includeAll && !delivers(record))
222
- row.excluded = exclusionReason(record);
223
+ row.excluded = exclusionReason(record, subject);
223
224
  return row;
224
225
  }
225
- export function gateText(gate) {
226
- return gate.pass ? 'pass' : `fail: ${gate.reason}`;
226
+ /** A gate verdict as one cell: `pass`, or `fail:` plus the clauses that failed
227
+ * — the planner's generic sentence only when the explainer cannot decompose
228
+ * the predicate. */
229
+ export function gateText(gate, clauses) {
230
+ return gate.pass ? 'pass' : `fail: ${gateReason(gate.reason, clauses)}`;
231
+ }
232
+ function gateReason(fallback, clauses) {
233
+ return clauses.length === 0 ? fallback : clauses.map(formatClause).join('; ');
227
234
  }
228
235
  export function entryText(entry) {
229
236
  if (entry === null)
@@ -239,16 +246,17 @@ export function entryText(entry) {
239
246
  }
240
247
  /** Why a considered document delivers nothing, in the order the planner applies
241
248
  * its checks — the first failing one is the answer an engineer acts on. */
242
- export function exclusionReason(record) {
243
- if (!record.docGate.pass)
244
- return `document gate: ${record.docGate.reason}`;
249
+ export function exclusionReason(record, subject) {
250
+ if (!record.docGate.pass) {
251
+ return `document gate: ${gateReason(record.docGate.reason, explainRecordGate(record, 'doc', subject))}`;
252
+ }
245
253
  if (record.shadowedBy !== null)
246
254
  return `shadowed by ${record.shadowedBy.scope}:${record.shadowedBy.path}`;
247
255
  if (record.authoredEntries.length === 0)
248
256
  return 'no surface entry for this event';
249
257
  if (record.matchedEntry === null) {
250
258
  return record.entryGate !== null && !record.entryGate.pass
251
- ? `entry gate: ${record.entryGate.reason}`
259
+ ? `entry gate: ${gateReason(record.entryGate.reason, explainRecordGate(record, 'entry', subject))}`
252
260
  : 'no authored entry matched this event\u2019s payload';
253
261
  }
254
262
  if (record.cappedRung === 'none') {
@@ -18,7 +18,8 @@ import { PROFILE_PROJECT_MEMORY_VALUES } from '../../../api/dto/profiles.js';
18
18
  import { cliClient } from '../../api-client.js';
19
19
  import { clearDefaultProfile, defaultProfileDirs, getDefaultProfileId, setDefaultProfileId, } from '../../../core/profiles/default-binding.js';
20
20
  import { ROOT_PROFILE_ID, addProfileProject, createProfile, listProfiles, pauseProfile, removeProfileProject, renameProfile, resumeProfile, } from '../../../core/profiles/manifest.js';
21
- import { profileCoversCwd, tildify } from '../../../core/profiles/select.js';
21
+ import { tildify } from '../../../core/fs-utils.js';
22
+ import { profileCoversCwd } from '../../../core/profiles/select.js';
22
23
  import { padAnsi, theme, visibleRange, wrapText, } from '../../../core/tui/panel.js';
23
24
  /** Coarse "used ..." for telling otherwise-alike profiles apart. */
24
25
  function relativeUsed(iso) {
@@ -3,6 +3,9 @@ export declare function realpathOrSelf(p: string): string;
3
3
  /** Expand a leading `~` against `homeDir` (pi's own convention). Other forms
4
4
  * — absolute, relative, `~user` — pass through as pi leaves them. */
5
5
  export declare function expandTilde(p: string, homeDir?: string): string;
6
+ /** Collapse the home prefix to `~` so a path reads short. The inverse of
7
+ * `expandTilde`, and the ONE collapse every surface prints paths through. */
8
+ export declare function tildify(p: string, homeDir?: string): string;
6
9
  export declare function ensureDir(dir: string): void;
7
10
  export declare function writeJson(path: string, data: unknown): void;
8
11
  export interface AtomicWriteOptions {
@@ -1,5 +1,5 @@
1
1
  import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, cpSync, readlinkSync, renameSync, chmodSync, realpathSync, } from 'node:fs';
2
- import { dirname, join, relative } from 'node:path';
2
+ import { dirname, join, relative, sep } from 'node:path';
3
3
  import { homedir, platform } from 'node:os';
4
4
  /** `realpathSync`, tolerant of a path that doesn't exist or can't be resolved. */
5
5
  export function realpathOrSelf(p) {
@@ -19,6 +19,15 @@ export function expandTilde(p, homeDir = homedir()) {
19
19
  return join(homeDir, p.slice(2));
20
20
  return p;
21
21
  }
22
+ /** Collapse the home prefix to `~` so a path reads short. The inverse of
23
+ * `expandTilde`, and the ONE collapse every surface prints paths through. */
24
+ export function tildify(p, homeDir = homedir()) {
25
+ if (p === homeDir)
26
+ return '~';
27
+ if (p.startsWith(homeDir + sep))
28
+ return '~' + p.slice(homeDir.length);
29
+ return p;
30
+ }
22
31
  export function ensureDir(dir) {
23
32
  mkdirSync(dir, { recursive: true });
24
33
  }
@@ -11,9 +11,6 @@ export declare function resolveProfileSearchAction(bindings: BindingResolution<B
11
11
  export declare function profileCoversCwd(entry: ProfileEntry, cwd: string): boolean;
12
12
  /** Resolve the headless selector against existing state without changing it. */
13
13
  export declare function selectProfileForCwdReadOnly(cwd: string): string;
14
- /** Collapse the home prefix to `~` so project paths read short. Exported so the
15
- * Profiles settings panel prints a project dir exactly as this menu does. */
16
- export declare function tildify(p: string): string;
17
14
  /** Select the profile a node about to boot at `cwd` should run under.
18
15
  *
19
16
  * 1. `explicitProfile` present → resolve it as a user-typed operand (exact id,
@@ -9,12 +9,12 @@
9
9
  // spawn.ts call it, never re-derive it.
10
10
  import { spawnSync } from 'node:child_process';
11
11
  import { existsSync, realpathSync } from 'node:fs';
12
- import { homedir } from 'node:os';
13
12
  import { basename, resolve as resolvePath, sep } from 'node:path';
14
13
  import { createInterface } from 'node:readline/promises';
15
14
  import { emitKeypressEvents } from 'node:readline';
16
15
  import { listProfiles, loadProfileManifest, resolveProfileOperand, updateProfileLastUsed, createProfile, addProfileProject, ensureRootProfile, ROOT_PROFILE_ID, } from './manifest.js';
17
16
  import { getDefaultProfileId, setDefaultProfileId, clearDefaultProfile, } from './default-binding.js';
17
+ import { tildify } from '../fs-utils.js';
18
18
  import { stdoutColor } from '../output.js';
19
19
  import { inTmux } from '../runtime/placement-tmux.js';
20
20
  import { surfaceTmuxStyleArgs } from '../runtime/surface-bg.js';
@@ -135,16 +135,6 @@ const accent = stdoutColor.cyan;
135
135
  function key(k) {
136
136
  return accent(bold(k));
137
137
  }
138
- /** Collapse the home prefix to `~` so project paths read short. Exported so the
139
- * Profiles settings panel prints a project dir exactly as this menu does. */
140
- export function tildify(p) {
141
- const home = homedir();
142
- if (p === home)
143
- return '~';
144
- if (p.startsWith(home + sep))
145
- return '~' + p.slice(home.length);
146
- return p;
147
- }
148
138
  /** Coarse "last used" for disambiguating profiles that otherwise look alike
149
139
  * (notably several profiles claiming the SAME dir). null → never used. */
150
140
  function relativeUsed(iso) {
@@ -0,0 +1,48 @@
1
+ import type { NodeConfigSubject } from './subject-fields.js';
2
+ import type { DeliveryRecord } from './plan.js';
3
+ /** The subject has no value at this field path — distinct from a value of
4
+ * `null`, which a gate can legitimately demand. */
5
+ export declare const ABSENT: unique symbol;
6
+ /** One failing condition of a gate.
7
+ *
8
+ * `mismatch` and `malformed` are two different remedies on two different
9
+ * objects: dial the node's configuration, or edit the document. */
10
+ export interface GateClause {
11
+ /** The gate field as authored (`kind`, `cwd`, `orchestration.depth`). Empty
12
+ * for a defect of the gate as a whole, such as an empty `gate: {}`. */
13
+ field: string;
14
+ failure: 'mismatch' | 'malformed';
15
+ /** The subject's resolved value at `field`, or `ABSENT`. */
16
+ actual: unknown;
17
+ /** The matcher as authored, structurally — rendered by `formatClause`. */
18
+ demand: unknown;
19
+ /** What is wrong with the gate itself. Only when `failure` is `malformed`. */
20
+ defect?: string;
21
+ }
22
+ /** The failing clauses of `condition` against `subject`, or an empty list when
23
+ * the failure cannot be decomposed — an `any`/`not` combinator swallowed it,
24
+ * or the predicate is a shape this walk does not recognise. An empty list is
25
+ * the caller's signal to print the generic reason instead. */
26
+ export declare function explainGate(condition: unknown, subject: NodeConfigSubject): GateClause[];
27
+ /** The grammar's own break between the reader's value and the gate's demand —
28
+ * the only place a clause line may be split. */
29
+ export declare const CLAUSE_SEPARATOR = " | gate: ";
30
+ /** One clause as one line of plain text — the ONLY place a gate failure is put
31
+ * into human words. Callers own indentation, colour, wrapping, and joining. */
32
+ export declare function formatClause(clause: GateClause): string;
33
+ /** The pattern as the thing it demands: an anchored literal path collapsed to
34
+ * the directory it names, else the longest literal run it insists on, else an
35
+ * admission that it is a pattern. The source is never printed. */
36
+ export declare function readableRegex(source: string): string;
37
+ /** Which of a record's two gates is being explained. */
38
+ export type GateSide = 'doc' | 'entry';
39
+ /** The failing clauses of one record's document or entry gate, or an empty list
40
+ * when there is nothing to explain — the gate passed, there is no subject to
41
+ * match against, or the predicate resists decomposition. An evaluator that
42
+ * throws is the planner's own `failed to evaluate` outcome and keeps its
43
+ * sentence. */
44
+ export declare function explainRecordGate(record: DeliveryRecord, side: GateSide, subject: NodeConfigSubject | null): GateClause[];
45
+ /** Does every failing clause name a node value the reader can change? Only then
46
+ * is "dial the snapshot" a true instruction — a malformed gate is fixed in the
47
+ * document. */
48
+ export declare function allMismatches(clauses: readonly GateClause[]): boolean;
@@ -0,0 +1,424 @@
1
+ // gate-explain.ts — why a gate refused THIS node, in the words the reader can
2
+ // act on: their own value on the left, the gate's demand on the right.
3
+ //
4
+ // This is a SECOND, opt-in read of the same predicate `evalCondition` already
5
+ // judged (predicate.ts), never a replacement for it. It shares that engine's
6
+ // leaf primitives (`matchField`, `getField`), so a leaf verdict cannot drift
7
+ // between the verdict and its explanation; only the structural walk exists
8
+ // twice, and the fallback covers the walk. It is never called while planning —
9
+ // only while rendering a document a human is already looking at.
10
+ //
11
+ // It is advisory. When it cannot decompose a failure it returns nothing, and
12
+ // every caller falls back to the planner's generic sentence rather than
13
+ // inventing one.
14
+ import { getField, matchField, evalCondition } from '../predicate.js';
15
+ import { tildify } from '../fs-utils.js';
16
+ /** The subject has no value at this field path — distinct from a value of
17
+ * `null`, which a gate can legitimately demand. */
18
+ export const ABSENT = Symbol('absent');
19
+ // The structural walk.
20
+ /** The failing clauses of `condition` against `subject`, or an empty list when
21
+ * the failure cannot be decomposed — an `any`/`not` combinator swallowed it,
22
+ * or the predicate is a shape this walk does not recognise. An empty list is
23
+ * the caller's signal to print the generic reason instead. */
24
+ export function explainGate(condition, subject) {
25
+ return walk(condition, subject) ?? [];
26
+ }
27
+ /** The failing clauses of one predicate, or `null` when its failure is opaque.
28
+ * Opacity travels outward: a sibling's mismatch is not the remedy when an
29
+ * undecomposable branch also failed, so the whole explanation is abandoned. */
30
+ function walk(condition, subject) {
31
+ if (condition == null || typeof condition !== 'object')
32
+ return [];
33
+ if (Array.isArray(condition))
34
+ return collect(condition, subject);
35
+ const c = condition;
36
+ const clauses = [];
37
+ if ('any' in c || 'not' in c) {
38
+ // Neither is decomposed: an OR has no single failing branch to name, and a
39
+ // satisfied negation has no failing clause at all. When one of them is what
40
+ // failed, the whole explanation is abandoned — an honest generic sentence
41
+ // beats an invented clause. When it PASSED, the failure is elsewhere and
42
+ // the rest of the predicate still explains itself.
43
+ if ('any' in c && !evalCondition({ any: c['any'] }, subject))
44
+ return null;
45
+ if ('not' in c && !evalCondition({ not: c['not'] }, subject))
46
+ return null;
47
+ }
48
+ if ('all' in c) {
49
+ const nested = collect(Array.isArray(c['all']) ? c['all'] : [c['all']], subject);
50
+ if (nested === null)
51
+ return null;
52
+ clauses.push(...nested);
53
+ }
54
+ const fields = Object.keys(c).filter((key) => key !== 'all' && key !== 'any' && key !== 'not');
55
+ // An empty condition is inert — it matches nothing, and no field explains why.
56
+ if (fields.length === 0 && !('all' in c) && !('any' in c) && !('not' in c)) {
57
+ return [{ field: '', failure: 'malformed', actual: ABSENT, demand: c, defect: 'is empty, and an empty gate never matches' }];
58
+ }
59
+ for (const field of fields) {
60
+ const matcher = c[field];
61
+ if (matchField(getField(subject, field), matcher))
62
+ continue;
63
+ clauses.push(clauseFor(field, matcher, subject));
64
+ }
65
+ return clauses;
66
+ }
67
+ /** Every sub-predicate's clauses, or `null` as soon as one is opaque. */
68
+ function collect(subs, subject) {
69
+ const clauses = [];
70
+ for (const sub of subs) {
71
+ const nested = walk(sub, subject);
72
+ if (nested === null)
73
+ return null;
74
+ clauses.push(...nested);
75
+ }
76
+ return clauses;
77
+ }
78
+ function clauseFor(field, matcher, subject) {
79
+ const raw = getField(subject, field);
80
+ const actual = raw === undefined ? ABSENT : raw;
81
+ const defect = matcherDefect(matcher);
82
+ return defect === null
83
+ ? { field, failure: 'mismatch', actual, demand: matcher }
84
+ : { field, failure: 'malformed', actual, demand: matcher, defect };
85
+ }
86
+ const KNOWN_OPS = new Set([
87
+ 'eq', 'ne', 'in', 'nin', 'exists', 'contains', 'containsAll', 'containsAny',
88
+ 'matches', 'imatches', 'gt', 'gte', 'lt', 'lte',
89
+ ]);
90
+ /** What makes this matcher unsatisfiable by ANY node, or null when it is a
91
+ * well-formed demand this node merely does not meet. */
92
+ function matcherDefect(matcher) {
93
+ // A scalar or an array is compared directly by the engine — a demand this
94
+ // node fails, never a defect of the gate.
95
+ if (matcher === null || Array.isArray(matcher) || typeof matcher !== 'object')
96
+ return null;
97
+ for (const [op, arg] of Object.entries(matcher)) {
98
+ if (!KNOWN_OPS.has(op))
99
+ return `uses unknown operator "${op}" \u2014 unknown operators never match`;
100
+ if (op === 'matches' || op === 'imatches') {
101
+ if (typeof arg !== 'string')
102
+ return 'is matched against something that is not a pattern \u2014 it never matches';
103
+ try {
104
+ new RegExp(arg, op === 'imatches' ? 'i' : '');
105
+ }
106
+ catch {
107
+ return 'has an invalid regular expression \u2014 an invalid pattern never matches';
108
+ }
109
+ }
110
+ if (['gt', 'gte', 'lt', 'lte'].includes(op) && Number.isNaN(Number(arg))) {
111
+ return `compares ${op} against something that is not a number \u2014 it never matches`;
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+ // The single phrasing site.
117
+ /** The rail row a gate field names, because that row is the remedy. */
118
+ function labelFor(field) {
119
+ if (field === 'hasManager')
120
+ return 'manager';
121
+ if (field === 'orchestration.depth')
122
+ return 'depth';
123
+ return field;
124
+ }
125
+ /** The grammar's own break between the reader's value and the gate's demand —
126
+ * the only place a clause line may be split. */
127
+ export const CLAUSE_SEPARATOR = ' | gate: ';
128
+ /** One clause as one line of plain text — the ONLY place a gate failure is put
129
+ * into human words. Callers own indentation, colour, wrapping, and joining. */
130
+ export function formatClause(clause) {
131
+ if (clause.failure === 'malformed') {
132
+ const label = clause.field === '' ? 'the gate' : labelFor(clause.field);
133
+ return `${label} ${clause.defect ?? 'never matches'}`;
134
+ }
135
+ return `${labelFor(clause.field)}: ${renderActual(clause.actual)}${CLAUSE_SEPARATOR}${renderDemand(clause.demand)}`;
136
+ }
137
+ function renderActual(value) {
138
+ if (value === ABSENT || value === null || value === undefined)
139
+ return 'none';
140
+ if (typeof value === 'boolean')
141
+ return value ? 'yes' : 'no';
142
+ if (Array.isArray(value))
143
+ return value.map((v) => renderActual(v)).join(', ');
144
+ if (typeof value === 'string')
145
+ return value === '' ? 'none' : shortPath(value);
146
+ if (typeof value === 'object')
147
+ return JSON.stringify(value);
148
+ return String(value);
149
+ }
150
+ /** The gate's demand, per matcher shape. Never regex source, never a full
151
+ * home-prefixed path. */
152
+ function renderDemand(matcher) {
153
+ if (matcher === null || matcher === undefined)
154
+ return 'none';
155
+ if (typeof matcher === 'boolean')
156
+ return matcher ? 'yes' : 'no';
157
+ if (typeof matcher === 'string')
158
+ return matcher === '' ? 'none' : shortPath(matcher);
159
+ if (typeof matcher === 'number')
160
+ return String(matcher);
161
+ if (Array.isArray(matcher))
162
+ return orList(matcher);
163
+ const ops = Object.entries(matcher);
164
+ if (ops.length === 0)
165
+ return 'nothing';
166
+ return ops.map(([op, arg]) => renderOp(op, arg)).join(' and ');
167
+ }
168
+ function renderOp(op, arg) {
169
+ switch (op) {
170
+ case 'eq':
171
+ return renderDemand(arg);
172
+ case 'ne':
173
+ return `not ${renderDemand(arg)}`;
174
+ case 'in':
175
+ return orList(arg);
176
+ case 'nin':
177
+ return `not ${orList(arg)}`;
178
+ case 'exists':
179
+ return arg === false ? 'no value' : 'any value';
180
+ case 'contains':
181
+ return `includes ${renderDemand(arg)}`;
182
+ case 'containsAll':
183
+ return `includes all of ${commaList(arg)}`;
184
+ case 'containsAny':
185
+ return `includes any of ${commaList(arg)}`;
186
+ case 'matches':
187
+ case 'imatches':
188
+ return typeof arg === 'string' ? readableRegex(arg) : 'a pattern';
189
+ case 'gt':
190
+ return `> ${String(arg)}`;
191
+ case 'gte':
192
+ return `\u2265 ${String(arg)}`;
193
+ case 'lt':
194
+ return `< ${String(arg)}`;
195
+ case 'lte':
196
+ return `\u2264 ${String(arg)}`;
197
+ default:
198
+ return `${op} ${String(arg)}`;
199
+ }
200
+ }
201
+ function orList(arg) {
202
+ const items = (Array.isArray(arg) ? arg : [arg]).map((v) => renderDemand(v));
203
+ return items.length === 0 ? 'nothing' : items.join(' or ');
204
+ }
205
+ function commaList(arg) {
206
+ return (Array.isArray(arg) ? arg : [arg]).map((v) => renderDemand(v)).join(', ');
207
+ }
208
+ function shortPath(value) {
209
+ return value.startsWith('/') ? tildify(value) : value;
210
+ }
211
+ // Regex reduction — every real `cwd` gate is an anchored literal path, whole or
212
+ // followed by one alternation of the projects it covers, and it should read as
213
+ // the directory or directories it means.
214
+ const META = new Set(['\\', '^', '$', '.', '|', '?', '*', '+', '(', ')', '[', ']', '{', '}']);
215
+ /** The pattern as the thing it demands: an anchored literal path collapsed to
216
+ * the directory it names, else the longest literal run it insists on, else an
217
+ * admission that it is a pattern. The source is never printed. */
218
+ export function readableRegex(source) {
219
+ let body = source;
220
+ if (body.startsWith('^'))
221
+ body = body.slice(1);
222
+ // The house `cwd` idiom: a trailing "this directory or anything under it".
223
+ body = body.replace(/\((?:\?:)?\\?\/\|\$\)\??$/, '');
224
+ if (body.endsWith('$') && !body.endsWith('\\$'))
225
+ body = body.slice(0, -1);
226
+ const literal = unescapeLiteral(body);
227
+ if (literal !== null && literal !== '')
228
+ return shortPath(literal);
229
+ const branches = distributeAlternation(body);
230
+ if (branches !== null)
231
+ return branches.map((branch) => shortPath(branch)).join(' or ');
232
+ const run = longestLiteralRun(source);
233
+ return run === null ? 'a pattern' : `contains "${shortPath(run)}"`;
234
+ }
235
+ /** The pattern's plain text when it holds no unescaped metacharacter, else
236
+ * null. `\/` and `\.` are literal characters and survive; `\d` and friends are
237
+ * classes and disqualify the whole pattern. */
238
+ function unescapeLiteral(source) {
239
+ let out = '';
240
+ for (let i = 0; i < source.length; i++) {
241
+ const ch = source[i];
242
+ if (ch === '\\') {
243
+ const next = source[i + 1];
244
+ if (next === undefined || (!META.has(next) && next !== '/' && next !== '-'))
245
+ return null;
246
+ out += next;
247
+ i++;
248
+ continue;
249
+ }
250
+ if (META.has(ch))
251
+ return null;
252
+ out += ch;
253
+ }
254
+ return out;
255
+ }
256
+ /** The paths a literal prefix followed by ONE alternation group means: each
257
+ * branch appended to the prefix, so the reader is handed directories they can
258
+ * actually be in rather than the prefix they all share. Null when the body is
259
+ * any other shape — a second group, text after the group, or a branch holding
260
+ * a metacharacter outside the glob vocabulary. */
261
+ function distributeAlternation(body) {
262
+ const open = body.indexOf('(');
263
+ if (open === -1 || !body.endsWith(')'))
264
+ return null;
265
+ const prefix = unescapeLiteral(body.slice(0, open));
266
+ if (prefix === null)
267
+ return null;
268
+ let inner = body.slice(open + 1, -1);
269
+ if (inner.startsWith('?:'))
270
+ inner = inner.slice(2);
271
+ // One group only: a nested group is a shape this reduction does not read.
272
+ if (inner.includes('(') || inner.includes(')'))
273
+ return null;
274
+ const branches = inner.split('|');
275
+ if (branches.length < 2)
276
+ return null;
277
+ const paths = [];
278
+ for (const branch of branches) {
279
+ const text = branchText(branch);
280
+ if (text === null)
281
+ return null;
282
+ paths.push(prefix + text);
283
+ }
284
+ return paths;
285
+ }
286
+ /** One branch as plain text, with a whole-segment wildcard rendered as the path
287
+ * glob it means. The vocabulary is exactly `[^/]+` and `.*`; any other
288
+ * metacharacter means the branch is not a path and the whole shape is
289
+ * abandoned. */
290
+ function branchText(branch) {
291
+ let out = '';
292
+ for (let i = 0; i < branch.length; i++) {
293
+ if (branch.startsWith('[^/]+', i)) {
294
+ out += '*';
295
+ i += 4;
296
+ continue;
297
+ }
298
+ if (branch.startsWith('[^\\/]+', i)) {
299
+ out += '*';
300
+ i += 5;
301
+ continue;
302
+ }
303
+ if (branch.startsWith('.*', i)) {
304
+ out += '*';
305
+ i += 1;
306
+ continue;
307
+ }
308
+ const ch = branch[i];
309
+ if (ch === '\\') {
310
+ const next = branch[i + 1];
311
+ if (next === undefined || (!META.has(next) && next !== '/' && next !== '-'))
312
+ return null;
313
+ out += next;
314
+ i++;
315
+ continue;
316
+ }
317
+ if (META.has(ch))
318
+ return null;
319
+ out += ch;
320
+ }
321
+ return out;
322
+ }
323
+ /** The longest stretch of characters EVERY string the pattern accepts contains,
324
+ * or null when it guarantees nothing longer than a single character. Only text
325
+ * outside every group and character class counts: a group may be alternated or
326
+ * quantified away, so its contents are not guaranteed. A top-level alternation
327
+ * guarantees nothing at all. */
328
+ function longestLiteralRun(source) {
329
+ let best = '';
330
+ let run = '';
331
+ let depth = 0;
332
+ let inClass = false;
333
+ const flush = () => {
334
+ if (run.length > best.length)
335
+ best = run;
336
+ run = '';
337
+ };
338
+ for (let i = 0; i < source.length; i++) {
339
+ const ch = source[i];
340
+ if (ch === '\\') {
341
+ const next = source[i + 1];
342
+ i++;
343
+ if (depth === 0 && !inClass && next !== undefined && (META.has(next) || next === '/' || next === '-')) {
344
+ run += next;
345
+ continue;
346
+ }
347
+ flush();
348
+ continue;
349
+ }
350
+ if (inClass) {
351
+ if (ch === ']')
352
+ inClass = false;
353
+ continue;
354
+ }
355
+ if (ch === '[') {
356
+ flush();
357
+ inClass = true;
358
+ continue;
359
+ }
360
+ if (ch === '(') {
361
+ flush();
362
+ depth++;
363
+ continue;
364
+ }
365
+ if (ch === ')') {
366
+ flush();
367
+ if (depth > 0)
368
+ depth--;
369
+ continue;
370
+ }
371
+ // Branches share no text, so nothing survives a top-level alternation.
372
+ if (ch === '|' && depth === 0)
373
+ return null;
374
+ if (ch === '{') {
375
+ // A repetition count can be zero — the character it applies to goes, and
376
+ // the count itself is syntax, not text the pattern matches.
377
+ if (run.length > 0)
378
+ run = run.slice(0, -1);
379
+ flush();
380
+ const close = source.indexOf('}', i);
381
+ if (close !== -1)
382
+ i = close;
383
+ continue;
384
+ }
385
+ if (META.has(ch) || depth > 0) {
386
+ // A quantifier applies to the character before it, which is therefore not
387
+ // guaranteed — drop it from the run.
388
+ if ((ch === '?' || ch === '*') && run.length > 0)
389
+ run = run.slice(0, -1);
390
+ flush();
391
+ continue;
392
+ }
393
+ run += ch;
394
+ }
395
+ flush();
396
+ return best.length > 1 ? best : null;
397
+ }
398
+ /** The failing clauses of one record's document or entry gate, or an empty list
399
+ * when there is nothing to explain — the gate passed, there is no subject to
400
+ * match against, or the predicate resists decomposition. An evaluator that
401
+ * throws is the planner's own `failed to evaluate` outcome and keeps its
402
+ * sentence. */
403
+ export function explainRecordGate(record, side, subject) {
404
+ const outcome = side === 'doc' ? record.docGate : record.entryGate;
405
+ if (outcome === null || outcome.pass || subject === null)
406
+ return [];
407
+ const predicate = side === 'doc'
408
+ ? record.doc.gate
409
+ : record.authoredEntries.find((decision) => !decision.gate.pass)?.entry.gate;
410
+ if (predicate === undefined)
411
+ return [];
412
+ try {
413
+ return explainGate(predicate, subject);
414
+ }
415
+ catch {
416
+ return [];
417
+ }
418
+ }
419
+ /** Does every failing clause name a node value the reader can change? Only then
420
+ * is "dial the snapshot" a true instruction — a malformed gate is fixed in the
421
+ * document. */
422
+ export function allMismatches(clauses) {
423
+ return clauses.length > 0 && clauses.every((clause) => clause.failure === 'mismatch');
424
+ }