@clear-capabilities/agentic-security-scanner 0.195.12 → 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.
@@ -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;
@@ -645,10 +714,18 @@ function analyze(source, file, dialect, ctx, res) {
645
714
  const items = [];
646
715
  let start = a;
647
716
  let i = a;
717
+ let lineCol = indent; // column of the first token on the current line (the block's own indent on the line it opened on)
718
+ let nestedOnLine = false; // a layout keyword opened a nested implicit block earlier on this line
648
719
  while (i < b) {
649
720
  ctx.tick();
650
721
  const t = T[i];
722
+ if (t.fol) { lineCol = t.lcol; nestedOnLine = false; }
723
+ if (t.k === 'kw' && (t.v === 'do' || t.v === 'of' || t.v === 'let' || t.v === 'where')) nestedOnLine = true;
651
724
  if (OPENERS[t.k] && match[i] > i) { i = match[i] + 1; continue; }
725
+ // In an implicit block a `;` separates items only on a line that starts at the block's own indent and opened no nested block before
726
+ // it: `s <- act pay; pure s` on one line of a nested `do` belongs to that `do`, and `remod t = do (a,_) <- f t; pure a` keeps its `;`
727
+ // in the `do` (hoogle; the same rule as haskell-syntax.js's splitItems).
728
+ if (t.k === ';' && indent !== null && (lineCol !== indent || nestedOnLine)) { i++; continue; }
652
729
  if (t.k === ';') {
653
730
  if (i > start) items.push({ a: start, b: i });
654
731
  start = i + 1;
@@ -678,6 +755,9 @@ function analyze(source, file, dialect, ctx, res) {
678
755
  ctx.tick();
679
756
  const t = T[j];
680
757
  if (j > a && t.fol && t.lcol < indent) break;
758
+ // Haskell's parse-error(t) rule: a `where` that starts a line at the block's own indent cannot begin an item, so it closes the
759
+ // implicit block (a function whose body is a `case` with its `where` at the alternatives' column; hoogle's request handler).
760
+ if (j > a && t.fol && t.lcol === indent && t.k === 'kw' && t.v === 'where') break;
681
761
  if (OPENERS[t.k] && match[j] > j) { j = match[j]; continue; }
682
762
  if (CLOSERS.has(t.k)) break;
683
763
  if (t.k === ',' && termComma) break;
@@ -1142,6 +1222,37 @@ function analyze(source, file, dialect, ctx, res) {
1142
1222
  b.assumption = 'upstream-quoter';
1143
1223
  res.blankRanges.push({ kind: 'quasiquote-hamlet', startOffset: b.span.startOffset, endOffset: b.span.endOffset, text });
1144
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
+ }
1145
1256
  }
1146
1257
 
1147
1258
  // ── errors → withheld calls ──
@@ -1229,7 +1340,7 @@ export function preprocessForSyntaxInfo(source, opts = {}) {
1229
1340
  const seg = out.slice(b.startOffset, b.endOffset);
1230
1341
  let repl = blank(seg);
1231
1342
  if (b.kind === 'quasiquote-inert' && repl.length >= 2) repl = `"${repl.slice(1, -1)}"`;
1232
- 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
1233
1344
  out = out.slice(0, b.startOffset) + repl + out.slice(b.endOffset);
1234
1345
  }
1235
1346
  return { text: out, opaque };
@@ -18,6 +18,11 @@ const KW = new Set(['case', 'class', 'data', 'default', 'deriving', 'do', 'else'
18
18
  const NUMERIC_TYPE = /^\(?\s*(?:(?:Maybe|IO|ActionM|Handler|Either\s+String)\s+)?\(?\s*(?:Int|Integer|Int(?:8|16|32|64)|Word|Word(?:8|16|32|64)|Double|Float|Natural|Bool|Scientific)\s*\)?\s*\)?$/;
19
19
  // The module enables Template Haskell (a LANGUAGE pragma; a cabal `default-extensions` entry is not seen here, a stated limit).
20
20
  const TH_PRAGMA = /\{-#\s*LANGUAGE\b[^#]*\bTemplateHaskell\b/;
21
+ // OverloadedRecordDot: `r.field` (no spaces, lowercase field) is a field selection, `(.field)` a selector section. OverloadedLabels: `#field`
22
+ // in prefix position is a label value (IHP's `get #name r`, `set #name v r`). Both read from the LANGUAGE pragma only, as above.
23
+ const RECORD_DOT_PRAGMA = /\{-#\s*LANGUAGE\b[^#]*\bOverloadedRecordDot\b/;
24
+ const LABELS_PRAGMA = /\{-#\s*LANGUAGE\b[^#]*\bOverloadedLabels\b/;
25
+ const RECDO_PRAGMA = /\{-#\s*LANGUAGE\b[^#]*\bRecursiveDo\b/;
21
26
  const RESERVED_OPS = new Set(['=', '|', '->', '<-', '::', '=>', '\\', '@', '~', '..']);
22
27
  const SYM = new Set('!#$%&*+./<=>?@\\^|-~:'.split(''));
23
28
  const OPEN = { '(': ')', '[': ']', '{': '}' };
@@ -36,6 +41,9 @@ export function lexHs(src, opts = {}) {
36
41
  const T = [];
37
42
  const n = src.length;
38
43
  const thSplices = !!opts.th;
44
+ const recordDot = !!opts.recordDot;
45
+ const labels = !!opts.labels;
46
+ const recursiveDo = !!opts.recursiveDo;
39
47
  let i = 0;
40
48
  let line = 1;
41
49
  let col = 0;
@@ -138,7 +146,9 @@ export function lexHs(src, opts = {}) {
138
146
  let k = i;
139
147
  while (isIdChar(src[k])) k++;
140
148
  const v = src.slice(i, k);
141
- push(KW.has(v) ? 'kw' : 'var', v, {}, k - i);
149
+ // `mdo` (RecursiveDo) is a `do` whose statements may refer to later ones; for the lowering that is a `do`. (`rec` blocks are not read.)
150
+ if (v === 'mdo' && recursiveDo) push('kw', 'do', {}, k - i);
151
+ else push(KW.has(v) ? 'kw' : 'var', v, {}, k - i);
142
152
  continue;
143
153
  }
144
154
  if (c === '(' || c === ')' || c === '[' || c === ']' || c === ',' || c === ';' || c === '`' || c === '{' || c === '}') {
@@ -153,6 +163,22 @@ export function lexHs(src, opts = {}) {
153
163
  const prefix = !(isIdChar(prev) || isSym(prev) || prev === ')' || prev === ']' || prev === '}' || prev === '"' || prev === "'");
154
164
  if (prefix && (src[k] === '(' || (isIdStart(src[k]) && !isUpper(src[k])))) { push('splice', src.slice(i, k), {}, k - i); continue; }
155
165
  }
166
+ // `r.field` / `(e).field` / `r.a.b` with OverloadedRecordDot: a `.` touching an identifier, `)` or `}` on its left and a lowercase name on
167
+ // its right is a field selection (`sel` token), never composition (GHC reads it the same way under the extension). `(.field)` is the
168
+ // selector section: a `.` touching `(` on its left.
169
+ if (c === '.' && recordDot && isIdStart(src[i + 1]) && !isUpper(src[i + 1]) && i > 0 && (isIdChar(src[i - 1]) || src[i - 1] === ')' || src[i - 1] === '}' || src[i - 1] === '(')) {
170
+ let m = i + 1;
171
+ while (isIdChar(src[m])) m++;
172
+ push('sel', src.slice(i + 1, m), { section: src[i - 1] === '(' }, m - i);
173
+ continue;
174
+ }
175
+ // `#field` with OverloadedLabels, in prefix position: a label value.
176
+ if (c === '#' && labels && isIdStart(src[i + 1]) && !isUpper(src[i + 1]) && (i === 0 || !(isIdChar(src[i - 1]) || isSym(src[i - 1]) || src[i - 1] === ')' || src[i - 1] === ']'))) {
177
+ let m = i + 1;
178
+ while (isIdChar(src[m])) m++;
179
+ push('label', src.slice(i + 1, m), {}, m - i);
180
+ continue;
181
+ }
156
182
  if (isSym(c)) {
157
183
  let j = i;
158
184
  while (isSym(src[j])) j++;
@@ -196,7 +222,7 @@ const BT_FIX = { elem: [4, 'l'], notElem: [4, 'l'], div: [7, 'l'], mod: [7, 'l']
196
222
  * @returns {{tokens:object[], module:object|null, imports:object[], decls:object[], sigs:object[], data:object[], classes:object[], instances:object[], errors:object[], boundaries:object[]}}
197
223
  */
198
224
  export function parseSyntax(src, opts = {}) {
199
- const T = lexHs(src, { th: TH_PRAGMA.test(src) });
225
+ const T = lexHs(src, { th: TH_PRAGMA.test(src), recordDot: RECORD_DOT_PRAGMA.test(src), labels: LABELS_PRAGMA.test(src), recursiveDo: RECDO_PRAGMA.test(src) });
200
226
  const mate = matchBrackets(T);
201
227
  const N = T.length;
202
228
  const errors = [];
@@ -255,9 +281,15 @@ export function parseSyntax(src, opts = {}) {
255
281
  const items = [];
256
282
  let start = a;
257
283
  let i = a;
284
+ // A `;` separates items of THIS block only when the line starts at the block's indent (or is the line the block opened on) and no
285
+ // layout keyword opened a nested implicit block earlier on that line: `remod t = do (a,_) <- f t; pure a` keeps the `;` in the `do`.
286
+ let lineCol = indent;
287
+ let nestedOnLine = false;
258
288
  while (i < b) {
259
289
  tick();
260
290
  const t = T[i];
291
+ if (t.first) { lineCol = t.col; nestedOnLine = false; }
292
+ if ((t.t === 'kw' && (t.v === 'do' || t.v === 'of' || t.v === 'let' || t.v === 'where')) || (isRop(t, '\\') && isKw(T[i + 1], 'case'))) nestedOnLine = true;
261
293
  // A new line at the block's indent starts a new item EVEN when that line opens a bracket
262
294
  // (`("GET", ["a"]) -> ...`): the boundary test must run before the bracket is skipped.
263
295
  if (indent !== null && i > start && t.first && t.col === indent && !isSp(t, ';')) {
@@ -265,6 +297,10 @@ export function parseSyntax(src, opts = {}) {
265
297
  start = i;
266
298
  }
267
299
  if (t.t === 'sp' && OPEN[t.v] && mate[i] > i) { i = mate[i] + 1; continue; }
300
+ // `s <- act pay; bs <- evaluate s; pure (s, bs)` on one line of a nested `do`: those semicolons separate the statements of the
301
+ // nested block, not the items of an enclosing implicit block (the module's top level split on them and read `bs <- ..` as a
302
+ // declaration; real code, hoogle). In an implicit block a `;` counts only on a line that starts at the block's own indent.
303
+ if (isSp(t, ';') && indent !== null && (lineCol !== indent || nestedOnLine)) { i++; continue; }
268
304
  if (isSp(t, ';')) {
269
305
  if (i > start) items.push([start, i]);
270
306
  start = i + 1;
@@ -459,6 +495,19 @@ export function parseSyntax(src, opts = {}) {
459
495
  pos = blk.b;
460
496
  return { t: 'lamcase', alts: withIndent(blk.indent, () => blk.items.map(([a, b]) => parseAlt(a, b))), line: t.line };
461
497
  }
498
+ // `\cases` (GHC 9.4): alternatives with SEVERAL patterns each, `0 y -> ..; x y -> ..`.
499
+ if (peek() && peek().t === 'var' && peek().v === 'cases') {
500
+ const blk = blockAfter(pos, lim, layoutIndent);
501
+ pos = blk.b;
502
+ const parseAltMulti = (a, b) => {
503
+ const k = findTop(a, b, (x) => isRop(x, '->') || isRop(x, '|'));
504
+ if (k < 0) { err('expected-arrow', T[a], 'case alternative has no `->`'); return { pats: [{ t: 'pwild' }], rhs: { guards: null, body: { t: 'unknown' }, where: [] }, line: T[a].line, bad: true }; }
505
+ const pats = [];
506
+ for (let q = a; q < k;) { const r = apatAt(q, k); if (!r) break; pats.push(r[0]); q = r[1]; }
507
+ return { pats, rhs: parseRhs(k, b, '->'), line: T[a].line };
508
+ };
509
+ return { t: 'lamcases', alts: withIndent(blk.indent, () => blk.items.map(([a, b]) => parseAltMulti(a, b))), line: t.line };
510
+ }
462
511
  const params = [];
463
512
  while (pos < lim && !isRop(T[pos], '->')) {
464
513
  const r = apatAt(pos, lim);
@@ -522,7 +571,7 @@ export function parseSyntax(src, opts = {}) {
522
571
  function startsAexp() {
523
572
  const t = peek();
524
573
  if (!t) return false;
525
- if (t.t === 'var' || t.t === 'con' || t.t === 'int' || t.t === 'float' || t.t === 'str' || t.t === 'chr' || t.t === 'splice') return true;
574
+ if (t.t === 'var' || t.t === 'con' || t.t === 'int' || t.t === 'float' || t.t === 'str' || t.t === 'chr' || t.t === 'splice' || t.t === 'label') return true;
526
575
  return isSp(t, '(') || isSp(t, '[');
527
576
  }
528
577
  function fexp() {
@@ -555,6 +604,7 @@ export function parseSyntax(src, opts = {}) {
555
604
  else if (peek() && peek().t === 'var') pos++;
556
605
  return { t: 'splice', line: t.line };
557
606
  }
607
+ else if (t.t === 'label') { pos++; e = { t: 'label', name: t.v, line: t.line }; }
558
608
  else { err('unexpected-token', t, `unexpected token ${t.v}`); pos++; return { t: 'unknown', line: t.line }; }
559
609
  while (isSp(peek(), '{') && mate[pos] > pos && mate[pos] < lim && (e.t === 'con' || e.t === 'var' || e.t === 'rec' || e.t === 'paren')) {
560
610
  const close = mate[pos];
@@ -570,6 +620,8 @@ export function parseSyntax(src, opts = {}) {
570
620
  e = e.t === 'con' ? { t: 'rec', con: e, fields, line: t.line } : { t: 'recupd', base: e, fields, line: t.line };
571
621
  pos = close + 1;
572
622
  }
623
+ // `e.field` (OverloadedRecordDot): the field selector applied to `e`, which the IR lowers to a member read of a known record field.
624
+ while (peek() && peek().t === 'sel' && !peek().section) { const s = peek(); pos++; e = { t: 'app', f: { t: 'var', name: s.v, qual: null, line: s.line }, args: [e], line: s.line, recordDot: true }; }
573
625
  return e;
574
626
  }
575
627
  const isOpTok = (x) => x && x.t === 'op';
@@ -584,6 +636,8 @@ export function parseSyntax(src, opts = {}) {
584
636
  }
585
637
  const [s, e] = parts[0];
586
638
  const first = T[s];
639
+ // `(.field)` (OverloadedRecordDot): the selector function itself.
640
+ if (first && first.t === 'sel' && first.section && e - s === 1) return { t: 'var', name: first.v, qual: null, line };
587
641
  if (isOpTok(first) && e - s === 1) return { t: 'var', name: first.v, qual: first.q || null, op: true, line };
588
642
  if (isOpTok(first) && first.v !== '-') return { t: 'rsec', op: first.v, qual: first.q || null, e: sub(s + 1, e, expr0), line };
589
643
  if (isSp(first, '`') && T[s + 2] && isSp(T[s + 2], '`') && e - s > 3) return { t: 'rsec', op: T[s + 1].v, qual: T[s + 1].q || null, e: sub(s + 3, e, expr0), line, backtick: true };
@@ -690,6 +744,9 @@ export function parseSyntax(src, opts = {}) {
690
744
  return out;
691
745
  }
692
746
  function parseDecl(a, b) {
747
+ // `pattern P :: T` / `pattern P x = ..` (PatternSynonyms): a synonym, recorded by name, not a function called `pattern`. Its right-hand
748
+ // side is not modelled (a match on `P` is a constructor pattern to the IR, which is sound for taint: it binds what it binds).
749
+ if (T[a].t === 'var' && T[a].v === 'pattern' && T[a + 1] && T[a + 1].t === 'con' && a + 1 < b) return { t: 'patsyn', name: T[a + 1].v, line: T[a].line };
693
750
  const mark = findTop(a, b, (x) => x.t === 'rop' && (x.v === '=' || x.v === '|' || x.v === '::'));
694
751
  if (mark < 0) { err('expected-declaration', T[a], 'item is neither a binding nor a signature'); return null; }
695
752
  if (T[mark].v === '::') {
@@ -744,7 +801,39 @@ export function parseSyntax(src, opts = {}) {
744
801
  let nameTok = null;
745
802
  for (let q = a + 1; q < (eq < 0 ? b : eq); q++) if (T[q].t === 'con') { nameTok = T[q]; break; }
746
803
  const decl = { t: 'data', newtype: isNewtype, name: nameTok ? nameTok.v : null, constructors: [], line: T[a].line };
747
- if (eq < 0) return decl;
804
+ if (eq < 0) {
805
+ // GADT syntax: `data T where { A :: Int -> T; B :: { fld :: String } -> T }`. Each item names one or more constructors, their record
806
+ // fields when written with braces, else an arity of one per top-level arrow; the result type is not read (no type checking).
807
+ const w = findTop(a, b, (x) => isKw(x, 'where'));
808
+ if (w < 0) return decl;
809
+ const blk = blockAfter(w, b, layoutIndent);
810
+ for (const [s, e] of blk.items) {
811
+ const names = [];
812
+ let i = s;
813
+ while (i < e && T[i].t === 'con') { names.push(T[i].v); i++; if (isSp(T[i], ',')) i++; else break; }
814
+ if (!names.length || !isRop(T[i], '::')) continue;
815
+ i++;
816
+ while (i < e && ((T[i].t === 'var' && T[i].v === 'forall') || T[i].t === 'op' || isRop(T[i], '=>'))) i = findTop(i, e, (x) => isRop(x, '=>')) >= 0 ? findTop(i, e, (x) => isRop(x, '=>')) + 1 : e;
817
+ let fields = null;
818
+ let arity = 0;
819
+ if (isSp(T[i], '{') && mate[i] > i) {
820
+ fields = [];
821
+ const close = mate[i];
822
+ let j = i + 1;
823
+ while (j < close) {
824
+ const ns = [];
825
+ while (j < close && T[j].t === 'var') { ns.push(T[j].v); j++; if (isSp(T[j], ',')) j++; else break; }
826
+ if (isRop(T[j], '::')) { j++; while (j < close && !isSp(T[j], ',')) j = Math.min(close, groupEnd(j)); j++; } else break;
827
+ fields.push(...ns);
828
+ }
829
+ arity = fields.length;
830
+ } else {
831
+ for (let j = i; j < e;) { if (isRop(T[j], '->')) arity++; j = Math.min(e, groupEnd(j)); }
832
+ }
833
+ for (const n of names) decl.constructors.push({ name: n, fields, arity });
834
+ }
835
+ return decl;
836
+ }
748
837
  let end = findTop(eq + 1, b, (x) => isKw(x, 'deriving'));
749
838
  if (end < 0) end = b;
750
839
  for (const [s0, e] of splitTop(eq + 1, end, (x) => isRop(x, '|'))) {
@@ -129,6 +129,11 @@ is also true of a scan that read zero files — so when nothing was examined eve
129
129
  mapped control degrades to `engine-gap` instead of reporting as satisfied. That
130
130
  one was caught by the module's own test, not in review.
131
131
 
132
+ **API contract diff** — `api-contract.js`. Cross-references OpenAPI/GraphQL contract files against the detected routes. A contract found
133
+ inside one package of a monorepo governs that package's routes only (`contractScope`: the nearest ancestor of the file with a package
134
+ manifest, else the scan root); two example OpenAPI files in a servant monorepo had produced 78 "implemented but not declared"
135
+ routes across every other package. `test/api-contract-scope.test.js`.
136
+
132
137
  **Posture artifacts** — `sbom.js`, `aibom.js`, `api-inventory.js`, `threat-model.js`, `trust-boundary-diagram.js`, `stack-playbook.js`, `deploy-platform.js`, `license-policy.js`, `material-change.js`, `mttr.js`, `streak.js`, `accuracy-scorecard.js` (see "Published accuracy scorecard (R3)" above — this line previously named a bare "scorecard" module that never existed under that filename), `security-trend.js`.
133
138
 
134
139
  **Why this fired** — `why-fired.js`. Runs LAST so it reflects every annotation. Customer-facing provenance.
@@ -185,9 +185,47 @@ export function runApiContractScan(scanRoot, codeRoutes) {
185
185
  if (c.kind === 'openapi') contractRoutes = parseOpenAPI(c.doc);
186
186
  else if (c.kind === 'graphql') contractRoutes = parseGraphQL(c.doc);
187
187
  contractRoutes.forEach(r => { r.contractFile = c.path; });
188
- findings.push(...diffRoutes(contractRoutes, codeRoutes || []));
188
+ findings.push(...diffRoutes(contractRoutes, routesGovernedBy(c.path, scanRoot, codeRoutes || [])));
189
189
  }
190
190
  return findings;
191
191
  }
192
192
 
193
+ // A manifest that makes a directory a package of its own: the contract found inside such a package describes that package's API.
194
+ const PACKAGE_MANIFEST = /^(?:package\.json|pyproject\.toml|setup\.py|setup\.cfg|go\.mod|pom\.xml|build\.gradle(?:\.kts)?|Cargo\.toml|composer\.json|Gemfile|mix\.exs|package\.yaml|[^/]+\.cabal|[^/]+\.csproj|[^/]+\.fsproj)$/i;
195
+
196
+ function hasPackageManifest(dir) {
197
+ try { return fs.readdirSync(dir).some((n) => PACKAGE_MANIFEST.test(n)); } catch { return false; }
198
+ }
199
+
200
+ /**
201
+ * The directory a contract file governs: the nearest ancestor of the file (up to the scan root) that holds a package manifest. A contract at
202
+ * the scan root's own package, or in a directory with no package above it, governs every route as before.
203
+ *
204
+ * Why: a monorepo's packages each carry their own API; an example `swagger.json` three packages away does not declare the routes of the
205
+ * others. Scanned over a servant monorepo, two example contracts in `servant-swagger/example` and `servant-openapi3/example` produced 78
206
+ * "implemented but not declared" routes across every other package, each twice. Scoping by package keeps the single-package case unchanged.
207
+ */
208
+ export function contractScope(contractPath, scanRoot) {
209
+ const top = path.resolve(scanRoot);
210
+ let dir = path.dirname(path.resolve(contractPath));
211
+ while (true) {
212
+ if (dir === top) return top;
213
+ if (!dir.startsWith(top + path.sep)) return top; // outside the scan root: no narrower scope can be claimed
214
+ if (hasPackageManifest(dir)) return dir;
215
+ const up = path.dirname(dir);
216
+ if (up === dir) return top;
217
+ dir = up;
218
+ }
219
+ }
220
+
221
+ function routesGovernedBy(contractPath, scanRoot, codeRoutes) {
222
+ const scope = contractScope(contractPath, scanRoot);
223
+ if (scope === path.resolve(scanRoot)) return codeRoutes;
224
+ return codeRoutes.filter((r) => {
225
+ if (!r || typeof r.file !== 'string') return false;
226
+ const abs = path.resolve(scanRoot, r.file);
227
+ return abs === scope || abs.startsWith(scope + path.sep);
228
+ });
229
+ }
230
+
193
231
  export const _internals = { CONTRACT_FILE_PATTERNS, parseOpenAPI, parseGraphQL };