Skip to content

Commit f3f791c

Browse files
Merge pull request #719 from protofire/feat-foundry-no-block-rule
Feat foundry no block rule
2 parents 600d6ad + 4b0bd6e commit f3f791c

File tree

10 files changed

+534
-10
lines changed

10 files changed

+534
-10
lines changed

conf/rulesets/solhint-all.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ module.exports = Object.freeze({
6767
'gas-struct-packing': 'warn',
6868
'comprehensive-interface': 'warn',
6969
'duplicated-imports': 'warn',
70+
'foundry-no-block-time-number': ['warn', ['test', 'tests']],
7071
'import-path-check': ['warn', ['[~dependenciesPath]']],
7172
quotes: ['error', 'double'],
7273
'const-name-snakecase': 'warn',

docs/rules.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,12 @@ title: "Rule Index of Solhint"
6969

7070
## Miscellaneous
7171

72-
| Rule Id | Error | Recommended | Deprecated |
73-
| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ---------- |
74-
| [comprehensive-interface](./rules/miscellaneous/comprehensive-interface.md) | Check that all public or external functions are overridden. This is useful to make sure that the whole API is extracted in an interface. | | |
75-
| [import-path-check](./rules/miscellaneous/import-path-check.md) | Check if an import file exits in target path | $~~~~~~~~$✔️ | |
76-
| [quotes](./rules/miscellaneous/quotes.md) | Enforces the use of double or simple quotes as configured for string literals. Values must be 'single' or 'double'. | $~~~~~~~~$✔️ | |
72+
| Rule Id | Error | Recommended | Deprecated |
73+
| ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ---------- |
74+
| [comprehensive-interface](./rules/miscellaneous/comprehensive-interface.md) | Check that all public or external functions are overridden. This is useful to make sure that the whole API is extracted in an interface. | | |
75+
| [foundry-no-block-time-number](./rules/miscellaneous/foundry-no-block-time-number.md) | Warn on the use of block.timestamp / block.number inside Foundry test files; recommend vm.getBlockTimestamp() / vm.getBlockNumber(). | | |
76+
| [import-path-check](./rules/miscellaneous/import-path-check.md) | Check if an import file exits in target path | $~~~~~~~~$✔️ | |
77+
| [quotes](./rules/miscellaneous/quotes.md) | Enforces the use of double or simple quotes as configured for string literals. Values must be 'single' or 'double'. | $~~~~~~~~$✔️ | |
7778
7879

7980
## Security Rules
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
warning: "This is a dynamically generated file. Do not edit manually."
3+
layout: "default"
4+
title: "foundry-no-block-time-number | Solhint"
5+
---
6+
7+
# foundry-no-block-time-number
8+
![Category Badge](https://img.shields.io/badge/-Miscellaneous-informational)
9+
![Default Severity Badge warn](https://img.shields.io/badge/Default%20Severity-warn-yellow)
10+
11+
## Description
12+
Warn on the use of block.timestamp / block.number inside Foundry test files; recommend vm.getBlockTimestamp() / vm.getBlockNumber().
13+
14+
## Options
15+
This rule accepts an array of options:
16+
17+
| Index | Description | Default Value |
18+
| ----- | ---------------------------------------------------------------------------------------------- | ---------------- |
19+
| 0 | Rule severity. Must be one of "error", "warn", "off". | warn |
20+
| 1 | Array of folder names for solhint to execute (defaults to ["test","tests"], case-insensitive). | ["test","tests"] |
21+
22+
23+
### Example Config
24+
```json
25+
{
26+
"rules": {
27+
"foundry-no-block-time-number": [
28+
"warn",
29+
[
30+
"test",
31+
"tests"
32+
]
33+
]
34+
}
35+
}
36+
```
37+
38+
### Notes
39+
- This rule only runs for files located under the configured test directories (e.g., test/** or tests/**).
40+
41+
## Examples
42+
This rule does not have examples.
43+
44+
## Version
45+
This rule was introduced in the latest version.
46+
47+
## Resources
48+
- [Rule source](https://github.com/protofire/solhint/blob/master/lib/rules/miscellaneous/foundry-no-block-time-number.js)
49+
- [Document source](https://github.com/protofire/solhint/blob/master/docs/rules/miscellaneous/foundry-no-block-time-number.md)
50+
- [Test cases](https://github.com/protofire/solhint/blob/master/test/rules/miscellaneous/foundry-no-block-time-number.js)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
const path = require('path')
2+
const BaseChecker = require('../base-checker')
3+
const { severityDescription } = require('../../doc/utils')
4+
5+
const ruleId = 'foundry-no-block-time-number'
6+
7+
const DEFAULT_SEVERITY = 'warn'
8+
const DEFAULT_TEST_DIRS = ['test', 'tests'] // default folders considered as Foundry test roots
9+
10+
const meta = {
11+
type: 'miscellaneous',
12+
13+
docs: {
14+
description:
15+
'Warn on the use of block.timestamp / block.number inside Foundry test files; recommend vm.getBlockTimestamp() / vm.getBlockNumber().',
16+
category: 'Miscellaneous',
17+
options: [
18+
{
19+
description: severityDescription,
20+
default: DEFAULT_SEVERITY,
21+
},
22+
{
23+
description:
24+
'Array of folder names for solhint to execute (defaults to ["test","tests"], case-insensitive).',
25+
default: JSON.stringify(DEFAULT_TEST_DIRS),
26+
},
27+
],
28+
notes: [
29+
{
30+
note: 'This rule only runs for files located under the configured test directories (e.g., test/** or tests/**).',
31+
},
32+
],
33+
},
34+
35+
fixable: false,
36+
recommended: false,
37+
// defaultSetup: [severity, testDirs[]]
38+
defaultSetup: [DEFAULT_SEVERITY, DEFAULT_TEST_DIRS],
39+
40+
schema: {
41+
type: 'array',
42+
description: 'Array of folder names for solhint to execute the rule (case-insensitive).',
43+
items: { type: 'string', errorMessage: 'Each item must be a string' },
44+
},
45+
}
46+
47+
class FoundryNoBlockTimeNumberChecker extends BaseChecker {
48+
constructor(reporter, config, fileName) {
49+
super(reporter, ruleId, meta)
50+
51+
// Read array of folders from config. If invalid/empty, fallback to defaults.
52+
const arr = config ? config.getArray(ruleId) : []
53+
this.testDirs = Array.isArray(arr) && arr.length > 0 ? arr.slice() : DEFAULT_TEST_DIRS.slice()
54+
55+
this.fileName = fileName
56+
this.enabledForThisFile = this.isInAnyTestDir(fileName, this.testDirs)
57+
}
58+
59+
// Only evaluate the AST if the current file is inside a configured test directory.
60+
MemberAccess(node) {
61+
if (!this.enabledForThisFile) return
62+
63+
// Detect `block.timestamp` / `block.number`
64+
if (node && node.type === 'MemberAccess') {
65+
const expr = node.expression
66+
const member = node.memberName
67+
68+
if (expr && expr.type === 'Identifier' && expr.name === 'block') {
69+
if (member === 'timestamp') {
70+
this.error(
71+
node,
72+
'Avoid `block.timestamp` in Foundry tests. Use `vm.getBlockTimestamp()` instead.'
73+
)
74+
} else if (member === 'number') {
75+
this.error(
76+
node,
77+
'Avoid `block.number` in Foundry tests. Use `vm.getBlockNumber()` instead.'
78+
)
79+
}
80+
}
81+
}
82+
}
83+
84+
// ---------- helpers ----------
85+
isInAnyTestDir(fileName, testDirs) {
86+
try {
87+
// Make the path relative to the project root to compare path segments
88+
const rel = path.relative(process.cwd(), fileName)
89+
const norm = path.normalize(rel) // normalize separators for Win/Linux
90+
const segments = norm.split(path.sep).map((s) => s.toLowerCase())
91+
92+
// Match by exact directory segment (case-insensitive)
93+
const wanted = new Set(testDirs.map((d) => String(d).toLowerCase()))
94+
return segments.some((seg) => wanted.has(seg))
95+
} catch (_) {
96+
// Fail-safe: if anything goes wrong, do not enable the rule for this file.
97+
return false
98+
}
99+
}
100+
}
101+
102+
module.exports = FoundryNoBlockTimeNumberChecker

lib/rules/miscellaneous/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ const QuotesChecker = require('./quotes')
22
const ComprehensiveInterfaceChecker = require('./comprehensive-interface')
33
const DuplicatedImportsChecker = require('./duplicated-imports')
44
const ImportPathChecker = require('./import-path-check')
5+
const FoundryNoBlockTimeNumberChecker = require('./foundry-no-block-time-number')
56

67
module.exports = function checkers(reporter, config, tokens, fileName) {
78
return [
89
new QuotesChecker(reporter, config, tokens),
910
new ComprehensiveInterfaceChecker(reporter, config, tokens),
1011
new DuplicatedImportsChecker(reporter),
1112
new ImportPathChecker(reporter, config, fileName),
13+
new FoundryNoBlockTimeNumberChecker(reporter, config, fileName),
1214
]
1315
}

lib/rules/naming/foundry-test-function-naming.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ const meta = {
7171
},
7272
}
7373

74-
class FoundryTestFunctionNaming extends BaseChecker {
74+
class FoundryTestFunctionNamingChecker extends BaseChecker {
7575
constructor(reporter, config) {
7676
super(reporter, ruleId, meta)
7777
this.skippedFunctions = config
@@ -97,4 +97,4 @@ class FoundryTestFunctionNaming extends BaseChecker {
9797
}
9898
}
9999

100-
module.exports = FoundryTestFunctionNaming
100+
module.exports = FoundryTestFunctionNamingChecker

lib/rules/naming/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const FunctionNamedParametersChecker = require('./func-named-parameters')
1414
// 👇 old (alias with deprecation)
1515
const FoundryTestFunctionsChecker = require('./foundry-test-functions')
1616
// 👇 new name
17-
const FoundryTestFunctionNaming = require('./foundry-test-function-naming')
17+
const FoundryTestFunctionNamingChecker = require('./foundry-test-function-naming')
1818

1919
module.exports = function checkers(reporter, config) {
2020
return [
@@ -33,6 +33,6 @@ module.exports = function checkers(reporter, config) {
3333

3434
// 👇 call both
3535
new FoundryTestFunctionsChecker(reporter, config),
36-
new FoundryTestFunctionNaming(reporter, config),
36+
new FoundryTestFunctionNamingChecker(reporter, config),
3737
]
3838
}

test/common/config-validator.js

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,4 +580,141 @@ describe('Better errors addition + rule disable on error', () => {
580580
assert.ok(logged.includes("invalid configuration for rule 'use-natspec'"))
581581
assert.ok(warnSpy.called)
582582
})
583+
584+
//
585+
// ---- foundry-no-block-time-number: CONFIG VALIDATOR TESTS ----
586+
//
587+
588+
it('Valid CFG - accept: foundry-no-block-time-number with only severity (uses DEFAULT test dirs)', () => {
589+
const report = linter.processStr(dummyCode, {
590+
rules: { 'foundry-no-block-time-number': 'warn' },
591+
})
592+
593+
// Not asserting execution here (rule is directory-gated and dummy file path may not match),
594+
// we only assert the config is accepted and no reporter was used.
595+
assert.equal(report.errorCount, 0)
596+
assert.equal(report.warningCount, 0)
597+
assert.deepEqual(report.messages, [])
598+
599+
// No config warning should be printed
600+
sinon.assert.notCalled(warnSpy)
601+
sinon.assert.notCalled(reportErrorSpy)
602+
sinon.assert.notCalled(reportWarnSpy)
603+
})
604+
605+
it('Valid CFG - accept: foundry-no-block-time-number with custom test dirs array', () => {
606+
const report = linter.processStr(dummyCode, {
607+
rules: { 'foundry-no-block-time-number': ['warn', ['tests', 'e2e', 'it']] },
608+
})
609+
610+
// Only checking config acceptance (no execution guarantees here).
611+
assert.equal(report.errorCount, 0)
612+
assert.equal(report.warningCount, 0)
613+
assert.deepEqual(report.messages, [])
614+
615+
// No schema warning expected
616+
sinon.assert.notCalled(warnSpy)
617+
sinon.assert.notCalled(reportErrorSpy)
618+
sinon.assert.notCalled(reportWarnSpy)
619+
})
620+
621+
it('Invalid CFG - not execute: foundry-no-block-time-number when wrong value type in array is provided', () => {
622+
// Second arg must be an array of strings; here it's [1] (invalid element type)
623+
const report = linter.processStr(dummyCode, {
624+
rules: { 'foundry-no-block-time-number': ['error', [1]] },
625+
})
626+
627+
assert.equal(report.errorCount, 0)
628+
assert.equal(report.warningCount, 0)
629+
assert.deepEqual(report.messages, [])
630+
631+
const logged = warnSpy
632+
.getCalls()
633+
.map((c) => c.args[0])
634+
.join('\n')
635+
636+
assert.ok(
637+
logged.includes("invalid configuration for rule 'foundry-no-block-time-number'"),
638+
`Expected a warning for foundry-no-block-time-number but got:\n${logged}`
639+
)
640+
641+
assert.ok(warnSpy.called, 'console.warn should have been called')
642+
sinon.assert.notCalled(reportErrorSpy)
643+
sinon.assert.notCalled(reportWarnSpy)
644+
})
645+
646+
it('Invalid CFG - not execute: foundry-no-block-time-number when wrong option type is provided (string)', () => {
647+
// Second arg must be an array; here it's a string (invalid)
648+
const report = linter.processStr(dummyCode, {
649+
rules: { 'foundry-no-block-time-number': ['error', 'wrong'] },
650+
})
651+
652+
assert.equal(report.errorCount, 0)
653+
assert.equal(report.warningCount, 0)
654+
assert.deepEqual(report.messages, [])
655+
656+
const logged = warnSpy
657+
.getCalls()
658+
.map((c) => c.args[0])
659+
.join('\n')
660+
661+
assert.ok(
662+
logged.includes("invalid configuration for rule 'foundry-no-block-time-number'"),
663+
`Expected a warning for foundry-no-block-time-number but got:\n${logged}`
664+
)
665+
666+
assert.ok(warnSpy.called, 'console.warn should have been called')
667+
sinon.assert.notCalled(reportErrorSpy)
668+
sinon.assert.notCalled(reportWarnSpy)
669+
})
670+
671+
it('Invalid CFG - not execute: foundry-no-block-time-number when empty object is provided', () => {
672+
// Second arg must be an array; here it's an object (invalid)
673+
const report = linter.processStr(dummyCode, {
674+
rules: { 'foundry-no-block-time-number': ['error', {}] },
675+
})
676+
677+
assert.equal(report.errorCount, 0)
678+
assert.equal(report.warningCount, 0)
679+
assert.deepEqual(report.messages, [])
680+
681+
const logged = warnSpy
682+
.getCalls()
683+
.map((c) => c.args[0])
684+
.join('\n')
685+
686+
assert.ok(
687+
logged.includes("invalid configuration for rule 'foundry-no-block-time-number'"),
688+
`Expected a warning for foundry-no-block-time-number but got:\n${logged}`
689+
)
690+
691+
assert.ok(warnSpy.called, 'console.warn should have been called')
692+
sinon.assert.notCalled(reportErrorSpy)
693+
sinon.assert.notCalled(reportWarnSpy)
694+
})
695+
696+
it('Invalid CFG - not execute: foundry-no-block-time-number when array items are not strings (mixed types)', () => {
697+
// Mixed invalid item types
698+
const report = linter.processStr(dummyCode, {
699+
rules: { 'foundry-no-block-time-number': ['warn', ['tests', 123, null]] },
700+
})
701+
702+
assert.equal(report.errorCount, 0)
703+
assert.equal(report.warningCount, 0)
704+
assert.deepEqual(report.messages, [])
705+
706+
const logged = warnSpy
707+
.getCalls()
708+
.map((c) => c.args[0])
709+
.join('\n')
710+
711+
assert.ok(
712+
logged.includes("invalid configuration for rule 'foundry-no-block-time-number'"),
713+
`Expected a warning for foundry-no-block-time-number but got:\n${logged}`
714+
)
715+
716+
assert.ok(warnSpy.called, 'console.warn should have been called')
717+
sinon.assert.notCalled(reportErrorSpy)
718+
sinon.assert.notCalled(reportWarnSpy)
719+
})
583720
})

0 commit comments

Comments
 (0)