diamond-detect 0.1.1 → 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 +466 -119
  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(":");
@@ -237,6 +307,105 @@ function collectSlotConstants(ctx) {
237
307
  }
238
308
  return out;
239
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
+ }
240
409
  function collectSlotAssignmentValueSrcs(yulNode, srcs) {
241
410
  if (!yulNode || typeof yulNode !== "object") return;
242
411
  const node = yulNode;
@@ -308,15 +477,23 @@ function isUsedAsSlot(constantId, slotUsedIds, aliases) {
308
477
  }
309
478
  return false;
310
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
+ }
311
493
  var diamondStorageAnalyzer = {
312
494
  name: "diamond-storage-namespace",
313
495
  run(ctx) {
314
- const constants = collectSlotConstants(ctx);
315
- const slotUsedIds = collectSlotUsedDeclarationIds(ctx.artifacts);
316
- const aliases = collectAliases(ctx.artifacts);
317
- const slotConstants = constants.filter(
318
- (c) => isUsedAsSlot(c.declarationId, slotUsedIds, aliases)
319
- );
496
+ const slotConstants = collectGatedSlotConstants(ctx);
320
497
  const bySlot = /* @__PURE__ */ new Map();
321
498
  for (const c of slotConstants) {
322
499
  const list = bySlot.get(c.slot) ?? [];
@@ -327,72 +504,38 @@ var diamondStorageAnalyzer = {
327
504
  for (const [slot, group] of bySlot) {
328
505
  const distinctSources = new Set(group.map((g) => g.sourcePath));
329
506
  if (distinctSources.size < 2) continue;
330
- 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);
331
512
  const facets = Array.from(new Set(group.map((g) => g.contract)));
332
513
  const locations = group.map((g) => {
333
514
  const sourceText = ctx.rawSources.get(g.sourcePath);
334
- return { file: g.sourcePath, line: lineFromSrc(g.src, sourceText) };
515
+ return { file: g.sourcePath, line: lineFromSrc(g.src, sourceText), src: g.src };
335
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
+ }
336
525
  findings.push({
337
526
  kind: "diamond-storage-namespace",
338
527
  severity: "error",
339
528
  slot,
340
- 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,
341
530
  facets,
342
531
  locations,
343
- detail: { namespaces, declarations: group }
532
+ detail: { namespaces, variableNames, declarations: group }
344
533
  });
345
534
  }
346
535
  return findings;
347
536
  }
348
537
  };
349
538
 
350
- // src/lib/eip7201.ts
351
- import { keccak_256 as keccak_2562 } from "@noble/hashes/sha3";
352
- var MASK_LAST_BYTE = (() => {
353
- const m = new Uint8Array(32).fill(255);
354
- m[31] = 0;
355
- return m;
356
- })();
357
- function utf8(s) {
358
- return new TextEncoder().encode(s);
359
- }
360
- function toHex(bytes) {
361
- let out = "0x";
362
- for (const b of bytes) out += b.toString(16).padStart(2, "0");
363
- return out;
364
- }
365
- function subOne(bytes) {
366
- const out = new Uint8Array(bytes);
367
- for (let i = out.length - 1; i >= 0; i--) {
368
- if (out[i] > 0) {
369
- out[i] = out[i] - 1;
370
- return out;
371
- }
372
- out[i] = 255;
373
- }
374
- return out;
375
- }
376
- function maskLastByte(bytes) {
377
- const out = new Uint8Array(32);
378
- for (let i = 0; i < 32; i++) out[i] = bytes[i] & MASK_LAST_BYTE[i];
379
- return out;
380
- }
381
- function erc7201Slot(namespaceId) {
382
- const inner = keccak_2562(utf8(namespaceId));
383
- const decremented = subOne(inner);
384
- const outer = keccak_2562(decremented);
385
- return toHex(maskLastByte(outer));
386
- }
387
- var ERC7201_PREFIX = "erc7201:";
388
- function parseErc7201Annotation(text) {
389
- const idx = text.indexOf(ERC7201_PREFIX);
390
- if (idx === -1) return null;
391
- const rest = text.slice(idx + ERC7201_PREFIX.length);
392
- const match = rest.match(/^[A-Za-z0-9_.\-]+/);
393
- return match ? match[0] : null;
394
- }
395
-
396
539
  // src/detector/analyzers/erc7201.ts
397
540
  var NAMED_NODE_TYPES = /* @__PURE__ */ new Set([
398
541
  "ContractDefinition",
@@ -480,7 +623,7 @@ var erc7201Analyzer = {
480
623
  if (distinctSources.size < 2) continue;
481
624
  const ids = Array.from(new Set(group.map((g) => g.namespaceId)));
482
625
  const facets = Array.from(new Set(group.map((g) => g.contract)));
483
- const locations = group.map((g) => ({ file: g.sourcePath }));
626
+ const locations = group.map((g) => ({ file: g.sourcePath, src: g.src }));
484
627
  findings.push({
485
628
  kind: "erc7201-namespace",
486
629
  severity: "error",
@@ -495,6 +638,102 @@ var erc7201Analyzer = {
495
638
  }
496
639
  };
497
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
+
498
737
  // src/detector/analyzers/appStorage.ts
499
738
  function memberFingerprint(m) {
500
739
  return { label: m.label, offset: m.offset, slot: m.slot, type: m.type };
@@ -659,7 +898,7 @@ var inlineAssemblyAnalyzer = {
659
898
  slot: lit.slot,
660
899
  message: `inline assembly writes to a hardcoded slot (sstore(${lit.rawValue}, \u2026)) \u2014 confirm no overlap with computed storage slots.`,
661
900
  facets: [lit.artifact.contractName],
662
- locations: [{ file: lit.artifact.sourcePath }],
901
+ locations: [{ file: lit.artifact.sourcePath, src: lit.src }],
663
902
  detail: { rawValue: lit.rawValue, src: lit.src }
664
903
  }));
665
904
  }
@@ -750,37 +989,140 @@ var defaultAnalyzers = [
750
989
  // src/reporter/terminal.ts
751
990
  import pc from "picocolors";
752
991
  var SEVERITY_RANK = { info: 0, warn: 1, error: 2 };
753
- function colorSeverity(sev) {
754
- if (sev === "error") return pc.red(pc.bold("ERROR"));
755
- if (sev === "warn") return pc.yellow("WARN ");
756
- 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 };
1035
+ }
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
+ ];
757
1052
  }
758
- function renderTerminal(findings, facetCount) {
1053
+ var KIND_TAG = {
1054
+ erc7201: "erc7201",
1055
+ namespace: "namespace",
1056
+ hardcoded: "precomputed"
1057
+ };
1058
+ function renderInventory(inventory) {
759
1059
  const lines = [];
760
- lines.push(pc.dim(`scanned ${facetCount} contract artifact(s)`));
761
- if (findings.length === 0) {
762
- lines.push(pc.green("\u2713 no storage collisions detected"));
763
- 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`)}`);
764
1077
  }
765
- const sorted = [...findings].sort(
766
- (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."))
767
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 = [];
768
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}]`)}`;
769
1096
  lines.push("");
770
- lines.push(`${colorSeverity(f.severity)} ${pc.bold(f.kind)} ${pc.dim(f.slot)}`);
771
- 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
+ });
772
1108
  if (f.facets.length > 0) {
773
- lines.push(` ${pc.dim("facets:")} ${f.facets.join(", ")}`);
1109
+ lines.push(` ${pc.dim("= facets:")} ${f.facets.join(pc.dim(", "))}`);
774
1110
  }
775
- for (const loc of f.locations) {
776
- const where = loc.line ? `${loc.file}:${loc.line}` : loc.file;
777
- lines.push(` ${pc.dim("at")} ${where}`);
1111
+ if (f.slot && f.slot !== "n/a") {
1112
+ lines.push(` ${pc.dim("= slot: ")} ${pc.dim(f.slot)}`);
778
1113
  }
1114
+ lines.push(` ${pc.dim("= help: ")} ${pc.dim(HELP[f.kind] ?? "")}`);
779
1115
  }
780
1116
  const errors = findings.filter((f) => f.severity === "error").length;
781
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"}`));
782
1124
  lines.push("");
783
- lines.push(pc.bold(`${errors} error(s), ${warns} warning(s)`));
1125
+ lines.push(`${parts.join(pc.dim(" \xB7 "))} ${pc.dim("\xB7")} ${artifactsNote}`);
784
1126
  return lines.join("\n");
785
1127
  }
786
1128
 
@@ -910,7 +1252,12 @@ async function run(target, opts) {
910
1252
  )
911
1253
  );
912
1254
  }
913
- 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
+ );
914
1261
  process.stdout.write(output + "\n");
915
1262
  const threshold = SEVERITY_RANK3[opts.severity];
916
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.1",
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": {