1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
import fs from 'fs'
import path from 'path'
const ROOT_DIR = path.join(__dirname, '..', '..')
const BASE_INVARIANTS_DIR = path.join(ROOT_DIR, 'test', 'invariants')
const BASE_DOCS_DIR = path.join(ROOT_DIR, 'invariant-docs')
const BASE_INVARIANT_GH_URL = '../test/invariants/'
const NATSPEC_INV = '@custom:invariant'
// Represents an invariant test contract
type Contract = {
name: string
fileName: string
docs: InvariantDoc[]
}
// Represents the documentation of an invariant
type InvariantDoc = {
header?: string
desc?: string
lineNo?: number
}
const writtenFiles = []
// Lazy-parses all test files in the `test/invariants` directory
// to generate documentation on all invariant tests.
const docGen = (dir: string): void => {
// Grab all files within the invariants test dir
const files = fs.readdirSync(dir)
// Array to store all found invariant documentation comments.
const docs: Contract[] = []
for (const fileName of files) {
// Read the contents of the invariant test file.
const fileContents = fs.readFileSync(path.join(dir, fileName)).toString()
// Split the file into individual lines and trim whitespace.
const lines = fileContents.split('\n').map((line: string) => line.trim())
// Create an object to store all invariant test docs for the current contract
const name = fileName.replace('.t.sol', '')
const contract: Contract = { name, fileName, docs: [] }
let currentDoc: InvariantDoc
// Loop through all lines to find comments.
for (let i = 0; i < lines.length; i++) {
let line = lines[i]
// We have an invariant doc
if (line.startsWith(`/// ${NATSPEC_INV}`)) {
// Assign the header of the invariant doc.
// TODO: Handle ambiguous case for `INVARIANT: ` prefix.
currentDoc = {
header: line.replace(`/// ${NATSPEC_INV}`, '').trim(),
desc: '',
}
// If the header is multi-line, continue appending to the `currentDoc`'s header.
line = lines[++i]
while (line.startsWith(`///`) && line.trim() !== '///') {
currentDoc.header += ` ${line.replace(`///`, '').trim()}`
line = lines[++i]
}
// Process the description
while ((line = lines[++i]).startsWith('///')) {
line = line.replace('///', '').trim()
// If the line has any contents, insert it into the desc.
// Otherwise, consider it a linebreak.
currentDoc.desc += line.length > 0 ? `${line} ` : '\n'
}
// Set the line number of the test
currentDoc.lineNo = i + 1
// Add the doc to the contract
contract.docs.push(currentDoc)
}
}
// Add the contract to the array of docs
docs.push(contract)
}
for (const contract of docs) {
const fileName = path.join(BASE_DOCS_DIR, `${contract.name}.md`)
const alreadyWritten = writtenFiles.includes(fileName)
// If the file has already been written, append the extra docs to the end.
// Otherwise, write the file from scratch.
fs.writeFileSync(
fileName,
alreadyWritten
? `${fs.readFileSync(fileName)}\n${renderContractDoc(contract, false)}`
: renderContractDoc(contract, true)
)
// If the file was just written for the first time, add it to the list of written files.
if (!alreadyWritten) {
writtenFiles.push(fileName)
}
}
console.log(
`Generated invariant test documentation for:\n - ${
docs.length
} contracts\n - ${docs.reduce(
(acc: number, contract: Contract) => acc + contract.docs.length,
0
)} invariant tests\nsuccessfully!`
)
}
// Generate a table of contents for all invariant docs and place it in the README.
const tocGen = (): void => {
const autoTOCPrefix = '<!-- START autoTOC -->\n'
const autoTOCPostfix = '<!-- END autoTOC -->\n'
// Grab the name of all markdown files in `BASE_DOCS_DIR` except for `README.md`.
const files = fs
.readdirSync(BASE_DOCS_DIR)
.filter((fileName: string) => fileName !== 'README.md')
// Generate a table of contents section.
const tocList = files
.map(
(fileName: string) => `- [${fileName.replace('.md', '')}](./${fileName})`
)
.join('\n')
const toc = `${autoTOCPrefix}\n## Table of Contents\n${tocList}\n${autoTOCPostfix}`
// Write the table of contents to the README.
const readmeContents = fs
.readFileSync(path.join(BASE_DOCS_DIR, 'README.md'))
.toString()
const above = readmeContents.split(autoTOCPrefix)[0]
const below = readmeContents.split(autoTOCPostfix)[1]
fs.writeFileSync(
path.join(BASE_DOCS_DIR, 'README.md'),
`${above}${toc}${below}`
)
}
// Render a `Contract` object into valid markdown.
const renderContractDoc = (contract: Contract, header: boolean): string => {
const _header = header ? `# \`${contract.name}\` Invariants\n` : ''
const docs = contract.docs
.map((doc: InvariantDoc) => {
const line = `${contract.fileName}#L${doc.lineNo}`
return `## ${doc.header}\n**Test:** [\`${line}\`](${BASE_INVARIANT_GH_URL}${line})\n\n${doc.desc}`
})
.join('\n\n')
return `${_header}\n${docs}`
}
// Generate the docs
// Forge
console.log('Generating docs for forge invariants...')
docGen(BASE_INVARIANTS_DIR)
// New line
console.log()
// Generate an updated table of contents
tocGen()