From c8cb910a1335ff9791f6070e6e0e9d2ca75fb43a Mon Sep 17 00:00:00 2001 From: myfreess Date: Mon, 3 Aug 2026 15:21:04 +0800 Subject: [PATCH 1/4] promote --- cli/pkg.generated.mbti | 1 - docs/pkg.generated.mbti | 1 - matching/pkg.generated.mbti | 1 - pkg.generated.mbti | 1 - query/pkg.generated.mbti | 1 - rule/apply/pkg.generated.mbti | 1 - rule/builtin/pkg.generated.mbti | 1 - rule/compile/pkg.generated.mbti | 1 - rule/internal/rules/moonbitlang/pkg.generated.mbti | 1 - rule/load/pkg.generated.mbti | 1 - rule/model/pkg.generated.mbti | 1 - rule/prefilter/pkg.generated.mbti | 1 - rule/taint_lowering/pkg.generated.mbti | 1 - taint/pkg.generated.mbti | 1 - untyped_ast/pkg.generated.mbti | 1 - 15 files changed, 15 deletions(-) diff --git a/cli/pkg.generated.mbti b/cli/pkg.generated.mbti index 6de86d5..1ca97c7 100644 --- a/cli/pkg.generated.mbti +++ b/cli/pkg.generated.mbti @@ -41,4 +41,3 @@ pub(all) struct CliOptions { // Type aliases // Traits - diff --git a/docs/pkg.generated.mbti b/docs/pkg.generated.mbti index a24ec07..8da9305 100644 --- a/docs/pkg.generated.mbti +++ b/docs/pkg.generated.mbti @@ -15,4 +15,3 @@ pub let writingrules_md : String // Type aliases // Traits - diff --git a/matching/pkg.generated.mbti b/matching/pkg.generated.mbti index 1b4c2a8..8cf9714 100644 --- a/matching/pkg.generated.mbti +++ b/matching/pkg.generated.mbti @@ -62,4 +62,3 @@ pub(all) struct IgnoredPatternField { // Type aliases // Traits - diff --git a/pkg.generated.mbti b/pkg.generated.mbti index a09063d..b20681b 100644 --- a/pkg.generated.mbti +++ b/pkg.generated.mbti @@ -10,4 +10,3 @@ package "moonbit-community/moongrep" // Type aliases // Traits - diff --git a/query/pkg.generated.mbti b/query/pkg.generated.mbti index 986945f..3dfb168 100644 --- a/query/pkg.generated.mbti +++ b/query/pkg.generated.mbti @@ -19,4 +19,3 @@ pub fn ExprQuery::captures_from_ast(Self, ast~ : @untyped_ast.Node) -> ArrayView // Type aliases // Traits - diff --git a/rule/apply/pkg.generated.mbti b/rule/apply/pkg.generated.mbti index e4c82b2..655ec54 100644 --- a/rule/apply/pkg.generated.mbti +++ b/rule/apply/pkg.generated.mbti @@ -24,4 +24,3 @@ pub fn ScanPlan::is_empty(Self) -> Bool // Type aliases // Traits - diff --git a/rule/builtin/pkg.generated.mbti b/rule/builtin/pkg.generated.mbti index 2afb49d..8e67268 100644 --- a/rule/builtin/pkg.generated.mbti +++ b/rule/builtin/pkg.generated.mbti @@ -15,4 +15,3 @@ pub fn load_rules() -> Array[@model.RawRuleSpec] raise // Type aliases // Traits - diff --git a/rule/compile/pkg.generated.mbti b/rule/compile/pkg.generated.mbti index aa1229d..0d3e2ad 100644 --- a/rule/compile/pkg.generated.mbti +++ b/rule/compile/pkg.generated.mbti @@ -15,4 +15,3 @@ pub fn compile_rules(Array[@model.RawRuleSpec]) -> Array[@model.CompiledRule] ra // Type aliases // Traits - diff --git a/rule/internal/rules/moonbitlang/pkg.generated.mbti b/rule/internal/rules/moonbitlang/pkg.generated.mbti index c54aa64..d07fb3d 100644 --- a/rule/internal/rules/moonbitlang/pkg.generated.mbti +++ b/rule/internal/rules/moonbitlang/pkg.generated.mbti @@ -29,4 +29,3 @@ pub let unnessary_else_yaml : String // Type aliases // Traits - diff --git a/rule/load/pkg.generated.mbti b/rule/load/pkg.generated.mbti index dd1e1e2..fe3964e 100644 --- a/rule/load/pkg.generated.mbti +++ b/rule/load/pkg.generated.mbti @@ -17,4 +17,3 @@ pub async fn load_rules(String) -> Array[@model.RawRuleSpec] // Type aliases // Traits - diff --git a/rule/model/pkg.generated.mbti b/rule/model/pkg.generated.mbti index aaedb1b..3158608 100644 --- a/rule/model/pkg.generated.mbti +++ b/rule/model/pkg.generated.mbti @@ -140,4 +140,3 @@ pub(all) enum TaintTarget { // Type aliases // Traits - diff --git a/rule/prefilter/pkg.generated.mbti b/rule/prefilter/pkg.generated.mbti index c6077df..8e13dd1 100644 --- a/rule/prefilter/pkg.generated.mbti +++ b/rule/prefilter/pkg.generated.mbti @@ -19,4 +19,3 @@ pub fn rule_is_relevant_to_source(@model.CompiledRule, String) -> Bool // Type aliases // Traits - diff --git a/rule/taint_lowering/pkg.generated.mbti b/rule/taint_lowering/pkg.generated.mbti index 90dc259..947ba05 100644 --- a/rule/taint_lowering/pkg.generated.mbti +++ b/rule/taint_lowering/pkg.generated.mbti @@ -16,4 +16,3 @@ pub fn lower_compiled_taint_rule(@model.CompiledRule, @model.CompiledTaintRule) // Type aliases // Traits - diff --git a/taint/pkg.generated.mbti b/taint/pkg.generated.mbti index 9e4e233..9a3aba9 100644 --- a/taint/pkg.generated.mbti +++ b/taint/pkg.generated.mbti @@ -143,4 +143,3 @@ pub(all) enum ValueSite { pub type TaintTree = Array[RelativeTaint] // Traits - diff --git a/untyped_ast/pkg.generated.mbti b/untyped_ast/pkg.generated.mbti index cbbb5bf..edece86 100644 --- a/untyped_ast/pkg.generated.mbti +++ b/untyped_ast/pkg.generated.mbti @@ -401,4 +401,3 @@ pub(all) struct ScopedExprNode { // Type aliases // Traits - From a881ae051567a204cf7dc8edcd44aba7fd67fdab Mon Sep 17 00:00:00 2001 From: myfreess Date: Mon, 3 Aug 2026 15:29:01 +0800 Subject: [PATCH 2/4] fix rule-writing guide commands --- TODO.md | 6 ------ docs/WritingRules.md | 24 ++++++++++-------------- docs/WritingRules_CN.md | 21 ++++++++------------- docs/writing_rules.mbt | 24 ++++++++++-------------- 4 files changed, 28 insertions(+), 47 deletions(-) diff --git a/TODO.md b/TODO.md index 66cd075..bf24ef7 100644 --- a/TODO.md +++ b/TODO.md @@ -8,12 +8,6 @@ an explicit kind for cross-position reuse, improving the diagnostic, or adding a storage-path metavariable kind. -- Correct the commands in the embedded rule-writing guides. They currently - recommend `moon run . -- scan ...`, which only runs moongrep when invoked - from the moongrep module. Document the installed `moongrep scan ...` and - Mooncakes/Wasm `moonx moonbit-community/moongrep -- scan ...` forms, and - keep `moon run .` in a contributor-only section if it is still needed. - - Define and document the contract for `#moongrep.skip`. It is currently recognized on functions, impls, top-level lets, tests, and views; ignores any payload, including `false`; and suppresses structural rules but not diff --git a/docs/WritingRules.md b/docs/WritingRules.md index 6e26b61..74e9709 100644 --- a/docs/WritingRules.md +++ b/docs/WritingRules.md @@ -602,10 +602,18 @@ different. ### 6. Run the scanner -Run `moongrep` from the module root with a rules directory: +Run the scanner from the root of the MoonBit module you want to scan. If +`moongrep` is installed, use: ```bash -moon run . -- scan [--verbose] --rules [scan-root] +moongrep scan [--verbose] --rules [scan-root] +``` + +To run the published WebAssembly CLI from Mooncakes without installing +`moongrep`, use: + +```bash +moonx moonbit-community/moongrep -- scan [--verbose] --rules [scan-root] ``` `--rules=` and `-r ` are accepted as equivalent forms. @@ -711,15 +719,3 @@ Check that `guard` is under a structural `patterns`, `patterns-not`, that every key names an `id` or `const` capture visible to that pattern. `exp`, `arg`, `pat`, and `type` captures cannot be guarded. Taint clauses reject `guard`. - -## Testing Workflow - -After changing a rule or rule behavior: - -1. run the scanner on a focused fixture with - `moon run . -- scan --rules ` -2. add or update focused tests under `rule/` -3. cover both a positive case and at least one nearby negative case - -Keep tests narrow. A good rule test proves the intended match and at least one -non-match that would be easy to regress. diff --git a/docs/WritingRules_CN.md b/docs/WritingRules_CN.md index 04cea61..a69973f 100644 --- a/docs/WritingRules_CN.md +++ b/docs/WritingRules_CN.md @@ -459,10 +459,16 @@ patterns: ### 6. 运行扫描器 -在模块根目录下使用规则目录运行 `moongrep`: +在要扫描的 MoonBit 模块根目录下运行扫描器。如果已安装 `moongrep`,请使用: ```bash -moon run . -- scan [--verbose] --rules [scan-root] +moongrep scan [--verbose] --rules [scan-root] +``` + +如果不安装 `moongrep`,而是通过 Mooncakes 运行已发布的 WebAssembly CLI,请使用: + +```bash +moonx moonbit-community/moongrep -- scan [--verbose] --rules [scan-root] ``` `--rules=` 和 `-r ` 是等价形式。如果省略 `scan-root`, @@ -559,14 +565,3 @@ patterns: 请检查 `guard` 是否位于结构规则的 `patterns`、`patterns-not`、`inside-expr` 或 `inside-toplevel` pattern object 下,是否是映射,并且每个键都引用了该 pattern 可见的 `id` 或 `const` 捕获。`exp`、`arg`、`pat`、`type` 和 ellipsis 捕获不能被 guard 过滤。taint 子句会拒绝 `guard`。 - -## 测试工作流 - -修改规则或规则行为后: - -1. 在聚焦 fixture 上运行扫描器: - `moon run . -- scan --rules ` -2. 在 `rule/` 下新增或更新聚焦测试 -3. 覆盖一个正例,以及至少一个容易回归的相近反例 - -保持测试范围窄。好的规则测试会证明预期匹配,以及至少一个容易回归的非匹配场景。 diff --git a/docs/writing_rules.mbt b/docs/writing_rules.mbt index ec6466b..05a8e76 100644 --- a/docs/writing_rules.mbt +++ b/docs/writing_rules.mbt @@ -606,10 +606,18 @@ let _embed_writingrules_md : String = #| #|### 6. Run the scanner #| - #|Run `moongrep` from the module root with a rules directory: + #|Run the scanner from the root of the MoonBit module you want to scan. If + #|`moongrep` is installed, use: #| #|```bash - #|moon run . -- scan [--verbose] --rules [scan-root] + #|moongrep scan [--verbose] --rules [scan-root] + #|``` + #| + #|To run the published WebAssembly CLI from Mooncakes without installing + #|`moongrep`, use: + #| + #|```bash + #|moonx moonbit-community/moongrep -- scan [--verbose] --rules [scan-root] #|``` #| #|`--rules=` and `-r ` are accepted as equivalent forms. @@ -716,18 +724,6 @@ let _embed_writingrules_md : String = #|`arg`, `pat`, and `type` captures cannot be guarded. Taint clauses reject #|`guard`. #| - #|## Testing Workflow - #| - #|After changing a rule or rule behavior: - #| - #|1. run the scanner on a focused fixture with - #| `moon run . -- scan --rules ` - #|2. add or update focused tests under `rule/` - #|3. cover both a positive case and at least one nearby negative case - #| - #|Keep tests narrow. A good rule test proves the intended match and at least one - #|non-match that would be easy to regress. - #| ///| pub let writingrules_md : String = _embed_writingrules_md From 542261ed5501bd1bbe3446c35c7baff7eae2be85 Mon Sep 17 00:00:00 2001 From: myfreess Date: Tue, 4 Aug 2026 10:32:51 +0800 Subject: [PATCH 3/4] tighten #moongrep.skip contract --- SKILL.md | 2 +- SKILL_CN.md | 2 +- TODO.md | 6 -- cli/json_render.mbt | 4 +- cli/json_render_wbtest.mbt | 12 ++- cli/render.mbt | 29 ++--- cli/scan.mbt | 153 ++++++++++++++++++--------- cli/scan_wbtest.mbt | 144 ++++++++++++++++++++----- cli/stream_output.mbt | 5 +- cli/stream_output_wbtest.mbt | 61 ++++++++++- docs/WritingRules.md | 31 +++++- docs/WritingRules_CN.md | 27 ++++- docs/writing_rules.mbt | 31 +++++- e2etests/SCAN.md | 29 +++++ testdata/skip-structural/src/hit.mbt | 15 +++ 15 files changed, 440 insertions(+), 111 deletions(-) diff --git a/SKILL.md b/SKILL.md index 95207c8..30ef1d5 100644 --- a/SKILL.md +++ b/SKILL.md @@ -143,7 +143,7 @@ moongrep scan --pattern 'inspect($_, content="true")' --output-json ``` JSON match records are written to standard output, one per line. Verbose -traversal messages and parse warnings are written to standard error. When no +traversal messages and scan warnings are written to standard error. When no match is found, JSON mode writes nothing to standard output. ## Pattern Guards diff --git a/SKILL_CN.md b/SKILL_CN.md index 6ef6b59..b1567c4 100644 --- a/SKILL_CN.md +++ b/SKILL_CN.md @@ -112,7 +112,7 @@ user.name == other.name moongrep scan --pattern 'inspect($_, content="true")' --output-json ``` -JSON 匹配记录会逐行写入标准输出。详细遍历信息和解析 warning 会写入标准错误; +JSON 匹配记录会逐行写入标准输出。详细遍历信息和扫描 warning 会写入标准错误; JSON 模式下没有命中时,标准输出为空。 ## 模式附加条件 diff --git a/TODO.md b/TODO.md index bf24ef7..cb97d35 100644 --- a/TODO.md +++ b/TODO.md @@ -8,12 +8,6 @@ an explicit kind for cross-position reuse, improving the diagnostic, or adding a storage-path metavariable kind. -- Define and document the contract for `#moongrep.skip`. It is currently - recognized on functions, impls, top-level lets, tests, and views; ignores - any payload, including `false`; and suppresses structural rules but not - taint analysis. If it remains public, cover those semantics in the user and - e2e documentation. Otherwise, make it internal or remove it. - - Clarify constant matching in the RuleSpec overview. Constants are compared using the parser AST constant kind and preserved source spelling, so equivalent values such as `1000` and `1_000` do not necessarily match. diff --git a/cli/json_render.mbt b/cli/json_render.mbt index f7f89e8..b5cdbfc 100644 --- a/cli/json_render.mbt +++ b/cli/json_render.mbt @@ -67,9 +67,9 @@ fn render_scan_hit_json(hit : ScanHit) -> String { } ///| -// JSON stdout is finding-only: scan trace, parse warnings, and the human +// JSON stdout is finding-only: scan trace, scan warnings, and the human // no-match summary are omitted so every non-empty line is a machine-readable -// finding record. Parse warnings are written separately to stderr. +// finding record. Scan warnings are written separately to stderr. fn render_directory_scan_json(result : DirectoryScanResult) -> String { let records : Array[String] = [] for hit in result.hits { diff --git a/cli/json_render_wbtest.mbt b/cli/json_render_wbtest.mbt index 55c7872..51c3d69 100644 --- a/cli/json_render_wbtest.mbt +++ b/cli/json_render_wbtest.mbt @@ -84,8 +84,12 @@ test "json directory output contains only one line per finding" { render_directory_scan_json({ scan_trace: ["trace"], hits: [], - skipped_files: [ - { file: "bad.mbt", block_start_line: None, reason: "parse failed" }, + warnings: [ + Parse({ + file: "bad.mbt", + block_start_line: None, + reason: "parse failed", + }), ], }), content="", @@ -93,8 +97,8 @@ test "json directory output contains only one line per finding" { let rendered = render_directory_scan_json({ scan_trace: ["trace"], hits: [render_test_hit(3, 3), render_test_hit(8, 8)], - skipped_files: [ - { file: "bad.mbt", block_start_line: None, reason: "parse failed" }, + warnings: [ + Parse({ file: "bad.mbt", block_start_line: None, reason: "parse failed" }), ], }) let records = rendered.split("\n").to_array() diff --git a/cli/render.mbt b/cli/render.mbt index 6034e83..bb94557 100644 --- a/cli/render.mbt +++ b/cli/render.mbt @@ -1,5 +1,5 @@ ///| -// Renders warnings first and match hits second so parse failures never hide +// Renders warnings first and match hits second so scan diagnostics never hide // successful findings from other files. fn render_directory_scan_result( result : DirectoryScanResult, @@ -9,7 +9,7 @@ fn render_directory_scan_result( if result.scan_trace.length() > 0 { sections.push(result.scan_trace.join("\n")) } - let warnings = render_skipped_file_warnings(result.skipped_files) + let warnings = render_scan_warnings(result.warnings) if warnings != "" { sections.push(warnings) } @@ -26,20 +26,25 @@ fn render_directory_scan_result( } ///| -fn render_skipped_file_warnings(skipped_files : Array[SkippedFile]) -> String { - let warnings : Array[String] = [] - for skipped in skipped_files { - warnings.push(render_skipped_file_warning(skipped)) +fn render_scan_warnings(scan_warnings : Array[ScanWarning]) -> String { + let rendered : Array[String] = [] + for warning in scan_warnings { + rendered.push(render_scan_warning(warning)) } - warnings.join("\n") + rendered.join("\n") } ///| -fn render_skipped_file_warning(skipped : SkippedFile) -> String { - if skipped.block_start_line is Some(line) { - "warning: skipping \{skipped.file} block starting at line \{line}: \{skipped.reason}" - } else { - "warning: skipping \{skipped.file}: \{skipped.reason}" +fn render_scan_warning(warning : ScanWarning) -> String { + match warning { + Parse(parse_warning) => + if parse_warning.block_start_line is Some(line) { + "warning: skipping \{parse_warning.file} block starting at line \{line}: \{parse_warning.reason}" + } else { + "warning: skipping \{parse_warning.file}: \{parse_warning.reason}" + } + InvalidMoongrepSkipPayload(file, loc) => + "warning: \{format_location(file, loc)}: #moongrep.skip does not accept a payload; use bare #moongrep.skip" } } diff --git a/cli/scan.mbt b/cli/scan.mbt index d6fc985..960f44c 100644 --- a/cli/scan.mbt +++ b/cli/scan.mbt @@ -1,17 +1,23 @@ ///| // Scanner-local result type. The rule model boundary is `rule_model.RuleFinding`; -// parse failures stay here as warnings for the CLI renderer. -priv struct SkippedFile { +// scan diagnostics stay here as warnings for the CLI renderer. +priv struct ParseWarning { file : String block_start_line : Int? reason : String } +///| +priv enum ScanWarning { + Parse(ParseWarning) + InvalidMoongrepSkipPayload(String, @basic.Location) +} + ///| priv struct DirectoryScanResult { scan_trace : Array[String] hits : Array[ScanHit] - skipped_files : Array[SkippedFile] + warnings : Array[ScanWarning] } ///| @@ -42,7 +48,7 @@ priv struct SourceLines { ///| priv enum ScanEvent { Trace(String) - Warning(SkippedFile) + Warning(ScanWarning) Hit(ScanHit) } @@ -53,8 +59,8 @@ priv struct BufferedScanHit { } ///| -priv struct BufferedSkippedFile { - skipped_file : SkippedFile +priv struct BufferedScanWarning { + warning : ScanWarning ordinal : Int } @@ -67,7 +73,7 @@ priv struct BufferedSkippedFile { pub async fn render_scan_command(options : CliOptions) -> String { let result = collect_directory_hits(options) if options.output_json { - let warnings = render_skipped_file_warnings(result.skipped_files) + let warnings = render_scan_warnings(result.warnings) if warnings != "" { @stdio.stderr.write(warnings + "\n") } @@ -112,29 +118,26 @@ async fn prepare_scan_rules( async fn collect_directory_hits(options : CliOptions) -> DirectoryScanResult { let scan_trace : Array[String] = [] let buffered_hits : Array[BufferedScanHit] = [] - let buffered_skipped_files : Array[BufferedSkippedFile] = [] + let buffered_warnings : Array[BufferedScanWarning] = [] scan_events(options, event => { match event { Trace(message) => scan_trace.push(message) - Warning(skipped_file) => - buffered_skipped_files.push({ - skipped_file, - ordinal: buffered_skipped_files.length(), - }) + Warning(warning) => + buffered_warnings.push({ warning, ordinal: buffered_warnings.length() }) Hit(hit) => buffered_hits.push({ hit, ordinal: buffered_hits.length() }) } }) buffered_hits.sort_by(compare_buffered_scan_hits) - buffered_skipped_files.sort_by(compare_buffered_skipped_files) + buffered_warnings.sort_by(compare_buffered_scan_warnings) let hits : Array[ScanHit] = [] for buffered in buffered_hits { hits.push(buffered.hit) } - let skipped_files : Array[SkippedFile] = [] - for buffered in buffered_skipped_files { - skipped_files.push(buffered.skipped_file) + let warnings : Array[ScanWarning] = [] + for buffered in buffered_warnings { + warnings.push(buffered.warning) } - { scan_trace, hits, skipped_files } + { scan_trace, hits, warnings } } ///| @@ -151,11 +154,19 @@ fn compare_buffered_scan_hits( } ///| -fn compare_buffered_skipped_files( - left : BufferedSkippedFile, - right : BufferedSkippedFile, +fn ScanWarning::file(self : ScanWarning) -> String { + match self { + Parse(warning) => warning.file + InvalidMoongrepSkipPayload(file, _) => file + } +} + +///| +fn compare_buffered_scan_warnings( + left : BufferedScanWarning, + right : BufferedScanWarning, ) -> Int { - let file_order = left.skipped_file.file.compare(right.skipped_file.file) + let file_order = left.warning.file().compare(right.warning.file()) if file_order == 0 { left.ordinal.compare(right.ordinal) } else { @@ -346,36 +357,70 @@ fn anonymous_pattern_rule( } ///| -fn impl_has_moongrep_skip(node : @syntax.Impl) -> Bool { - match node { - TopFuncDef(fun_decl~, ..) => attributes_have_moongrep_skip(fun_decl.attrs) - TopImpl(attrs~, ..) => attributes_have_moongrep_skip(attrs) - TopLetDef(attrs~, ..) => attributes_have_moongrep_skip(attrs) - TopTest(attrs~, ..) => attributes_have_moongrep_skip(attrs) - TopView(attrs~, ..) => attributes_have_moongrep_skip(attrs) - _ => false +priv struct MoongrepSkipAttributes { + skip_structural : Bool + invalid_payload_locations : Array[@basic.Location] +} + +///| +fn impl_moongrep_skip_attributes(node : @syntax.Impl) -> MoongrepSkipAttributes { + let attrs = match node { + TopFuncDef(fun_decl~, ..) => fun_decl.attrs + TopImpl(attrs~, ..) => attrs + TopLetDef(attrs~, ..) => attrs + TopTest(attrs~, ..) => attrs + TopView(attrs~, ..) => attrs + _ => return { skip_structural: false, invalid_payload_locations: [] } } + analyze_moongrep_skip_attributes(attrs) } ///| -fn attributes_have_moongrep_skip( +fn analyze_moongrep_skip_attributes( attrs : @list.List[@attribute.Attribute], -) -> Bool { +) -> MoongrepSkipAttributes { + let mut skip_structural = false + let invalid_payload_locations : Array[@basic.Location] = [] for attr in attrs { - if attribute_is_moongrep_skip(attr) { - return true + match attr.parsed { + Some(Ident(id)) if attribute_id_is_moongrep_skip(id) => + if raw_moongrep_skip_is_bare(attr.raw) { + skip_structural = true + } else { + invalid_payload_locations.push(attr.loc) + } + Some(Apply(id, _)) if attribute_id_is_moongrep_skip(id) => + invalid_payload_locations.push(attr.loc) + None if unparsed_attribute_targets_moongrep_skip(attr.raw) => + invalid_payload_locations.push(attr.loc) + _ => () } } - false + { skip_structural, invalid_payload_locations } +} + +///| +fn attribute_id_is_moongrep_skip(id : @attribute.Id) -> Bool { + id.qual == Some("moongrep") && id.name == "skip" } ///| -fn attribute_is_moongrep_skip(attr : @attribute.Attribute) -> Bool { - match attr.parsed { - Some(Ident(id)) | Some(Apply(id, _)) => - id.qual == Some("moongrep") && id.name == "skip" - _ => false +fn raw_moongrep_skip_is_bare(raw : String) -> Bool { + let prefix = "#moongrep.skip" + raw.has_prefix(prefix) && raw[prefix.length():].is_blank() +} + +///| +fn unparsed_attribute_targets_moongrep_skip(raw : String) -> Bool { + let prefix = "#moongrep.skip" + if !raw.has_prefix(prefix) { + return false + } + if raw.length() == prefix.length() { + return true } + let next = raw[prefix.length()] + !(next is ('a'..='z' | 'A'..='Z' | '0'..='9' | '_')) } ///| @@ -436,21 +481,29 @@ async fn scan_source_file( // A malformed matching block does not prevent other matching blocks in // the same file from being scanned. receive( - Warning({ - file, - block_start_line: if has_multiple_blocks { - Some(block.start_line) - } else { - None - }, - reason: "parse failed due to \{compact_scan_reports(reports)}", - }), + Warning( + Parse({ + file, + block_start_line: if has_multiple_blocks { + Some(block.start_line) + } else { + None + }, + reason: "parse failed due to \{compact_scan_reports(reports)}", + }), + ), ) continue } for node in impls { let untyped_node = @untyped_ast.from_impl(node) - if !impl_has_moongrep_skip(node) { + let skip = impl_moongrep_skip_attributes(node) + for loc in skip.invalid_payload_locations { + receive( + Warning(InvalidMoongrepSkipPayload(file, block.rebase_location(loc))), + ) + } + if !skip.skip_structural { let structural_hits = @rule_apply.apply_structural_scan_plan_to_node( file, untyped_node, block_plan, ) diff --git a/cli/scan_wbtest.mbt b/cli/scan_wbtest.mbt index 6c8520a..54a8cca 100644 --- a/cli/scan_wbtest.mbt +++ b/cli/scan_wbtest.mbt @@ -8,32 +8,110 @@ fn parse_single_scan_impl(source : String) -> @syntax.Impl raise { } ///| -fn parsed_impl_has_moongrep_skip(source : String) -> Bool raise { - impl_has_moongrep_skip(parse_single_scan_impl(source)) +fn parsed_impl_moongrep_skip_attributes( + source : String, +) -> MoongrepSkipAttributes raise { + impl_moongrep_skip_attributes(parse_single_scan_impl(source)) } ///| -test "scan recognizes only qualified moongrep skip attribute" { - let qualified = - #|#moongrep.skip - #|fn skipped { 1 } - #| - let qualified_with_payload = - #|#moongrep.skip(true) - #|fn skipped_with_payload { 1 } - #| - let unqualified = - #|#skip - #|fn unqualified { 1 } - #| - let other_qualified = - #|#other.skip - #|fn other_qualified { 1 } - #| - assert_true(parsed_impl_has_moongrep_skip(qualified)) - assert_true(parsed_impl_has_moongrep_skip(qualified_with_payload)) - assert_false(parsed_impl_has_moongrep_skip(unqualified)) - assert_false(parsed_impl_has_moongrep_skip(other_qualified)) +test "bare moongrep skip supports five top-level item kinds" { + let sources = [ + ( + #|#moongrep.skip + #|fn skipped { 1 } + ), + ( + #|#moongrep.skip + #|impl Show for Int with output(self, logger) { 1 } + ), + ( + #|#moongrep.skip + #|let skipped = 1 + ), + ( + #|#moongrep.skip + #|test "skipped" { 1 } + ), + ( + #|#moongrep.skip + #|enumview ValueView { Value(Int) } for Int with value_view(value) { Value(value) } + ), + ] + for source in sources { + let skip = parsed_impl_moongrep_skip_attributes(source) + assert_true(skip.skip_structural) + assert_true(skip.invalid_payload_locations.is_empty()) + } +} + +///| +test "moongrep skip ignores unrelated qualifiers and names" { + let sources = [ + ( + #|#skip + #|fn unqualified { 1 } + ), + ( + #|#other.skip + #|fn other_qualified { 1 } + ), + ( + #|#moongrep.skipper( + #|fn adjacent_name { 1 } + ), + ] + for source in sources { + let skip = parsed_impl_moongrep_skip_attributes(source) + assert_false(skip.skip_structural) + assert_true(skip.invalid_payload_locations.is_empty()) + } +} + +///| +test "moongrep skip rejects every payload form" { + let sources = [ + ( + #|#moongrep.skip() + #|fn empty_payload { 1 } + ), + ( + #|#moongrep.skip(true) + #|fn true_payload { 1 } + ), + ( + #|#moongrep.skip(false) + #|fn false_payload { 1 } + ), + ( + #|#moongrep.skip(Debug) + #|fn arbitrary_payload { 1 } + ), + ( + #|#moongrep.skip( + #|fn malformed_payload { 1 } + ), + ] + for source in sources { + let skip = parsed_impl_moongrep_skip_attributes(source) + assert_false(skip.skip_structural) + assert_eq(skip.invalid_payload_locations.length(), 1) + } +} + +///| +test "bare skip remains effective beside invalid payloads" { + let skip = parsed_impl_moongrep_skip_attributes( + ( + #|#moongrep.skip(false) + #|#other.skip + #|#moongrep.skip + #|#moongrep.skip() + #|fn mixed_attributes { 1 } + ), + ) + assert_true(skip.skip_structural) + assert_eq(skip.invalid_payload_locations.length(), 2) } ///| @@ -218,13 +296,23 @@ fn string_array_contains(items : Array[String], expected : String) -> Bool { } ///| -async test "scan skips structural rules only for moongrep skip attribute" { +async test "scan enforces moongrep skip structural and taint contract" { let result = collect_directory_hits(skip_scan_options()) let summaries : Array[String] = [] for hit in result.hits { summaries.push(scan_hit_line_summary(hit)) } - debug_inspect(summaries, content="[\"structural:7\", \"taint:13\"]") + debug_inspect( + summaries, + content="[\"structural:9\", \"structural:21\", \"taint:28\"]", + ) + inspect( + render_scan_warnings(result.warnings), + content=( + #|warning: testdata/skip-structural/src/hit.mbt:7:1-7:22: #moongrep.skip does not accept a payload; use bare #moongrep.skip + #|warning: testdata/skip-structural/src/hit.mbt:13:1-13:17: #moongrep.skip does not accept a payload; use bare #moongrep.skip + ), + ) } ///| @@ -287,7 +375,7 @@ async test "scan does not parse unmatched source blocks" { summaries.push(scan_hit_line_summary(hit)) } debug_inspect(summaries, content="[\"target:6\"]") - assert_true(result.skipped_files.is_empty()) + assert_true(result.warnings.is_empty()) } ///| @@ -298,12 +386,12 @@ async test "scan isolates matching source block parse failures" { summaries.push(scan_hit_line_summary(hit)) } debug_inspect(summaries, content="[\"other:3\", \"target:10\"]") - guard result.skipped_files is [skipped] else { + guard result.warnings is [Parse(skipped)] else { fail("expected one skipped source block") } assert_true(skipped.block_start_line == Some(5)) assert_true( - render_skipped_file_warning(skipped).has_prefix( + render_scan_warning(Parse(skipped)).has_prefix( "warning: skipping testdata/prefilter-block/matched-broken.mbt block starting at line 5: parse failed due to ", ), ) diff --git a/cli/stream_output.mbt b/cli/stream_output.mbt index 5828664..2588e41 100644 --- a/cli/stream_output.mbt +++ b/cli/stream_output.mbt @@ -7,7 +7,7 @@ priv struct ScanOutputState { /// Scans source files and writes results as they are found. /// /// Match records are written to standard output. Verbose traversal messages -/// and parse warnings are written to standard error. +/// and scan warnings are written to standard error. pub async fn run_scan_command(options : CliOptions) -> Unit { run_scan_command_with_writers(options, write_scan_stdout, write_scan_stderr) } @@ -47,8 +47,7 @@ async fn write_stream_scan_event( ) -> Unit { match event { Trace(message) => stderr_writer(message + "\n") - Warning(skipped_file) => - stderr_writer(render_skipped_file_warning(skipped_file) + "\n") + Warning(warning) => stderr_writer(render_scan_warning(warning) + "\n") Hit(hit) => { let rendered = if options.output_json { render_scan_hit_json(hit) diff --git a/cli/stream_output_wbtest.mbt b/cli/stream_output_wbtest.mbt index ee089fa..6e6cb57 100644 --- a/cli/stream_output_wbtest.mbt +++ b/cli/stream_output_wbtest.mbt @@ -86,7 +86,7 @@ fn stream_options_with_format( fn scan_event_summary(event : ScanEvent) -> String { match event { Trace(message) => "trace:\{message}" - Warning(skipped) => "warning:\{skipped.file}" + Warning(warning) => "warning:\{warning.file()}" Hit(hit) => "hit:\{hit.finding.file}:\{hit.finding.rule_id}:\{hit.finding.loc.start.lnum}" } @@ -302,6 +302,44 @@ async test "stream diagnostics are written only to stderr" { } } +///| +async test "skip payload warnings keep streaming and JSON stdout clean" { + let human_stdout : Array[String] = [] + let human_stderr : Array[String] = [] + run_scan_command_with_writers( + stream_options_with_format(skip_scan_options(), false, false), + output => human_stdout.push(output), + output => human_stderr.push(output), + ) + assert_eq(human_stdout.length(), 3) + assert_false(human_stdout.join("").contains("warning:")) + debug_inspect( + human_stderr, + content=( + #|[ + #| "warning: testdata/skip-structural/src/hit.mbt:7:1-7:22: #moongrep.skip does not accept a payload; use bare #moongrep.skip\n", + #| "warning: testdata/skip-structural/src/hit.mbt:13:1-13:17: #moongrep.skip does not accept a payload; use bare #moongrep.skip\n", + #|] + ), + ) + let json_stdout : Array[String] = [] + let json_stderr : Array[String] = [] + run_scan_command_with_writers( + stream_options_with_format(skip_scan_options(), false, true), + output => json_stdout.push(output), + output => json_stderr.push(output), + ) + assert_eq(json_stdout.length(), 3) + for output in json_stdout { + guard output.strip_suffix("\n") is Some(record) else { + fail("expected newline-terminated JSON record") + } + assert_false(record.contains("warning:")) + ignore(@json.parse(record)) + } + assert_true(json_stderr == human_stderr) +} + ///| async test "buffered renderer keeps warnings before sorted hits" { let output = render_scan_command( @@ -322,3 +360,24 @@ async test "buffered renderer keeps warnings before sorted hits" { ) assert_true(warning >= 0 && warning < hit) } + +///| +async test "buffered renderer sorts skip warnings before hits" { + let output = render_scan_command( + stream_options_with_format(skip_scan_options(), false, false), + ) + assert_true( + output.has_prefix( + "warning: testdata/skip-structural/src/hit.mbt:7:1-7:22: #moongrep.skip does not accept a payload; use bare #moongrep.skip\nwarning: testdata/skip-structural/src/hit.mbt:13:1-13:17: #moongrep.skip does not accept a payload; use bare #moongrep.skip", + ), + ) + let warning = string_index_containing( + output.split("\n").map(view => view.to_owned()).to_array(), + "#moongrep.skip does not accept a payload", + ) + let hit = string_index_containing( + output.split("\n").map(view => view.to_owned()).to_array(), + "testdata/skip-structural/src/hit.mbt:9:3-9:11", + ) + assert_true(warning >= 0 && warning < hit) +} diff --git a/docs/WritingRules.md b/docs/WritingRules.md index 74e9709..075a75a 100644 --- a/docs/WritingRules.md +++ b/docs/WritingRules.md @@ -98,6 +98,35 @@ bodies, and applies structural rules to those expression subtrees. location. With only `patterns-not`, it reports the matched top-level item location instead. +### Source-level structural suppression + +Attach a bare `#moongrep.skip` attribute to a function definition, impl method, +top-level `let` definition, test, or view declaration to suppress all +structural rules within that item: + +```moonbit +#moongrep.skip +fn generated_adapter { + legacy_call() +} +``` + +This marker affects structural rules only. Taint analysis always continues on +marked function definitions and impl methods. + +Payloads are not supported. `#moongrep.skip()`, Boolean payloads, other +payloads, and malformed payloads do not suppress structural rules. Each +invalid payload writes the following warning to standard error and scanning +continues without changing the exit status: + +```text +warning: : #moongrep.skip does not accept a payload; use bare #moongrep.skip +``` + +`#skip`, `#other.skip`, and other unrelated attributes are ignored. When an +item has both a bare marker and invalid payload forms, the invalid forms still +produce warnings and the bare marker still suppresses structural rules. + For taint rules: - `taint` must be a mapping with non-empty `sources` and `sinks` arrays. @@ -619,7 +648,7 @@ moonx moonbit-community/moongrep -- scan [--verbose] --rules [scan- `--rules=` and `-r ` are accepted as equivalent forms. If `scan-root` is omitted, the scanner uses `.`. Match results are streamed to standard output. `--verbose` writes loaded rule ids and directory traversal -progress to standard error as the scan proceeds; parse warnings also use +progress to standard error as the scan proceeds; scan warnings also use standard error. The scanner uses the untyped AST matcher by default. Repeated `exp`, `arg`, diff --git a/docs/WritingRules_CN.md b/docs/WritingRules_CN.md index a69973f..fe1c8e4 100644 --- a/docs/WritingRules_CN.md +++ b/docs/WritingRules_CN.md @@ -50,6 +50,31 @@ YAML 规则文件是扫描器的输入。规则根目录可以是通过 `--rules - 每个成功匹配的 `inside-expr` 外层表达式最多报告一个命中,`loc` 是外层表达式位置;当内部正向 pattern 命中时,遍历顺序中的第一个命中决定 `pattern_index`。 - `inside-toplevel` 会在内部正向匹配位置分别报告命中;只有 `patterns-not` 时,报告匹配到的顶层项位置。 +### 源码级结构规则抑制 + +把裸 `#moongrep.skip` 属性附加到函数定义、impl 方法、顶层 `let` 定义、test +或 view 声明上,可以抑制该顶层项内的全部结构规则: + +```moonbit +#moongrep.skip +fn generated_adapter { + legacy_call() +} +``` + +该标记只影响结构规则。对于带标记的函数定义和 impl 方法,污点分析始终继续运行。 + +该属性不支持 payload。`#moongrep.skip()`、布尔 payload、其他 payload 和畸形 +payload 都不会抑制结构规则。每个无效 payload 都会在标准错误输出以下 warning, +扫描会继续,退出码不变: + +```text +warning: : #moongrep.skip does not accept a payload; use bare #moongrep.skip +``` + +`#skip`、`#other.skip` 等无关属性会被忽略。如果同一顶层项同时带有裸标记和 +无效 payload,无效形式仍会产生 warning,裸标记仍会抑制结构规则。 + 对于污点规则: - `taint` 必须是映射,且包含非空 `sources` 和 `sinks` 数组。`sanitizers` 是可选的,默认值为空;如果出现,它必须是数组。 @@ -473,7 +498,7 @@ moonx moonbit-community/moongrep -- scan [--verbose] --rules [scan- `--rules=` 和 `-r ` 是等价形式。如果省略 `scan-root`, 扫描器使用 `.`。匹配结果会流式写入标准输出;`--verbose` 会在扫描过程中把已加载的 -rule id 和目录遍历进度写入标准错误,解析 warning 也写入标准错误。 +rule id 和目录遍历进度写入标准错误,扫描 warning 也写入标准错误。 扫描器默认使用 untyped AST matcher。重复的 `exp`、`arg`、`pat` 和 `type` 捕获会按忽略源码位置的 untyped AST 结构相等性进行比较。 diff --git a/docs/writing_rules.mbt b/docs/writing_rules.mbt index 05a8e76..cedc973 100644 --- a/docs/writing_rules.mbt +++ b/docs/writing_rules.mbt @@ -102,6 +102,35 @@ let _embed_writingrules_md : String = #| location. With only `patterns-not`, it reports the matched top-level item #| location instead. #| + #|### Source-level structural suppression + #| + #|Attach a bare `#moongrep.skip` attribute to a function definition, impl method, + #|top-level `let` definition, test, or view declaration to suppress all + #|structural rules within that item: + #| + #|```moonbit + #|#moongrep.skip + #|fn generated_adapter { + #| legacy_call() + #|} + #|``` + #| + #|This marker affects structural rules only. Taint analysis always continues on + #|marked function definitions and impl methods. + #| + #|Payloads are not supported. `#moongrep.skip()`, Boolean payloads, other + #|payloads, and malformed payloads do not suppress structural rules. Each + #|invalid payload writes the following warning to standard error and scanning + #|continues without changing the exit status: + #| + #|```text + #|warning: : #moongrep.skip does not accept a payload; use bare #moongrep.skip + #|``` + #| + #|`#skip`, `#other.skip`, and other unrelated attributes are ignored. When an + #|item has both a bare marker and invalid payload forms, the invalid forms still + #|produce warnings and the bare marker still suppresses structural rules. + #| #|For taint rules: #| #|- `taint` must be a mapping with non-empty `sources` and `sinks` arrays. @@ -623,7 +652,7 @@ let _embed_writingrules_md : String = #|`--rules=` and `-r ` are accepted as equivalent forms. #|If `scan-root` is omitted, the scanner uses `.`. Match results are streamed to #|standard output. `--verbose` writes loaded rule ids and directory traversal - #|progress to standard error as the scan proceeds; parse warnings also use + #|progress to standard error as the scan proceeds; scan warnings also use #|standard error. #| #|The scanner uses the untyped AST matcher by default. Repeated `exp`, `arg`, diff --git a/e2etests/SCAN.md b/e2etests/SCAN.md index 8a61995..4b4abff 100644 --- a/e2etests/SCAN.md +++ b/e2etests/SCAN.md @@ -54,6 +54,35 @@ source: 3 | } ``` +## Source-level structural suppression + +A bare `#moongrep.skip` suppresses every structural rule in its function while +taint analysis still runs. A payload form does not suppress structural rules. +The fixture also combines `#moongrep.skip()` with a bare marker on one function; +that function stays structurally suppressed because the bare marker remains +effective. With warnings hidden, only the payload-only function, the unmarked +function, and the taint flow under a bare marker are reported. + +```mooncram +$ cd "$TESTDIR"/.. && moonrun "$TESTDIR"/moongrep.wasm -- scan --rules testdata/skip-structural/rules testdata/skip-structural/src 2>/dev/null | grep -E '^(testdata/|rule:)' +testdata/skip-structural/src/hit.mbt:9:3-9:11 +rule: structural +testdata/skip-structural/src/hit.mbt:21:3-21:11 +rule: structural +testdata/skip-structural/src/hit.mbt:28:8-28:9 +rule: taint +``` + +Every payload form writes a source-rebased warning to standard error. The +command still exits successfully, and the empty-payload warning is emitted even +though the same function also has a valid bare marker. + +```mooncram +$ cd "$TESTDIR"/.. && moonrun "$TESTDIR"/moongrep.wasm -- scan --rules testdata/skip-structural/rules testdata/skip-structural/src 2>&1 >/dev/null +warning: testdata/skip-structural/src/hit.mbt:7:1-7:22: #moongrep.skip does not accept a payload; use bare #moongrep.skip +warning: testdata/skip-structural/src/hit.mbt:13:1-13:17: #moongrep.skip does not accept a payload; use bare #moongrep.skip +``` + ## Parse warnings Parse warnings are written to standard error even without `--verbose`. An diff --git a/testdata/skip-structural/src/hit.mbt b/testdata/skip-structural/src/hit.mbt index c25235d..e638bf5 100644 --- a/testdata/skip-structural/src/hit.mbt +++ b/testdata/skip-structural/src/hit.mbt @@ -3,10 +3,25 @@ fn skipped_structural { target() } +///| +#moongrep.skip(false) +fn payload_structural { + target() +} + +///| +#moongrep.skip() +#moongrep.skip +fn mixed_structural { + target() +} + +///| fn unskipped_structural { target() } +///| #moongrep.skip fn skipped_taint { let x = get_user_input() From b3805c110c959c2f36e867ca3693ddebfaea1a04 Mon Sep 17 00:00:00 2001 From: myfreess Date: Wed, 5 Aug 2026 18:24:20 +0800 Subject: [PATCH 4/4] fix constant matching doc --- TODO.md | 6 ------ docs/RuleSpec.md | 21 +++++++++++++-------- docs/RuleSpec_CN.md | 9 ++++++--- docs/rule_spec.mbt | 21 +++++++++++++-------- e2etests/SCAN.md | 20 ++++++++++++++++++++ testdata/constant-spelling/sample.mbt | 8 ++++++++ 6 files changed, 60 insertions(+), 25 deletions(-) create mode 100644 testdata/constant-spelling/sample.mbt diff --git a/TODO.md b/TODO.md index cb97d35..1d09838 100644 --- a/TODO.md +++ b/TODO.md @@ -8,12 +8,6 @@ an explicit kind for cross-position reuse, improving the diagnostic, or adding a storage-path metavariable kind. -- Clarify constant matching in the RuleSpec overview. Constants are compared - using the parser AST constant kind and preserved source spelling, so - equivalent values such as `1000` and `1_000` do not necessarily match. - Update the English and Chinese summaries so they do not imply that all - formatting is ignored. - - Make the Chinese rule documentation available through the `docs` command, or document that only English documents are embedded. `docs/export.mbt` currently registers only `RuleSpec` and `WritingRules`; consider adding diff --git a/docs/RuleSpec.md b/docs/RuleSpec.md index dcca246..a3ed9da 100644 --- a/docs/RuleSpec.md +++ b/docs/RuleSpec.md @@ -15,8 +15,11 @@ rule authors can rely on today. Rules use YAML format. Every `shape` value is MoonBit surface syntax. The scanner matches parsed MoonBit expression structure and does not match raw text. -Formatting and comments are not significant. Expression form, operators, -literal values, labels, callee names, and argument structure are significant. +Whitespace formatting and comments are not significant. Literal source spelling +is an exception: constants are compared by their parser AST kind and preserved +source spelling, not by normalized semantic value. Equivalent values such as +`1000` and `1_000` therefore do not necessarily match. Expression form, +operators, labels, callee names, and argument structure are also significant. Wildcards and declared metavariables modify these matching requirements as documented below. @@ -130,13 +133,14 @@ It is one item and cannot represent a whole file or import list. Shapes are structural: - undeclared identifiers and labels match literally -- constants match by value +- constants match by parser AST kind and preserved source spelling; equivalent + values such as `1000` and `1_000` do not necessarily match - operators match literally - call and method-call argument kinds, labels, order, and arity must match - type annotations and type names in matched syntax must match where present -- source locations and formatting do not participate in matching; top-level - documentation is an AST field and follows the `inside-toplevel` matching - mode +- source locations, comments, and whitespace formatting do not participate in + matching; top-level documentation is an AST field and follows the + `inside-toplevel` matching mode The scanner does not type-check shapes and does not resolve names semantically. For example, two imported names that refer to the same definition compare as @@ -487,8 +491,9 @@ Qualified constructor identities include their extra info, such as `@pkg.Ctor`, A `const` metavar captures a parsed MoonBit constant. In expression position, it matches `Expr::Constant`; in pattern position, it matches `Pattern::Constant`. -It compares the parser AST constant kind and value only; it does not type-check -or normalize equivalent values. +It compares the parser AST constant kind and preserved source spelling; it does +not type-check or normalize equivalent values. For example, `1000` and `1_000` +do not necessarily compare as the same constant. Example: diff --git a/docs/RuleSpec_CN.md b/docs/RuleSpec_CN.md index 323ab4d..0c5a7a8 100644 --- a/docs/RuleSpec_CN.md +++ b/docs/RuleSpec_CN.md @@ -10,7 +10,7 @@ 关键词 "must"、"must not"、"may" 和 "currently" 描述的是规则作者今天可以依赖的行为。 -规则使用 YAML 格式。每个 `shape` 值都是 MoonBit 表面语法。扫描器匹配解析后的 MoonBit 表达式结构,不匹配原始文本。格式和注释不参与匹配。表达式形式、操作符、字面量值、标签、被调用名称和参数结构参与匹配。通配符和已声明元变量按下文规则修改这些匹配要求。 +规则使用 YAML 格式。每个 `shape` 值都是 MoonBit 表面语法。扫描器匹配解析后的 MoonBit 表达式结构,不匹配原始文本。空白排版和注释不参与匹配,但字面量的源码拼写是例外:常量按 parser AST 中的常量种类和保留的源码拼写比较,而不是按归一化后的语义值比较。因此,`1000` 和 `1_000` 不一定匹配。表达式形式、操作符、标签、被调用名称和参数结构也参与匹配。通配符和已声明元变量按下文规则修改这些匹配要求。 ## 规则文件 @@ -105,11 +105,12 @@ Taint `sources`、`sinks` 和 `sanitizers` 同样使用 `shape` 键。这些字 shape 是结构性的: - 未声明的标识符和标签按字面匹配 -- 常量按值匹配 +- 常量按 parser AST 中的常量种类和保留的源码拼写匹配;`1000` 和 + `1_000` 这样的等值常量不一定匹配 - 操作符按字面匹配 - 调用和方法调用的参数种类、标签、顺序和数量必须匹配 - 匹配语法中出现的类型注解和类型名必须匹配 -- 源码位置和格式不参与匹配;顶层文档是 AST 字段,遵循 +- 源码位置、注释和空白排版不参与匹配;顶层文档是 AST 字段,遵循 `inside-toplevel` 的匹配模式 扫描器不会对 shape 做类型检查,也不会按语义解析名称。例如,两个指向同一定义的导入名称只在解析后的源码拼写一致或被元变量捕获时视为相同。 @@ -391,6 +392,8 @@ for i = 0; j < n; i = i + 1 { `const` 元变量捕获解析后的 MoonBit 常量。在表达式位置,它匹配 `Expr::Constant`;在 pattern 位置,它匹配 `Pattern::Constant`。 +常量比较使用 parser AST 中的常量种类和保留的源码拼写;它不会进行类型检查,也不会归一化等值常量。例如,`1000` 和 `1_000` 不一定被视为同一个常量。 + 示例: ```yaml diff --git a/docs/rule_spec.mbt b/docs/rule_spec.mbt index e2a3bc7..c8b7536 100644 --- a/docs/rule_spec.mbt +++ b/docs/rule_spec.mbt @@ -19,8 +19,11 @@ let _embed_rulespec_md : String = #| #|Rules use YAML format. Every `shape` value is MoonBit surface syntax. The scanner #|matches parsed MoonBit expression structure and does not match raw text. - #|Formatting and comments are not significant. Expression form, operators, - #|literal values, labels, callee names, and argument structure are significant. + #|Whitespace formatting and comments are not significant. Literal source spelling + #|is an exception: constants are compared by their parser AST kind and preserved + #|source spelling, not by normalized semantic value. Equivalent values such as + #|`1000` and `1_000` therefore do not necessarily match. Expression form, + #|operators, labels, callee names, and argument structure are also significant. #|Wildcards and declared metavariables modify these matching requirements as #|documented below. #| @@ -134,13 +137,14 @@ let _embed_rulespec_md : String = #|Shapes are structural: #| #|- undeclared identifiers and labels match literally - #|- constants match by value + #|- constants match by parser AST kind and preserved source spelling; equivalent + #| values such as `1000` and `1_000` do not necessarily match #|- operators match literally #|- call and method-call argument kinds, labels, order, and arity must match #|- type annotations and type names in matched syntax must match where present - #|- source locations and formatting do not participate in matching; top-level - #| documentation is an AST field and follows the `inside-toplevel` matching - #| mode + #|- source locations, comments, and whitespace formatting do not participate in + #| matching; top-level documentation is an AST field and follows the + #| `inside-toplevel` matching mode #| #|The scanner does not type-check shapes and does not resolve names semantically. #|For example, two imported names that refer to the same definition compare as @@ -491,8 +495,9 @@ let _embed_rulespec_md : String = #| #|A `const` metavar captures a parsed MoonBit constant. In expression position, #|it matches `Expr::Constant`; in pattern position, it matches `Pattern::Constant`. - #|It compares the parser AST constant kind and value only; it does not type-check - #|or normalize equivalent values. + #|It compares the parser AST constant kind and preserved source spelling; it does + #|not type-check or normalize equivalent values. For example, `1000` and `1_000` + #|do not necessarily compare as the same constant. #| #|Example: #| diff --git a/e2etests/SCAN.md b/e2etests/SCAN.md index 4b4abff..3073a36 100644 --- a/e2etests/SCAN.md +++ b/e2etests/SCAN.md @@ -225,6 +225,26 @@ source: 4 | render("raw") ``` +## Constant source spelling + +Constant equality preserves the spelling stored in the parser AST instead of +normalizing numeric values. A literal `1000` pattern therefore matches only the +identically spelled call; the equivalent `1_000` call is omitted. + +```mooncram +$ cd "$TESTDIR"/.. && moonrun "$TESTDIR"/moongrep.wasm -- scan --output-json --pattern 'literal(1000)' testdata/constant-spelling/sample.mbt | sed -n 's/.*"matched_source":"\(.*\)","source_context".*/\1/p' +literal(1000) +``` + +The same comparison applies when a named `const` metavariable is repeated. +Pairs with consistent spelling match, while `repeated(1000, 1_000)` does not. + +```mooncram +$ cd "$TESTDIR"/.. && moonrun "$TESTDIR"/moongrep.wasm -- scan --output-json --pattern 'repeated($(value:const), $(value:const))' testdata/constant-spelling/sample.mbt | sed -n 's/.*"matched_source":"\(.*\)","source_context".*/\1/p' +repeated(1000, 1000) +repeated(1_000, 1_000) +``` + ## Rule filtering Loading the prefilter rule directory runs both rules and reports their matches diff --git a/testdata/constant-spelling/sample.mbt b/testdata/constant-spelling/sample.mbt new file mode 100644 index 0000000..d010181 --- /dev/null +++ b/testdata/constant-spelling/sample.mbt @@ -0,0 +1,8 @@ +///| +fn constant_spellings { + literal(1000) + literal(1_000) + repeated(1000, 1000) + repeated(1_000, 1_000) + repeated(1000, 1_000) +}