@clear-capabilities/agentic-security-scanner 0.195.13 → 0.195.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- 4fb0a3a5e772bc0558fbeda75768c8f0b657d976f2ae0e2b7782c6f3b9b91736 agentic-security.mjs
1
+ bb2980af58a2c5183cbe0c14efd1d611922784ee2f051111e2f3bf03d1846e79 agentic-security.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.195.13",
3
+ "version": "0.195.14",
4
4
  "description": "Scanner engine for the agentic-security Claude Code plugin: SAST, SCA with reachability and CISA KEV, secrets, IaC, Haskell and Nix/NixOS, prompt-injection, MCP/agent-tool audit, authZ analysis, SBOM, compliance attestation, and more.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -93,7 +93,7 @@
93
93
  "test:lineage": "node --test test/lineage/analysis-completion.test.js test/lineage/schema.test.js test/lineage/ids.test.js test/lineage/protection.test.js test/lineage/classification.test.js test/lineage/validate.test.js test/lineage/json-schema-parity.test.js test/lineage/flagship-fixture.test.js test/lineage/flagship-fixture-semantics.test.js test/lineage/field-identity.test.js test/lineage/engine-expr-resolver.test.js test/lineage/engine-walker.test.js test/lineage/engine-integration.test.js test/lineage/engine-receiver-identity.test.js test/lineage/summaries.test.js test/lineage/driver.test.js test/lineage/engine-provenance.test.js test/lineage/engine-provenance-interprocedural.test.js test/lineage/path-store.test.js test/lineage/path-query.test.js test/lineage/flow-grade.test.js test/lineage/source-registry.test.js test/lineage/sink-registry.test.js test/lineage/transform-catalog.test.js test/lineage/handling-analyzer.test.js test/lineage/registry-real-code.test.js test/lineage/source-seeding.test.js test/lineage/graph-builder.test.js test/lineage/coverage.test.js test/lineage/resolve-destination.test.js test/lineage/queue-detail.test.js test/lineage/index.test.js test/lineage/obligation-mapping.test.js test/lineage/obligation-predicates.test.js test/lineage/obligation-predicates-walkthrough.test.js test/lineage/transit-protection.test.js test/lineage/at-rest-protection.test.js test/lineage/protection-summary.test.js test/lineage/ac01-multi-sink.test.js test/lineage/policy-verdict.test.js test/lineage/governance-refs.test.js test/lineage/export-privacy.test.js test/lineage/export-json.test.js test/lineage/export-csv.test.js test/lineage/decision-story.test.js test/lineage/export-briefing.test.js test/lineage/graph-snapshot.test.js test/lineage/graph-diff.test.js test/lineage/drift-policy.test.js test/lineage/recipient-profile.test.js test/lineage/recipient-registry.test.js test/lineage/recipient-wiring.test.js test/lineage/redact-graph.test.js test/lineage-scan-wiring.test.js test/lineage-artifact-write.test.js test/bench-data-lineage-runner.test.js test/bundle-frontend.test.js test/bundle-frontend-golden.test.js test/generate-html-report.test.js test/export-image.test.js test/lineage/scenario.test.js test/lineage/scenario-engine.test.js test/lineage/scenario-diff.test.js test/lineage/scenario-no-obligation-wiring.test.js test/lineage/impact-assessment.test.js test/lineage/impact-engine.test.js test/lineage/impact-no-obligation-wiring.test.js test/lineage/governance-edit.test.js test/lineage/remediation.test.js test/lineage/runtime-observation.test.js test/lineage/observation-correlation.test.js test/lineage/observation-adapters.test.js test/lineage/observation-store.test.js test/lineage/runtime-corroboration-wiring.test.js test/lineage/cross-repo-link.test.js test/lineage/federation-loader.test.js test/lineage/cross-repo-link-wiring.test.js test/lineage/runtime-assurance.test.js test/lineage/runtime-edge-states.test.js test/lineage/runtime-explorer-view.test.js test/cli/runtime-explorer-cli.test.js test/cli/runtime-identity-cli.test.js test/cli/runtime-twin-cli.test.js test/lineage/cut-planner.test.js test/lineage/scc.test.js test/lineage/scc-solver.test.js test/graph/graph-views.test.js test/lineage/dag07-completion.test.js test/lineage/owned-build.test.js test/dataflow/boundary-propagation.test.js test/lineage/boundary-contract.test.js test/lineage/correlation-v2.test.js test/lineage/path-control-evidence.test.js test/lineage/smt-z3-backend.test.js test/cli/dag-commands.test.js",
94
94
  "test:bench-modules": "node --test test/phase4-harness.test.js test/pipeline.test.js test/proof-corpus-lib.test.js test/proof-corpus-runner.test.js",
95
95
  "test:server": "node --test test/server/graph-loader.test.js test/server/security.test.js test/server/routes.test.js test/server/http-server.test.js test/server/cmd-explore.test.js test/server/cmd-dataflow-export.test.js test/server/static-assets.test.js test/server/dataflow-scenario-cli.test.js test/server/dataflow-impact-cli.test.js",
96
- "test:haskell": "node --test test/haskell/haskell-reachability.test.js test/haskell/haskell-real-code-shapes.test.js test/haskell/haskell-unseen-v5-shapes.test.js test/haskell/haskell-unseen-v4-shapes.test.js test/haskell/haskell-alias-taint.test.js test/haskell/haskell-weak-hash-names.test.js test/haskell/haskell-models-extended.test.js test/haskell/haskell-rules-extended.test.js test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-remediation-0190.test.js test/haskell/haskell-remediation-kdf.test.js test/haskell/haskell-remediation-html-text.test.js test/haskell/haskell-remediation-scotty-body.test.js test/haskell/haskell-taint-shapes.test.js test/haskell/haskell-real-advisory-shapes.test.js test/haskell/haskell-discovery-shapes.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-sca-severity.test.js test/haskell/haskell-web-guards.test.js test/haskell/haskell-advisory-feed.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js test/haskell/haskell-boundaries.test.js test/haskell/haskell-live-feed-scan.test.js test/haskell/haskell-supply-bounds.test.js test/haskell/haskell-ir-scaling.test.js test/haskell/haskell-boundary-propagation.test.js test/haskell/haskell-generalization.test.js test/haskell/haskell-trust-boundary.test.js test/haskell/haskell-comment-invariance.test.js test/haskell/haskell-remediation-classes.test.js test/haskell/haskell-trust-boundary-frameworks.test.js test/haskell/haskell-generic-detectors.test.js",
96
+ "test:haskell": "node --test test/haskell/haskell-reachability.test.js test/haskell/haskell-real-code-shapes.test.js test/haskell/haskell-unseen-v5-shapes.test.js test/haskell/haskell-unseen-v4-shapes.test.js test/haskell/haskell-alias-taint.test.js test/haskell/haskell-weak-hash-names.test.js test/haskell/haskell-models-extended.test.js test/haskell/haskell-rules-extended.test.js test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-remediation-0190.test.js test/haskell/haskell-remediation-kdf.test.js test/haskell/haskell-remediation-html-text.test.js test/haskell/haskell-remediation-scotty-body.test.js test/haskell/haskell-taint-shapes.test.js test/haskell/haskell-real-advisory-shapes.test.js test/haskell/haskell-discovery-shapes.test.js test/haskell/haskell-quasiquote-interp.test.js test/haskell/haskell-record-dot.test.js test/haskell/haskell-global-cell.test.js test/haskell/haskell-syntax-extensions.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-sca-severity.test.js test/haskell/haskell-web-guards.test.js test/haskell/haskell-advisory-feed.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js test/haskell/haskell-boundaries.test.js test/haskell/haskell-live-feed-scan.test.js test/haskell/haskell-supply-bounds.test.js test/haskell/haskell-ir-scaling.test.js test/haskell/haskell-boundary-propagation.test.js test/haskell/haskell-generalization.test.js test/haskell/haskell-trust-boundary.test.js test/haskell/haskell-comment-invariance.test.js test/haskell/haskell-remediation-classes.test.js test/haskell/haskell-trust-boundary-frameworks.test.js test/haskell/haskell-generic-detectors.test.js",
97
97
  "test:nix": "node --test test/nix/nix-option-types.test.js test/nix/nix-development-shapes.test.js test/nix/nixos-hardening-daemon.test.js test/nix/nixos-hardening-extended.test.js test/nix/nix-build-trust.test.js test/nix/nix-eval-isolation.test.js test/nix/nix-input-inventory.test.js test/nix/nix-parser-ir.test.js test/nix/nix-remediation.test.js test/nix/nix-resolved-closure.test.js test/nix/nix-sca-patches.test.js test/nix/nix-sca-severity.test.js test/nix/nix-haskell-closure-names.test.js test/nix/nix-script-taint.test.js test/nix/nix-secrets-store.test.js test/nix/nixos-hardening.test.js test/nix/nixos-module-resolution.test.js test/nix/nix-fix-multifile.test.js test/nix/nixos-evaluator-forms.test.js test/nix/nix-advisory-feed.test.js test/nix/nix-fix-prewrite.test.js test/nix/nix-boundary-scope.test.js",
