@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.
- package/dist/clients/attach/viewer.js +64 -64
- package/dist/commands/sys/context/admin/detail-panel.d.ts +3 -1
- package/dist/commands/sys/context/admin/detail-panel.js +4 -1
- package/dist/commands/sys/context/admin/docs-panel.d.ts +10 -13
- package/dist/commands/sys/context/admin/docs-panel.js +48 -61
- package/dist/commands/sys/context/admin/list-view.d.ts +2 -4
- package/dist/commands/sys/context/admin/list-view.js +5 -6
- package/dist/commands/sys/context/admin/model.d.ts +16 -12
- package/dist/commands/sys/context/admin/model.js +17 -30
- package/dist/commands/sys/context/admin/rail-panel.d.ts +43 -7
- package/dist/commands/sys/context/admin/rail-panel.js +104 -37
- package/dist/commands/sys/context/admin/read-view.d.ts +4 -0
- package/dist/commands/sys/context/admin/read-view.js +41 -4
- package/dist/commands/sys/context/admin/shell.d.ts +14 -4
- package/dist/commands/sys/context/admin/shell.js +97 -50
- package/dist/commands/sys/context/doc.js +6 -5
- package/dist/commands/sys/context/resolve.d.ts +7 -3
- package/dist/commands/sys/context/resolve.js +19 -11
- package/dist/commands/sys/panels/profiles-panel.js +2 -1
- package/dist/core/fs-utils.d.ts +3 -0
- package/dist/core/fs-utils.js +10 -1
- package/dist/core/profiles/select.d.ts +0 -3
- package/dist/core/profiles/select.js +1 -11
- package/dist/core/substrate/gate-explain.d.ts +48 -0
- package/dist/core/substrate/gate-explain.js +424 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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 {
|
|
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) {
|
package/dist/core/fs-utils.d.ts
CHANGED
|
@@ -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 {
|
package/dist/core/fs-utils.js
CHANGED
|
@@ -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
|
+
}
|