@openzeppelin/upgrades-core 1.45.0 → 1.47.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 (88) hide show
  1. package/dist/cli/validate/validate-upgrade-safety.test.js +18 -0
  2. package/dist/cli/validate/validate-upgrade-safety.test.js.map +1 -1
  3. package/dist/storage/compare.d.ts.map +1 -1
  4. package/dist/storage/compare.js +3 -3
  5. package/dist/storage/compare.js.map +1 -1
  6. package/dist/storage/extract.d.ts +15 -3
  7. package/dist/storage/extract.d.ts.map +1 -1
  8. package/dist/storage/extract.js +68 -22
  9. package/dist/storage/extract.js.map +1 -1
  10. package/dist/storage/namespace.d.ts.map +1 -1
  11. package/dist/storage/namespace.js +26 -13
  12. package/dist/storage/namespace.js.map +1 -1
  13. package/dist/storage/report-rename-retype.test.js +30 -0
  14. package/dist/storage/report-rename-retype.test.js.map +1 -1
  15. package/dist/storage/report.js +9 -1
  16. package/dist/storage/report.js.map +1 -1
  17. package/dist/storage-erc7201-builtin.test.d.ts +2 -0
  18. package/dist/storage-erc7201-builtin.test.d.ts.map +1 -0
  19. package/dist/storage-erc7201-builtin.test.js +105 -0
  20. package/dist/storage-erc7201-builtin.test.js.map +1 -0
  21. package/dist/storage-namespaced-renamed-retyped.test.d.ts +2 -0
  22. package/dist/storage-namespaced-renamed-retyped.test.d.ts.map +1 -0
  23. package/dist/storage-namespaced-renamed-retyped.test.js +278 -0
  24. package/dist/storage-namespaced-renamed-retyped.test.js.map +1 -0
  25. package/dist/storage-struct-member-renamed-retyped.test.d.ts +2 -0
  26. package/dist/storage-struct-member-renamed-retyped.test.d.ts.map +1 -0
  27. package/dist/storage-struct-member-renamed-retyped.test.js +256 -0
  28. package/dist/storage-struct-member-renamed-retyped.test.js.map +1 -0
  29. package/dist/utils/annotations.d.ts +1 -0
  30. package/dist/utils/annotations.d.ts.map +1 -1
  31. package/dist/utils/annotations.js +4 -1
  32. package/dist/utils/annotations.js.map +1 -1
  33. package/dist/utils/erc7201-builtin.d.ts +24 -0
  34. package/dist/utils/erc7201-builtin.d.ts.map +1 -0
  35. package/dist/utils/erc7201-builtin.js +83 -0
  36. package/dist/utils/erc7201-builtin.js.map +1 -0
  37. package/dist/utils/erc7201.d.ts.map +1 -1
  38. package/dist/utils/erc7201.js +5 -3
  39. package/dist/utils/erc7201.js.map +1 -1
  40. package/dist/utils/erc7201.test.js +10 -0
  41. package/dist/utils/erc7201.test.js.map +1 -1
  42. package/dist/utils/integer-literals.d.ts +19 -6
  43. package/dist/utils/integer-literals.d.ts.map +1 -1
  44. package/dist/utils/integer-literals.js +28 -10
  45. package/dist/utils/integer-literals.js.map +1 -1
  46. package/dist/utils/integer-literals.test.js +12 -0
  47. package/dist/utils/integer-literals.test.js.map +1 -1
  48. package/dist/utils/struct-member-docs.d.ts +52 -0
  49. package/dist/utils/struct-member-docs.d.ts.map +1 -0
  50. package/dist/utils/struct-member-docs.js +264 -0
  51. package/dist/utils/struct-member-docs.js.map +1 -0
  52. package/dist/utils/struct-member-docs.test.d.ts +2 -0
  53. package/dist/utils/struct-member-docs.test.d.ts.map +1 -0
  54. package/dist/utils/struct-member-docs.test.js +320 -0
  55. package/dist/utils/struct-member-docs.test.js.map +1 -0
  56. package/dist/validate/data.d.ts +1 -1
  57. package/dist/validate/data.d.ts.map +1 -1
  58. package/dist/validate/data.js +1 -1
  59. package/dist/validate/run.d.ts +1 -1
  60. package/dist/validate/run.d.ts.map +1 -1
  61. package/dist/validate/run.js +22 -2
  62. package/dist/validate/run.js.map +1 -1
  63. package/package.json +5 -5
  64. package/src/cli/validate/validate-upgrade-safety.test.ts +35 -0
  65. package/src/cli/validate/validate-upgrade-safety.test.ts.md +10 -0
  66. package/src/cli/validate/validate-upgrade-safety.test.ts.snap +0 -0
  67. package/src/storage/compare.ts +3 -4
  68. package/src/storage/extract.ts +83 -30
  69. package/src/storage/namespace.ts +31 -14
  70. package/src/storage/report-rename-retype.test.ts +41 -0
  71. package/src/storage/report.ts +9 -1
  72. package/src/storage-erc7201-builtin.test.ts +127 -0
  73. package/src/storage-namespaced-renamed-retyped.test.ts +338 -0
  74. package/src/storage-namespaced-renamed-retyped.test.ts.md +183 -0
  75. package/src/storage-namespaced-renamed-retyped.test.ts.snap +0 -0
  76. package/src/storage-struct-member-renamed-retyped.test.ts +299 -0
  77. package/src/storage-struct-member-renamed-retyped.test.ts.md +260 -0
  78. package/src/storage-struct-member-renamed-retyped.test.ts.snap +0 -0
  79. package/src/utils/annotations.ts +4 -4
  80. package/src/utils/erc7201-builtin.ts +91 -0
  81. package/src/utils/erc7201.test.ts +12 -0
  82. package/src/utils/erc7201.ts +6 -4
  83. package/src/utils/integer-literals.test.ts +15 -1
  84. package/src/utils/integer-literals.ts +29 -10
  85. package/src/utils/struct-member-docs.test.ts +351 -0
  86. package/src/utils/struct-member-docs.ts +293 -0
  87. package/src/validate/data.ts +2 -2
  88. package/src/validate/run.ts +23 -1
