ultra11y 2.28.0 → 2.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,9 +14,9 @@ var ENGINE_VERSION, SCHEMA_VERSION, EXTRACTOR_VERSION;
14
14
  var init_types = __esm({
15
15
  "src/types.ts"() {
16
16
  "use strict";
17
- ENGINE_VERSION = "2.24.2";
17
+ ENGINE_VERSION = "2.27.1";
18
18
  SCHEMA_VERSION = 5;
19
- EXTRACTOR_VERSION = 12;
19
+ EXTRACTOR_VERSION = 13;
20
20
  }
21
21
  });
22
22
 
@@ -922,7 +922,18 @@ var init_common = __esm({
922
922
  ".scss": "scss",
923
923
  ".vue": "vue",
924
924
  ".svelte": "svelte",
925
- ".astro": "astro"
925
+ ".astro": "astro",
926
+ // Extended-tier AST languages (src/ast/loader.ts EXT_GRAMMAR). Without an
927
+ // entry here `extToLang` answered "other", which `classify` reads as non-code
928
+ // — so a real scan never called extractCode for them and they extracted
929
+ // nothing, while the quality harness (which calls extractCode directly)
930
+ // published a perfect score. The grammar still arrives via `grammars pull`;
931
+ // absent it these fall back to the regex tier like any other language.
932
+ ".zig": "zig",
933
+ ".hcl": "hcl",
934
+ ".tf": "terraform",
935
+ ".tfvars": "terraform",
936
+ ".sol": "solidity"
926
937
  };
927
938
  REEXPORT_EXTS = /* @__PURE__ */ new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
928
939
  MAX_REEXPORTS = 400;
@@ -1689,6 +1700,65 @@ var init_markdown = __esm({
1689
1700
  }
1690
1701
  });
1691
1702
 