98
98
  "test:language": "node --test test/bench-haskell-precision.test.js test/bench-independent-haskell-panel.test.js test/language/capability-ledger.test.js test/language/language-accuracy-regression.test.js test/language/language-aibom.test.js test/language/language-assurance.test.js test/language/language-bom.test.js test/language/language-compliance.test.js test/language/language-contracts.test.js test/language/language-corpora-integrity.test.js test/language/language-discovery.test.js test/language/language-doc-coverage.test.js test/language/language-evidence-posture.test.js test/language/language-explorer.test.js test/language/frozen-splits.test.js test/language/language-gap-fixes.test.js test/language/language-gap-fixes-2.test.js test/language/language-governance.test.js test/language/language-integrations.test.js test/language/language-llm-agent.test.js test/language/language-local-ai.test.js test/language/language-parity-haskell.test.js test/language/language-polyglot-bridges.test.js test/language/language-privacy-lineage.test.js test/language/language-proof-remediation.test.js test/language/language-report-formats.test.js test/language/language-secrets.test.js test/language/language-trust-boundary.test.js",
99
99
  "test:language-stress": "node --test --test-concurrency=1 test/language/language-stress-offline.test.js",
@@ -824,11 +824,13 @@ function _isInternalModuleCall(calleeExpr, callContext) {
824
824
  const _JS_FILE = /\.(?:[cm]?[jt]sx?)$/i;
825
825
  function _isJsFile(file) { return typeof file === 'string' && _JS_FILE.test(file); }
826
826
 
827
- function _arrayLiteralTaint(expr, prefix, state, callContext) {
827
+ function _arrayLiteralTaint(expr, prefix, state, callContext, bracket = false) {
828
828
  const out = { whole: false, paths: [] };
829
829
  (expr.elements || []).forEach((el, i) => {
830
830
  if (!el) return;
831
- const path = prefix ? `${prefix}.${i}` : String(i);
831
+ // Haskell reads a tuple component as the member `[i]` (haskell-ir.js bindPattern), so its paths are written that way (`bracket`).
832
+ const seg = bracket ? `[${i}]` : String(i);
833
+ const path = prefix ? `${prefix}.${seg}` : seg;
832
834
  if (el.kind === 'object') {
833
835
  const inner = _objectLiteralTaint(el, path, state, callContext);
834
836
  if (inner.whole) out.paths.push(path);
@@ -1632,6 +1634,8 @@ function step(node, stateIn, callContext) {
1632
1634
  case 'assign': {
1633
1635
  const src = exprIsSource(node.source, callContext);
1634
1636
  const target = typeof node.target === 'string' ? node.target : null;
1637
+ // A Haskell module-level cell written with tainted data: every function reads it tainted from now on (see _cellSeeds).
1638
+ if (target && target.startsWith('$cell:') && !_cellSeeds.has(target) && exprTaint(node.source, state, callContext)) _cellSeeds.add(target);
1635
1639
  // Sink matching is additive to this case's existing source/target/taint
1636
1640
  // handling below: an assignment's RHS can itself be a sink call (e.g.
1637
1641
  // `const rows = db.query(tainted)`), which previously went unreported
@@ -1829,10 +1833,13 @@ function step(node, stateIn, callContext) {
1829
1833
  }
1830
1834
  }
1831
1835
  }
1832
- if (target && node.source && node.source.kind === 'array' && !src && _isJsFile(_currentFile) && !(node.source.spreads && node.source.spreads.length)) {
1836
+ // Haskell: a tuple literal (`hs.tuple`) is assigned component by component the same way, so `(a, _) = (clean, tainted)` leaves `a` clean
1837
+ // (a `\cases` or a `case` on `(x, y)` matches through a tuple scratch variable). Other Haskell arrays (lists, constructors) keep the whole rule.
1838
+ const hsTuple = node.source && node.source.kind === 'array' && node.source.hs && node.source.hs.tuple && /\.l?hs$/i.test(_currentFile || '');
1839
+ if (target && node.source && node.source.kind === 'array' && !src && (_isJsFile(_currentFile) || hsTuple) && !(node.source.spreads && node.source.spreads.length)) {
1833
1840
  // E2-03: an array literal without spread elements is assigned index by index, so `[tainted, 'ls']` leaves `arr[1]` clean. Reading the
1834
1841
  // array as a whole (`arr.join()`, passing `arr`) is still tainted through the descendant rule. A literal with a spread keeps the whole-array rule.
1835
- const lit = _arrayLiteralTaint(node.source, '', newState, callContext);
1842
+ const lit = _arrayLiteralTaint(node.source, '', newState, callContext, !!hsTuple);
1836
1843
  newState = removePathAndDescendants(newState, target);
1837
1844
  if (lit.whole) newState = _addPathAliasAware(newState, target, callContext);
1838
1845
  for (const p of lit.paths) newState = _addPathAliasAware(newState, `${target}.${p}`, callContext);
@@ -2228,8 +2235,13 @@ function _siteKey(expr) {
2228
2235
  // only for fields some function assigns, so `req.other` stays clean. Cross-file middleware chains need a router model and are out of scope.
2229
2236
  const _REQ_PARAM = /^(?:req|request|ctx)$/;
2230
2237
  let _requestFieldSeeds = new Map(); // file -> Set<'field' | 'a.b'>
2238
+ // Haskell module-level cells (`$cell:<qid>`, haskell-ir.js lowerCellOp): a cell that any function wrote with tainted data is tainted at the
2239
+ // entry of every Haskell function from then on (flow-insensitive, project-wide, like a global). Filled while the fixed-point pre-pass walks
2240
+ // the writers; a change re-runs the pass so the readers see it.
2241
+ let _cellSeeds = new Set();
2231
2242
  function _buildRequestFieldSeeds(fnList, callGraph, opts, deadlineMs) {
2232
2243
  _requestFieldSeeds = new Map();
2244
+ _cellSeeds = new Set();
2233
2245
  for (const fn of fnList) {
2234
2246
  if (!fn || !Array.isArray(fn.params) || !_REQ_PARAM.test(fn.params[0] || '') || !fn.cfg || !fn.cfg.nodes) continue;
2235
2247
  const p0 = fn.params[0];
@@ -2253,6 +2265,7 @@ function _buildRequestFieldSeeds(fnList, callGraph, opts, deadlineMs) {
2253
2265
  }
2254
2266
 
2255
2267
  function _unionAnnotationTaint(fn, entrySet) {
2268
+ if (_cellSeeds.size && /\.l?hs$/i.test(fn.file || '')) entrySet = new Set([...entrySet, ..._cellSeeds]);
2256
2269
  const seeds = _requestFieldSeeds.get(fn.file);
2257
2270
  if (seeds && seeds.size && Array.isArray(fn.params) && _REQ_PARAM.test(fn.params[0] || '')) {
2258
2271
  entrySet = new Set([...entrySet, ...[...seeds].map((f) => `${fn.params[0]}.${f}`)]);
@@ -2595,6 +2608,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2595
2608
  // iteration; if nothing changed, the fixed point has been reached and
2596
2609
  // further iterations would recompute byte-identical results.
2597
2610
  let changedThisIter = false;
2611
+ const cellSeedsBefore = _cellSeeds.size;
2598
2612
  for (const fn of fnList) {
2599
2613
  if (Date.now() > deadlineMs) break;
2600
2614
  // DAG-05 begin: a function proved clean is not re-walked; its recorded exit state and findings stand in.
@@ -2659,6 +2673,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2659
2673
  // write, silently delivering 2 rounds of fixed-point refinement instead
2660
2674
  // of the MAX_FP_ITERS=3 this code and dataflow/CLAUDE.md both promise.
2661
2675
  // `changedThisIter` tracks actual value changes instead.
2676
+ if (_cellSeeds.size !== cellSeedsBefore) changedThisIter = true; // a newly tainted Haskell cell changes every reader's entry state
2662
2677
  if (!changedThisIter) { _fpConverged = true; break; }
2663
2678
  }
2664
2679
  // DAG-05 begin
@@ -66,6 +66,8 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
66
66
  - **Guards are recognised by what they do.** `haskell-guards.js`: a project function is an HTML escaper when it (or its local helpers) spells out the entities for `<`, `>` and `&` (`htmlEscapers`; never by name), and it neutralises a CWE-79 flow only when EVERY use of the value in the sink argument is under such a call (`allUsesEscaped`); a host allow-list may be a named constant and may sit in an `&&`; `sh -c SCRIPT arg0 arg1` is shell injection only when the SCRIPT is built from the value, and tainted positional parameters are data when the constant script expands them only inside double quotes after `--` (`quotedPositionals`, which decodes the Haskell string escapes the IR keeps raw).
67
67
  - **Handlers named by the framework's convention are routes.** `haskell-web.js` adds `getFooR`/`postFooR`/`handleFooR` functions whose signature mentions `Handler` in a module importing Yesod when no route table in an analysed file names them (the table usually lives in `config/routes`). Scotty's rejection order is judged by `inlineOrder` as well as by line: a `case` lists the success branch first, so `status 40x >> finish` in the LAST alternative guards it (a bare `status` with no halting call does not).
68
68
  - **A project definition of a modelled name keeps the registry's meaning (since 0.195.11).** `haskell-ir.js` `applyVar`/`lowerVarValue`: a resolution to a project `def` whose module-qualified name `isKnownApi` (the project IS the framework, or vendors it) is lowered as the LIBRARY call (`hs.status: 'external'`, `hs.modelledDefinition: qid`) and the definition is still referenced (`noteRef(..., 'modelled-definition')`, so it is never an uncalled entry point). Before this, IHP's own `IHP.Controller.Param.param` shadowed the catalog when IHP scanned itself, and a request parameter reaching `Process.system` through a helper was not reported (four lost true positives on the fresh set). A test stub named like a catalog module gets the same treatment, which is why the html-text remediation test keeps its scotty stub out of the scan. Pinned both ways in `test/haskell/haskell-taint-shapes.test.js`, with the three shapes below.
69
+ - **Record dot, labels, global cells, lambda-form functions, more syntax (since 0.195.14).** `haskell-syntax.js` reads `r.field` / `(e).field` / `(.field)` as a field selection (`sel` token, `RECORD_DOT_PRAGMA`; the IR lowers `field r` to a member read of a known record field) and `#field` as a label value (`LABELS_PRAGMA`; the IR lowers it to a literal, so IHP's `get #f r` / `set #f v r` are library calls carrying the record's and the value's taint, not a field selection: a stated limit); both only under the LANGUAGE pragma. `\cases` (`lamcases`), `mdo` (a `do` under `RECDO_PRAGMA`; `rec` blocks are not read), GADT `data T where` constructors (names, record fields, arity by arrows) and `pattern P ..` (a `patsyn` declaration, not a function named `pattern`) parse. `haskell-ir.js` `lambdaBodied` rewrites `f = \x -> ..`, `f = \case ..`, `f = \cases ..` into `f x = ..` at the top level and in `let`/`where` (a call then resolves to the function instead of applying a closure value). `cellBodyOf` recognises a module-level cell (`name = unsafePerformIO (newIORef ..)`, MVar/TVar/STRef alike) and `lowerCellOp` names it `$cell:<qid>` for the whole project (a read is that ident with `hs.globalCell`, a write an assignment to it); `dataflow/engine.js` `_cellSeeds` records a cell any function assigned tainted data to and `_unionAnnotationTaint` seeds it at the entry of every Haskell function, the fixed-point pre-pass re-running when the set grows (flow-insensitive; the root is not carried across, so the trust verdict of the reader is `unattributed`). `aliasBodyOf` accepts a partial application of a library function to names or literals (`remember = writeIORef lastName`), with the argument names checked for shadowing. `haskell-models.js`: typed-process `proc` (CWE-78 program, CWE-88 arguments), esqueleto `rawSql`/`rawExecute`, optparse-applicative `execParser`/`customExecParser` as a `cli` source, versions recorded in `HS_PACKAGES`. Pinned in `test/haskell/haskell-{record-dot,global-cell,syntax-extensions}.test.js`.
70
+ - **String-interpolating quasi-quoters (since 0.195.14; `test/haskell/haskell-quasiquote-interp.test.js`).** `haskell-parser.js` `INTERP_QUOTERS` maps a module to its quoters and interpolation syntax (`hash` `#{e}`: Data.String.Interpolate, Data.String.Interpolate.IsString, Text.Shakespeare.Text; `dollar` `$name`/`${e}`: NeatInterpolation; `brace` `{e}`/`{e:spec}`: PyF); `interpolationsOf` reads them (escapes `$$`, `{{`; a format spec after a top-level `:` is dropped). An accepted quote is rewritten in place, same length and newlines, as `(iQ (e1) (e2))` (kind `quasiquote-interp`, non-opaque, assumption `upstream-quoter`); a literal-only body stays `quasiquote-inert`; an unimported module or an unknown quoter stays `quasiquote` (opaque). `haskell-ir.js` `applyVar` lowers an UNBOUND `iQ` application to a concatenation (`binary +`, `hs.concat`, `hs.quasiquote`), so the taint engine follows it and a sanitizer inside an interpolation is on the path. The template text is not modelled (no markup literal for `htmlSkeleton` sinks).
69
71
  - **The discovery set and two layout rules (since 0.195.13; `bench/dag/discovery-haskell`, pinned in `test/haskell/haskell-discovery-shapes.test.js`).** Six projects the engine had never been run on (hoogle, the wai and servant monorepos, mighttpd2, postgres-websockets, the yesod scaffold), scanned once and every flagged site read and labelled (`labels.json`, `USE-LOG.md`): a DEVELOPMENT set, not the precision gate's fresh set and not the sealed set. Hoogle's request handler was dropped by two layout rules, fixed in BOTH parsers: a `where` that starts a line at an implicit block's own indent closes the block (Haskell's parse-error(t) rule; `haskell-parser.js` `openBlock`, as `haskell-syntax.js` `blockAfter` already did), and a `;` separates the items of an implicit block only on a line that starts at the block's indent and opened no nested block (`do`, `of`, `let`, `where`, `\case`) earlier on that line (`splitItems` in both; `s <- act pay; pure s` on one line of a nested `do`, `remod t = do (a,_) <- f t; pure a` in a `where`). The route-contract detector (`posture/api-contract.js` `contractScope`) now scopes a contract file to the package that holds it, which is what turned a servant monorepo's two example `swagger.json` files into 78 Haskell route findings.
70
72
  - **Shapes from the real code behind published advisories (since 0.195.12; `bench/cve-replay/real-haskell-advisories.md`, pinned in `test/haskell/haskell-real-advisory-shapes.test.js`).** `haskell-syntax.js`: a record pattern accepts whitespace before its brace (`Con {..}`; a `{` after a constructor inside a pattern is always a record pattern), a top-level `|` after an `of`, a `\case` or a multiway `if` inside a list literal is that construct's guard and not a comprehension bar, a `$(..)`/`$$(..)`/`$name` in prefix position is one `splice` token when the module's LANGUAGE pragma enables TemplateHaskell (`TH_PRAGMA`; a cabal `default-extensions` entry is not seen, a stated limit) and `aexp` reads it as an opaque `{t:'splice'}` atom (the IR lowers it to an unknown value without a lowering-gap diagnostic, the parser's `th-splice` boundary already discloses it), and `var@Pat = ..` in a declaration is a pattern binding, not a function named by the variable. `haskell-parser.js`: the Hamlet replacement closes its parenthesis at the `|]` (it used to go after the last `}`, a newline when the interpolation ended its line, so the paren was lost and the function failed to parse). `haskell-ir.js`: `fieldFunctionCandidates(con, field)` (the project class) indexes the function values every construction of `con` stores in `field` (`Con { f = g }`, `Con{..}` with `f <- pure g` / `f = g` in the building clause, `case`/`if` branches, `pure`/`return` peeled, `const <$> e` / `fmap const e` / `const e` as `ignores`; a lambda or anything else is `opaque`); a record-pattern-bound variable remembers its field (`{kind:'local', field:{con,name}}`, set in `bindPattern`) and `applyFieldCandidates` lowers the application as the union over the candidates (names applied in the module of the construction, `ignores` as an unknown value not derived from the arguments), only when NO candidate is opaque. `dataflow/engine.js` `_nestedSinkFindings` walks the elements of a Haskell tuple or constructor `array` (`hs.tuple`/`hs.con`) like arguments, so a sink built inside `return (a, Con (sink ..))` is a site. `haskell-guards.js` `waiHtmlContext` returns `redirect` for a header list with `Location` and no content type and the finding is lowered to info (`redirect-response-body`). `haskell-models.js`: blaze-builder's HTML escapers are xss sanitizers (names read from the Hackage index, `tested: ['0.4.4']`). `posture/corpus-match.js` scores neither a `proven-*` finding nor an `info` finding with a `guardNote` (see `bench/cve-replay/CONTRIBUTING.md`).
71
73
  - **Mutable cells, instance dispatch, functions in record fields (since 0.195.11).** `haskell-ir.js`: `CELL_OPS` lowers `writeIORef ref v` (MVar, TVar, STRef alike) to an assignment of the cell's local variable, a read to that variable, `modifyIORef ref f` to `ref = f ref` (`lowerCellOp`; only a cell held in a local variable, never across functions, disclosed in the guide). `dispatchInstance`: a class-method call with a constructor argument of a type that has exactly one visible instance resolves to that instance method (`hs.status: 'resolved'`, `hs.dispatchedOn`); instance functions are now named `Mod.$inst.Class.Type.method` (`instanceMeta`, `instanceTypeOf` reads the instance head) so two instances of one class resolve to different functions by name, which the dataflow engine needs because it resolves callees by name. `heldFieldFunction`: `exec ops x` with `ops` a top-level constant record literal applies the field's expression (lambda or name), lowered in the constant's module with the arguments lowered in the caller's. The cell and STM names are in `HS_RULE_APIS` so wildcard imports qualify them.
@@ -199,6 +199,7 @@ function buildModuleInfo(file, src, cpp) {
199
199
  const pp = preprocessForSyntaxInfo(src, { file, cpp });
200
200
  const parse = parseSyntax(pp.text);
201
201
  const groups = groupDecls(parse.decls);
202
+ groups.funs = groups.funs.map(lambdaBodied);
202
203
  const name = parse.module && parse.module.name ? parse.module.name : 'Main';
203
204
  const mod = {
204
205
  name, file, parse, groups, exports: parse.module ? parse.module.exports : null, imports: parse.imports,
@@ -479,14 +480,54 @@ function aliasBodyOf(fnAst) {
479
480
  const c = fnAst.clauses[0];
480
481
  if (c.pats.length || !c.rhs || (c.rhs.guards && c.rhs.guards.length) || (c.rhs.where && c.rhs.where.length) || !c.rhs.body) return null;
481
482
  const names = [];
483
+ const argNames = []; // names the alias applies its function to (`remember = writeIORef lastName`): checked for shadowing, not for being library names
482
484
  const ok = (e) => {
483
485
  const x = unparen(e);
484
486
  if (!x) return false;
485
487
  if (x.t === 'var') { if (!x.qual) names.push(x.name); return x.name !== fnAst.name; }
486
488
  if (x.t === 'op' && x.op === '.' && !x.qual) return ok(x.l) && ok(x.r);
489
+ // A partial application of a library function to names or literals is an alias of that function with those arguments fixed.
490
+ if (x.t === 'app') return ok(x.f) && x.args.every((a) => { const u = unparen(a); if (!u) return false; if (u.t === 'lit') return true; if (u.t === 'var') { if (!u.qual) argNames.push(u.name); return u.name !== fnAst.name; } return false; });
487
491
  return false;
488
492
  };
489
- return ok(c.rhs.body) ? { body: c.rhs.body, names } : null;
493
+ return ok(c.rhs.body) ? { body: c.rhs.body, names, argNames } : null;
494
+ }
495
+
496
+ // `f = \x y -> ..`, `f = \case ..`, `f = \cases ..`: a function written in lambda form is the function `f x y = ..` (a `\case` takes one
497
+ // parameter and matches it; a `\cases` takes as many as its widest alternative and matches them as a tuple), so a call `f c` is resolved
498
+ // and analysed with the value it is given instead of being the unknown application of a closure value. Applied to top-level, `let` and
499
+ // `where` bindings alike (a guard or a `where` on the binding keeps it as written).
500
+ function lambdaBodied(f) {
501
+ if (!f || f.clauses.length !== 1) return f;
502
+ const c = f.clauses[0];
503
+ const body = c.pats.length === 0 && c.rhs && !(c.rhs.guards && c.rhs.guards.length) && !(c.rhs.where && c.rhs.where.length) ? unparen(c.rhs.body) : null;
504
+ if (!body) return f;
505
+ if (body.t === 'lam' && body.params.every((p) => p && p.t === 'pvar')) return { ...f, clauses: [{ ...c, pats: body.params, rhs: { ...c.rhs, body: body.body } }] };
506
+ if (body.t === 'lamcase') return { ...f, clauses: [{ ...c, pats: [{ t: 'pvar', name: '$lc' }], rhs: { ...c.rhs, body: { t: 'case', scrut: { t: 'var', name: '$lc', qual: null }, alts: body.alts, line: body.line } } }] };
507
+ if (body.t === 'lamcases') {
508
+ const n = Math.max(1, ...body.alts.map((a) => (a.pats || []).length));
509
+ const pats = Array.from({ length: n }, (_, i) => ({ t: 'pvar', name: `$lc${i}` }));
510
+ const scrut = n === 1 ? { t: 'var', name: '$lc0', qual: null } : { t: 'tuple', items: pats.map((p) => ({ t: 'var', name: p.name, qual: null })) };
511
+ const alts = body.alts.map((a) => ({ pat: n === 1 ? (a.pats[0] || { t: 'pwild' }) : { t: 'ptuple', items: Array.from({ length: n }, (_, i) => a.pats[i] || { t: 'pwild' }) }, rhs: a.rhs, line: a.line }));
512
+ return { ...f, clauses: [{ ...c, pats, rhs: { ...c.rhs, body: { t: 'case', scrut, alts, line: body.line } } }] };
513
+ }
514
+ return f;
515
+ }
516
+
517
+ // A module-level mutable cell: `name = unsafePerformIO (newIORef ..)` (MVar, TVar, STRef alike; the NOINLINE pragma is a comment the parser
518
+ // does not see). A write anywhere in the project reaches a read anywhere (see lowerCellOp and the taint engine's cell seeds).
519
+ const CELL_NEW = new Set(['newIORef', 'newMVar', 'newEmptyMVar', 'newTVarIO', 'newTVar', 'newSTRef']);
520
+ function cellBodyOf(fnAst) {
521
+ if (!fnAst || fnAst.clauses.length !== 1) return false;
522
+ const c = fnAst.clauses[0];
523
+ if (c.pats.length || !c.rhs || !c.rhs.body || (c.rhs.guards && c.rhs.guards.length)) return false;
524
+ const b = unparen(c.rhs.body);
525
+ let inner = null;
526
+ if (b && b.t === 'app' && b.args.length === 1) { const h = unparen(b.f); if (h && h.t === 'var' && h.name === 'unsafePerformIO') inner = unparen(b.args[0]); }
527
+ else if (b && b.t === 'op' && b.op === '$') { const h = unparen(b.l); if (h && h.t === 'var' && h.name === 'unsafePerformIO') inner = unparen(b.r); }
528
+ if (!inner) return false;
529
+ const head = inner.t === 'app' ? unparen(inner.f) : inner;
530
+ return !!(head && head.t === 'var' && CELL_NEW.has(head.name));
490
531
  }
491
532
 
492
533
  // ── per-function lowering ────────────────────────────────────────────────────
@@ -575,6 +616,14 @@ class FnLowerer {
575
616
  case 'multiif': return this.lowerGuards(e.guards, ln);
576
617
  case 'case': return this.lowerCase(e, ln);
577
618
  case 'lamcase': return this.liftClosure({ params: [{ t: 'pvar', name: '$lc' }], body: { t: 'case', scrut: { t: 'var', name: '$lc', qual: null }, alts: e.alts, line: ln }, line: ln }, ln);
619
+ // `\cases`: a lambda of as many parameters as the widest alternative, matched as a tuple.
620
+ case 'lamcases': {
621
+ const n = Math.max(1, ...e.alts.map((a) => (a.pats || []).length));
622
+ const params = Array.from({ length: n }, (_, i) => ({ t: 'pvar', name: `$lc${i}` }));
623
+ const scrut = n === 1 ? { t: 'var', name: '$lc0', qual: null } : { t: 'tuple', items: params.map((p) => ({ t: 'var', name: p.name, qual: null })) };
624
+ const alts = e.alts.map((a) => ({ pat: n === 1 ? (a.pats[0] || { t: 'pwild' }) : { t: 'ptuple', items: Array.from({ length: n }, (_, i) => a.pats[i] || { t: 'pwild' }) }, rhs: a.rhs, line: a.line }));
625
+ return this.liftClosure({ params, body: { t: 'case', scrut, alts, line: ln }, line: ln }, ln);
626
+ }
578
627
  case 'lam': return this.liftClosure(e, ln);
579
628
  case 'let': {
580
629
  this.push();
@@ -592,6 +641,9 @@ class FnLowerer {
592
641
  // A Template Haskell splice in expression position: generated code the analysis never sees. The parser has already disclosed the
593
642
  // `th-splice` boundary for the module, so this is not a lowering gap of the IR; the value is unknown.
594
643
  case 'splice': return unknownExpr({ splice: true });
644
+ // `#name` (OverloadedLabels): a label value, a literal naming the field. An IHP `get #name r` is then a library call with the label and
645
+ // the record as arguments (the record's taint reaches the result; the field is not selected, a stated limit).
646
+ case 'label': return { kind: 'literal', value: e.name, hs: { label: true } };
595
647
  default: this.diagnostics.push({ kind: 'lowering-gap', detail: e.t || 'unknown' }); return unknownExpr({ ast: e.t || 'unknown' });
596
648
  }
597
649
  }
@@ -697,6 +749,12 @@ class FnLowerer {
697
749
 
698
750
  applyVar(f, argAsts, ln, via) {
699
751
  const r = this.resolve(f.name, f.qual);
752
+ // `iQ`: the parser's stand-in for a string-interpolating quasi-quote (`[i|.. #{a} .. #{b}|]` becomes `(iQ (a) (b))`, haskell-parser.js
753
+ // INTERP_QUOTERS): the value is the concatenation of the interpolated expressions. Only the unbound name is taken; a project `iQ` is itself.
754
+ if (f.name === 'iQ' && !f.qual && r.kind === 'unknown' && argAsts.length) {
755
+ const parts = argAsts.map((a) => this.lowerExpr(a, ln));
756
+ return parts.reduce((acc, p) => ({ kind: 'binary', op: '+', left: acc, right: p, hs: { concat: '<>', quasiquote: true } }));
757
+ }
700
758
  // Bounded collection summaries only apply to library names, never a user definition.
701
759
  if (HOF[f.name] && (r.kind === 'builtin' || r.kind === 'external')) {
702
760
  const out = this.lowerHof(f.name, argAsts, ln);
@@ -903,13 +961,27 @@ class FnLowerer {
903
961
  const op = CELL_OPS[callee];
904
962
  if (!op) return null;
905
963
  const ref = args[0];
906
- if (!ref || ref.kind !== 'ident' || !ref.name || ref.hs) return null;
964
+ let name = null; let global = false;
965
+ if (ref && ref.kind === 'ident' && ref.name && !ref.hs) name = ref.name;
966
+ else {
967
+ // A module-level cell (cellBodyOf): one variable for the whole project, `$cell:<qid>`, which the taint engine seeds across functions.
968
+ const u = unparen(argAsts[0]);
969
+ if (u && u.t === 'var') {
970
+ const r = this.resolve(u.name, u.qual);
971
+ if (r.kind === 'def' && r.def.kind === 'fun' && r.def.arity === 0) {
972
+ const mod = this.proj.modules.get(r.mod);
973
+ if (cellBodyOf(mod && mod.groups.funs.find((f) => f.name === r.name))) { name = `$cell:${r.def.qid}`; global = true; this.noteRef(r.def.qid, 'reference', ln); }
974
+ }
975
+ }
976
+ }
977
+ if (!name) return null;
907
978
  this.sawIO = true;
908
- if (op === 'read') return { kind: 'ident', name: ref.name };
909
- if (op === 'write' && args.length >= 2) { this.cfg.add('assign', ln, { target: ref.name, source: args[1], hs: { demand: 'executed', cellWrite: callee } }); return call; }
979
+ const cell = global ? { kind: 'ident', name, hs: { globalCell: true } } : { kind: 'ident', name };
980
+ if (op === 'read') return cell;
981
+ if (op === 'write' && args.length >= 2) { this.cfg.add('assign', ln, { target: name, source: args[1], hs: { demand: 'executed', cellWrite: callee, ...(global ? { globalCell: true } : {}) } }); return call; }
910
982
  if (op === 'modify' && argAsts.length >= 2) {
911
- const applied = this.lowerApp(argAsts[1], [{ t: 'lowered', expr: { kind: 'ident', name: ref.name } }], ln, callee);
912
- this.cfg.add('assign', ln, { target: ref.name, source: applied, hs: { demand: 'executed', cellWrite: callee } });
983
+ const applied = this.lowerApp(argAsts[1], [{ t: 'lowered', expr: cell }], ln, callee);
984
+ this.cfg.add('assign', ln, { target: name, source: applied, hs: { demand: 'executed', cellWrite: callee, ...(global ? { globalCell: true } : {}) } });
913
985
  return call;
914
986
  }
915
987
  return null;
@@ -933,7 +1005,7 @@ class FnLowerer {
933
1005
  alias = calleeMod ? aliasBodyOf(calleeMod.groups.funs.find((f) => f.name === r.name)) : null;
934
1006
  }
935
1007
  if (!alias || (this.inlineDepth || 0) >= INLINE_MAX_DEPTH) return null;
936
- if (alias.names.some((n) => this.lookupLocal(n))) return null;
1008
+ if ([...alias.names, ...(alias.argNames || [])].some((n) => this.lookupLocal(n))) return null;
937
1009
  const savedMod = this.mod;
938
1010
  // Only an alias of LIBRARY functions is expanded. A project function keeps its own summary and call-graph node, and
939
1011
  // expanding it here would replace the edge to it with edges to what it calls.
@@ -1258,12 +1330,7 @@ class FnLowerer {
1258
1330
  const g = groupDecls(decls);
1259
1331
  // `let run = \x -> ...` is a function of one parameter written in lambda form: treat it as `run x = ...` so a call `run c` is resolved and analysed
1260
1332
  // for the value it is given, instead of being an unknown call of a local value.
1261
- g.funs = g.funs.map((f) => {
1262
- if (f.clauses.length !== 1) return f;
1263
- const c = f.clauses[0];
1264
- const body = c.pats.length === 0 && c.rhs && !(c.rhs.guards && c.rhs.guards.length) && !(c.rhs.where && c.rhs.where.length) ? unparen(c.rhs.body) : null;
1265
- return body && body.t === 'lam' && body.params.every((p) => p && p.t === 'pvar') ? { ...f, clauses: [{ ...c, pats: body.params, rhs: { ...c.rhs, body: body.body } }] } : f;
1266
- });
1333
+ g.funs = g.funs.map(lambdaBodied);
1267
1334
  const scope = this.scopes[this.scopes.length - 1];
1268
1335
  const parent = this.info.parentName || this.info.name;
1269
1336
  for (const f of g.funs) {
@@ -43,6 +43,9 @@ export const HS_PACKAGES = Object.freeze({
43
43
  wreq: { modules: ['Network.Wreq', 'Network.Wreq.Session'], tested: ['0.5'] },
44
44
  'blaze-html': { modules: ['Text.Blaze', 'Text.Blaze.Html', 'Text.Blaze.Html5', 'Text.Blaze.Internal'], tested: ['0.9'] },
45
45
  'blaze-builder': { modules: ['Blaze.ByteString.Builder.Html.Utf8', 'Blaze.ByteString.Builder.Html.Word'], tested: ['0.4.4'] },
46
+ 'typed-process': { modules: ['System.Process.Typed'], tested: ['0.2.13'] },
47
+ esqueleto: { modules: ['Database.Esqueleto', 'Database.Esqueleto.Experimental', 'Database.Esqueleto.Legacy'], tested: ['3.6'] },
48
+ 'optparse-applicative': { modules: ['Options.Applicative'], tested: ['0.19'] },
46
49
  lucid: { modules: ['Lucid', 'Lucid.Base'], tested: ['2.11'] },
47
50
  shakespeare: { modules: ['Text.Hamlet', 'Text.Shakespeare'], tested: ['2.0'] },
48
51
  text: { modules: ['Data.Text', 'Data.Text.IO', 'Data.Text.Lazy', 'Data.Text.Lazy.IO', 'Data.Text.Encoding'], tested: ['2.0'] },
@@ -98,6 +101,8 @@ src('Data.ByteString', ['hGetLine', 'hGetContents'], 'file-read', 'bytes input',
98
101
  src('Data.ByteString.Char8', ['getLine', 'getContents'], 'stdin', 'bytes input', { package: 'bytestring' });
99
102
  src('Data.ByteString.Char8', ['hGetLine', 'hGetContents'], 'file-read', 'bytes input', { package: 'bytestring' });
100
103
  src('System.Environment', ['getArgs'], 'cli', 'command-line argument', { package: 'base' });
104
+ // optparse-applicative (0.19.0.0): the parsed options record is the command line (`opts <- execParser parserInfo`; each field is an argument).
105
+ src('Options.Applicative', ['execParser', 'customExecParser'], 'cli', 'command-line options (optparse-applicative)', { package: 'optparse-applicative' });
101
106
  src('System.Environment', ['getEnv', 'lookupEnv', 'getEnvironment'], 'env', 'environment variable', { package: 'base' });
102
107
  src('Prelude', ['readFile'], 'file-read', 'file content', { package: 'base' });
103
108
  src('System.IO', ['readFile'], 'file-read', 'file content', { package: 'base' });
@@ -208,6 +213,10 @@ sink('System.Process', ['callProcess', 'spawnProcess', 'readProcess', 'readProce
208
213
  sink('System.Process', ['callProcess', 'spawnProcess', 'proc'], { family: 'cmd', cwe: 'CWE-88', severity: 'medium', argIndex: 1, shell: false, argv: true, package: 'process', vuln: 'Argument Injection (process arguments)', remediation: 'Shell-free argument lists prevent shell parsing but not option injection: validate each argument against an allow-list and insert `--` before untrusted values.' });
209
214
  sink('System.Process', ['readProcess', 'readProcessWithExitCode'], { family: 'cmd', cwe: 'CWE-88', severity: 'medium', argIndex: 1, shell: false, argv: true, package: 'process', vuln: 'Argument Injection (process arguments)', remediation: 'Validate each argument against an allow-list and insert `--` before untrusted values.' });
210
215
  sink('System.Process.Typed', ['shell'], { family: 'cmd', cwe: 'CWE-78', severity: 'critical', argIndex: 0, shell: true, package: 'process', vuln: 'OS Command Injection (typed-process shell)', remediation: 'Use `proc` with a fixed program and an argument list.' });
216
+ // typed-process (names read from the 0.2.13.0 Haddock index): `proc prog args` builds the ProcessConfig that `readProcess`, `runProcess`,
217
+ // `startProcess` and `withProcess*` run, so the construction is the sink: the program (CWE-78) and the argument list (CWE-88), as for process.
218
+ sink('System.Process.Typed', ['proc'], { family: 'cmd', cwe: 'CWE-78', severity: 'high', argIndex: 0, shell: false, package: 'typed-process', vuln: 'OS Command Injection (attacker-chosen executable)', remediation: 'The program path must be a fixed value or chosen from an allow-list.' });
219
+ sink('System.Process.Typed', ['proc'], { family: 'cmd', cwe: 'CWE-88', severity: 'medium', argIndex: 1, shell: false, argv: true, package: 'typed-process', vuln: 'Argument Injection (process arguments)', remediation: 'Shell-free argument lists prevent shell parsing but not option injection: validate each argument against an allow-list and insert `--` before untrusted values.' });
211
220
 
212
221
  // CWE-22. Path-taking operations.
213
222
  const PATH_FN = ['readFile', 'writeFile', 'appendFile', 'openFile', 'withFile', 'readFile\'', 'openBinaryFile', 'withBinaryFile'];
@@ -301,6 +310,8 @@ sink('Database.HDBC', ['run', 'quickQuery', "quickQuery'", 'prepare', 'runRaw'],
301
310
  sink('Database.SQLite3', ['exec', 'prepare'], { ...SQL, argIndex: 1, package: 'direct-sqlite', vuln: 'SQL Injection (SQLite statement text)' });
302
311
  sink('Database.PostgreSQL.LibPQ', ['exec', 'execParams', 'prepare'], { ...SQL, argIndex: 1, package: 'postgresql-libpq', vuln: 'SQL Injection (libpq command text)' });
303
312
  for (const mod of ['Database.Persist.Postgresql', 'Database.Persist.Sqlite', 'Database.Persist.MySQL']) sink(mod, ['rawSql', 'rawExecute', 'rawQuery'], { ...SQL, argIndex: 0, package: 'persistent-backends', vuln: 'SQL Injection (raw SQL text)' });
313
+ // esqueleto re-exports persistent's raw SQL (names read from the 3.6.0.3 Haddock index of Database.Esqueleto.Experimental).
314
+ for (const mod of ['Database.Esqueleto', 'Database.Esqueleto.Experimental', 'Database.Esqueleto.Legacy']) sink(mod, ['rawSql', 'rawExecute'], { ...SQL, argIndex: 0, package: 'esqueleto', vuln: 'SQL Injection (raw SQL text)' });
304
315
 
305
316
  // CWE-918. Req builds a URL from typed pieces, so the HOST is the attacker-steered part; HTTP's request constructors take the URL text.
306
317
  sink('Network.HTTP.Req', ['https', 'http'], { ...SSRF, argIndex: 0, package: 'req', vuln: 'Server-Side Request Forgery (request host)' });
@@ -40,21 +40,90 @@ export const DEFAULT_PARSE_BUDGETS = Object.freeze({
40
40
 
41
41
  // Boundaries that are disclosed but do not make a file incomplete: the content was either parsed (cpp, ffi) or matched
42
42
  // by a closed, sound pattern and stated as an assumption (see haskell-boundaries.js).
43
- export const NON_OPAQUE_BOUNDARIES = new Set(['cpp', 'ffi', 'th-safe-splice', 'quasiquote-inert', 'quasiquote-hamlet']);
43
+ export const NON_OPAQUE_BOUNDARIES = new Set(['cpp', 'ffi', 'th-safe-splice', 'quasiquote-inert', 'quasiquote-hamlet', 'quasiquote-interp']);
44
44
 
45
45
  // Shakespeare's HTML template quoters: the interpolations are Haskell expressions of the enclosing scope (see absorbSafeBoundaries).
46
46
  const HAMLET_QUOTERS = new Set(['hamlet', 'whamlet', 'shamlet', 'xhamlet', 'ihamlet', 'xshamlet']);
47
47
 
48
+ // String-interpolating quoters: the body is TEXT with Haskell expressions spliced in, so `[i|SELECT .. '#{name}'|]` is a concatenation of the
49
+ // text and `name`. Each is accepted only under an import of its documented module (a project quoter that borrows the name stays opaque),
50
+ // and the interpolation syntax is the quoter's own: `#{e}` (string-interpolate, interpolate, shakespeare-text), `$name` / `${e}`
51
+ // (neat-interpolation, `$$` escapes), `{e}` / `{e:spec}` (PyF, `{{` escapes). Names read from each package's Haddock index.
52
+ const INTERP_QUOTERS = {
53
+ 'Data.String.Interpolate': { syntax: 'hash', quoters: ['i', '__i', 'iii', "__i'E", "__i'L", "iii'E", "iii'L"] },
54
+ 'Data.String.Interpolate.IsString': { syntax: 'hash', quoters: ['i'] },
55
+ 'Text.Shakespeare.Text': { syntax: 'hash', quoters: ['st', 'lt', 'sbt', 'lbt', 'stext', 'ltext', 'text'] },
56
+ 'NeatInterpolation': { syntax: 'dollar', quoters: ['text', 'trimming', 'untrimming'] },
57
+ 'PyF': { syntax: 'brace', quoters: ['fmt', 'fmtTrim', 'str', 'strTrim'] },
58
+ };
59
+
60
+ /** The interpolations of a string-interpolating quoter's body (see INTERP_QUOTERS): `{open, exprStart, exprEnd, close, bare}` offsets. */
61
+ export function interpolationsOf(seg, from, to, syntax) {
62
+ if (syntax === 'hash') return hamletInterpolations(seg, from, to, '#').map((it) => ({ ...it, exprEnd: it.close }));
63
+ const out = [];
64
+ const closeOf = (j) => { // the index of the `}` matching the `{` before j, brace-nesting and string-aware; -1 when unclosed
65
+ let depth = 1, k = j, inStr = false;
66
+ for (; k < to && depth > 0; k++) {
67
+ const c = seg[k];
68
+ if (inStr) { if (c === '\\') k++; else if (c === '"') inStr = false; continue; }
69
+ if (c === '"') inStr = true;
70
+ else if (c === '{') depth++;
71
+ else if (c === '}') { depth--; if (depth === 0) return k; }
72
+ }
73
+ return -1;
74
+ };
75
+ if (syntax === 'dollar') {
76
+ for (let i = from; i < to; i++) {
77
+ if (seg[i] !== '$') continue;
78
+ if (seg[i + 1] === '$') { i++; continue; }
79
+ if (seg[i + 1] === '{') {
80
+ const close = closeOf(i + 2);
81
+ if (close < 0) continue;
82
+ if (close > i + 2) out.push({ open: i, exprStart: i + 2, exprEnd: close, close, bare: false });
83
+ i = close;
84
+ } else if (/[a-z_]/.test(seg[i + 1] || '')) {
85
+ let k = i + 1;
86
+ while (k < to && /[A-Za-z0-9_']/.test(seg[k])) k++;
87
+ out.push({ open: i, exprStart: i + 1, exprEnd: k, close: -1, bare: true });
88
+ i = k - 1;
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+ if (syntax === 'brace') {
94
+ for (let i = from; i < to; i++) {
95
+ if (seg[i] !== '{') continue;
96
+ if (seg[i + 1] === '{') { i++; continue; }
97
+ const close = closeOf(i + 1);
98
+ if (close < 0) continue;
99
+ // `{expr:spec}`: the format spec after a top-level `:` is not an expression (a `:` inside brackets or quotes is part of the expression)
100
+ let exprEnd = close, depth = 0, inStr = false;
101
+ for (let k = i + 1; k < close; k++) {
102
+ const c = seg[k];
103
+ if (inStr) { if (c === '\\') k++; else if (c === '"') inStr = false; continue; }
104
+ if (c === '"') inStr = true;
105
+ else if (c === '(' || c === '[' || c === '{') depth++;
106
+ else if (c === ')' || c === ']' || c === '}') depth--;
107
+ else if (c === ':' && depth === 0) { exprEnd = k; break; }
108
+ }
109
+ if (exprEnd > i + 1) out.push({ open: i, exprStart: i + 1, exprEnd, close, bare: false });
110
+ i = close;
111
+ }
112
+ return out;
113
+ }
114
+ return out;
115
+ }
116
+
48
117
  /**
49
118
  * The interpolations of a hamlet body, in order: `#{e}` (escaped value), `^{e}` (embedded template or widget), `@{r}` / `@?{r}` (route) and `_{m}`
50
119
  * (message). Braces nest (a record or a lambda inside), and a string literal may contain `}`. Returns offsets into the segment: the opener's
51
120
  * first character, the expression's first character, and the closing brace.
52
121
  */
53
- export function hamletInterpolations(seg, from, to) {
122
+ export function hamletInterpolations(seg, from, to, markers = '#^@_') {
54
123
  const out = [];
55
124
  for (let i = from; i < to; i++) {
56
125
  const ch = seg[i];
57
- if (!(ch === '#' || ch === '^' || ch === '@' || ch === '_')) continue;
126
+ if (!markers.includes(ch)) continue;
58
127
  let j = i + 1;
59
128
  if (ch === '@' && seg[j] === '?') j++;
60
129
  if (seg[j] !== '{') continue;
@@ -1153,6 +1222,37 @@ function analyze(source, file, dialect, ctx, res) {
1153
1222
  b.assumption = 'upstream-quoter';
1154
1223
  res.blankRanges.push({ kind: 'quasiquote-hamlet', startOffset: b.span.startOffset, endOffset: b.span.endOffset, text });
1155
1224
  }
1225
+ // String-interpolating quoters (`[i|SELECT .. '#{name}'|]`, INTERP_QUOTERS): rewritten in place, same length, same newlines, as
1226
+ // `(iQ (e1) (e2))`, an application the IR lowers to a CONCATENATION of the interpolated expressions (haskell-ir.js), so text reaching a
1227
+ // sink through the quoter is followed. The template text itself is not modelled (a text sink that needs a markup literal, `putStrLn`,
1228
+ // does not fire through a quoter: a stated limit). A quoter of an unimported module, or an unknown quoter, stays an opaque boundary.
1229
+ const interpModules = res.imports.map((im) => im.module).filter((m) => INTERP_QUOTERS[m]);
1230
+ for (const b of res.boundaries) {
1231
+ if (b.kind !== 'quasiquote' || !interpModules.length) continue;
1232
+ const mod = interpModules.find((m) => INTERP_QUOTERS[m].quoters.includes(b.quoter));
1233
+ if (!mod) continue;
1234
+ const seg = source.slice(b.span.startOffset, b.span.endOffset);
1235
+ const bar = seg.indexOf('|');
1236
+ if (bar < 0 || !seg.endsWith('|]')) continue;
1237
+ const interps = interpolationsOf(seg, bar + 1, seg.length - 2, INTERP_QUOTERS[mod].syntax);
1238
+ if (!interps.length) continue; // a literal-only body is left to the inert rule above
1239
+ const chars = seg.split('').map((ch) => (ch === '\n' || ch === '\r' ? ch : ' '));
1240
+ const put = (at, s) => { for (let i = 0; i < s.length; i++) if (chars[at + i] !== '\n' && chars[at + i] !== '\r') chars[at + i] = s[i]; };
1241
+ put(0, '(iQ'.padEnd(bar + 1));
1242
+ for (const it of interps) {
1243
+ if (it.bare) { put(it.open, ' '); put(it.exprStart, seg.slice(it.exprStart, it.exprEnd)); continue; } // `$name`: the name at its offset
1244
+ put(it.open, ' ('.slice(-(it.exprStart - it.open)).padStart(it.exprStart - it.open)); // `#{`, `${` become ` (`; `{` becomes `(`
1245
+ put(it.exprStart, seg.slice(it.exprStart, it.exprEnd));
1246
+ put(it.close, ')');
1247
+ }
1248
+ put(seg.length - 2, ')');
1249
+ const text = chars.join('');
1250
+ if (text.length !== seg.length) continue;
1251
+ b.kind = 'quasiquote-interp';
1252
+ b.detail = `quasi-quote [${b.quoter}|...|] with ${interps.length} interpolation(s), read as the concatenation of the interpolated expressions (analysed in the enclosing scope); the template text is not modelled; ASSUMED to be the ${mod} quoter`;
1253
+ b.assumption = 'upstream-quoter';
1254
+ res.blankRanges.push({ kind: 'quasiquote-interp', startOffset: b.span.startOffset, endOffset: b.span.endOffset, text });
1255
+ }
1156
1256
  }
1157
1257
 
1158
1258
  // ── errors → withheld calls ──
@@ -1240,7 +1340,7 @@ export function preprocessForSyntaxInfo(source, opts = {}) {
1240
1340
  const seg = out.slice(b.startOffset, b.endOffset);
1241
1341
  let repl = blank(seg);
1242
1342
  if (b.kind === 'quasiquote-inert' && repl.length >= 2) repl = `"${repl.slice(1, -1)}"`;
1243
- if (b.kind === 'quasiquote-hamlet' && typeof b.text === 'string' && b.text.length === seg.length) repl = b.text; // the interpolations, at their offsets
1343
+ if ((b.kind === 'quasiquote-hamlet' || b.kind === 'quasiquote-interp') && typeof b.text === 'string' && b.text.length === seg.length) repl = b.text; // the interpolations, at their offsets
1244
1344
  out = out.slice(0, b.startOffset) + repl + out.slice(b.endOffset);
1245
1345
  }
1246
1346
  return { text: out, opaque };