@@ -1,5 +1,7 @@
1
1
  import { keccak256 } from 'ethereumjs-util';
2
2
 
3
+ import { toBytes32Hex } from './integer-literals';
4
+
3
5
  export const ERC7201_FORMULA_PREFIX = 'erc7201:';
4
6
 
5
7
  /**
@@ -12,14 +14,14 @@ export const ERC7201_FORMULA_PREFIX = 'erc7201:';
12
14
  export function calculateERC7201StorageLocation(id: string): string {
13
15
  const firstHash = keccak256(Buffer.from(id));
14
16
  const minusOne = BigInt(`0x${firstHash.toString('hex')}`) - 1n;
15
- const minusOneBuffer = Buffer.from(minusOne.toString(16), 'hex');
17
+ // Pad to 64 hex chars so that `Buffer.from` doesn't silently truncate odd-length input;
18
+ // `abi.encode(uint256)` is always 32 bytes.
19
+ const minusOneBuffer = Buffer.from(minusOne.toString(16).padStart(64, '0'), 'hex');
16
20
 
17
21
  const secondHash = keccak256(minusOneBuffer);
18
22
 
19
23
  const mask = BigInt('0xff');
20
24
  const masked = BigInt(`0x${secondHash.toString('hex')}`) & ~mask;
21
25
 
22
- const padded = masked.toString(16).padStart(64, '0');
23
-
24
- return `0x${padded}`;
26
+ return toBytes32Hex(masked);
25
27
  }
@@ -1,6 +1,6 @@
1
1
  import test from 'ava';
2
2
 
3
- import { normalizeUint256Literal } from './integer-literals';
3
+ import { normalizeUint256Literal, toBytes32Hex } from './integer-literals';
4
4
 
5
5
  const ZEROES = '0x0000000000000000000000000000000000000000000000000000000000000000';
6
6
 
@@ -71,3 +71,17 @@ test('full hex with leading and trailing zeroes', t => {
71
71
  '0x00ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00',
72
72
  );
73
73
  });
74
+
75
+ test('rejects values above uint256 max', t => {
76
+ const tooLarge = '0x10000000000000000000000000000000000000000000000000000000000000000';
77
+ const error = t.throws(() => normalizeUint256Literal(tooLarge));
78
+ t.regex(error?.message ?? '', /uint256 range/);
79
+ });
80
+
81
+ test('toBytes32Hex - boundaries', t => {
82
+ const MAX_HEX = '0x' + 'f'.repeat(64);
83
+ t.is(toBytes32Hex(0n), '0x' + '0'.repeat(64));
84
+ t.is(toBytes32Hex(BigInt(MAX_HEX)), MAX_HEX);
85
+ t.throws(() => toBytes32Hex(BigInt(MAX_HEX) + 1n), { message: /uint256 range/ });
86
+ t.throws(() => toBytes32Hex(-1n), { message: /uint256 range/ });
87
+ });
@@ -1,21 +1,42 @@
1
1
  import BigNumber from 'bignumber.js';
2
2
 
3
+ const MAX_UINT256 = (1n << 256n) - 1n;
4
+
5
+ /**
6
+ * Encodes a bigint as a 0x-prefixed, 64-char lowercase hex string — the canonical text encoding of
7
+ * a `bytes32` value, used throughout this package for storage slots and similar 256-bit identifiers.
8
+ * Throws if the value is outside the 0..2^256-1 range, since that almost certainly indicates a bug
9
+ * upstream (overflow, wrong hash, etc.) and would silently produce a string longer than 64 chars.
10
+ *
11
+ * @param value bigint value to encode
12
+ * @returns 0x-prefixed, 64-char lowercase hex string
13
+ */
14
+ export function toBytes32Hex(value: bigint): string {
15
+ if (value < 0n || value > MAX_UINT256) {
16
+ throw new Error(`Value out of uint256 range: ${value}`);
17
+ }
18
+ return `0x${value.toString(16).padStart(64, '0')}`;
19
+ }
20
+
3
21
  /**
4
- * Converts a Solidity unsigned integer literal according to https://docs.soliditylang.org/en/latest/types.html#rational-literals
5
- * to a 64-byte padded hex string.
22
+ * Parses a Solidity unsigned integer literal source string (per
23
+ * https://docs.soliditylang.org/en/latest/types.html#rational-literals) and returns it in the
24
+ * canonical hex form produced by {@link toBytes32Hex}.
6
25
  *
7
- * Accepts decimal, hexadecimal, and scientific notation formats.
26
+ * Parsing is delegated to {@link BigNumber}, which accepts decimal (`1000`), hex (`0x1f`), and
27
+ * scientific notation (`1e18`). Underscore separators and uppercase digits, which BigNumber rejects
28
+ * but Solidity allows, are stripped/lowercased first. Non-integer results (e.g. `1.5e3`) are rejected.
8
29
  *
9
- * If the input is null or undefined, returns '0x0000000000000000000000000000000000000000000000000000000000000000'.
30
+ * If the input is null or undefined, returns the canonical hex form of zero.
10
31
  *
11
32
  * Assumes the input is a valid uint256-compatible literal for Solidity, and only performs minimal validation.
12
33
  *
13
- * @param literal Integer literal to convert
14
- * @returns Hex string with 0x prefix, padded to 64 bytes with leading zeroes
34
+ * @param literal Integer literal source string to parse
35
+ * @returns 0x-prefixed, 64-char lowercase hex string
15
36
  */