1703
+ // src/extract/literals.ts
1704
+ function isInterestingString(value) {
1705
+ if (value.length < MIN_LITERAL_LEN || value.length > MAX_LITERAL_LEN) return false;
1706
+ return HAS_SUBSTANCE.test(value);
1707
+ }
1708
+ function isInterestingNumber(raw) {
1709
+ const v = raw.trim();
1710
+ if (!v || v.length > MAX_LITERAL_LEN) return false;
1711
+ if (TRIVIAL_NUMBERS.has(v)) return false;
1712
+ return Number.isFinite(Number(v));
1713
+ }
1714
+ function unquote(text) {
1715
+ let s = text;
1716
+ const raw = /^(?:[rRbBuUfF]{1,2}|@|\$)?(?:#*)?(['"`])/.exec(s);
1717
+ if (raw) {
1718
+ const q = raw[1];
1719
+ const start2 = s.indexOf(q);
1720
+ const end = s.lastIndexOf(q);
1721
+ if (end > start2) s = s.slice(start2 + 1, end);
1722
+ }
1723
+ return s;
1724
+ }
1725
+ var MAX_LITERAL_LEN, MIN_LITERAL_LEN, MAX_LITERALS, TRIVIAL_NUMBERS, HAS_SUBSTANCE, LiteralCollector;
1726
+ var init_literals = __esm({
1727
+ "src/extract/literals.ts"() {
1728
+ "use strict";
1729
+ init_sort();
1730
+ MAX_LITERAL_LEN = 80;
1731
+ MIN_LITERAL_LEN = 2;
1732
+ MAX_LITERALS = 256;
1733
+ TRIVIAL_NUMBERS = /* @__PURE__ */ new Set(["0", "1", "-1", "2", "-2"]);
1734
+ HAS_SUBSTANCE = /[\p{L}\p{N}]/u;
1735
+ LiteralCollector = class {
1736
+ seen = /* @__PURE__ */ new Set();
1737
+ out = [];
1738
+ get full() {
1739
+ return this.out.length >= MAX_LITERALS;
1740
+ }
1741
+ add(kind, value, line) {
1742
+ if (this.full) return;
1743
+ if (kind === "string" && !isInterestingString(value)) return;
1744
+ if (kind === "number" && !isInterestingNumber(value)) return;
1745
+ if (kind === "regex" && !value) return;
1746
+ const key = `${kind}\0${value}\0${line}`;
1747
+ if (this.seen.has(key)) return;
1748
+ this.seen.add(key);
1749
+ this.out.push({ value, line, kind });
1750
+ }
1751
+ addString(text, line) {
1752
+ this.add("string", unquote(text), line);
1753
+ }
1754
+ result() {
1755
+ if (!this.out.length) return void 0;
1756
+ return this.out.sort((a, b) => byStr(a.value, b.value) || a.line - b.line || byStr(a.kind, b.kind));
1757
+ }
1758
+ };
1759
+ }
1760
+ });
1761
+
1692
1762
  // node_modules/.pnpm/web-tree-sitter@0.26.11/node_modules/web-tree-sitter/web-tree-sitter.js
1693
1763
  function assertInternal(x) {
1694
1764
  if (x !== INTERNAL) throw new Error("Illegal constructor");
@@ -7059,6 +7129,9 @@ var init_doc = __esm({
7059
7129
  });
7060
7130
 
7061
7131
  // src/ast/extract.ts
7132
+ function isPlainString(node) {
7133
+ return node.namedChildren.every((c2) => STRING_PART.test(c2.type));
7134
+ }
7062
7135
  function collectAll(root, spec, defNames, maxCalls, wantImports) {
7063
7136
  const identsFound = /* @__PURE__ */ new Set();
7064
7137
  const wantCalls = spec.calls !== void 0;
@@ -7080,6 +7153,7 @@ function collectAll(root, spec, defNames, maxCalls, wantImports) {
7080
7153
  termsFound.add(t);
7081
7154
  }
7082
7155
  };
7156
+ const literals = new LiteralCollector();
7083
7157
  const wantNames = spec.imports?.import_statement !== void 0;
7084
7158
  const namesFound = /* @__PURE__ */ new Set();
7085
7159
  const wantRefs = wantImports && spec.imports !== void 0;
@@ -7101,9 +7175,15 @@ function collectAll(root, spec, defNames, maxCalls, wantImports) {
7101
7175
  }
7102
7176
  if (COMMENT_NODE.test(type)) {
7103
7177
  for (const line of node.text.split(/\r?\n/)) addTerms2(stripCommentMarkers(line));
7104
- } else if (kids.length === 0 && STRING_NODE.test(type) && node.endIndex - node.startIndex <= MAX_LITERAL_LEN) {
7178
+ } else if (kids.length === 0 && STRING_NODE.test(type) && node.endIndex - node.startIndex <= MAX_LITERAL_LEN2) {
7105
7179
  addTerms2(node.text.replace(/^['"`]+|['"`]+$/g, ""));
7106
7180
  }
7181
+ if (!literals.full) {
7182
+ const line = node.startPosition.row + 1;
7183
+ if (STRING_NODE.test(type) && isPlainString(node)) literals.addString(node.text, line);
7184
+ else if (kids.length === 0 && NUMBER_NODE.test(type)) literals.add("number", node.text.trim(), line);
7185
+ else if (REGEX_NODE.test(type)) literals.add("regex", node.text, line);
7186
+ }
7107
7187
  if (wantCalls && !(spec.kindFrom?.[type] && spec.kindFrom[type](node)) && !spec.skipCall?.(node)) {
7108
7188
  const how = spec.calls[type];
7109
7189
  if (how === "function") {
@@ -7152,7 +7232,8 @@ function collectAll(root, spec, defNames, maxCalls, wantImports) {
7152
7232
  idents: [...identsFound].sort().slice(0, MAX_REF_IDENTS),
7153
7233
  calls: calls.slice(0, maxCalls),
7154
7234
  importedNames: [...namesFound].sort(byStr).slice(0, MAX_IMPORTED_NAMES),
7155
- terms: [...termsFound].sort(byStr)
7235
+ terms: [...termsFound].sort(byStr),
7236
+ literals: literals.result() ?? []
7156
7237
  };
7157
7238
  }
7158
7239
  function boundNames(pattern) {
@@ -7494,7 +7575,7 @@ function extractAst(rel2, ext, content, opts = {}) {
7494
7575
  for (const s of symbols) if (!s.exported && exportedNames.has(s.name)) s.exported = true;
7495
7576
  }
7496
7577
  const wantImports = opts.imports !== false;
7497
- const { refs, idents, calls, importedNames, terms } = collectAll(
7578
+ const { refs, idents, calls, importedNames, terms, literals } = collectAll(
7498
7579
  root,
7499
7580
  spec,
7500
7581
  new Set(symbols.map((s) => s.name)),
@@ -7516,6 +7597,7 @@ function extractAst(rel2, ext, content, opts = {}) {
7516
7597
  importedNames,
7517
7598
  relations,
7518
7599
  terms,
7600
+ literals,
7519
7601
  ...symbols.length >= maxSymbols ? { truncated: true } : {}
7520
7602
  };
7521
7603
  } catch {
@@ -7524,10 +7606,11 @@ function extractAst(rel2, ext, content, opts = {}) {
7524
7606
  tree?.delete();
7525
7607
  }
7526
7608
  }
7527
- var MAX_REF_IDENTS, MAX_CALLS, MAX_IMPORTED_NAMES, MAX_SYMBOLS, MAX_RELATIONS, MAX_TERMS, MAX_LITERAL_LEN, MAX_FUNC_DEPTH, NESTED_TYPE_KINDS, ANON_DEFAULT_FN, ANON_DEFAULT_CLASS, REF_IDENT_TYPE, REF_IDENT_TEXT, COMMENT_NODE, STRING_NODE;
7609
+ var MAX_REF_IDENTS, MAX_CALLS, MAX_IMPORTED_NAMES, MAX_SYMBOLS, MAX_RELATIONS, MAX_TERMS, MAX_LITERAL_LEN2, MAX_FUNC_DEPTH, NESTED_TYPE_KINDS, ANON_DEFAULT_FN, ANON_DEFAULT_CLASS, REF_IDENT_TYPE, REF_IDENT_TEXT, COMMENT_NODE, STRING_NODE, NUMBER_NODE, REGEX_NODE, STRING_PART;
7528
7610
  var init_extract = __esm({
7529
7611
  "src/ast/extract.ts"() {
7530
7612
  "use strict";
7613
+ init_literals();
7531
7614
  init_sort();
7532
7615
  init_loader();
7533
7616
  init_node();
@@ -7542,7 +7625,7 @@ var init_extract = __esm({
7542
7625
  MAX_SYMBOLS = 2e3;
7543
7626
  MAX_RELATIONS = 256;
7544
7627
  MAX_TERMS = 512;
7545
- MAX_LITERAL_LEN = 80;
7628
+ MAX_LITERAL_LEN2 = 80;
7546
7629
  MAX_FUNC_DEPTH = 2;
7547
7630
  NESTED_TYPE_KINDS = /* @__PURE__ */ new Set(["class", "struct", "enum", "interface", "trait", "type", "record", "union"]);
7548
7631
  ANON_DEFAULT_FN = /* @__PURE__ */ new Set([
@@ -7558,6 +7641,9 @@ var init_extract = __esm({
7558
7641
  REF_IDENT_TEXT = /^[A-Za-z_]\w{4,}$/;
7559
7642
  COMMENT_NODE = /(^|_)comment$/;
7560
7643
  STRING_NODE = /(^|_)string(_literal)?$/;
7644
+ NUMBER_NODE = /(^|_)(integer|float|number|decimal|numeric)(_literal)?$/;
7645
+ REGEX_NODE = /(^|_)(regex|regular_expression)(_pattern|_literal)?$/;
7646
+ STRING_PART = /(^|_)(fragment|content|escape_sequence|character)$/;
7561
7647
  }
7562
7648
  });
7563
7649
 
@@ -7807,17 +7893,55 @@ function collectTermsRegex(content) {
7807
7893
  }
7808
7894
  for (const m of line.matchAll(/(['"`])((?:\\.|(?!\1)[^\\])*)\1/g)) {
7809
7895
  const body2 = m[2];
7810
- if (body2.length && body2.length <= MAX_LITERAL_LEN2) add(body2);
7896
+ if (body2.length && body2.length <= MAX_LITERAL_LEN3) add(body2);
7811
7897
  }
7812
7898
  }
7813
7899
  return [...found].sort();
7814
7900
  }
7901
+ function collectLiteralsRegex(content) {
7902
+ const literals = new LiteralCollector();
7903
+ let inBlock = false;
7904
+ let lineNo = 0;
7905
+ for (const raw of content.split("\n")) {
7906
+ lineNo++;
7907
+ if (literals.full) break;
7908
+ let line = raw;
7909
+ if (inBlock) {
7910
+ const close = line.indexOf("*/");
7911
+ if (close === -1) continue;
7912
+ inBlock = false;
7913
+ line = line.slice(close + 2);
7914
+ }
7915
+ const open = line.indexOf("/*");
7916
+ if (open !== -1) {
7917
+ const close = line.indexOf("*/", open + 2);
7918
+ if (close === -1) {
7919
+ inBlock = true;
7920
+ line = line.slice(0, open);
7921
+ } else line = line.slice(0, open) + line.slice(close + 2);
7922
+ }
7923
+ const lineComment = /(^|\s)(\/\/|#|--)(.*)$/.exec(line);
7924
+ if (lineComment) line = line.slice(0, lineComment.index);
7925
+ for (const m of line.matchAll(/(['"`])((?:\\.|(?!\1)[^\\])*)\1/g)) {
7926
+ literals.add("string", m[2], lineNo);
7927
+ }
7928
+ for (const m of line.replace(/(['"`])(?:\\.|(?!\1)[^\\])*\1/g, " ").matchAll(/(?<![\w.])-?\d[\d_]*(?:\.\d+)?(?![\w.])/g)) {
7929
+ literals.add("number", m[0].replace(/_/g, ""), lineNo);
7930
+ }
7931
+ }
7932
+ return literals.result();
7933
+ }
7815
7934
  function extractCode(rel2, ext, content, opts = {}) {
7816
7935
  const ast = extractAst(rel2, ext, content, { maxCalls: opts.maxCallsPerFile, imports: false });
7817
7936
  const raw = ast ? ast.symbols : extractSymbols(rel2, ext, content);
7818
7937
  const symbols = raw.slice(0, MAX_FILE_SYMBOLS);
7819
7938
  const known = new Set(symbols.map((s) => s.name));
7820
7939
  const reexports = extractReexports(rel2, content, symbols).filter((s) => !known.has(s.name));
7940
+ const refs = extractImports(ext, content);
7941
+ const importSpecs = new Set(refs.map((r) => r.spec));
7942
+ const literals = (ast ? ast.literals.length ? ast.literals : void 0 : collectLiteralsRegex(content))?.filter(
7943
+ (l) => !(l.kind === "string" && importSpecs.has(l.value))
7944
+ );
7821
7945
  return {
7822
7946
  symbols: [...symbols, ...reexports],
7823
7947
  // A re-export list that hit its own ceiling is truncated too — a barrel that
@@ -7825,7 +7949,7 @@ function extractCode(rel2, ext, content, opts = {}) {
7825
7949
  // exists to prevent.
7826
7950
  ...ast?.truncated || raw.length > symbols.length || reexports.length >= MAX_REEXPORTS ? { truncated: true } : {},
7827
7951
  summary: topDocComment(content),
7828
- refs: extractImports(ext, content),
7952
+ refs,
7829
7953
  // pkg anchors namespace→source-root resolution: Java's `package`, C#'s
7830
7954
  // `namespace` (block or file-scoped). Both feed the same resolver pattern.
7831
7955
  pkg: ext === ".java" ? /^\s*package\s+([\w.]+)\s*;/m.exec(content)?.[1] : ext === ".cs" ? /^\s*(?:file-scoped\s+)?namespace\s+([\w.]+)/m.exec(content)?.[1] : void 0,
@@ -7840,13 +7964,15 @@ function extractCode(rel2, ext, content, opts = {}) {
7840
7964
  // The AST tier reads comments and literals structurally; without a grammar
7841
7965
  // the line scanner above still supplies a vocabulary, so search quality does
7842
7966
  // not silently collapse for a language with no wasm.
7843
- terms: ast ? ast.terms : collectTermsRegex(content)
7967
+ terms: ast ? ast.terms : collectTermsRegex(content),
7968
+ literals: literals?.length ? literals : void 0
7844
7969
  };
7845
7970
  }
7846
- var MAX_FILE_SYMBOLS, JS_TS, PY, C_CPP, MAX_USE_EXPANSION, CALL_KEYWORDS, DEF_INTRODUCERS, MAX_TERMS2, MAX_LITERAL_LEN2;
7971
+ var MAX_FILE_SYMBOLS, JS_TS, PY, C_CPP, MAX_USE_EXPANSION, CALL_KEYWORDS, DEF_INTRODUCERS, MAX_TERMS2, MAX_LITERAL_LEN3;
7847
7972
  var init_code = __esm({
7848
7973
  "src/extract/code.ts"() {
7849
7974
  "use strict";
7975
+ init_literals();
7850
7976
  init_registry();
7851
7977
  init_extract();
7852
7978
  init_common();
@@ -7895,7 +8021,51 @@ var init_code = __esm({
7895
8021
  ]);
7896
8022
  DEF_INTRODUCERS = /(?:\bfunction|\bdef|\bfunc|\bfun|\bfn|\bclass|\bsub|\bmacro|\bproc)\s*[*]?\s*$/;
7897
8023
  MAX_TERMS2 = 512;
7898
- MAX_LITERAL_LEN2 = 80;
8024
+ MAX_LITERAL_LEN3 = 80;
8025
+ }
8026
+ });
8027
+
8028
+ // src/extract/config.ts
8029
+ function extractConfigLiterals(content) {
8030
+ const literals = new LiteralCollector();
8031
+ let lineNo = 0;
8032
+ for (const raw of content.split("\n")) {
8033
+ lineNo++;
8034
+ if (literals.full) break;
8035
+ const line = stripTrailingComment(raw);
8036
+ for (const m of line.matchAll(QUOTED)) literals.add("string", m[2], lineNo);
8037
+ const bare = UNQUOTED_SCALAR.exec(line);
8038
+ if (bare) {
8039
+ const v = bare[1].trim();
8040
+ if (v && !/^[[{|>&*-]/.test(v) && !HAS_QUOTE.test(v)) literals.add("string", v, lineNo);
8041
+ }
8042
+ for (const m of line.replace(QUOTED, " ").matchAll(BARE_NUMBER)) {
8043
+ literals.add("number", m[0].replace(/_/g, ""), lineNo);
8044
+ }
8045
+ }
8046
+ return literals.result();
8047
+ }
8048
+ function stripTrailingComment(line) {
8049
+ let inQuote;
8050
+ for (let i2 = 0; i2 < line.length; i2++) {
8051
+ const c2 = line[i2];
8052
+ if (inQuote) {
8053
+ if (c2 === "\\") i2++;
8054
+ else if (c2 === inQuote) inQuote = void 0;
8055
+ } else if (c2 === '"' || c2 === "'") inQuote = c2;
8056
+ else if (c2 === "#") return line.slice(0, i2);
8057
+ }
8058
+ return line;
8059
+ }
8060
+ var QUOTED, HAS_QUOTE, BARE_NUMBER, UNQUOTED_SCALAR;
8061
+ var init_config = __esm({
8062
+ "src/extract/config.ts"() {
8063
+ "use strict";
8064
+ init_literals();
8065
+ QUOTED = /(['"])((?:\\.|(?!\1)[^\\])*)\1/g;
8066
+ HAS_QUOTE = /(['"])((?:\\.|(?!\1)[^\\])*)\1/;
8067
+ BARE_NUMBER = /(?<![\w.$-])-?\d[\d_]*(?:\.\d+)?(?![\w.$-])/g;
8068
+ UNQUOTED_SCALAR = /^\s*[\w.$-]+\s*[:=]\s*([^#\n]+?)\s*$/;
7899
8069
  }
7900
8070
  });
7901
8071
 
@@ -8045,6 +8215,10 @@ function scanRepo(root, opts = {}) {
8045
8215
  record.truncated = code.truncated;
8046
8216
  record.relations = code.relations;
8047
8217
  record.terms = code.terms;
8218
+ record.literals = code.literals;
8219
+ } else if (kind === "config") {
8220
+ record.title = basename(f.rel);
8221
+ record.literals = extractConfigLiterals(content);
8048
8222
  } else {
8049
8223
  record.title = basename(f.rel);
8050
8224
  }
@@ -8084,6 +8258,7 @@ var init_scan = __esm({
8084
8258
  init_sort();
8085
8259
  init_markdown();
8086
8260
  init_code();
8261
+ init_config();
8087
8262
  }
8088
8263
  });
8089
8264
 
@@ -11373,6 +11548,137 @@ var init_graph_json = __esm({
11373
11548
  }
11374
11549
  });
11375
11550
 
11551
+ // src/literals.ts
11552
+ function isDistinctive2(value, kind) {
11553
+ if (kind !== "string") return true;
11554
+ return value.length >= DISTINCTIVE_MIN_LEN || HAS_SEPARATOR.test(value);
11555
+ }
11556
+ function isFunctionValued(signature) {
11557
+ if (!signature) return false;
11558
+ for (let i2 = 0; i2 < signature.length; i2++) {
11559
+ if (signature[i2] !== "=") continue;
11560
+ if (signature[i2 + 1] === "=" || signature[i2 + 1] === ">") continue;
11561
+ if ("=!<>+-*/%&|^".includes(signature[i2 - 1] ?? "")) continue;
11562
+ return FUNCTION_RHS.test(signature.slice(i2 + 1));
11563
+ }
11564
+ return false;
11565
+ }
11566
+ function holderFor(symbols, line) {
11567
+ let best;
11568
+ for (const s of symbols) {
11569
+ if (!HOLDER_KINDS.has(s.kind)) continue;
11570
+ if (isFunctionValued(s.signature)) continue;
11571
+ const end = s.endLine ?? s.line;
11572
+ if (end - s.line > MAX_HOLDER_SPAN) continue;
11573
+ if (line < s.line || line > end) continue;
11574
+ if (!best || s.line > best.line) best = s;
11575
+ }
11576
+ return best;
11577
+ }
11578
+ function findLiteralDuplications(scan2, opts = {}) {
11579
+ const minFiles = opts.minFiles ?? DEFAULTS.minFiles;
11580
+ const minCount = opts.minCount ?? DEFAULTS.minCount;
11581
+ const groups = /* @__PURE__ */ new Map();
11582
+ for (const f of scan2.files) {
11583
+ if (!f.literals?.length) continue;
11584
+ if (!opts.includeTests && isTestPath(f.rel)) continue;
11585
+ for (const lit of f.literals) {
11586
+ if (opts.kinds && !opts.kinds.has(lit.kind)) continue;
11587
+ if (!isDistinctive2(lit.value, lit.kind)) continue;
11588
+ const key = `${lit.kind}\0${lit.value}`;
11589
+ let g = groups.get(key);
11590
+ if (!g) groups.set(key, g = { value: lit.value, kind: lit.kind, sites: [] });
11591
+ const holder = holderFor(f.symbols, lit.line);
11592
+ g.sites.push(
11593
+ holder ? { file: f.rel, line: lit.line, holder: holder.name, holderExported: holder.exported } : { file: f.rel, line: lit.line }
11594
+ );
11595
+ }
11596
+ }
11597
+ const duplications = [];
11598
+ for (const g of groups.values()) {
11599
+ const files = new Set(g.sites.map((s) => s.file));
11600
+ if (files.size < minFiles || g.sites.length < minCount) continue;
11601
+ const holders = g.sites.filter((s) => s.holder);
11602
+ const literals = g.sites.filter((s) => !s.holder);
11603
+ const distinctHolderNames = new Set(holders.map((h) => h.holder));
11604
+ const distinctHolders = new Set(holders.map((h) => `${h.file}\0${h.holder}`));
11605
+ let tier = distinctHolders.size >= 2 ? "competing" : holders.length > 0 ? "bypassed" : "uncentralized";
11606
+ if (g.kind === "number") {
11607
+ if (distinctHolderNames.size !== 1 || literals.length === 0) continue;
11608
+ tier = "bypassed";
11609
+ }
11610
+ if (tier === "bypassed" && literals.length === 0) continue;
11611
+ duplications.push({
11612
+ value: g.value,
11613
+ kind: g.kind,
11614
+ tier,
11615
+ holders: holders.sort(siteOrder),
11616
+ literals: literals.sort(siteOrder),
11617
+ files: files.size,
11618
+ count: g.sites.length
11619
+ });
11620
+ }
11621
+ duplications.sort(
11622
+ (a, b) => tierRank(a.tier) - tierRank(b.tier) || b.files - a.files || b.count - a.count || byStr(a.value, b.value)
11623
+ );
11624
+ return { duplications, families: groupFamilies(duplications) };
11625
+ }
11626
+ function siteOrder(a, b) {
11627
+ return byStr(a.file, b.file) || a.line - b.line;
11628
+ }
11629
+ function tierRank(t) {
11630
+ return t === "competing" ? 0 : t === "bypassed" ? 1 : 2;
11631
+ }
11632
+ function groupFamilies(dups) {
11633
+ const byPrefix = /* @__PURE__ */ new Map();
11634
+ for (const d of dups) {
11635
+ if (d.kind !== "string" || !PATH_LIKE.test(d.value)) continue;
11636
+ const prefix = pathPrefix(d.value);
11637
+ if (!prefix) continue;
11638
+ const list = byPrefix.get(prefix);
11639
+ if (list) list.push(d);
11640
+ else byPrefix.set(prefix, [d]);
11641
+ }
11642
+ const families = [];
11643
+ for (const [prefix, members] of byPrefix) {
11644
+ if (members.length < FAMILY_MIN_MEMBERS) continue;
11645
+ const files = /* @__PURE__ */ new Set();
11646
+ let count = 0;
11647
+ for (const m of members) {
11648
+ for (const s of [...m.holders, ...m.literals]) files.add(s.file);
11649
+ count += m.count;
11650
+ }
11651
+ families.push({ prefix, members, files: files.size, count });
11652
+ }
11653
+ families.sort((a, b) => b.files - a.files || b.count - a.count || byStr(a.prefix, b.prefix));
11654
+ return families;
11655
+ }
11656
+ function pathPrefix(value) {
11657
+ const body2 = value.startsWith("/") ? value.slice(1) : value;
11658
+ const cut = body2.search(/[/:]/);
11659
+ if (cut <= 0) return void 0;
11660
+ const prefix = (value.startsWith("/") ? "/" : "") + body2.slice(0, cut);
11661
+ return prefix.length >= FAMILY_MIN_PREFIX ? prefix : void 0;
11662
+ }
11663
+ var LITERAL_DUPLICATION_CAP, DEFAULTS, HOLDER_KINDS, MAX_HOLDER_SPAN, DISTINCTIVE_MIN_LEN, HAS_SEPARATOR, PATH_LIKE, FAMILY_MIN_MEMBERS, FAMILY_MIN_PREFIX, FUNCTION_RHS;
11664
+ var init_literals2 = __esm({
11665
+ "src/literals.ts"() {
11666
+ "use strict";
11667
+ init_tests_map();
11668
+ init_sort();
11669
+ LITERAL_DUPLICATION_CAP = 24;
11670
+ DEFAULTS = { minFiles: 2, minCount: 3 };
11671
+ HOLDER_KINDS = /* @__PURE__ */ new Set(["const", "constant", "variable", "enum", "enumerator", "property", "field", "static"]);
11672
+ MAX_HOLDER_SPAN = 12;
11673
+ DISTINCTIVE_MIN_LEN = 6;
11674
+ HAS_SEPARATOR = /[/:._-]/;
11675
+ PATH_LIKE = /^[/@a-z0-9][\w./:@-]*$/i;
11676
+ FAMILY_MIN_MEMBERS = 2;
11677
+ FAMILY_MIN_PREFIX = 4;
11678
+ FUNCTION_RHS = /^\s*(?:async\b|function\b|\(|[A-Za-z_$][\w$]*\s*=>)/;
11679
+ }
11680
+ });
11681
+
11376
11682
  // src/pipeline.ts
11377
11683
  function buildIndexArtifacts(repo, opts = {}) {
11378
11684
  return buildArtifactsFromScan(scanRepo(repo, opts), opts);
@@ -11397,6 +11703,8 @@ function buildArtifactsFromScan(scan2, opts = {}) {
11397
11703
  }
11398
11704
  const surprises = computeSurprises(graph);
11399
11705
  if (surprises.length) graph.surprises = surprises;
11706
+ const { duplications } = findLiteralDuplications(scan2);
11707
+ if (duplications.length) graph.literalDuplications = duplications.slice(0, LITERAL_DUPLICATION_CAP);
11400
11708
  const symbols = buildSymbolIndex(scan2, symbolRefsFor(scan2), opts.meta?.schemaVersion);
11401
11709
  return { scan: scan2, graph, symbols };
11402
11710
  }
@@ -11411,6 +11719,7 @@ var init_pipeline = __esm({
11411
11719
  init_centrality();
11412
11720
  init_tests_map();
11413
11721
  init_surprise();
11722
+ init_literals2();
11414
11723
  init_symbols_json();
11415
11724
  }
11416
11725
  });
@@ -11907,8 +12216,14 @@ function parseRules(input) {
11907
12216
  throw new Error(`${at} (${r.name}): \`comment\` must be a string`);
11908
12217
  if (r.builtin !== void 0) {
11909
12218
  if (!BUILTINS.has(r.builtin))
11910
- throw new Error(`${at} (${r.name}): \`builtin\` must be "cycles" or "orphans"`);
11911
- return { name: r.name, builtin: r.builtin, severity: r.severity, comment: r.comment };
12219
+ throw new Error(`${at} (${r.name}): \`builtin\` must be "cycles", "orphans" or "literals"`);
12220
+ return {
12221
+ name: r.name,
12222
+ builtin: r.builtin,
12223
+ severity: r.severity,
12224
+ comment: r.comment,
12225
+ ...r.tiers !== void 0 ? { tiers: r.tiers } : {}
12226
+ };
11912
12227
  }
11913
12228
  const glob = (field) => {
11914
12229
  const v = r[field];
@@ -12017,6 +12332,17 @@ function checkRules(graph, rules) {
12017
12332
  for (const c2 of findImportCycles(graph)) {
12018
12333
  emit2(rule, { from: c2.start, to: c2.path.join(" -> "), kind: "cycle" });
12019
12334
  }
12335
+ } else if (rule.builtin === "literals") {
12336
+ const wanted = new Set(rule.tiers?.length ? rule.tiers : GATED_TIERS);
12337
+ for (const d of graph.literalDuplications ?? []) {
12338
+ if (!wanted.has(d.tier)) continue;
12339
+ const origin = d.holders[0] ?? d.literals[0];
12340
+ emit2(rule, {
12341
+ from: `${origin.file}:${origin.line}`,
12342
+ to: `${d.tier} ${JSON.stringify(d.value)} (${d.count} sites, ${d.files} files)`,
12343
+ kind: "literal"
12344
+ });
12345
+ }
12020
12346
  } else {
12021
12347
  for (const f of graph.files) {
12022
12348
  if (f.fileKind !== "code" || f.degIn !== 0 || f.degOut !== 0) continue;
@@ -12040,7 +12366,7 @@ function checkRules(graph, rules) {
12040
12366
  out2.sort((a, b) => byStr(a.rule, b.rule) || byStr(a.from, b.from) || byStr(a.to, b.to) || byStr(a.kind, b.kind));
12041
12367
  return out2;
12042
12368
  }
12043
- var EDGE_KINDS, SEVERITIES, BUILTINS, ENTRYPOINT_STEMS;
12369
+ var EDGE_KINDS, SEVERITIES, BUILTINS, GATED_TIERS, ENTRYPOINT_STEMS;
12044
12370
  var init_rules = __esm({
12045
12371
  "src/rules.ts"() {
12046
12372
  "use strict";
@@ -12048,7 +12374,8 @@ var init_rules = __esm({
12048
12374
  init_sort();
12049
12375
  EDGE_KINDS = /* @__PURE__ */ new Set(["contains", "doc-link", "import", "call", "use", "mention"]);
12050
12376
  SEVERITIES = /* @__PURE__ */ new Set(["error", "warn"]);
12051
- BUILTINS = /* @__PURE__ */ new Set(["cycles", "orphans"]);
12377
+ BUILTINS = /* @__PURE__ */ new Set(["cycles", "orphans", "literals"]);
12378
+ GATED_TIERS = ["competing", "bypassed"];
12052
12379
  ENTRYPOINT_STEMS = /* @__PURE__ */ new Set([
12053
12380
  "index",
12054
12381
  "main",
@@ -12595,6 +12922,22 @@ var init_tools = __esm({
12595
12922
  required: ["repo"]
12596
12923
  }
12597
12924
  },
12925
+ {
12926
+ name: "duplicated_literals",
12927
+ description: "Values with no single source of truth: one literal written out across many files. Three labeled tiers \u2014 'competing' (two or more exported constants hold the same value), 'bypassed' (a constant holds it and other files rewrite it anyway), 'uncentralized' (nothing holds it). Path-like values are also grouped into namespace families, so a whole route space reports once instead of once per route. Covers config files (JSON/YAML/TOML) as well as code, which is where the dangerous cases live: a threshold declared in TypeScript and again in a rules JSON is checked by no compiler. Use it to answer 'what breaks if this value changes' and 'is there already a helper for this'.",
12928
+ inputSchema: {
12929
+ type: "object",
12930
+ properties: {
12931
+ ...repoProp,
12932
+ ...scopeProps,
12933
+ minFiles: { type: "number", description: "Distinct files a value must span (default 2)" },
12934
+ minCount: { type: "number", description: "Total occurrences required (default 3)" },
12935
+ includeTests: { type: "boolean", description: "Count test files too (default false)" },
12936
+ limit: { type: "number", description: "Cap duplications (default: all)" }
12937
+ },
12938
+ required: ["repo"]
12939
+ }
12940
+ },
12598
12941
  {
12599
12942
  name: "complexity",
12600
12943
  description: "Cyclomatic-complexity estimates (branch-token counting over AST line spans), most-complex first. Pass `file` for one file's symbols, omit for the repo-wide top. Combine with hotspots: the `risk` field of this tool's sibling ranks complexity \xD7 churn.",
@@ -12804,6 +13147,14 @@ var init_tools = __esm({
12804
13147
  properties: { ok: { type: "boolean" }, couplings: { type: "array", items: anyObj } },
12805
13148
  required: ["ok", "couplings"]
12806
13149
  },
13150
+ duplicated_literals: {
13151
+ type: "object",
13152
+ properties: {
13153
+ duplications: { type: "array", items: anyObj },
13154
+ families: { type: "array", items: anyObj }
13155
+ },
13156
+ required: ["duplications", "families"]
13157
+ },
12807
13158
  embed_status: {
12808
13159
  type: "object",
12809
13160
  properties: {
@@ -12859,6 +13210,7 @@ var init_tools = __esm({
12859
13210
  list_memories: { title: "List memories" },
12860
13211
  delete_memory: { title: "Delete memory", write: true, destructive: true, idempotent: true },
12861
13212
  dead_code: { title: "Dead-code candidates" },
13213
+ duplicated_literals: { title: "Values with no single source of truth" },
12862
13214
  complexity: { title: "Complexity" },
12863
13215
  mermaid: { title: "Mermaid module diagram" },
12864
13216
  grep: { title: "Grep file contents" },
@@ -13145,6 +13497,26 @@ async function callTool(name2, args2, defaultRepo) {
13145
13497
  if (limit === void 0 || all.length <= limit) return JSON.stringify(all, null, 2);
13146
13498
  return JSON.stringify({ total: all.length, shown: limit, truncated: true, candidates: all.slice(0, limit) }, null, 2);
13147
13499
  }
13500
+ if (name2 === "duplicated_literals") {
13501
+ const report = findLiteralDuplications(getScan(repo, scanOpts, walked), {
13502
+ minFiles: num(args2.minFiles),
13503
+ minCount: num(args2.minCount),
13504
+ includeTests: args2.includeTests === true
13505
+ });
13506
+ const limit = num(args2.limit);
13507
+ if (limit === void 0 || report.duplications.length <= limit) return JSON.stringify(report, null, 2);
13508
+ return JSON.stringify(
13509
+ {
13510
+ total: report.duplications.length,
13511
+ shown: limit,
13512
+ truncated: true,
13513
+ duplications: report.duplications.slice(0, limit),
13514
+ families: report.families
13515
+ },
13516
+ null,
13517
+ 2
13518
+ );
13519
+ }
13148
13520
  if (name2 === "complexity") {
13149
13521
  const scan2 = getScan(repo, scanOpts, walked);
13150
13522
  if (args2.risk === true) {
@@ -13382,6 +13754,7 @@ var init_mcp = __esm({
13382
13754
  init_coupling();
13383
13755
  init_repomap();
13384
13756
  init_deadcode();
13757
+ init_literals2();
13385
13758
  init_complexity();
13386
13759
  init_viz();
13387
13760
  init_query();
@@ -13713,7 +14086,16 @@ var CODE_EXTS = /* @__PURE__ */ new Set([
13713
14086
  ".sh",
13714
14087
  ".bash",
13715
14088
  ".zig",
13716
- ".elm"
14089
+ ".elm",
14090
+ ".hcl",
14091
+ ".tf",
14092
+ ".tfvars",
14093
+ ".sol",
14094
+ ".hh",
14095
+ ".sc",
14096
+ ".pyi",
14097
+ ".rake",
14098
+ ".cxx"
13717
14099
  ]);
13718
14100
  var STYLE_EXTS = /* @__PURE__ */ new Set([".css", ".scss", ".sass", ".less", ".styl", ".pcss"]);
13719
14101
  var DOC_EXTS = /* @__PURE__ */ new Set([".md", ".mdx", ".rst", ".adoc", ".txt"]);
@@ -14400,6 +14782,7 @@ init_rules();
14400
14782
  init_coupling();
14401
14783
  init_repomap();
14402
14784
  init_deadcode();
14785
+ init_literals2();
14403
14786
  init_complexity();
14404
14787
  init_viz();
14405
14788
 
@@ -14813,6 +15196,7 @@ init_grep();
14813
15196
  init_coupling();
14814
15197
  init_repomap();
14815
15198
  init_deadcode();
15199
+ init_literals2();
14816
15200
  init_complexity();
14817
15201
  init_viz();
14818
15202
  init_bm25();
@@ -14865,12 +15249,21 @@ Commands:
14865
15249
  asset into the shared cache (sha256-verified,
14866
15250
  atomic). Override the source with
14867
15251
  CODEINDEX_GRAMMARS_URL
14868
- rules Architecture rules (forbidden edges, cycles, orphans) validated
14869
- against the link-graph: --config <codeindex.rules.json>; exits 1
14870
- on any error-severity violation (a CI gate)
15252
+ rules Architecture rules (forbidden edges, cycles, orphans, literals)
15253
+ validated against the link-graph: --config <codeindex.rules.json>;
15254
+ exits 1 on any error-severity violation (a CI gate)
14871
15255
  repomap Token-budgeted map of the highest-PageRank files (--budget-tokens)
14872
15256
  hotspots Churn \xD7 size ranking of the files where work concentrates (JSON)
14873
15257
  coupling Change coupling: files that change together (JSON; --since <ref>)
15258
+ literals Values with no single source of truth: one literal written out
15259
+ across many files, in three labeled tiers \u2014 'competing' (two or
15260
+ more exported constants hold it), 'bypassed' (a constant holds
15261
+ it and other files rewrite it anyway), 'uncentralized' (nothing
15262
+ holds it). Groups path-like values into namespace families so a
15263
+ whole route space reports once, not forty times. Reads code AND
15264
+ config files (JSON/YAML/TOML), because the duplications that hurt
15265
+ are the ones crossing a language boundary no compiler checks.
15266
+ (--min-files, --min-count, --include-tests)
14874
15267
  deadcode Dead-code candidates in two labeled tiers: 'unreferenced' (no
14875
15268
  call site binds AND nothing references the name) and 'uncalled'
14876
15269
  (referenced \u2014 re-export, type position \u2014 but never called)
@@ -14891,7 +15284,7 @@ Commands:
14891
15284
  and exits 0, or exits 1 when it has no opinion (run the original).
14892
15285
  Deliberately conservative \u2014 any shell metacharacter or unknown
14893
15286
  flag refuses the rewrite
14894
- mcp Run as an MCP server over stdio (26 tools: scan_summary, graph,
15287
+ mcp Run as an MCP server over stdio (30 tools: scan_summary, graph,
14895
15288
  symbols, callers, workspaces, churn, symbols_overview,
14896
15289
  find_symbol, find_references, repo_map, hotspots, coupling,
14897
15290
  dead_code, complexity, mermaid, grep, search, embed_status,
@@ -14945,6 +15338,10 @@ Flags (accepted before OR after the subcommand: '--repo X scan' and
14945
15338
  each site corroborated|unique-name
14946
15339
  --ignore-case \`grep\`: case-insensitive matching
14947
15340
  --max-hits <n> \`grep\`: cap returned hits (default 200)
15341
+ --min-files <n> \`literals\`: distinct files a value must span (default 2)
15342
+ --min-count <n> \`literals\`: total occurrences required (default 3)
15343
+ --include-tests \`literals\`: count test files too. Off by default \u2014 a test
15344
+ restating a value is usually asserting it deliberately
14948
15345
  `;
14949
15346
  function parseFlags(args2) {
14950
15347
  const flags2 = { repo: process.cwd(), include: [], exclude: [], gitignore: true, ignoreDirs: [], noAst: false, fuzzy: true, semantic: false };
@@ -14977,6 +15374,9 @@ function parseFlags(args2) {
14977
15374
  else if (a === "--ignore-case") flags2.ignoreCase = true;
14978
15375
  else if (a === "--max-hits") flags2.maxHits = num2();
14979
15376
  else if (a === "--budget-tokens") flags2.budgetTokens = num2();
15377
+ else if (a === "--min-files") flags2.minFiles = num2();
15378
+ else if (a === "--min-count") flags2.minCount = num2();
15379
+ else if (a === "--include-tests") flags2.includeTests = true;
14980
15380
  else if (a === "--no-ast") flags2.noAst = true;
14981
15381
  else if (a === "--index") flags2.indexDir = next();
14982
15382
  else if (a === "--no-index-cache") flags2.noIndexCache = true;
@@ -15070,6 +15470,8 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
15070
15470
  "--max-calls",
15071
15471
  "--max-hits",
15072
15472
  "--budget-tokens",
15473
+ "--min-files",
15474
+ "--min-count",
15073
15475
  "--since",
15074
15476
  "--config",
15075
15477
  "--limit",
@@ -15501,6 +15903,13 @@ async function runCli(rawArgv) {
15501
15903
  emit(JSON.stringify({ ok, couplings }, null, 2) + "\n", flags2.out);
15502
15904
  } else if (cmd === "deadcode") {
15503
15905
  emit(JSON.stringify(findDeadCode(readScan()), null, 2) + "\n", flags2.out);
15906
+ } else if (cmd === "literals") {
15907
+ const report = findLiteralDuplications(readScan(), {
15908
+ minFiles: flags2.minFiles,
15909
+ minCount: flags2.minCount,
15910
+ includeTests: flags2.includeTests
15911
+ });
15912
+ emit(JSON.stringify(report, null, 2) + "\n", flags2.out);
15504
15913
  } else if (cmd === "complexity") {
15505
15914
  const scan2 = readScan();
15506
15915
  emit(JSON.stringify(symbolComplexity(scan2, flags2.positional), null, 2) + "\n", flags2.out);
@@ -15625,6 +16034,7 @@ export {
15625
16034
  fetchExpectedSha256,
15626
16035
  fetchGrammarsTarball,
15627
16036
  findDeadCode,
16037
+ findLiteralDuplications,
15628
16038
  findReferences,
15629
16039
  findSymbol,
15630
16040
  foldText,