@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.
- package/CHANGELOG.md +33 -0
- package/bin/agentic-security.js +6 -0
- package/dist/{2bbc3428df6b8d66516c.json → 4fe5c024ca0626112826.json} +3 -3
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/dataflow/engine.js +19 -4
- package/src/language/CLAUDE.md +3 -0
- package/src/language/haskell-ir.js +80 -13
- package/src/language/haskell-models.js +11 -0
- package/src/language/haskell-parser.js +115 -4
- package/src/language/haskell-syntax.js +93 -4
- package/src/posture/CLAUDE.md +5 -0
- package/src/posture/api-contract.js +39 -1
|
@@ -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
|
|
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
|
-
|
|
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)
|
|
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, '|'))) {
|
package/src/posture/CLAUDE.md
CHANGED
|
@@ -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 };
|