16
37
  export function normalizeUint256Literal(literal: string | null | undefined): string {
17
38
  if (!literal) {
18
- return '0x0000000000000000000000000000000000000000000000000000000000000000';
39
+ return toBytes32Hex(0n);
19
40
  }
20
41
  const lowercaseNoUnderscores = literal.replace(/_/g, '').toLowerCase();
21
42
 
@@ -23,7 +44,5 @@ export function normalizeUint256Literal(literal: string | null | undefined): str
23
44
  if (!parsed.isInteger()) {
24
45
  throw new Error(`Invalid integer literal: ${literal}`);
25
46
  }
26
- const calculatedValueAsHex = parsed.toString(16);
27
- const padded = calculatedValueAsHex.padStart(64, '0');
28
- return `0x${padded}`;
47
+ return toBytes32Hex(BigInt(parsed.toFixed()));
29
48
  }
@@ -0,0 +1,351 @@
1
+ import test, { ExecutionContext } from 'ava';
2
+ import { artifacts, run } from 'hardhat';
3
+ import {
4
+ TASK_COMPILE_SOLIDITY_GET_SOLC_BUILD,
5
+ TASK_COMPILE_SOLIDITY_RUN_SOLC,
6
+ TASK_COMPILE_SOLIDITY_RUN_SOLCJS,
7
+ } from 'hardhat/builtin-tasks/task-names';
8
+ import { SolcBuild } from 'hardhat/types/builtin-tasks';
9
+ import { promises as fs } from 'fs';
10
+ import { ContractDefinition, StructDefinition, VariableDeclaration } from 'solidity-ast';
11
+ import { findAll } from 'solidity-ast/utils';
12
+ import * as versions from 'compare-versions';
13
+
14
+ import { SolcInput, SolcOutput } from '../solc-api';
15
+ import { solcInputOutputDecoder } from '../src-decoder';
16
+ import { getRetypedRenamedFromDoc } from '../storage/extract';
17
+ import {
18
+ getDocumentationBefore,
19
+ getStructMemberDocumentation,
20
+ makeStructDocsResolver,
21
+ UnexpectedStructSourceError,
22
+ } from './struct-member-docs';
23
+
24
+ const twinsSource = 'contracts/test/StructMemberDocs.sol';
25
+
26
+ function getPositions(node: { src: string }) {
27
+ const [start, length] = node.src.split(':').map(Number);
28
+ return { start, end: start + length };
29
+ }
30
+
31
+ async function getBuildInfo(source: string, contract: string) {
32
+ const buildInfo = await artifacts.getBuildInfo(`${source}:${contract}`);
33
+ if (buildInfo === undefined) {
34
+ throw new Error('Build info not found');
35
+ }
36
+ return buildInfo;
37
+ }
38
+
39
+ function getSource(input: SolcInput, source: string): Buffer {
40
+ const content = input.sources[source].content;
41
+ if (content === undefined) {
42
+ throw new Error(`Content for ${source} not available`);
43
+ }
44
+ return Buffer.from(content, 'utf8');
45
+ }
46
+
47
+ function getContract(output: SolcOutput, source: string, name: string): ContractDefinition {
48
+ const contractDef = [...findAll('ContractDefinition', output.sources[source].ast)].find(c => c.name === name);
49
+ if (contractDef === undefined) {
50
+ throw new Error(`Contract ${name} not found`);
51
+ }
52
+ return contractDef;
53
+ }
54
+
55
+ function getStruct(contractDef: ContractDefinition, name: string): StructDefinition {
56
+ const structDef = [...findAll('StructDefinition', contractDef)].find(s => s.name === name);
57
+ if (structDef === undefined) {
58
+ throw new Error(`Struct ${name} not found`);
59
+ }
60
+ return structDef;
61
+ }
62
+
63
+ /**
64
+ * Asserts that each member of the struct in the twin fixture has documentation exactly when the state variable with
65
+ * the same name has, with the same annotations.
66
+ */
67
+ function assertTwins(t: ExecutionContext, input: SolcInput, output: SolcOutput) {
68
+ const contractDef = getContract(output, twinsSource, 'StructMemberDocs');
69
+ const stateVars: VariableDeclaration[] = [];
70
+ for (const node of contractDef.nodes) {
71
+ if (node.nodeType === 'VariableDeclaration') {
72
+ stateVars.push(node);
73
+ }
74
+ }
75
+ const structDef = getStruct(contractDef, 'S');
76
+ const names = structDef.members.map(m => m.name);
77
+ t.deepEqual(
78
+ stateVars.map(v => v.name),
79
+ names,
80
+ );
81
+
82
+ const expected = new Map<string, string>();
83
+ for (const stateVar of stateVars) {
84
+ if (stateVar.documentation) {
85
+ expected.set(stateVar.name, stateVar.documentation.text);
86
+ }
87
+ }
88
+ t.true(expected.size >= 25);
89
+
90
+ const docs = getStructMemberDocumentation(structDef, getSource(input, twinsSource));
91
+ t.deepEqual([...docs.keys()], [...expected.keys()]);
92
+ for (const name of names) {
93
+ t.deepEqual(
94
+ getRetypedRenamedFromDoc(docs.get(name) ?? ''),
95
+ getRetypedRenamedFromDoc(expected.get(name) ?? ''),
96
+ name,
97
+ );
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Compiles the twin fixture again with the given line endings.
103
+ */
104
+ async function compileTwins(lineEnding: string) {
105
+ const { input, solcVersion } = await getBuildInfo(twinsSource, 'StructMemberDocs');
106
+ const content = getSource(input, twinsSource)
107
+ .toString('utf8')
108
+ .replace(/\r\n|\r|\n/g, lineEnding);
109
+ const newInput = { ...input, sources: { [twinsSource]: { content } } };
110
+ return { input: newInput, output: await hardhatCompile(newInput, solcVersion) };
111
+ }
112
+
113
+ async function hardhatCompile(input: SolcInput, solcVersion: string): Promise<SolcOutput> {
114
+ const solcBuild: SolcBuild = await run(TASK_COMPILE_SOLIDITY_GET_SOLC_BUILD, {
115
+ quiet: true,
116
+ solcVersion,
117
+ });
118
+
119
+ const output: SolcOutput = solcBuild.isSolcJs
120
+ ? await run(TASK_COMPILE_SOLIDITY_RUN_SOLCJS, { input, solcJsPath: solcBuild.compilerPath })
121
+ : await run(TASK_COMPILE_SOLIDITY_RUN_SOLC, { input, solcPath: solcBuild.compilerPath });
122
+
123
+ const errors = output.errors?.filter(e => e.severity === 'error');
124
+ if (errors !== undefined && errors.length > 0) {
125
+ throw new Error(errors.map(e => e.formattedMessage).join('\n'));
126
+ }
127
+ return output;
128
+ }
129
+
130
+ test('member documentation has the same annotations as state variable documentation', async t => {
131
+ const { input, output } = await getBuildInfo(twinsSource, 'StructMemberDocs');
132
+ // The fixture has a line of only whitespace between two doc lines, and a doc block line of a star and a space
133
+ const content = getSource(input, twinsSource).toString('utf8');
134
+ t.regex(content, /\n[ \t]+\r?\n/);
135
+ t.regex(content, /\n[ \t]+\* \r?\n/);
136
+ assertTwins(t, input, output);
137
+ });
138
+
139
+ test('member documentation with CRLF line endings', async t => {
140
+ const { input, output } = await compileTwins('\r\n');
141
+ assertTwins(t, input, output);
142
+ });
143
+
144
+ test('member documentation with CR line endings', async t => {
145
+ const { input, output } = await compileTwins('\r');
146
+ assertTwins(t, input, output);
147
+ });
148
+
149
+ test('member documentation', async t => {
150
+ const source = 'contracts/test/StructMemberRenamedRetyped.sol';
151
+ const { input, output } = await getBuildInfo(source, 'StructMemberRenamedRetypedV2');
152
+ const contractDef = getContract(output, source, 'StructMemberRenamedRetypedV2');
153
+ const content = getSource(input, source);
154
+ t.deepEqual(
155
+ getStructMemberDocumentation(getStruct(contractDef, 'S'), content),
156
+ new Map([
157
+ ['x', '@custom:oz-retyped-from bytes32'],
158
+ ['z', ' @dev documented in a block\n @custom:oz-renamed-from y'],
159
+ ['f', '@custom:oz-retyped-from uint8'],
160
+ ['d', '@custom:oz-renamed-from oldD'],
161
+ ]),
162
+ );
163
+ t.deepEqual(
164
+ getStructMemberDocumentation(getStruct(contractDef, 'Inner'), content),
165
+ new Map([
166
+ ['g', '@custom:oz-renamed-from a\n@custom:oz-retyped-from uint8 '],
167
+ ['bb', '@custom:oz-renamed-from b\n @dev documented on a second line'],
168
+ ]),
169
+ );
170
+ t.deepEqual(getStructMemberDocumentation(getStruct(contractDef, 'Outer'), content), new Map());
171
+ });
172
+
173
+ test('struct shapes', async t => {
174
+ const { input, output } = await getBuildInfo(twinsSource, 'StructMemberDocs');
175
+ const contractDef = getContract(output, twinsSource, 'StructMemberDocs');
176
+ const source = getSource(input, twinsSource);
177
+ const docs = (name: string) => Object.fromEntries(getStructMemberDocumentation(getStruct(contractDef, name), source));
178
+ t.deepEqual(docs('HeaderComments'), {});
179
+ t.deepEqual(docs('BraceOnNextLine'), { first: '@custom:oz-renamed-from a' });
180
+ t.deepEqual(docs('OneLine'), {});
181
+ t.deepEqual(docs('DanglingDoc'), {});
182
+ });
183
+
184
+ /**
185
+ * Yields the build info of every compilation of the fixtures.
186
+ */
187
+ async function* getBuildInfos(): AsyncGenerator<{ solcVersion: string; input: SolcInput; output: SolcOutput }> {
188
+ for (const buildInfoPath of await artifacts.getBuildInfoPaths()) {
189
+ yield JSON.parse(await fs.readFile(buildInfoPath, 'utf8'));
190
+ }
191
+ }
192
+
193
+ test('parity with solc documentation of state variables', async t => {
194
+ // The annotations read from the source must be those that solc reads from the same comments, for every solc version
195
+ // used by the fixtures. State variables are compared since solc includes their documentation in the AST. The texts
196
+ // are compared as well, except for whitespace at their ends, which is where the text read from the source can differ.
197
+ let compared = 0;
198
+ for await (const buildInfo of getBuildInfos()) {
199
+ // Solidity 0.5 does not include documentation in the AST
200
+ const hasDocumentation = versions.compare(buildInfo.solcVersion, '0.6.0', '>=');
201
+ for (const [sourcePath, { ast }] of Object.entries(buildInfo.output.sources)) {
202
+ const source = getSource(buildInfo.input, sourcePath);
203
+ for (const contractDef of findAll('ContractDefinition', ast)) {
204
+ // The first declaration is preceded by the opening brace of the contract
205
+ let prevEnd = source.indexOf('{', getPositions(contractDef).start) + 1;
206
+ for (const node of contractDef.nodes) {
207
+ const { start, end } = getPositions(node);
208
+ if (node.nodeType === 'VariableDeclaration') {
209
+ const description = `${buildInfo.solcVersion} ${sourcePath}:${contractDef.name}.${node.name}`;
210
+ let actual;
211
+ try {
212
+ actual = getDocumentationBefore(source, prevEnd, start);
213
+ } catch (e) {
214
+ t.fail(`${description}: ${e}`);
215
+ }
216
+ if (hasDocumentation) {
217
+ const expected = node.documentation?.text;
218
+ t.is(actual?.trim(), expected?.trim(), description);
219
+ t.deepEqual(
220
+ getRetypedRenamedFromDoc(actual ?? ''),
221
+ getRetypedRenamedFromDoc(expected ?? ''),
222
+ description,
223
+ );
224
+ if (expected !== undefined) {
225
+ compared += 1;
226
+ }
227
+ }
228
+ }
229
+ prevEnd = skipSemicolon(source, end);
230
+ }
231
+ }
232
+ }
233
+ }
234
+ t.true(compared >= 50, `Compared ${compared} documented state variables`);
235
+ });
236
+
237
+ /**
238
+ * The source range of some declarations, such as state variables, does not include the `;` that ends them, nor the
239
+ * whitespace and comments before it.
240
+ */
241
+ function skipSemicolon(source: Buffer, pos: number): number {
242
+ const match = /^(?:[ \t\r\n]|\/\/[^\r\n]*|\/\*[^]*?\*\/)*;/.exec(source.toString('latin1', pos));
243
+ return match === null ? pos : pos + match[0].length;
244
+ }
245
+
246
+ test('struct definitions of every fixture', async t => {
247
+ // The source of every struct must have the shape that the scanner expects, whichever solc version compiled it, and
248
+ // a struct without doc comments has no member documentation
249
+ let checked = 0;
250
+ for await (const buildInfo of getBuildInfos()) {
251
+ for (const [sourcePath, { ast }] of Object.entries(buildInfo.output.sources)) {
252
+ const source = getSource(buildInfo.input, sourcePath);
253
+ for (const structDef of findAll('StructDefinition', ast)) {
254
+ const description = `${buildInfo.solcVersion} ${sourcePath}:${structDef.canonicalName}`;
255
+ let docs;
256
+ try {
257
+ docs = getStructMemberDocumentation(structDef, source);
258
+ } catch (e) {
259
+ t.fail(`${description}: ${e}`);
260
+ continue;
261
+ }
262
+ const { start, end } = getPositions(structDef);
263
+ const text = source.toString('utf8', start, end);
264
+ if (!text.includes('///') && !text.includes('/**')) {
265
+ t.is(docs.size, 0, description);
266
+ }
267
+ checked += 1;
268
+ }
269
+ }
270
+ }
271
+ t.true(checked >= 100, `Checked ${checked} structs`);
272
+ });
273
+
274
+ test('struct not in the original compilation', async t => {
275
+ const source = 'contracts/test/StructMemberRenamedRetyped.sol';
276
+ const buildInfo = await getBuildInfo(source, 'StructMemberRenamedRetypedV2');
277
+ const decodeSrc = solcInputOutputDecoder(buildInfo.input, buildInfo.output);
278
+ const resolver = makeStructDocsResolver(buildInfo.input, buildInfo.output, decodeSrc);
279
+ const [structDef] = findAll('StructDefinition', buildInfo.output.sources[source].ast);
280
+ t.true(resolver(structDef) instanceof Map);
281
+ t.throws(() => resolver({ ...structDef, canonicalName: 'Unknown.S' }), { message: /Broken invariant/ });
282
+ });
283
+
284
+ test('unexpected text', t => {
285
+ const source = Buffer.from('/* a */ /// b\n uint8 x /// c\n', 'utf8');
286
+ t.is(getDocumentationBefore(source, 0, source.indexOf('uint8')), 'b');
287
+
288
+ const error = t.throws(() => getDocumentationBefore(source, 0, source.length), {
289
+ instanceOf: UnexpectedStructSourceError,
290
+ });
291
+ t.is(error?.position, source.indexOf('uint8'));
292
+
293
+ // Only spaces, tabs and line breaks are whitespace
294
+ for (const char of ['\u00a0', '\f']) {
295
+ const text = Buffer.from(`/// a\n${char}uint8 x`, 'utf8');
296
+ const error = t.throws(() => getDocumentationBefore(text, 0, text.indexOf('uint8')), {
297
+ instanceOf: UnexpectedStructSourceError,
298
+ });
299
+ t.is(error?.position, text.indexOf(char));
300
+ }
301
+
302
+ t.throws(() => getDocumentationBefore(Buffer.from('/** a\n'), 0, 6), { instanceOf: UnexpectedStructSourceError });
303
+ t.throws(() => getDocumentationBefore(Buffer.from('/* a\n'), 0, 5), { instanceOf: UnexpectedStructSourceError });
304
+ });
305
+
306
+ /**
307
+ * Builds the AST of a struct from its source text, in which each member declaration is enclosed in brackets that are
308
+ * not part of the source. The source range of the struct is the whole text.
309
+ */
310
+ function makeStruct(name: string, text: string): { structDef: StructDefinition; source: Buffer } {
311
+ const members = [];
312
+ let source = '';
313
+ for (const [i, part] of text.split(/[[\]]/).entries()) {
314
+ // The parts at odd indexes are the member declarations
315
+ if (i % 2 === 1) {
316
+ const src = `${Buffer.byteLength(source)}:${Buffer.byteLength(part)}:0`;
317
+ members.push({ nodeType: 'VariableDeclaration', name: part.split(/\s+/).pop(), src });
318
+ }
319
+ source += part;
320
+ }
321
+ const structDef = { nodeType: 'StructDefinition', name, src: `0:${Buffer.byteLength(source)}:0`, members };
322
+ return { structDef: structDef as unknown as StructDefinition, source: Buffer.from(source, 'utf8') };
323
+ }
324
+
325
+ function assertUnexpectedStruct(t: ExecutionContext, name: string, text: string, position: (source: Buffer) => number) {
326
+ const { structDef, source } = makeStruct(name, text);
327
+ const error = t.throws(() => getStructMemberDocumentation(structDef, source), {
328
+ instanceOf: UnexpectedStructSourceError,
329
+ });
330
+ t.is(error?.position, position(source), text);
331
+ }
332
+
333
+ test('unexpected struct source', t => {
334
+ const { structDef, source } = makeStruct('S', 'struct S {\n /// a\n [uint256 x];\n [uint256 y] /// b\n ; /// c\n}');
335
+ t.deepEqual(getStructMemberDocumentation(structDef, source), new Map([['x', 'a']]));
336
+
337
+ assertUnexpectedStruct(t, 'S', 'contract S { [uint256 x]; }', () => 0);
338
+ assertUnexpectedStruct(t, 'T', 'struct S { [uint256 x]; }', s => s.indexOf('S'));
339
+ assertUnexpectedStruct(t, 'S', 'struct S [uint256 x]; }', s => s.indexOf('uint256'));
340
+ assertUnexpectedStruct(t, 'S', 'struct S { [uint256 x] [uint256 y]; }', s => s.indexOf('uint256 y'));
341
+ assertUnexpectedStruct(t, 'S', 'struct S { [uint256 x]; } ', s => s.indexOf('}') + 1);
342
+ assertUnexpectedStruct(t, 'S', 'struct S { [uint256 x]; \u00a0}', s => s.indexOf('\u00a0'));
343
+ assertUnexpectedStruct(t, 'S', 'struct S { /** a\n [uint256 x]; }', s => s.indexOf('/**'));
344
+
345
+ // A member that starts before the end of the previous member
346
+ structDef.members.reverse();
347
+ const error = t.throws(() => getStructMemberDocumentation(structDef, source), {
348
+ instanceOf: UnexpectedStructSourceError,
349
+ });
350
+ t.is(error?.position, source.indexOf('uint256 x'));
351
+ });