Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions GLOSSARY.txt
Original file line number Diff line number Diff line change
Expand Up @@ -709,3 +709,6 @@ andx
alnum
wpa # `security.authentication-types=wpa2-psk` — a wifi argument name whose interior dot the operator fill must not read as concatenation
psk # pre-shared key — the value half of the same grounded `wpa2-psk` example

# #293 B2 arg-fill vocabulary
retag # B5 may retag the `=` separator from the provisional `arg` class without moving byte coverage
31 changes: 21 additions & 10 deletions commands/explain/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1206,8 +1206,8 @@ And the grounded complement — asked, and refused:
`bun run explain:token-census:readme` and gated against it by
`bun run explain:token-census:readme:check`; the fixture itself is gated
against a fresh corpus run by `bun run explain:token-census:check`. Of
1,426,731 analyzed bytes, 403,516 are classified (28.28%), the remaining
1,023,215 are `unclassified`. The census emits 63,416 tokens (avg 66.9 per
1,426,731 analyzed bytes, 497,801 are classified (34.89%), the remaining
928,930 are `unclassified`. The census emits 86,290 tokens (avg 91.0 per
script). Every byte belongs to exactly one token — sorted by `start`, no
gaps, no overlaps, `join(slice) === input` — and the `class` field is
provisional until #264 B5. Each B2 fill should move the classified
Expand All @@ -1219,14 +1219,25 @@ partition whose `class` is provisional until #264 B5 (every unclaimed byte is
`unclassified`). Since B1 its only fill source was `data.spans[]` (comment runs
and resolved variable occurrences); **`#290`'s operator fill is the first B2
fill** — `src/explain/operator-tokens.ts` claims `operator` bytes on the
residual left by spans, with fill order enforced by argument order to
`buildTokens` ([#290 design decision 1](../../src/explain.ts)).
`ExplainTokenClass` is `ExplainSpanClass | "operator" | "unclassified"` — one
provisional `operator` class for all 26 spellings + 2 aliases, not per-operator
or per-category (`#264` B5). `ExplainSpanClass` and `data.spans[]` stay
proof-only; `src/explain/operators.ts` remains data plus accessors, and the
operator table above is its source. The `centrs → highlight` projection is B4
and reads both.
residual left by spans — and **`#293`'s arg fill is the second** —
`src/explain/arg-tokens.ts` claims argument names and their `=` (name run
`[span.start, valueSpan.start - 1)` plus the single `=` byte at
`valueSpan.start - 1`; the value itself, `valueSpan`, is left for the next
fill) on the residual left by spans, before operators see it. Fill order is
enforced by argument order to `buildTokens`
([#290 design decision 1](../../src/explain.ts)): `spans` claims first, then
`arg`, then `operator` sees only what neither wanted — which is why the
operator fill can abstain on `, / = -` outside `( )`, on bytes glued after an
argument `=`, and on bytes glued into an argument name without a smarter
operator scanner.
`ExplainTokenClass` is `ExplainSpanClass | "operator" | "arg" | "unclassified"`
— one provisional `operator` class for all 26 spellings + 2 aliases plus one
provisional `arg` class for both the name bytes and the `=` separator (emit
first, name later — whether the `=` later deserves its own class is #264 B5 and
does not move the byte coverage), not per-operator or per-category (`#264` B5).
`ExplainSpanClass` and `data.spans[]` stay proof-only; `src/explain/operators.ts`
remains data plus accessors, and the operator table above is its source. The
`centrs → highlight` projection is B4 and reads both.

**Where the operator fill abstains.** Fill order is the resolution mechanism, so
a byte that is structurally part of a path or an argument is left
Expand Down
68 changes: 50 additions & 18 deletions src/explain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import {
canonicalizeExecuteCommand,
isWriteShaped,
} from "./execute.ts";
import { argSpans } from "./explain/arg-tokens.ts";
import {
type ArgumentKind,
lexArguments,
Expand Down Expand Up @@ -416,8 +417,8 @@ export interface ExplainSpan extends ExplainSpanRange {
* (#290 design decision 1) and a structural ambiguity (`/` path sep vs `division`)
* resolves by which analyzer came first rather than by a smarter byte scanner.
*
* A fill is typed as `ExplainToken[]` because B2 fills (operator, then
* path/arg) introduce classes outside `ExplainSpanClass`; the proof-only
* A fill is typed as `ExplainToken[]` because B2 fills (operator, arg, then
* path) introduce classes outside `ExplainSpanClass`; the proof-only
* `spans[]` (ExplainSpan[]) remains a subtype and so fits the same slot.
*/
export type TokenFill = readonly ExplainToken[];
Expand All @@ -435,8 +436,21 @@ export type TokenFill = readonly ExplainToken[];
* not the final LSP/SCIP legend, and every byte no analyzer claims becomes
* `unclassified`. Filling those holes is B2, one PR per fill. `unclassified`
* is a first-class answer, not a placeholder to be avoided.
*
* B2 so far: `operator` (26 spellings + 2 aliases, `syntax-meta` is a residual
* merge, never a source, #255) and `arg` (argument names and their `=` as
* located by `args.ts` — the name run is `[span.start, valueSpan.start - 1)`
* and the `=` is the single byte at `valueSpan.start - 1`; the value itself is
* `valueSpan` and is left for the next fill, #293). `arg` covers both the name
* bytes and the `=` byte in one provisional class (emit first, name later);
* whether the `=` later deserves its own class is #264 B5 vocabulary and does
* not move the byte coverage.
*/
export type ExplainTokenClass = ExplainSpanClass | "operator" | "unclassified";
export type ExplainTokenClass =
| ExplainSpanClass
| "operator"
| "arg"
| "unclassified";

export interface ExplainToken extends ExplainSpanRange {
/**
Expand Down Expand Up @@ -617,6 +631,7 @@ const EV = {
transport: "e8",
values: "e9",
operators: "e10",
args: "e11",
} as const;

type EvidenceKey = keyof typeof EV;
Expand Down Expand Up @@ -699,6 +714,13 @@ const EVIDENCE: Record<EvidenceKey, ExplainEvidence> = {
basis: "heuristic",
outcome: "ok",
},
args: {
id: EV.args,
source: "canonicalizer",
probe: "argSpans",
basis: "direct",
outcome: "ok",
},
Comment thread
mobileskyfi marked this conversation as resolved.
};

/** Diagnostic rendering for each defect class. */
Expand Down Expand Up @@ -1125,29 +1147,39 @@ export function explainCommand(
// `spans` (proof-only: comment + variables) claims first; every later fill
// sees only the residual left by the fills before it, so a structural
// ambiguity (`/` path vs division, `,` arg sep vs concat) resolves by which
// analyzer came first. `operatorSpans` is the first such fill.
//
// **The path and arg fills belong BEFORE `operatorSpans`, not after.** The
// intended end state is that `pathresolve.ts` has already claimed the `/`
// and `args.ts` the `=` by the time the operator scanner runs, so it only
// ever sees bytes nobody else wanted. Until those fills exist, the operator
// fill buys the same safety by abstaining wherever a byte is structurally
// path or argument (see `operator-tokens.ts` — three grounded abstentions).
// Inserting a fill after it would leave those abstentions doing work they
// should not have to do, and the scanner can relax them only once the fill
// that owns those bytes runs first:
// analyzer came first. `argSpans` is the second such fill, `operatorSpans`
// the third — the intended end state is
// `const fills: TokenFill[] = [spans, argSpans, opSpans];` so `args.ts` has
// claimed the `=` before the operator scanner runs (#293 design decision 1).
// Until the path fill exists, the operator fill still abstains on `, / -`
// outside `( )` wherever a path byte would be, but for `=` the arg fill now
// owns the byte first, so the operator conservatism is no longer load-bearing
// for `=` (it stays for `, / -`). Inserting a fill after `operatorSpans`
// would leave those abstentions doing work they should not have to do, and
// the scanner can relax them only once the fill that owns those bytes runs
// first:
// const pathSpans = pathSpansOnResidual(analyzed, residual0, ...);
// const residual1 = residualRanges(analyzed.length, [...spans, ...pathSpans]);
// const opSpans = operatorSpans(analyzed, residual1);
// const fills: TokenFill[] = [spans, pathSpans, opSpans];
// const argSpansList = argSpans(analyzed, residual1, argCandidates);
// const residual2 = residualRanges(analyzed.length, [...spans, ...pathSpans, ...argSpansList]);
// const opSpans = operatorSpans(analyzed, residual2);
// const fills: TokenFill[] = [spans, pathSpans, argSpansList, opSpans];
//
// Gate the scan on `options.tokens` — no residual work when the caller
// did not ask for `data.tokens`. Future B2 fills belong inside this branch.
let tokens: ExplainToken[] | undefined;
if (options.tokens === true) {
const residual0 = residualRanges(analyzed.length, spans);
const opSpans = operatorSpans(analyzed, residual0);
const fills: TokenFill[] = [spans, opSpans];
const argCandidates = statements.flatMap((s) =>
s.arguments?.read === true ? s.arguments.tokens : [],
);
const argSpansList = argSpans(analyzed, residual0, argCandidates);
const residual1 = residualRanges(analyzed.length, [
...spans,
...argSpansList,
]);
const opSpans = operatorSpans(analyzed, residual1);
const fills: TokenFill[] = [spans, argSpansList, opSpans];
tokens = buildTokens(analyzed, fills);
Comment thread
mobileskyfi marked this conversation as resolved.
}

Expand Down
167 changes: 167 additions & 0 deletions src/explain/arg-tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* B2 arg fill — claims argument names and their `=` on the residual.
*
* This is the second B2 fill of #264, added after the operator fill. It runs
* **after** the proof-only spans (`comment` + `variable-*`) and **before** the
* operator fill, so the fill order is structural: `spans` claims first, then
* `arg`, then `operator` sees only what neither wanted. That ordering is why the
* operator fill can abstain on `, / = -` outside `( )`, on bytes glued after
* an argument `=`, and on bytes glued into an argument name — those bytes
* belong to a later fill, not to a smarter operator scanner.
Comment thread
mobileskyfi marked this conversation as resolved.
*
* The source is `src/explain/args.ts`'s `Argument` — `span` and `valueSpan`
* already rebased into document analyzed-byte space by `src/explain.ts`. The
* name run is `[span.start, valueSpan.start - 1)` and the `=` is the single
* byte at `valueSpan.start - 1`; the value itself is `valueSpan` and is left
* for the next fill (see #293). A `positional` has no `name`/`valueSpan` and a
* `query` is a `?` word whose interior `args.ts` explicitly refuses to split
* (see `Argument.value`'s doc comment), so both are ignored — the `=` is
* derived from the token shape, never by scanning for the byte.
*
* `Argument.value` is not `Argument.text` and is never read: `value` is absent
* whenever the source spells a substitution or an escape this phase does not
* decode. This slice claims `span`/`valueSpan` *positions* only.
*
* A statement whose bytes were normalized is not addressable: `explain.ts`
* already refuses those (`its text was normalized`), and this fill inherits the
* refusal — it only sees tokens from `read === true` statements, which are
* exactly the addressable ones.
*
* Vocabulary is provisional until #264 B5: one `arg` class for both the name
* bytes and the `=` separator (design decision 2 of #293 — emit first, name
* later; `highlight`'s `syntax-meta` is a residual merge and never a source).
* If B5 splits the `=` into its own class, the byte coverage does not move,
* only the retag.
*/

import type { ExplainArgumentToken, ExplainToken } from "../explain.ts";

export type ArgCandidate = ExplainArgumentToken;

function clipToResidual(
start: number,
end: number,
residual: readonly { start: number; end: number }[],
): { start: number; end: number }[] {
if (start >= end) return [];
if (residual.length === 0) return [];
// Binary-search to first residual that could overlap `start` (r.end > start).
let lo = 0;
let hi = residual.length - 1;
let first = residual.length;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const r = residual[mid] as { start: number; end: number };
if (r.end <= start) lo = mid + 1;
else {
first = mid;
hi = mid - 1;
}
}
const out: { start: number; end: number }[] = [];
for (let i = first; i < residual.length; i++) {
const r = residual[i] as { start: number; end: number };
if (r.start >= end) break;
const oStart = Math.max(start, r.start);
const oEnd = Math.min(end, r.end);
if (oStart < oEnd) out.push({ start: oStart, end: oEnd });
}
return out;
}
Comment thread
mobileskyfi marked this conversation as resolved.

/**
* Argument name + `=` spans on the residual.
*
* `analyzed` is the ASCII-normalized document text (only used for length
* checks); `residual` is the gap set left by earlier fills (sorted, no
* overlaps); `candidates` are the already-rebased `Argument` tokens from
* `read === true` statements. Every emitted span's bytes are fully inside
* `residual`, sorted by `start`, non-overlapping, and carry
* `class: "arg"` + `ev: "e11"`.
*/
export function argSpans(
analyzed: string,
residual: readonly { start: number; end: number }[],
candidates: readonly ArgCandidate[],
): ExplainToken[] {
const len = analyzed.length;
if (len === 0 || residual.length === 0 || candidates.length === 0) return [];

const out: ExplainToken[] = [];

for (const candidate of candidates) {
if (candidate.kind !== "attribute") continue;
if (candidate.valueSpan === undefined) continue;
if (candidate.name === undefined) continue;
const nameStart = candidate.span.start;
const eqEnd = candidate.valueSpan.start;
const eqStart = eqEnd - 1;
const nameEnd = eqStart;
// Defensive: token shape must give non-empty name and single-byte `=`.
if (
!Number.isInteger(nameStart) ||
!Number.isInteger(nameEnd) ||
!Number.isInteger(eqStart) ||
!Number.isInteger(eqEnd)
)
continue;
if (nameStart < 0 || eqEnd > len) continue;
if (nameStart >= nameEnd) continue;
// The whole token's `=` byte must be `=` in the analyzed text — a cheap
// shape check that catches a caller that passed un-rebased or misaligned
// spans. Not a scan for `=`; the position is derived from `valueSpan`.
if (analyzed[eqStart] !== "=") continue;
// Also ensure the candidate lies fully inside the analyzed length.
if (candidate.span.start < 0 || candidate.span.end > len) continue;
if (candidate.valueSpan.start < 0 || candidate.valueSpan.end > len)
continue;

// Clip each run to the residual — a `variable-*` span may already claim
// bytes that look like an arg name/value (e.g. `comment=$c` is unread,
// so it never reaches here, but a future variable inside a quoted value
// would). Offering only residual keeps `buildTokens`'s cross-fill overlap
// throw as a safety net rather than a production path.
// A `variable-*` claiming the `=` does not imply it claims the name
// bytes, so the name and the `=` are clipped separately.
const nameClipped = clipToResidual(nameStart, nameEnd, residual);
const eqClipped = clipToResidual(eqStart, eqEnd, residual);
for (const r of nameClipped) {
out.push({
start: r.start,
end: r.end,
class: "arg" as const,
ev: "e11",
});
}
for (const r of eqClipped) {
out.push({
start: r.start,
end: r.end,
class: "arg" as const,
ev: "e11",
});
}
}

out.sort((a, b) => a.start - b.start || a.end - b.end);
// Coalesce adjacent `arg` runs that were split only by the name/`=` boundary
// when both were residual: `[name][=]` of the same attribute becomes one
// maximal `arg` run rather than two adjacent tokens of the same class. The
// two bytes remain distinguishable by the `=` position, but the partition
// itself need not multiply tokens for a single name+separator.
const coalesced: ExplainToken[] = [];
for (const t of out) {
const last = coalesced[coalesced.length - 1];
if (
last !== undefined &&
last.class === t.class &&
last.ev === t.ev &&
last.end === t.start
) {
last.end = t.end;
} else {
coalesced.push({ ...t });
}
}
return coalesced;
}
20 changes: 11 additions & 9 deletions test/fixtures/explain/tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,30 @@
"corpus": {
"sourceScripts": 948,
"totalBytes": 1426731,
"classifiedBytes": 403516,
"unclassifiedBytes": 1023215,
"classifiedPct": 28.28255641743258,
"totalTokens": 63416,
"avgTokensPerScript": 66.89451476793249,
"classifiedBytes": 497801,
"unclassifiedBytes": 928930,
"classifiedPct": 34.891020101196375,
"totalTokens": 86290,
"avgTokensPerScript": 91.02320675105486,
"classCounts": {
"comment": 5545,
"unclassified": 30709,
"unclassified": 42145,
"variable-global": 2769,
"variable-local": 11331,
"variable-parameter": 2242,
"operator": 9418,
"variable-auto": 1402,
"operator": 9418
"arg": 11438
},
"classByteCounts": {
"comment": 277806,
"unclassified": 1023215,
"unclassified": 928930,
"variable-global": 27428,
"variable-local": 71695,
"variable-parameter": 10277,
"operator": 12042,
"variable-auto": 4268,
"operator": 12042
"arg": 94285
}
},
"_note": "token partition census — see scripts/explain-token-census.ts; re-derive with bun run explain:token-census --json (wrapped as {corpus: ...})"
Expand Down
Loading
Loading