From c4b05db466d5a58b90f2b014a68766110530ccdf Mon Sep 17 00:00:00 2001 From: queil <4584075+queil@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:45:24 +0000 Subject: [PATCH 01/12] =?UTF-8?q?ci,ledger:=20release-smoke=20gate=20?= =?UTF-8?q?=E2=80=94=20the=20packaged=20BINARY=20must=20uphold=20the=20hyg?= =?UTF-8?q?iene=20guarantees?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every strix retest found a credential/output-hygiene fix correct on main but still present in the installed binary — a release-publishing gap. New ci/release-smoke.weir runs the promises against a binary (URL userinfo redaction, bounded parse/decode errors, deep-value no-crash) via the transport-error path and file-run rendering — no server, no tty, portable. release.yml's build job self-tests each freshly-built artifact before upload, so a regressed promise can't reach 'released'. Verified: fixed binary 3/3, v0.0.47 fails at check 1 (userinfo verbatim, exit 1). --- .github/workflows/release.yml | 10 +++++++ ci/release-smoke.weir | 54 +++++++++++++++++++++++++++++++++++ docs/DECISIONS.md | 1 + 3 files changed, 65 insertions(+) create mode 100644 ci/release-smoke.weir diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 982bf84b..b6300a94 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -120,6 +120,16 @@ jobs: win-*) cp "$BIN" "weir-${{ github.ref_name }}-${{ matrix.rid }}.exe" ;; *) cp "$BIN" "weir-${{ github.ref_name }}-${{ matrix.rid }}" ;; esac + # the ARTIFACT, not just the source tree, must uphold the output-hygiene + # guarantees — every strix retest found a fix "correct in source, still + # present in the shipped binary". The binary self-tests before it can ship. + - name: security smoke — the packaged binary upholds the hygiene guarantees + run: | + case "${{ matrix.rid }}" in + win-*) BIN=src/Weir/bin/Release/net10.0/${{ matrix.rid }}/publish/Weir.exe ;; + *) BIN=src/Weir/bin/Release/net10.0/${{ matrix.rid }}/publish/Weir ;; + esac + "$BIN" ci/release-smoke.weir --bin "$BIN" # signed build provenance [D:install-checksum-scope]: the installer's # embedded checksum is the primary trust anchor; this adds an # independent, OIDC-signed record that THIS repo's Actions built the diff --git a/ci/release-smoke.weir b/ci/release-smoke.weir new file mode 100644 index 00000000..8d8629b2 --- /dev/null +++ b/ci/release-smoke.weir @@ -0,0 +1,54 @@ +#!/usr/bin/env weir +// Release SECURITY SMOKE [D:release-artifact-smoke]: the strix retests +// found every credential/output-hygiene fix "correct in source, still +// present in the SHIPPED binary" — a release-publishing gap, not a code +// one. A guarantee that lives only in an unreleased tree protects no user. +// This gate runs the promises against a BINARY (the packaged artifact), so +// a build that regressed one cannot reach "released". +// +// weir ci/release-smoke.weir --bin +// +// The probes use the transport-error path (a bogus host — no server) and +// file-run rendering (no tty), so the gate is self-contained and portable. + +type Cli = { bin: string } + +let cli = Args.load Cli +let bin = cli.bin + +// run on a one-off script; return the whole diagnostic stream and exit +let probe program = + within tmp d + let path = $"{d}/probe.weir" + program |> File.write path + let r = ^$bin $path | complete + let text = Seq.append r.stdout r.stderr |> Str.join "\n" + (text, r.exitCode) + +// 1. HTTP URL userinfo is REDACTED in error output (CWE-532) +let out1, _ = probe [ @"let _r = Http.fetch ""http://alice:hunter2@nonexistent.invalid/x""" ] +if Str.contains "alice:hunter2" out1 then + fail $"REDACTION: URL userinfo printed verbatim in the error — {out1}" +if not (Str.contains "***" out1) then + fail $"REDACTION: no *** mask in the transport error — {out1}" +print "ok: HTTP userinfo redacted (user:pass -> ***)" + +// 2. parse/decode error output is BOUNDED, not proportional to input (CWE-1286) +let out2, _ = + probe [ @"let big = Str.replicate 500000 ""!"""; "let _b = Bytes.fromBase64 big" ] +let n2 = Str.length out2 +if n2 > 2000 then + fail $"BOUNDED-OUTPUT: a 500k invalid input yielded {n2} bytes of error (unbounded echo)" +print $"ok: parse/decode error bounded ({n2} bytes for a 500k input)" + +// 3. a deeply-nested value does NOT crash the process (CWE-674, file-run render) +let _, code3 = + probe + [ "type N = { d: int; next: Option }" + "let deep = [1..200000] |> Seq.fold (fun acc i -> N { d = i; next = Some acc }) (N { d = 0; next = None })" + "print (Str.length (show deep))" ] +if code3 <> 0 then + fail $"DEEP-VALUE: a 200k-deep value crashed the binary (exit {code3} — likely SIGSEGV/SIGABRT)" +print "ok: deep value renders without crashing (exit 0)" + +print $"release-smoke: all security guarantees hold in {bin}" diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 1da16c98..4f62229d 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -441,6 +441,7 @@ the old key, never an edit-in-place. | help-glance | 2026-09-17 | `#help` BECOMES GLANCEABLE — a RENDERING change, the data existed. `#help ` lists ONE MEMBER PER LINE: the name padded, then the FIRST LINE of its builtinDocs Summary (the hover's own one source [D:repl-directives] — the glance IS the doc's first line, so it cannot drift), the whole row clipped to the terminal width (piped output uses a FIXED width of 100 so the piped byte surface stays deterministic). Bare `#help` lists modules one per line, each with a SHORT BLURB — module-level doc strings did NOT exist (the recorded [D:repl-directives] gap), so a new ONE-SOURCE table `Builtins.moduleBlurbs` authors them (one terse line per module, tone derived from the member docs). COMPLETENESS-GUARDED two ways (the gen-lexical pattern): a unit pin asserts every derived module (typeEnv.Modules) has a blurb AND every blurb names a live module — a new module without a blurb fails loud, a retired module cannot leave a stale line. The directive list in bare #help stays a hand-written literal (each line carries bespoke usage text; the DISPATCH source `Complete.sessionDirectives` now feeds the unknown-directive did-you-mean, the cheap half of deriving it). flowNames (the word-soup flow) retired with its last caller. | Repl.fs glanceWidth/clipTo/memberGlance/moduleMembersOf/glanceTable + helpDirective bare/module arms; Builtins.fs moduleBlurbs; ci/skill-surface.sh extractor moved to the glance shape (name-first, simpler); tests helpUxTests (a)-(c) + two directive-set pin updates; tests/repl/repl-directives.py glance cells; docs/repl.md Help; CHANGELOG v0.0.40 | | help-find | 2026-09-17 | `#find [query]` — FUZZY HELP SEARCH, fzf-first with a LIVE PREVIEW, fallback-always [D:repl-quality]. The candidate set is ONE SOURCE with #help's rendering: every module (`Seq — blurb`) and every member (`Seq.map — glance`), built from the same moduleBlurbs/builtinDocs/moduleMembersOf derivations — never a second copy. AT A TTY WITH FZF: candidates feed fzf (`--no-extended` HARDWIRED first — weir glyphs `^ | $ !` are fzf extended-search operators; last-flag-wins lets finderFlags restore), the optional query rides `--query`, and `--preview` runs the RUNNING BINARY headlessly (`Environment.ProcessPath`, never `weir`-on-PATH assumed) via a new CLI seam: `weir --repl-doc ` prints the EXACT `#help ` text for the builtin surface — read-only, no user code, no eval; deliberately absent from usage (lightly documented in docs/repl.md). The name is fzf's `{1}` (first whitespace field — the ` — glance` tail never reaches the render). Selection prints the exact #help answer; cancel/130 prints NOTHING and the session continues; the kitty-keyboard pop guard rides along [D:binary-echo]. WITHOUT FZF OR PIPED: a deterministic case-insensitive substring filter over the same lines (bare `#find` teaches usage in one line) — NEVER an "install fzf" message, and piped sessions ALWAYS take the fallback (testable bytes). `#find` joins Complete.sessionDirectives (Tab-completes, empty-prompt teaching set), and the unknown-directive message gained a did-you-mean over that one-source list. PINS: --repl-doc == #help byte-equality (e2e diff); fzf-stub pty cell asserting the argv contract (--no-extended + --preview --repl-doc + --query); fallback cells piped. DOCS: repl.md "weir and fzf" section (all fzf touchpoints in one place); lexical.md's directive table (script-file / init.weir / prompt — the three contexts, the two-lifetimes law extended honestly), e2e-gated so every dispatched directive must appear. | Repl.fs findCandidates/findFallback/findFzf/findDirective/replDocText + dispatch arm + did-you-mean; Program.fs --repl-doc arm; Complete.fs sessionDirectives; tests helpUxTests (d)-(g); tests/repl/repl-quality.py #find stub cell; tests/repl/repl-directives.py fallback cells; ci/e2e.sh help-glance/#find cell; docs/repl.md + docs/reference/lexical.md + README + SKILL.md; CHANGELOG v0.0.40 | | release-assets | 2026-09-17 | RELEASE ASSET COMPLETENESS IS MACHINE-CHECKED — the v0.0.40 incident: `gh release create` (create + upload in one call) hit a transient HTTP 500 mid-asset-upload, the manual recovery re-created the release with only 5 of 10 assets (win-arm64.exe, SHA256SUMS, install.sh, install.ps1 and grammar-manifest.json all missing), the draft review missed it, and a PUBLISHED release is immutable — nothing repaired it, v0.0.41 superseded. THE GUARD, three layers off ONE list (ci/release-assets.weir owns the expected-asset set — 6 platform binaries, SHA256SUMS, both pinned installers, grammar-manifest.json; nothing restates it): (1) release.yml SPLITS create from upload — the draft is created with NO assets, then uploads run in a 3-attempt backoff loop with --clobber on every attempt so a 500-ghost (asset created server-side, state never "uploaded") cannot block a retry; (2) the publish job then runs release-assets.weir --tag with GH_TOKEN — a draft is invisible to the anonymous API, and /releases/tags/ answers PUBLISHED only, so the draft rides a list-releases fallback — asserting every expected asset present AND state == uploaded AND size > 0, failing the job before a human can review a plausible-looking incomplete draft; (3) ci/release-published.weir re-verifies the newest PUBLISHED release on every main run — KNOWN CONSEQUENCE, deliberate: red while v0.0.40 is the newest stable tag, clearing when the next release supersedes it (the failure names the supersede path). UPDATE 2026-09-18 — the always-red-while-newest ruling REVERSED on first contact: the red blocked the SUPERSEDING release's own PR CI (the check eating its repair). ci/release-known-incomplete.yaml acknowledges a permanently-incomplete release (the skill-omitted pattern: tag + reason, both-ways SWEPT — a listed-but-complete release fails stale, an unlisted incomplete one still reds); v0.0.40 is the first entry. Verified live both directions: v0.0.40 fails naming exactly the 5 missing assets; v0.0.39 passes 10/10. | ci/release-assets.weir; release.yml publish job; ci/release-published.weir | +| release-artifact-smoke | 2026-09-23 | THE SHIPPED BINARY MUST PROVE THE OUTPUT-HYGIENE GUARANTEES, NOT JUST THE SOURCE TREE — every strix retest found a credential/output-hygiene fix "correct on main, still present in the installed binary" (URL userinfo redaction CWE-532, bounded parse/decode errors CWE-1286, deep-value no-crash CWE-674): a release-PUBLISHING gap, not a code one, because a guarantee that lives only in an unreleased tree protects no user. ci/release-smoke.weir runs the promises against a BINARY — the transport-error path (a `.invalid` host, no server) and file-run rendering (no tty), so it is self-contained and portable — and release.yml's build job self-tests each freshly-built artifact (`$BIN ci/release-smoke.weir --bin $BIN`) BEFORE upload, so a build that regressed a promise cannot reach "released". Verified both directions: the fixed binary passes 3/3; v0.0.47 fails at check 1 (userinfo printed verbatim, exit 1). | ci/release-smoke.weir; release.yml build job | | help-tint | 2026-09-18 | HELP CODE SPANS RENDER AS CODE AT A TTY — `#help`/`#find` doc text tints its `` `code` `` spans cyan (Types.Color.cyan — the colorizer's number/sigil tint, one "reads as code" colour) with the backticks THEMSELVES dropped; the headline/signature stays untinted (a terminal, not a TUI). ONE RENDERER: Repl.renderHelpText (colour gate explicit) is the only transform, called by every REPL help print site — the #help dispatch, #find's selection print and its fallback; per-site regex is structurally impossible. THE PIPED BYTES DO NOT MOVE: replDocText/`--repl-doc` and redirected output pass through untouched (literal backticks — the pinned surface, and `--repl-doc` == piped `#help` byte-equality holds unchanged); a STRIPPED tty (NO_COLOR / TERM=dumb, the shared Color gate) falls back to the same literal spelling, so removing the tint never loses the span boundary. LSP hover untouched — editors render markdown backticks themselves. UPDATE 2026-09-18 — THE SIGNATURE LINE AND EXAMPLE BLOCK TINT TOO (the two code surfaces the span pass could not reach: neither carries backticks). The SIGNATURE tints STRUCTURALLY at composition, never re-parsed: formatSignature refactored to formatSignatureWith over a SigStyle (name/types/punctuation), plainSigStyle keeping every existing caller byte-identical; sigTintStyle is the input colorizer's OWN palette (name bold = the known-head tint, types yellow 33 = the casing law, punctuation dim), built from the Color functions so the codes cannot drift. The EXAMPLE renders through Script.colorizeRepl ITSELF, per line — an example is weir code, so the live prompt and #help share ONE brain (a colorizer improvement lifts #help for free; the head verdict rides knownIn, the ONE membership the prompt repaint uses; colorizeRepl is state-free over (isKnown, line), so a standalone snippet is safe). Both ride memberHelp's colour flag (helpDirective threads it; renderHelpDirective is the one tty pipeline, one gate with the span pass); colour=false is the pinned piped/--repl-doc surface, verified byte-identical against the base build. Glances and #find candidate lines keep their current treatment. | Repl.fs renderHelpText/renderHelp/renderHelpDirective + knownIn + memberHelp colour flag; Types.fs Color.cyan + SigStyle/plainSigStyle/sigTintStyle/formatSignatureWith; Builtins.fs renderBuiltinDocWith; tests helpUxTests (h)/(i)/(j)/(k)/(l); tests/repl/repl-color.py help-tint + signature/example cells; tests/repl/repl-directives.py piped-backticks + piped-signature pins; docs/repl.md Help; CHANGELOG v0.0.42 | | no-shouting | 2026-09-18 | USER-FACING PROSE DOES NOT SHOUT — uppercase-emphasis words (`It DRAFTS what the sample has`, `NOT check-time inference`, `READ-ONLY`, `STOPS`, …) are not house style for rendered text: emphasis reads as noise at a prompt, and the ledger already owns the loud register. The corpus — builtinDocs Summary/Pointer prose, moduleBlurbs, attrDocs, the bare-#help directive list, the `init: not loaded` teach — is rewritten in normal case; syntax depictions (`KEY=value`) become code spans, which are exempt by definition (they quote identifiers/syntax). PINNED AS A CLASS, the (a2) posture: helpUxTests (a3) rejects any all-caps word (3+ uppercase letters, outside backtick spans, underscore names like WEIR_LOG exempt) across the enumerable corpus; real acronyms (JSON, UTF-8, GET, FIFO, …) live on a commented allowlist beside the pin — a new one is a one-line addition with its reason. Comments and the DECISIONS ledger keep their register — the ruling is user-rendered text only. | Builtins.fs docs/blurbs/attrDocs sweep; Repl.fs #echo line + init teach; tests helpUxTests (a3) + the wire-tagged hover pin; ci/e2e.sh init greps; site reference.json regenerated; docs/repl.md; CHANGELOG v0.0.42 | | quoted-fold | 2026-09-18 | MULTI-LINE QUOTED FLOW SCALARS READ — the next real-kubectl gap after [D:yaml-seq]/[D:yaml-empty-flow], user-hit live: `kubectl get po -A -o yaml` evicted-pod `message:` values are quoted scalars whose closing quote sits on a LATER, deeper-indented line, and the block parser took the continuation lines for a nested block (`'message' has both an inline value and a nested block`). THE RULING: the subset reads multi-line single- AND double-quoted scalars with YAML's flow folding, PyYAML-refereed before implementation — a line break folds to ONE SPACE; each empty continuation line contributes a NEWLINE; continuation indentation strips (lines must sit RIGHT of the owning key/dash column — stricter than yaml's lax scanners, the block-scalar extent rule applied consistently); trailing whitespace before a break folds away, trailing space before the CLOSING quote is content. Escapes resolve AFTER folding through scalarCore on the re-wrapped body (ONE machine — `''` and the double-quote escape set work mid-continuation; a `\`-escaped line break is NOT in the subset). EXTENT: the continuation lines are CONSUMED by the scalar and come from the RAW source (blank and `#`-shaped lines are bytes inside the quotes — the block-scalar precedent), in map-value AND sequence-item position at any depth incl. zero-indent seqs; the single-line paths keep ruling closed scalars (quotedValue engages only on scalarCore's unclosed verdict, so every current read is byte-identical). TEACHING: unterminated (EOF or a dedent to the key's column) errors at the OPENING line in the unclosed family; content after the closing quote errors at ITS line; a deeper line past the close is named (the blockValue extent-guard twin). Quotedness stays load-bearing across the fold (a folded `no` is a STRING — the Norway law) and the WRITE side is untouched: weir still emits block scalars/quoted one-liners, never multi-line quoted, and the read-write roundtrip is pinned. | Yaml.fs closeQuoteAt/multilineQuoted/quotedValue; Tests "multi-line quoted scalars read", "multi-line quoted: Norway stays string"; ci/e2e.sh kubectl-list message forms; SKILL yaml paragraph; adapters.md | From a49ae26f32bd500f41a414467b29919a655e66f7 Mon Sep 17 00:00:00 2001 From: queil <4584075+queil@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:55:56 +0000 Subject: [PATCH 02/12] adapters: #infer decides open-map over ALL siblings, not a coincidental pair Sibling objects with different keys whose values only coincidentally agreed in an early pair (a k8s securityContext: bool field, then int two elements later) made the pairwise merge commit to seq, and a later 'from json' rejected the int ('expected bool, got Number'). Defer the open-map verdict from the pairwise mergeObjs to a post-merge openMaps pass over the fully-merged shape, where value uniformity is judged across every sibling: mixed values stay a typed record; genuinely uniform data-keyed objects (ConfigMap data) still draft an open mapping. The now-dead IMap absorb arms in mergeTwo are removed. --- CHANGELOG.md | 14 ++++++ src/Weir/Infer.fs | 88 +++++++++++++++++++++------------- tests/Weir.Tests/InferProbe.fs | 20 ++++++++ 3 files changed, 88 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56808665..55e0750d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## v0.0.50 + +### Fixed + +- **`#infer` no longer mis-drafts a heterogeneous object as a homogeneous + map.** When sibling objects in a sample array carried different keys whose + values only *coincidentally* agreed in an early pair (e.g. a Kubernetes + `securityContext` with a `bool` field, then an `int` field two elements + later), the pairwise merge committed to `seq` and a later + `from json` rejected the int (`expected bool, got Number`). The open-map + verdict is now decided over the *whole* set of siblings, so an object with + mixed value types stays a typed record; genuinely uniform data-keyed + objects (a ConfigMap's `data`) are still drafted as an open mapping. + ## v0.0.49 ### Fixed diff --git a/src/Weir/Infer.fs b/src/Weir/Infer.fs index b993f5c1..50a4bc46 100644 --- a/src/Weir/Infer.fs +++ b/src/Weir/Infer.fs @@ -307,12 +307,6 @@ let rec private mergeTwo (note: Note -> unit) (path: string) (a: INode) (b: INod | IOpt x, y -> iopt (mergeTwo note path x y) | x, IOpt y -> iopt (mergeTwo note path x y) | IObj xs, IObj ys -> mergeObjs note path xs ys - // an established map verdict absorbs a sibling's entries — the - // VALUES merge; a conflicting value keeps the map's (the first-wins - // rule, one level in) - | IMap v, IObj fs -> IMap(fs |> List.fold (fun acc (_, fv) -> mergeTwo note path acc fv) v) - | IObj fs, IMap v -> IMap(fs |> List.fold (fun acc (_, fv) -> mergeTwo note path acc fv) v) - | IMap x, IMap y -> IMap(mergeTwo note path x y) // arrays pool their elements; the enclosing seq walk merges the pool | IArr xs, IArr ys -> IArr(xs @ ys) | a, _ -> @@ -320,40 +314,66 @@ let rec private mergeTwo (note: Note -> unit) (path: string) (a: INode) (b: INod a and private mergeObjs (note: Note -> unit) (path: string) (xs: (string * INode) list) (ys: (string * INode) list) : INode = + // ALWAYS the record union here; the open-map verdict (detection half b) + // is DEFERRED to openMaps over the fully-merged shape [D:repl-infer]. + // Value uniformity must hold across ALL siblings, not a pair — the + // pairwise fold committed to a map from a coincidentally-uniform early + // pair, then absorbed a later CONFLICTING value first-wins (a k8s + // securityContext with a bool field then an int field drafted + // Map and rejected the int). The record merge: union of + // keys in first-seen order; a shared key merges recursively, a one-sided + // key drafts Option (which is how differing key sets read downstream). let kx = xs |> List.map fst |> Set.ofList - let ky = ys |> List.map fst |> Set.ofList - - match (if kx <> ky then uniformValue ((xs @ ys) |> List.map snd) else None) with - | Some v -> - // detection half (b): differing sibling key sets, one value - // shape — the keys are data [D:repl-infer] - note $"'{path}' carries different keys across the array's elements — its keys are data, drafted as an open mapping seq" - IMap v - | None -> - // the record merge: union of keys in first-seen order; a shared - // key merges recursively, a one-sided key drafts Option - let ym = Map.ofList ys - - let fromX = - xs - |> List.map (fun (k, xv) -> - match Map.tryFind k ym with - | Some yv -> k, mergeTwo note $"{path}.{k}" xv yv - | None -> k, iopt xv) - - let fromY = - ys - |> List.filter (fun (k, _) -> not (Set.contains k kx)) - |> List.map (fun (k, yv) -> k, iopt yv) - - IObj(fromX @ fromY) + let ym = Map.ofList ys + + let fromX = + xs + |> List.map (fun (k, xv) -> + match Map.tryFind k ym with + | Some yv -> k, mergeTwo note $"{path}.{k}" xv yv + | None -> k, iopt xv) + + let fromY = + ys + |> List.filter (fun (k, _) -> not (Set.contains k kx)) + |> List.map (fun (k, yv) -> k, iopt yv) + + IObj(fromX @ fromY) + +// the open-map verdict, DEFERRED to the fully-merged shape (detection half +// b) [D:repl-infer]: an object whose keys DIFFER across the array's +// elements — every field OPTIONAL after the union, none shared — AND whose +// values share ONE shape has data keys, drafted seq. Deciding +// here rather than in the pairwise merge is what lets value uniformity be +// judged over ALL siblings (a securityContext's bool+int values are not +// uniform, so it stays a record; a ConfigMap data's all-string values are). +// Walks bottom-up so a nested map is settled before its parent is judged. +let rec private openMaps (note: Note -> unit) (path: string) (node: INode) : INode = + match node with + | IObj fields -> + let fields' = fields |> List.map (fun (k, v) -> k, openMaps note $"{path}.{k}" v) + let allOptional = fields' |> List.forall (fun (_, v) -> (match v with IOpt _ -> true | _ -> false)) + + let bare = + fields' |> List.map (fun (_, v) -> (match v with IOpt x -> x | x -> x)) + + match (if allOptional && List.length fields' > 1 then uniformValue bare else None) with + | Some v -> + note $"'{path}' carries different keys across the array's elements — its keys are data, drafted as an open mapping seq" + IMap v + | None -> IObj fields' + | IArr items -> IArr(items |> List.map (openMaps note path)) + | IOpt x -> iopt (openMaps note path x) + | IMap v -> IMap(openMaps note path v) + | _ -> node /// merge every non-null element of an array into ONE element shape (null -/// elements never decide a shape — the adapters' existing posture) +/// elements never decide a shape — the adapters' existing posture), then +/// settle open maps over the full result let private mergeElems (note: Note -> unit) (path: string) (items: INode list) : INode option = match items |> List.filter ((<>) INull) with | [] -> None - | first :: rest -> Some(rest |> List.fold (mergeTwo note path) first) + | first :: rest -> Some(rest |> List.fold (mergeTwo note path) first |> openMaps note path) // `srcKey` is the wire key (or top name) that produced `desired` — the // shadow note names the user's own spelling, not the derived stem diff --git a/tests/Weir.Tests/InferProbe.fs b/tests/Weir.Tests/InferProbe.fs index da50c027..4b132ec1 100644 --- a/tests/Weir.Tests/InferProbe.fs +++ b/tests/Weir.Tests/InferProbe.fs @@ -543,6 +543,26 @@ let inferRules = Expect.isFalse (out.Contains "metadata: seq<") "never a mapping" } + test "differing sibling keys with MIXED value types stay a record, not a coincidental bool map [D:repl-infer]" { + // the k8s securityContext bug: sibling objects carry different + // keys (runAsNonRoot / readOnly / runAsGroup) whose values only + // COINCIDENTALLY agree in the first pair (bool, bool), then an + // int. The pairwise fold drafted Map and a later + // `from json` REJECTED the int ("expected bool, got Number"). + // Value uniformity is now judged over ALL siblings, so mixed + // values keep a record with each field typed. + let sample = + "{\"items\": [{\"sc\": {\"runAsNonRoot\": true}}, {\"sc\": {\"readOnly\": false}}, {\"sc\": {\"runAsGroup\": 1000}}]}" + + match Infer.infer Parser.keywords takenBase Infer.Json "PodList" [ sample ] with + | Error e -> failtestf "infer failed: %s" e + | Ok(decls, _) -> + let out = String.concat "\n" decls + Expect.isFalse (out.Contains "sc: seq<") "sc is NOT drafted as an open mapping" + Expect.stringContains out "runAsGroup: Option" "the int field survives as a typed record field" + Expect.stringContains out "runAsNonRoot: Option" "the bool field stays bool" + } + test "an empty {} drafts seq and READS on both boundaries [D:yaml-empty-flow]" { // json draft + note match Infer.infer Parser.keywords takenBase Infer.Json "Spec" [ "{\"resources\": {}}" ] with From c7b10e915157a1ed5fd41d8123d632f4a1f7178e Mon Sep 17 00:00:00 2001 From: queil <4584075+queil@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:30:56 +0000 Subject: [PATCH 03/12] builtins,repl: add Path.home + XDG dirs (configHome/stateHome/cacheHome) --- CHANGELOG.md | 12 ++++++++ docs/DECISIONS.md | 1 + skills/weir/SKILL.md | 2 +- src/Weir/Builtins.fs | 63 +++++++++++++++++++++++++++++++++++++++ src/Weir/Repl.fs | 23 +++----------- tests/Weir.Tests/Tests.fs | 17 +++++++++++ 6 files changed, 98 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55e0750d..d93c58a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## v0.0.50 +### Added + +- **`Path.home` and the XDG directory trio (`Path.configHome`, + `Path.stateHome`, `Path.cacheHome`).** The typed stand-in for `~`/`$HOME`, + which never expand in argv — build a path with an interpolation instead: + `cat $"{Path.home ()}/.bashrc"`, `cat $"{Path.stateHome ()}/weir/history"`. + Each is a pure `unit -> string` query with platform-native output: + `configHome`/`stateHome`/`cacheHome` resolve `%APPDATA%`/`%LOCALAPPDATA%` + on Windows and `$XDG_CONFIG_HOME`/`$XDG_STATE_HOME`/`$XDG_CACHE_HOME` + (falling back to `~/.config`, `~/.local/state`, `~/.cache`) on POSIX — + the same resolution the REPL uses for its own history file. + ### Fixed - **`#infer` no longer mis-drafts a heterogeneous object as a homogeneous diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 4f62229d..444421ab 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -486,4 +486,5 @@ the old key, never an edit-in-place. | assemble-quadratic | 2026-09-21 | THE LINE ASSEMBLER SCALES LINEARLY — an algorithmic-DoS cluster (four O(N²) paths in `Script.fs`'s `assemble` fold, each confirmed to stall `weir check`/`fmt`/REPL/LSP on crafted hundreds-of-KB inputs). ROOT: the pending statement's text was an IMMUTABLE string rebuilt per continuation join (`ll.Text + sep + piece`, plus per-line whole-text `TrimEnd`/`LastIndexOf`/`Substring` queries), so N joins cost O(N²). RULED: the pending statement carries a `PendBuf` — a single `StringBuilder` mutated in place plus incrementally-maintained indexes (last-non-white for the bracket-dangle `EndsWith`/last-char predicates; last-segment-start for the `within proc`/`within serve` binder-head scan, with a `Contains` fast reject; a byte-index with-header check replacing the per-opener `Substring`) — materialized to the immutable `LogicalLine.Text` ONCE at statement close (`bufToLL`). Span arithmetic stays BYTE-IDENTICAL: `applyJoin`'s `joinedStart` derives from `bufLen` at exactly the point the old code read `ll.Text.Length`, the separator literals are unchanged, and `wrapFrom` (paren-wrap a compound) does the same `(`/`)` insert via `StringBuilder.Insert`/`Append` with the same segment-shift. A PURE performance fix — the emitted assembly diagnostic and the segment/translate tables are unchanged (fmt round-trip + checker output byte-identical on the whole `.weir` corpus). Measured: continuation flood 80k lines 9.3s→1.3s; bareword `within proc` marker 80k lines 24.1s→3.6s; bracket-heavy single line 400k `{}` openers 29s→0.5s. | src/Weir/Script.fs PendBuf + applyJoin/bufToLL/bufNew/wrapFrom + endsInBinderHeadSeg/bracketFold index-based with-header; tests/Weir.Tests assembler pins (unchanged, all green); ci/e2e.sh hostile-input performance fixture (60k continuation flood + 200k bracket-opener single line assert linear settle); CHANGELOG v0.0.48 Fixed | | spawn-nul-funnel | 2026-09-21 | THE NUL REFUSAL MOVES TO THE SPAWN FUNNEL — completes [D:nul-boundary] (vuln-0003): that fix put the NUL refusal in the EVALUATOR's command-statement/pipe constructors (`Eval.argvOf`/`overlayOf`), but FOUR other spawn paths assemble argv/env DOWNSTREAM and skipped it, so a NUL-bearing value (from external DATA — a command whose stdout carries a NUL, `File.read`, base64→bytes) silently TRUNCATED at execve: the child saw the prefix, exit 0, no diagnostic. THE FOUR: the reifier builtins (`\| complete`/`succeeds`/`exitCode`/`orFail` — Builtins builds prog/argv/overlay and calls `Proc.completeWith`/`streamCode` directly), the ambient `within env` overlay (applied in `Proc.spawn` from `Session.envOverlay()`, never through `overlayOf`) and its `$e(...)` twin, `into` (a NUL in the `sh -c` cmdline arg), and the dynamic head `^$name` (WORST — a NUL-bearing head resolves through PATH to the PREFIX program and runs it with the remaining words as argv; the reified head rides `checkDynHead` as a plain string, skipping `progOf`'s own `noNul`). RULED: move the invariant to `Proc.spawn` — the ONE point every process start funnels through (`start`→`spawn`; `chainLinesOf` and `startSpilled` call `spawn` directly; corroborated by [D:plan-proc-runtime-guard] naming the same funnel) — validating the program name, EVERY argument, and every env KEY and VALUE (the RESOLVED ambient overlay, not just a captured snapshot — the reifier path carries `Ambient=None` and the live `within env` layer must still be checked), raising the SAME located diagnostic the statement path produced. The four downstream paths inherit the boundary automatically; the statement-path `noNul` stays (first raise wins, no double-raise). Two ADJACENT shapes get weir-shaped diagnostics too: an EMPTY program name ("the command program name is empty — nothing to run") and a NUL-bearing PATH-LIKE program name — which `resolveProg`'s `Path.GetFullPath` turned into a raw "Null character in path" platform exception, so the refusal reaches `resolveProg` as well (it runs before the funnel). | Proc.fs nulRefusal + spawn integrity gate (prog/args/resolved-ambient/env, empty-prog) + resolveProg NUL guard; tests/Weir.Tests boundaryTests funnel pins (each downstream entry + env key/value + program-name/empty/path-like + clean-spawn control + statement-path control); ci/e2e.sh spawn-nul-funnel section (5 hostile scripts refuse no-child + env-key + statement control + clean control); CHANGELOG v0.0.48 Fixed | | yaml-depth | 2026-09-21 | UNBOUNDED RECURSION IN THE YAML SUBSYSTEM — a safe-by-design totality defect (STRIX-6), two sinks with no depth limit in either, the fix mirroring the expression parser's ceiling posture [D:depth-guard]. (1) PARSER: `Yaml.parseDocs`/`parseBlock` recurses once per mapping/sequence nesting level with NO cap, and each map level re-scans its remaining extent — a hostile `a:` ladder (1-space indents) is CUBIC in depth (measured on a JIT build: 3000→4.6s, 4000→10s, 5000→19s), hanging `Yaml.parse`/`from yaml T`/`#infer`/`Yaml.inferShape` and thus `weir check` on a deep-ladder district; external command output piped into a yaml adapter is the realistic hostile source. FIX: thread a `depth` counter through `parseBlock` (incremented per nesting recursion), and past 500 return a LOCATED `Error` "yaml nesting is too deep (limit 500) — the subset reads real manifests, not adversarial ladders", surfacing through the existing `Yaml.parse:`/district/#infer error paths. The residual per-level re-scan (the cubic factor WITHIN the cap) is a stated FOLLOW-UP — the depth bound alone converts the multi-minute hang into a prompt diagnostic (a capped 5000-ladder now errors in ~3s); rewriting the parser to an explicit stack is deferred. (2) EMITTER: `Eval.yamlRender`/`renderSeq` recurses per level with NO cap, so a value built iteratively (a `Seq.fold` nesting a `YSeq` ~100k deep) crashes the process with an UNCATCHABLE StackOverflow (exit 134) on `to yaml` — violating the never-crash-on-hostile-data invariant. FIX: thread a `depth` counter through `yamlRender`, and past 100 `failwith` "to yaml: the value nests deeper than 100 — the emitter needs finite trees" (the `[]`-fallback boundary-error precedent), turning the crash into a clean exit-1 diagnostic. the parser cap 500 and the emitter cap 100 (the emitter's heavy frames overflow a small-stack thread at ~600 levels, so it matches the show renderer's bound, NOT 1000) sit far above real manifests (kubectl nests ~10) and below their crash floors; legal 250-deep docs parse and ~50-deep values render unchanged. | Yaml.fs maxDepth + parseBlock depth param/guard (+ parseDocs entry at 0); Eval.fs yamlMaxDepth + yamlRender depth param/guard (+ yamlToLines entry at 0); tests/Weir.Tests (deep-parse→cap diagnostic + 250-deep parses; deep-emit→cap diagnostic + 250-deep renders); ci/e2e.sh STRIX-6 cells; CHANGELOG v0.0.48 Fixed | +| path-home | 2026-09-24 | `Path.home` + THE XDG TRIO (`Path.configHome`/`Path.stateHome`/`Path.cacheHome`) — the TYPED stand-in for `~`/`$HOME`, which never expand in argv ([D:path-glob]'s no-expansion law: nothing in a word expands, ever). THE TRIGGER: `cat ~/.local/state/weir/history` silently passed `~` as a literal word (correct by the law, surprising to the user), and the only working spelling was hard-coding the absolute path — so a script that wants the home dir had no readable idiom. RULED: four pure `unit -> string` queries (the `Path.tempRoot ()` call shape [D:gap-a-remainder]), platform-native output ([D:windows-v1]'s Path-members-are-native ruling), no trailing separator. `home` = the user profile (`SpecialFolder.UserProfile`, both platforms). The XDG trio REUSES the exact resolution the REPL history file already used (the Windows `%APPDATA%`/`%LOCALAPPDATA%` vs POSIX `XDG_*`-or-`~` split from [D:windows-v1]) — now hoisted to shared `Builtins.homeDir`/`configDir`/`stateDir`/`cacheDir` so the members and the REPL's own history path read the ONE source (Repl.fs delegates, no drift). The idiom: `cat $"{Path.home ()}/.bashrc"` / `cat $"{Path.stateHome ()}/weir/history"`. NO `~`-expansion added (the law stands); these are the visible, injection-proof alternative. | src/Weir/Builtins.fs homeDir/configDir/stateDir/cacheDir + pathMembers home/configHome/stateHome/cacheHome + builtinDocs entries; src/Weir/Repl.fs configHome/stateHome delegate to Builtins; tests/Weir.Tests/Tests.fs "Path.home + XDG dirs resolve"; skills/weir/SKILL.md Path list; CHANGELOG v0.0.50 | | eq-depth | 2026-09-21 | VALUE EQUALITY IS ITERATIVE (STRIX-2 / vuln-0006): a checker-accepted, legally-built recursive-record value (an Option-linked record folded ~100k deep via `Seq.fold`) crashed the whole process with an uncatchable StackOverflow on `==`, because `Value.Equals` recursed one stack frame per nesting level. RULED: the equality walk carries an explicit heap work-list of pending `(Value * Value)` pairs instead — scalars compare in place; VRecord (order-insensitive [D:record-order]), VUnion payloads, VTuple, and VMap entry values QUEUE their children; VSeq compares LOCKSTEP via enumerators (never materialize two lists, short-circuit at the first mismatch — the Seq.equal discipline); a mismatch drains the list. Every prior equality semantic is preserved (closures/builtins/proc/server by reference; bytes structural; floats [D:floats]). The `show`/interpolation renderer (`formatValue`) shared the same recursive-crash class — it now carries a finite MaxDepth (100, past the ~11 corpus max) with a teaching ellipsis, matching the REPL echo's existing depth bound. THE STANDING RULE: any new Value-walking helper must walk iteratively or carry a depth bound. | Eval.fs Value.Equals (work-list) + showLimits.MaxDepth; tests/Weir.Tests typeClassTests (200k-deep VRecord compares true, deep-vs-shallow false, 200k VSeq lockstep true / tail-mismatch false); CHANGELOG v0.0.48 Fixed | diff --git a/skills/weir/SKILL.md b/skills/weir/SKILL.md index f34cad78..d88cd746 100644 --- a/skills/weir/SKILL.md +++ b/skills/weir/SKILL.md @@ -2240,7 +2240,7 @@ not the teaching. - `Map`: `add` `count` `get` `has` `keys` `ofPairs` `pairs` `remove` `tryGet` `values` - `Net`: `portOpen` - `Option`: `defaultValue` `defaultWith` `iter` `map` `orElse` -- `Path`: `combine` `dir` `extension` `fileName` `glob` `newTempDir` `stem` `tempRoot` `under` +- `Path`: `cacheHome` `combine` `configHome` `dir` `extension` `fileName` `glob` `home` `newTempDir` `stateHome` `stem` `tempRoot` `under` — `home`/`configHome`/`stateHome`/`cacheHome` (each `unit -> string`) are the typed stand-in for `~`/`$HOME`, which never expand in argv: `cat $"{Path.home ()}/.bashrc"` - `Poll`: `defaults` - `Proc`: `pid` `running` `stop` `tail` `wait` - `Server`: `port` `running` diff --git a/src/Weir/Builtins.fs b/src/Weir/Builtins.fs index f53029a3..e9c48a66 100644 --- a/src/Weir/Builtins.fs +++ b/src/Weir/Builtins.fs @@ -2107,6 +2107,39 @@ let private pathNormalize (p: string) : string = elif body = "" then "." else body +// home + XDG dirs [D:path-home], the ONE implementation the REPL's +// config/state paths also use: Windows maps to SpecialFolder +// (%APPDATA% / %LOCALAPPDATA%), POSIX to the XDG_* var else the ~ +// fallback. Re-read per call — the environment can change. These replace +// the argv-expansion weir does NOT do (no `~`, no `$HOME`): a typed value +// to interpolate, injection-proof by construction. +let xdgDir (var: string) (fallback: string) : string = + match System.Environment.GetEnvironmentVariable var with + | null + | "" -> System.IO.Path.Combine(System.Environment.GetFolderPath System.Environment.SpecialFolder.UserProfile, fallback) + | v -> v + +let homeDir () : string = + System.Environment.GetFolderPath System.Environment.SpecialFolder.UserProfile + +let configDir () : string = + if System.OperatingSystem.IsWindows() then + System.Environment.GetFolderPath System.Environment.SpecialFolder.ApplicationData + else + xdgDir "XDG_CONFIG_HOME" ".config" + +let stateDir () : string = + if System.OperatingSystem.IsWindows() then + System.Environment.GetFolderPath System.Environment.SpecialFolder.LocalApplicationData + else + xdgDir "XDG_STATE_HOME" ".local/state" + +let cacheDir () : string = + if System.OperatingSystem.IsWindows() then + System.Environment.GetFolderPath System.Environment.SpecialFolder.LocalApplicationData + else + xdgDir "XDG_CACHE_HOME" ".cache" + let private pathMembers: (string * Ty * Value) list = [ "extension", TFun(TStr, TStr), str1 "extension" Path.GetExtension "fileName", TFun(TStr, TStr), str1 "fileName" Path.GetFileName @@ -2121,6 +2154,12 @@ let private pathMembers: (string * Ty * Value) list = "normalize", TFun(TStr, TStr), str1 "normalize" pathNormalize "under", TFun(TStr, TFun(TStr, TStr)), pathUnderImpl "glob", TFun(TStr, TSeq TStr), globImpl + // home + XDG dirs [D:path-home] — the typed replacement for `~`/`$HOME` + // (weir expands nothing in argv): `cat $"{Path.home ()}/.bashrc"` + "home", TFun(TUnit, TStr), VBuiltin(fun _ -> VStr(homeDir ())) + "configHome", TFun(TUnit, TStr), VBuiltin(fun _ -> VStr(configDir ())) + "stateHome", TFun(TUnit, TStr), VBuiltin(fun _ -> VStr(stateDir ())) + "cacheHome", TFun(TUnit, TStr), VBuiltin(fun _ -> VStr(cacheDir ())) // the QUERY (pure): the system temp root, no trailing separator "tempRoot", TFun(TUnit, TStr), @@ -5113,6 +5152,30 @@ let builtinDocs: Map = (Some "Path.glob \"*.nope123\" |> Seq.freeze") None |> named [ "pattern" ] + "Path.home", + (bd + "The user's home directory (a pure query; no trailing separator, platform-native). The typed stand-in for `~`/`$HOME`, which never expand in argv — build a path with `$\"{Path.home ()}/.bashrc\"`." + (Some "Path.home ()") + None + |> named [ "()" ]) + "Path.configHome", + (bd + "The base directory for user config: `$XDG_CONFIG_HOME` (or `~/.config`) on POSIX, `%APPDATA%` on Windows." + (Some "Path.configHome ()") + None + |> named [ "()" ]) + "Path.stateHome", + (bd + "The base directory for user state: `$XDG_STATE_HOME` (or `~/.local/state`) on POSIX, `%LOCALAPPDATA%` on Windows. Where the REPL keeps its history." + (Some "Path.stateHome ()") + None + |> named [ "()" ]) + "Path.cacheHome", + (bd + "The base directory for user cache: `$XDG_CACHE_HOME` (or `~/.cache`) on POSIX, `%LOCALAPPDATA%` on Windows." + (Some "Path.cacheHome ()") + None + |> named [ "()" ]) // ---- File (read/write touch the filesystem — no inline example) ---- "File.exists", diff --git a/src/Weir/Repl.fs b/src/Weir/Repl.fs index 19e17a8f..6fde6109 100644 --- a/src/Weir/Repl.fs +++ b/src/Weir/Repl.fs @@ -408,25 +408,10 @@ type private ReplConfig = // wiring exists now (the session cap), so the key is real again EchoElems: int } -let private xdgHome (var: string) (fallback: string) = - match Environment.GetEnvironmentVariable var with - | null - | "" -> Path.Combine(Environment.GetFolderPath Environment.SpecialFolder.UserProfile, fallback) - | v -> v - -// Windows has no XDG: config -> %APPDATA%, state -> %LOCALAPPDATA% -// [D:windows-v1]. POSIX unchanged (XDG var, else ~/.config | ~/.local/state). -let private configHome () = - if OperatingSystem.IsWindows() then - Environment.GetFolderPath Environment.SpecialFolder.ApplicationData - else - xdgHome "XDG_CONFIG_HOME" ".config" - -let private stateHome () = - if OperatingSystem.IsWindows() then - Environment.GetFolderPath Environment.SpecialFolder.LocalApplicationData - else - xdgHome "XDG_STATE_HOME" ".local/state" +// config/state dirs come from Builtins [D:path-home] — the ONE impl the +// Path.home/configHome/stateHome members also expose (was duplicated here) +let private configHome () = Builtins.configDir () +let private stateHome () = Builtins.stateDir () let private defaultConfig = { HistorySize = 5000 diff --git a/tests/Weir.Tests/Tests.fs b/tests/Weir.Tests/Tests.fs index bba34a73..b2ac3136 100644 --- a/tests/Weir.Tests/Tests.fs +++ b/tests/Weir.Tests/Tests.fs @@ -12131,6 +12131,23 @@ let agentFindingsTests = expectValue "Path.normalize \"/a/../../b\"" (VStr "/b") expectValue "Path.combine \"/repo/src/App\" \"../Core/Core.csproj\" |> Path.normalize" (VStr "/repo/src/Core/Core.csproj") } + test "Path.home + XDG dirs resolve — the typed stand-in for ~/$HOME [D:path-home]" { + // each is unit -> string, non-empty; the XDG dirs live under + // home (POSIX default / Windows profile), and stateHome is + // exactly where the REPL keeps history + let asStr what v = + match v with + | VStr s -> s + | other -> failtestf "%s: expected VStr, got %A" what other + + let home = asStr "home" (run "Path.home ()") + Expect.isNotEmpty home "home resolves" + + for m in [ "configHome"; "stateHome"; "cacheHome" ] do + let d = asStr m (run $"Path.{m} ()") + Expect.isNotEmpty d $"{m} resolves" + Expect.stringContains d home $"{m} sits under home" + } test "Path.under confines; Path.combine does not [D:path-under]" { // RUNS ON EVERY PLATFORM. An earlier skipOnWindows left this member with // ZERO Windows coverage and pointed at an e2e row that did not exist; the From 12c31135172c0fd346b33bb300da1e3094c3ed70 Mon Sep 17 00:00:00 2001 From: queil <4584075+queil@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:42:04 +0000 Subject: [PATCH 04/12] repl: add #history [N] directive (dumps history, prints the file path) --- CHANGELOG.md | 6 +++ docs/DECISIONS.md | 1 + docs/reference/lexical.md | 1 + docs/repl.md | 14 +++++++ skills/weir/SKILL.md | 6 ++- src/Weir/Complete.fs | 2 +- src/Weir/Repl.fs | 34 ++++++++++++++++ tests/Weir.Tests/Tests.fs | 4 +- tests/repl/repl-directives.py | 74 ++++++++++++++++++++++++++++++++++- 9 files changed, 136 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d93c58a5..df0f6926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ (falling back to `~/.config`, `~/.local/state`, `~/.cache`) on POSIX — the same resolution the REPL uses for its own history file. +- **`#history` REPL directive.** Shows the session's history with the + **file path in the header** (a quick way to find where history lives, + since `~` never expands): bare `#history` dumps every entry numbered, + `#history N` shows the last N. Entries render one per line, matching the + history search's display. + ### Fixed - **`#infer` no longer mis-drafts a heterogeneous object as a homogeneous diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 444421ab..5d482625 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -486,5 +486,6 @@ the old key, never an edit-in-place. | assemble-quadratic | 2026-09-21 | THE LINE ASSEMBLER SCALES LINEARLY — an algorithmic-DoS cluster (four O(N²) paths in `Script.fs`'s `assemble` fold, each confirmed to stall `weir check`/`fmt`/REPL/LSP on crafted hundreds-of-KB inputs). ROOT: the pending statement's text was an IMMUTABLE string rebuilt per continuation join (`ll.Text + sep + piece`, plus per-line whole-text `TrimEnd`/`LastIndexOf`/`Substring` queries), so N joins cost O(N²). RULED: the pending statement carries a `PendBuf` — a single `StringBuilder` mutated in place plus incrementally-maintained indexes (last-non-white for the bracket-dangle `EndsWith`/last-char predicates; last-segment-start for the `within proc`/`within serve` binder-head scan, with a `Contains` fast reject; a byte-index with-header check replacing the per-opener `Substring`) — materialized to the immutable `LogicalLine.Text` ONCE at statement close (`bufToLL`). Span arithmetic stays BYTE-IDENTICAL: `applyJoin`'s `joinedStart` derives from `bufLen` at exactly the point the old code read `ll.Text.Length`, the separator literals are unchanged, and `wrapFrom` (paren-wrap a compound) does the same `(`/`)` insert via `StringBuilder.Insert`/`Append` with the same segment-shift. A PURE performance fix — the emitted assembly diagnostic and the segment/translate tables are unchanged (fmt round-trip + checker output byte-identical on the whole `.weir` corpus). Measured: continuation flood 80k lines 9.3s→1.3s; bareword `within proc` marker 80k lines 24.1s→3.6s; bracket-heavy single line 400k `{}` openers 29s→0.5s. | src/Weir/Script.fs PendBuf + applyJoin/bufToLL/bufNew/wrapFrom + endsInBinderHeadSeg/bracketFold index-based with-header; tests/Weir.Tests assembler pins (unchanged, all green); ci/e2e.sh hostile-input performance fixture (60k continuation flood + 200k bracket-opener single line assert linear settle); CHANGELOG v0.0.48 Fixed | | spawn-nul-funnel | 2026-09-21 | THE NUL REFUSAL MOVES TO THE SPAWN FUNNEL — completes [D:nul-boundary] (vuln-0003): that fix put the NUL refusal in the EVALUATOR's command-statement/pipe constructors (`Eval.argvOf`/`overlayOf`), but FOUR other spawn paths assemble argv/env DOWNSTREAM and skipped it, so a NUL-bearing value (from external DATA — a command whose stdout carries a NUL, `File.read`, base64→bytes) silently TRUNCATED at execve: the child saw the prefix, exit 0, no diagnostic. THE FOUR: the reifier builtins (`\| complete`/`succeeds`/`exitCode`/`orFail` — Builtins builds prog/argv/overlay and calls `Proc.completeWith`/`streamCode` directly), the ambient `within env` overlay (applied in `Proc.spawn` from `Session.envOverlay()`, never through `overlayOf`) and its `$e(...)` twin, `into` (a NUL in the `sh -c` cmdline arg), and the dynamic head `^$name` (WORST — a NUL-bearing head resolves through PATH to the PREFIX program and runs it with the remaining words as argv; the reified head rides `checkDynHead` as a plain string, skipping `progOf`'s own `noNul`). RULED: move the invariant to `Proc.spawn` — the ONE point every process start funnels through (`start`→`spawn`; `chainLinesOf` and `startSpilled` call `spawn` directly; corroborated by [D:plan-proc-runtime-guard] naming the same funnel) — validating the program name, EVERY argument, and every env KEY and VALUE (the RESOLVED ambient overlay, not just a captured snapshot — the reifier path carries `Ambient=None` and the live `within env` layer must still be checked), raising the SAME located diagnostic the statement path produced. The four downstream paths inherit the boundary automatically; the statement-path `noNul` stays (first raise wins, no double-raise). Two ADJACENT shapes get weir-shaped diagnostics too: an EMPTY program name ("the command program name is empty — nothing to run") and a NUL-bearing PATH-LIKE program name — which `resolveProg`'s `Path.GetFullPath` turned into a raw "Null character in path" platform exception, so the refusal reaches `resolveProg` as well (it runs before the funnel). | Proc.fs nulRefusal + spawn integrity gate (prog/args/resolved-ambient/env, empty-prog) + resolveProg NUL guard; tests/Weir.Tests boundaryTests funnel pins (each downstream entry + env key/value + program-name/empty/path-like + clean-spawn control + statement-path control); ci/e2e.sh spawn-nul-funnel section (5 hostile scripts refuse no-child + env-key + statement control + clean control); CHANGELOG v0.0.48 Fixed | | yaml-depth | 2026-09-21 | UNBOUNDED RECURSION IN THE YAML SUBSYSTEM — a safe-by-design totality defect (STRIX-6), two sinks with no depth limit in either, the fix mirroring the expression parser's ceiling posture [D:depth-guard]. (1) PARSER: `Yaml.parseDocs`/`parseBlock` recurses once per mapping/sequence nesting level with NO cap, and each map level re-scans its remaining extent — a hostile `a:` ladder (1-space indents) is CUBIC in depth (measured on a JIT build: 3000→4.6s, 4000→10s, 5000→19s), hanging `Yaml.parse`/`from yaml T`/`#infer`/`Yaml.inferShape` and thus `weir check` on a deep-ladder district; external command output piped into a yaml adapter is the realistic hostile source. FIX: thread a `depth` counter through `parseBlock` (incremented per nesting recursion), and past 500 return a LOCATED `Error` "yaml nesting is too deep (limit 500) — the subset reads real manifests, not adversarial ladders", surfacing through the existing `Yaml.parse:`/district/#infer error paths. The residual per-level re-scan (the cubic factor WITHIN the cap) is a stated FOLLOW-UP — the depth bound alone converts the multi-minute hang into a prompt diagnostic (a capped 5000-ladder now errors in ~3s); rewriting the parser to an explicit stack is deferred. (2) EMITTER: `Eval.yamlRender`/`renderSeq` recurses per level with NO cap, so a value built iteratively (a `Seq.fold` nesting a `YSeq` ~100k deep) crashes the process with an UNCATCHABLE StackOverflow (exit 134) on `to yaml` — violating the never-crash-on-hostile-data invariant. FIX: thread a `depth` counter through `yamlRender`, and past 100 `failwith` "to yaml: the value nests deeper than 100 — the emitter needs finite trees" (the `[]`-fallback boundary-error precedent), turning the crash into a clean exit-1 diagnostic. the parser cap 500 and the emitter cap 100 (the emitter's heavy frames overflow a small-stack thread at ~600 levels, so it matches the show renderer's bound, NOT 1000) sit far above real manifests (kubectl nests ~10) and below their crash floors; legal 250-deep docs parse and ~50-deep values render unchanged. | Yaml.fs maxDepth + parseBlock depth param/guard (+ parseDocs entry at 0); Eval.fs yamlMaxDepth + yamlRender depth param/guard (+ yamlToLines entry at 0); tests/Weir.Tests (deep-parse→cap diagnostic + 250-deep parses; deep-emit→cap diagnostic + 250-deep renders); ci/e2e.sh STRIX-6 cells; CHANGELOG v0.0.48 Fixed | +| repl-history | 2026-09-24 | `#history [N]` — the REPL shows its own history, PATH FIRST. THE TRIGGER: a user wanting to `cat` history had no way to find the file (`~` never expands in argv [D:path-glob], and the location is `$XDG_STATE_HOME/weir/history` or the Windows `%LOCALAPPDATA%` split [D:windows-v1] — not memorable), so "where does history live" had no in-tool answer. RULED a SESSION DIRECTIVE (not a builtin) — it reads the REPL's live in-memory `history`, tooling state, so it belongs with `#find`/`#save` in [D:repl-directives]' one source (`Complete.sessionDirectives`, so the dispatch string-match, `#help`, completion, and the did-you-mean pool all learn it at once). THE HEADER NAMES THE FILE (`history at (N entries)`) — the point is discovery, so the path leads; bare `#history` dumps ALL (cat parity), `#history N` the last N (tail), a non-positive/non-int arg teaches (the `#echo` shape). Entries render DECODED and numbered by real position, one per line via `displayEntry` (a multi-line entry stays one greppable line — the fzf display form [D:repl-multiline]). The in-memory `history` is the source, so the dump reflects THIS session — and a tty records every submitted line including the `#history` directive itself (bash-style; the test's counts account for it). | src/Weir/Repl.fs historyDirective + dispatch + #help list; src/Weir/Complete.fs sessionDirectives; tests/repl/repl-directives.py #history block (path header, count, bare-all, tail-n, help-listed, typo did-you-mean); tests/Weir.Tests/Tests.fs directive-set pins (bare '#' slot, empty-prompt); docs/repl.md; CHANGELOG v0.0.50 | | path-home | 2026-09-24 | `Path.home` + THE XDG TRIO (`Path.configHome`/`Path.stateHome`/`Path.cacheHome`) — the TYPED stand-in for `~`/`$HOME`, which never expand in argv ([D:path-glob]'s no-expansion law: nothing in a word expands, ever). THE TRIGGER: `cat ~/.local/state/weir/history` silently passed `~` as a literal word (correct by the law, surprising to the user), and the only working spelling was hard-coding the absolute path — so a script that wants the home dir had no readable idiom. RULED: four pure `unit -> string` queries (the `Path.tempRoot ()` call shape [D:gap-a-remainder]), platform-native output ([D:windows-v1]'s Path-members-are-native ruling), no trailing separator. `home` = the user profile (`SpecialFolder.UserProfile`, both platforms). The XDG trio REUSES the exact resolution the REPL history file already used (the Windows `%APPDATA%`/`%LOCALAPPDATA%` vs POSIX `XDG_*`-or-`~` split from [D:windows-v1]) — now hoisted to shared `Builtins.homeDir`/`configDir`/`stateDir`/`cacheDir` so the members and the REPL's own history path read the ONE source (Repl.fs delegates, no drift). The idiom: `cat $"{Path.home ()}/.bashrc"` / `cat $"{Path.stateHome ()}/weir/history"`. NO `~`-expansion added (the law stands); these are the visible, injection-proof alternative. | src/Weir/Builtins.fs homeDir/configDir/stateDir/cacheDir + pathMembers home/configHome/stateHome/cacheHome + builtinDocs entries; src/Weir/Repl.fs configHome/stateHome delegate to Builtins; tests/Weir.Tests/Tests.fs "Path.home + XDG dirs resolve"; skills/weir/SKILL.md Path list; CHANGELOG v0.0.50 | | eq-depth | 2026-09-21 | VALUE EQUALITY IS ITERATIVE (STRIX-2 / vuln-0006): a checker-accepted, legally-built recursive-record value (an Option-linked record folded ~100k deep via `Seq.fold`) crashed the whole process with an uncatchable StackOverflow on `==`, because `Value.Equals` recursed one stack frame per nesting level. RULED: the equality walk carries an explicit heap work-list of pending `(Value * Value)` pairs instead — scalars compare in place; VRecord (order-insensitive [D:record-order]), VUnion payloads, VTuple, and VMap entry values QUEUE their children; VSeq compares LOCKSTEP via enumerators (never materialize two lists, short-circuit at the first mismatch — the Seq.equal discipline); a mismatch drains the list. Every prior equality semantic is preserved (closures/builtins/proc/server by reference; bytes structural; floats [D:floats]). The `show`/interpolation renderer (`formatValue`) shared the same recursive-crash class — it now carries a finite MaxDepth (100, past the ~11 corpus max) with a teaching ellipsis, matching the REPL echo's existing depth bound. THE STANDING RULE: any new Value-walking helper must walk iteratively or carry a depth bound. | Eval.fs Value.Equals (work-list) + showLimits.MaxDepth; tests/Weir.Tests typeClassTests (200k-deep VRecord compares true, deep-vs-shallow false, 200k VSeq lockstep true / tail-mismatch false); CHANGELOG v0.0.48 Fixed | diff --git a/docs/reference/lexical.md b/docs/reference/lexical.md index e332c076..0324eeec 100644 --- a/docs/reference/lexical.md +++ b/docs/reference/lexical.md @@ -310,6 +310,7 @@ three contexts: | `#echo` | the unforced-echo cap | REPL prompt, runs now | | `#infer` | draft named types from a JSON/YAML sample | REPL prompt, runs now | | `#save` | distill the session to a runnable script | REPL prompt, runs now | +| `#history` | show history (`#history` all, `#history N` last N); prints the file path | REPL prompt, runs now | | `#quit` | leave the REPL | REPL prompt, runs now | The [REPL manual](../repl.md) covers the prompt set and the init diff --git a/docs/repl.md b/docs/repl.md index 533d4530..194d82ed 100644 --- a/docs/repl.md +++ b/docs/repl.md @@ -166,6 +166,20 @@ time; session directives (`#help`, `#quit`) run now. One glyph, two lifetimes — the [reference table](reference/lexical.md#directives) maps every directive to its context. +## History + +`#history` shows the session's history, the **file path first** — +`history at (N entries)` — because nothing in argv expands +(`~` is a literal word), so the path is the answer to "where does +history live". Bare `#history` dumps every entry, numbered; +`#history 20` shows the last twenty. Each entry renders on one line +(a multi-line entry joins with `⏎`, so it stays greppable), the way +the history search displays them. The history file itself lives at +`$XDG_STATE_HOME/weir/history` (else `~/.local/state/weir/history`; +`%LOCALAPPDATA%\weir\` on Windows), created `0600` — a REPL line can +carry a secret. `Path.stateHome ()` computes that base if you want to +read it from a script. + ## The last result: `it` Expressions and commands rebind `it` — always, unit included (FSI's diff --git a/skills/weir/SKILL.md b/skills/weir/SKILL.md index d88cd746..c4520de5 100644 --- a/skills/weir/SKILL.md +++ b/skills/weir/SKILL.md @@ -1575,8 +1575,10 @@ type Bad = C of int `#help ` glances one member per line (name + its doc's first line) and `#find [query]` fuzzy-searches modules and members [D:help-find] (fzf with a live doc preview at a tty; a substring - fallback piped/without fzf). All REPL scaffolding — see - docs/repl.md. + fallback piped/without fzf). `#history [N]` shows history (bare = + all, `N` = last N) with the file path in its header — the quick way + to find where history lives, since `~` never expands. All REPL + scaffolding — see docs/repl.md. ```weir let sample = ["{\"id\": 1, \"tags\": [\"a\"]}"] diff --git a/src/Weir/Complete.fs b/src/Weir/Complete.fs index 09ff8157..5248235e 100644 --- a/src/Weir/Complete.fs +++ b/src/Weir/Complete.fs @@ -24,7 +24,7 @@ let private keywords = Weir.Parser.keywords - unsuggestedKeywords |> Set.toList // first. Both completion slots read this: the line-head '#' slot (bare // names — the editor's word starts after the '#') and the empty-prompt // head [D:empty-prompt-directives] (the '#'-prefixed teaching set). -let sessionDirectives = [ "help"; "find"; "echo"; "infer"; "save"; "quit" ] +let sessionDirectives = [ "help"; "find"; "echo"; "infer"; "save"; "history"; "quit" ] // the `#help` DOCUMENTABLE universe, one source [D:help-arg-complete]: // the bare names `#help ` can DOCUMENT — modules, top-level diff --git a/src/Weir/Repl.fs b/src/Weir/Repl.fs index 6fde6109..385647b7 100644 --- a/src/Weir/Repl.fs +++ b/src/Weir/Repl.fs @@ -662,6 +662,36 @@ let private appendHistory (entry: string) = with _ -> () +// #history [N] [D:repl-history]: dump the session's history with the +// file's PATH in the header — a user cannot cat what they cannot find, +// and nothing in argv expands (`~` is a literal), so the path IS the +// answer to "where does history live". Entries render DECODED, one per +// line via displayEntry (a multi-line entry stays one greppable line — +// the fzf display form), numbered by their real position. The in-memory +// `history` is the source, so the dump reflects THIS session including +// the line-per-entry appends not yet load-capped. Bare = all (cat +// parity); a positive N = the last N (tail). +let private historyDirective (arg: string) : string = + let render (startIdx: int) = + if history.Count = 0 then + $"history at {historyFile} (empty)" + else + let width = history.Count.ToString().Length + + let lines = + [ for i in startIdx .. history.Count - 1 -> + let n = (i + 1).ToString().PadLeft width + $" {n} {displayEntry history[i]}" ] + + String.concat "\n" ($"history at {historyFile} ({history.Count} entries)" :: lines) + + match arg.Trim() with + | "" -> render 0 + | a -> + match Int32.TryParse a with + | true, n when n > 0 -> render (max 0 (history.Count - n)) + | _ -> "#history takes a positive count — e.g. #history 20 (bare = all)" + // Ctrl+Left/Right navigation; '.' stays a separator here (unlike // completion's wordStartAt) so field chains hop segment by segment let private isWordChar (c: char) = Char.IsLetterOrDigit c || c = '_' @@ -1973,6 +2003,7 @@ let private helpDirective (color: bool) (te: TypeEnv) (arg: string) : string = + " #infer [] from as \n" + " // draft named types from a sample (src defaults to 'it')\n" + " #save // dump the session's accepted lines to a runnable .weir\n" + + " #history [] // show history (bare = all, = last n); prints the file path\n" + " #alias [name = cmd …] // bare lists; a command-head alias (init.weir is canonical)\n" + " #quit // leave the REPL (Ctrl+D works too)\n\n" + "Modules:\n" @@ -2706,6 +2737,9 @@ let rec private loop (state: State) = elif t = "#save" || t.StartsWith "#save " then saveDirective state (t.Substring(5).Trim()) loop state + elif t = "#history" || t.StartsWith "#history " then + Console.WriteLine(historyDirective (t.Substring(8).Trim())) + loop state elif t = "#alias" || t.StartsWith "#alias " then // a live command-head alias [D:command-head-alias]. init.weir is // the canonical place; a bare `#alias` LISTS the table, a diff --git a/tests/Weir.Tests/Tests.fs b/tests/Weir.Tests/Tests.fs index b2ac3136..8aa54165 100644 --- a/tests/Weir.Tests/Tests.fs +++ b/tests/Weir.Tests/Tests.fs @@ -3287,7 +3287,7 @@ let completionTests = // replacement yields `#help` — never `##help` or `head` // the closed set is the one source Complete.sessionDirectives // — the '#'-slot and the empty-prompt head both read it - Expect.equal (suggest "#" 1) [ "echo"; "find"; "help"; "infer"; "quit"; "save" ] "the closed set" + Expect.equal (suggest "#" 1) [ "echo"; "find"; "help"; "history"; "infer"; "quit"; "save" ] "the closed set" Expect.equal (suggest "#he" 1) [ "help" ] "the prefix filters" Expect.equal (suggest "#q" 1) [ "quit" ] "" Expect.isFalse (List.contains "head" (suggest "#he" 1)) "the general pool stays out" @@ -3874,7 +3874,7 @@ let completionTests = // `suggest "" 0` used to return 1130 (954 PATH execs + the // universe) via `StartsWith ""`. A fresh Tab now teaches the // REPL's affordances, `#help` first. - Expect.equal (suggest "" 0) [ "#help"; "#find"; "#echo"; "#infer"; "#save"; "#quit" ] "the curated directive set" + Expect.equal (suggest "" 0) [ "#help"; "#find"; "#echo"; "#infer"; "#save"; "#history"; "#quit" ] "the curated directive set" // filtered completion is unaffected (a real prefix at a head). // Assert only environment-stable facts: the `File` MODULE is diff --git a/tests/repl/repl-directives.py b/tests/repl/repl-directives.py index ccf286a9..638c9336 100755 --- a/tests/repl/repl-directives.py +++ b/tests/repl/repl-directives.py @@ -9,6 +9,7 @@ import select import subprocess import sys +import tempfile import time WEIR = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/.local/bin/weir") @@ -325,6 +326,77 @@ def drain(t): if "WriteFile" in t: failures.append(f"a constructor (WriteFile) must not complete at a statement head: {t[-300:]!r}") +# --- #history [D:repl-history]: shows the entries with the file PATH in +# the header (a user cannot cat what they cannot find; `~` never expands), +# bare = all, = the last n. Only the TTY path records (piped input is +# not the user's history), so this must be a pty session; a FRESH HOME per +# session makes the count deterministic. A tty records EVERY submitted +# line, the `#history` directive included (bash-style), so the count and +# the tail account for the directive line itself. --------------------- +def pty_history(lines, settle=0.6): + home = tempfile.mkdtemp(prefix="weir-hist-") + pid, fd = pty.fork() + if pid == 0: + os.environ["HOME"] = home + # scrub XDG so stateHome resolves under the clean HOME + for k in ("XDG_STATE_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"): + os.environ.pop(k, None) + os.execv(WEIR, ["weir"]) + time.sleep(0.8) + out = b"" + + def drain(t): + nonlocal out + deadline = time.time() + t + while time.time() < deadline: + r, _, _ = select.select([fd], [], [], 0.1) + if r: + try: + out += os.read(fd, 65536) + except OSError: + return + + segs = [] + for l in lines: + start = len(out) + os.write(fd, (l + "\r").encode()) + drain(settle) + segs.append(re.sub(r"\x1b\[[0-9;]*[A-Za-z]|\x1b=", "", out[start:].decode(errors="replace"))) + os.write(fd, b"\x04") + time.sleep(0.3) + try: + os.close(fd) + except OSError: + pass + os.waitpid(pid, 0) + return segs + + +# bare #history: the three lets plus the `#history` line itself (4 entries) +allseg = pty_history(["let ha = 1", "let hb = 2", "let hc = 3", "#history"])[3] +if "history at " not in allseg or "/weir/history" not in allseg: + failures.append(f"#history must print the file path in its header: {allseg[-300:]!r}") +if "(4 entries)" not in allseg: + failures.append(f"#history header must count the entries (3 lets + the directive): {allseg[-300:]!r}") +for want in ("let ha = 1", "let hb = 2", "let hc = 3"): + if want not in allseg: + failures.append(f"bare #history must dump every entry ({want}): {allseg[-400:]!r}") + +# #history 3 is the tail of [ha, hb, hc, "#history 3"]: hb, hc, the +# directive line — the oldest (ha) is excluded +tailseg = pty_history(["let ha = 1", "let hb = 2", "let hc = 3", "#history 3"])[3] +if "let hb = 2" not in tailseg or "let hc = 3" not in tailseg: + failures.append(f"#history 3 must show the recent entries: {tailseg[-300:]!r}") +if "let ha = 1" in tailseg: + failures.append(f"#history 3 must NOT show the oldest entry (tail): {tailseg[-300:]!r}") + +t = piped("#help\n#quit\n") +if "#history" not in t: + failures.append(f"#help must list #history: {t[-300:]!r}") +t = piped("#hisory\n#quit\n") +if "did you mean" not in t.lower() or "#history" not in t: + failures.append(f"a #history typo must did-you-mean it (dispatch reads sessionDirectives): {t[-200:]!r}") + # --- Ctrl+D still leaves (the pty half) ------------------------------- pid, fd = pty.fork() if pid == 0: @@ -355,4 +427,4 @@ def drain(t): print("repl-directives FAIL:", f) sys.exit(1) -print("repl-directives: #help x3 (one source), glance rendering (member + module blurbs), #find fallback (substring/usage/no-match), #quit + Ctrl+D, :q retired, comments no-op, #echo cap (report/set/all/teach, tty live, piped pinned), unknown-directive message trimmed (#sig/#schema redirect), #alias recognized (list/add/single-hop/help), empty-prompt Tab offers directives, constructor not a head") +print("repl-directives: #help x3 (one source), glance rendering (member + module blurbs), #find fallback (substring/usage/no-match), #quit + Ctrl+D, :q retired, comments no-op, #echo cap (report/set/all/teach, tty live, piped pinned), #history (path header, count, bare-all, tail-n, help-listed, typo did-you-mean), unknown-directive message trimmed (#sig/#schema redirect), #alias recognized (list/add/single-hop/help), empty-prompt Tab offers directives, constructor not a head") From 57781717c53eefda83d96310841f49a2e71f4ee0 Mon Sep 17 00:00:00 2001 From: queil <4584075+queil@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:51:53 +0000 Subject: [PATCH 05/12] checker,parser: warn on Path.newTempDir bound then deleted in-scope (suggest within tmp) --- CHANGELOG.md | 8 +++ docs/DECISIONS.md | 1 + skills/weir/SKILL.md | 6 ++- src/Weir/Check.fs | 88 +++++++++++++++++++++++++++++++ src/Weir/Script.fs | 107 ++++++++++++++++++++++++++++++++++++++ tests/Weir.Tests/Tests.fs | 74 ++++++++++++++++++++++++++ 6 files changed, 283 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df0f6926..d44477c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,14 @@ `#history N` shows the last N. Entries render one per line, matching the history search's display. +- **A checker warning for the `Path.newTempDir` cleanup footgun.** A + `Path.newTempDir ()` binding that is later `Dir.delete`/`Dir.deleteAll`'d + in the same scope now draws an advisory warning pointing at `within tmp`, + which cleans up on scope exit *and* on Ctrl+C/kill (a manual delete + misses the signalled case). `newTempDir` remains the right tool for a + directory that must outlive its scope, so a binding with no in-scope + delete stays silent. Warning severity — `check` still exits 0. + ### Fixed - **`#infer` no longer mis-drafts a heterogeneous object as a homogeneous diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5d482625..d463ab41 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -488,4 +488,5 @@ the old key, never an edit-in-place. | yaml-depth | 2026-09-21 | UNBOUNDED RECURSION IN THE YAML SUBSYSTEM — a safe-by-design totality defect (STRIX-6), two sinks with no depth limit in either, the fix mirroring the expression parser's ceiling posture [D:depth-guard]. (1) PARSER: `Yaml.parseDocs`/`parseBlock` recurses once per mapping/sequence nesting level with NO cap, and each map level re-scans its remaining extent — a hostile `a:` ladder (1-space indents) is CUBIC in depth (measured on a JIT build: 3000→4.6s, 4000→10s, 5000→19s), hanging `Yaml.parse`/`from yaml T`/`#infer`/`Yaml.inferShape` and thus `weir check` on a deep-ladder district; external command output piped into a yaml adapter is the realistic hostile source. FIX: thread a `depth` counter through `parseBlock` (incremented per nesting recursion), and past 500 return a LOCATED `Error` "yaml nesting is too deep (limit 500) — the subset reads real manifests, not adversarial ladders", surfacing through the existing `Yaml.parse:`/district/#infer error paths. The residual per-level re-scan (the cubic factor WITHIN the cap) is a stated FOLLOW-UP — the depth bound alone converts the multi-minute hang into a prompt diagnostic (a capped 5000-ladder now errors in ~3s); rewriting the parser to an explicit stack is deferred. (2) EMITTER: `Eval.yamlRender`/`renderSeq` recurses per level with NO cap, so a value built iteratively (a `Seq.fold` nesting a `YSeq` ~100k deep) crashes the process with an UNCATCHABLE StackOverflow (exit 134) on `to yaml` — violating the never-crash-on-hostile-data invariant. FIX: thread a `depth` counter through `yamlRender`, and past 100 `failwith` "to yaml: the value nests deeper than 100 — the emitter needs finite trees" (the `[]`-fallback boundary-error precedent), turning the crash into a clean exit-1 diagnostic. the parser cap 500 and the emitter cap 100 (the emitter's heavy frames overflow a small-stack thread at ~600 levels, so it matches the show renderer's bound, NOT 1000) sit far above real manifests (kubectl nests ~10) and below their crash floors; legal 250-deep docs parse and ~50-deep values render unchanged. | Yaml.fs maxDepth + parseBlock depth param/guard (+ parseDocs entry at 0); Eval.fs yamlMaxDepth + yamlRender depth param/guard (+ yamlToLines entry at 0); tests/Weir.Tests (deep-parse→cap diagnostic + 250-deep parses; deep-emit→cap diagnostic + 250-deep renders); ci/e2e.sh STRIX-6 cells; CHANGELOG v0.0.48 Fixed | | repl-history | 2026-09-24 | `#history [N]` — the REPL shows its own history, PATH FIRST. THE TRIGGER: a user wanting to `cat` history had no way to find the file (`~` never expands in argv [D:path-glob], and the location is `$XDG_STATE_HOME/weir/history` or the Windows `%LOCALAPPDATA%` split [D:windows-v1] — not memorable), so "where does history live" had no in-tool answer. RULED a SESSION DIRECTIVE (not a builtin) — it reads the REPL's live in-memory `history`, tooling state, so it belongs with `#find`/`#save` in [D:repl-directives]' one source (`Complete.sessionDirectives`, so the dispatch string-match, `#help`, completion, and the did-you-mean pool all learn it at once). THE HEADER NAMES THE FILE (`history at (N entries)`) — the point is discovery, so the path leads; bare `#history` dumps ALL (cat parity), `#history N` the last N (tail), a non-positive/non-int arg teaches (the `#echo` shape). Entries render DECODED and numbered by real position, one per line via `displayEntry` (a multi-line entry stays one greppable line — the fzf display form [D:repl-multiline]). The in-memory `history` is the source, so the dump reflects THIS session — and a tty records every submitted line including the `#history` directive itself (bash-style; the test's counts account for it). | src/Weir/Repl.fs historyDirective + dispatch + #help list; src/Weir/Complete.fs sessionDirectives; tests/repl/repl-directives.py #history block (path header, count, bare-all, tail-n, help-listed, typo did-you-mean); tests/Weir.Tests/Tests.fs directive-set pins (bare '#' slot, empty-prompt); docs/repl.md; CHANGELOG v0.0.50 | | path-home | 2026-09-24 | `Path.home` + THE XDG TRIO (`Path.configHome`/`Path.stateHome`/`Path.cacheHome`) — the TYPED stand-in for `~`/`$HOME`, which never expand in argv ([D:path-glob]'s no-expansion law: nothing in a word expands, ever). THE TRIGGER: `cat ~/.local/state/weir/history` silently passed `~` as a literal word (correct by the law, surprising to the user), and the only working spelling was hard-coding the absolute path — so a script that wants the home dir had no readable idiom. RULED: four pure `unit -> string` queries (the `Path.tempRoot ()` call shape [D:gap-a-remainder]), platform-native output ([D:windows-v1]'s Path-members-are-native ruling), no trailing separator. `home` = the user profile (`SpecialFolder.UserProfile`, both platforms). The XDG trio REUSES the exact resolution the REPL history file already used (the Windows `%APPDATA%`/`%LOCALAPPDATA%` vs POSIX `XDG_*`-or-`~` split from [D:windows-v1]) — now hoisted to shared `Builtins.homeDir`/`configDir`/`stateDir`/`cacheDir` so the members and the REPL's own history path read the ONE source (Repl.fs delegates, no drift). The idiom: `cat $"{Path.home ()}/.bashrc"` / `cat $"{Path.stateHome ()}/weir/history"`. NO `~`-expansion added (the law stands); these are the visible, injection-proof alternative. | src/Weir/Builtins.fs homeDir/configDir/stateDir/cacheDir + pathMembers home/configHome/stateHome/cacheHome + builtinDocs entries; src/Weir/Repl.fs configHome/stateHome delegate to Builtins; tests/Weir.Tests/Tests.fs "Path.home + XDG dirs resolve"; skills/weir/SKILL.md Path list; CHANGELOG v0.0.50 | +| newtempdir-lint | 2026-09-24 | THE `Path.newTempDir` FOOTGUN LINT — a `Path.newTempDir ()` binding then `Dir.delete`/`Dir.deleteAll`'d in the same scope warns, pointing at `within tmp`. THE TRIGGER: agents reach for `let d = Path.newTempDir ()` … `Dir.deleteAll d` (the bash `mktemp`-then-`rm` reflex) when `within tmp d` is the weir spelling — and a STRICTLY better one: `within` removes the directory on scope exit AND on Ctrl+C/kill (the exit hook sweeps it [D:within-scopes]), which a straight-line delete misses when the body raises or the process is signalled. RULED an ADVISORY WARNING, not a gate (severity `warning`, check exits 0) — `newTempDir` is legitimate for the ESCAPING case (a directory that must OUTLIVE the scope, a cross-process handoff [D:gap-a-remainder]), so an UNMATCHED bind (no in-scope delete) stays SILENT: the lint fires only when the bind/delete PAIRING is visible, which is exactly the case that wanted `within`. MECHANISM mirrors [D:reenum-warning] (the whole-file threading precedent, since bind and delete sit statements apart): `Check.tempDirEvents` is the scope-threaded event walk (shadow-aware, drops a name at lambda/match/within/let-pattern boundaries; block-local binders join the tracked set for their body, top-level binders arrive via the tracker's map), and `Script.TempDirTracker` is the per-statement feed / post-fold flush, warning ONCE per binder at its first matched delete (a resolved binder is dropped, so a later stray delete of a reused name does not re-warn). POISONED like the siblings: any errored statement silences the pass (one real error beats advisory noise). Scripts only (a module cannot pair a bind with a delete). Both `Dir.delete` and `Dir.deleteAll` count; a delete of a DIFFERENT directory, or with no newTempDir binding, is silent. | src/Weir/Check.fs isNewTempDirCall + tempDirEvents; src/Weir/Script.fs TempDirTracker + analyzeLines feed/poison/flush (Code "temp-dir-cleanup"); tests/Weir.Tests/Tests.fs tempDirLintTests (deleteAll/delete warn, escaping/within/different-dir/no-binding silent, block-local, one-warning-per-binder, poison); skills/weir/SKILL.md Path.newTempDir note; CHANGELOG v0.0.50 | | eq-depth | 2026-09-21 | VALUE EQUALITY IS ITERATIVE (STRIX-2 / vuln-0006): a checker-accepted, legally-built recursive-record value (an Option-linked record folded ~100k deep via `Seq.fold`) crashed the whole process with an uncatchable StackOverflow on `==`, because `Value.Equals` recursed one stack frame per nesting level. RULED: the equality walk carries an explicit heap work-list of pending `(Value * Value)` pairs instead — scalars compare in place; VRecord (order-insensitive [D:record-order]), VUnion payloads, VTuple, and VMap entry values QUEUE their children; VSeq compares LOCKSTEP via enumerators (never materialize two lists, short-circuit at the first mismatch — the Seq.equal discipline); a mismatch drains the list. Every prior equality semantic is preserved (closures/builtins/proc/server by reference; bytes structural; floats [D:floats]). The `show`/interpolation renderer (`formatValue`) shared the same recursive-crash class — it now carries a finite MaxDepth (100, past the ~11 corpus max) with a teaching ellipsis, matching the REPL echo's existing depth bound. THE STANDING RULE: any new Value-walking helper must walk iteratively or carry a depth bound. | Eval.fs Value.Equals (work-list) + showLimits.MaxDepth; tests/Weir.Tests typeClassTests (200k-deep VRecord compares true, deep-vs-shallow false, 200k VSeq lockstep true / tail-mismatch false); CHANGELOG v0.0.48 Fixed | diff --git a/skills/weir/SKILL.md b/skills/weir/SKILL.md index c4520de5..6de78a5f 100644 --- a/skills/weir/SKILL.md +++ b/skills/weir/SKILL.md @@ -871,7 +871,11 @@ print first an Option-returning step (map without the re-wrap); `Option.flatten` collapses `Option>` to `Option`. `Path.tempRoot ()` is the pure query; `Path.newTempDir ()` CREATES (cleanup is yours — - `within tmp` is the scoped-cleanup spelling). + `within tmp` is the scoped-cleanup spelling). Binding a `newTempDir` + then `Dir.delete`/`Dir.deleteAll`-ing it in the same scope is a + checker WARNING [D:newtempdir-lint] pointing at `within tmp` (which + also cleans up on Ctrl+C/kill); `newTempDir` is for a directory that + must OUTLIVE the scope, so an un-deleted bind is silent. ```weir // bind chains an Option step; flatten collapses one nesting level diff --git a/src/Weir/Check.fs b/src/Weir/Check.fs index 83bc4832..18bd58ea 100644 --- a/src/Weir/Check.fs +++ b/src/Weir/Check.fs @@ -6244,3 +6244,91 @@ let reenumEvents (nextId: unit -> int) (tracked0: Map) (root: Typed walk tracked0 root List.ofSeq acc + +// ---- the newTempDir footgun [D:newtempdir-lint] -------------------------- +// `Path.newTempDir` bound then `Dir.delete`/`Dir.deleteAll`'d in the same +// scope is the MANUAL spelling of a `within tmp d` block — and a worse one: +// `within` removes the directory on scope exit AND on Ctrl+C/kill (the exit +// hook sweeps it), which a straight-line delete misses when the body raises +// or the process is signalled. newTempDir EARNS its place for the escaping +// case (a directory that outlives the block — a cross-process handoff), so +// an UNMATCHED bind (no in-scope delete) is exactly that legitimate use and +// stays silent [D:gap-a-remainder]. The warning fires only when the pairing +// is visible, mirroring the re-enumeration walk's scope discipline. + +/// a `Path.newTempDir ()` call (the arg is unit — ignored) +let isNewTempDirCall (te: TypedExpr) : bool = + match te.Kind with + | TEApp({ Kind = TEVar "Path.newTempDir" }, _) -> true + | _ -> false + +/// a `Dir.delete`/`Dir.deleteAll` applied to a bare name -> Some(name, isAll) +let private dirDeleteOf (te: TypedExpr) : (string * bool) option = + match te.Kind with + | TEApp({ Kind = TEVar "Dir.deleteAll" }, { Kind = TEVar n }) -> Some(n, true) + | TEApp({ Kind = TEVar "Dir.delete" }, { Kind = TEVar n }) -> Some(n, false) + | _ -> None + +type TempDirEvent = + // a block-local newTempDir binder — joins the tracked set for its body + | TempBind of id: int * name: string + // a delete of a tracked binder, at its site + | TempDelete of id: int * name: string * span: Span * isAll: bool + +/// newTempDir binds and their in-scope deletes in one statement tree, +/// source order, shadow-aware — the same threading as [D:reenum-warning]: +/// block-local binders join the tracked set for their body (fresh ids from +/// the caller's well), and a name leaving scope (lambda/match/within/let +/// pattern) drops from tracking. Top-level binders arrive via tracked0. +let tempDirEvents (nextId: unit -> int) (tracked0: Map) (root: TypedExpr) : TempDirEvent list = + let acc = ResizeArray() + + let removeAll (names: string list) (m: Map) = + names |> List.fold (fun m n -> Map.remove n m) m + + let rec walk (tracked: Map) (te: TypedExpr) : unit = + (match dirDeleteOf te with + | Some(n, isAll) -> + match Map.tryFind n tracked with + | Some id -> acc.Add(TempDelete(id, n, te.Span, isAll)) + | None -> () + | None -> ()) + + match te.Kind with + | TELet(n, _, v, b) -> + walk tracked v + + let trackedB = + if isNewTempDirCall v then + let id = nextId () + acc.Add(TempBind(id, n)) + Map.add n id tracked + else + Map.remove n tracked + + walk trackedB b + | TELetPat(pat, v, b) -> + walk tracked v + walk (removeAll (patNameSpans pat |> List.map fst) tracked) b + | TELambda(p, _, b) -> walk (Map.remove p tracked) b + | TELambdaPat(pat, b) -> walk (removeAll (patNameSpans pat |> List.map fst) tracked) b + | TEMatch(s, arms) -> + walk tracked s + + for pat, g, b in arms do + let t' = removeAll (patNameSpans pat |> List.map fst) tracked + g |> Option.iter (walk t') + walk t' b + | TEWithin(_, binder, a, o, b) -> + a |> Option.iter (walk tracked) + o |> Option.iter (walk tracked) + + walk + (match binder with + | Some n -> Map.remove n tracked + | None -> tracked) + b + | _ -> childExprs te |> List.iter (walk tracked) + + walk tracked0 root + List.ofSeq acc diff --git a/src/Weir/Script.fs b/src/Weir/Script.fs index 980bed87..4f419877 100644 --- a/src/Weir/Script.fs +++ b/src/Weir/Script.fs @@ -3638,6 +3638,93 @@ type ReenumTracker() = member _.Flush() : ReenumFinding list = if poisoned then [] else List.ofSeq found +// ---- the newTempDir footgun [D:newtempdir-lint]: whole-file threading ----- +// A newTempDir binding and its delete can sit any number of statements +// apart (`let d = Path.newTempDir ()` … work … `Dir.deleteAll d`), so the +// pairing is a WHOLE-FILE judgement — the re-enumeration tracker's shape +// exactly. Fed post-check per statement, flushed after the fold; WARNING +// severity (advisory, check still exits 0); poisoned on any errored +// statement (one real error beats advisory noise). Modules never bind a +// command/effect `let`, so the pairing cannot arise there — the tracker is +// only fed for scripts. + +type TempDirFinding = + { TLine: int + TCol: int + TEndCol: int + TMessage: string } + +type TempDirTracker() = + // top-level newTempDir binders: name -> id + let tracked = System.Collections.Generic.Dictionary() + // every bind id seen (top-level + block-local) still awaiting a delete + let openIds = System.Collections.Generic.HashSet() + let found = ResizeArray() + let mutable nextId = 0 + let mutable poisoned = false + + let fresh () = + let id = nextId + nextId <- nextId + 1 + id + + /// walk one statement tree: a delete of an open binder (top-level or a + /// qualifying block-local) warns ONCE at the delete site + let consume (ll: LogicalLine) (te: Check.TypedExpr) = + let tracked0 = + tracked |> Seq.map (fun kv -> kv.Key, kv.Value) |> Map.ofSeq + + for ev in Check.tempDirEvents fresh tracked0 te do + match ev with + | Check.TempBind(id, _) -> openIds.Add id |> ignore + | Check.TempDelete(id, name, span, isAll) -> + if openIds.Remove id then + // a matched top-level binder is resolved — drop it so a + // later stray delete of a reused name does not re-warn + for kv in tracked |> Seq.filter (fun kv -> kv.Value = id) |> Seq.toList do + tracked.Remove kv.Key |> ignore + + let l, c = translate ll span.Start.Col + let _, ec = translate ll span.End.Col + let deleteCall = if isAll then "Dir.deleteAll" else "Dir.delete" + + found.Add + { TLine = l + TCol = c + TEndCol = max (c + 1) ec + TMessage = + $"'{name}' is a Path.newTempDir directory later removed with {deleteCall} — " + + $"use a 'within tmp {name}' block instead: it removes the directory on scope exit " + + "AND on Ctrl+C/kill, which a manual delete misses (newTempDir is for a directory " + + "that must OUTLIVE the scope)" } + + member _.Poison() = poisoned <- true + + member _.Feed (ll: LogicalLine) (chk: CheckedStatement) = + match chk.Kind with + | KType _ + | KSig _ + | KModule _ + | KImport _ -> () + | KLet(name, _, te) -> + consume ll te + tracked.Remove name |> ignore + + if Check.isNewTempDirCall te then + let id = fresh () + tracked[name] <- id + openIds.Add id |> ignore + | KLetPat(pat, _, te) -> + consume ll te + + for n, _ in Check.patNameSpans pat do + tracked.Remove n |> ignore + | KCmd te + | KExpr te -> consume ll te + + member _.Flush() : TempDirFinding list = + if poisoned then [] else List.ofSeq found + // ---- the module loader [D:modules-v1] ------------------------------------ // A module's OWN base env: builtins (strict) + prelude + Self, with // Self.scriptPath = the module's own path. Pure — no stdin/args/Session @@ -5552,6 +5639,11 @@ let analyzeLines // `let` is already the module-rule error) let reenumTracker = ReenumTracker() + // the newTempDir footgun [D:newtempdir-lint]: same per-statement + // feed / post-fold flush; scripts only (modules cannot pair a + // newTempDir bind with a delete) + let tempDirTracker = TempDirTracker() + // budget stop-at-first [D:budget-stop-first]: the per-statement // inference budget is a whole-file DoS when the multi-error fold // multiplies it across independent burning statements. A budget @@ -5699,12 +5791,14 @@ let analyzeLines if not isModule then reenumTracker.Feed ll chk + tempDirTracker.Feed ll chk stmts.Add(ll, chk) tenv <- chk.Env | Error d -> unusedTracker.Poison() reenumTracker.Poison() + tempDirTracker.Poison() d.Warnings |> List.iter warn // [PLAN-diagnostics-arc B5+B6]: an ERRORED statement @@ -5883,6 +5977,19 @@ let analyzeLines Code = "re-enumeration" Message = f.RMessage }) + // the newTempDir footgun [D:newtempdir-lint] lands on the delete + // site — warning severity, so check still exits 0 + (for f in tempDirTracker.Flush() do + diags.Add + { File = path + Line = f.TLine + Col = f.TCol + EndLine = Some f.TLine + EndCol = Some f.TEndCol + Severity = "warning" + Code = "temp-dir-cleanup" + Message = f.TMessage }) + (let sigLoadDiags, sigInfos = loadSigs path sigDecls // POSITION ORDER, not accumulation order (rider D1): assembly diff --git a/tests/Weir.Tests/Tests.fs b/tests/Weir.Tests/Tests.fs index 8aa54165..a4b22a1e 100644 --- a/tests/Weir.Tests/Tests.fs +++ b/tests/Weir.Tests/Tests.fs @@ -20767,6 +20767,79 @@ let reenumWarningTests = "one real error beats advisory noise" } ] +let tempDirLintTests = + // the newTempDir footgun [D:newtempdir-lint]: a Path.newTempDir binding + // deleted in the same scope is the manual (and Ctrl+C-leaky) spelling of + // a `within tmp` block; an UNMATCHED bind is the legitimate escaping use + // and stays silent. Warning severity — check still exits 0. + let diagsOf (lines: string list) = + let ds, _, _, _ = Weir.Script.analyzeLines "tmp.weir" lines + ds |> List.filter (fun d -> d.Code = "temp-dir-cleanup") + + let silent (lines: string list) (label: string) = + Expect.isEmpty + (diagsOf lines) + $"{label}: expected no temp-dir warning, got {diagsOf lines |> List.map _.Message}" + + testList + "newTempDir footgun [D:newtempdir-lint]" + [ test "bind then Dir.deleteAll warns at the delete site — command named, within suggested" { + match diagsOf [ "let d = Path.newTempDir ()"; "print d"; "Dir.deleteAll d" ] with + | [ d ] -> + Expect.equal (d.Line, d.Col) (3, 1) "located at the delete" + Expect.equal d.Severity "warning" "advisory, never a gate" + Expect.stringContains d.Message "Dir.deleteAll" "the delete call is named" + Expect.stringContains d.Message "within tmp d" "the repair points at the scoped block" + | other -> failtest $"expected one warning, got {other |> List.map (fun d -> d.Message)}" + } + test "Dir.delete (non-recursive) also warns, naming the call it saw" { + match diagsOf [ "let d = Path.newTempDir ()"; "Dir.delete d" ] with + | [ d ] -> Expect.stringContains d.Message "Dir.delete " "the non-recursive call is named" + | other -> failtest $"expected one warning, got {other |> List.map (fun d -> d.Message)}" + } + test "warning severity is exit-0's substance: no error rides along" { + let ds, _, _, _ = Weir.Script.analyzeLines "tmp.weir" [ "let d = Path.newTempDir ()"; "Dir.deleteAll d" ] + Expect.isFalse (ds |> List.exists (fun d -> d.Severity = "error")) "check exits 0 on a warning-only file" + } + test "an UNMATCHED bind is the escaping use — silent" { + silent [ "let d = Path.newTempDir ()"; "print d" ] "no in-scope delete" + } + test "a within tmp block is the good form — silent" { + silent [ "within tmp scratch"; " print scratch" ] "within tmp" + } + test "deleting a DIFFERENT directory does not warn" { + silent [ "let d = Path.newTempDir ()"; "print d"; "Dir.deleteAll \"/tmp/other\"" ] "unrelated delete" + } + test "a plain Dir.deleteAll with no newTempDir binding is silent" { + silent [ "let p = \"/tmp/scratch\""; "Dir.deleteAll p" ] "not a temp dir" + } + test "block-local bind + delete warns inside its own body" { + match + diagsOf + [ "let f () =" + " let d = Path.newTempDir ()" + " Dir.deleteAll d" + "" + "f ()" ] + with + | [ d ] -> + Expect.equal d.Line 3 "the local delete" + Expect.stringContains d.Message "within tmp d" "the scoped repair" + | other -> failtest $"expected one warning, got {other |> List.map (fun d -> d.Message)}" + } + test "one bind, one delete, one warning — a later stray delete of the name does not re-warn" { + // the binder is resolved at its first matched delete; a second + // delete of the (now untracked) name is not a newTempDir pairing + match diagsOf [ "let d = Path.newTempDir ()"; "Dir.deleteAll d"; "Dir.deleteAll d" ] with + | [ _ ] -> () + | other -> failtest $"expected exactly one warning, got {other |> List.map (fun d -> d.Message)}" + } + test "POISON: an errored statement suppresses the advisory pass" { + silent + [ "let d = Path.newTempDir ()"; "Dir.deleteAll d"; "print (\"a\" + 1)" ] + "one real error beats advisory noise" + } ] + // ---- #save DISTILL [D:repl-save] ------------------------------------- // the distill seam: transcript survivors (a `TDef` name + physical // source) through qualify -> dedup(last) -> the check guarantee. The @@ -21752,6 +21825,7 @@ let allTests = districtTests unusedBindingTests reenumWarningTests + tempDirLintTests replSaveDistillTests aliasTests dynamicHeadTests From 3542caf85fb5526420bd66a1741f9c10485f5725 Mon Sep 17 00:00:00 2001 From: queil <4584075+queil@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:16:31 +0000 Subject: [PATCH 06/12] =?UTF-8?q?eval:=20exec=20=E2=80=94=20the=20execve?= =?UTF-8?q?=20primitive=20(process=20replacement),=20unwired=20core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DECISIONS.md | 1 + src/Weir/Proc.fs | 55 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d463ab41..c655e1e6 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -55,6 +55,7 @@ the old key, never an edit-in-place. | splice-default-last | 2026-07-22 | splice/hole scalar defaulting is a FINALIZATION step at the statement boundary, not an eager bind — fixes the only wrong-rejection on the books (`1 \| (fun k -> $"{k}")`); still-unresolved holes default to string, non-scalars keep the original rejection | SEMANTICS: the soundness condition, re-verified; TRANSCRIPTION addendum | | fmt-respace | 2026-07-22 | fmt v2: intra-line respace (collapse space runs, pad record braces, tidy `;`) under a PARSE-SHAPE guard — each statement must sexpr-match its original under Script.assumeResolver or it reverts; string interiors, leading indent, and pre-comment alignment gaps untouched | SEMANTICS: fmt; the sexpr renderer moved to Ast (shared with the parse pins) | | exit-reifiers | 2026-07-23 | `\| succeeds` (bool, ExitCode==0 exactly, silent) and `\| orFail "msg"` (unit-or-raise, msg + exit code, stderr replayed) join complete's family — one fold, one single-segment rule, env twins; unit became printable-as-nothing so orFail sits in !()/districts (the decided cell); bool-valued command statements join the discard family | PLAN-exit-reifiers; SKILL: the exit-zero sentence | +| exec | 2026-09-23 | ` \| exec` — PROCESS REPLACEMENT joins the reifier family [D:exit-reifiers]: the terminal thing you do with a command, execve not spawn. The current weir image is REPLACED (POSIX execvp) so a container entrypoint keeps its pid and the app receives signals DIRECTLY — no weir layer to forward or reap, closing the Docker-entrypoint gap the port spike found (weir-as-PID-1 dropped SIGTERM and orphaned the app, no graceful shutdown). WEIR-SHAPE over bash: the reifier suffix (`^$cmd $@rest \| exec`) not a bash `exec` prefix — one family, one mental model, and weir sheds bash conventions anyway (nothing expands in argv). Diverging type (tA, like fail/exit) so code after is unreachable; the env overlay + cwd land on THIS process first so the replaced image inherits them; NUL-refused at the spawn funnel [D:spawn-nul-funnel]. Windows has no execve — it runs the child foreground then HARD-exits with its code (no scope unwind, matching execve's discard; same observable end minus the pid handoff). Marker/desugar mirror the family (ExecMarker → \|execed/\|execedEnv/\|execedIn); the value-headed \|execedIn REFUSES — weir is gone, it cannot write stdin to its own replacement. Landed in stages: Proc.exec (the execvp/Environment.Exit core) first, then the reifier wiring + e2e (an out-of-process test, since execve replaces the runner). | Proc.fs exec (execvp/Environment.Exit); Builtins \|execed family; Parser ExecMarker + desugar; e2e exec cell; CHANGELOG v0.0.50 | | paramful-rhs | 2026-07-23 | param-ful lets take a command RHS; params shadow PATH in their own RHS (bindings-beat-PATH's scope — `let f x = x` stays identity, pinned against a real PATH x); spliced params ride boundary defaulting; the FIRST feature enabled by a bug fix (splice-default-last was the wall) | PLAN-paramful-rhs; SEMANTICS: soundness note 3rd edition | | seq-fold | 2026-07-22 | Seq.fold lands (state-first folder, FCS-probed; strict; constraint-free); the check-mode nested-lambda push-through fixed en route (the canonical piped sum rejected); Env.pair/ofPairs ride (inline-env, NOT an anon-records case) | PLAN-fold; SEMANTICS: library | | fun-sugar | 2026-07-22 | `fun a b ->` desugars via curryParams — one rule, two positions with let-param sugar; duplicate params reject in BOTH (the probe caught let-sugar accepting them; F# rejects) | PLAN-fold; oracle pins | diff --git a/src/Weir/Proc.fs b/src/Weir/Proc.fs index b973fab4..d823298e 100644 --- a/src/Weir/Proc.fs +++ b/src/Weir/Proc.fs @@ -273,6 +273,61 @@ let runInherited (s: Spec) : unit = finally reap p +// process REPLACEMENT [D:exec] — execvp, not spawn: the current weir image +// is REPLACED by the command and keeps its pid, so as a container +// entrypoint (PID 1) the app receives signals DIRECTLY, with no weir layer +// to forward or reap. NEVER returns on POSIX success. Windows has no +// execve (CreateProcess only), so it spawns the child in the foreground, +// waits, and returns the exit code for the caller to exit with — the same +// observable end (weir gone, the app's status is the script's) minus the +// pid handoff. The env overlay and cwd land on THIS process first, so the +// replaced image inherits them; execvp searches PATH for a bare name. +[] +extern int private execvp(string file, string[] argv) + +let exec (s: Spec) : unit = + // the same boundary spawn enforces [D:spawn-nul-funnel] + nulRefusal "the command program name" s.Prog + + if s.Prog = "" then + failwith "the command program name is empty — nothing to run" + + for a in s.Args do + nulRefusal "a command argument" a + + let ambient = + match s.Ambient with + | Some snap -> snap + | None -> Session.envOverlay () |> List.rev |> List.collect id + + for k, v in List.append ambient s.Env do + nulRefusal $"the env key '{k}'" k + nulRefusal $"the env value for '{k}'" v + System.Environment.SetEnvironmentVariable(k, v) + + match s.Cwd with + | Some wd when System.IO.Directory.Exists wd -> System.IO.Directory.SetCurrentDirectory wd + | Some wd -> failwith $"the working directory no longer exists: {wd}" + | None -> () + + if System.OperatingSystem.IsWindows() then + // no execve: run foreground, then HARD-exit with the child's code + // (no scope unwind — exec discards the image, matching POSIX) + let p = start false false s + p.WaitForExit() + let code = p.ExitCode + reap p + System.Environment.Exit code + else + // argv[0] is the program name by convention; the array is + // NULL-terminated (the null element marshals to a NULL pointer) + let argv = Array.append (List.toArray (s.Prog :: s.Args)) [| null |] + execvp (s.Prog, argv) |> ignore + // execvp returns ONLY on failure — success replaced the image + failwith $"command not found or not executable: {s.Prog}" + // stdout relayed to the console as it arrives; the code as the result // [D:exit-reifiers]: output goes to the human, the code is the meaning let streamCodeOf (s: Spec) : int = From 36db535fc7c0dcd1b454962ea79d8162d49367b3 Mon Sep 17 00:00:00 2001 From: queil <4584075+queil@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:10:56 +0000 Subject: [PATCH 07/12] builtins,checker,eval,lsp,parser: wire exec reifier (process replacement) --- CHANGELOG.md | 10 ++++++++++ ci/e2e.sh | 37 +++++++++++++++++++++++++++++++++++++ docs/DECISIONS.md | 2 +- skills/weir/SKILL.md | 12 +++++++++--- src/Weir/Builtins.fs | 35 ++++++++++++++++++++++++++++++++++- src/Weir/Check.fs | 9 +++++++++ src/Weir/Lsp.fs | 4 +++- src/Weir/Parser.fs | 27 ++++++++++++++++++++++++++- src/Weir/Proc.fs | 15 +++++++++++++++ tests/Weir.Tests/Tests.fs | 24 ++++++++++++++++++++++++ 10 files changed, 168 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d44477c9..fc32b35e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ ### Added +- **`exec`: process replacement.** `cmd | exec` replaces the running weir + process with the command (POSIX `execve`; Windows spawns, waits, and + exits with the child's code). weir keeps its pid, so as a container + entrypoint the application receives signals directly — no forwarding or + reaping layer. It is a reifier like `complete`/`orFail`, diverging like + `fail`/`exit` (never returns), so it is a legal bare statement; it takes + a literal or dynamic (`^$cmd`) head and an env-sigil overlay + (`$e(cmd | exec)`), but refuses a piped stdin — there is no parent left + to feed the replacement. + - **`Path.home` and the XDG directory trio (`Path.configHome`, `Path.stateHome`, `Path.cacheHome`).** The typed stand-in for `~`/`$HOME`, which never expand in argv — build a path with an interpolation instead: diff --git a/ci/e2e.sh b/ci/e2e.sh index 4284aa71..06a6803b 100755 --- a/ci/e2e.sh +++ b/ci/e2e.sh @@ -412,6 +412,43 @@ expect "POSIX one-liner via the external shell" '["a"; "b"]' "$out" out=$($BIN -e 'sh -c "exit 7" | complete |> _.exitCode') expect "sh lines can complete now (old builtin boundary gone)" "7 : int" "$out" +# ---- exec: process replacement [D:exec] ----------------------------- +# exec REPLACES the runner (execve), so the command's own stdout AND exit +# code ARE the script's — there is no weir layer left to reify through. +# Out-of-process by nature: -e / a script drives it and the child's bytes +# and status are observed directly. POSIX-only (execve; the coreutils +# heads have no Windows shadow — the Windows spawn-wait-exit path is the +# hand-run item, like the other sh/coreutils cells). +if [ "$IS_WINDOWS" = "0" ]; then + out=$($BIN -e 'echo exec-marker | exec') + expect "exec replaces the process with the command" "exec-marker" "$out" + + execdir=$(mkweirtmp) + cat > "$execdir/exec.weir" <<'WEOF' +sh -c "exit 7" | exec +print "unreached" +WEOF + $BIN "$execdir/exec.weir" + code=$? + [ "$code" = "7" ] || fail "exec must exit with the replacement's code (got $code)" + out=$($BIN "$execdir/exec.weir" 2>&1) + echo "$out" | grep -qF "unreached" && fail "no statement runs after exec — the image is gone" + + # the env overlay reaches the replacement (libc setenv, since execvp + # reads the C environ, not .NET's managed copy) + cat > "$execdir/execenv.weir" <<'WEOF' +let e = [Env.pair "EXECENV" "reached"] +$e(printenv EXECENV | exec) +WEOF + out=$($BIN "$execdir/execenv.weir") + expect "exec's env overlay reaches the replacement" "reached" "$out" + + # a value pipe into exec is refused at parse (no parent to feed stdin) + printf '["a"] | grep a | exec\n' > "$execdir/execbad.weir" + out=$($BIN check "$execdir/execbad.weir" 2>&1) + echo "$out" | grep -qF "cannot take a piped stdin" || fail "value-headed exec must refuse at check" +fi + # a 2-param generic union checks + evals through the binary (was the # prelude-Result pin; Result removed [D:no-result], the fixture is now a # locally-declared Either) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c655e1e6..e64d8d47 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -55,7 +55,7 @@ the old key, never an edit-in-place. | splice-default-last | 2026-07-22 | splice/hole scalar defaulting is a FINALIZATION step at the statement boundary, not an eager bind — fixes the only wrong-rejection on the books (`1 \| (fun k -> $"{k}")`); still-unresolved holes default to string, non-scalars keep the original rejection | SEMANTICS: the soundness condition, re-verified; TRANSCRIPTION addendum | | fmt-respace | 2026-07-22 | fmt v2: intra-line respace (collapse space runs, pad record braces, tidy `;`) under a PARSE-SHAPE guard — each statement must sexpr-match its original under Script.assumeResolver or it reverts; string interiors, leading indent, and pre-comment alignment gaps untouched | SEMANTICS: fmt; the sexpr renderer moved to Ast (shared with the parse pins) | | exit-reifiers | 2026-07-23 | `\| succeeds` (bool, ExitCode==0 exactly, silent) and `\| orFail "msg"` (unit-or-raise, msg + exit code, stderr replayed) join complete's family — one fold, one single-segment rule, env twins; unit became printable-as-nothing so orFail sits in !()/districts (the decided cell); bool-valued command statements join the discard family | PLAN-exit-reifiers; SKILL: the exit-zero sentence | -| exec | 2026-09-23 | ` \| exec` — PROCESS REPLACEMENT joins the reifier family [D:exit-reifiers]: the terminal thing you do with a command, execve not spawn. The current weir image is REPLACED (POSIX execvp) so a container entrypoint keeps its pid and the app receives signals DIRECTLY — no weir layer to forward or reap, closing the Docker-entrypoint gap the port spike found (weir-as-PID-1 dropped SIGTERM and orphaned the app, no graceful shutdown). WEIR-SHAPE over bash: the reifier suffix (`^$cmd $@rest \| exec`) not a bash `exec` prefix — one family, one mental model, and weir sheds bash conventions anyway (nothing expands in argv). Diverging type (tA, like fail/exit) so code after is unreachable; the env overlay + cwd land on THIS process first so the replaced image inherits them; NUL-refused at the spawn funnel [D:spawn-nul-funnel]. Windows has no execve — it runs the child foreground then HARD-exits with its code (no scope unwind, matching execve's discard; same observable end minus the pid handoff). Marker/desugar mirror the family (ExecMarker → \|execed/\|execedEnv/\|execedIn); the value-headed \|execedIn REFUSES — weir is gone, it cannot write stdin to its own replacement. Landed in stages: Proc.exec (the execvp/Environment.Exit core) first, then the reifier wiring + e2e (an out-of-process test, since execve replaces the runner). | Proc.fs exec (execvp/Environment.Exit); Builtins \|execed family; Parser ExecMarker + desugar; e2e exec cell; CHANGELOG v0.0.50 | +| exec | 2026-09-23 | ` \| exec` — PROCESS REPLACEMENT joins the reifier family [D:exit-reifiers]: the terminal thing you do with a command, execve not spawn. The current weir image is REPLACED (POSIX execvp) so a container entrypoint keeps its pid and the app receives signals DIRECTLY — no weir layer to forward or reap, closing the Docker-entrypoint gap the port spike found (weir-as-PID-1 dropped SIGTERM and orphaned the app, no graceful shutdown). WEIR-SHAPE over bash: the reifier suffix (`^$cmd $@rest \| exec`) not a bash `exec` prefix — one family, one mental model, and weir sheds bash conventions anyway (nothing expands in argv). Diverging type (tA, like fail/exit) so code after is unreachable — and the bare-statement discard gate EXEMPTS it via `Check.divergesTo` (the fail/exit spine, extended to the `|execed` application spine), so a bare `cmd | exec` AND `$e(cmd | exec)` both check without the discard error. The env overlay + cwd land on THIS process first so the replaced image inherits them — the overlay via libc `setenv` (a P/Invoke twin of execvp), because execvp reads the C `environ` that .NET's `SetEnvironmentVariable` does NOT sync on Unix (a real bug caught by the env e2e: the overlay silently missed the replacement until setenv; cwd rides `Directory.SetCurrentDirectory`'s native chdir, already synced); NUL-refused at the spawn funnel [D:spawn-nul-funnel]. Windows has no execve — it runs the child foreground then HARD-exits with its code (no scope unwind, matching execve's discard; same observable end minus the pid handoff). Marker/desugar mirror the family (ExecMarker → \|execed/\|execedEnv, registered diverging tA); the value-headed route REFUSES at PARSE (foldChain, teaching "exec … cannot take a piped stdin"), so \|execedIn is never emitted or registered — weir is gone, it cannot feed stdin to its own replacement. Landed in stages: Proc.exec (the execvp/Environment.Exit core) first, then the reifier wiring + e2e (an out-of-process test, since execve replaces the runner). | Proc.fs exec (execvp/setenv/Environment.Exit); Builtins \|execed/\|execedEnv + exec hover doc; Parser ExecMarker + desugar + value-headed refusal; Check.divergesTo execSpine; Lsp reifierHeads; tests exec desugar/diverging-statement/refusal cells; ci/e2e.sh exec cell; skills/weir/SKILL.md reifier family; CHANGELOG v0.0.50 | | paramful-rhs | 2026-07-23 | param-ful lets take a command RHS; params shadow PATH in their own RHS (bindings-beat-PATH's scope — `let f x = x` stays identity, pinned against a real PATH x); spliced params ride boundary defaulting; the FIRST feature enabled by a bug fix (splice-default-last was the wall) | PLAN-paramful-rhs; SEMANTICS: soundness note 3rd edition | | seq-fold | 2026-07-22 | Seq.fold lands (state-first folder, FCS-probed; strict; constraint-free); the check-mode nested-lambda push-through fixed en route (the canonical piped sum rejected); Env.pair/ofPairs ride (inline-env, NOT an anon-records case) | PLAN-fold; SEMANTICS: library | | fun-sugar | 2026-07-22 | `fun a b ->` desugars via curryParams — one rule, two positions with let-param sugar; duplicate params reject in BOTH (the probe caught let-sugar accepting them; F# rejects) | PLAN-fold; oracle pins | diff --git a/skills/weir/SKILL.md b/skills/weir/SKILL.md index 6de78a5f..f8f4b3c2 100644 --- a/skills/weir/SKILL.md +++ b/skills/weir/SKILL.md @@ -1316,7 +1316,12 @@ within tmp d interior lines; `cmd | exitCode` STREAMS and gives the code as INT, never raises — bind it or match it (`| 130 ->` for cancels); a bare/`!()`/`$()` position is a teaching error ($() captures — use - `| complete` there). **`succeeds` is exitCode == 0, exactly** — + `| complete` there). `cmd | exec` REPLACES the weir process with the + command [D:exec] (execve — keeps weir's pid, so as a container + entrypoint the app gets signals directly with no forwarding layer); + it NEVER returns, diverging like `fail`/`exit`, so it is a legal bare + statement and cannot take a piped stdin (there is no parent left to + feed it). **`succeeds` is exitCode == 0, exactly** — for tools whose nonzero codes AND output are both data (grep, fzf), use `| complete` and read the record. An `if`/`elif` CONDITION takes the chain inline: `if test -f $p | succeeds then` @@ -1326,8 +1331,9 @@ within tmp d Full inspection: `cmd | complete` gives `{ exitCode; stdout; stderr }`; a COMPUTED argv splats into the chain — `$author(git commit-tree $@argv | complete) |> _.stdout` (literal - head, splatted argv, sigil env; works with all four reifiers, - value-headed and interior lines too). `print ()` is silent (unit + head, splatted argv, sigil env; works with every reifier, + value-headed and interior lines too — except `exec`, which refuses a + value head). `print ()` is silent (unit prints nothing — the rule that lets orFail sit in effect positions). - Capture is IN MEMORY: `| complete` holds the whole output as one diff --git a/src/Weir/Builtins.fs b/src/Weir/Builtins.fs index e9c48a66..f297d3b9 100644 --- a/src/Weir/Builtins.fs +++ b/src/Weir/Builtins.fs @@ -383,6 +383,27 @@ let private exitCodedWith (overlay: (string * string) list) : Value = VInt(int64 (Proc.streamCode overlay (Proc.resolveProg prog) argv)) | _ -> unreachable "the checker rejects 'exitCoded' on these arguments")) +// process replacement [D:exec]: Proc.exec REPLACES the image (execve) — +// it NEVER returns on success, and raises on a missing/failed exec, so the +// VBuiltin's own result is unreachable. Diverging (typed tA), like +// fail/exit. The overlay lands on this process before the handoff, so the +// replacement inherits it (the env-sigil route `$e(cmd | exec)`). +let private execedWith (overlay: (string * string) list) : Value = + VBuiltin(fun progV -> + VBuiltin(fun argsV -> + match progV, argsV with + | VStr prog, VSeq args -> + Proc.exec + { Prog = Proc.resolveProg prog + Args = argStrings args + Env = overlay + Input = None + Cwd = None + Ambient = None } + + unreachable "exec returned — execve replaces the image or raises" + | _ -> unreachable "the checker rejects 'exec' on these arguments")) + // stdin-carrying reifier twins [D:value-headed-pipe]: `xs | grep foo | // complete` reifies the segment WITH the value as stdin. INTERNAL — // the public expression-position spellings (completed/succeeded/…) keep @@ -5704,6 +5725,11 @@ let builtinDocs: Map = (Some "the reifier law: output streams, the exit is the meaning.") "exitCode", bd "Reify a command to its integer exit code." None (Some "the reifier law: the meaning is the code.") + "exec", + bd + "Replace the current process with the command (execve) — never returns; the app keeps weir's pid, so as a container entrypoint it gets signals directly. Diverging, like fail/exit; cannot take piped stdin." + None + (Some "the reifier law: the command becomes the process.") // ---- types: a hover renders the structure; the value here is // WHEN you get one ---- @@ -5759,6 +5785,7 @@ let reifierSurface (name: string) : string option = elif name.StartsWith "|succeeded" then Some "succeeds" elif name.StartsWith "|orFailed" then Some "orFail" elif name.StartsWith "|exitCoded" then Some "exitCode" + elif name.StartsWith "|execed" then Some "exec" else None /// the hover/completion text: summary, then example, then pointer — each @@ -5918,6 +5945,9 @@ let private entries: (string * Ty * Value) list = "|succeeded", TFun(TStr, TFun(TSeq TStr, TBool)), succeededWith [] "|orFailed", TFun(TStr, TFun(TStr, TFun(TSeq TStr, TUnit))), orFailedWith [] "|exitCoded", TFun(TStr, TFun(TSeq TStr, TInt)), exitCodedWith [] + // process replacement [D:exec] — diverging (tA), like fail/exit; the + // stdin twin is refused at parse (no parent to feed a replacement) + "|execed", TFun(TStr, TFun(TSeq TStr, tA)), execedWith [] // stdin-carrying twins — the value-headed reifier route // (`xs | grep | complete`) [D:value-headed-pipe] "|completedIn", TFun(TStr, TFun(TSeq TStr, TFun(TSeq TStr, TNamed(completedDef.Name, [])))), completedWithIn [] @@ -5957,7 +5987,10 @@ let private entries: (string * Ty * Value) list = VBuiltin(fun envV -> orFailedWith (envVarPairs envV)) "|exitCodedEnv", TFun(TSeq(TNamed("EnvVar", [])), TFun(TStr, TFun(TSeq TStr, TInt))), - VBuiltin(fun envV -> exitCodedWith (envVarPairs envV)) ] + VBuiltin(fun envV -> exitCodedWith (envVarPairs envV)) + "|execedEnv", + TFun(TSeq(TNamed("EnvVar", [])), TFun(TStr, TFun(TSeq TStr, tA))), + VBuiltin(fun envV -> execedWith (envVarPairs envV)) ] @ bareEntries let private showImpl: Value = VBuiltin(formatValue >> VStr) diff --git a/src/Weir/Check.fs b/src/Weir/Check.fs index 18bd58ea..8e4c3466 100644 --- a/src/Weir/Check.fs +++ b/src/Weir/Check.fs @@ -909,8 +909,17 @@ let private seqUnitError (first: Expr) (ty: Ty) : string = // fresh var no position should have to name. Shared by the unit-position // carves (ESeq head, else-less if) and the statement gate. let rec divergesTo (x: Expr) : bool = + // exec REPLACES the process [D:exec] — a diverging reifier like + // fail/exit; its desugar is a `|execed`/`|execedEnv` application spine + let rec execSpine (e: Expr) = + match e.Kind with + | EVar v when v.StartsWith "|execed" -> true + | EApp(f, _) -> execSpine f + | _ -> false + match x.Kind with | EApp({ Kind = EVar("fail" | "exit") }, _) -> true + | EApp _ when execSpine x -> true | ESeq(_, b) | ELet(_, _, _, b) | ELetPat(_, _, b) diff --git a/src/Weir/Lsp.fs b/src/Weir/Lsp.fs index 038662f0..80976504 100644 --- a/src/Weir/Lsp.fs +++ b/src/Weir/Lsp.fs @@ -208,7 +208,9 @@ let semanticTokensFor (lines: string list) : (int * int * int * int) list = "|succeededIn" "|completedIn" "|exitCodedIn" - "|orFailedIn" ] + "|orFailedIn" + "|execed" + "|execedEnv" ] let rec spineIsReifier (te: Check.TypedExpr) = match te.Kind with diff --git a/src/Weir/Parser.fs b/src/Weir/Parser.fs index 3d351ade..52d1c4ae 100644 --- a/src/Weir/Parser.fs +++ b/src/Weir/Parser.fs @@ -1146,6 +1146,7 @@ let rec private chainReifier (e: Expr) : string option = | EVar v when v.StartsWith "|succeeded" -> Some "succeeds" | EVar v when v.StartsWith "|exitCoded" -> Some "exitCode" | EVar v when v.StartsWith "|orFailed" -> Some "orFail" + | EVar v when v.StartsWith "|execed" -> Some "exec" | EApp(f, _) -> chainReifier f | EPipe(l, r) -> chainReifier r |> Option.orElseWith (fun () -> chainReifier l) | _ -> None @@ -3822,6 +3823,10 @@ type private Seg = | SucceedsMarker of Span | ExitCodeMarker of Span | OrFailMarker of Expr * Span + // process replacement [D:exec] — the diverging reifier: it never + // returns (execve replaces the image), so it ends a command chain + // like the rest of the family + | ExecMarker of Span let private reifierEnd = // the let-RHS chain also ends at bare `in` [D:block-let-cmd] — @@ -3890,6 +3895,14 @@ let private exitCodeMarker = ) |>> fun (_, span) -> ExitCodeMarker span +let private execMarker = + attempt ( + spanned (pstring "exec" .>> notFollowedBy (satisfy cmdWordChar)) + .>> ws + .>> reifierEnd + ) + |>> fun (_, span) -> ExecMarker span + // fold a parsed pipeline — an initial head expression plus piped stages // and reifier markers — into one Expr. Shared by the command-headed // chain and the value-headed chain [D:value-headed-pipe]: the ONLY @@ -3927,13 +3940,14 @@ let private foldChain (h: Expr) (rest: ((string * Span) * Seg) list) : Result + | (CompleteMarker _ | SucceedsMarker _ | ExitCodeMarker _ | OrFailMarker _ | ExecMarker _ as marker) -> let stageName, mspan, plainVar, envVar, stdinVar, extraArgs = match marker with | CompleteMarker sp -> "complete", sp, "|completed", "|completedEnv", "|completedIn", [] | SucceedsMarker sp -> "succeeds", sp, "|succeeded", "|succeededEnv", "|succeededIn", [] | ExitCodeMarker sp -> "exitCode", sp, "|exitCoded", "|exitCodedEnv", "|exitCodedIn", [] | OrFailMarker(msg, sp) -> "orFail", sp, "|orFailed", "|orFailedEnv", "|orFailedIn", [ msg ] + | ExecMarker sp -> "exec", sp, "|execed", "|execedEnv", "|execedIn", [] | Stage _ -> "", acc.Span, "", "", "", [] // a chain head is command-ish (an external segment or a @@ -4031,6 +4045,16 @@ let private foldChain (h: Expr) (rest: ((string * Span) * Seg) list) : Result + // exec REPLACES this process — there is no parent left + // to feed a piped stdin, so the value-headed route is + // refused [D:exec] (the stdin twin is never emitted) + if stageName = "exec" then + Result.Error( + "'exec' replaces the current process, so it cannot take a piped stdin — there is no parent left to feed it; drop the value pipe (spawn the command instead if you need its input)", + mspan + ) + else + let span = Span.union acc.Span mspan let headVar = { Kind = EVar stdinVar; Span = mspan } let progArg = progArgOf h acc.Span @@ -4128,6 +4152,7 @@ let private pipedStages (builtinHeads: bool) (argP: Parser) (sigilEn <|> succeedsMarker <|> exitCodeMarker <|> orFailMarker + <|> execMarker <|> reifierStageGuard <|> (segment builtinHeads argP sigilEnv r |>> Stage)) ) diff --git a/src/Weir/Proc.fs b/src/Weir/Proc.fs index d823298e..65f950b6 100644 --- a/src/Weir/Proc.fs +++ b/src/Weir/Proc.fs @@ -287,6 +287,14 @@ let runInherited (s: Spec) : unit = CharSet = System.Runtime.InteropServices.CharSet.Ansi)>] extern int private execvp(string file, string[] argv) +// execvp reads the C `environ`, which .NET's SetEnvironmentVariable does +// NOT sync to on Unix [D:exec] — so the env overlay must land via libc +// setenv (overwrite = 1) for the replacement image to inherit it. +[] +extern int private setenv(string name, string value, int overwrite) + let exec (s: Spec) : unit = // the same boundary spawn enforces [D:spawn-nul-funnel] nulRefusal "the command program name" s.Prog @@ -302,10 +310,17 @@ let exec (s: Spec) : unit = | Some snap -> snap | None -> Session.envOverlay () |> List.rev |> List.collect id + let isWindows = System.OperatingSystem.IsWindows() + for k, v in List.append ambient s.Env do nulRefusal $"the env key '{k}'" k nulRefusal $"the env value for '{k}'" v System.Environment.SetEnvironmentVariable(k, v) + // POSIX execvp reads the C environ, not .NET's managed copy — + // setenv so the replacement inherits the overlay; Windows spawns + // (CreateProcess inherits the process env block set above) + if not isWindows then + setenv (k, v, 1) |> ignore match s.Cwd with | Some wd when System.IO.Directory.Exists wd -> System.IO.Directory.SetCurrentDirectory wd diff --git a/tests/Weir.Tests/Tests.fs b/tests/Weir.Tests/Tests.fs index a4b22a1e..ce168ec2 100644 --- a/tests/Weir.Tests/Tests.fs +++ b/tests/Weir.Tests/Tests.fs @@ -11942,6 +11942,30 @@ let agentFindingsTests = | Error msg -> Expect.stringContains msg "single external command segment" "" | Ok _ -> failtest "exitCode must keep the family's segment rule" } + test "exec desugars to the execed application [D:exec]" { + match Weir.Parser.parseLine cmdResolver "echo hi | exec" with + | Ok(SCmd e) -> Expect.stringContains (Weir.Ast.sexpr e) "|execed" "" + | other -> failtest $"expected the execed desugar, got {other}" + } + test "exec is a diverging bare statement — no discard error, either route [D:exec]" { + // exec never returns (execve/exit), so a bare statement is + // legitimate: the discard gate must exempt it like fail/exit, + // in the command route AND the env-sigil capture route + let clean (lines: string list) (label: string) = + let diags, _, _, _ = Weir.Script.analyzeLines "exec.weir" lines + Expect.isEmpty (diags |> List.filter (fun d -> d.Severity = "error")) $"{label}: {diags |> List.map _.Message}" + + clean [ "echo replaced | exec" ] "plain command route" + clean [ "let e = [Env.pair \"X\" \"1\"]"; "$e(printenv X | exec)" ] "env-sigil capture route" + clean [ "let cmd = \"echo\""; "let rest = [\"a\"; \"b\"]"; "^$cmd $@rest | exec" ] "dynamic head" + } + test "exec refuses a piped stdin — no parent left to feed a replacement [D:exec]" { + match Weir.Parser.parseLine cmdResolver "[\"a\"] | grep a | exec" with + | Error msg -> + Expect.stringContains msg "exec" "" + Expect.stringContains msg "cannot take a piped stdin" "" + | Ok _ -> failtest "value-headed exec must refuse" + } test "the fifth refusal cell: refused-context reifiers TEACH, never PATH-resolve [D:reifier-family-complete]" { // [D:statement-lets] moved the boundary: if-body and // within-body block lets now TAKE the reifier (statement From 833b121bc50ba035220497ac9dbd637d52d07bb6 Mon Sep 17 00:00:00 2001 From: queil <4584075+queil@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:48:49 +0000 Subject: [PATCH 08/12] parser: top-level block if/else parses (a dedented else/elif continues the if) --- CHANGELOG.md | 7 +++++++ docs/DECISIONS.md | 1 + src/Weir/Script.fs | 9 +++++++++ tests/Weir.Tests/Tests.fs | 39 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc32b35e..abbaa5af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,13 @@ ### Fixed +- **A top-level `if … then … else …` block now parses.** The block form at + column 0 (`if c then` + an indented body, then a dedented `else`/`elif`) + was rejected as a stray `else` keyword — the assembler treated the + dedented `else` as a new statement. A col-0 `else`/`elif` now continues + its `if`, the same way a dedented `|`/`until`/`always` already did. (The + indented form inside a function body always worked.) + - **`#infer` no longer mis-drafts a heterogeneous object as a homogeneous map.** When sibling objects in a sample array carried different keys whose values only *coincidentally* agreed in an early pair (e.g. a Kubernetes diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index e64d8d47..21992656 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -490,4 +490,5 @@ the old key, never an edit-in-place. | repl-history | 2026-09-24 | `#history [N]` — the REPL shows its own history, PATH FIRST. THE TRIGGER: a user wanting to `cat` history had no way to find the file (`~` never expands in argv [D:path-glob], and the location is `$XDG_STATE_HOME/weir/history` or the Windows `%LOCALAPPDATA%` split [D:windows-v1] — not memorable), so "where does history live" had no in-tool answer. RULED a SESSION DIRECTIVE (not a builtin) — it reads the REPL's live in-memory `history`, tooling state, so it belongs with `#find`/`#save` in [D:repl-directives]' one source (`Complete.sessionDirectives`, so the dispatch string-match, `#help`, completion, and the did-you-mean pool all learn it at once). THE HEADER NAMES THE FILE (`history at (N entries)`) — the point is discovery, so the path leads; bare `#history` dumps ALL (cat parity), `#history N` the last N (tail), a non-positive/non-int arg teaches (the `#echo` shape). Entries render DECODED and numbered by real position, one per line via `displayEntry` (a multi-line entry stays one greppable line — the fzf display form [D:repl-multiline]). The in-memory `history` is the source, so the dump reflects THIS session — and a tty records every submitted line including the `#history` directive itself (bash-style; the test's counts account for it). | src/Weir/Repl.fs historyDirective + dispatch + #help list; src/Weir/Complete.fs sessionDirectives; tests/repl/repl-directives.py #history block (path header, count, bare-all, tail-n, help-listed, typo did-you-mean); tests/Weir.Tests/Tests.fs directive-set pins (bare '#' slot, empty-prompt); docs/repl.md; CHANGELOG v0.0.50 | | path-home | 2026-09-24 | `Path.home` + THE XDG TRIO (`Path.configHome`/`Path.stateHome`/`Path.cacheHome`) — the TYPED stand-in for `~`/`$HOME`, which never expand in argv ([D:path-glob]'s no-expansion law: nothing in a word expands, ever). THE TRIGGER: `cat ~/.local/state/weir/history` silently passed `~` as a literal word (correct by the law, surprising to the user), and the only working spelling was hard-coding the absolute path — so a script that wants the home dir had no readable idiom. RULED: four pure `unit -> string` queries (the `Path.tempRoot ()` call shape [D:gap-a-remainder]), platform-native output ([D:windows-v1]'s Path-members-are-native ruling), no trailing separator. `home` = the user profile (`SpecialFolder.UserProfile`, both platforms). The XDG trio REUSES the exact resolution the REPL history file already used (the Windows `%APPDATA%`/`%LOCALAPPDATA%` vs POSIX `XDG_*`-or-`~` split from [D:windows-v1]) — now hoisted to shared `Builtins.homeDir`/`configDir`/`stateDir`/`cacheDir` so the members and the REPL's own history path read the ONE source (Repl.fs delegates, no drift). The idiom: `cat $"{Path.home ()}/.bashrc"` / `cat $"{Path.stateHome ()}/weir/history"`. NO `~`-expansion added (the law stands); these are the visible, injection-proof alternative. | src/Weir/Builtins.fs homeDir/configDir/stateDir/cacheDir + pathMembers home/configHome/stateHome/cacheHome + builtinDocs entries; src/Weir/Repl.fs configHome/stateHome delegate to Builtins; tests/Weir.Tests/Tests.fs "Path.home + XDG dirs resolve"; skills/weir/SKILL.md Path list; CHANGELOG v0.0.50 | | newtempdir-lint | 2026-09-24 | THE `Path.newTempDir` FOOTGUN LINT — a `Path.newTempDir ()` binding then `Dir.delete`/`Dir.deleteAll`'d in the same scope warns, pointing at `within tmp`. THE TRIGGER: agents reach for `let d = Path.newTempDir ()` … `Dir.deleteAll d` (the bash `mktemp`-then-`rm` reflex) when `within tmp d` is the weir spelling — and a STRICTLY better one: `within` removes the directory on scope exit AND on Ctrl+C/kill (the exit hook sweeps it [D:within-scopes]), which a straight-line delete misses when the body raises or the process is signalled. RULED an ADVISORY WARNING, not a gate (severity `warning`, check exits 0) — `newTempDir` is legitimate for the ESCAPING case (a directory that must OUTLIVE the scope, a cross-process handoff [D:gap-a-remainder]), so an UNMATCHED bind (no in-scope delete) stays SILENT: the lint fires only when the bind/delete PAIRING is visible, which is exactly the case that wanted `within`. MECHANISM mirrors [D:reenum-warning] (the whole-file threading precedent, since bind and delete sit statements apart): `Check.tempDirEvents` is the scope-threaded event walk (shadow-aware, drops a name at lambda/match/within/let-pattern boundaries; block-local binders join the tracked set for their body, top-level binders arrive via the tracker's map), and `Script.TempDirTracker` is the per-statement feed / post-fold flush, warning ONCE per binder at its first matched delete (a resolved binder is dropped, so a later stray delete of a reused name does not re-warn). POISONED like the siblings: any errored statement silences the pass (one real error beats advisory noise). Scripts only (a module cannot pair a bind with a delete). Both `Dir.delete` and `Dir.deleteAll` count; a delete of a DIFFERENT directory, or with no newTempDir binding, is silent. | src/Weir/Check.fs isNewTempDirCall + tempDirEvents; src/Weir/Script.fs TempDirTracker + analyzeLines feed/poison/flush (Code "temp-dir-cleanup"); tests/Weir.Tests/Tests.fs tempDirLintTests (deleteAll/delete warn, escaping/within/different-dir/no-binding silent, block-local, one-warning-per-binder, poison); skills/weir/SKILL.md Path.newTempDir note; CHANGELOG v0.0.50 | +| toplevel-if-else | 2026-09-24 | A TOP-LEVEL BLOCK `if/else` NOW ASSEMBLES — the block form `if c then ` followed by a DEDENTED `else`/`elif ` at column 0 was a parse error (`'else' is a keyword`, backtrack). ROOT: the ASSEMBLER, not the expression parser. The `if` is its own logical statement, and the col-0 continuation gate ([D:retry-poll]'s precedent — a dedented `|`/`until`/`always` continues the statement above) admitted `|`, `until`, `always`, but not `else`/`elif`, so a col-0 `else` fell to the new-statement branch and the parser hit a stray keyword. The INDENTED form (inside a function/expression body — the else rides at the body's indent, `raw[0]=' '`) always worked, and the join machinery was already ready (`classifyPiece` yields `ElseHead`, and the `[D:pipe-alignment]` join extends the piece with "no sibling `;`, else keeps its standing rules"). FIX: add a col-0 `else`/`elif` to the same gate — a keyword-boundary check (`lw = "else" || lw.StartsWith "else " || lw.StartsWith "elif "`, so an identifier like `elsewhere` is untouched). Since `else`/`elif` are keywords they can ONLY continue an open `if`, never head a fresh statement, so the admission is unconditional (a stray one errors at parse exactly as a stray `until` does). Surfaced by the docker-postgres port [D:exec], which wanted a top-level `if firstRun then … else …`. | src/Weir/Script.fs assemble col-0 continuation gate (else/elif clause); tests/Weir.Tests/Tests.fs "top-level if/else assembles as ONE statement" (if/else, if/elif/else, multi-stmt then, checks-clean); CHANGELOG v0.0.50 | | eq-depth | 2026-09-21 | VALUE EQUALITY IS ITERATIVE (STRIX-2 / vuln-0006): a checker-accepted, legally-built recursive-record value (an Option-linked record folded ~100k deep via `Seq.fold`) crashed the whole process with an uncatchable StackOverflow on `==`, because `Value.Equals` recursed one stack frame per nesting level. RULED: the equality walk carries an explicit heap work-list of pending `(Value * Value)` pairs instead — scalars compare in place; VRecord (order-insensitive [D:record-order]), VUnion payloads, VTuple, and VMap entry values QUEUE their children; VSeq compares LOCKSTEP via enumerators (never materialize two lists, short-circuit at the first mismatch — the Seq.equal discipline); a mismatch drains the list. Every prior equality semantic is preserved (closures/builtins/proc/server by reference; bytes structural; floats [D:floats]). The `show`/interpolation renderer (`formatValue`) shared the same recursive-crash class — it now carries a finite MaxDepth (100, past the ~11 corpus max) with a teaching ellipsis, matching the REPL echo's existing depth bound. THE STANDING RULE: any new Value-walking helper must walk iteratively or carry a depth bound. | Eval.fs Value.Equals (work-list) + showLimits.MaxDepth; tests/Weir.Tests typeClassTests (200k-deep VRecord compares true, deep-vs-shallow false, 200k VSeq lockstep true / tail-mismatch false); CHANGELOG v0.0.48 Fixed | diff --git a/src/Weir/Script.fs b/src/Weir/Script.fs index 4f419877..4a623113 100644 --- a/src/Weir/Script.fs +++ b/src/Weir/Script.fs @@ -1334,6 +1334,15 @@ let assemble (numbered: (int * string) list) : Result // a col-0 `always` continues its bare within // [D:within-always] || raw.TrimEnd() = "always" + // a col-0 `else`/`elif` continues its `if` — the + // top-level block form (`if c then ` then a + // dedented `else `); the ElseHead join below + // already handles it, only this gate excluded a + // dedented else/elif [D:toplevel-if-else]. `else`/ + // `elif` are keywords, so they can only ever + // continue an open if, never head a fresh statement + || (let lw = raw.TrimEnd() in + lw = "else" || lw.StartsWith "else " || lw.StartsWith "elif ") || inOpenBrace || inOpenLambda then diff --git a/tests/Weir.Tests/Tests.fs b/tests/Weir.Tests/Tests.fs index ce168ec2..3759f9d8 100644 --- a/tests/Weir.Tests/Tests.fs +++ b/tests/Weir.Tests/Tests.fs @@ -5937,6 +5937,45 @@ let bracketContinuationTests = "'}' closes the '[' opened at line 2" "" } + test "top-level if/else assembles as ONE statement — a dedented else/elif continues the if [D:toplevel-if-else]" { + // the block form at column 0: `if c then ` then a + // DEDENTED `else`/`elif`. Before this, a col-0 `else` started a + // fresh statement and the parser hit a stray keyword; the col-0 + // continuation gate now admits else/elif like `until`/`always`. + let asm lines = + Weir.Script.assemble (lines |> List.mapi (fun i l -> i + 1, l)) + + match asm [ "if x then"; " print \"a\""; "else"; " print \"b\"" ] with + | Ok [ _ ] -> () + | other -> failtest $"top-level if/else must be ONE statement, got {other}" + + match + asm + [ "if n > 5 then" + " print \"big\"" + "elif n > 1 then" + " print \"mid\"" + "else" + " print \"small\"" ] + with + | Ok [ _ ] -> () + | other -> failtest $"top-level if/elif/else must be ONE statement, got {other}" + + // a multi-statement then-block still keeps the else attached + match asm [ "if x then"; " print \"a\""; " print \"b\""; "else"; " print \"c\"" ] with + | Ok [ _ ] -> () + | other -> failtest $"multi-stmt then + else must be ONE statement, got {other}" + + // and it checks clean end to end (not just assembles) + let diags, _, _, _ = + Weir.Script.analyzeLines + "ifelse.weir" + [ "let x = true"; "if x then"; " print \"a\""; "else"; " print \"b\"" ] + + Expect.isEmpty + (diags |> List.filter (fun d -> d.Severity = "error")) + $"top-level if/else checks clean: {diags |> List.map _.Message}" + } // blanks are transparent inside brackets [D:blank-in-brackets] test "blank inside an open list is transparent" { Expect.equal (joined [ "let x ="; " [1"; ""; " 2]" ]) "let x = [1 ; 2]" "" From d35064ae45a19b8f174e86406c1d29e2f260615a Mon Sep 17 00:00:00 2001 From: queil <4584075+queil@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:04:52 +0000 Subject: [PATCH 09/12] parser: hoist the else/elif continuation helper and flatten the exec-refusal arm (avoid an FS0193 compiler ICE on the CI SDK) --- src/Weir/Parser.fs | 18 ++++++++---------- src/Weir/Script.fs | 21 ++++++++++++++------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/Weir/Parser.fs b/src/Weir/Parser.fs index 52d1c4ae..54552e37 100644 --- a/src/Weir/Parser.fs +++ b/src/Weir/Parser.fs @@ -4044,17 +4044,15 @@ let private foldChain (h: Expr) (rest: ((string * Span) * Seg) list) : Result + Result.Error( + "'exec' replaces the current process, so it cannot take a piped stdin — there is no parent left to feed it; drop the value pipe (spawn the command instead if you need its input)", + mspan + ) | EPipe(stdinE, { Kind = ECmd(h, args, None) }) when not (isCommandish stdinE) -> - // exec REPLACES this process — there is no parent left - // to feed a piped stdin, so the value-headed route is - // refused [D:exec] (the stdin twin is never emitted) - if stageName = "exec" then - Result.Error( - "'exec' replaces the current process, so it cannot take a piped stdin — there is no parent left to feed it; drop the value pipe (spawn the command instead if you need its input)", - mspan - ) - else - let span = Span.union acc.Span mspan let headVar = { Kind = EVar stdinVar; Span = mspan } let progArg = progArgOf h acc.Span diff --git a/src/Weir/Script.fs b/src/Weir/Script.fs index 4a623113..58faebb9 100644 --- a/src/Weir/Script.fs +++ b/src/Weir/Script.fs @@ -245,6 +245,17 @@ let classifyLine (raw: string) : LineKind = else LineKind.Code +/// a col-0 `else`/`elif` line continues its open `if` [D:toplevel-if-else] +/// — the top-level block form (`if c then ` then a dedented +/// `else`/`elif `). `else`/`elif` are keywords, so a line whose +/// leading word is one can only continue an open if, never head a fresh +/// statement; checked on the trimmed text so an identifier such as +/// `elsewhere` is untouched. Hoisted out of the assembler's col-0 gate so +/// that giant function carries no inline `let … in`. +let continuesOpenIf (raw: string) : bool = + let lw = raw.TrimEnd() + lw = "else" || lw.StartsWith "else " || lw.StartsWith "elif " + /// Piece classification, inside assembly: the join/structure decisions. /// Kind is exclusive; Marker and OpensCompound are orthogonal fields — /// `let d = yaml` is a let head AND arms the yaml district. @@ -1335,14 +1346,10 @@ let assemble (numbered: (int * string) list) : Result // [D:within-always] || raw.TrimEnd() = "always" // a col-0 `else`/`elif` continues its `if` — the - // top-level block form (`if c then ` then a - // dedented `else `); the ElseHead join below + // top-level block form; the ElseHead join below // already handles it, only this gate excluded a - // dedented else/elif [D:toplevel-if-else]. `else`/ - // `elif` are keywords, so they can only ever - // continue an open if, never head a fresh statement - || (let lw = raw.TrimEnd() in - lw = "else" || lw.StartsWith "else " || lw.StartsWith "elif ") + // dedented else/elif [D:toplevel-if-else] + || continuesOpenIf raw || inOpenBrace || inOpenLambda then From a3255020edfb2234fc5ba57b0fed616f44eb2353 Mon Sep 17 00:00:00 2001 From: queil <4584075+queil@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:26:41 +0000 Subject: [PATCH 10/12] =?UTF-8?q?ci,ledger:=20optimize=20in=20Debug=20too?= =?UTF-8?q?=20=E2=80=94=20dodge=20the=20FS0193=20debug-info=20ICE=20on=20t?= =?UTF-8?q?he=20large=20Builtins=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ci/commit-area.weir | 5 ++++- docs/DECISIONS.md | 1 + src/Weir/Weir.fsproj | 11 +++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/ci/commit-area.weir b/ci/commit-area.weir index 27726e04..c131929b 100755 --- a/ci/commit-area.weir +++ b/ci/commit-area.weir @@ -80,7 +80,10 @@ let areaOfOther p = "site" elif Str.startsWith "ci/" p || Str.startsWith ".github/" p || Str.startsWith "tools/" p || Str.startsWith "tests/pty/" p then "ci" - elif Str.startsWith "publish." p || Str.startsWith "install." p || p == "Dockerfile" || p == "weir.slnx" || p == ".gitignore" || p == ".dockerignore" || p == ".gitattributes" then + elif Str.startsWith "publish." p || Str.startsWith "install." p || p == "Dockerfile" || p == "weir.slnx" || p == ".gitignore" || p == ".dockerignore" || p == ".gitattributes" || Str.endsWith ".fsproj" p then + // a .fsproj with no accompanying .fs change is BUILD config (it + // rides the src it configures via areaOfSrc when both move; alone, + // it is ci, never the docs fallback) [D:commit-areas] "ci" elif Str.startsWith "tests/" p then "tests" diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 21992656..2a12d25b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -491,4 +491,5 @@ the old key, never an edit-in-place. | path-home | 2026-09-24 | `Path.home` + THE XDG TRIO (`Path.configHome`/`Path.stateHome`/`Path.cacheHome`) — the TYPED stand-in for `~`/`$HOME`, which never expand in argv ([D:path-glob]'s no-expansion law: nothing in a word expands, ever). THE TRIGGER: `cat ~/.local/state/weir/history` silently passed `~` as a literal word (correct by the law, surprising to the user), and the only working spelling was hard-coding the absolute path — so a script that wants the home dir had no readable idiom. RULED: four pure `unit -> string` queries (the `Path.tempRoot ()` call shape [D:gap-a-remainder]), platform-native output ([D:windows-v1]'s Path-members-are-native ruling), no trailing separator. `home` = the user profile (`SpecialFolder.UserProfile`, both platforms). The XDG trio REUSES the exact resolution the REPL history file already used (the Windows `%APPDATA%`/`%LOCALAPPDATA%` vs POSIX `XDG_*`-or-`~` split from [D:windows-v1]) — now hoisted to shared `Builtins.homeDir`/`configDir`/`stateDir`/`cacheDir` so the members and the REPL's own history path read the ONE source (Repl.fs delegates, no drift). The idiom: `cat $"{Path.home ()}/.bashrc"` / `cat $"{Path.stateHome ()}/weir/history"`. NO `~`-expansion added (the law stands); these are the visible, injection-proof alternative. | src/Weir/Builtins.fs homeDir/configDir/stateDir/cacheDir + pathMembers home/configHome/stateHome/cacheHome + builtinDocs entries; src/Weir/Repl.fs configHome/stateHome delegate to Builtins; tests/Weir.Tests/Tests.fs "Path.home + XDG dirs resolve"; skills/weir/SKILL.md Path list; CHANGELOG v0.0.50 | | newtempdir-lint | 2026-09-24 | THE `Path.newTempDir` FOOTGUN LINT — a `Path.newTempDir ()` binding then `Dir.delete`/`Dir.deleteAll`'d in the same scope warns, pointing at `within tmp`. THE TRIGGER: agents reach for `let d = Path.newTempDir ()` … `Dir.deleteAll d` (the bash `mktemp`-then-`rm` reflex) when `within tmp d` is the weir spelling — and a STRICTLY better one: `within` removes the directory on scope exit AND on Ctrl+C/kill (the exit hook sweeps it [D:within-scopes]), which a straight-line delete misses when the body raises or the process is signalled. RULED an ADVISORY WARNING, not a gate (severity `warning`, check exits 0) — `newTempDir` is legitimate for the ESCAPING case (a directory that must OUTLIVE the scope, a cross-process handoff [D:gap-a-remainder]), so an UNMATCHED bind (no in-scope delete) stays SILENT: the lint fires only when the bind/delete PAIRING is visible, which is exactly the case that wanted `within`. MECHANISM mirrors [D:reenum-warning] (the whole-file threading precedent, since bind and delete sit statements apart): `Check.tempDirEvents` is the scope-threaded event walk (shadow-aware, drops a name at lambda/match/within/let-pattern boundaries; block-local binders join the tracked set for their body, top-level binders arrive via the tracker's map), and `Script.TempDirTracker` is the per-statement feed / post-fold flush, warning ONCE per binder at its first matched delete (a resolved binder is dropped, so a later stray delete of a reused name does not re-warn). POISONED like the siblings: any errored statement silences the pass (one real error beats advisory noise). Scripts only (a module cannot pair a bind with a delete). Both `Dir.delete` and `Dir.deleteAll` count; a delete of a DIFFERENT directory, or with no newTempDir binding, is silent. | src/Weir/Check.fs isNewTempDirCall + tempDirEvents; src/Weir/Script.fs TempDirTracker + analyzeLines feed/poison/flush (Code "temp-dir-cleanup"); tests/Weir.Tests/Tests.fs tempDirLintTests (deleteAll/delete warn, escaping/within/different-dir/no-binding silent, block-local, one-warning-per-binder, poison); skills/weir/SKILL.md Path.newTempDir note; CHANGELOG v0.0.50 | | toplevel-if-else | 2026-09-24 | A TOP-LEVEL BLOCK `if/else` NOW ASSEMBLES — the block form `if c then ` followed by a DEDENTED `else`/`elif ` at column 0 was a parse error (`'else' is a keyword`, backtrack). ROOT: the ASSEMBLER, not the expression parser. The `if` is its own logical statement, and the col-0 continuation gate ([D:retry-poll]'s precedent — a dedented `|`/`until`/`always` continues the statement above) admitted `|`, `until`, `always`, but not `else`/`elif`, so a col-0 `else` fell to the new-statement branch and the parser hit a stray keyword. The INDENTED form (inside a function/expression body — the else rides at the body's indent, `raw[0]=' '`) always worked, and the join machinery was already ready (`classifyPiece` yields `ElseHead`, and the `[D:pipe-alignment]` join extends the piece with "no sibling `;`, else keeps its standing rules"). FIX: add a col-0 `else`/`elif` to the same gate — a keyword-boundary check (`lw = "else" || lw.StartsWith "else " || lw.StartsWith "elif "`, so an identifier like `elsewhere` is untouched). Since `else`/`elif` are keywords they can ONLY continue an open `if`, never head a fresh statement, so the admission is unconditional (a stray one errors at parse exactly as a stray `until` does). Surfaced by the docker-postgres port [D:exec], which wanted a top-level `if firstRun then … else …`. | src/Weir/Script.fs assemble col-0 continuation gate (else/elif clause); tests/Weir.Tests/Tests.fs "top-level if/else assembles as ONE statement" (if/else, if/elif/else, multi-stmt then, checks-clean); CHANGELOG v0.0.50 | +| debug-optimize | 2026-09-24 | `true` IN EVERY CONFIGURATION — the F# compiler's NON-optimized debug-info generator ICEs (`FS0193 internal error: … (Parameter 'index')`) on the large `Builtins.fs` module. IT REPRODUCES DETERMINISTICALLY under `dotnet build`/`dotnet test` in the DEFAULT Debug config while `-c Release` (optimized) has ALWAYS been clean — which is why local Release work + the AOT publish never saw it and CI's `dotnet test tests/Weir.Tests` did. BISECTED to the module SIZE, not a construct: adding ~5 trivial top-level bindings to v0.0.49's Builtins reproduces the ICE, and removing the exact new bindings does not clear it — v0.0.49 sits at ZERO headroom in the Debug debug-info index (a standing hazard: any future Builtins growth re-triggers it). Every `DebugType` (portable/embedded/full) ICEs; only turning OFF symbols or turning ON the optimizer avoids it. RULED: optimize in Debug too — it reshapes the module past the overflow AND keeps the portable PDB (stack-trace line numbers intact, unlike DebugType=none), so the only cost is step-debug fidelity, accepted (weir dev is test-driven). Not an SDK pin (the earlier hypothesis): the trigger is our module size on the shipped compiler, not a floated SDK regression. Release build/publish unchanged (already optimized). | src/Weir/Weir.fsproj `true` | | eq-depth | 2026-09-21 | VALUE EQUALITY IS ITERATIVE (STRIX-2 / vuln-0006): a checker-accepted, legally-built recursive-record value (an Option-linked record folded ~100k deep via `Seq.fold`) crashed the whole process with an uncatchable StackOverflow on `==`, because `Value.Equals` recursed one stack frame per nesting level. RULED: the equality walk carries an explicit heap work-list of pending `(Value * Value)` pairs instead — scalars compare in place; VRecord (order-insensitive [D:record-order]), VUnion payloads, VTuple, and VMap entry values QUEUE their children; VSeq compares LOCKSTEP via enumerators (never materialize two lists, short-circuit at the first mismatch — the Seq.equal discipline); a mismatch drains the list. Every prior equality semantic is preserved (closures/builtins/proc/server by reference; bytes structural; floats [D:floats]). The `show`/interpolation renderer (`formatValue`) shared the same recursive-crash class — it now carries a finite MaxDepth (100, past the ~11 corpus max) with a teaching ellipsis, matching the REPL echo's existing depth bound. THE STANDING RULE: any new Value-walking helper must walk iteratively or carry a depth bound. | Eval.fs Value.Equals (work-list) + showLimits.MaxDepth; tests/Weir.Tests typeClassTests (200k-deep VRecord compares true, deep-vs-shallow false, 200k VSeq lockstep true / tail-mismatch false); CHANGELOG v0.0.48 Fixed | diff --git a/src/Weir/Weir.fsproj b/src/Weir/Weir.fsproj index 8b2cd0ce..6f3c23cf 100644 --- a/src/Weir/Weir.fsproj +++ b/src/Weir/Weir.fsproj @@ -5,6 +5,17 @@ net10.0 true true + + true