style_lint: STYLE040 finds extractable duplicated statement regions, and extract every one in daslib + utils - #3685
Open
aleksisch wants to merge 2 commits into
Open
Conversation
…d absorb
Adds an AST clone detector and wires it as STYLE040 (on by default).
daslib/dupe_detect.das builds a preorder index of every own-module function
body - one record per AST node carrying {payload hash, merkle hash, size,
nchild} - so a node's subtree is exactly the interval [id, id+size), its first
child is id+1 and the next sibling is c + size[c]. Every containment, overlap
and nesting question is then an integer interval test with no child-list
storage. Discovery buckets equal-hash statements and extends sibling runs in
O(1) per step; candidates are processed widest-first with a claim set so the
largest region wins.
Hashes only ever PICK candidates. Each pair is confirmed by subtree_equal, an
exact lockstep walk comparing node class, payload text and memoized type text
under a variable bijection, so no hash collision can become a finding. A class
absent from KNOWN_CLASSES is opaque and never matches - an omission costs
recall, never correctness. payload_key is the single reader of per-node payload
(names, ops, literal values, flag bitfields) shared by hashing and
verification, so the two cannot drift.
The rule fires only when the extraction is mechanically valid. Rejected:
a local declared inside the region and read after it (needs a return value),
an escaping return/break/continue/goto/yield/label, an `assume` alias the rest
of the block consumes, a scoped or finalizable declaration whose finalization
would move, a macro-generated statement, a `defer` residue, and a written free
variable that is not a ref or ref type (the write would not survive the call).
The message reports the span, the AST node count and the suggested parameter
list derived from the region's free variables.
Defaults: 20 AST nodes / 2 statements, per-module overrides
`options _dupe_min_nodes` / `_dupe_min_statements`, suppression via
`// nolint:STYLE040`. Measured cost is under 1% of a lint pass on the largest
modules in the tree, which is why it ships on rather than behind an opt-in.
Not to be confused with utils/detect-dupe, which clusters whole functions
across a corpus; this works inside one module and reports extractable regions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Acts on every STYLE040 finding in daslib and utils - 30 regions, 0 left. Each
extraction is the mechanical one the rule suggests (same statements, same
order, free variables become parameters), so behaviour is unchanged by
construction.
daslib:
perf_lint pop_loop_scope - the scope/if-depth/early-exit pop
shared by visitExprFor and visitExprWhile
debug finish_structure_preview (afterStructure and
afterStructureCancel had identical bodies),
finish_leaf_value (array element + table value),
wait_for_resume (3 of 4 tick_debugger waits),
push_dap_variable (3 identical DAP Variable rows)
decs store_entity_row - component memcpy + entityLookup
write; the two sites differed only in the row index
rst_comment begin_decl(next_state) - 5 sites collapse into one
aot_cpp write_field_access_suffix - value and pointer arms
ast_print write_variable_decl - let and global printing
match bind_match_rules - declaration + `_` var-tag rules
das_source_formatter mark_type_after_separator - multi-line and one-line
typedef scans
toml take_time_of_day - HH:MM:SS + fractional seconds, the
tail of both a date-time and a local-time
rst collect_field_rows - class and structure field tables
sql_boost index_ddl (CREATE [UNIQUE] INDEX, annotation + call
macro), quoted_column_list
sql_linq reversed_json_path, reserve_projection (5 parallel
arrays), push_source_column
ast_match guarded_block - the `if (cond) { body }` every folded /
direct match arm appends
utils:
mcp/tools/common setup_introspection_policies - the CodeOfPolicies setup
both compile_only overloads share
mcp/subtools/ write_type_body - class and struct field listing, with
list_types skip_methods for the class arm
daspkg/commands rollback_partial_install - 4 sites
dasHerd watcher send_changelist_action_result (4 sites - two were
byte-identical, two differed only in a literal action
name, so the helper takes the action),
close_current_hunk, clear_attachment,
scroll_to_current_hunk, sync_diff_scroll,
setup_commit_table_columns
Two deliberate non-changes:
utf8_utils keeps its repetition under a nolint - the continuation-byte tail is
genuinely shared between the 3- and 4-byte arms, but those branches mirror the
UTF-8 spec table and a helper would obscure that shape.
debug.das keeps one asymmetry: the stepRequestedStack arm runs afterPause()
before clearing continueRequested where the other three clear it first.
STYLE040 correctly did not merge it, and the ordering is left alone rather
than silently normalized.
Verified: 1141 tests pass (tests/daslib 262, tests/ast_match 392, tests/decs
245, utils/lint/tests 64, tests/match 61, tests/debug_agent 52, tests/lint 38,
tests/mcp 14, plus utils/daspkg 219); das-fmt --verify clean over daslib and
utils; list_types output byte-identical before and after; lint clean on every
changed file except pre-existing STYLE037/038/PERF030 confirmed against
baseline. tests/dasSQLITE and tests/sql_conformance cannot run here (the
sqlite native module is not built in this tree), so the sql_boost / sql_linq
extractions rest on compile + CI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
aleksisch
force-pushed
the
churkin/style040-duplicate-region-lint
branch
from
August 10, 2026 17:41
b409c4f to
be5b0f4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds STYLE040 — a lint rule that finds runs of statements duplicated elsewhere in the same module that a helper function could absorb verbatim — then acts on what it reports in
daslib.Two commits, deliberately separate:
style_lint: STYLE040 …— the engine (daslib/dupe_detect.das) + rule wiring + fixture + docs.daslib: extract the duplicated regions …— the extractions the rule asked for.This is not
utils/detect-dupe. That tool clusters whole functions across a corpus by canonical token stream; this works inside one module and reports extractable regions — the sub-function granularity the corpus matcher cannot see.How it works
A preorder index of every own-module function body, one record per AST node carrying
{payload hash, merkle hash, size, nchild}. A node's subtree is exactly the interval[id, id+size), its first child isid+1, the next sibling isc + size[c]— so full tree navigation needs no child-list storage, and every containment / overlap / nesting question is an integer interval test.Discovery buckets equal-hash statements and extends sibling runs in O(1) per step, then processes candidates widest-first with a claim set so the largest region wins.
Hashes only ever pick candidates. Every reported pair is confirmed by
subtree_equal, an exact lockstep walk comparing node class, payload text and memoized type text under a variable bijection — so no hash collision can become a finding. A class absent fromKNOWN_CLASSESis opaque and never matches: an omission costs recall, never correctness.payload_keyis the single reader of per-node payload (names, ops, literal values, flag bitfields) shared by hashing and verification, so the two cannot drift.Variable names may differ (they map through the bijection and become parameters); structure, types, called functions and literal values may not.
Zero-false-positive gates
The rule fires only when the extraction is mechanically valid. It stays silent for:
return/break/continue/goto/yield/labelassumealias the rest of the block consumesdeferresidue (nada())Cost, and why it ships on by default
Measured, min-of-3, on the two largest modules in the tree:
daslib/linq_fold_common.dasdaslib/debug.dasUnder 1% of a lint pass; compile time dominates. Thresholds default to 20 AST nodes / 2 statements, per-module overrides
options _dupe_min_nodes/_dupe_min_statements, suppression via a// nolint:STYLE040tail comment on the reported line.Also checked: a normal compile with optimizations and auto-inlining on does not invent duplicates from two call sites of the same inlined helper — the generated-statement gate covers it.
What it found, and what commit 2 does
30 regions across
daslib(20) andutils(10), spanning 2–9 lines each, 137 duplicated lines per copy. I read all 30 by hand; every one is genuine — no false positives. Commit 2 fixes all 30, leaving zero STYLE040 reports indaslibandutils:perf_lintpop_loop_scope— shared byvisitExprFor/visitExprWhiledebugfinish_structure_preview,finish_leaf_value,wait_for_resume,push_dap_variable(4 regions, 3 of them 3-site)decsstore_entity_row— the two call sites differed only in the row indexrst_commentbegin_decl(next_state)— 5 sites collapse into oneaot_cppwrite_field_access_suffixast_printwrite_variable_declmatchbind_match_rulesdas_source_formattermark_type_after_separatortomltake_time_of_day— the tail of both a date-time and a local-timerstcollect_field_rowssql_boostindex_ddl,quoted_column_listsql_linqreversed_json_path,reserve_projection,push_source_columnast_matchguarded_block— theif (cond) { body }every match arm appendsmcp/tools/commonsetup_introspection_policiesmcp/subtools/list_typeswrite_type_bodydaspkg/commandsrollback_partial_install— 4 sitessend_changelist_action_result(4 sites),close_current_hunk,clear_attachment,scroll_to_current_hunk,sync_diff_scroll,setup_commit_table_columnsNet −86 lines (22 files, +372 / −458).
Where a region had more sites than the rule reported, the helper takes the differing value and absorbs them all:
watcher_serverhad 4changelist_action_resultsends of which only 2 were byte-identical (the rule was right); the helper takes the action name and covers all four.Two deliberate non-changes:
utf8_utilskeeps its repetition under anolint— the continuation-byte tail really is shared between the 3- and 4-byte arms, but those branches mirror the UTF-8 spec table and a helper would obscure it. The rule reports extractable, not advisable.debug.daskeeps one asymmetry: thestepRequestedStackarm runsafterPause()before clearingcontinueRequested, where the other three clear it first. STYLE040 correctly did not merge it; the ordering is left alone rather than silently normalized.Bugs the audit surfaced (all in the engine, all fixed here)
Hand-auditing early output found three false-positive mechanisms, which is the reason the gates above exist:
aot_cppoffered threewrite_sep()calls separated byifblocks). Cause: scalar pending-statement state clobbered by inner blocks → now aPendFramestack.declare_varnever stamped the vid on the declaring node, so locals declared inside a region were invisible (bool_arrayoffered to extractlet offs+let mask, both read afterwards).assumealiases — matched as ordinary statements though the rest of the block consumes the alias.Plus one silent hashing bug: the Merkle combine was FNV's
(h ^ v) * prime, which annihilates to 0 when the accumulator equals the child hash — constant here — so a differing literal (+7vs+8) never reached the statement hash. Replaced with a murmur3-finalizer combine. The exact-verification pass had been catching it, which is precisely why hashes only pick candidates.Verification
tests/ast_match392,tests/daslib262,tests/decs245,utils/daspkg219,utils/lint/tests64,tests/match61,tests/debug_agent52,tests/lint38,tests/mcp14,tests/ast13utils/lint/testsandtests/lintassert exact warning counts, so default-on causing no regression there is meaningfuldas-fmt --verifyclean over the whole tracked tree (3791 files) — the real gate for thedas_source_formatterchangelist_typesoutput byte-identical before/after (its two branches merged into one helper with a flag)git stashutils/lint/tests/style040_duplicate_region.daspins one hit plus three negatives (differing literal, escaping local, non-ref scalar write)Two deliberate non-changes:
utf8_utilskeeps its repetition under anolint(the 3-/4-byte arms mirror the UTF-8 spec table and a helper would obscure it — the rule reports extractable, not advisable), anddebug.daskeeps one asymmetry where thestepRequestedStackarm runsafterPause()before clearingcontinueRequested; STYLE040 correctly did not merge it and the ordering is left alone rather than silently normalized.Scope note
daslibalready carries 28 STYLE037 + 32 STYLE038 warnings today, so STYLE040 lands in the same advisory band as the existing default-on metric rules — but unlike those, it starts at zero outstanding reports acrossdaslibandutils.Not swept:
tests/,modules/*/daslib/,examples/, and the 106utils/files whose deps don't resolve standalone. Those may report on their next touch.Not run — please weigh these in review
core.hooksPathis not enabled in this clone, so the pre-push token gate did not apply.tests/dasSQLITEandtests/sql_conformancecannot run in this tree — thesqlitenative module is not built (missing prerequisite 'sqlite', same on baseline). Thesql_boost/sql_linqextractions therefore rest on compile + lint + CI, not on executed tests.aot_cpp.dasemitter change is behaviour-identical by construction (same three writes, same order, same conditions), but the nightly fulltest_aotis its real gate.🤖 Generated with Claude Code