7.15. detect-dupe — Cross-file similar-function detector
detect-dupe normalises every user function in a corpus of .das files into an
alpha-renamed token stream (identifiers, types and literals collapsed) and reports
near-identical functions across it. One engine behind two interfaces: the CLI
utils/detect-dupe/main.das and the MCP tools export_corpus / detect_duplicates.
7.15.1. What it reports
Exact-clone clusters — canonical token streams byte-identical.
Fuzzy near-duplicates — pairs scored
sqrt(jaccard x len_ratio)over a 64-slot MinHash signature, gated onlen_ratio >= threshold. The geometric mean admits a Jaccard somewhat belowthresholdwhen lengths match closely — a deliberate recall bias.
7.15.2. Flags
Invocation is bin/daslang utils/detect-dupe/main.das -- <flags>.
Flag |
Default |
Meaning |
|---|---|---|
|
required+ |
File or directory to scan; repeatable |
|
off |
Newline-delimited path list from a file ( |
|
off |
Same, from stdin. Mutually exclusive with |
|
0 (auto) |
Workers for |
|
0.7 |
Fuzzy floor (0..1) on |
|
20 |
Top-N entries in the stdout summary |
|
off |
Path for the full JSON report |
|
off |
Skip the MinHash pass — exact clusters only |
|
8 |
Drop functions with fewer than N tokens |
|
off |
Skip top-level functions; every |
|
off |
Write all extracted functions to a JSON file and exit before clustering |
|
off |
Load functions from an |
|
off |
B1: load corpus JSON; tag records whose member identity ( |
|
off |
B1 modifier: also drop clusters whose canonical exists in the baseline (only fully-new clusters survive) |
|
off |
B2 candidate path (file or directory), repeatable. Parsed in-process, tagged candidates, report filtered |
|
off |
B2 candidate paths from stdin, newline-delimited |
|
off |
Exit non-zero when the post-filter report contains any clusters/pairs (CI gate) |
|
off |
In |
|
off |
Pattern name to KEEP despite default skip (repeatable); |
|
off |
Per-file progress |
|
Show help |
one of
-p,--paths-from,--paths-stdin,--import-functionsor--againstis required.
builtin.das and any path containing ast-fuzz/selftest/ (deliberately-broken AST
fixtures) are skipped automatically.
Each file is parsed alone: no prerequisite walk, no infer, no macro run, no module cache.
A require, annotation, parent class or reader macro the parse cannot resolve is recorded
and walked past - a module this build or platform does not carry costs nothing, and a
%name~ ... %% whose reader is not loaded reads to its %% like any other. A syntax
error ends the file’s parse: the file is reported FAIL and counted, the functions parsed
before it are still collected, and the export is written with a warning naming the count.
Nothing blocks on a broken file.
7.15.3. Pattern filter
A “pattern” is a structural shape whose canonical token stream carries no signal beyond its repetition count. Matched functions are dropped from clustering by default.
Name |
Detects |
Why it’s boilerplate |
|---|---|---|
|
Class-method whose hook name starts with |
|
|
Body is N >= 2 byte-identical top-level statement chunks |
|
|
1..6 top-level statements, each a single trivial |
Emitter shells like |
Match order in classify() is name-first (visitor), then body-shape (dispatch,
emit); first match wins.
The stdout summary reports patterns skipped: N dispatch, N emit, N visitor;
--verbose adds one pattern-skip [name] file:line func (note) line per filtered
record.
7.15.4. Canonical form
Each function emits a flat tag stream:
def add(a,b:int):int { return a+b }
FN ARG <var_0> TYP ARG <var_1> TYP TYP BODY BLK STMT RET OP2:+ <var_0> <var_1> ENDBLK ENDFN
def double(a:int) { return a*2 }
FN ARG <var_0> TYP BODY BLK STMT RET OP2:* <var_0> LIT ENDBLK ENDFN
User identifiers become <var_0>, <var_1>, …; all types collapse to TYP; all
literals to LIT; field/swizzle names use .FLD / .SWZ. Called function names
are kept — CALL:push vs CALL:emplace is real signal.
7.15.5. Modes
-p alone reports flat. Two filtered modes layer on top via a single
is_candidate flag inside FuncRecord: a cluster or fuzzy pair is kept iff at
least one of its members is a candidate. An AI judge
(utils_find_dupe)
can triage the resulting JSON into real duplicates, partial matches and false
positives.
7.15.5.1. B1 — baseline diff (CI gate)
# one-off: build the baseline (commit this)
bin/daslang utils/detect-dupe/main.das -- -p tests --export-functions tests_baseline.json
# CI: scan again, surface only what isn't in the baseline
bin/daslang utils/detect-dupe/main.das -- -p tests --baseline tests_baseline.json --check
Growth counts, not just brand-new canonicals: a new copy of an already-tracked
canonical in a new location surfaces its cluster. --baseline-strict drops that
case. Pairs are never strict-filtered (the baseline carries no MinHash signatures),
so strict is cluster-only.
file:line:name keying means an unrelated edit that shifts line numbers looks like
a new member and surfaces its cluster — acceptable for CI, since touched code is
the right default to re-check.
7.15.5.2. B2 — PR-files / interactive
“Did I just write something that already exists?” — compare a file list against a pre-built corpus:
bin/daslang utils/detect-dupe/main.das -- \
--import-functions tests_baseline.json --against tests/strings/new_helper.das
# git pipeline:
git diff --name-only master | grep '\.das$' | \
bin/daslang utils/detect-dupe/main.das -- \
--import-functions tests_baseline.json --against-from-stdin
With --against and --import-functions both set, corpus records whose file
matches any candidate path are dropped first (look for the
dropped N corpus records overridden line), then the candidate is freshly parsed
— so the file is compared against the rest of the world, never against its own
stale copy in the baseline.
7.15.6. Export / import
Dump post-canonicalization records to hand off to another tool, or to shard the scan across machines and merge later:
bin/daslang utils/detect-dupe/main.das -- -p tests --export-functions /tmp/funcs.json
bin/daslang utils/detect-dupe/main.das -- --import-functions /tmp/funcs.json --json /tmp/dupes.json
The on-disk schema is a small envelope:
{
"schema_version": 1,
"functions": [
{
"name": "add_int",
"file": "tests/foo.das",
"line": 4,
"is_lambda": false,
"canonical": "FN ARG <var_0> TYP ..."
}
]
}
MinHash signatures are not included — they’re recomputed on import. On import,
--no-fuzzy and --min-tokens apply just as in the parse path.
7.15.6.1. Parallel export (-j / --workers)
--workers N fans the export across N child detect-dupe processes: the file list
is sorted, split into N contiguous chunks, and the parent reads the shards back
in chunk-index order, so output is byte-identical to a --workers 1 run on the
same inputs. A child that exits non-zero fails the whole export; a parse failure
inside a child is reported and counted exactly as on the sequential path.
7.15.6.2. Explicit file-list inputs
--paths-from <file> and --paths-stdin scope an export to a precomputed list,
typically a PR diff; the file form avoids ARG_MAX on big PRs.
git diff --name-only master | grep '\.das$' | \
bin/daslang utils/detect-dupe/main.das -- \
--paths-stdin --export-functions pr.json
For a comma- or newline-separated list of files, directories and globs in a single
argument (the format all MCP file/glob tools take), the canonical expander is
daslib/fio.parse_file_list — it strips whitespace, passes literals through,
expands globs via expand_glob, and preserves the order of plain entries across
glob expansions.
7.15.7. MCP integration
The utils_mcp server wraps the engine end-to-end:
Tool |
Purpose |
|---|---|
|
Scan |
|
B2 mode. Pass |
Both take a keep parameter mirroring the CLI --keep. The envelope also reports
candidate_functions_pre_filter, distinguishing “no candidates parsed” from “all
candidates pattern-filtered out”.
7.15.8. Implementation
File |
Role |
|---|---|
|
|
|
64-slot MinHash signatures over 5-grams, Jaccard estimate |
|
Exact-bucket clustering + fuzzy all-pairs with length gate |
|
JSON + stdout summary writer |
|
CLI ( |
|
|
|
|
|
On-disk JSON schema + writer/reader for |
|
End-to-end visitor smoke fixture; one-function-per-concern |
|
The auto-skip fixture the scan cells walk |
|
dastest suite — |
7.15.9. Notes
Each file goes through
parse_file_no_prerequisites(daslib/ast): the corpus is the AST as the parser built it. Nothing is folded, inlined, reified or macro-expanded, andv.xyis a field access, not a swizzle - a function matches by its source shape.Default mode records every top-level function and generic. A lambda body sits inline in its parent (
MK_BLK BLK ... ENDBLK), so two dispatchers with different lambda bodies differ.-Lrecords every@(...) { }/$(...) { }block in a function as its own record, nested ones included, named<function>+`lambda@<column>at the block’s line - the column keeps two blocks on one line apart in reports, exports and baseline identity.Functions whose
at.fileInfopoints outside the parsed file are filtered out; one record per(file, line).Two overloads whose parameter types are both unresolved mangle alike; the parser keeps the first (
already_declared_functionin the diagnostics) and the second never enters the corpus.