@clear-capabilities/agentic-security-scanner 0.153.1 → 0.154.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 +67 -0
- package/bin/agentic-security.js +123 -19
- package/dist/1444.index.js +64 -19
- package/dist/1905.index.js +46 -3
- package/dist/2862.index.js +325 -0
- package/dist/3499.index.js +5 -1
- package/dist/3531.index.js +261 -0
- package/dist/3596.index.js +103 -0
- package/dist/4113.index.js +15 -3
- package/dist/4171.index.js +319 -0
- package/dist/5637.index.js +10 -4
- package/dist/6359.index.js +433 -0
- package/dist/7838.index.js +15 -3
- package/dist/8551.index.js +1164 -0
- package/dist/8587.index.js +410 -0
- package/dist/8714.index.js +98 -0
- package/dist/8798.index.js +218 -0
- package/dist/9220.index.js +35 -3
- package/dist/9560.index.js +26 -3
- package/dist/agentic-security.mjs +51 -51
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +18 -3
- package/src/dataflow/CLAUDE.md +9 -0
- package/src/dataflow/catalog-haskell.js +37 -0
- package/src/dataflow/catalog.js +4 -0
- package/src/dataflow/engine.js +33 -3
- package/src/dataflow/privacy-catalog-haskell.js +50 -0
- package/src/dataflow/privacy-catalog.js +5 -1
- package/src/discovery/CLAUDE.md +6 -0
- package/src/discovery/index.js +12 -3
- package/src/discovery/lenses.js +4 -1
- package/src/discovery/partition.js +28 -0
- package/src/egress/redact.js +8 -0
- package/src/engine.js +140 -19
- package/src/ir/index.js +39 -2
- package/src/language/CLAUDE.md +74 -0
- package/src/language/accuracy.js +90 -0
- package/src/language/aibom.js +307 -0
- package/src/language/assurance.js +193 -0
- package/src/language/bom-validate.js +227 -0
- package/src/language/bom.js +236 -0
- package/src/language/bridges.js +392 -0
- package/src/language/compliance-map.js +61 -0
- package/src/language/context.js +199 -0
- package/src/language/contracts.js +339 -0
- package/src/language/discovery.js +359 -0
- package/src/language/engine-pass.js +120 -0
- package/src/language/fix-lifecycle.js +120 -0
- package/src/language/haskell-adapter.js +51 -0
- package/src/language/haskell-disclosure.js +170 -0
- package/src/language/haskell-findings.js +44 -0
- package/src/language/haskell-fix.js +409 -0
- package/src/language/haskell-grammar.js +78 -0
- package/src/language/haskell-guards.js +382 -0
- package/src/language/haskell-ir.js +1377 -0
- package/src/language/haskell-llm.js +128 -0
- package/src/language/haskell-manifests.js +1290 -0
- package/src/language/haskell-models.js +278 -0
- package/src/language/haskell-parser.js +1073 -0
- package/src/language/haskell-resolved-graph.js +414 -0
- package/src/language/haskell-sca.js +387 -0
- package/src/language/haskell-security-rules.js +332 -0
- package/src/language/haskell-supply.js +140 -0
- package/src/language/haskell-sweep.js +85 -0
- package/src/language/haskell-syntax.js +895 -0
- package/src/language/haskell-web.js +749 -0
- package/src/language/nix-adapter.js +42 -0
- package/src/language/nix-agents.js +155 -0
- package/src/language/nix-build-trust.js +790 -0
- package/src/language/nix-closure.js +331 -0
- package/src/language/nix-eval-isolation.js +274 -0
- package/src/language/nix-fix.js +268 -0
- package/src/language/nix-grammar.js +97 -0
- package/src/language/nix-inventory.js +379 -0
- package/src/language/nix-ir.js +642 -0
- package/src/language/nix-parser.js +908 -0
- package/src/language/nix-privacy.js +114 -0
- package/src/language/nix-sca.js +358 -0
- package/src/language/nix-script-taint.js +885 -0
- package/src/language/nix-secrets.js +396 -0
- package/src/language/nixos-hardening.js +564 -0
- package/src/language/nixos-module-resolver.js +595 -0
- package/src/language/nixos-option-catalog.js +96 -0
- package/src/language/pragma.js +150 -0
- package/src/language/resolved-pass.js +146 -0
- package/src/language/secrets.js +323 -0
- package/src/language/state-artifacts.js +78 -0
- package/src/language/support-registry.js +224 -0
- package/src/language/witness.js +185 -0
- package/src/lineage/CLAUDE.md +8 -0
- package/src/lineage/coverage.js +2 -0
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/graph-builder.js +5 -1
- package/src/lineage/haskell-view.js +319 -0
- package/src/lineage/index.js +8 -1
- package/src/lineage/nix-view.js +198 -0
- package/src/lineage/sink-registry.js +10 -1
- package/src/lineage/source-registry.js +3 -0
- package/src/lineage/transform-catalog.js +12 -0
- package/src/llm-validator/index.js +16 -2
- package/src/lsp/server.js +69 -9
- package/src/mcp/CLAUDE.md +7 -0
- package/src/mcp/tools.js +26 -3
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/accuracy-scorecard.js +51 -0
- package/src/posture/aibom.js +61 -3
- package/src/posture/artifact-registry.js +12 -0
- package/src/posture/auditor-walkthrough.js +41 -0
- package/src/posture/clustering.js +2 -2
- package/src/posture/evidence-bundle.js +7 -1
- package/src/posture/execution-proof.js +15 -0
- package/src/posture/falsification.js +7 -0
- package/src/posture/family-resolve.js +7 -0
- package/src/posture/fix-history.js +40 -0
- package/src/posture/fix-verify.js +15 -3
- package/src/posture/provenance/git-evidence.js +24 -0
- package/src/posture/provenance/origin-resolver.js +14 -2
- package/src/posture/provenance/predicate-replay.js +11 -2
- package/src/posture/provenance/sca-origin.js +20 -3
- package/src/posture/sbom-diff.js +7 -6
- package/src/posture/sbom.js +85 -32
- package/src/posture/sca-policy.js +10 -1
- package/src/posture/sca-verdict.js +8 -1
- package/src/posture/secret-history.js +62 -18
- package/src/posture/stable-id.js +21 -2
- package/src/posture/state-dir.js +21 -0
- package/src/posture/threat-model-auto.js +4 -4
- package/src/posture/triage.js +18 -1
- package/src/posture/watch-mode.js +34 -3
- package/src/pr-delta.js +9 -4
- package/src/report/index.js +35 -9
- package/src/runScan.js +56 -5
- package/src/sast/code-injection-multilang.js +15 -0
- package/src/sast/go-extended.js +11 -0
- package/src/sast/pipeline.js +34 -1
|
@@ -0,0 +1,1290 @@
|
|
|
1
|
+
// Haskell declared-manifest parsers (HS-007).
|
|
2
|
+
//
|
|
3
|
+
// Static only: .cabal, cabal.project, cabal.project.freeze, package.yaml (Hpack),
|
|
4
|
+
// stack.yaml and stack.yaml.lock are READ as text. No solver, no `cabal`/`stack`/
|
|
5
|
+
// `hpack` process, no Setup.hs, no network. Everything here is a DECLARATION:
|
|
6
|
+
//
|
|
7
|
+
// * a version bound (`>=4 && <5`) is never reported as an installed version;
|
|
8
|
+
// `resolvedVersion` is always null on a declared dependency;
|
|
9
|
+
// * only a lock-shaped source (cabal.project.freeze `==x.y.z`, stack.yaml.lock,
|
|
10
|
+
// a pinned stack extra-dep) populates `lockedPackages`;
|
|
11
|
+
// * a Stack snapshot selector (lts-22.10) is recorded as a selector. Its package
|
|
12
|
+
// set needs the snapshot file, which we do not fetch, so it is `resolved:false`;
|
|
13
|
+
// * constraints we cannot parse are preserved with their raw text and a
|
|
14
|
+
// diagnostic instead of being dropped.
|
|
15
|
+
//
|
|
16
|
+
// Every parser is bounded by MANIFEST_BUDGETS and returns diagnostics rather than
|
|
17
|
+
// throwing on malformed input.
|
|
18
|
+
import { redactUrlsDeep } from './secrets.js';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { load as yamlLoad, CORE_SCHEMA } from '../util/yaml.js';
|
|
22
|
+
import { isLanguageExcludedPath } from './discovery.js';
|
|
23
|
+
|
|
24
|
+
export const MANIFEST_SCHEMA_VERSION = 1;
|
|
25
|
+
|
|
26
|
+
export const MANIFEST_BUDGETS = Object.freeze({
|
|
27
|
+
maxBytes: 2 * 1024 * 1024,
|
|
28
|
+
maxLines: 40000,
|
|
29
|
+
maxDepth: 48,
|
|
30
|
+
maxNodes: 50000,
|
|
31
|
+
maxFiles: 2000,
|
|
32
|
+
maxImportDepth: 4,
|
|
33
|
+
maxRangeTokens: 1000,
|
|
34
|
+
maxRangeDepth: 64,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function budgetsOf(opts) {
|
|
38
|
+
return { ...MANIFEST_BUDGETS, ...(opts && opts.budgets ? opts.budgets : {}) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function diag(kind, message, file, line, severity = 'warning', extra = {}) {
|
|
42
|
+
return { kind, severity, message, file: file || null, line: Number.isInteger(line) ? line : null, ...extra };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
46
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Version ranges
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/** Parse "1.2.3.4.5" into BigInt components (any length). Null when not a version. */
|
|
53
|
+
export function parseVersion(text) {
|
|
54
|
+
if (typeof text !== 'string' || text.length > 256 || !/^\d+(?:\.\d+)*$/.test(text)) return null;
|
|
55
|
+
return text.split('.').map((p) => BigInt(p));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function asVersion(v) {
|
|
59
|
+
if (Array.isArray(v)) return v;
|
|
60
|
+
const p = parseVersion(v);
|
|
61
|
+
if (!p) throw new TypeError(`not a version: ${String(v)}`);
|
|
62
|
+
return p;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Haskell (PVP) ordering: component-wise, missing trailing components are 0. */
|
|
66
|
+
export function compareVersions(a, b) {
|
|
67
|
+
const x = asVersion(a);
|
|
68
|
+
const y = asVersion(b);
|
|
69
|
+
const n = Math.max(x.length, y.length);
|
|
70
|
+
for (let i = 0; i < n; i++) {
|
|
71
|
+
const p = x[i] ?? 0n;
|
|
72
|
+
const q = y[i] ?? 0n;
|
|
73
|
+
if (p < q) return -1;
|
|
74
|
+
if (p > q) return 1;
|
|
75
|
+
}
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const RANGE_TOKEN = /\s*(\|\||&&|\^>=|>=|<=|==|<|>|\(|\)|-any\b|-none\b|\d+(?:\.\d+)*(?:\.\*)?)/y;
|
|
80
|
+
const CMP_OPS = new Set(['>=', '>', '<=', '<', '==', '^>=']);
|
|
81
|
+
|
|
82
|
+
function tokenizeRange(text, budgets) {
|
|
83
|
+
const src = text.trim();
|
|
84
|
+
const toks = [];
|
|
85
|
+
let pos = 0;
|
|
86
|
+
while (pos < src.length) {
|
|
87
|
+
RANGE_TOKEN.lastIndex = pos;
|
|
88
|
+
const m = RANGE_TOKEN.exec(src);
|
|
89
|
+
if (!m) throw new SyntaxError(`unexpected text '${src.slice(pos, pos + 12).trim()}' in version range`);
|
|
90
|
+
toks.push(m[1]);
|
|
91
|
+
pos = RANGE_TOKEN.lastIndex;
|
|
92
|
+
if (toks.length > budgets.maxRangeTokens) throw new RangeError('version range exceeds token budget');
|
|
93
|
+
}
|
|
94
|
+
return toks;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Parse a Cabal/Hpack version range. Returns {ok:true, ast, text} or
|
|
99
|
+
* {ok:false, error, text}; never throws. `&&` binds tighter than `||`.
|
|
100
|
+
* AST nodes: any | none | cmp{op,version,wildcard} | and{l,r} | or{l,r}.
|
|
101
|
+
*/
|
|
102
|
+
export function parseVersionRange(text, opts = {}) {
|
|
103
|
+
const budgets = budgetsOf(opts);
|
|
104
|
+
const raw = typeof text === 'string' ? text : '';
|
|
105
|
+
if (raw.trim() === '') return { ok: true, ast: { t: 'any' }, text: raw, implicit: true };
|
|
106
|
+
try {
|
|
107
|
+
const toks = tokenizeRange(raw, budgets);
|
|
108
|
+
let i = 0;
|
|
109
|
+
const atom = (d) => {
|
|
110
|
+
const t = toks[i++];
|
|
111
|
+
if (t === undefined) throw new SyntaxError('unexpected end of version range');
|
|
112
|
+
if (t === '(') {
|
|
113
|
+
const e = expr(d + 1);
|
|
114
|
+
if (toks[i++] !== ')') throw new SyntaxError("missing ')' in version range");
|
|
115
|
+
return e;
|
|
116
|
+
}
|
|
117
|
+
if (t === '-any') return { t: 'any' };
|
|
118
|
+
if (t === '-none') return { t: 'none' };
|
|
119
|
+
if (CMP_OPS.has(t)) {
|
|
120
|
+
const v = toks[i++];
|
|
121
|
+
if (v === undefined || !/^\d/.test(v)) throw new SyntaxError(`operator '${t}' needs a version`);
|
|
122
|
+
const wildcard = v.endsWith('.*');
|
|
123
|
+
if (wildcard && t !== '==') throw new SyntaxError(`wildcard version only valid after '==', got '${t}'`);
|
|
124
|
+
return { t: 'cmp', op: t, version: parseVersion(wildcard ? v.slice(0, -2) : v), wildcard, text: v };
|
|
125
|
+
}
|
|
126
|
+
throw new SyntaxError(`unexpected token '${t}' in version range`);
|
|
127
|
+
};
|
|
128
|
+
const and = (d) => {
|
|
129
|
+
let l = atom(d);
|
|
130
|
+
while (toks[i] === '&&') { i++; l = { t: 'and', l, r: atom(d) }; }
|
|
131
|
+
return l;
|
|
132
|
+
};
|
|
133
|
+
const expr = (d) => {
|
|
134
|
+
if (d > budgets.maxRangeDepth) throw new RangeError('version range nested too deeply');
|
|
135
|
+
let l = and(d);
|
|
136
|
+
while (toks[i] === '||') { i++; l = { t: 'or', l, r: and(d) }; }
|
|
137
|
+
return l;
|
|
138
|
+
};
|
|
139
|
+
const ast = expr(0);
|
|
140
|
+
if (i < toks.length) throw new SyntaxError(`unexpected token '${toks[i]}' in version range`);
|
|
141
|
+
return { ok: true, ast, text: raw };
|
|
142
|
+
} catch (e) {
|
|
143
|
+
return { ok: false, error: e.message, text: raw };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Upper bound of a caret range: ^>=1.2.3 is <1.3, ^>=1 is <1.1 (Cabal's majorUpperBound). */
|
|
148
|
+
export function caretUpperBound(version) {
|
|
149
|
+
const v = asVersion(version);
|
|
150
|
+
return v.length < 2 ? [v[0], 1n] : [v[0], v[1] + 1n];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function evalRange(n, v) {
|
|
154
|
+
switch (n.t) {
|
|
155
|
+
case 'any': return true;
|
|
156
|
+
case 'none': return false;
|
|
157
|
+
case 'and': return evalRange(n.l, v) && evalRange(n.r, v);
|
|
158
|
+
case 'or': return evalRange(n.l, v) || evalRange(n.r, v);
|
|
159
|
+
case 'cmp': {
|
|
160
|
+
const c = compareVersions(v, n.version);
|
|
161
|
+
switch (n.op) {
|
|
162
|
+
case '>=': return c >= 0;
|
|
163
|
+
case '>': return c > 0;
|
|
164
|
+
case '<=': return c <= 0;
|
|
165
|
+
case '<': return c < 0;
|
|
166
|
+
case '==':
|
|
167
|
+
if (n.wildcard) return n.version.every((p, i) => (v[i] ?? 0n) === p);
|
|
168
|
+
return c === 0;
|
|
169
|
+
case '^>=': return c >= 0 && compareVersions(v, caretUpperBound(n.version)) < 0;
|
|
170
|
+
default: return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
default: return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** True when `version` (string) is inside `range` (string or a parseVersionRange result). */
|
|
178
|
+
export function versionSatisfies(range, version) {
|
|
179
|
+
const r = typeof range === 'string' ? parseVersionRange(range) : range;
|
|
180
|
+
if (!r || !r.ok) throw new SyntaxError(r && r.error ? r.error : 'invalid version range');
|
|
181
|
+
return evalRange(r.ast, asVersion(version));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 'exact' for a single `==x.y.z`, 'unbounded' for any, 'none', otherwise 'bounded'. */
|
|
185
|
+
export function rangeKind(ast) {
|
|
186
|
+
if (!ast) return 'unparsed';
|
|
187
|
+
if (ast.t === 'any') return 'unbounded';
|
|
188
|
+
if (ast.t === 'none') return 'none';
|
|
189
|
+
if (ast.t === 'cmp' && ast.op === '==' && !ast.wildcard) return 'exact';
|
|
190
|
+
return 'bounded';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Cabal-style layout: lines -> indentation tree
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
const FIELD = /^([A-Za-z][A-Za-z0-9_.-]*)\s*:(.*)$/;
|
|
198
|
+
const DEP = /^([A-Za-z0-9][A-Za-z0-9-]*)(?::(?:\{([^}]*)\}|([A-Za-z0-9][A-Za-z0-9_-]*)))?\s*(.*)$/s;
|
|
199
|
+
|
|
200
|
+
function indentOf(raw) {
|
|
201
|
+
let n = 0;
|
|
202
|
+
for (const ch of raw) {
|
|
203
|
+
if (ch === ' ') n += 1;
|
|
204
|
+
else if (ch === '\t') n += 8 - (n % 8);
|
|
205
|
+
else break;
|
|
206
|
+
}
|
|
207
|
+
return n;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function lexLines(text, budgets, diagnostics, file) {
|
|
211
|
+
let src = typeof text === 'string' ? text : '';
|
|
212
|
+
if (src.length > budgets.maxBytes) {
|
|
213
|
+
diagnostics.push(diag('budget_exceeded', `manifest larger than ${budgets.maxBytes} bytes; remainder not read`, file, null));
|
|
214
|
+
src = src.slice(0, budgets.maxBytes);
|
|
215
|
+
}
|
|
216
|
+
const raw = src.replace(/^/, '').split(/\r\n|\r|\n/);
|
|
217
|
+
const limit = Math.min(raw.length, budgets.maxLines);
|
|
218
|
+
if (raw.length > limit) diagnostics.push(diag('budget_exceeded', `manifest has more than ${limit} lines; remainder not read`, file, limit));
|
|
219
|
+
const out = [];
|
|
220
|
+
for (let i = 0; i < limit; i++) {
|
|
221
|
+
const r = raw[i];
|
|
222
|
+
const t = r.trim();
|
|
223
|
+
if (!t) continue;
|
|
224
|
+
// `--sha256: <hash>` inside a source-repository-package is a comment by
|
|
225
|
+
// syntax but carries the content hash; keep it as a field.
|
|
226
|
+
const sha = /^--\s*sha256:\s*(\S+)/.exec(t);
|
|
227
|
+
if (sha) { out.push({ n: i + 1, indent: indentOf(r), raw: r, text: `sha256: ${sha[1]}`, fromComment: true }); continue; }
|
|
228
|
+
if (t.startsWith('--')) continue;
|
|
229
|
+
out.push({ n: i + 1, indent: indentOf(r), raw: r, text: t });
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function buildTree(lines, budgets, diagnostics, file) {
|
|
235
|
+
const root = { indent: -1, children: [] };
|
|
236
|
+
const stack = [root];
|
|
237
|
+
let warned = false;
|
|
238
|
+
for (const ln of lines) {
|
|
239
|
+
while (stack.length > 1 && ln.indent <= stack[stack.length - 1].indent) stack.pop();
|
|
240
|
+
const node = { line: ln, indent: ln.indent, children: [] };
|
|
241
|
+
stack[stack.length - 1].children.push(node);
|
|
242
|
+
if (stack.length <= budgets.maxDepth) stack.push(node);
|
|
243
|
+
else if (!warned) { warned = true; diagnostics.push(diag('budget_exceeded', `indentation deeper than ${budgets.maxDepth}; nesting flattened`, file, ln.n)); }
|
|
244
|
+
}
|
|
245
|
+
return root;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function* descendants(node) {
|
|
249
|
+
for (const c of node.children) { yield c; yield* descendants(c); }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Field value text across continuation lines, with offset -> (line, column) marks. */
|
|
253
|
+
function fieldValue(node) {
|
|
254
|
+
const first = node.line;
|
|
255
|
+
let text = '';
|
|
256
|
+
const marks = [];
|
|
257
|
+
const add = (s, line, col) => { marks.push({ offset: text.length, line, col }); text += `${s}\n`; };
|
|
258
|
+
if (first.fromComment) {
|
|
259
|
+
add(first.text.slice(first.text.indexOf(':') + 1).trim(), first.n, 0);
|
|
260
|
+
} else {
|
|
261
|
+
const ci = first.raw.indexOf(':');
|
|
262
|
+
const rest = first.raw.slice(ci + 1);
|
|
263
|
+
add(rest.trim(), first.n, ci + 1 + (rest.length - rest.trimStart().length));
|
|
264
|
+
}
|
|
265
|
+
for (const d of descendants(node)) {
|
|
266
|
+
add(d.line.text, d.line.n, d.line.raw.length - d.line.raw.trimStart().length);
|
|
267
|
+
}
|
|
268
|
+
return { text, marks, line: first.n, column: marks[0].col };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function locate(val, offset) {
|
|
272
|
+
let m = val.marks[0];
|
|
273
|
+
for (const k of val.marks) { if (k.offset <= offset) m = k; else break; }
|
|
274
|
+
return { line: m.line, column: m.col + (offset - m.offset) };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Split on top-level commas (outside parens/braces); pieces keep their text offset. */
|
|
278
|
+
function splitCommas(text) {
|
|
279
|
+
const out = [];
|
|
280
|
+
let depth = 0;
|
|
281
|
+
let start = 0;
|
|
282
|
+
const push = (end) => {
|
|
283
|
+
const seg = text.slice(start, end);
|
|
284
|
+
const t = seg.trim();
|
|
285
|
+
if (t) out.push({ text: t.replace(/\s+/g, ' '), offset: start + (seg.length - seg.trimStart().length) });
|
|
286
|
+
};
|
|
287
|
+
for (let i = 0; i <= text.length; i++) {
|
|
288
|
+
const c = text[i];
|
|
289
|
+
if (c === '(' || c === '{') depth++;
|
|
290
|
+
else if (c === ')' || c === '}') depth = Math.max(0, depth - 1);
|
|
291
|
+
if (i === text.length || (c === ',' && depth === 0)) { push(i); start = i + 1; }
|
|
292
|
+
}
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Whitespace/comma separated words honouring double quotes. */
|
|
297
|
+
function splitWords(text) {
|
|
298
|
+
const out = [];
|
|
299
|
+
const re = /"([^"]*)"|([^\s,]+)/g;
|
|
300
|
+
let m;
|
|
301
|
+
while ((m = re.exec(text)) !== null) out.push({ text: m[1] !== undefined ? m[1] : m[2], offset: m.index });
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function flagRefsOf(cond) {
|
|
306
|
+
const out = [];
|
|
307
|
+
const re = /flag\(\s*([^)\s]+)\s*\)/g;
|
|
308
|
+
let m;
|
|
309
|
+
while ((m = re.exec(cond)) !== null) out.push(m[1]);
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function makeDep(parsed, ctx) {
|
|
314
|
+
const rr = parseVersionRange(parsed.rangeText);
|
|
315
|
+
return {
|
|
316
|
+
name: parsed.name,
|
|
317
|
+
sublibrary: parsed.sublibrary || null,
|
|
318
|
+
declaredRange: parsed.rangeText === '' ? null : parsed.rangeText,
|
|
319
|
+
rangeKind: rr.ok ? rangeKind(rr.ast) : 'unparsed',
|
|
320
|
+
range: rr.ok ? rr.ast : null,
|
|
321
|
+
rangeError: rr.ok ? null : rr.error,
|
|
322
|
+
// A bound is a declaration. It is never an installed or resolved version.
|
|
323
|
+
resolvedVersion: null,
|
|
324
|
+
exactPin: rr.ok && rr.ast.t === 'cmp' && rr.ast.op === '==' && !rr.ast.wildcard ? rr.ast.text : null,
|
|
325
|
+
field: ctx.field,
|
|
326
|
+
scopeOverride: ctx.scopeOverride || null,
|
|
327
|
+
conditions: ctx.conds.slice(),
|
|
328
|
+
flagRefs: [...new Set(ctx.conds.flatMap(flagRefsOf))],
|
|
329
|
+
line: ctx.line,
|
|
330
|
+
column: ctx.column,
|
|
331
|
+
locationKind: ctx.locationKind || 'exact',
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function parseDepPiece(text) {
|
|
336
|
+
const m = DEP.exec(text);
|
|
337
|
+
if (!m) return null;
|
|
338
|
+
const subs = m[2] !== undefined ? m[2].split(',').map((s) => s.trim()).filter(Boolean) : (m[3] ? [m[3]] : [null]);
|
|
339
|
+
return subs.map((sublibrary) => ({ name: m[1], sublibrary, rangeText: m[4].trim() }));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function scanGhcOptions(text, locFor, sink) {
|
|
343
|
+
const re = /\S+/g;
|
|
344
|
+
const toks = [];
|
|
345
|
+
let m;
|
|
346
|
+
while ((m = re.exec(text)) !== null) toks.push({ t: m[0], index: m.index });
|
|
347
|
+
for (let i = 0; i < toks.length; i++) {
|
|
348
|
+
const { t, index } = toks[i];
|
|
349
|
+
let mm;
|
|
350
|
+
if ((mm = /^-fplugin=(.+)$/.exec(t))) sink('ghc-plugin', `-fplugin=${mm[1]}`, index);
|
|
351
|
+
else if (/^-(?:f)?plugin-package(?:-id)?(?:=.*)?$/.test(t)) sink('ghc-plugin-package', t, index);
|
|
352
|
+
else if (t === '-F') sink('ghc-preprocessor', '-F', index);
|
|
353
|
+
else if (/^-pgm[A-Za-z]+$/.test(t)) sink('ghc-program-override', `${t} ${toks[i + 1] ? toks[i + 1].t : ''}`.trim(), index);
|
|
354
|
+
}
|
|
355
|
+
void locFor;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// .cabal
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
const HEADER = /^(library|executable|test-suite|benchmark|foreign-library|common|flag|source-repository|custom-setup)\b\s*(.*)$/i;
|
|
363
|
+
const SCOPE_OF = { library: 'runtime', executable: 'runtime', 'foreign-library': 'runtime', 'test-suite': 'test', benchmark: 'benchmark', 'custom-setup': 'setup', common: 'runtime' };
|
|
364
|
+
|
|
365
|
+
function detectGenerated(text) {
|
|
366
|
+
const head = String(text).slice(0, 4000);
|
|
367
|
+
const g = /generated from (\S+) by hpack version (\d+(?:\.\d+)*)/i.exec(head);
|
|
368
|
+
if (!g) return { generatedBy: null };
|
|
369
|
+
const h = /^--\s*hash:\s*([0-9a-f]{64})/im.exec(head);
|
|
370
|
+
return { generatedBy: 'hpack', source: g[1], hpackVersion: g[2], hash: h ? h[1] : null, hashVerified: false };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function newStanza(kind, name, line, scope) {
|
|
374
|
+
return { kind, name, scope, line, deps: [], imports: [], surfaces: [], flagRefs: [], fields: {} };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function handleCabalField(st, name, node, conds, file) {
|
|
378
|
+
const val = fieldValue(node);
|
|
379
|
+
const addDeps = (field, scopeOverride) => {
|
|
380
|
+
for (const piece of splitCommas(val.text)) {
|
|
381
|
+
const parsed = parseDepPiece(piece.text);
|
|
382
|
+
if (!parsed) { st.problems.push(diag('malformed_dependency', `cannot read dependency '${piece.text}'`, file, locate(val, piece.offset).line)); continue; }
|
|
383
|
+
const loc = locate(val, piece.offset);
|
|
384
|
+
for (const p of parsed) {
|
|
385
|
+
const d = makeDep(p, { field, scopeOverride, conds, line: loc.line, column: loc.column });
|
|
386
|
+
if (d.rangeError) st.problems.push(diag('invalid_version_range', `${p.name}: ${d.rangeError}`, file, loc.line, 'warning', { rangeText: p.rangeText }));
|
|
387
|
+
st.deps.push(d);
|
|
388
|
+
if (scopeOverride === 'build-tool') st.surfaces.push({ kind: 'build-tool-depends', detail: `${p.name}${p.sublibrary ? `:${p.sublibrary}` : ''} ${p.rangeText}`.trim(), file, line: loc.line, column: loc.column });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
switch (name) {
|
|
393
|
+
case 'build-depends': addDeps('build-depends', null); break;
|
|
394
|
+
case 'setup-depends': addDeps('setup-depends', 'setup'); break;
|
|
395
|
+
case 'build-tool-depends': addDeps('build-tool-depends', 'build-tool'); break;
|
|
396
|
+
case 'build-tools': addDeps('build-tools', 'build-tool'); break;
|
|
397
|
+
case 'import':
|
|
398
|
+
for (const w of splitWords(val.text)) st.imports.push({ name: w.text, line: locate(val, w.offset).line });
|
|
399
|
+
break;
|
|
400
|
+
case 'ghc-options': case 'ghc-prof-options': case 'ghc-shared-options':
|
|
401
|
+
scanGhcOptions(val.text, null, (kind, detail, idx) => {
|
|
402
|
+
const loc = locate(val, idx);
|
|
403
|
+
st.surfaces.push({ kind, detail, file, line: loc.line, column: loc.column });
|
|
404
|
+
});
|
|
405
|
+
break;
|
|
406
|
+
case 'hs-source-dirs': case 'main-is': st.fields[name] = val.text.trim(); break;
|
|
407
|
+
default: break;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function walkCabalBody(children, st, conds, file, depth, budgets) {
|
|
412
|
+
if (depth > budgets.maxDepth) return;
|
|
413
|
+
let prev = [];
|
|
414
|
+
for (const node of children) {
|
|
415
|
+
const t = node.line.text;
|
|
416
|
+
const fm = FIELD.exec(t);
|
|
417
|
+
if (fm) { prev = []; handleCabalField(st, fm[1].toLowerCase(), node, conds, file); continue; }
|
|
418
|
+
const cm = /^(if|elif|else)\b\s*(.*)$/i.exec(t);
|
|
419
|
+
if (!cm) continue;
|
|
420
|
+
const kw = cm[1].toLowerCase();
|
|
421
|
+
const negs = prev.map((p) => `!(${p})`);
|
|
422
|
+
let next;
|
|
423
|
+
if (kw === 'if') { prev = [cm[2]]; next = [...conds, cm[2]]; }
|
|
424
|
+
else if (kw === 'elif') { next = [...conds, ...negs, cm[2]]; prev.push(cm[2]); }
|
|
425
|
+
else { next = [...conds, ...negs]; prev = []; }
|
|
426
|
+
for (const r of flagRefsOf(cm[2])) st.flagRefs.push({ flag: r, line: node.line.n });
|
|
427
|
+
walkCabalBody(node.children, st, next, file, depth + 1, budgets);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Parse one .cabal file into a package model. Never throws. */
|
|
432
|
+
export function parseCabalFile(text, opts = {}) {
|
|
433
|
+
const file = opts.file || 'package.cabal';
|
|
434
|
+
const budgets = budgetsOf(opts);
|
|
435
|
+
const diagnostics = [];
|
|
436
|
+
const provenance = detectGenerated(text);
|
|
437
|
+
const lines = lexLines(text, budgets, diagnostics, file);
|
|
438
|
+
const tree = buildTree(lines, budgets, diagnostics, file);
|
|
439
|
+
|
|
440
|
+
const pkg = { name: null, version: null, buildType: null, cabalVersion: null };
|
|
441
|
+
const topSurfaces = [];
|
|
442
|
+
const stanzas = [];
|
|
443
|
+
const commons = {};
|
|
444
|
+
const flags = [];
|
|
445
|
+
const sourceRepositories = [];
|
|
446
|
+
|
|
447
|
+
for (const node of tree.children) {
|
|
448
|
+
const t = node.line.text;
|
|
449
|
+
const fm = FIELD.exec(t);
|
|
450
|
+
if (fm) {
|
|
451
|
+
const name = fm[1].toLowerCase();
|
|
452
|
+
const val = fieldValue(node);
|
|
453
|
+
const v = val.text.trim();
|
|
454
|
+
if (name === 'name') pkg.name = v;
|
|
455
|
+
else if (name === 'version') pkg.version = v;
|
|
456
|
+
else if (name === 'cabal-version') pkg.cabalVersion = v;
|
|
457
|
+
else if (name === 'build-type') {
|
|
458
|
+
pkg.buildType = v;
|
|
459
|
+
if (/^(custom|configure|make)$/i.test(v)) topSurfaces.push({ kind: `build-type-${v.toLowerCase()}`, detail: `build-type: ${v}`, file, line: val.line, column: val.column });
|
|
460
|
+
}
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
const hm = HEADER.exec(t);
|
|
464
|
+
if (!hm) { diagnostics.push(diag('unrecognized_line', `unrecognized top-level line '${t.slice(0, 60)}'`, file, node.line.n, 'info')); continue; }
|
|
465
|
+
const kind = hm[1].toLowerCase();
|
|
466
|
+
const arg = hm[2].trim();
|
|
467
|
+
if (kind === 'flag') {
|
|
468
|
+
const f = { package: null, name: arg, default: true, defaultDeclared: false, manual: false, file, line: node.line.n };
|
|
469
|
+
for (const c of node.children) {
|
|
470
|
+
const cf = FIELD.exec(c.line.text);
|
|
471
|
+
if (!cf) continue;
|
|
472
|
+
const fname = cf[1].toLowerCase();
|
|
473
|
+
const v = fieldValue(c).text.trim().toLowerCase();
|
|
474
|
+
if (fname === 'default') { f.default = v === 'true'; f.defaultDeclared = true; }
|
|
475
|
+
else if (fname === 'manual') f.manual = v === 'true';
|
|
476
|
+
}
|
|
477
|
+
flags.push(f);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (kind === 'source-repository') {
|
|
481
|
+
const r = { kind: arg || 'head', type: null, location: null, tag: null, branch: null, subdir: null, file, line: node.line.n };
|
|
482
|
+
for (const c of node.children) {
|
|
483
|
+
const cf = FIELD.exec(c.line.text);
|
|
484
|
+
if (!cf) continue;
|
|
485
|
+
const fname = cf[1].toLowerCase();
|
|
486
|
+
const v = fieldValue(c).text.trim();
|
|
487
|
+
if (['type', 'location', 'tag', 'branch', 'subdir'].includes(fname)) r[fname] = v;
|
|
488
|
+
}
|
|
489
|
+
r.pinned = !!r.tag && /^[0-9a-f]{7,40}$/i.test(r.tag);
|
|
490
|
+
sourceRepositories.push(r);
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
const isLib = kind === 'library';
|
|
494
|
+
const name = kind === 'common' ? arg : (isLib ? (arg || pkg.name) : (kind === 'custom-setup' ? 'setup' : arg));
|
|
495
|
+
const st = newStanza(kind, name, node.line.n, SCOPE_OF[kind]);
|
|
496
|
+
st.problems = diagnostics;
|
|
497
|
+
st.sublibrary = isLib && !!arg;
|
|
498
|
+
st.name = name;
|
|
499
|
+
if (kind === 'custom-setup') st.surfaces.push({ kind: 'custom-setup', detail: 'custom-setup stanza', file, line: node.line.n, column: node.line.indent });
|
|
500
|
+
walkCabalBody(node.children, st, [], file, 0, budgets);
|
|
501
|
+
if (kind === 'common') commons[arg] = st; else stanzas.push(st);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Expand `import:` of common stanzas; the dependency keeps its ORIGINAL line.
|
|
505
|
+
const expand = (st, into, seen, via) => {
|
|
506
|
+
for (const imp of st.imports) {
|
|
507
|
+
const c = commons[imp.name];
|
|
508
|
+
if (!c) { diagnostics.push(diag('unresolved_import', `import '${imp.name}' has no matching common stanza`, file, imp.line)); continue; }
|
|
509
|
+
if (seen.has(imp.name)) { diagnostics.push(diag('import_cycle', `common stanza '${imp.name}' imports itself`, file, imp.line)); continue; }
|
|
510
|
+
const nextSeen = new Set(seen).add(imp.name);
|
|
511
|
+
for (const d of c.deps) into.deps.push({ ...d, via: `common:${imp.name}` });
|
|
512
|
+
for (const s of c.surfaces) into.surfaces.push({ ...s, via: `common:${imp.name}` });
|
|
513
|
+
for (const r of c.flagRefs) into.flagRefs.push(r);
|
|
514
|
+
expand(c, into, nextSeen, via);
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
const declared = new Set(flags.map((f) => f.name));
|
|
519
|
+
const unknownFlags = [];
|
|
520
|
+
const components = stanzas.map((st) => {
|
|
521
|
+
const own = { deps: st.deps.slice(), surfaces: st.surfaces.slice(), flagRefs: st.flagRefs.slice() };
|
|
522
|
+
expand(st, own, new Set(), null);
|
|
523
|
+
for (const r of own.flagRefs) {
|
|
524
|
+
if (!declared.has(r.flag)) {
|
|
525
|
+
unknownFlags.push({ flag: r.flag, component: st.name, file, line: r.line });
|
|
526
|
+
diagnostics.push(diag('unknown_flag', `condition references flag '${r.flag}' that this package never declares`, file, r.line, 'warning', { flag: r.flag }));
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return {
|
|
530
|
+
kind: st.kind,
|
|
531
|
+
name: st.name,
|
|
532
|
+
scope: st.scope,
|
|
533
|
+
sublibrary: st.sublibrary,
|
|
534
|
+
file,
|
|
535
|
+
line: st.line,
|
|
536
|
+
imports: st.imports,
|
|
537
|
+
fields: st.fields,
|
|
538
|
+
dependencies: own.deps.map((d) => ({ ...d, scope: d.scopeOverride || st.scope, scopeOverride: undefined })),
|
|
539
|
+
surfaces: own.surfaces,
|
|
540
|
+
};
|
|
541
|
+
});
|
|
542
|
+
for (const f of flags) f.package = pkg.name;
|
|
543
|
+
if (!pkg.name) diagnostics.push(diag('missing_package_name', 'no `name:` field found', file, null));
|
|
544
|
+
|
|
545
|
+
return {
|
|
546
|
+
kind: 'cabal', file, package: pkg, provenance, components, flags, unknownFlags,
|
|
547
|
+
sourceRepositories: sourceRepositories.map((r) => ({ ...r, package: pkg.name })),
|
|
548
|
+
surfaces: [...topSurfaces, ...components.flatMap((c) => c.surfaces.map((s) => ({ ...s, component: c.name })))],
|
|
549
|
+
diagnostics,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
// package.yaml (Hpack)
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
|
|
557
|
+
class BudgetError extends Error {}
|
|
558
|
+
|
|
559
|
+
function findLine(lines, re, from) {
|
|
560
|
+
for (let i = Math.max(0, from); i < lines.length; i++) if (re.test(lines[i])) return i;
|
|
561
|
+
return -1;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function hpackDepEntries(v, budget) {
|
|
565
|
+
const tick = () => { if (++budget.n > budget.max) throw new BudgetError('node budget exceeded'); };
|
|
566
|
+
const out = [];
|
|
567
|
+
const fromObj = (o) => {
|
|
568
|
+
for (const [k, val] of Object.entries(o)) {
|
|
569
|
+
tick();
|
|
570
|
+
let rangeText = '';
|
|
571
|
+
let numeric = false;
|
|
572
|
+
if (typeof val === 'string') rangeText = val;
|
|
573
|
+
else if (typeof val === 'number') { rangeText = String(val); numeric = true; }
|
|
574
|
+
else if (isObj(val) && val.version != null) rangeText = String(val.version);
|
|
575
|
+
out.push({ name: k, sublibrary: null, rangeText: rangeText.trim(), numeric });
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
const fromString = (s) => {
|
|
579
|
+
const p = parseDepPiece(s.trim());
|
|
580
|
+
if (p) out.push(...p.map((x) => ({ ...x, numeric: false }))); else out.push({ name: s, malformed: true, rangeText: '' });
|
|
581
|
+
};
|
|
582
|
+
tick();
|
|
583
|
+
if (typeof v === 'string') fromString(v);
|
|
584
|
+
else if (Array.isArray(v)) {
|
|
585
|
+
for (const e of v) { tick(); if (typeof e === 'string') fromString(e); else if (isObj(e)) fromObj(e); }
|
|
586
|
+
} else if (isObj(v)) fromObj(v);
|
|
587
|
+
return out;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Parse package.yaml. Malformed YAML yields {incomplete:true} plus a diagnostic. */
|
|
591
|
+
export function parseHpackFile(text, opts = {}) {
|
|
592
|
+
const file = opts.file || 'package.yaml';
|
|
593
|
+
const budgets = budgetsOf(opts);
|
|
594
|
+
const diagnostics = [];
|
|
595
|
+
const empty = (extra = {}) => ({ kind: 'hpack', file, package: { name: null, version: null, buildType: null }, provenance: { generatedBy: null }, components: [], packageDependencies: [], flags: [], unknownFlags: [], sourceRepositories: [], surfaces: [], incomplete: true, diagnostics, ...extra });
|
|
596
|
+
const src = typeof text === 'string' ? text : '';
|
|
597
|
+
if (src.length > budgets.maxBytes) { diagnostics.push(diag('budget_exceeded', `manifest larger than ${budgets.maxBytes} bytes; not parsed`, file, null)); return empty(); }
|
|
598
|
+
let doc;
|
|
599
|
+
try {
|
|
600
|
+
doc = yamlLoad(src, { schema: CORE_SCHEMA });
|
|
601
|
+
} catch (e) {
|
|
602
|
+
const line = e && e.mark && Number.isInteger(e.mark.line) ? e.mark.line + 1 : null;
|
|
603
|
+
diagnostics.push(diag('malformed_yaml', String(e && e.reason ? e.reason : e && e.message).split('\n')[0].slice(0, 200), file, line, 'error'));
|
|
604
|
+
return empty();
|
|
605
|
+
}
|
|
606
|
+
if (doc === undefined) { diagnostics.push(diag('empty_manifest', 'package.yaml has no content', file, null)); return empty(); }
|
|
607
|
+
if (!isObj(doc)) { diagnostics.push(diag('malformed_manifest', 'package.yaml top level must be a mapping', file, 1, 'error')); return empty(); }
|
|
608
|
+
|
|
609
|
+
const lines = src.split(/\r\n|\r|\n/);
|
|
610
|
+
const budget = { n: 0, max: budgets.maxNodes };
|
|
611
|
+
const pkg = { name: typeof doc.name === 'string' ? doc.name : null, version: doc.version != null ? String(doc.version) : null, buildType: typeof doc['build-type'] === 'string' ? doc['build-type'] : null };
|
|
612
|
+
const surfaces = [];
|
|
613
|
+
const flagList = [];
|
|
614
|
+
const sourceRepositories = [];
|
|
615
|
+
const unknownFlags = [];
|
|
616
|
+
let incomplete = false;
|
|
617
|
+
|
|
618
|
+
const collect = (sec, st, conds, start, depth) => {
|
|
619
|
+
if (!isObj(sec) || depth > budgets.maxDepth) return;
|
|
620
|
+
if (++budget.n > budget.max) throw new BudgetError('node budget exceeded');
|
|
621
|
+
// Top-level keys sit at column 0; anchoring stops a nested section's key
|
|
622
|
+
// (custom-setup, library) from being mistaken for the package-wide one.
|
|
623
|
+
const ind = depth === 0 ? '' : '\\s*';
|
|
624
|
+
for (const [key, scopeOverride] of [['dependencies', null], ['build-tools', 'build-tool'], ['build-tool-depends', 'build-tool']]) {
|
|
625
|
+
if (!(key in sec)) continue;
|
|
626
|
+
let cursor = findLine(lines, new RegExp(`^${ind}(?:-\\s+)?${esc(key)}\\s*:`), start);
|
|
627
|
+
if (cursor < 0) cursor = start;
|
|
628
|
+
for (const e of hpackDepEntries(sec[key], budget)) {
|
|
629
|
+
let li = findLine(lines, new RegExp(`(?<![A-Za-z0-9-])${esc(e.name)}(?![A-Za-z0-9-])`), cursor);
|
|
630
|
+
const exact = li >= 0;
|
|
631
|
+
if (li < 0) li = cursor;
|
|
632
|
+
cursor = li;
|
|
633
|
+
const line = li + 1;
|
|
634
|
+
if (e.malformed) { diagnostics.push(diag('malformed_dependency', `cannot read dependency '${e.name}'`, file, line)); continue; }
|
|
635
|
+
const d = makeDep(e, { field: key, scopeOverride, conds, line, column: Math.max(0, (lines[li] || '').search(new RegExp(`(?<![A-Za-z0-9-])${esc(e.name)}(?![A-Za-z0-9-])`))), locationKind: exact ? 'text-match' : 'section' });
|
|
636
|
+
if (d.rangeError) diagnostics.push(diag('invalid_version_range', `${e.name}: ${d.rangeError}`, file, line, 'warning', { rangeText: e.rangeText }));
|
|
637
|
+
if (e.numeric) diagnostics.push(diag('yaml_numeric_version', `'${e.name}: ${e.rangeText}' is a YAML number; quote it, trailing zeros are lost`, file, line));
|
|
638
|
+
st.deps.push(d);
|
|
639
|
+
if (scopeOverride === 'build-tool') surfaces.push({ kind: 'build-tool-depends', detail: `${e.name} ${e.rangeText}`.trim(), file, line, column: d.column, component: st.name });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if ('ghc-options' in sec) {
|
|
643
|
+
const opt = Array.isArray(sec['ghc-options']) ? sec['ghc-options'].join(' ') : String(sec['ghc-options']);
|
|
644
|
+
let li = findLine(lines, new RegExp(`^${ind}(?:-\\s+)?ghc-options\\s*:`), start);
|
|
645
|
+
if (li < 0) li = start;
|
|
646
|
+
scanGhcOptions(opt, null, (kind, detail) => surfaces.push({ kind, detail, file, line: li + 1, column: 0, component: st.name }));
|
|
647
|
+
}
|
|
648
|
+
if ('verbatim' in sec) surfaces.push({ kind: 'hpack-verbatim', detail: 'verbatim cabal passthrough', file, line: Math.max(1, findLine(lines, /^\s*verbatim\s*:/, start) + 1), column: 0, component: st.name });
|
|
649
|
+
if ('when' in sec) {
|
|
650
|
+
const whens = Array.isArray(sec.when) ? sec.when : [sec.when];
|
|
651
|
+
for (const w of whens) {
|
|
652
|
+
if (!isObj(w)) continue;
|
|
653
|
+
const cond = String(w.condition);
|
|
654
|
+
let ws = findLine(lines, new RegExp(`condition\\s*:\\s*['"]?${esc(cond)}`), start);
|
|
655
|
+
if (ws < 0) ws = start;
|
|
656
|
+
for (const r of flagRefsOf(cond)) st.flagRefs.push({ flag: r, line: ws + 1 });
|
|
657
|
+
const inner = { ...w }; delete inner.then; delete inner.else; delete inner.condition;
|
|
658
|
+
collect(inner, st, [...conds, cond], ws, depth + 1);
|
|
659
|
+
if (isObj(w.then)) collect(w.then, st, [...conds, cond], ws, depth + 1);
|
|
660
|
+
if (isObj(w.else)) collect(w.else, st, [...conds, `!(${cond})`], ws, depth + 1);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
const mkStanza = (kind, name, scope) => ({ kind, name, scope, deps: [], flagRefs: [] });
|
|
666
|
+
const finish = (st, line) => {
|
|
667
|
+
for (const r of st.flagRefs) {
|
|
668
|
+
if (!(doc.flags && isObj(doc.flags) && r.flag in doc.flags)) {
|
|
669
|
+
unknownFlags.push({ flag: r.flag, component: st.name, file, line: r.line });
|
|
670
|
+
diagnostics.push(diag('unknown_flag', `condition references flag '${r.flag}' that package.yaml never declares`, file, r.line, 'warning', { flag: r.flag }));
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return { kind: st.kind, name: st.name, scope: st.scope, file, line, dependencies: st.deps.map((d) => ({ ...d, scope: d.scopeOverride || st.scope, scopeOverride: undefined })) };
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
const components = [];
|
|
677
|
+
let packageDependencies = [];
|
|
678
|
+
try {
|
|
679
|
+
const top = mkStanza('package', pkg.name, 'package');
|
|
680
|
+
collect(doc, top, [], 0, 0);
|
|
681
|
+
// top-level `when` and deps apply to every component
|
|
682
|
+
const topLoc = finish(top, 1);
|
|
683
|
+
packageDependencies = topLoc.dependencies;
|
|
684
|
+
|
|
685
|
+
const section = (key, kind, scope, single) => {
|
|
686
|
+
if (!(key in doc)) return;
|
|
687
|
+
const base = Math.max(0, findLine(lines, new RegExp(`^${esc(key)}\\s*:`), 0));
|
|
688
|
+
const add = (name, sec, start) => {
|
|
689
|
+
const st = mkStanza(kind, name, scope);
|
|
690
|
+
collect(sec, st, [], start, 1);
|
|
691
|
+
components.push(finish(st, start + 1));
|
|
692
|
+
};
|
|
693
|
+
if (single) { add(kind === 'library' ? pkg.name : pkg.name, doc[key], base); return; }
|
|
694
|
+
if (!isObj(doc[key])) { diagnostics.push(diag('malformed_manifest', `'${key}' must be a mapping`, file, base + 1)); return; }
|
|
695
|
+
for (const [name, sec] of Object.entries(doc[key])) {
|
|
696
|
+
let s = findLine(lines, new RegExp(`^\\s+${esc(name)}\\s*:`), base);
|
|
697
|
+
if (s < 0) s = base;
|
|
698
|
+
add(name, sec, s);
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
section('library', 'library', 'runtime', true);
|
|
702
|
+
section('internal-libraries', 'library', 'runtime', false);
|
|
703
|
+
section('executable', 'executable', 'runtime', true);
|
|
704
|
+
section('executables', 'executable', 'runtime', false);
|
|
705
|
+
section('foreign-libraries', 'foreign-library', 'runtime', false);
|
|
706
|
+
section('tests', 'test-suite', 'test', false);
|
|
707
|
+
section('benchmarks', 'benchmark', 'benchmark', false);
|
|
708
|
+
|
|
709
|
+
if (isObj(doc['custom-setup'])) {
|
|
710
|
+
const st = mkStanza('custom-setup', 'setup', 'setup');
|
|
711
|
+
const start = Math.max(0, findLine(lines, /^custom-setup\s*:/, 0));
|
|
712
|
+
collect(doc['custom-setup'], st, [], start, 1);
|
|
713
|
+
surfaces.push({ kind: 'custom-setup', detail: 'custom-setup section', file, line: start + 1, column: 0, component: 'setup' });
|
|
714
|
+
components.push(finish(st, start + 1));
|
|
715
|
+
}
|
|
716
|
+
} catch (e) {
|
|
717
|
+
if (e instanceof BudgetError) { incomplete = true; diagnostics.push(diag('budget_exceeded', 'package.yaml exceeds node budget (alias expansion or size); inventory truncated', file, null, 'error')); }
|
|
718
|
+
else throw e;
|
|
719
|
+
}
|
|
720
|
+
if (pkg.buildType && /^(custom|configure|make)$/i.test(pkg.buildType)) {
|
|
721
|
+
surfaces.push({ kind: `build-type-${pkg.buildType.toLowerCase()}`, detail: `build-type: ${pkg.buildType}`, file, line: Math.max(1, findLine(lines, /^build-type\s*:/, 0) + 1), column: 0 });
|
|
722
|
+
}
|
|
723
|
+
if (isObj(doc.flags)) {
|
|
724
|
+
for (const [name, f] of Object.entries(doc.flags)) {
|
|
725
|
+
flagList.push({ package: pkg.name, name, default: isObj(f) && typeof f.default === 'boolean' ? f.default : null, manual: isObj(f) && f.manual === true, file, line: Math.max(1, findLine(lines, new RegExp(`^\\s+${esc(name)}\\s*:`), Math.max(0, findLine(lines, /^flags\s*:/, 0))) + 1) });
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const repoLine = (key) => Math.max(1, findLine(lines, new RegExp(`^${key}\\s*:`), 0) + 1);
|
|
729
|
+
if (typeof doc.github === 'string') {
|
|
730
|
+
const [user, repo, ...sub] = doc.github.split('/');
|
|
731
|
+
sourceRepositories.push({ kind: 'head', type: 'git', location: `https://github.com/${user}/${repo}`, subdir: sub.join('/') || null, tag: null, branch: null, pinned: false, package: pkg.name, file, line: repoLine('github') });
|
|
732
|
+
}
|
|
733
|
+
if (typeof doc.git === 'string') sourceRepositories.push({ kind: 'head', type: 'git', location: doc.git, subdir: null, tag: null, branch: null, pinned: false, package: pkg.name, file, line: repoLine('git') });
|
|
734
|
+
|
|
735
|
+
return {
|
|
736
|
+
kind: 'hpack', file, package: pkg, provenance: { generatedBy: null, source: 'hand-written' },
|
|
737
|
+
components, packageDependencies, flags: flagList, unknownFlags, sourceRepositories,
|
|
738
|
+
surfaces, incomplete, diagnostics,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// ---------------------------------------------------------------------------
|
|
743
|
+
// cabal.project / cabal.project.freeze
|
|
744
|
+
// ---------------------------------------------------------------------------
|
|
745
|
+
|
|
746
|
+
function parseConstraintEntry(text) {
|
|
747
|
+
const m = /^(?:(any|setup)\.|([A-Za-z0-9-]+):setup\.)?([A-Za-z0-9][A-Za-z0-9-]*)\s*(.*)$/.exec(text);
|
|
748
|
+
if (!m) return null;
|
|
749
|
+
const rest = m[4].trim();
|
|
750
|
+
const base = { name: m[3], qualifier: m[1] || (m[2] ? `${m[2]}:setup` : null), raw: text };
|
|
751
|
+
if (rest === '') return { ...base, kind: 'bare', rangeText: '' };
|
|
752
|
+
if (rest !== '-any' && rest !== '-none' && /^(?:[+-][A-Za-z0-9_-]+\s*)+$/.test(rest)) {
|
|
753
|
+
return { ...base, kind: 'flags', flags: rest.split(/\s+/).map((f) => ({ flag: f.slice(1), value: f[0] === '+' })) };
|
|
754
|
+
}
|
|
755
|
+
if (rest === 'installed' || rest === 'source') return { ...base, kind: 'preference', preference: rest };
|
|
756
|
+
if (rest === 'test' || rest === 'bench') return { ...base, kind: 'preference', preference: rest };
|
|
757
|
+
const rr = parseVersionRange(rest);
|
|
758
|
+
return { ...base, kind: 'range', rangeText: rest, range: rr.ok ? rr.ast : null, rangeError: rr.ok ? null : rr.error };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function isRemote(p) { return /^[a-z][a-z0-9+.-]*:\/\//i.test(p); }
|
|
762
|
+
|
|
763
|
+
/** Parse cabal.project, cabal.project.local, cabal.project.freeze or cabal.config. */
|
|
764
|
+
export function parseCabalProject(text, opts = {}) {
|
|
765
|
+
const file = opts.file || 'cabal.project';
|
|
766
|
+
const budgets = budgetsOf(opts);
|
|
767
|
+
const diagnostics = [];
|
|
768
|
+
const base = path.posix.basename(file);
|
|
769
|
+
const isFreeze = opts.freeze === true || /\.freeze$/.test(base) || base === 'cabal.config';
|
|
770
|
+
const depth = opts._depth || 0;
|
|
771
|
+
const seen = opts._seen || new Set([file]);
|
|
772
|
+
const lines = lexLines(text, budgets, diagnostics, file);
|
|
773
|
+
const tree = buildTree(lines, budgets, diagnostics, file);
|
|
774
|
+
|
|
775
|
+
const res = {
|
|
776
|
+
kind: isFreeze ? 'cabal-freeze' : 'cabal-project', file,
|
|
777
|
+
packages: [], constraints: [], allowRelaxations: [], extraPackages: [], imports: [], sourceRepositories: [],
|
|
778
|
+
flagSettings: [], packageStanzas: [], lockedPackages: [], surfaces: [], indexState: null, withCompiler: null, diagnostics,
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
const addConstraint = (c, val, offset, conds, from) => {
|
|
782
|
+
const loc = locate(val, offset);
|
|
783
|
+
const rec = { ...c, conditions: conds.slice(), file: from || file, line: loc.line, column: loc.column, importedFrom: from && from !== file ? from : null };
|
|
784
|
+
if (rec.rangeError) diagnostics.push(diag('invalid_version_range', `${rec.name}: ${rec.rangeError}`, rec.file, rec.line, 'warning', { rangeText: rec.rangeText }));
|
|
785
|
+
res.constraints.push(rec);
|
|
786
|
+
if (rec.kind === 'flags') for (const f of rec.flags) res.flagSettings.push({ scope: 'project', package: rec.name, name: f.flag, value: f.value, file: rec.file, line: rec.line });
|
|
787
|
+
if (isFreeze) {
|
|
788
|
+
if (rec.kind === 'range' && rec.range && rec.range.t === 'cmp' && rec.range.op === '==' && !rec.range.wildcard) {
|
|
789
|
+
res.lockedPackages.push({ name: rec.name, version: rec.range.text, qualifier: rec.qualifier, source: base, file: rec.file, line: rec.line, column: rec.column });
|
|
790
|
+
} else if (rec.kind === 'preference' && rec.preference === 'installed') {
|
|
791
|
+
res.lockedPackages.push({ name: rec.name, version: null, installed: true, qualifier: rec.qualifier, source: base, file: rec.file, line: rec.line, column: rec.column });
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
const walk = (children, conds, dpt) => {
|
|
797
|
+
if (dpt > budgets.maxDepth) return;
|
|
798
|
+
for (const node of children) {
|
|
799
|
+
const t = node.line.text;
|
|
800
|
+
const fm = FIELD.exec(t);
|
|
801
|
+
if (fm) {
|
|
802
|
+
const name = fm[1].toLowerCase();
|
|
803
|
+
const val = fieldValue(node);
|
|
804
|
+
switch (name) {
|
|
805
|
+
case 'packages': case 'optional-packages':
|
|
806
|
+
for (const w of splitWords(val.text)) res.packages.push({ pattern: w.text, optional: name === 'optional-packages', file, line: locate(val, w.offset).line });
|
|
807
|
+
break;
|
|
808
|
+
case 'extra-packages':
|
|
809
|
+
for (const piece of splitCommas(val.text)) {
|
|
810
|
+
const p = parseDepPiece(piece.text);
|
|
811
|
+
if (!p) { diagnostics.push(diag('malformed_dependency', `cannot read extra-package '${piece.text}'`, file, locate(val, piece.offset).line)); continue; }
|
|
812
|
+
const loc = locate(val, piece.offset);
|
|
813
|
+
for (const x of p) res.extraPackages.push(makeDep(x, { field: 'extra-packages', conds, line: loc.line, column: loc.column }));
|
|
814
|
+
}
|
|
815
|
+
break;
|
|
816
|
+
case 'constraints':
|
|
817
|
+
for (const piece of splitCommas(val.text)) {
|
|
818
|
+
const c = parseConstraintEntry(piece.text);
|
|
819
|
+
if (!c) { diagnostics.push(diag('malformed_constraint', `cannot read constraint '${piece.text}'`, file, locate(val, piece.offset).line)); continue; }
|
|
820
|
+
addConstraint(c, val, piece.offset, conds, null);
|
|
821
|
+
}
|
|
822
|
+
break;
|
|
823
|
+
case 'allow-newer': case 'allow-older':
|
|
824
|
+
for (const piece of splitCommas(val.text)) res.allowRelaxations.push({ direction: name === 'allow-newer' ? 'newer' : 'older', target: piece.text, weakensBounds: true, file, line: locate(val, piece.offset).line });
|
|
825
|
+
break;
|
|
826
|
+
case 'index-state': res.indexState = val.text.trim(); break;
|
|
827
|
+
case 'with-compiler': res.withCompiler = val.text.trim(); break;
|
|
828
|
+
case 'flags':
|
|
829
|
+
for (const w of splitWords(val.text)) {
|
|
830
|
+
const fl = /^([+-])([A-Za-z0-9_-]+)$/.exec(w.text);
|
|
831
|
+
if (fl) res.flagSettings.push({ scope: 'project', package: null, name: fl[2], value: fl[1] === '+', file, line: locate(val, w.offset).line });
|
|
832
|
+
}
|
|
833
|
+
break;
|
|
834
|
+
case 'import': {
|
|
835
|
+
const target = val.text.trim();
|
|
836
|
+
handleImport(target, val.line, conds);
|
|
837
|
+
break;
|
|
838
|
+
}
|
|
839
|
+
default: break;
|
|
840
|
+
}
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
const sm = /^source-repository-package\b/i.exec(t);
|
|
844
|
+
if (sm) {
|
|
845
|
+
const r = { type: null, location: null, tag: null, branch: null, subdir: null, sha256: null, file, line: node.line.n };
|
|
846
|
+
for (const c of node.children) {
|
|
847
|
+
const cf = FIELD.exec(c.line.text);
|
|
848
|
+
if (!cf) continue;
|
|
849
|
+
const k = cf[1].toLowerCase();
|
|
850
|
+
const v = fieldValue(c).text.trim();
|
|
851
|
+
if (k in r && k !== 'file' && k !== 'line') r[k] = v;
|
|
852
|
+
}
|
|
853
|
+
r.pinned = !!r.tag && /^[0-9a-f]{7,40}$/i.test(r.tag);
|
|
854
|
+
r.integrity = r.sha256 ? 'sha256' : 'none';
|
|
855
|
+
res.sourceRepositories.push(r);
|
|
856
|
+
res.surfaces.push({ kind: 'source-repository-package', detail: `${r.location || '?'} ${r.tag || r.branch || '(no ref)'}`, file, line: r.line, column: node.line.indent });
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
const pm = /^package\s+(\S+)$/i.exec(t);
|
|
860
|
+
if (pm) {
|
|
861
|
+
const sp = { selector: pm[1], flags: [], file, line: node.line.n };
|
|
862
|
+
for (const c of node.children) {
|
|
863
|
+
const cf = FIELD.exec(c.line.text);
|
|
864
|
+
if (!cf) continue;
|
|
865
|
+
if (cf[1].toLowerCase() === 'flags') {
|
|
866
|
+
const val = fieldValue(c);
|
|
867
|
+
for (const w of splitWords(val.text)) {
|
|
868
|
+
const fl = /^([+-])([A-Za-z0-9_-]+)$/.exec(w.text);
|
|
869
|
+
if (fl) { sp.flags.push({ name: fl[2], value: fl[1] === '+' }); res.flagSettings.push({ scope: 'package', package: pm[1], name: fl[2], value: fl[1] === '+', file, line: locate(val, w.offset).line }); }
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
res.packageStanzas.push(sp);
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
const cm = /^(if|elif|else)\b\s*(.*)$/i.exec(t);
|
|
877
|
+
if (cm) walk(node.children, [...conds, cm[1].toLowerCase() === 'else' ? 'else' : cm[2]], dpt + 1);
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
function handleImport(target, line, conds) {
|
|
882
|
+
const rec = { target, resolved: false, file, line, conditions: conds.slice() };
|
|
883
|
+
res.imports.push(rec);
|
|
884
|
+
if (isRemote(target)) {
|
|
885
|
+
res.surfaces.push({ kind: 'remote-import', detail: target, file, line, column: 0 });
|
|
886
|
+
diagnostics.push(diag('remote_import_not_fetched', `import of ${target} is a remote fetch; not followed, so its constraints are unknown`, file, line));
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(file), target));
|
|
890
|
+
if (resolved.startsWith('..') || path.posix.isAbsolute(resolved)) { diagnostics.push(diag('import_outside_root', `import '${target}' escapes the project root; not read`, file, line)); return; }
|
|
891
|
+
if (seen.has(resolved)) { diagnostics.push(diag('import_cycle', `import '${target}' forms a cycle`, file, line)); return; }
|
|
892
|
+
if (depth >= budgets.maxImportDepth) { diagnostics.push(diag('budget_exceeded', `import chain deeper than ${budgets.maxImportDepth}`, file, line)); return; }
|
|
893
|
+
const sub = typeof opts.readFile === 'function' ? opts.readFile(resolved) : null;
|
|
894
|
+
if (typeof sub !== 'string') { diagnostics.push(diag('unresolved_import', `import '${target}' could not be read; constraints it contributes are unknown`, file, line)); return; }
|
|
895
|
+
rec.resolved = true;
|
|
896
|
+
rec.resolvedPath = resolved;
|
|
897
|
+
const child = parseCabalProject(sub, { ...opts, file: resolved, _depth: depth + 1, _seen: new Set(seen).add(resolved) });
|
|
898
|
+
const local = new Map(res.constraints.map((c) => [c.name, c]));
|
|
899
|
+
for (const c of child.constraints) {
|
|
900
|
+
const prior = local.get(c.name);
|
|
901
|
+
if (prior && prior.kind === 'range' && c.kind === 'range' && prior.rangeText !== c.rangeText) {
|
|
902
|
+
diagnostics.push(diag('import_override', `'${c.name}' is constrained as '${prior.rangeText}' and also by import ${resolved} as '${c.rangeText}'; the effective bound is their intersection or the later one, not decided here`, file, line, 'warning', { name: c.name }));
|
|
903
|
+
}
|
|
904
|
+
res.constraints.push({ ...c, importedFrom: c.importedFrom || resolved });
|
|
905
|
+
}
|
|
906
|
+
res.packages.push(...child.packages);
|
|
907
|
+
res.allowRelaxations.push(...child.allowRelaxations);
|
|
908
|
+
res.sourceRepositories.push(...child.sourceRepositories);
|
|
909
|
+
res.flagSettings.push(...child.flagSettings);
|
|
910
|
+
res.surfaces.push(...child.surfaces);
|
|
911
|
+
res.lockedPackages.push(...child.lockedPackages.map((l) => ({ ...l, importedFrom: resolved })));
|
|
912
|
+
res.imports.push(...child.imports);
|
|
913
|
+
diagnostics.push(...child.diagnostics);
|
|
914
|
+
if (!res.indexState && child.indexState) res.indexState = child.indexState;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
walk(tree.children, [], 0);
|
|
918
|
+
return res;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// ---------------------------------------------------------------------------
|
|
922
|
+
// stack.yaml / stack.yaml.lock
|
|
923
|
+
// ---------------------------------------------------------------------------
|
|
924
|
+
|
|
925
|
+
/** Classify a Stack snapshot selector. The package set is NOT resolved (needs the snapshot file). */
|
|
926
|
+
export function classifySnapshot(v) {
|
|
927
|
+
const out = { kind: 'unknown', selector: null, floating: false, url: null, sha256: null, resolved: false, pinnedByHash: false };
|
|
928
|
+
if (typeof v === 'string') {
|
|
929
|
+
out.selector = v;
|
|
930
|
+
if (/^lts-\d+$/.test(v)) { out.kind = 'lts'; out.floating = true; }
|
|
931
|
+
else if (/^lts-\d+\.\d+$/.test(v)) out.kind = 'lts';
|
|
932
|
+
else if (/^nightly-\d{4}-\d{2}-\d{2}$/.test(v)) out.kind = 'nightly';
|
|
933
|
+
else if (/^ghc-\d+(?:\.\d+)*$/.test(v)) out.kind = 'compiler';
|
|
934
|
+
else if (/^https?:\/\//i.test(v)) { out.kind = 'url'; out.url = v; }
|
|
935
|
+
else if (/\.ya?ml$/i.test(v) || /^\.{0,2}\//.test(v)) out.kind = 'file';
|
|
936
|
+
return out;
|
|
937
|
+
}
|
|
938
|
+
if (isObj(v)) {
|
|
939
|
+
if (typeof v.url === 'string') { out.kind = 'url'; out.url = v.url; out.selector = v.url; }
|
|
940
|
+
else if (typeof v.github === 'string') { out.kind = 'github'; out.selector = `${v.github}:${v.path || ''}`; }
|
|
941
|
+
else if (typeof v.compiler === 'string') { out.kind = 'compiler'; out.selector = v.compiler; }
|
|
942
|
+
if (typeof v.sha256 === 'string') { out.sha256 = v.sha256; out.pinnedByHash = true; }
|
|
943
|
+
}
|
|
944
|
+
return out;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/** Parse a Pantry locator such as `foo-1.2.3@sha256:ab..,1234` or `foo-1.2.3@rev:2`. */
|
|
948
|
+
export function parseHackageLocator(s) {
|
|
949
|
+
const [id, ...restParts] = String(s).split('@');
|
|
950
|
+
const rest = restParts.join('@');
|
|
951
|
+
const m = /^(.+)-(\d+(?:\.\d+)*)$/.exec(id);
|
|
952
|
+
const out = { name: m ? m[1] : id, version: m ? m[2] : null, sha256: null, size: null, revision: null, malformed: false };
|
|
953
|
+
if (rest) {
|
|
954
|
+
const sha = /^sha256:([0-9a-f]{64})(?:,(\d+))?$/i.exec(rest);
|
|
955
|
+
const rev = /^rev:(\d+)$/.exec(rest);
|
|
956
|
+
if (sha) { out.sha256 = sha[1]; out.size = sha[2] ? Number(sha[2]) : null; } else if (rev) out.revision = Number(rev[1]); else out.malformed = true;
|
|
957
|
+
}
|
|
958
|
+
if (!m) out.malformed = true;
|
|
959
|
+
return out;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function parseExtraDep(e, line) {
|
|
963
|
+
const base = { file: null, line, kind: 'unknown', name: null, version: null, commit: null, locator: null, sha256: null, size: null, revision: null, subdirs: [] };
|
|
964
|
+
if (typeof e === 'string') {
|
|
965
|
+
if (/^\.{0,2}\//.test(e) || e === '.') return { ...base, kind: 'path', locator: e };
|
|
966
|
+
if (isRemote(e)) return { ...base, kind: 'url', locator: e };
|
|
967
|
+
const h = parseHackageLocator(e);
|
|
968
|
+
return { ...base, kind: 'hackage', name: h.name, version: h.version, sha256: h.sha256, size: h.size, revision: h.revision, locator: e, malformed: h.malformed };
|
|
969
|
+
}
|
|
970
|
+
if (isObj(e)) {
|
|
971
|
+
const subdirs = Array.isArray(e.subdirs) ? e.subdirs.map(String) : [];
|
|
972
|
+
if (typeof e.hackage === 'string') { const h = parseHackageLocator(e.hackage); return { ...base, kind: 'hackage', name: h.name, version: h.version, sha256: h.sha256, size: h.size, revision: h.revision, locator: e.hackage, malformed: h.malformed }; }
|
|
973
|
+
if (typeof e.git === 'string') return { ...base, kind: 'git', locator: e.git, commit: e.commit ? String(e.commit) : null, subdirs };
|
|
974
|
+
if (typeof e.github === 'string') return { ...base, kind: 'github', locator: e.github, commit: e.commit ? String(e.commit) : null, subdirs };
|
|
975
|
+
if (typeof e.hg === 'string') return { ...base, kind: 'hg', locator: e.hg, commit: e.commit ? String(e.commit) : null, subdirs };
|
|
976
|
+
if (typeof e.url === 'string' || typeof e.archive === 'string') return { ...base, kind: 'url', locator: e.url || e.archive, sha256: typeof e.sha256 === 'string' ? e.sha256 : null, size: Number.isInteger(e.size) ? e.size : null, subdirs };
|
|
977
|
+
}
|
|
978
|
+
return base;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** Parse stack.yaml. */
|
|
982
|
+
export function parseStackYaml(text, opts = {}) {
|
|
983
|
+
const file = opts.file || 'stack.yaml';
|
|
984
|
+
const budgets = budgetsOf(opts);
|
|
985
|
+
const diagnostics = [];
|
|
986
|
+
const res = { kind: 'stack', file, snapshot: null, compiler: null, packages: [], extraDeps: [], flagSettings: [], allowNewer: false, surfaces: [], incomplete: false, diagnostics };
|
|
987
|
+
const src = typeof text === 'string' ? text : '';
|
|
988
|
+
if (src.length > budgets.maxBytes) { diagnostics.push(diag('budget_exceeded', `manifest larger than ${budgets.maxBytes} bytes; not parsed`, file, null)); res.incomplete = true; return res; }
|
|
989
|
+
let doc;
|
|
990
|
+
try { doc = yamlLoad(src, { schema: CORE_SCHEMA }); } catch (e) {
|
|
991
|
+
diagnostics.push(diag('malformed_yaml', String(e && e.reason ? e.reason : e && e.message).split('\n')[0].slice(0, 200), file, e && e.mark && Number.isInteger(e.mark.line) ? e.mark.line + 1 : null, 'error'));
|
|
992
|
+
res.incomplete = true; return res;
|
|
993
|
+
}
|
|
994
|
+
if (doc === undefined) { diagnostics.push(diag('empty_manifest', 'stack.yaml has no content', file, null)); res.incomplete = true; return res; }
|
|
995
|
+
if (!isObj(doc)) { diagnostics.push(diag('malformed_manifest', 'stack.yaml top level must be a mapping', file, 1, 'error')); res.incomplete = true; return res; }
|
|
996
|
+
const lines = src.split(/\r\n|\r|\n/);
|
|
997
|
+
const keyLine = (k) => Math.max(1, findLine(lines, new RegExp(`^${esc(k)}\\s*:`), 0) + 1);
|
|
998
|
+
|
|
999
|
+
const selKey = 'snapshot' in doc ? 'snapshot' : ('resolver' in doc ? 'resolver' : null);
|
|
1000
|
+
if ('snapshot' in doc && 'resolver' in doc) diagnostics.push(diag('both_resolver_and_snapshot', 'both `snapshot` and `resolver` are set; `snapshot` wins', file, keyLine('resolver')));
|
|
1001
|
+
if (selKey) res.snapshot = { ...classifySnapshot(doc[selKey]), key: selKey, file, line: keyLine(selKey) };
|
|
1002
|
+
else diagnostics.push(diag('missing_snapshot', 'stack.yaml declares no snapshot/resolver; the package set is undetermined', file, null));
|
|
1003
|
+
if (res.snapshot) {
|
|
1004
|
+
if (res.snapshot.floating) diagnostics.push(diag('floating_snapshot', `snapshot '${res.snapshot.selector}' has no minor version; it resolves to whatever is latest`, file, res.snapshot.line));
|
|
1005
|
+
if (res.snapshot.kind === 'url' && !res.snapshot.pinnedByHash) diagnostics.push(diag('unpinned_snapshot_url', `snapshot URL has no sha256; its contents can change`, file, res.snapshot.line));
|
|
1006
|
+
if (res.snapshot.kind === 'unknown') diagnostics.push(diag('unrecognized_snapshot', 'snapshot selector not recognised; kept verbatim', file, res.snapshot.line));
|
|
1007
|
+
}
|
|
1008
|
+
if (typeof doc.compiler === 'string') res.compiler = doc.compiler;
|
|
1009
|
+
|
|
1010
|
+
if ('packages' in doc) {
|
|
1011
|
+
const arr = Array.isArray(doc.packages) ? doc.packages : [doc.packages];
|
|
1012
|
+
for (const p of arr.slice(0, budgets.maxNodes)) res.packages.push(typeof p === 'string' ? p : (isObj(p) && typeof p.location === 'string' ? p.location : String(p)));
|
|
1013
|
+
} else res.packages.push('.');
|
|
1014
|
+
|
|
1015
|
+
if (Array.isArray(doc['extra-deps'])) {
|
|
1016
|
+
const base = keyLine('extra-deps');
|
|
1017
|
+
let cursor = base - 1;
|
|
1018
|
+
for (const e of doc['extra-deps'].slice(0, budgets.maxNodes)) {
|
|
1019
|
+
const rec = parseExtraDep(e, base);
|
|
1020
|
+
const needle = rec.name || rec.locator;
|
|
1021
|
+
if (needle) { const li = findLine(lines, new RegExp(esc(String(needle).split(/[@,]/)[0])), cursor + 1); if (li >= 0) { cursor = li; rec.line = li + 1; } }
|
|
1022
|
+
rec.file = file;
|
|
1023
|
+
rec.integrity = rec.sha256 ? 'sha256' : (rec.commit ? 'commit' : (rec.revision != null ? 'revision' : 'none'));
|
|
1024
|
+
if (rec.malformed) diagnostics.push(diag('malformed_extra_dep', `cannot read extra-dep '${String(rec.locator)}'`, file, rec.line));
|
|
1025
|
+
if (rec.kind === 'unknown') diagnostics.push(diag('malformed_extra_dep', 'unrecognised extra-dep entry', file, rec.line));
|
|
1026
|
+
if (rec.kind === 'git' && !rec.commit) diagnostics.push(diag('unpinned_extra_dep', `git extra-dep ${rec.locator} has no commit`, file, rec.line));
|
|
1027
|
+
if (rec.kind === 'url' && !rec.sha256) diagnostics.push(diag('unpinned_extra_dep', `url extra-dep ${rec.locator} has no sha256`, file, rec.line));
|
|
1028
|
+
if (['git', 'github', 'hg', 'url'].includes(rec.kind)) res.surfaces.push({ kind: 'extra-dep-source', detail: `${rec.kind} ${rec.locator}`, file, line: rec.line, column: 0 });
|
|
1029
|
+
res.extraDeps.push(rec);
|
|
1030
|
+
}
|
|
1031
|
+
} else if ('extra-deps' in doc) diagnostics.push(diag('malformed_manifest', "'extra-deps' must be a list", file, keyLine('extra-deps')));
|
|
1032
|
+
|
|
1033
|
+
if (isObj(doc.flags)) {
|
|
1034
|
+
const base = keyLine('flags') - 1;
|
|
1035
|
+
for (const [pkgName, fl] of Object.entries(doc.flags)) {
|
|
1036
|
+
if (!isObj(fl)) { diagnostics.push(diag('malformed_manifest', `flags for '${pkgName}' must be a mapping`, file, base + 1)); continue; }
|
|
1037
|
+
for (const [flag, value] of Object.entries(fl)) {
|
|
1038
|
+
const li = findLine(lines, new RegExp(`^\\s+${esc(flag)}\\s*:`), base);
|
|
1039
|
+
res.flagSettings.push({ scope: 'stack', package: pkgName, name: flag, value: value === true || value === false ? value : null, file, line: (li >= 0 ? li : base) + 1 });
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
if (doc['allow-newer'] === true) { res.allowNewer = true; diagnostics.push(diag('allow_newer', 'allow-newer: true disables upper bounds across the project', file, keyLine('allow-newer'))); }
|
|
1044
|
+
if (isObj(doc.nix) && doc.nix.enable === true) res.surfaces.push({ kind: 'stack-nix-integration', detail: 'nix.enable: true', file, line: keyLine('nix'), column: 0 });
|
|
1045
|
+
if (isObj(doc.docker) && doc.docker.enable === true) res.surfaces.push({ kind: 'stack-docker-integration', detail: 'docker.enable: true', file, line: keyLine('docker'), column: 0 });
|
|
1046
|
+
return res;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/** Parse stack.yaml.lock. Hashes here are Pantry content hashes. */
|
|
1050
|
+
export function parseStackLock(text, opts = {}) {
|
|
1051
|
+
const file = opts.file || 'stack.yaml.lock';
|
|
1052
|
+
const budgets = budgetsOf(opts);
|
|
1053
|
+
const diagnostics = [];
|
|
1054
|
+
const res = { kind: 'stack-lock', file, packages: [], snapshots: [], incomplete: false, diagnostics };
|
|
1055
|
+
const src = typeof text === 'string' ? text : '';
|
|
1056
|
+
if (src.length > budgets.maxBytes) { diagnostics.push(diag('budget_exceeded', `lockfile larger than ${budgets.maxBytes} bytes; not parsed`, file, null)); res.incomplete = true; return res; }
|
|
1057
|
+
let doc;
|
|
1058
|
+
try { doc = yamlLoad(src, { schema: CORE_SCHEMA }); } catch (e) {
|
|
1059
|
+
diagnostics.push(diag('malformed_yaml', String(e && e.reason ? e.reason : e && e.message).split('\n')[0].slice(0, 200), file, e && e.mark && Number.isInteger(e.mark.line) ? e.mark.line + 1 : null, 'error'));
|
|
1060
|
+
res.incomplete = true; return res;
|
|
1061
|
+
}
|
|
1062
|
+
if (!isObj(doc)) { diagnostics.push(diag(doc === undefined ? 'empty_manifest' : 'malformed_manifest', 'stack.yaml.lock must be a mapping', file, 1, doc === undefined ? 'warning' : 'error')); res.incomplete = true; return res; }
|
|
1063
|
+
const lines = src.split(/\r\n|\r|\n/);
|
|
1064
|
+
const pkgBase = Math.max(0, findLine(lines, /^packages\s*:/, 0));
|
|
1065
|
+
let cursor = pkgBase;
|
|
1066
|
+
for (const p of (Array.isArray(doc.packages) ? doc.packages : []).slice(0, budgets.maxNodes)) {
|
|
1067
|
+
const c = isObj(p) && isObj(p.completed) ? p.completed : null;
|
|
1068
|
+
const o = isObj(p) && isObj(p.original) ? p.original : null;
|
|
1069
|
+
if (!c) { diagnostics.push(diag('malformed_lock_entry', 'lock package entry has no `completed` mapping', file, cursor + 1)); continue; }
|
|
1070
|
+
const tree = isObj(c['pantry-tree']) ? { sha256: c['pantry-tree'].sha256 || null, size: Number.isInteger(c['pantry-tree'].size) ? c['pantry-tree'].size : null } : null;
|
|
1071
|
+
let rec;
|
|
1072
|
+
if (typeof c.hackage === 'string') {
|
|
1073
|
+
const h = parseHackageLocator(c.hackage);
|
|
1074
|
+
rec = { kind: 'hackage', name: h.name, version: h.version, sha256: h.sha256, size: h.size, revision: h.revision, locator: c.hackage };
|
|
1075
|
+
} else if (typeof c.git === 'string') rec = { kind: 'git', name: c.name || null, version: c.version != null ? String(c.version) : null, commit: c.commit || null, locator: c.git };
|
|
1076
|
+
else if (typeof c.url === 'string') rec = { kind: 'url', name: c.name || null, version: c.version != null ? String(c.version) : null, sha256: c.sha256 || null, locator: c.url };
|
|
1077
|
+
else { diagnostics.push(diag('malformed_lock_entry', 'lock entry has no hackage/git/url locator', file, cursor + 1)); continue; }
|
|
1078
|
+
const needle = rec.locator.split('@')[0];
|
|
1079
|
+
const li = findLine(lines, new RegExp(esc(needle)), cursor);
|
|
1080
|
+
if (li >= 0) cursor = li;
|
|
1081
|
+
res.packages.push({ ...rec, pantryTree: tree, original: o, file, line: cursor + 1, source: 'stack.yaml.lock' });
|
|
1082
|
+
}
|
|
1083
|
+
const snapBase = Math.max(0, findLine(lines, /^snapshots\s*:/, 0));
|
|
1084
|
+
for (const s of (Array.isArray(doc.snapshots) ? doc.snapshots : []).slice(0, budgets.maxNodes)) {
|
|
1085
|
+
const c = isObj(s) && isObj(s.completed) ? s.completed : {};
|
|
1086
|
+
const o = isObj(s) && isObj(s.original) ? s.original : (isObj(s) ? s.original : null);
|
|
1087
|
+
res.snapshots.push({ original: o, url: typeof c.url === 'string' ? c.url : null, sha256: typeof c.sha256 === 'string' ? c.sha256 : null, size: Number.isInteger(c.size) ? c.size : null, file, line: snapBase + 1 });
|
|
1088
|
+
}
|
|
1089
|
+
return res;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// ---------------------------------------------------------------------------
|
|
1093
|
+
// Project-level analysis
|
|
1094
|
+
// ---------------------------------------------------------------------------
|
|
1095
|
+
|
|
1096
|
+
function classifyManifest(rel) {
|
|
1097
|
+
const base = path.posix.basename(rel);
|
|
1098
|
+
if (/\.cabal$/i.test(base) && base.length > 6) return 'cabal';
|
|
1099
|
+
if (base === 'package.yaml') return 'hpack';
|
|
1100
|
+
if (base === 'cabal.project' || base === 'cabal.project.local') return 'project';
|
|
1101
|
+
if (base === 'cabal.project.freeze' || base === 'cabal.config') return 'freeze';
|
|
1102
|
+
if (base === 'stack.yaml.lock') return 'stack-lock';
|
|
1103
|
+
if (/^stack(?:-[\w.-]+)?\.yaml$/.test(base)) return 'stack';
|
|
1104
|
+
return null;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function inventoryDep(d, ctx) {
|
|
1108
|
+
return {
|
|
1109
|
+
ecosystem: 'hackage', name: d.name, sublibrary: d.sublibrary, declaredRange: d.declaredRange, rangeKind: d.rangeKind,
|
|
1110
|
+
rangeError: d.rangeError, resolvedVersion: null, exactPin: d.exactPin,
|
|
1111
|
+
package: ctx.package, component: ctx.component, componentKind: ctx.componentKind, scope: d.scope || ctx.scope, field: d.field,
|
|
1112
|
+
conditions: d.conditions, flagRefs: d.flagRefs, via: d.via || null,
|
|
1113
|
+
manifest: ctx.file, manifestType: ctx.manifestType, provenance: ctx.provenance,
|
|
1114
|
+
shadowedBy: ctx.shadowedBy || null, line: d.line, column: d.column, locationKind: d.locationKind,
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* Analyse a set of manifests. `files` is [{path, text}] with posix paths relative
|
|
1120
|
+
* to the project root. Handles multi-package projects, pairs package.yaml with its
|
|
1121
|
+
* generated .cabal, and reports mismatches. Never throws on malformed input.
|
|
1122
|
+
*/
|
|
1123
|
+
export function analyzeHaskellManifests(files, opts = {}) {
|
|
1124
|
+
const budgets = budgetsOf(opts);
|
|
1125
|
+
const byPath = new Map();
|
|
1126
|
+
for (const f of files || []) if (f && typeof f.path === 'string') byPath.set(f.path.replace(/\\/g, '/'), typeof f.text === 'string' ? f.text : '');
|
|
1127
|
+
const readFile = (p) => (byPath.has(p) ? byPath.get(p) : null);
|
|
1128
|
+
|
|
1129
|
+
const out = {
|
|
1130
|
+
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
1131
|
+
packages: [], hpack: [], projects: [], stack: [], stackLocks: [],
|
|
1132
|
+
dependencies: [], lockedPackages: [], constraints: [], sourceRepositories: [], flags: [], flagSettings: [], snapshots: [], surfaces: [],
|
|
1133
|
+
diagnostics: [],
|
|
1134
|
+
coverage: { discovered: 0, parsed: 0, failed: 0, skipped: 0 },
|
|
1135
|
+
};
|
|
1136
|
+
const entries = [...byPath.keys()].sort().map((p) => [p, classifyManifest(p)]).filter(([, k]) => k);
|
|
1137
|
+
out.coverage.discovered = entries.length;
|
|
1138
|
+
const toParse = entries.slice(0, budgets.maxFiles);
|
|
1139
|
+
out.coverage.skipped = entries.length - toParse.length;
|
|
1140
|
+
if (out.coverage.skipped) out.diagnostics.push(diag('budget_exceeded', `${out.coverage.skipped} manifest(s) beyond the ${budgets.maxFiles} file budget were not read`, null, null));
|
|
1141
|
+
|
|
1142
|
+
const hpackByDir = new Map();
|
|
1143
|
+
const cabalByDir = new Map();
|
|
1144
|
+
const parsedResults = [];
|
|
1145
|
+
for (const [p, kind] of toParse) {
|
|
1146
|
+
const text = byPath.get(p);
|
|
1147
|
+
let r;
|
|
1148
|
+
try {
|
|
1149
|
+
if (kind === 'cabal') r = parseCabalFile(text, { file: p, budgets });
|
|
1150
|
+
else if (kind === 'hpack') r = parseHpackFile(text, { file: p, budgets });
|
|
1151
|
+
else if (kind === 'project' || kind === 'freeze') r = parseCabalProject(text, { file: p, freeze: kind === 'freeze', readFile, budgets });
|
|
1152
|
+
else if (kind === 'stack') r = parseStackYaml(text, { file: p, budgets });
|
|
1153
|
+
else r = parseStackLock(text, { file: p, budgets });
|
|
1154
|
+
} catch (e) {
|
|
1155
|
+
out.coverage.failed++;
|
|
1156
|
+
out.diagnostics.push(diag('parser_failure', `${kind} parser failed: ${String(e && e.message).slice(0, 160)}`, p, null, 'error'));
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
if (r.incomplete) out.coverage.failed++; else out.coverage.parsed++;
|
|
1160
|
+
out.diagnostics.push(...r.diagnostics);
|
|
1161
|
+
parsedResults.push([p, kind, r]);
|
|
1162
|
+
const dir = path.posix.dirname(p);
|
|
1163
|
+
if (kind === 'hpack') hpackByDir.set(dir, r);
|
|
1164
|
+
if (kind === 'cabal') { if (!cabalByDir.has(dir)) cabalByDir.set(dir, []); cabalByDir.get(dir).push(r); }
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const localPackages = new Map(); // name -> declared flag name set
|
|
1168
|
+
for (const [, kind, r] of parsedResults) {
|
|
1169
|
+
if ((kind === 'cabal' || kind === 'hpack') && r.package.name) {
|
|
1170
|
+
const set = localPackages.get(r.package.name) || new Set();
|
|
1171
|
+
for (const f of r.flags) set.add(f.name);
|
|
1172
|
+
localPackages.set(r.package.name, set);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
for (const [p, kind, r] of parsedResults) {
|
|
1177
|
+
const dir = path.posix.dirname(p);
|
|
1178
|
+
if (kind === 'cabal') {
|
|
1179
|
+
const hp = hpackByDir.get(dir);
|
|
1180
|
+
let provenance = 'hand-written';
|
|
1181
|
+
let shadowedBy = null;
|
|
1182
|
+
if (r.provenance.generatedBy === 'hpack') {
|
|
1183
|
+
provenance = 'hpack-generated';
|
|
1184
|
+
if (hp && !hp.incomplete) shadowedBy = hp.file;
|
|
1185
|
+
else if (!hp) out.diagnostics.push(diag('generated_cabal_source_missing', 'cabal file says it was generated by hpack but no package.yaml sits beside it', p, 1));
|
|
1186
|
+
} else if (hp && !hp.incomplete && (!hp.package.name || hp.package.name === r.package.name)) {
|
|
1187
|
+
out.diagnostics.push(diag('cabal_not_hpack_generated', 'package.yaml sits beside a hand-written .cabal; Hpack will refuse to overwrite it, so the two can drift', p, 1));
|
|
1188
|
+
}
|
|
1189
|
+
if (r.provenance.generatedBy === 'hpack' && hp && !hp.incomplete) {
|
|
1190
|
+
if (hp.package.name && r.package.name && hp.package.name !== r.package.name) out.diagnostics.push(diag('hpack_cabal_mismatch', `package.yaml name '${hp.package.name}' differs from generated cabal name '${r.package.name}'`, p, 1, 'warning', { field: 'name' }));
|
|
1191
|
+
if (hp.package.version && r.package.version && hp.package.version !== r.package.version) out.diagnostics.push(diag('hpack_cabal_mismatch', `package.yaml version '${hp.package.version}' differs from generated cabal version '${r.package.version}'`, p, 1, 'warning', { field: 'version' }));
|
|
1192
|
+
const hn = new Set([...hp.packageDependencies, ...hp.components.flatMap((c) => c.dependencies)].filter((d) => d.scope !== 'setup' && d.scope !== 'build-tool').map((d) => d.name));
|
|
1193
|
+
const cn = new Set(r.components.flatMap((c) => c.dependencies).filter((d) => d.scope !== 'setup' && d.scope !== 'build-tool').map((d) => d.name));
|
|
1194
|
+
const onlyHpack = [...hn].filter((n) => !cn.has(n)).sort();
|
|
1195
|
+
const onlyCabal = [...cn].filter((n) => !hn.has(n)).sort();
|
|
1196
|
+
if (onlyHpack.length || onlyCabal.length) out.diagnostics.push(diag('hpack_cabal_mismatch', `dependency sets differ between package.yaml and its generated cabal (only in package.yaml: ${onlyHpack.join(', ') || 'none'}; only in cabal: ${onlyCabal.join(', ') || 'none'}); the cabal file is stale or hand-edited`, p, 1, 'warning', { field: 'dependencies', onlyInHpack: onlyHpack, onlyInCabal: onlyCabal }));
|
|
1197
|
+
}
|
|
1198
|
+
out.packages.push({ name: r.package.name, version: r.package.version, buildType: r.package.buildType, file: p, provenance: r.provenance, manifestProvenance: provenance, components: r.components.map((c) => ({ kind: c.kind, name: c.name, scope: c.scope })) });
|
|
1199
|
+
for (const c of r.components) for (const d of c.dependencies) out.dependencies.push(inventoryDep(d, { package: r.package.name, component: c.name, componentKind: c.kind, scope: c.scope, file: p, manifestType: 'cabal', provenance, shadowedBy }));
|
|
1200
|
+
out.flags.push(...r.flags);
|
|
1201
|
+
out.sourceRepositories.push(...r.sourceRepositories.map((s) => ({ ...s, source: 'cabal' })));
|
|
1202
|
+
out.surfaces.push(...r.surfaces.map((s) => ({ ...s, package: r.package.name })));
|
|
1203
|
+
} else if (kind === 'hpack') {
|
|
1204
|
+
if (!(cabalByDir.get(dir) || []).length) out.diagnostics.push(diag('hpack_cabal_missing', 'package.yaml has no generated .cabal beside it; dependencies are declared by package.yaml only', p, 1, 'info'));
|
|
1205
|
+
out.hpack.push({ name: r.package.name, version: r.package.version, buildType: r.package.buildType, file: p, incomplete: r.incomplete, components: r.components.map((c) => ({ kind: c.kind, name: c.name, scope: c.scope })) });
|
|
1206
|
+
for (const d of r.packageDependencies) out.dependencies.push(inventoryDep(d, { package: r.package.name, component: null, componentKind: 'package', scope: 'package', file: p, manifestType: 'hpack', provenance: 'hand-written' }));
|
|
1207
|
+
for (const c of r.components) for (const d of c.dependencies) out.dependencies.push(inventoryDep(d, { package: r.package.name, component: c.name, componentKind: c.kind, scope: c.scope, file: p, manifestType: 'hpack', provenance: 'hand-written' }));
|
|
1208
|
+
out.flags.push(...r.flags);
|
|
1209
|
+
out.sourceRepositories.push(...r.sourceRepositories.map((s) => ({ ...s, source: 'hpack' })));
|
|
1210
|
+
out.surfaces.push(...r.surfaces.map((s) => ({ ...s, package: r.package.name })));
|
|
1211
|
+
} else if (kind === 'project' || kind === 'freeze') {
|
|
1212
|
+
out.projects.push({ file: p, kind: r.kind, packages: r.packages, imports: r.imports, indexState: r.indexState, withCompiler: r.withCompiler, allowRelaxations: r.allowRelaxations, packageStanzas: r.packageStanzas });
|
|
1213
|
+
out.constraints.push(...r.constraints.map((c) => ({ ...c, manifest: p, resolvedVersion: null })));
|
|
1214
|
+
out.lockedPackages.push(...r.lockedPackages);
|
|
1215
|
+
for (const e of r.extraPackages) out.dependencies.push(inventoryDep(e, { package: null, component: null, componentKind: 'project', scope: 'project', file: p, manifestType: 'cabal.project', provenance: 'hand-written' }));
|
|
1216
|
+
out.sourceRepositories.push(...r.sourceRepositories.map((s) => ({ ...s, source: 'cabal.project' })));
|
|
1217
|
+
out.flagSettings.push(...r.flagSettings);
|
|
1218
|
+
out.surfaces.push(...r.surfaces);
|
|
1219
|
+
if (r.allowRelaxations.length) out.diagnostics.push(diag('bounds_relaxed', `project relaxes version bounds (${r.allowRelaxations.map((a) => `allow-${a.direction}: ${a.target}`).join('; ')}); declared bounds are not authoritative`, p, r.allowRelaxations[0].line, 'info'));
|
|
1220
|
+
} else if (kind === 'stack') {
|
|
1221
|
+
out.stack.push({ file: p, snapshot: r.snapshot, compiler: r.compiler, packages: r.packages, extraDeps: r.extraDeps, incomplete: r.incomplete });
|
|
1222
|
+
if (r.snapshot) out.snapshots.push({ ...r.snapshot, source: 'stack.yaml' });
|
|
1223
|
+
for (const e of r.extraDeps) {
|
|
1224
|
+
if (e.kind === 'hackage' && e.version) out.lockedPackages.push({ name: e.name, version: e.version, source: 'stack.yaml extra-deps', integrity: e.integrity, file: p, line: e.line, column: 0 });
|
|
1225
|
+
}
|
|
1226
|
+
out.flagSettings.push(...r.flagSettings);
|
|
1227
|
+
out.surfaces.push(...r.surfaces);
|
|
1228
|
+
} else if (kind === 'stack-lock') {
|
|
1229
|
+
out.stackLocks.push({ file: p, snapshots: r.snapshots, packageCount: r.packages.length, incomplete: r.incomplete });
|
|
1230
|
+
out.lockedPackages.push(...r.packages.filter((x) => x.version).map((x) => ({ name: x.name, version: x.version, source: 'stack.yaml.lock', integrity: x.sha256 || (x.pantryTree && x.pantryTree.sha256) ? 'sha256' : 'none', kind: x.kind, file: p, line: x.line, column: 0 })));
|
|
1231
|
+
for (const s of r.snapshots) out.snapshots.push({ kind: 'lock', selector: typeof s.original === 'string' ? s.original : (s.original && s.original.url) || null, url: s.url, sha256: s.sha256, resolved: !!s.sha256, resolvedBy: 'stack.yaml.lock', pinnedByHash: !!s.sha256, file: p, line: s.line, source: 'stack.yaml.lock' });
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// Stack lock vs extra-deps disagreement (same directory).
|
|
1236
|
+
for (const [p, kind, r] of parsedResults) {
|
|
1237
|
+
if (kind !== 'stack') continue;
|
|
1238
|
+
const lockRes = parsedResults.find(([lp, lk]) => lk === 'stack-lock' && path.posix.dirname(lp) === path.posix.dirname(p));
|
|
1239
|
+
if (!lockRes) continue;
|
|
1240
|
+
const locked = new Map(lockRes[2].packages.filter((x) => x.version).map((x) => [x.name, x.version]));
|
|
1241
|
+
for (const e of r.extraDeps) {
|
|
1242
|
+
if (e.kind === 'hackage' && e.version && locked.has(e.name) && locked.get(e.name) !== e.version) {
|
|
1243
|
+
out.diagnostics.push(diag('stack_lock_mismatch', `extra-dep ${e.name}-${e.version} but stack.yaml.lock records ${locked.get(e.name)}; the lock is stale`, p, e.line));
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// Flag settings that name a flag a LOCAL package never declares.
|
|
1249
|
+
for (const s of out.flagSettings) {
|
|
1250
|
+
if (!s.package || s.package === '*') continue;
|
|
1251
|
+
const declared = localPackages.get(s.package);
|
|
1252
|
+
if (declared && !declared.has(s.name)) {
|
|
1253
|
+
s.unknown = true;
|
|
1254
|
+
out.diagnostics.push(diag('unknown_flag', `${s.scope} sets flag '${s.name}' on '${s.package}', which that package does not declare`, s.file, s.line, 'warning', { flag: s.name, package: s.package }));
|
|
1255
|
+
} else if (!declared) s.verified = false;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
return redactUrlsDeep(out);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const WALK_SKIP = new Set(['node_modules', '.git', '.hg', '.svn']);
|
|
1262
|
+
|
|
1263
|
+
/** Read manifests under `root` (bounded, symlinks never followed) and analyse them. */
|
|
1264
|
+
export function analyzeHaskellManifestsInDir(root, opts = {}) {
|
|
1265
|
+
const budgets = budgetsOf(opts);
|
|
1266
|
+
const files = [];
|
|
1267
|
+
const diagnostics = [];
|
|
1268
|
+
const queue = ['.'];
|
|
1269
|
+
let dirs = 0;
|
|
1270
|
+
while (queue.length) {
|
|
1271
|
+
const rel = queue.shift();
|
|
1272
|
+
if (++dirs > budgets.maxFiles) { diagnostics.push(diag('budget_exceeded', 'directory walk budget reached', null, null)); break; }
|
|
1273
|
+
let ents;
|
|
1274
|
+
try { ents = fs.readdirSync(path.join(root, rel), { withFileTypes: true }); } catch { continue; }
|
|
1275
|
+
for (const e of ents) {
|
|
1276
|
+
const r = rel === '.' ? e.name : `${rel}/${e.name}`;
|
|
1277
|
+
if (e.isSymbolicLink()) { if (classifyManifest(r)) diagnostics.push(diag('symlink_skipped', 'symlinked manifest not followed', r, null, 'info')); continue; }
|
|
1278
|
+
if (e.isDirectory()) { if (!WALK_SKIP.has(e.name) && !isLanguageExcludedPath(`${r}/`)) queue.push(r); continue; }
|
|
1279
|
+
if (!e.isFile() || !classifyManifest(r) || isLanguageExcludedPath(r)) continue;
|
|
1280
|
+
try {
|
|
1281
|
+
const st = fs.statSync(path.join(root, r));
|
|
1282
|
+
if (st.size > budgets.maxBytes) { diagnostics.push(diag('budget_exceeded', `${r} exceeds ${budgets.maxBytes} bytes; not read`, r, null)); continue; }
|
|
1283
|
+
files.push({ path: r, text: fs.readFileSync(path.join(root, r), 'utf8') });
|
|
1284
|
+
} catch (err) { diagnostics.push(diag('read_failed', String(err && err.message).slice(0, 120), r, null)); }
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
const res = analyzeHaskellManifests(files, opts);
|
|
1288
|
+
res.diagnostics.unshift(...diagnostics);
|
|
1289
|
+
return res;
|
|
1290
|
+
}
|