@clear-capabilities/agentic-security-scanner 0.157.2 → 0.159.0
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 +49 -0
- package/dist/1919.index.js +5 -2
- package/dist/2862.index.js +29 -9
- package/dist/4171.index.js +2 -2
- package/dist/{8551.index.js → 7033.index.js} +430 -22
- package/dist/9560.index.js +3 -1
- package/dist/agentic-security.mjs +15 -15
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +7 -5
- package/src/dataflow/engine.js +23 -0
- package/src/engine.js +7 -1
- package/src/language/CLAUDE.md +53 -1
- package/src/language/assurance.js +21 -4
- package/src/language/context.js +2 -0
- package/src/language/cvss.js +91 -0
- package/src/language/fix-lifecycle.js +114 -21
- package/src/language/haskell-adapter.js +4 -3
- package/src/language/haskell-advisory-feed.js +194 -0
- package/src/language/haskell-boundaries.js +220 -0
- package/src/language/haskell-cpp.js +460 -0
- package/src/language/haskell-ir.js +6 -3
- package/src/language/haskell-models.js +12 -5
- package/src/language/haskell-parser.js +122 -38
- package/src/language/haskell-sca.js +28 -10
- package/src/language/haskell-security-rules.js +1 -1
- package/src/language/haskell-supply.js +70 -8
- package/src/language/haskell-web.js +143 -10
- package/src/language/nix-advisory-feed.js +275 -0
- package/src/language/nix-fix.js +162 -24
- package/src/language/nix-ir.js +16 -12
- package/src/language/nix-sca.js +80 -15
- package/src/language/nixos-eval-forms.js +162 -0
- package/src/language/nixos-hardening.js +37 -2
- package/src/language/nixos-module-resolver.js +359 -39
- package/src/language/nixos-option-catalog.js +1 -0
- package/src/language/resolved-pass.js +65 -3
- package/src/lsp/server.js +5 -2
- package/src/mcp/tools.js +3 -1
- package/src/posture/fix-history.js +30 -3
- package/src/report/index.js +4 -1
- package/dist/1310.index.js +0 -3161
- package/dist/2376.index.js +0 -3151
- package/dist/3526.index.js +0 -1019
- package/dist/7310.index.js +0 -520
- package/dist/8520.index.js +0 -13
- package/dist/9091.index.js +0 -3135
|
@@ -13,7 +13,8 @@ export const HASKELL_PARSE_PRODUCER = 'language:haskell-parse';
|
|
|
13
13
|
|
|
14
14
|
// Boundaries whose content the parser could not see. cpp and ffi are disclosed on
|
|
15
15
|
// the parse result but do not make the file unresolved: both CPP branches are
|
|
16
|
-
// parsed and a foreign declaration's own Haskell signature is.
|
|
16
|
+
// parsed and a foreign declaration's own Haskell signature is. `th-safe-splice` and `quasiquote-inert` are matched by a
|
|
17
|
+
// closed, sound pattern (haskell-boundaries.js) and stay disclosed as assumptions; every other TH/quasi-quote boundary is opaque.
|
|
17
18
|
const OPAQUE_BOUNDARIES = new Set(['th-splice', 'th-quote', 'th-name-quote', 'th-top-level-splice', 'quasiquote', 'hsc', 'generated']);
|
|
18
19
|
|
|
19
20
|
export function ensureHaskellParseProducer() {
|
|
@@ -24,7 +25,7 @@ export function ensureHaskellParseProducer() {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
|
-
* @param {{grammarSource?:Function, budgets?:object, mode?:string, onParse?:(file:string, parse:object)=>void}} [opts]
|
|
28
|
+
* @param {{grammarSource?:Function, budgets?:object, mode?:string, cpp?:object, onParse?:(file:string, parse:object)=>void}} [opts]
|
|
28
29
|
*/
|
|
29
30
|
export function createHaskellAdapter(opts = {}) {
|
|
30
31
|
ensureHaskellParseProducer();
|
|
@@ -33,7 +34,7 @@ export function createHaskellAdapter(opts = {}) {
|
|
|
33
34
|
language: 'haskell',
|
|
34
35
|
hasGrammar: () => loadHaskellGrammar({ grammarSource: opts.grammarSource }).available,
|
|
35
36
|
analyze(file, content) {
|
|
36
|
-
const parse = parseHaskell(content, { file, grammarSource: opts.grammarSource, budgets: opts.budgets, mode: opts.mode });
|
|
37
|
+
const parse = parseHaskell(content, { file, grammarSource: opts.grammarSource, budgets: opts.budgets, mode: opts.mode, cpp: opts.cpp });
|
|
37
38
|
if (typeof opts.onParse === 'function') opts.onParse(file, parse);
|
|
38
39
|
if (parse.status === 'budget_exceeded' && parse.budget && parse.budget.name === 'deadlineMs') {
|
|
39
40
|
throw Object.assign(new Error(`Haskell parser deadline exceeded for ${file}`), { code: 'LANG_TIMEOUT' });
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Live Hackage advisory feed (HS-009 follow-on). The matching logic in haskell-sca.js takes records from its caller and never fetches;
|
|
2
|
+
// this module is the caller that can. It turns the OSV `Hackage` ecosystem (HSEC advisories and their aliases) into the same
|
|
3
|
+
// hash-recorded snapshot the loader already verifies, and writes it where only the operator can: the per-user configuration
|
|
4
|
+
// directory. A scanned project cannot supply or alter it (see trusted-inputs.js).
|
|
5
|
+
//
|
|
6
|
+
// Contract, in the order it matters:
|
|
7
|
+
// * Opt in. Nothing here touches the network unless AGENTIC_SECURITY_HACKAGE_ADVISORIES_LIVE=1, and never when
|
|
8
|
+
// AGENTIC_SECURITY_OFFLINE=1. An operator-pinned snapshot (AGENTIC_SECURITY_HACKAGE_ADVISORIES) always wins: it is not refreshed over.
|
|
9
|
+
// * Coverage is recorded, not assumed. The snapshot lists each package it actually queried and when. A package that was not queried,
|
|
10
|
+
// or whose query failed part-way, is NOT covered, and the matcher reports it `feed-incomplete` (unknown), never `no-advisories` (clean).
|
|
11
|
+
// * Failure degrades, it does not abort. An unreachable feed leaves the previous snapshot in place and the scan runs on it, with its age
|
|
12
|
+
// disclosed; with no previous snapshot the scan reports `feed-unavailable`, exactly as before this module existed.
|
|
13
|
+
// * Everything from the wire is untrusted: ids and package names are validated before they are placed in a URL or a path, sizes are
|
|
14
|
+
// bounded, and a record that does not parse as an OSV record is dropped and counted.
|
|
15
|
+
|
|
16
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync, chmodSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { buildSnapshot, loadAdvisorySnapshot } from './haskell-sca.js';
|
|
19
|
+
import { operatorConfigDir } from './trusted-inputs.js';
|
|
20
|
+
|
|
21
|
+
export const FEED_ENV = 'AGENTIC_SECURITY_HACKAGE_ADVISORIES_LIVE';
|
|
22
|
+
export const PINNED_ENV = 'AGENTIC_SECURITY_HACKAGE_ADVISORIES';
|
|
23
|
+
export const SNAPSHOT_FILE = 'hackage-advisories.json';
|
|
24
|
+
export const OSV_BASE = 'https://api.osv.dev';
|
|
25
|
+
|
|
26
|
+
const DEFAULTS = Object.freeze({
|
|
27
|
+
ttlMs: 24 * 3600 * 1000, // a covered package is not queried again inside this window
|
|
28
|
+
timeoutMs: 8000,
|
|
29
|
+
concurrency: 10,
|
|
30
|
+
batchSize: 500,
|
|
31
|
+
maxPackages: 500,
|
|
32
|
+
maxRecordBytes: 1 << 20,
|
|
33
|
+
maxSnapshotRecords: 20000,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const NAME_OK = /^[A-Za-z0-9][A-Za-z0-9-]{0,127}$/; // Hackage package names
|
|
37
|
+
const ID_OK = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,95}$/; // HSEC-2023-0001, GHSA-..., CVE-...
|
|
38
|
+
|
|
39
|
+
let last = null;
|
|
40
|
+
/** What the most recent refresh in this process did, so a "no feed" reason can say why. */
|
|
41
|
+
export const getLastRefresh = () => last;
|
|
42
|
+
export const _resetLastRefresh = () => { last = null; };
|
|
43
|
+
|
|
44
|
+
export function liveFeedEnabled(env = process.env) {
|
|
45
|
+
return env[FEED_ENV] === '1' && env.AGENTIC_SECURITY_OFFLINE !== '1';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function snapshotPath(env = process.env, dir = null) {
|
|
49
|
+
return join(dir || operatorConfigDir(env), SNAPSHOT_FILE);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readCurrent(path, maxAgeDays) {
|
|
53
|
+
let raw;
|
|
54
|
+
try { raw = JSON.parse(readFileSync(path, 'utf8')); } catch { return { records: new Map(), covered: {}, problem: null }; }
|
|
55
|
+
// Integrity first: a cache whose records do not match their own hashes is discarded, not repaired.
|
|
56
|
+
const r = loadAdvisorySnapshot(raw, { maxAgeDays });
|
|
57
|
+
if (!r.ok) return { records: new Map(), covered: {}, problem: `the cached snapshot was discarded: ${r.reason}` };
|
|
58
|
+
const covered = raw.covered && typeof raw.covered === 'object' ? { ...raw.covered } : {};
|
|
59
|
+
return { records: new Map(raw.records.map((x) => [x.id, x])), covered, problem: null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function getJson(fetchImpl, url, init, timeoutMs, maxBytes) {
|
|
63
|
+
const resp = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
|
64
|
+
if (!resp || resp.status !== 200) throw new Error(`HTTP ${resp ? resp.status : 'none'}`);
|
|
65
|
+
const text = await resp.text();
|
|
66
|
+
if (Buffer.byteLength(text) > maxBytes) throw new Error('response too large');
|
|
67
|
+
return JSON.parse(text);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function pool(items, limit, worker) {
|
|
71
|
+
const out = new Array(items.length);
|
|
72
|
+
let i = 0;
|
|
73
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
74
|
+
while (i < items.length) { const k = i++; out[k] = await worker(items[k], k); }
|
|
75
|
+
}));
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Refresh the operator's Hackage advisory snapshot for the given package names.
|
|
81
|
+
* Never throws. Returns a plain result describing what happened; the snapshot file is only rewritten when something changed.
|
|
82
|
+
*
|
|
83
|
+
* @param {string[]} names Hackage package names the scan is about to evaluate
|
|
84
|
+
* @param {{env?: object, fetchImpl?: Function, now?: number, dir?: string, ttlMs?: number, timeoutMs?: number}} [opts]
|
|
85
|
+
*/
|
|
86
|
+
export async function refreshHackageAdvisories(names, opts = {}) {
|
|
87
|
+
const env = opts.env || process.env;
|
|
88
|
+
const cfg = { ...DEFAULTS, ...Object.fromEntries(Object.entries(opts).filter(([k]) => k in DEFAULTS)) };
|
|
89
|
+
const now = opts.now ?? Date.now();
|
|
90
|
+
const done = (r) => { last = { ...r, at: new Date(now).toISOString() }; return last; };
|
|
91
|
+
|
|
92
|
+
if (env[PINNED_ENV]) return done({ status: 'pinned-snapshot-in-use', detail: `${PINNED_ENV} names an operator snapshot; it is used as given and is not refreshed` });
|
|
93
|
+
if (env[FEED_ENV] !== '1') return done({ status: 'disabled', detail: `set ${FEED_ENV}=1 to fetch Hackage advisories from the OSV feed` });
|
|
94
|
+
if (env.AGENTIC_SECURITY_OFFLINE === '1') return done({ status: 'offline', detail: 'AGENTIC_SECURITY_OFFLINE=1: no advisory data was fetched' });
|
|
95
|
+
|
|
96
|
+
const wanted = [...new Set((names || []).filter((n) => typeof n === 'string' && NAME_OK.test(n)))].sort().slice(0, cfg.maxPackages);
|
|
97
|
+
if (!wanted.length) return done({ status: 'nothing-to-do', detail: 'no Hackage packages to look up' });
|
|
98
|
+
|
|
99
|
+
const path = snapshotPath(env, opts.dir);
|
|
100
|
+
const cur = readCurrent(path, 36500);
|
|
101
|
+
const need = wanted.filter((n) => { const t = Date.parse(cur.covered[n]); return !Number.isFinite(t) || now - t > cfg.ttlMs || t > now + 3600000; });
|
|
102
|
+
if (!need.length) return done({ status: 'current', detail: `${wanted.length} package(s) covered within the last ${Math.round(cfg.ttlMs / 3600000)}h; no request made`, packages: wanted.length, requested: 0 });
|
|
103
|
+
|
|
104
|
+
const fetchImpl = opts.fetchImpl || globalThis.fetch;
|
|
105
|
+
if (typeof fetchImpl !== 'function') return done({ status: 'failed', detail: 'no fetch implementation is available in this runtime' });
|
|
106
|
+
|
|
107
|
+
const failures = [];
|
|
108
|
+
const ok = new Set(); // packages whose whole answer was obtained
|
|
109
|
+
const refs = new Map(); // package -> [{id, modified}]
|
|
110
|
+
// 1. which advisories does OSV hold for each package?
|
|
111
|
+
for (let i = 0; i < need.length; i += cfg.batchSize) {
|
|
112
|
+
const chunk = need.slice(i, i + cfg.batchSize);
|
|
113
|
+
try {
|
|
114
|
+
const data = await getJson(fetchImpl, `${OSV_BASE}/v1/querybatch`, {
|
|
115
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
116
|
+
body: JSON.stringify({ queries: chunk.map((name) => ({ package: { name, ecosystem: 'Hackage' } })) }),
|
|
117
|
+
}, cfg.timeoutMs, 8 << 20);
|
|
118
|
+
const results = Array.isArray(data && data.results) ? data.results : [];
|
|
119
|
+
chunk.forEach((name, k) => {
|
|
120
|
+
const res = results[k];
|
|
121
|
+
if (!res || typeof res !== 'object') { failures.push(`${name}: no result`); return; }
|
|
122
|
+
if (res.next_page_token) { failures.push(`${name}: more advisories than one page; not treated as covered`); return; }
|
|
123
|
+
refs.set(name, (Array.isArray(res.vulns) ? res.vulns : []).filter((v) => v && typeof v.id === 'string' && ID_OK.test(v.id)).map((v) => ({ id: v.id, modified: v.modified || null })));
|
|
124
|
+
});
|
|
125
|
+
} catch (e) { for (const n of chunk) failures.push(`${n}: ${String((e && e.message) || e).slice(0, 80)}`); }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 2. fetch the records that are new or newer than the cached copy
|
|
129
|
+
const toFetch = new Map();
|
|
130
|
+
for (const list of refs.values()) for (const { id, modified } of list) {
|
|
131
|
+
const have = cur.records.get(id);
|
|
132
|
+
if (!have || (modified && have.modified && Date.parse(modified) > Date.parse(have.modified)) || (modified && !have.modified)) toFetch.set(id, modified);
|
|
133
|
+
}
|
|
134
|
+
const fetched = new Map();
|
|
135
|
+
const badIds = new Set();
|
|
136
|
+
await pool([...toFetch.keys()], cfg.concurrency, async (id) => {
|
|
137
|
+
try {
|
|
138
|
+
const rec = await getJson(fetchImpl, `${OSV_BASE}/v1/vulns/${encodeURIComponent(id)}`, {}, cfg.timeoutMs, cfg.maxRecordBytes);
|
|
139
|
+
if (!rec || typeof rec !== 'object' || rec.id !== id) throw new Error('not the requested record');
|
|
140
|
+
fetched.set(id, rec);
|
|
141
|
+
} catch (e) { badIds.add(id); failures.push(`${id}: ${String((e && e.message) || e).slice(0, 80)}`); }
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// 3. a package is covered only if every advisory it references is in hand
|
|
145
|
+
const records = new Map(cur.records);
|
|
146
|
+
for (const [id, rec] of fetched) records.set(id, rec);
|
|
147
|
+
const covered = { ...cur.covered };
|
|
148
|
+
const iso = new Date(now).toISOString();
|
|
149
|
+
let newlyCovered = 0;
|
|
150
|
+
for (const [name, list] of refs) {
|
|
151
|
+
if (list.some((v) => badIds.has(v.id) || !records.has(v.id))) continue;
|
|
152
|
+
covered[name] = iso; ok.add(name); newlyCovered++;
|
|
153
|
+
}
|
|
154
|
+
const uncovered = need.filter((n) => !ok.has(n));
|
|
155
|
+
|
|
156
|
+
if (!ok.size && !fetched.size) {
|
|
157
|
+
return done({ status: 'failed', detail: `the OSV feed could not be read (${failures.slice(0, 2).join('; ') || 'no response'}); ${cur.records.size ? 'the previous snapshot is used and its age is disclosed' : 'no advisory data is available'}`, packages: wanted.length, requested: need.length, uncovered, failures: failures.slice(0, 10), cacheProblem: cur.problem });
|
|
158
|
+
}
|
|
159
|
+
if (records.size > cfg.maxSnapshotRecords) return done({ status: 'failed', detail: `the snapshot would exceed ${cfg.maxSnapshotRecords} records; refusing to write it`, packages: wanted.length, requested: need.length, uncovered });
|
|
160
|
+
|
|
161
|
+
// 4. write the snapshot atomically, readable by the operator only
|
|
162
|
+
const snap = buildSnapshot([...records.values()].sort((a, b) => (a.id < b.id ? -1 : 1)), iso);
|
|
163
|
+
snap.covered = Object.fromEntries(Object.entries(covered).sort(([a], [b]) => (a < b ? -1 : 1)));
|
|
164
|
+
try {
|
|
165
|
+
const dir = opts.dir || operatorConfigDir(env);
|
|
166
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
167
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
168
|
+
writeFileSync(tmp, JSON.stringify(snap), { mode: 0o600 });
|
|
169
|
+
try { chmodSync(tmp, 0o600); } catch { /* best effort on filesystems without modes */ }
|
|
170
|
+
renameSync(tmp, path);
|
|
171
|
+
} catch (e) {
|
|
172
|
+
return done({ status: 'failed', detail: `the snapshot could not be written: ${e.code || e.message}`, packages: wanted.length, requested: need.length, uncovered });
|
|
173
|
+
}
|
|
174
|
+
return done({
|
|
175
|
+
status: uncovered.length ? 'partial' : 'refreshed',
|
|
176
|
+
detail: `${newlyCovered} package(s) covered, ${fetched.size} advisory record(s) fetched${uncovered.length ? `; ${uncovered.length} package(s) NOT covered (${uncovered.slice(0, 3).join(', ')}${uncovered.length > 3 ? ', ...' : ''}) and will be reported as unknown` : ''}`,
|
|
177
|
+
packages: wanted.length, requested: need.length, fetched: fetched.size, records: records.size, uncovered, failures: failures.slice(0, 10), path, cacheProblem: cur.problem,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The scan-health view of this feed, in the shape language/assurance.js takes for an optional mode: `selected` is whether the operator
|
|
183
|
+
* opted in, `result` is what the refresh did (absent when none happened, which assurance reports as "selected but did not run").
|
|
184
|
+
* Outcomes that mean the feed answered are `ok`; a partial refresh is `partial` (some packages stay unknown) and is not dressed as success.
|
|
185
|
+
*/
|
|
186
|
+
export function liveFeedOptionalState(env = process.env, refresh = getLastRefresh()) {
|
|
187
|
+
if (env[FEED_ENV] !== '1') return { selected: false };
|
|
188
|
+
if (!refresh) return { selected: true, result: undefined };
|
|
189
|
+
const ok = new Set(['refreshed', 'current', 'nothing-to-do']);
|
|
190
|
+
if (ok.has(refresh.status)) return { selected: true, result: { status: 'ok' } };
|
|
191
|
+
if (refresh.status === 'partial') return { selected: true, result: { status: 'partial', reason: refresh.detail } };
|
|
192
|
+
if (refresh.status === 'failed') return { selected: true, result: { status: 'failed', reason: refresh.detail } };
|
|
193
|
+
return { selected: true, result: { status: refresh.status, ran: false, reason: refresh.detail } };
|
|
194
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Safe Template Haskell declarations and inert quasi-quotes (HS boundaries).
|
|
2
|
+
//
|
|
3
|
+
// The parser treats a Template Haskell splice or a quasi-quote as an opaque boundary: it cannot see the code the
|
|
4
|
+
// compiler will generate, so the file is `unresolved` in scan health. A small, closed set of constructs is different:
|
|
5
|
+
// a well-known generator applied to nothing but NAMES and LITERALS (`makeLenses ''T`, `deriveJSON defaultOptions ''T`,
|
|
6
|
+
// `share [mkPersist sqlSettings] [persistLowerCase| ... |]`) defines names, executes no project-controlled string and
|
|
7
|
+
// introduces no source, sink or sanitizer; and a raw-string quasi-quote (`[r|...|]`, or `[i|...|]` with no
|
|
8
|
+
// interpolation) is a string literal. Those are recognised here and reported as a DISCLOSED ASSUMPTION instead of an
|
|
9
|
+
// opaque boundary.
|
|
10
|
+
//
|
|
11
|
+
// Soundness rules (each one is pinned by a test in both directions):
|
|
12
|
+
// - The generator must be IMPORTED from the upstream module that defines it, with the import actually exposing the name,
|
|
13
|
+
// and must not be defined in the file. A local `makeLenses` stays opaque.
|
|
14
|
+
// - Every token of the declaration must be accounted for. An argument that is a variable, a call, a lambda, a string
|
|
15
|
+
// built from an expression, a nested splice or any other quasi-quote leaves the whole declaration opaque.
|
|
16
|
+
// - Nothing here executes anything. This is a closed-form match over tokens, not an evaluation.
|
|
17
|
+
// - The assumption is stated on the boundary: the generator is taken to be the upstream package's, and what it
|
|
18
|
+
// generates (lenses, instances, persistent entities) is not modelled.
|
|
19
|
+
|
|
20
|
+
// generator name -> modules that export it
|
|
21
|
+
const GENERATORS = new Map([
|
|
22
|
+
['makeLenses', ['Control.Lens', 'Control.Lens.TH', 'Lens.Micro.TH', 'Lens.Micro.Platform']],
|
|
23
|
+
['makeLensesWith', ['Control.Lens', 'Control.Lens.TH']],
|
|
24
|
+
['makeClassy', ['Control.Lens', 'Control.Lens.TH', 'Lens.Micro.TH', 'Lens.Micro.Platform']],
|
|
25
|
+
['makeClassyPrisms', ['Control.Lens', 'Control.Lens.TH']],
|
|
26
|
+
['makePrisms', ['Control.Lens', 'Control.Lens.TH']],
|
|
27
|
+
['makeFields', ['Control.Lens', 'Control.Lens.TH']],
|
|
28
|
+
['makeWrapped', ['Control.Lens', 'Control.Lens.TH', 'Control.Lens.Wrapped']],
|
|
29
|
+
['deriveJSON', ['Data.Aeson.TH']],
|
|
30
|
+
['deriveToJSON', ['Data.Aeson.TH']],
|
|
31
|
+
['deriveFromJSON', ['Data.Aeson.TH']],
|
|
32
|
+
['deriveSafeCopy', ['Data.SafeCopy']],
|
|
33
|
+
['deriveSafeCopySimple', ['Data.SafeCopy']],
|
|
34
|
+
['mkPersist', ['Database.Persist.TH']],
|
|
35
|
+
['mkMigrate', ['Database.Persist.TH']],
|
|
36
|
+
['mkSave', ['Database.Persist.TH']],
|
|
37
|
+
['mkDeleteCascade', ['Database.Persist.TH']],
|
|
38
|
+
['share', ['Database.Persist.TH']],
|
|
39
|
+
['makeAcidic', ['Data.Acid', 'Data.Acid.TH']],
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
// plain identifiers that may appear as an argument: option records and settings values the generators document
|
|
43
|
+
const SAFE_VALUES = new Map([
|
|
44
|
+
['defaultOptions', ['Data.Aeson.TH', 'Data.Aeson']],
|
|
45
|
+
['lensRules', ['Control.Lens', 'Control.Lens.TH']],
|
|
46
|
+
['camelCaseFields', ['Control.Lens', 'Control.Lens.TH']],
|
|
47
|
+
['abbreviatedFields', ['Control.Lens', 'Control.Lens.TH']],
|
|
48
|
+
['underscoreFields', ['Control.Lens', 'Control.Lens.TH']],
|
|
49
|
+
['sqlSettings', ['Database.Persist.TH']],
|
|
50
|
+
['mkPersistSettings', ['Database.Persist.TH']],
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
// quasi-quoters accepted inside a safe declaration
|
|
54
|
+
const SAFE_DECL_QUOTERS = new Map([
|
|
55
|
+
['persistLowerCase', ['Database.Persist.TH']],
|
|
56
|
+
['persistUpperCase', ['Database.Persist.TH']],
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
// Option-record update (`defaultOptions { fieldLabelModifier = drop 4 }`): field names and the pure helpers a value may use.
|
|
60
|
+
const OPTION_FIELDS = new Set(['fieldLabelModifier', 'constructorTagModifier', 'omitNothingFields', 'allNullaryToStringTag', 'sumEncoding', 'unwrapUnaryRecords', 'tagSingleConstructors', 'rejectUnknownFields']);
|
|
61
|
+
const PURE_HELPERS = new Set(['drop', 'take', 'map', 'toLower', 'toUpper', 'id', 'camelTo2', 'tail', 'init']);
|
|
62
|
+
|
|
63
|
+
// inert quasi-quoters: name -> { modules, interpolating }. `interpolating` quoters are inert only when the body has no
|
|
64
|
+
// interpolation marker; a raw-string quoter is inert whatever the body says.
|
|
65
|
+
const INERT_QUOTERS = new Map([
|
|
66
|
+
['r', { modules: ['Text.RawString.QQ'], check: false }],
|
|
67
|
+
['here', { modules: ['Data.String.Here', 'Data.String.Here.Uninterpolated', 'Data.String.Here.Interpolated'], check: true }],
|
|
68
|
+
['hereLit', { modules: ['Data.String.Here', 'Data.String.Here.Uninterpolated'], check: true }],
|
|
69
|
+
['i', { modules: ['Data.String.Interpolate', 'Data.String.Interpolate.IsString', 'Data.String.Here', 'Data.String.Here.Interpolated'], check: true }],
|
|
70
|
+
['iii', { modules: ['Data.String.Interpolate'], check: true }],
|
|
71
|
+
['__i', { modules: ['Data.String.Interpolate'], check: true }],
|
|
72
|
+
['iTrim', { modules: ['Data.String.Here', 'Data.String.Here.Interpolated'], check: true }],
|
|
73
|
+
]);
|
|
74
|
+
const INTERPOLATION_MARK = /#\{|\$\{|\$\(|\$[A-Za-z_]/;
|
|
75
|
+
|
|
76
|
+
function splitQuoter(q) {
|
|
77
|
+
const i = q.lastIndexOf('.');
|
|
78
|
+
return i < 0 ? { qual: null, name: q } : { qual: q.slice(0, i), name: q.slice(i + 1) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Is `name` (optionally qualified by `qual`) exported by one of `modules` through a visible import, and not shadowed locally? */
|
|
82
|
+
function importedFrom(name, qual, modules, imports, defined) {
|
|
83
|
+
if (defined.has(name) && !qual) return false;
|
|
84
|
+
for (const imp of imports) {
|
|
85
|
+
if (!imp || !imp.module || !modules.includes(imp.module)) continue;
|
|
86
|
+
if (qual) { if ((imp.as || imp.module) !== qual) continue; }
|
|
87
|
+
else if (imp.qualified) continue;
|
|
88
|
+
if (imp.items) {
|
|
89
|
+
const listed = imp.items.includes(name);
|
|
90
|
+
if (imp.hiding ? listed : !listed) continue;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Classify one top-level splice item as a safe generative declaration.
|
|
99
|
+
* @returns {{generators:string[], modules:string[]}|null} null leaves it opaque
|
|
100
|
+
*/
|
|
101
|
+
export function classifySafeSplice(T, a, b, match, imports, defined) {
|
|
102
|
+
const generators = [];
|
|
103
|
+
const modules = new Set();
|
|
104
|
+
const tok = (i) => T[i];
|
|
105
|
+
const sameWord = (t) => t.k === 'varid' || t.k === 'qvarid';
|
|
106
|
+
|
|
107
|
+
// value: a single argument starting at p, returns the next index or -1
|
|
108
|
+
const parseValue = (p, end) => {
|
|
109
|
+
const t = tok(p);
|
|
110
|
+
if (!t) return -1;
|
|
111
|
+
if (t.k === 'thname' || t.k === 'string' || t.k === 'int' || t.k === 'conid' || t.k === 'qconid') {
|
|
112
|
+
let q = p + 1;
|
|
113
|
+
// a record update right after an atom: defaultOptions { field = value, ... }
|
|
114
|
+
if (q < end && tok(q).k === '{' && match[q] > q && match[q] < end) {
|
|
115
|
+
if (!parseRecordUpdate(q, match[q])) return -1;
|
|
116
|
+
q = match[q] + 1;
|
|
117
|
+
}
|
|
118
|
+
return q;
|
|
119
|
+
}
|
|
120
|
+
if (sameWord(t)) {
|
|
121
|
+
const sv = SAFE_VALUES.get(t.name || t.v);
|
|
122
|
+
if (!sv || !importedFrom(t.name || t.v, t.qual || null, sv, imports, defined)) return -1;
|
|
123
|
+
let q = p + 1;
|
|
124
|
+
if (q < end && tok(q).k === '{' && match[q] > q && match[q] < end) {
|
|
125
|
+
if (!parseRecordUpdate(q, match[q])) return -1;
|
|
126
|
+
q = match[q] + 1;
|
|
127
|
+
}
|
|
128
|
+
return q;
|
|
129
|
+
}
|
|
130
|
+
if (t.k === 'quasiquote') {
|
|
131
|
+
const { qual, name } = splitQuoter(t.quoter || '');
|
|
132
|
+
const mods = SAFE_DECL_QUOTERS.get(name);
|
|
133
|
+
if (!mods || !importedFrom(name, qual, mods, imports, defined)) return -1;
|
|
134
|
+
return p + 1;
|
|
135
|
+
}
|
|
136
|
+
if (t.k === '(' && match[p] > p && match[p] < end) {
|
|
137
|
+
if (!parseApp(p + 1, match[p], true)) return -1;
|
|
138
|
+
return match[p] + 1;
|
|
139
|
+
}
|
|
140
|
+
if (t.k === '[' && match[p] > p && match[p] < end) {
|
|
141
|
+
// a list of applications or atoms, separated by top-level commas
|
|
142
|
+
let q = p + 1;
|
|
143
|
+
const close = match[p];
|
|
144
|
+
if (q === close) return close + 1;
|
|
145
|
+
for (;;) {
|
|
146
|
+
let e = q;
|
|
147
|
+
while (e < close && tok(e).k !== ',') e = (['(', '[', '{'].includes(tok(e).k) && match[e] > e) ? match[e] + 1 : e + 1;
|
|
148
|
+
if (e === q) return -1;
|
|
149
|
+
if (!parseApp(q, e, true)) return -1;
|
|
150
|
+
if (e >= close) break;
|
|
151
|
+
q = e + 1;
|
|
152
|
+
}
|
|
153
|
+
return close + 1;
|
|
154
|
+
}
|
|
155
|
+
return -1;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const parseRecordUpdate = (open, close) => {
|
|
159
|
+
let p = open + 1;
|
|
160
|
+
if (p === close) return true;
|
|
161
|
+
for (;;) {
|
|
162
|
+
const f = tok(p);
|
|
163
|
+
if (!f || f.k !== 'varid' || !OPTION_FIELDS.has(f.v)) return false;
|
|
164
|
+
if (!tok(p + 1) || tok(p + 1).k !== 'rop' || tok(p + 1).v !== '=') return false;
|
|
165
|
+
p += 2;
|
|
166
|
+
let valueEnd = p;
|
|
167
|
+
while (valueEnd < close && tok(valueEnd).k !== ',') valueEnd++;
|
|
168
|
+
if (valueEnd === p) return false;
|
|
169
|
+
for (let q = p; q < valueEnd; q++) {
|
|
170
|
+
const t = tok(q);
|
|
171
|
+
const ok = t.k === 'int' || t.k === 'string' || t.k === 'conid' || t.k === 'qconid' || t.k === '(' || t.k === ')'
|
|
172
|
+
|| (t.k === 'op' && t.v === '.') || (t.k === 'varid' && PURE_HELPERS.has(t.v));
|
|
173
|
+
if (!ok) return false;
|
|
174
|
+
}
|
|
175
|
+
if (valueEnd >= close) return true;
|
|
176
|
+
p = valueEnd + 1;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// application: generator followed by values. `nested` allows a generator head inside parentheses or lists.
|
|
181
|
+
const parseApp = (p, end, nested) => {
|
|
182
|
+
const h = tok(p);
|
|
183
|
+
if (!h || !sameWord(h)) {
|
|
184
|
+
// a bare value inside a list (`[mkPersist sqlSettings, mkMigrate "m"]` elements are applications, but a quoter or name may stand alone)
|
|
185
|
+
if (nested && p < end) { const n = parseValue(p, end); return n === end; }
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
const name = h.name || h.v;
|
|
189
|
+
const mods = GENERATORS.get(name);
|
|
190
|
+
if (!mods && nested) return parseValue(p, end) === end;
|
|
191
|
+
if (!mods || !importedFrom(name, h.qual || null, mods, imports, defined)) return false;
|
|
192
|
+
generators.push(name);
|
|
193
|
+
for (const m of mods) modules.add(m);
|
|
194
|
+
let q = p + 1;
|
|
195
|
+
while (q < end) {
|
|
196
|
+
const n = parseValue(q, end);
|
|
197
|
+
if (n < 0) return false;
|
|
198
|
+
q = n;
|
|
199
|
+
}
|
|
200
|
+
return q === end;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// optional outer splice wrapper: $( ... )
|
|
204
|
+
let s = a;
|
|
205
|
+
let e = b;
|
|
206
|
+
if (tok(a).k === 'op' && tok(a).v === '$' && tok(a + 1) && tok(a + 1).k === '(' && tok(a).e === tok(a + 1).s && match[a + 1] === b - 1) { s = a + 2; e = b - 1; }
|
|
207
|
+
if (s >= e) return null;
|
|
208
|
+
if (!parseApp(s, e, false)) return null;
|
|
209
|
+
return { generators, modules: [...modules].sort() };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Classify one quasi-quote token as an inert string. @returns {{quoter:string, module:string}|null} */
|
|
213
|
+
export function classifyInertQuasiQuote(quoter, body, imports, defined) {
|
|
214
|
+
if (!quoter) return null;
|
|
215
|
+
const { qual, name } = splitQuoter(quoter);
|
|
216
|
+
const q = INERT_QUOTERS.get(name);
|
|
217
|
+
if (!q || !importedFrom(name, qual, q.modules, imports, defined)) return null;
|
|
218
|
+
if (q.check && INTERPOLATION_MARK.test(body)) return null;
|
|
219
|
+
return { quoter: name, modules: q.modules };
|
|
220
|
+
}
|