diamond-detect 0.1.0 → 0.2.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.
Files changed (3) hide show
  1. package/dist/cli.js +540 -115
  2. package/dist/index.js +496 -44
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -72,6 +72,18 @@ function extractSourcePath(artifactPath, parsed) {
72
72
  }
73
73
  return path.basename(path.dirname(artifactPath));
74
74
  }
75
+ async function loadRawSources(inputPath, sourcePaths) {
76
+ const { root } = await resolveFoundryRoot(inputPath);
77
+ const out = /* @__PURE__ */ new Map();
78
+ for (const sourcePath of new Set(sourcePaths)) {
79
+ if (out.has(sourcePath)) continue;
80
+ try {
81
+ out.set(sourcePath, await fs.readFile(path.join(root, sourcePath), "utf8"));
82
+ } catch {
83
+ }
84
+ }
85
+ return out;
86
+ }
75
87
  async function loadFoundryArtifacts(inputPath, opts = {}) {
76
88
  const { outDir } = await resolveFoundryRoot(inputPath);
77
89
  if (!await fileExists(outDir)) {
@@ -105,55 +117,58 @@ async function loadFoundryArtifacts(inputPath, opts = {}) {
105
117
  return artifacts;
106
118
  }
107
119
 
108
- // src/detector/index.ts
109
- var DEFAULT_IGNORE_GLOBS = [
110
- "lib/**",
111
- "test/**",
112
- "script/**",
113
- "**/*.t.sol",
114
- "**/*.s.sol"
115
- ];
116
- function compilePatterns(globs) {
117
- return globs.map((g) => {
118
- const escaped = g.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "::DOUBLESTAR::").replace(/\*/g, "[^/]*").replace(/::DOUBLESTAR::/g, ".*").replace(/\?/g, ".");
119
- return new RegExp(`^${escaped}$`);
120
- });
121
- }
122
- function buildIgnore(userGlobs, noDefault) {
123
- const globs = [
124
- ...noDefault ? [] : DEFAULT_IGNORE_GLOBS,
125
- ...userGlobs ?? []
126
- ];
127
- if (globs.length === 0) return void 0;
128
- const patterns = compilePatterns(globs);
129
- return (sourcePath) => patterns.some((p) => p.test(sourcePath));
120
+ // src/detector/analyzers/diamondStorage.ts
121
+ import { keccak_256 as keccak_2562 } from "@noble/hashes/sha3";
122
+
123
+ // src/lib/eip7201.ts
124
+ import { keccak_256 } from "@noble/hashes/sha3";
125
+ var MASK_LAST_BYTE = (() => {
126
+ const m = new Uint8Array(32).fill(255);
127
+ m[31] = 0;
128
+ return m;
129
+ })();
130
+ function utf8(s) {
131
+ return new TextEncoder().encode(s);
130
132
  }
131
- function buildIsFacet(globs) {
132
- if (!globs || globs.length === 0) return void 0;
133
- const patterns = compilePatterns(globs);
134
- return (artifact) => patterns.some((p) => p.test(artifact.sourcePath));
133
+ function toHex(bytes) {
134
+ let out = "0x";
135
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
136
+ return out;
135
137
  }
136
- async function detect(options, analyzers) {
137
- const artifacts = await loadFoundryArtifacts(options.path, {
138
- ignoreSourcePath: buildIgnore(options.ignoreGlobs, options.noDefaultIgnore)
139
- });
140
- const ctx = {
141
- artifacts,
142
- rawSources: /* @__PURE__ */ new Map(),
143
- isFacet: buildIsFacet(options.facetGlobs)
144
- };
145
- const findings = [];
146
- for (const analyzer of analyzers) {
147
- const out = await analyzer.run(ctx);
148
- findings.push(...out);
138
+ function subOne(bytes) {
139
+ const out = new Uint8Array(bytes);
140
+ for (let i = out.length - 1; i >= 0; i--) {
141
+ if (out[i] > 0) {
142
+ out[i] = out[i] - 1;
143
+ return out;
144
+ }
145
+ out[i] = 255;
149
146
  }
150
- return { artifacts, findings };
147
+ return out;
148
+ }
149
+ function maskLastByte(bytes) {
150
+ const out = new Uint8Array(32);
151
+ for (let i = 0; i < 32; i++) out[i] = bytes[i] & MASK_LAST_BYTE[i];
152
+ return out;
153
+ }
154
+ function erc7201Slot(namespaceId) {
155
+ const inner = keccak_256(utf8(namespaceId));
156
+ const decremented = subOne(inner);
157
+ const outer = keccak_256(decremented);
158
+ return toHex(maskLastByte(outer));
159
+ }
160
+ var ERC7201_PREFIX = "erc7201:";
161
+ function parseErc7201Annotation(text) {
162
+ const idx = text.indexOf(ERC7201_PREFIX);
163
+ if (idx === -1) return null;
164
+ const rest = text.slice(idx + ERC7201_PREFIX.length);
165
+ const match = rest.match(/^[A-Za-z0-9_.\-]+/);
166
+ return match ? match[0] : null;
151
167
  }
152
168
 
153
169
  // src/detector/analyzers/diamondStorage.ts
154
- import { keccak_256 } from "@noble/hashes/sha3";
155
170
  function keccak256Hex(input) {
156
- const bytes = keccak_256(new TextEncoder().encode(input));
171
+ const bytes = keccak_2562(new TextEncoder().encode(input));
157
172
  let out = "0x";
158
173
  for (const b of bytes) out += b.toString(16).padStart(2, "0");
159
174
  return out;
@@ -176,6 +191,61 @@ function extractKeccakStringArg(value) {
176
191
  if (arg.nodeType !== "Literal" || arg.kind !== "string") return null;
177
192
  return typeof arg.value === "string" ? arg.value : null;
178
193
  }
194
+ function extractBytes32HexLiteral(value) {
195
+ if (!value || typeof value !== "object") return null;
196
+ const v = value;
197
+ if (v.nodeType !== "Literal" || v.kind !== "number") return null;
198
+ if (typeof v.value !== "string") return null;
199
+ try {
200
+ const big = BigInt(v.value);
201
+ if (big < 0n) return null;
202
+ return "0x" + big.toString(16).padStart(64, "0");
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+ function findKeccakStringArg(node) {
208
+ if (!node || typeof node !== "object") return null;
209
+ const v = node;
210
+ if (v.nodeType !== "FunctionCall") return null;
211
+ const expr = v.expression;
212
+ if (expr?.nodeType !== "Identifier" || expr.name !== "keccak256") return null;
213
+ const args = v.arguments;
214
+ if (!Array.isArray(args) || args.length !== 1) return null;
215
+ const arg = args[0];
216
+ if (arg.nodeType !== "Literal" || arg.kind !== "string") return null;
217
+ return typeof arg.value === "string" ? arg.value : null;
218
+ }
219
+ function someNode(node, pred) {
220
+ if (!node || typeof node !== "object") return false;
221
+ const n = node;
222
+ if (typeof n.nodeType === "string" && pred(n)) return true;
223
+ for (const key of Object.keys(n)) {
224
+ const value = n[key];
225
+ if (Array.isArray(value)) {
226
+ if (value.some((c) => someNode(c, pred))) return true;
227
+ } else if (value && typeof value === "object") {
228
+ if (someNode(value, pred)) return true;
229
+ }
230
+ }
231
+ return false;
232
+ }
233
+ function extractErc7201FormulaNamespace(value) {
234
+ const strings = [];
235
+ someNode(value, (n) => {
236
+ const s = findKeccakStringArg(n);
237
+ if (s !== null) strings.push(s);
238
+ return false;
239
+ });
240
+ if (strings.length !== 1) return null;
241
+ const hasSubOne = someNode(
242
+ value,
243
+ (n) => n.nodeType === "BinaryOperation" && n.operator === "-" && n.rightExpression?.nodeType === "Literal" && n.rightExpression.value === "1"
244
+ );
245
+ const hasMask = someNode(value, (n) => n.nodeType === "BinaryOperation" && n.operator === "&");
246
+ if (!hasSubOne || !hasMask) return null;
247
+ return strings[0];
248
+ }
179
249
  function lineFromSrc(src, sourceText) {
180
250
  if (typeof src !== "string" || !sourceText) return void 0;
181
251
  const [startStr] = src.split(":");
@@ -217,6 +287,7 @@ function collectSlotConstants(ctx) {
217
287
  if (!isBytes32Constant(node)) return;
218
288
  const namespace = extractKeccakStringArg(node.value);
219
289
  if (namespace === null) return;
290
+ const declarationId = typeof node.id === "number" ? node.id : -1;
220
291
  const variableName = node.name ?? "<anon>";
221
292
  const contract = declaringContract(parents) ?? artifact.contractName;
222
293
  const src = node.src;
@@ -224,6 +295,7 @@ function collectSlotConstants(ctx) {
224
295
  if (seen.has(dedupeKey)) return;
225
296
  seen.add(dedupeKey);
226
297
  out.push({
298
+ declarationId,
227
299
  variableName,
228
300
  namespace,
229
301
  slot: keccak256Hex(namespace),
@@ -235,12 +307,195 @@ function collectSlotConstants(ctx) {
235
307
  }
236
308
  return out;
237
309
  }
310
+ function collectLiteralSlotConstants(ctx) {
311
+ const seen = /* @__PURE__ */ new Set();
312
+ const out = [];
313
+ for (const artifact of ctx.artifacts) {
314
+ if (!artifact.ast) continue;
315
+ walkAst(artifact.ast, (node, parents) => {
316
+ if (!isBytes32Constant(node)) return;
317
+ if (extractKeccakStringArg(node.value) !== null) return;
318
+ const slot = extractBytes32HexLiteral(node.value);
319
+ if (slot === null) return;
320
+ const declarationId = typeof node.id === "number" ? node.id : -1;
321
+ const variableName = node.name ?? "<anon>";
322
+ const contract = declaringContract(parents) ?? artifact.contractName;
323
+ const src = node.src;
324
+ const dedupeKey = `${artifact.sourcePath}::${contract}::${variableName}::${src ?? ""}`;
325
+ if (seen.has(dedupeKey)) return;
326
+ seen.add(dedupeKey);
327
+ out.push({
328
+ declarationId,
329
+ variableName,
330
+ namespace: null,
331
+ slot,
332
+ contract,
333
+ sourcePath: artifact.sourcePath,
334
+ src
335
+ });
336
+ });
337
+ }
338
+ return out;
339
+ }
340
+ function collectFormulaSlotConstants(ctx) {
341
+ const seen = /* @__PURE__ */ new Set();
342
+ const out = [];
343
+ for (const artifact of ctx.artifacts) {
344
+ if (!artifact.ast) continue;
345
+ walkAst(artifact.ast, (node, parents) => {
346
+ if (!isBytes32Constant(node)) return;
347
+ if (extractKeccakStringArg(node.value) !== null) return;
348
+ const namespace = extractErc7201FormulaNamespace(node.value);
349
+ if (namespace === null) return;
350
+ const declarationId = typeof node.id === "number" ? node.id : -1;
351
+ const variableName = node.name ?? "<anon>";
352
+ const contract = declaringContract(parents) ?? artifact.contractName;
353
+ const src = node.src;
354
+ const dedupeKey = `${artifact.sourcePath}::${contract}::${variableName}::${src ?? ""}`;
355
+ if (seen.has(dedupeKey)) return;
356
+ seen.add(dedupeKey);
357
+ out.push({
358
+ declarationId,
359
+ variableName,
360
+ namespace,
361
+ slot: erc7201Slot(namespace),
362
+ contract,
363
+ sourcePath: artifact.sourcePath,
364
+ src
365
+ });
366
+ });
367
+ }
368
+ return out;
369
+ }
370
+ function collectAssemblyLiteralSlots(artifacts) {
371
+ const seen = /* @__PURE__ */ new Set();
372
+ const out = [];
373
+ for (const artifact of artifacts) {
374
+ if (!artifact.ast) continue;
375
+ walkAst(artifact.ast, (node, parents) => {
376
+ if (node.nodeType !== "YulAssignment") return;
377
+ const targets = node.variableNames;
378
+ const targetsSlot = Array.isArray(targets) && targets.some((t) => typeof t.name === "string" && t.name.endsWith(".slot"));
379
+ if (!targetsSlot) return;
380
+ const value = node.value;
381
+ if (value?.nodeType !== "YulLiteral" || value.kind !== "number") return;
382
+ if (typeof value.value !== "string") return;
383
+ let slot;
384
+ try {
385
+ const big = BigInt(value.value);
386
+ if (big < 0n) return;
387
+ slot = "0x" + big.toString(16).padStart(64, "0");
388
+ } catch {
389
+ return;
390
+ }
391
+ const contract = declaringContract(parents) ?? artifact.contractName;
392
+ const src = node.src;
393
+ const dedupeKey = `${artifact.sourcePath}::${contract}::${slot}::${src ?? ""}`;
394
+ if (seen.has(dedupeKey)) return;
395
+ seen.add(dedupeKey);
396
+ out.push({
397
+ declarationId: -1,
398
+ variableName: "<assembly literal>",
399
+ namespace: null,
400
+ slot,
401
+ contract,
402
+ sourcePath: artifact.sourcePath,
403
+ src
404
+ });
405
+ });
406
+ }
407
+ return out;
408
+ }
409
+ function collectSlotAssignmentValueSrcs(yulNode, srcs) {
410
+ if (!yulNode || typeof yulNode !== "object") return;
411
+ const node = yulNode;
412
+ if (node.nodeType === "YulAssignment") {
413
+ const targets = node.variableNames;
414
+ const targetsSlot = Array.isArray(targets) && targets.some((t) => typeof t.name === "string" && t.name.endsWith(".slot"));
415
+ if (targetsSlot) {
416
+ const value = node.value;
417
+ if (value?.nodeType === "YulIdentifier" && typeof value.src === "string") {
418
+ srcs.add(value.src);
419
+ }
420
+ }
421
+ }
422
+ for (const key of Object.keys(node)) {
423
+ const v = node[key];
424
+ if (Array.isArray(v)) {
425
+ for (const child of v) collectSlotAssignmentValueSrcs(child, srcs);
426
+ } else if (v && typeof v === "object") {
427
+ collectSlotAssignmentValueSrcs(v, srcs);
428
+ }
429
+ }
430
+ }
431
+ function collectSlotUsedDeclarationIds(artifacts) {
432
+ const ids = /* @__PURE__ */ new Set();
433
+ for (const artifact of artifacts) {
434
+ if (!artifact.ast) continue;
435
+ walkAst(artifact.ast, (node) => {
436
+ if (node.nodeType !== "InlineAssembly") return;
437
+ const yulAst = node.AST;
438
+ if (!yulAst) return;
439
+ const slotValueSrcs = /* @__PURE__ */ new Set();
440
+ collectSlotAssignmentValueSrcs(yulAst, slotValueSrcs);
441
+ if (slotValueSrcs.size === 0) return;
442
+ const refs = node.externalReferences;
443
+ if (!Array.isArray(refs)) return;
444
+ for (const ref of refs) {
445
+ if (typeof ref.src === "string" && slotValueSrcs.has(ref.src) && typeof ref.declaration === "number") {
446
+ ids.add(ref.declaration);
447
+ }
448
+ }
449
+ });
450
+ }
451
+ return ids;
452
+ }
453
+ function collectAliases(artifacts) {
454
+ const aliases = /* @__PURE__ */ new Map();
455
+ for (const artifact of artifacts) {
456
+ if (!artifact.ast) continue;
457
+ walkAst(artifact.ast, (node) => {
458
+ if (node.nodeType !== "VariableDeclarationStatement") return;
459
+ const initialValue = node.initialValue;
460
+ if (initialValue?.nodeType !== "Identifier") return;
461
+ const referenced = initialValue.referencedDeclaration;
462
+ if (typeof referenced !== "number") return;
463
+ const decls = node.declarations;
464
+ if (!Array.isArray(decls)) return;
465
+ for (const d of decls) {
466
+ if (typeof d?.id === "number") aliases.set(d.id, referenced);
467
+ }
468
+ });
469
+ }
470
+ return aliases;
471
+ }
472
+ function isUsedAsSlot(constantId, slotUsedIds, aliases) {
473
+ if (constantId < 0) return false;
474
+ if (slotUsedIds.has(constantId)) return true;
475
+ for (const [aliasId, refId] of aliases) {
476
+ if (refId === constantId && slotUsedIds.has(aliasId)) return true;
477
+ }
478
+ return false;
479
+ }
480
+ function collectGatedSlotConstants(ctx) {
481
+ const constants = [
482
+ ...collectSlotConstants(ctx),
483
+ ...collectLiteralSlotConstants(ctx),
484
+ ...collectFormulaSlotConstants(ctx)
485
+ ];
486
+ const slotUsedIds = collectSlotUsedDeclarationIds(ctx.artifacts);
487
+ const aliases = collectAliases(ctx.artifacts);
488
+ return [
489
+ ...constants.filter((c) => isUsedAsSlot(c.declarationId, slotUsedIds, aliases)),
490
+ ...collectAssemblyLiteralSlots(ctx.artifacts)
491
+ ];
492
+ }
238
493
  var diamondStorageAnalyzer = {
239
494
  name: "diamond-storage-namespace",
240
495
  run(ctx) {
241
- const constants = collectSlotConstants(ctx);
496
+ const slotConstants = collectGatedSlotConstants(ctx);
242
497
  const bySlot = /* @__PURE__ */ new Map();
243
- for (const c of constants) {
498
+ for (const c of slotConstants) {
244
499
  const list = bySlot.get(c.slot) ?? [];
245
500
  list.push(c);
246
501
  bySlot.set(c.slot, list);
@@ -249,72 +504,38 @@ var diamondStorageAnalyzer = {
249
504
  for (const [slot, group] of bySlot) {
250
505
  const distinctSources = new Set(group.map((g) => g.sourcePath));
251
506
  if (distinctSources.size < 2) continue;
252
- const namespaces = Array.from(new Set(group.map((g) => g.namespace)));
507
+ const namespaces = Array.from(
508
+ new Set(group.map((g) => g.namespace).filter((n) => n !== null))
509
+ );
510
+ const variableNames = Array.from(new Set(group.map((g) => g.variableName)));
511
+ const hasLiteral = group.some((g) => g.namespace === null);
253
512
  const facets = Array.from(new Set(group.map((g) => g.contract)));
254
513
  const locations = group.map((g) => {
255
514
  const sourceText = ctx.rawSources.get(g.sourcePath);
256
- return { file: g.sourcePath, line: lineFromSrc(g.src, sourceText) };
515
+ return { file: g.sourcePath, line: lineFromSrc(g.src, sourceText), src: g.src };
257
516
  });
517
+ let message;
518
+ if (!hasLiteral) {
519
+ message = namespaces.length === 1 ? `Diamond Storage namespace "${namespaces[0]}" is declared in ${distinctSources.size} different sources, all resolving to the same slot.` : `Distinct namespaces ${namespaces.map((n) => `"${n}"`).join(", ")} hash to the same slot.`;
520
+ } else if (namespaces.length === 0) {
521
+ message = `Hardcoded storage slot ${slot} is used as a Diamond Storage pointer by ${distinctSources.size} different sources (${variableNames.join(", ")}), so distinct facets share the same slot.`;
522
+ } else {
523
+ message = `Hardcoded slot ${slot} collides with namespace(s) ${namespaces.map((n) => `"${n}"`).join(", ")} \u2014 they resolve to the same storage slot.`;
524
+ }
258
525
  findings.push({
259
526
  kind: "diamond-storage-namespace",
260
527
  severity: "error",
261
528
  slot,
262
- message: namespaces.length === 1 ? `Diamond Storage namespace "${namespaces[0]}" is declared in ${distinctSources.size} different sources, all resolving to the same slot.` : `Distinct namespaces ${namespaces.map((n) => `"${n}"`).join(", ")} hash to the same slot.`,
529
+ message,
263
530
  facets,
264
531
  locations,
265
- detail: { namespaces, declarations: group }
532
+ detail: { namespaces, variableNames, declarations: group }
266
533
  });
267
534
  }
268
535
  return findings;
269
536
  }
270
537
  };
271
538
 
272
- // src/lib/eip7201.ts
273
- import { keccak_256 as keccak_2562 } from "@noble/hashes/sha3";
274
- var MASK_LAST_BYTE = (() => {
275
- const m = new Uint8Array(32).fill(255);
276
- m[31] = 0;
277
- return m;
278
- })();
279
- function utf8(s) {
280
- return new TextEncoder().encode(s);
281
- }
282
- function toHex(bytes) {
283
- let out = "0x";
284
- for (const b of bytes) out += b.toString(16).padStart(2, "0");
285
- return out;
286
- }
287
- function subOne(bytes) {
288
- const out = new Uint8Array(bytes);
289
- for (let i = out.length - 1; i >= 0; i--) {
290
- if (out[i] > 0) {
291
- out[i] = out[i] - 1;
292
- return out;
293
- }
294
- out[i] = 255;
295
- }
296
- return out;
297
- }
298
- function maskLastByte(bytes) {
299
- const out = new Uint8Array(32);
300
- for (let i = 0; i < 32; i++) out[i] = bytes[i] & MASK_LAST_BYTE[i];
301
- return out;
302
- }
303
- function erc7201Slot(namespaceId) {
304
- const inner = keccak_2562(utf8(namespaceId));
305
- const decremented = subOne(inner);
306
- const outer = keccak_2562(decremented);
307
- return toHex(maskLastByte(outer));
308
- }
309
- var ERC7201_PREFIX = "erc7201:";
310
- function parseErc7201Annotation(text) {
311
- const idx = text.indexOf(ERC7201_PREFIX);
312
- if (idx === -1) return null;
313
- const rest = text.slice(idx + ERC7201_PREFIX.length);
314
- const match = rest.match(/^[A-Za-z0-9_.\-]+/);
315
- return match ? match[0] : null;
316
- }
317
-
318
539
  // src/detector/analyzers/erc7201.ts
319
540
  var NAMED_NODE_TYPES = /* @__PURE__ */ new Set([
320
541
  "ContractDefinition",
@@ -402,7 +623,7 @@ var erc7201Analyzer = {
402
623
  if (distinctSources.size < 2) continue;
403
624
  const ids = Array.from(new Set(group.map((g) => g.namespaceId)));
404
625
  const facets = Array.from(new Set(group.map((g) => g.contract)));
405
- const locations = group.map((g) => ({ file: g.sourcePath }));
626
+ const locations = group.map((g) => ({ file: g.sourcePath, src: g.src }));
406
627
  findings.push({
407
628
  kind: "erc7201-namespace",
408
629
  severity: "error",
@@ -417,6 +638,102 @@ var erc7201Analyzer = {
417
638
  }
418
639
  };
419
640
 
641
+ // src/detector/inventory.ts
642
+ function lineOf(src, text) {
643
+ if (!src || !text) return void 0;
644
+ const [startStr] = src.split(":");
645
+ const start = Number(startStr);
646
+ if (!Number.isFinite(start)) return void 0;
647
+ let line = 1;
648
+ for (let i = 0; i < start && i < text.length; i++) {
649
+ if (text.charCodeAt(i) === 10) line++;
650
+ }
651
+ return line;
652
+ }
653
+ var PRIORITY = { erc7201: 0, namespace: 1, hardcoded: 2 };
654
+ function buildInventory(ctx) {
655
+ const bySlot = /* @__PURE__ */ new Map();
656
+ const add = (region) => {
657
+ const existing = bySlot.get(region.slot);
658
+ if (!existing || PRIORITY[region.kind] < PRIORITY[existing.kind]) {
659
+ bySlot.set(region.slot, region);
660
+ }
661
+ };
662
+ for (const c of collectGatedSlotConstants(ctx)) {
663
+ add({
664
+ slot: c.slot,
665
+ label: c.namespace ?? c.variableName,
666
+ kind: c.namespace ? "namespace" : "hardcoded",
667
+ contract: c.contract,
668
+ file: c.sourcePath,
669
+ line: lineOf(c.src, ctx.rawSources.get(c.sourcePath))
670
+ });
671
+ }
672
+ for (const a of collectErc7201Annotations(ctx.artifacts)) {
673
+ add({
674
+ slot: a.slot,
675
+ label: a.namespaceId,
676
+ kind: "erc7201",
677
+ contract: a.contract,
678
+ file: a.sourcePath,
679
+ line: lineOf(a.src, ctx.rawSources.get(a.sourcePath))
680
+ });
681
+ }
682
+ return [...bySlot.values()].sort(
683
+ (x, y) => x.file.localeCompare(y.file) || x.slot.localeCompare(y.slot)
684
+ );
685
+ }
686
+
687
+ // src/detector/index.ts
688
+ var DEFAULT_IGNORE_GLOBS = [
689
+ "lib/**",
690
+ "test/**",
691
+ "script/**",
692
+ "**/*.t.sol",
693
+ "**/*.s.sol"
694
+ ];
695
+ function compilePatterns(globs) {
696
+ return globs.map((g) => {
697
+ const escaped = g.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "::DOUBLESTAR::").replace(/\*/g, "[^/]*").replace(/::DOUBLESTAR::/g, ".*").replace(/\?/g, ".");
698
+ return new RegExp(`^${escaped}$`);
699
+ });
700
+ }
701
+ function buildIgnore(userGlobs, noDefault) {
702
+ const globs = [
703
+ ...noDefault ? [] : DEFAULT_IGNORE_GLOBS,
704
+ ...userGlobs ?? []
705
+ ];
706
+ if (globs.length === 0) return void 0;
707
+ const patterns = compilePatterns(globs);
708
+ return (sourcePath) => patterns.some((p) => p.test(sourcePath));
709
+ }
710
+ function buildIsFacet(globs) {
711
+ if (!globs || globs.length === 0) return void 0;
712
+ const patterns = compilePatterns(globs);
713
+ return (artifact) => patterns.some((p) => p.test(artifact.sourcePath));
714
+ }
715
+ async function detect(options, analyzers) {
716
+ const artifacts = await loadFoundryArtifacts(options.path, {
717
+ ignoreSourcePath: buildIgnore(options.ignoreGlobs, options.noDefaultIgnore)
718
+ });
719
+ const rawSources = await loadRawSources(
720
+ options.path,
721
+ artifacts.map((a) => a.sourcePath)
722
+ );
723
+ const ctx = {
724
+ artifacts,
725
+ rawSources,
726
+ isFacet: buildIsFacet(options.facetGlobs)
727
+ };
728
+ const findings = [];
729
+ for (const analyzer of analyzers) {
730
+ const out = await analyzer.run(ctx);
731
+ findings.push(...out);
732
+ }
733
+ const inventory = buildInventory(ctx);
734
+ return { artifacts, findings, rawSources, inventory };
735
+ }
736
+
420
737
  // src/detector/analyzers/appStorage.ts
421
738
  function memberFingerprint(m) {
422
739
  return { label: m.label, offset: m.offset, slot: m.slot, type: m.type };
@@ -581,7 +898,7 @@ var inlineAssemblyAnalyzer = {
581
898
  slot: lit.slot,
582
899
  message: `inline assembly writes to a hardcoded slot (sstore(${lit.rawValue}, \u2026)) \u2014 confirm no overlap with computed storage slots.`,
583
900
  facets: [lit.artifact.contractName],
584
- locations: [{ file: lit.artifact.sourcePath }],
901
+ locations: [{ file: lit.artifact.sourcePath, src: lit.src }],
585
902
  detail: { rawValue: lit.rawValue, src: lit.src }
586
903
  }));
587
904
  }
@@ -672,37 +989,140 @@ var defaultAnalyzers = [
672
989
  // src/reporter/terminal.ts
673
990
  import pc from "picocolors";
674
991
  var SEVERITY_RANK = { info: 0, warn: 1, error: 2 };
675
- function colorSeverity(sev) {
676
- if (sev === "error") return pc.red(pc.bold("ERROR"));
677
- if (sev === "warn") return pc.yellow("WARN ");
678
- return pc.cyan("INFO ");
992
+ var SEV = {
993
+ error: { label: "error", glyph: "\u2716", paint: (s) => pc.red(s) },
994
+ warn: { label: "warning", glyph: "\u26A0", paint: (s) => pc.yellow(s) },
995
+ info: { label: "note", glyph: "\u25CF", paint: (s) => pc.cyan(s) }
996
+ };
997
+ var HELP = {
998
+ "diamond-storage-namespace": "give every facet a unique storage seed; never reuse a namespace string, precomputed slot, or formula across facets",
999
+ "erc7201-namespace": "use a distinct erc7201 namespace id per facet",
1000
+ "appstorage-fingerprint": "keep the shared struct layout identical across all facets; append fields, never reorder or insert",
1001
+ "inheritance-overlap": "facets must not declare sequential state variables; move state into namespaced Diamond Storage",
1002
+ "inline-assembly-slot": "confirm this hardcoded slot cannot overlap any namespaced storage region",
1003
+ "mapping-overlap": "ensure mapping base slots are derived from distinct, collision-resistant seeds"
1004
+ };
1005
+ function shortSlot(slot) {
1006
+ if (!slot.startsWith("0x") || slot.length <= 14) return slot;
1007
+ return `${slot.slice(0, 8)}\u2026${slot.slice(-6)}`;
1008
+ }
1009
+ function expandTabs(s) {
1010
+ return s.replace(/\t/g, " ");
1011
+ }
1012
+ function resolveSpan(loc, sourceText) {
1013
+ if (!sourceText || !loc.src) return null;
1014
+ const [offStr, lenStr] = loc.src.split(":");
1015
+ const offset = Number(offStr);
1016
+ const length = Number(lenStr);
1017
+ if (!Number.isFinite(offset) || offset < 0 || offset > sourceText.length) return null;
1018
+ let line = 1;
1019
+ let lineStart = 0;
1020
+ for (let i = 0; i < offset; i++) {
1021
+ if (sourceText.charCodeAt(i) === 10) {
1022
+ line++;
1023
+ lineStart = i + 1;
1024
+ }
1025
+ }
1026
+ const nl = sourceText.indexOf("\n", lineStart);
1027
+ const lineEnd = nl === -1 ? sourceText.length : nl;
1028
+ const rawLine = sourceText.slice(lineStart, lineEnd);
1029
+ const rawPrefix = sourceText.slice(lineStart, offset);
1030
+ const column = expandTabs(rawPrefix).length + 1;
1031
+ const visibleLen = expandTabs(rawLine).length;
1032
+ const rawCaret = Number.isFinite(length) && length > 0 ? length : 1;
1033
+ const caretLen = Math.max(1, Math.min(rawCaret, visibleLen - (column - 1)));
1034
+ return { line, column, lineText: expandTabs(rawLine), caretLen };
679
1035
  }
680
- function renderTerminal(findings, facetCount) {
1036
+ function renderFrame(loc, span, sev, note) {
1037
+ const gutter = String(span.line);
1038
+ const pad = " ".repeat(gutter.length);
1039
+ const bar = pc.dim("\u2502");
1040
+ const arrow = pc.dim("\u256D\u2500[");
1041
+ const close = pc.dim("]");
1042
+ const caret = sev.paint("\u2500".repeat(span.caretLen));
1043
+ const caretPad = " ".repeat(span.column - 1);
1044
+ const label = note ? " " + sev.paint(note) : "";
1045
+ return [
1046
+ ` ${arrow}${pc.cyan(`${loc.file}:${span.line}:${span.column}`)}${close}`,
1047
+ ` ${pad} ${bar}`,
1048
+ ` ${pc.dim(gutter)} ${bar} ${span.lineText}`,
1049
+ ` ${pad} ${pc.dim("\xB7")} ${caretPad}${caret}${label}`,
1050
+ ` ${pad} ${pc.dim("\u2570\u2500")}`
1051
+ ];
1052
+ }
1053
+ var KIND_TAG = {
1054
+ erc7201: "erc7201",
1055
+ namespace: "namespace",
1056
+ hardcoded: "precomputed"
1057
+ };
1058
+ function renderInventory(inventory) {
681
1059
  const lines = [];
682
- lines.push(pc.dim(`scanned ${facetCount} contract artifact(s)`));
683
- if (findings.length === 0) {
684
- lines.push(pc.green("\u2713 no storage collisions detected"));
685
- return lines.join("\n");
1060
+ const shown = inventory.slice(0, 60);
1061
+ const labelW = Math.max(...shown.map((r) => r.label.length), 0);
1062
+ const tagW = Math.max(...shown.map((r) => KIND_TAG[r.kind].length), 0);
1063
+ const contractW = Math.max(...shown.map((r) => r.contract.length), 0);
1064
+ lines.push("");
1065
+ lines.push(
1066
+ pc.bold(`Verified ${inventory.length} storage region${inventory.length === 1 ? "" : "s"}`) + pc.dim(", each on its own slot:")
1067
+ );
1068
+ lines.push("");
1069
+ for (const r of shown) {
1070
+ const where = r.line ? `${r.file}:${r.line}` : r.file;
1071
+ lines.push(
1072
+ ` ${pc.green("\u2022")} ${pc.green(r.label.padEnd(labelW))} ${pc.dim(KIND_TAG[r.kind].padEnd(tagW))} ${pc.cyan(shortSlot(r.slot))} ${r.contract.padEnd(contractW)} ${pc.dim(where)}`
1073
+ );
1074
+ }
1075
+ if (inventory.length > shown.length) {
1076
+ lines.push(` ${pc.dim(`\u2026 and ${inventory.length - shown.length} more`)}`);
686
1077
  }
687
- const sorted = [...findings].sort(
688
- (a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]
1078
+ lines.push("");
1079
+ lines.push(
1080
+ pc.green("Every facet keeps to its own namespace, and no two regions share a slot. ") + pc.green(pc.bold("Nicely done."))
689
1081
  );
1082
+ return lines;
1083
+ }
1084
+ function renderTerminal(findings, facetCount, rawSources, inventory = []) {
1085
+ const artifactsNote = pc.dim(`${facetCount} artifact${facetCount === 1 ? "" : "s"} scanned`);
1086
+ if (findings.length === 0) {
1087
+ const header = `${pc.green(pc.bold("\u2714 no storage collisions detected"))} ${pc.dim("\xB7")} ${artifactsNote}`;
1088
+ if (inventory.length === 0) return header;
1089
+ return [header, ...renderInventory(inventory)].join("\n");
1090
+ }
1091
+ const sorted = [...findings].sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]);
1092
+ const lines = [];
690
1093
  for (const f of sorted) {
1094
+ const sev = SEV[f.severity];
1095
+ const tag = `${pc.bold(sev.paint(sev.label))}${pc.dim(`[${f.kind}]`)}`;
691
1096
  lines.push("");
692
- lines.push(`${colorSeverity(f.severity)} ${pc.bold(f.kind)} ${pc.dim(f.slot)}`);
693
- lines.push(` ${f.message}`);
1097
+ lines.push(`${tag}: ${pc.bold(f.message)}`);
1098
+ f.locations.forEach((loc, i) => {
1099
+ const span = resolveSpan(loc, rawSources?.get(loc.file));
1100
+ const note = i === 0 ? `slot ${shortSlot(f.slot)}` : "same slot here";
1101
+ if (span) {
1102
+ lines.push(...renderFrame(loc, span, sev, f.slot === "n/a" ? "here" : note));
1103
+ } else {
1104
+ const where = loc.line ? `${loc.file}:${loc.line}` : loc.file;
1105
+ lines.push(` ${pc.dim("\u256D\u2500[")}${pc.cyan(where)}${pc.dim("]")}`);
1106
+ }
1107
+ });
694
1108
  if (f.facets.length > 0) {
695
- lines.push(` ${pc.dim("facets:")} ${f.facets.join(", ")}`);
1109
+ lines.push(` ${pc.dim("= facets:")} ${f.facets.join(pc.dim(", "))}`);
696
1110
  }
697
- for (const loc of f.locations) {
698
- const where = loc.line ? `${loc.file}:${loc.line}` : loc.file;
699
- lines.push(` ${pc.dim("at")} ${where}`);
1111
+ if (f.slot && f.slot !== "n/a") {
1112
+ lines.push(` ${pc.dim("= slot: ")} ${pc.dim(f.slot)}`);
700
1113
  }
1114
+ lines.push(` ${pc.dim("= help: ")} ${pc.dim(HELP[f.kind] ?? "")}`);
701
1115
  }
702
1116
  const errors = findings.filter((f) => f.severity === "error").length;
703
1117
  const warns = findings.filter((f) => f.severity === "warn").length;
1118
+ const notes = findings.filter((f) => f.severity === "info").length;
1119
+ const parts = [];
1120
+ if (errors > 0) parts.push(pc.red(`${SEV.error.glyph} ${errors} error${errors === 1 ? "" : "s"}`));
1121
+ if (warns > 0)
1122
+ parts.push(pc.yellow(`${SEV.warn.glyph} ${warns} warning${warns === 1 ? "" : "s"}`));
1123
+ if (notes > 0) parts.push(pc.cyan(`${SEV.info.glyph} ${notes} note${notes === 1 ? "" : "s"}`));
704
1124
  lines.push("");
705
- lines.push(pc.bold(`${errors} error(s), ${warns} warning(s)`));
1125
+ lines.push(`${parts.join(pc.dim(" \xB7 "))} ${pc.dim("\xB7")} ${artifactsNote}`);
706
1126
  return lines.join("\n");
707
1127
  }
708
1128
 
@@ -832,7 +1252,12 @@ async function run(target, opts) {
832
1252
  )
833
1253
  );
834
1254
  }
835
- const output = opts.json ? renderJson(result.findings, result.artifacts.length) : opts.markdown ? renderMarkdown(result.findings, result.artifacts.length) : renderTerminal(result.findings, result.artifacts.length);
1255
+ const output = opts.json ? renderJson(result.findings, result.artifacts.length) : opts.markdown ? renderMarkdown(result.findings, result.artifacts.length) : renderTerminal(
1256
+ result.findings,
1257
+ result.artifacts.length,
1258
+ result.rawSources,
1259
+ result.inventory
1260
+ );
836
1261
  process.stdout.write(output + "\n");
837
1262
  const threshold = SEVERITY_RANK3[opts.severity];
838
1263
  const hit = result.findings.some((f) => SEVERITY_RANK3[f.severity] >= threshold);
package/dist/index.js CHANGED
@@ -68,6 +68,18 @@ function extractSourcePath(artifactPath, parsed) {
68
68
  }
69
69
  return path.basename(path.dirname(artifactPath));
70
70
  }
71
+ async function loadRawSources(inputPath, sourcePaths) {
72
+ const { root } = await resolveFoundryRoot(inputPath);
73
+ const out = /* @__PURE__ */ new Map();
74
+ for (const sourcePath of new Set(sourcePaths)) {
75
+ if (out.has(sourcePath)) continue;
76
+ try {
77
+ out.set(sourcePath, await fs.readFile(path.join(root, sourcePath), "utf8"));
78
+ } catch {
79
+ }
80
+ }
81
+ return out;
82
+ }
71
83
  async function loadFoundryArtifacts(inputPath, opts = {}) {
72
84
  const { outDir } = await resolveFoundryRoot(inputPath);
73
85
  if (!await fileExists(outDir)) {
@@ -101,50 +113,8 @@ async function loadFoundryArtifacts(inputPath, opts = {}) {
101
113
  return artifacts;
102
114
  }
103
115
 
104
- // src/detector/index.ts
105
- var DEFAULT_IGNORE_GLOBS = [
106
- "lib/**",
107
- "test/**",
108
- "script/**",
109
- "**/*.t.sol",
110
- "**/*.s.sol"
111
- ];
112
- function compilePatterns(globs) {
113
- return globs.map((g) => {
114
- const escaped = g.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "::DOUBLESTAR::").replace(/\*/g, "[^/]*").replace(/::DOUBLESTAR::/g, ".*").replace(/\?/g, ".");
115
- return new RegExp(`^${escaped}$`);
116
- });
117
- }
118
- function buildIgnore(userGlobs, noDefault) {
119
- const globs = [
120
- ...noDefault ? [] : DEFAULT_IGNORE_GLOBS,
121
- ...userGlobs ?? []
122
- ];
123
- if (globs.length === 0) return void 0;
124
- const patterns = compilePatterns(globs);
125
- return (sourcePath) => patterns.some((p) => p.test(sourcePath));
126
- }
127
- function buildIsFacet(globs) {
128
- if (!globs || globs.length === 0) return void 0;
129
- const patterns = compilePatterns(globs);
130
- return (artifact) => patterns.some((p) => p.test(artifact.sourcePath));
131
- }
132
- async function detect(options, analyzers) {
133
- const artifacts = await loadFoundryArtifacts(options.path, {
134
- ignoreSourcePath: buildIgnore(options.ignoreGlobs, options.noDefaultIgnore)
135
- });
136
- const ctx = {
137
- artifacts,
138
- rawSources: /* @__PURE__ */ new Map(),
139
- isFacet: buildIsFacet(options.facetGlobs)
140
- };
141
- const findings = [];
142
- for (const analyzer of analyzers) {
143
- const out = await analyzer.run(ctx);
144
- findings.push(...out);
145
- }
146
- return { artifacts, findings };
147
- }
116
+ // src/detector/analyzers/diamondStorage.ts
117
+ import { keccak_256 as keccak_2562 } from "@noble/hashes/sha3";
148
118
 
149
119
  // src/lib/eip7201.ts
150
120
  import { keccak_256 } from "@noble/hashes/sha3";
@@ -191,6 +161,488 @@ function parseErc7201Annotation(text) {
191
161
  const match = rest.match(/^[A-Za-z0-9_.\-]+/);
192
162
  return match ? match[0] : null;
193
163
  }
164
+
165
+ // src/detector/analyzers/diamondStorage.ts
166
+ function keccak256Hex(input) {
167
+ const bytes = keccak_2562(new TextEncoder().encode(input));
168
+ let out = "0x";
169
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
170
+ return out;
171
+ }
172
+ function isBytes32Constant(node) {
173
+ if (node.nodeType !== "VariableDeclaration") return false;
174
+ if (node.constant !== true) return false;
175
+ const typeName = node.typeName;
176
+ return typeName?.nodeType === "ElementaryTypeName" && typeName.name === "bytes32";
177
+ }
178
+ function extractKeccakStringArg(value) {
179
+ if (!value || typeof value !== "object") return null;
180
+ const v = value;
181
+ if (v.nodeType !== "FunctionCall") return null;
182
+ const expr = v.expression;
183
+ if (expr?.nodeType !== "Identifier" || expr.name !== "keccak256") return null;
184
+ const args = v.arguments;
185
+ if (!Array.isArray(args) || args.length !== 1) return null;
186
+ const arg = args[0];
187
+ if (arg.nodeType !== "Literal" || arg.kind !== "string") return null;
188
+ return typeof arg.value === "string" ? arg.value : null;
189
+ }
190
+ function extractBytes32HexLiteral(value) {
191
+ if (!value || typeof value !== "object") return null;
192
+ const v = value;
193
+ if (v.nodeType !== "Literal" || v.kind !== "number") return null;
194
+ if (typeof v.value !== "string") return null;
195
+ try {
196
+ const big = BigInt(v.value);
197
+ if (big < 0n) return null;
198
+ return "0x" + big.toString(16).padStart(64, "0");
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+ function findKeccakStringArg(node) {
204
+ if (!node || typeof node !== "object") return null;
205
+ const v = node;
206
+ if (v.nodeType !== "FunctionCall") return null;
207
+ const expr = v.expression;
208
+ if (expr?.nodeType !== "Identifier" || expr.name !== "keccak256") return null;
209
+ const args = v.arguments;
210
+ if (!Array.isArray(args) || args.length !== 1) return null;
211
+ const arg = args[0];
212
+ if (arg.nodeType !== "Literal" || arg.kind !== "string") return null;
213
+ return typeof arg.value === "string" ? arg.value : null;
214
+ }
215
+ function someNode(node, pred) {
216
+ if (!node || typeof node !== "object") return false;
217
+ const n = node;
218
+ if (typeof n.nodeType === "string" && pred(n)) return true;
219
+ for (const key of Object.keys(n)) {
220
+ const value = n[key];
221
+ if (Array.isArray(value)) {
222
+ if (value.some((c) => someNode(c, pred))) return true;
223
+ } else if (value && typeof value === "object") {
224
+ if (someNode(value, pred)) return true;
225
+ }
226
+ }
227
+ return false;
228
+ }
229
+ function extractErc7201FormulaNamespace(value) {
230
+ const strings = [];
231
+ someNode(value, (n) => {
232
+ const s = findKeccakStringArg(n);
233
+ if (s !== null) strings.push(s);
234
+ return false;
235
+ });
236
+ if (strings.length !== 1) return null;
237
+ const hasSubOne = someNode(
238
+ value,
239
+ (n) => n.nodeType === "BinaryOperation" && n.operator === "-" && n.rightExpression?.nodeType === "Literal" && n.rightExpression.value === "1"
240
+ );
241
+ const hasMask = someNode(value, (n) => n.nodeType === "BinaryOperation" && n.operator === "&");
242
+ if (!hasSubOne || !hasMask) return null;
243
+ return strings[0];
244
+ }
245
+ function walkAst(ast, visit, parents = []) {
246
+ if (!ast || typeof ast !== "object") return;
247
+ const node = ast;
248
+ if (typeof node.nodeType === "string") visit(node, parents);
249
+ const nextParents = typeof node.nodeType === "string" ? [...parents, node] : parents;
250
+ for (const key of Object.keys(node)) {
251
+ const value = node[key];
252
+ if (Array.isArray(value)) {
253
+ for (const child of value) walkAst(child, visit, nextParents);
254
+ } else if (value && typeof value === "object") {
255
+ walkAst(value, visit, nextParents);
256
+ }
257
+ }
258
+ }
259
+ function declaringContract(parents) {
260
+ for (let i = parents.length - 1; i >= 0; i--) {
261
+ const p = parents[i];
262
+ if (p.nodeType === "ContractDefinition") return p.name ?? null;
263
+ }
264
+ return null;
265
+ }
266
+ function collectSlotConstants(ctx) {
267
+ const seen = /* @__PURE__ */ new Set();
268
+ const out = [];
269
+ for (const artifact of ctx.artifacts) {
270
+ if (!artifact.ast) continue;
271
+ walkAst(artifact.ast, (node, parents) => {
272
+ if (!isBytes32Constant(node)) return;
273
+ const namespace = extractKeccakStringArg(node.value);
274
+ if (namespace === null) return;
275
+ const declarationId = typeof node.id === "number" ? node.id : -1;
276
+ const variableName = node.name ?? "<anon>";
277
+ const contract = declaringContract(parents) ?? artifact.contractName;
278
+ const src = node.src;
279
+ const dedupeKey = `${artifact.sourcePath}::${contract}::${variableName}::${src ?? ""}`;
280
+ if (seen.has(dedupeKey)) return;
281
+ seen.add(dedupeKey);
282
+ out.push({
283
+ declarationId,
284
+ variableName,
285
+ namespace,
286
+ slot: keccak256Hex(namespace),
287
+ contract,
288
+ sourcePath: artifact.sourcePath,
289
+ src
290
+ });
291
+ });
292
+ }
293
+ return out;
294
+ }
295
+ function collectLiteralSlotConstants(ctx) {
296
+ const seen = /* @__PURE__ */ new Set();
297
+ const out = [];
298
+ for (const artifact of ctx.artifacts) {
299
+ if (!artifact.ast) continue;
300
+ walkAst(artifact.ast, (node, parents) => {
301
+ if (!isBytes32Constant(node)) return;
302
+ if (extractKeccakStringArg(node.value) !== null) return;
303
+ const slot = extractBytes32HexLiteral(node.value);
304
+ if (slot === null) return;
305
+ const declarationId = typeof node.id === "number" ? node.id : -1;
306
+ const variableName = node.name ?? "<anon>";
307
+ const contract = declaringContract(parents) ?? artifact.contractName;
308
+ const src = node.src;
309
+ const dedupeKey = `${artifact.sourcePath}::${contract}::${variableName}::${src ?? ""}`;
310
+ if (seen.has(dedupeKey)) return;
311
+ seen.add(dedupeKey);
312
+ out.push({
313
+ declarationId,
314
+ variableName,
315
+ namespace: null,
316
+ slot,
317
+ contract,
318
+ sourcePath: artifact.sourcePath,
319
+ src
320
+ });
321
+ });
322
+ }
323
+ return out;
324
+ }
325
+ function collectFormulaSlotConstants(ctx) {
326
+ const seen = /* @__PURE__ */ new Set();
327
+ const out = [];
328
+ for (const artifact of ctx.artifacts) {
329
+ if (!artifact.ast) continue;
330
+ walkAst(artifact.ast, (node, parents) => {
331
+ if (!isBytes32Constant(node)) return;
332
+ if (extractKeccakStringArg(node.value) !== null) return;
333
+ const namespace = extractErc7201FormulaNamespace(node.value);
334
+ if (namespace === null) return;
335
+ const declarationId = typeof node.id === "number" ? node.id : -1;
336
+ const variableName = node.name ?? "<anon>";
337
+ const contract = declaringContract(parents) ?? artifact.contractName;
338
+ const src = node.src;
339
+ const dedupeKey = `${artifact.sourcePath}::${contract}::${variableName}::${src ?? ""}`;
340
+ if (seen.has(dedupeKey)) return;
341
+ seen.add(dedupeKey);
342
+ out.push({
343
+ declarationId,
344
+ variableName,
345
+ namespace,
346
+ slot: erc7201Slot(namespace),
347
+ contract,
348
+ sourcePath: artifact.sourcePath,
349
+ src
350
+ });
351
+ });
352
+ }
353
+ return out;
354
+ }
355
+ function collectAssemblyLiteralSlots(artifacts) {
356
+ const seen = /* @__PURE__ */ new Set();
357
+ const out = [];
358
+ for (const artifact of artifacts) {
359
+ if (!artifact.ast) continue;
360
+ walkAst(artifact.ast, (node, parents) => {
361
+ if (node.nodeType !== "YulAssignment") return;
362
+ const targets = node.variableNames;
363
+ const targetsSlot = Array.isArray(targets) && targets.some((t) => typeof t.name === "string" && t.name.endsWith(".slot"));
364
+ if (!targetsSlot) return;
365
+ const value = node.value;
366
+ if (value?.nodeType !== "YulLiteral" || value.kind !== "number") return;
367
+ if (typeof value.value !== "string") return;
368
+ let slot;
369
+ try {
370
+ const big = BigInt(value.value);
371
+ if (big < 0n) return;
372
+ slot = "0x" + big.toString(16).padStart(64, "0");
373
+ } catch {
374
+ return;
375
+ }
376
+ const contract = declaringContract(parents) ?? artifact.contractName;
377
+ const src = node.src;
378
+ const dedupeKey = `${artifact.sourcePath}::${contract}::${slot}::${src ?? ""}`;
379
+ if (seen.has(dedupeKey)) return;
380
+ seen.add(dedupeKey);
381
+ out.push({
382
+ declarationId: -1,
383
+ variableName: "<assembly literal>",
384
+ namespace: null,
385
+ slot,
386
+ contract,
387
+ sourcePath: artifact.sourcePath,
388
+ src
389
+ });
390
+ });
391
+ }
392
+ return out;
393
+ }
394
+ function collectSlotAssignmentValueSrcs(yulNode, srcs) {
395
+ if (!yulNode || typeof yulNode !== "object") return;
396
+ const node = yulNode;
397
+ if (node.nodeType === "YulAssignment") {
398
+ const targets = node.variableNames;
399
+ const targetsSlot = Array.isArray(targets) && targets.some((t) => typeof t.name === "string" && t.name.endsWith(".slot"));
400
+ if (targetsSlot) {
401
+ const value = node.value;
402
+ if (value?.nodeType === "YulIdentifier" && typeof value.src === "string") {
403
+ srcs.add(value.src);
404
+ }
405
+ }
406
+ }
407
+ for (const key of Object.keys(node)) {
408
+ const v = node[key];
409
+ if (Array.isArray(v)) {
410
+ for (const child of v) collectSlotAssignmentValueSrcs(child, srcs);
411
+ } else if (v && typeof v === "object") {
412
+ collectSlotAssignmentValueSrcs(v, srcs);
413
+ }
414
+ }
415
+ }
416
+ function collectSlotUsedDeclarationIds(artifacts) {
417
+ const ids = /* @__PURE__ */ new Set();
418
+ for (const artifact of artifacts) {
419
+ if (!artifact.ast) continue;
420
+ walkAst(artifact.ast, (node) => {
421
+ if (node.nodeType !== "InlineAssembly") return;
422
+ const yulAst = node.AST;
423
+ if (!yulAst) return;
424
+ const slotValueSrcs = /* @__PURE__ */ new Set();
425
+ collectSlotAssignmentValueSrcs(yulAst, slotValueSrcs);
426
+ if (slotValueSrcs.size === 0) return;
427
+ const refs = node.externalReferences;
428
+ if (!Array.isArray(refs)) return;
429
+ for (const ref of refs) {
430
+ if (typeof ref.src === "string" && slotValueSrcs.has(ref.src) && typeof ref.declaration === "number") {
431
+ ids.add(ref.declaration);
432
+ }
433
+ }
434
+ });
435
+ }
436
+ return ids;
437
+ }
438
+ function collectAliases(artifacts) {
439
+ const aliases = /* @__PURE__ */ new Map();
440
+ for (const artifact of artifacts) {
441
+ if (!artifact.ast) continue;
442
+ walkAst(artifact.ast, (node) => {
443
+ if (node.nodeType !== "VariableDeclarationStatement") return;
444
+ const initialValue = node.initialValue;
445
+ if (initialValue?.nodeType !== "Identifier") return;
446
+ const referenced = initialValue.referencedDeclaration;
447
+ if (typeof referenced !== "number") return;
448
+ const decls = node.declarations;
449
+ if (!Array.isArray(decls)) return;
450
+ for (const d of decls) {
451
+ if (typeof d?.id === "number") aliases.set(d.id, referenced);
452
+ }
453
+ });
454
+ }
455
+ return aliases;
456
+ }
457
+ function isUsedAsSlot(constantId, slotUsedIds, aliases) {
458
+ if (constantId < 0) return false;
459
+ if (slotUsedIds.has(constantId)) return true;
460
+ for (const [aliasId, refId] of aliases) {
461
+ if (refId === constantId && slotUsedIds.has(aliasId)) return true;
462
+ }
463
+ return false;
464
+ }
465
+ function collectGatedSlotConstants(ctx) {
466
+ const constants = [
467
+ ...collectSlotConstants(ctx),
468
+ ...collectLiteralSlotConstants(ctx),
469
+ ...collectFormulaSlotConstants(ctx)
470
+ ];
471
+ const slotUsedIds = collectSlotUsedDeclarationIds(ctx.artifacts);
472
+ const aliases = collectAliases(ctx.artifacts);
473
+ return [
474
+ ...constants.filter((c) => isUsedAsSlot(c.declarationId, slotUsedIds, aliases)),
475
+ ...collectAssemblyLiteralSlots(ctx.artifacts)
476
+ ];
477
+ }
478
+
479
+ // src/detector/analyzers/erc7201.ts
480
+ var NAMED_NODE_TYPES = /* @__PURE__ */ new Set([
481
+ "ContractDefinition",
482
+ "StructDefinition",
483
+ "FunctionDefinition",
484
+ "VariableDeclaration",
485
+ "ErrorDefinition",
486
+ "EventDefinition",
487
+ "ModifierDefinition",
488
+ "EnumDefinition"
489
+ ]);
490
+ function getDocText(node) {
491
+ const doc = node.documentation;
492
+ if (!doc) return null;
493
+ if (typeof doc === "string") return doc;
494
+ if (typeof doc === "object" && doc !== null) {
495
+ const text = doc.text;
496
+ if (typeof text === "string") return text;
497
+ }
498
+ return null;
499
+ }
500
+ function nearestContract(parents) {
501
+ for (let i = parents.length - 1; i >= 0; i--) {
502
+ const p = parents[i];
503
+ if (p.nodeType === "ContractDefinition") return p.name ?? null;
504
+ }
505
+ return null;
506
+ }
507
+ function walkAst2(ast, visit, parents = []) {
508
+ if (!ast || typeof ast !== "object") return;
509
+ const node = ast;
510
+ if (typeof node.nodeType === "string") visit(node, parents);
511
+ const nextParents = typeof node.nodeType === "string" ? [...parents, node] : parents;
512
+ for (const key of Object.keys(node)) {
513
+ const value = node[key];
514
+ if (Array.isArray(value)) {
515
+ for (const child of value) walkAst2(child, visit, nextParents);
516
+ } else if (value && typeof value === "object") {
517
+ walkAst2(value, visit, nextParents);
518
+ }
519
+ }
520
+ }
521
+ function collectErc7201Annotations(artifacts) {
522
+ const seen = /* @__PURE__ */ new Set();
523
+ const out = [];
524
+ for (const artifact of artifacts) {
525
+ if (!artifact.ast) continue;
526
+ walkAst2(artifact.ast, (node, parents) => {
527
+ if (!node.nodeType || !NAMED_NODE_TYPES.has(node.nodeType)) return;
528
+ const text = getDocText(node);
529
+ if (!text || !text.includes("erc7201:")) return;
530
+ const namespaceId = parseErc7201Annotation(text);
531
+ if (!namespaceId) return;
532
+ const attachedTo = `${node.nodeType}:${node.name ?? "<anon>"}`;
533
+ const contract = node.nodeType === "ContractDefinition" ? node.name ?? artifact.contractName : nearestContract(parents) ?? artifact.contractName;
534
+ const src = node.src;
535
+ const dedupeKey = `${artifact.sourcePath}::${attachedTo}::${src ?? ""}`;
536
+ if (seen.has(dedupeKey)) return;
537
+ seen.add(dedupeKey);
538
+ out.push({
539
+ namespaceId,
540
+ slot: erc7201Slot(namespaceId),
541
+ attachedTo,
542
+ contract,
543
+ sourcePath: artifact.sourcePath,
544
+ src
545
+ });
546
+ });
547
+ }
548
+ return out;
549
+ }
550
+
551
+ // src/detector/inventory.ts
552
+ function lineOf(src, text) {
553
+ if (!src || !text) return void 0;
554
+ const [startStr] = src.split(":");
555
+ const start = Number(startStr);
556
+ if (!Number.isFinite(start)) return void 0;
557
+ let line = 1;
558
+ for (let i = 0; i < start && i < text.length; i++) {
559
+ if (text.charCodeAt(i) === 10) line++;
560
+ }
561
+ return line;
562
+ }
563
+ var PRIORITY = { erc7201: 0, namespace: 1, hardcoded: 2 };
564
+ function buildInventory(ctx) {
565
+ const bySlot = /* @__PURE__ */ new Map();
566
+ const add = (region) => {
567
+ const existing = bySlot.get(region.slot);
568
+ if (!existing || PRIORITY[region.kind] < PRIORITY[existing.kind]) {
569
+ bySlot.set(region.slot, region);
570
+ }
571
+ };
572
+ for (const c of collectGatedSlotConstants(ctx)) {
573
+ add({
574
+ slot: c.slot,
575
+ label: c.namespace ?? c.variableName,
576
+ kind: c.namespace ? "namespace" : "hardcoded",
577
+ contract: c.contract,
578
+ file: c.sourcePath,
579
+ line: lineOf(c.src, ctx.rawSources.get(c.sourcePath))
580
+ });
581
+ }
582
+ for (const a of collectErc7201Annotations(ctx.artifacts)) {
583
+ add({
584
+ slot: a.slot,
585
+ label: a.namespaceId,
586
+ kind: "erc7201",
587
+ contract: a.contract,
588
+ file: a.sourcePath,
589
+ line: lineOf(a.src, ctx.rawSources.get(a.sourcePath))
590
+ });
591
+ }
592
+ return [...bySlot.values()].sort(
593
+ (x, y) => x.file.localeCompare(y.file) || x.slot.localeCompare(y.slot)
594
+ );
595
+ }
596
+
597
+ // src/detector/index.ts
598
+ var DEFAULT_IGNORE_GLOBS = [
599
+ "lib/**",
600
+ "test/**",
601
+ "script/**",
602
+ "**/*.t.sol",
603
+ "**/*.s.sol"
604
+ ];
605
+ function compilePatterns(globs) {
606
+ return globs.map((g) => {
607
+ const escaped = g.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "::DOUBLESTAR::").replace(/\*/g, "[^/]*").replace(/::DOUBLESTAR::/g, ".*").replace(/\?/g, ".");
608
+ return new RegExp(`^${escaped}$`);
609
+ });
610
+ }
611
+ function buildIgnore(userGlobs, noDefault) {
612
+ const globs = [
613
+ ...noDefault ? [] : DEFAULT_IGNORE_GLOBS,
614
+ ...userGlobs ?? []
615
+ ];
616
+ if (globs.length === 0) return void 0;
617
+ const patterns = compilePatterns(globs);
618
+ return (sourcePath) => patterns.some((p) => p.test(sourcePath));
619
+ }
620
+ function buildIsFacet(globs) {
621
+ if (!globs || globs.length === 0) return void 0;
622
+ const patterns = compilePatterns(globs);
623
+ return (artifact) => patterns.some((p) => p.test(artifact.sourcePath));
624
+ }
625
+ async function detect(options, analyzers) {
626
+ const artifacts = await loadFoundryArtifacts(options.path, {
627
+ ignoreSourcePath: buildIgnore(options.ignoreGlobs, options.noDefaultIgnore)
628
+ });
629
+ const rawSources = await loadRawSources(
630
+ options.path,
631
+ artifacts.map((a) => a.sourcePath)
632
+ );
633
+ const ctx = {
634
+ artifacts,
635
+ rawSources,
636
+ isFacet: buildIsFacet(options.facetGlobs)
637
+ };
638
+ const findings = [];
639
+ for (const analyzer of analyzers) {
640
+ const out = await analyzer.run(ctx);
641
+ findings.push(...out);
642
+ }
643
+ const inventory = buildInventory(ctx);
644
+ return { artifacts, findings, rawSources, inventory };
645
+ }
194
646
  export {
195
647
  detect,
196
648
  erc7201Slot,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diamond-detect",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Static analyzer for EIP-2535 Diamond storage-slot collisions across facets",
5
5
  "homepage": "https://github.com/jayeshy14/Diamond-Storage-Detector#readme",
6
6
  "repository": {