@clear-capabilities/agentic-security-scanner 0.158.0 → 0.160.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 +39 -0
- package/dist/1919.index.js +5 -2
- package/dist/2862.index.js +29 -9
- package/dist/3531.index.js +1 -1
- package/dist/4171.index.js +4 -4
- package/dist/{8551.index.js → 7033.index.js} +415 -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 +11 -6
- package/src/engine.js +6 -1
- package/src/language/CLAUDE.md +48 -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 +15 -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 +4 -3
- package/src/language/haskell-parser.js +122 -38
- package/src/language/haskell-sca.js +10 -6
- package/src/language/haskell-supply.js +42 -8
- package/src/language/haskell-web.js +128 -23
- 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 +71 -12
- package/src/language/nixos-eval-forms.js +162 -0
- package/src/language/nixos-module-resolver.js +346 -51
- package/src/language/resolved-pass.js +57 -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
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
// Preview never writes. Apply writes a backup first and refuses a file that changed since planning.
|
|
11
11
|
// Undo restores the backup byte for byte.
|
|
12
12
|
|
|
13
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
13
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
|
+
import { randomBytes } from 'node:crypto';
|
|
15
16
|
import { statePath } from '../posture/state-dir.js';
|
|
16
17
|
import { maybeEncryptForWrite, maybeDecryptForRead } from '../posture/encryption-provider.js';
|
|
17
18
|
import { recordExternalFix, markRevertedByBackup } from '../posture/fix-history.js';
|
|
@@ -30,31 +31,102 @@ export function unifiedDiff(file, a, b) {
|
|
|
30
31
|
}
|
|
31
32
|
const safeRel = (root, rel) => { const p = join(root, rel); if (!p.startsWith(join(root) + '/')) throw new Error(`path escapes the root: ${rel}`); return p; };
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
/** Save the pre-image of `rel` (customer source: it follows the encryption policy) and return its backup. Writes nothing to the target. */
|
|
35
|
+
function saveBackup(root, rel, before, after) {
|
|
34
36
|
const target = safeRel(root, rel);
|
|
35
|
-
const id = `${Date.now().toString(36)}-${
|
|
37
|
+
const id = `${Date.now().toString(36)}-${randomBytes(5).toString('hex')}`;
|
|
36
38
|
const dir = statePath(root, 'fix-backups', id);
|
|
37
39
|
const disk = existsSync(target) ? readFileSync(target, 'utf8') : null;
|
|
38
40
|
if (disk !== before) throw new Error('the file changed on disk since the fix was planned; refusing to overwrite');
|
|
39
|
-
//
|
|
40
|
-
// that REQUIRES encryption with no working provider refuses the fix before anything is written.
|
|
41
|
+
// A policy that REQUIRES encryption with no working provider refuses the fix before anything is written.
|
|
41
42
|
const enc = maybeEncryptForWrite(root, 'fix-backups', before);
|
|
42
43
|
if (!enc.ok) throw new Error(enc.reason);
|
|
43
44
|
mkdirSync(dir, { recursive: true });
|
|
44
45
|
writeFileSync(join(dir, 'original'), enc.content);
|
|
45
46
|
writeFileSync(join(dir, 'meta.json'), JSON.stringify({ file: rel, appliedAt: new Date().toISOString(), sizeBefore: before.length, sizeAfter: after.length }));
|
|
47
|
+
return { id, dir, target };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function writeWithBackup(root, rel, before, after) {
|
|
51
|
+
const { id, dir, target } = saveBackup(root, rel, before, after);
|
|
46
52
|
writeFileSync(target, after);
|
|
47
53
|
return { id, dir };
|
|
48
54
|
}
|
|
49
55
|
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Apply several file edits as ONE unit. Nothing is written until every file has been checked (inside the root, unchanged
|
|
58
|
+
* since planning) and every pre-image is backed up. If a write then fails, every file already written (and the one that
|
|
59
|
+
* failed, which may be truncated) is put back, and the error says whether that rollback was complete.
|
|
60
|
+
* @param {string} root
|
|
61
|
+
* @param {Array<{file:string,before:string,after:string}>} edits
|
|
62
|
+
* @param {{writeFile?:Function}} [hooks] test seam for the file writer
|
|
63
|
+
* @returns {{id:string, dir:string, members:Array<{id:string,dir:string,file:string}>}}
|
|
64
|
+
*/
|
|
65
|
+
export function writeManyWithBackup(root, edits, hooks = {}) {
|
|
66
|
+
const write = hooks.writeFile || writeFileSync;
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
for (const e of edits) { if (seen.has(e.file)) throw new Error(`the same file is edited twice in one fix: ${e.file}`); seen.add(e.file); safeRel(root, e.file); }
|
|
69
|
+
const members = [];
|
|
70
|
+
const cleanup = () => { for (const m of members) { try { rmSync(m.dir, { recursive: true, force: true }); } catch { /* best effort */ } } };
|
|
71
|
+
try { for (const e of edits) { const b = saveBackup(root, e.file, e.before, e.after); members.push({ id: b.id, dir: b.dir, file: e.file, target: b.target, before: e.before, after: e.after }); } }
|
|
72
|
+
catch (err) { cleanup(); throw new Error(`${err.message} (no file was written)`); }
|
|
73
|
+
const written = [];
|
|
74
|
+
for (const m of members) {
|
|
75
|
+
written.push(m);
|
|
76
|
+
try { write(m.target, m.after); } catch (err) {
|
|
77
|
+
const failed = [];
|
|
78
|
+
for (const w of written.slice().reverse()) { try { write(w.target, w.before); } catch { failed.push(w.file); } }
|
|
79
|
+
if (!failed.length) cleanup();
|
|
80
|
+
const e = new Error(failed.length
|
|
81
|
+
? `write to ${m.file} failed (${err.message}); ROLLBACK INCOMPLETE, restore these from the backups under .agentic-security/fix-backups: ${failed.join(', ')}`
|
|
82
|
+
: `write to ${m.file} failed (${err.message}); every file in this fix was restored to its original content`);
|
|
83
|
+
e.rolledBack = failed.length === 0; e.rollbackFailed = failed; e.failedFile = m.file;
|
|
84
|
+
throw e;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const groupId = `g${Date.now().toString(36)}-${randomBytes(5).toString('hex')}`;
|
|
88
|
+
const dir = statePath(root, 'fix-backups', groupId);
|
|
89
|
+
mkdirSync(dir, { recursive: true });
|
|
90
|
+
writeFileSync(join(dir, 'meta.json'), JSON.stringify({ file: members[0].file, group: members.map((m) => ({ id: m.id, file: m.file })), appliedAt: new Date().toISOString() }));
|
|
91
|
+
return { id: groupId, dir, members: members.map((m) => ({ id: m.id, dir: m.dir, file: m.file })) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Undo a fix. A multi-file fix is restored as a unit: every pre-image is read and every target resolved before the first
|
|
96
|
+
* write, and if a restore fails the files already restored are put back to the content they had, so a partial undo is
|
|
97
|
+
* never left behind.
|
|
98
|
+
*/
|
|
99
|
+
export function undoFix(root, backupId, hooks = {}) {
|
|
100
|
+
const write = hooks.writeFile || writeFileSync;
|
|
52
101
|
const dir = statePath(root, 'fix-backups', backupId);
|
|
53
102
|
const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf8'));
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
103
|
+
if (!Array.isArray(meta.group)) {
|
|
104
|
+
const target = safeRel(root, meta.file);
|
|
105
|
+
writeFileSync(target, maybeDecryptForRead(readFileSync(join(dir, 'original'), 'utf8')));
|
|
106
|
+
try { markRevertedByBackup(root, backupId); } catch { /* history is best effort */ }
|
|
107
|
+
return { ok: true, file: meta.file, files: [meta.file] };
|
|
108
|
+
}
|
|
109
|
+
const items = meta.group.map((m) => {
|
|
110
|
+
const mdir = statePath(root, 'fix-backups', m.id);
|
|
111
|
+
const md = JSON.parse(readFileSync(join(mdir, 'meta.json'), 'utf8'));
|
|
112
|
+
return { file: md.file, target: safeRel(root, md.file), original: maybeDecryptForRead(readFileSync(join(mdir, 'original'), 'utf8')), id: m.id };
|
|
113
|
+
});
|
|
114
|
+
const current = items.map((it) => (existsSync(it.target) ? readFileSync(it.target, 'utf8') : null));
|
|
115
|
+
const touched = [];
|
|
116
|
+
for (let i = 0; i < items.length; i++) {
|
|
117
|
+
touched.push(i);
|
|
118
|
+
try { write(items[i].target, items[i].original); } catch (err) {
|
|
119
|
+
const failed = [];
|
|
120
|
+
for (const j of touched.slice().reverse()) { try { if (current[j] !== null) write(items[j].target, current[j]); } catch { failed.push(items[j].file); } }
|
|
121
|
+
const e = new Error(failed.length
|
|
122
|
+
? `undo failed at ${items[i].file} (${err.message}); ROLLBACK INCOMPLETE for: ${failed.join(', ')}`
|
|
123
|
+
: `undo failed at ${items[i].file} (${err.message}); no file was changed`);
|
|
124
|
+
e.rolledBack = failed.length === 0; e.rollbackFailed = failed;
|
|
125
|
+
throw e;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const id of [backupId, ...items.map((it) => it.id)]) { try { markRevertedByBackup(root, id); } catch { /* history is best effort */ } }
|
|
129
|
+
return { ok: true, file: items[0].file, files: items.map((it) => it.file) };
|
|
58
130
|
}
|
|
59
131
|
|
|
60
132
|
/**
|
|
@@ -75,10 +147,14 @@ export async function runFixLifecycle(o) {
|
|
|
75
147
|
const { plan, files, finding } = o;
|
|
76
148
|
const gates = {};
|
|
77
149
|
// A patch may only touch a file inside the project: a target that escapes the root is refused before anything else runs.
|
|
78
|
-
|
|
79
|
-
|
|
150
|
+
// A plan may carry `edits` (several files, one logical fix); a plan without them is the single edit of `plan.file`.
|
|
151
|
+
const edits = Array.isArray(plan.edits) && plan.edits.length ? plan.edits : [{ file: plan.file, before: plan.before, after: plan.after }];
|
|
152
|
+
const escaping = edits.filter((e) => !isRootRelativePath(e.file));
|
|
153
|
+
gates.path = { ok: escaping.length === 0, detail: edits.map((e) => e.file).join(', ') };
|
|
154
|
+
if (!gates.path.ok) return { status: 'blocked', applied: false, reason: `path-escape: ${escaping.map((e) => String(e.file).slice(0, 120)).join(', ')} is outside the project root`, plan, gates, label: plan.label };
|
|
80
155
|
gates.syntax = o.syntax(plan);
|
|
81
|
-
const patched = { ...files
|
|
156
|
+
const patched = { ...files };
|
|
157
|
+
for (const e of edits) patched[e.file] = e.after;
|
|
82
158
|
const blocked = (why) => ({ status: 'blocked', applied: false, reason: why, plan, gates, label: plan.label });
|
|
83
159
|
if (!gates.syntax.ok) return blocked(`syntax: ${gates.syntax.detail}`);
|
|
84
160
|
if (o.behaviorCheck) { gates.behavior = o.behaviorCheck(plan); if (gates.behavior && gates.behavior.ok === false) return blocked(`behavior: ${gates.behavior.detail || 'unrelated behavior change'}`); }
|
|
@@ -94,6 +170,12 @@ export async function runFixLifecycle(o) {
|
|
|
94
170
|
gates.rescan = { ok: gone && added.length === 0, originalGone: gone, newMediumOrHigher: [...new Set(added.map((f) => `${f.cwe || f.vuln}@${f.file}:${f.line}`))] };
|
|
95
171
|
if (!gone) return blocked('the original finding is still reported after the patch');
|
|
96
172
|
if (added.length) return blocked(`the patch introduces ${added.length} new medium-or-higher finding(s)`);
|
|
173
|
+
// Optional effective-configuration gate: the rescan above only proves a finding stopped being REPORTED, which a patch that
|
|
174
|
+
// turns the option into a module-system conflict also achieves. The check says whether the option now HAS the intended value.
|
|
175
|
+
if (o.effectiveCheck) {
|
|
176
|
+
gates.effective = o.effectiveCheck(plan, patched, files) || { ok: false, detail: 'no effective-configuration result' };
|
|
177
|
+
if (gates.effective.ok === false) return blocked(`effective: ${gates.effective.detail}`);
|
|
178
|
+
}
|
|
97
179
|
// Optional controlled-witness gate (X-009): the hook returns judgeFixWitness(before, after). A patch that STILL
|
|
98
180
|
// reproduces the effect is blocked; a witness that could not be judged (unsupported, invalid, unavailable) verifies
|
|
99
181
|
// nothing and, unless `requireWitness` is set, does not block (it is recorded as not-verified).
|
|
@@ -106,15 +188,26 @@ export async function runFixLifecycle(o) {
|
|
|
106
188
|
if (gates.compile.ran && !gates.compile.ok) return blocked(`compile: ${gates.compile.detail}`);
|
|
107
189
|
if (o.requireCompile && !gates.compile.ran) return blocked(`compile verification required but ${gates.compile.detail}`);
|
|
108
190
|
const verified = { syntax: true, rescan: true, compile: gates.compile.ran ? 'passed' : 'not-run', witness: gates.witness ? (gates.witness.verified ? 'passed' : 'not-verified') : 'not-run' };
|
|
109
|
-
const res = { status: 'verified', applied: false, plan, gates, label: plan.label, verified, preview: unifiedDiff(
|
|
191
|
+
const res = { status: 'verified', applied: false, plan, gates, label: plan.label, verified, preview: edits.map((e) => unifiedDiff(e.file, e.before, e.after)).join('\n'), files: edits.map((e) => e.file) };
|
|
110
192
|
if (o.apply) {
|
|
111
193
|
if (!o.root) return blocked('apply requires a root directory');
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
194
|
+
const base = { scanRoot: o.root, findingId: finding.id || `${finding.file}:${finding.line}`, ruleId: finding.rule || null, vuln: finding.vuln || null, stableId: finding.stableId || null, fixLabel: plan.label || null, verification: verified, findingProvenance: finding.findingProvenance || null };
|
|
195
|
+
if (edits.length === 1) {
|
|
196
|
+
res.backup = writeWithBackup(o.root, plan.file, plan.before, plan.after);
|
|
197
|
+
res.applied = true; res.status = 'applied';
|
|
198
|
+
// One ledger: the same fix-history `agentic-security undo` reads, with the tier and the gates that passed.
|
|
199
|
+
try { res.history = await recordExternalFix({ ...base, file: plan.file, originalContent: plan.before, newContent: plan.after, backup: res.backup }); } catch { res.history = null; }
|
|
200
|
+
} else {
|
|
201
|
+
try { res.backup = writeManyWithBackup(o.root, edits, { writeFile: o.writeFile }); } catch (err) {
|
|
202
|
+
return { ...blocked(`apply failed, nothing was left changed: ${err.message}`), rolledBack: err.rolledBack !== false, rollbackFailed: err.rollbackFailed || [] };
|
|
203
|
+
}
|
|
204
|
+
res.applied = true; res.status = 'applied';
|
|
205
|
+
// One ledger entry per touched file, all carrying the group id so `undo` reverts them together.
|
|
206
|
+
res.history = [];
|
|
207
|
+
for (let i = 0; i < edits.length; i++) {
|
|
208
|
+
try { res.history.push(await recordExternalFix({ ...base, file: edits[i].file, originalContent: edits[i].before, newContent: edits[i].after, backup: res.backup.members[i], languageGroupId: res.backup.id })); } catch { res.history.push(null); }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
118
211
|
}
|
|
119
212
|
return res;
|
|
120
213
|
}
|
|
@@ -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' });
|
|
@@ -177,3 +177,18 @@ export async function refreshHackageAdvisories(names, opts = {}) {
|
|
|
177
177
|
packages: wanted.length, requested: need.length, fetched: fetched.size, records: records.size, uncovered, failures: failures.slice(0, 10), path, cacheProblem: cur.problem,
|
|
178
178
|
});
|
|
179
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
|
+
}
|