ultra11y 2.29.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.
@@ -6,7 +6,7 @@ import { join as join45, relative as relative4, sep as sep7, dirname as dirname1
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "2.29.0";
9
+ var VERSION = "2.30.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -1197,7 +1197,7 @@ var wcag_universe_default = {
1197
1197
  wcagVersion: "2.2",
1198
1198
  source: "https://www.w3.org/TR/WCAG22/",
1199
1199
  criteriaSource: "https://github.com/w3c/wcag",
1200
- provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-02 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
1200
+ provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-06 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
1201
1201
  criteria: [
1202
1202
  {
1203
1203
  id: "1.1.1",
@@ -18887,9 +18887,9 @@ var EXTRACTOR_VERSION;
18887
18887
  var init_types = __esm({
18888
18888
  "src/types.ts"() {
18889
18889
  "use strict";
18890
- ENGINE_VERSION = "2.26.0";
18890
+ ENGINE_VERSION = "2.27.1";
18891
18891
  SCHEMA_VERSION2 = 5;
18892
- EXTRACTOR_VERSION = 12;
18892
+ EXTRACTOR_VERSION = 13;
18893
18893
  }
18894
18894
  });
18895
18895
  function sh(cmd, args2, opts = {}) {
@@ -19786,7 +19786,18 @@ var init_common = __esm({
19786
19786
  ".scss": "scss",
19787
19787
  ".vue": "vue",
19788
19788
  ".svelte": "svelte",
19789
- ".astro": "astro"
19789
+ ".astro": "astro",
19790
+ // Extended-tier AST languages (src/ast/loader.ts EXT_GRAMMAR). Without an
19791
+ // entry here `extToLang` answered "other", which `classify` reads as non-code
19792
+ // — so a real scan never called extractCode for them and they extracted
19793
+ // nothing, while the quality harness (which calls extractCode directly)
19794
+ // published a perfect score. The grammar still arrives via `grammars pull`;
19795
+ // absent it these fall back to the regex tier like any other language.
19796
+ ".zig": "zig",
19797
+ ".hcl": "hcl",
19798
+ ".tf": "terraform",
19799
+ ".tfvars": "terraform",
19800
+ ".sol": "solidity"
19790
19801
  };
19791
19802
  REEXPORT_EXTS = /* @__PURE__ */ new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
19792
19803
  MAX_REEXPORTS = 400;
@@ -20547,6 +20558,68 @@ var init_markdown = __esm({
20547
20558
  "use strict";
20548
20559
  }
20549
20560
  });
20561
+ function isInterestingString(value) {
20562
+ if (value.length < MIN_LITERAL_LEN || value.length > MAX_LITERAL_LEN) return false;
20563
+ return HAS_SUBSTANCE.test(value);
20564
+ }
20565
+ function isInterestingNumber(raw) {
20566
+ const v = raw.trim();
20567
+ if (!v || v.length > MAX_LITERAL_LEN) return false;
20568
+ if (TRIVIAL_NUMBERS.has(v)) return false;
20569
+ return Number.isFinite(Number(v));
20570
+ }
20571
+ function unquote(text) {
20572
+ let s = text;
20573
+ const raw = /^(?:[rRbBuUfF]{1,2}|@|\$)?(?:#*)?(['"`])/.exec(s);
20574
+ if (raw) {
20575
+ const q = raw[1];
20576
+ const start2 = s.indexOf(q);
20577
+ const end = s.lastIndexOf(q);
20578
+ if (end > start2) s = s.slice(start2 + 1, end);
20579
+ }
20580
+ return s;
20581
+ }
20582
+ var MAX_LITERAL_LEN;
20583
+ var MIN_LITERAL_LEN;
20584
+ var MAX_LITERALS;
20585
+ var TRIVIAL_NUMBERS;
20586
+ var HAS_SUBSTANCE;
20587
+ var LiteralCollector;
20588
+ var init_literals = __esm({
20589
+ "src/extract/literals.ts"() {
20590
+ "use strict";
20591
+ init_sort();
20592
+ MAX_LITERAL_LEN = 80;
20593
+ MIN_LITERAL_LEN = 2;
20594
+ MAX_LITERALS = 256;
20595
+ TRIVIAL_NUMBERS = /* @__PURE__ */ new Set(["0", "1", "-1", "2", "-2"]);
20596
+ HAS_SUBSTANCE = /[\p{L}\p{N}]/u;
20597
+ LiteralCollector = class {
20598
+ seen = /* @__PURE__ */ new Set();
20599
+ out = [];
20600
+ get full() {
20601
+ return this.out.length >= MAX_LITERALS;
20602
+ }
20603
+ add(kind, value, line) {
20604
+ if (this.full) return;
20605
+ if (kind === "string" && !isInterestingString(value)) return;
20606
+ if (kind === "number" && !isInterestingNumber(value)) return;
20607
+ if (kind === "regex" && !value) return;
20608
+ const key = `${kind}\0${value}\0${line}`;
20609
+ if (this.seen.has(key)) return;
20610
+ this.seen.add(key);
20611
+ this.out.push({ value, line, kind });
20612
+ }
20613
+ addString(text, line) {
20614
+ this.add("string", unquote(text), line);
20615
+ }
20616
+ result() {
20617
+ if (!this.out.length) return void 0;
20618
+ return this.out.sort((a, b) => byStr(a.value, b.value) || a.line - b.line || byStr(a.kind, b.kind));
20619
+ }
20620
+ };
20621
+ }
20622
+ });
20550
20623
  function assertInternal(x) {
20551
20624
  if (x !== INTERNAL) throw new Error("Illegal constructor");
20552
20625
  }
@@ -25961,6 +26034,9 @@ var init_doc = __esm({
25961
26034
  DECORATION = /decorator|annotation|modifiers/;
25962
26035
  }
25963
26036
  });
26037
+ function isPlainString(node) {
26038
+ return node.namedChildren.every((c2) => STRING_PART.test(c2.type));
26039
+ }
25964
26040
  function collectAll(root, spec, defNames, maxCalls, wantImports) {
25965
26041
  const identsFound = /* @__PURE__ */ new Set();
25966
26042
  const wantCalls = spec.calls !== void 0;
@@ -25982,6 +26058,7 @@ function collectAll(root, spec, defNames, maxCalls, wantImports) {
25982
26058
  termsFound.add(t2);
25983
26059
  }
25984
26060
  };
26061
+ const literals = new LiteralCollector();
25985
26062
  const wantNames = spec.imports?.import_statement !== void 0;
25986
26063
  const namesFound = /* @__PURE__ */ new Set();
25987
26064
  const wantRefs = wantImports && spec.imports !== void 0;
@@ -26003,9 +26080,15 @@ function collectAll(root, spec, defNames, maxCalls, wantImports) {
26003
26080
  }
26004
26081
  if (COMMENT_NODE.test(type)) {
26005
26082
  for (const line of node.text.split(/\r?\n/)) addTerms2(stripCommentMarkers(line));
26006
- } else if (kids.length === 0 && STRING_NODE.test(type) && node.endIndex - node.startIndex <= MAX_LITERAL_LEN) {
26083
+ } else if (kids.length === 0 && STRING_NODE.test(type) && node.endIndex - node.startIndex <= MAX_LITERAL_LEN2) {
26007
26084
  addTerms2(node.text.replace(/^['"`]+|['"`]+$/g, ""));
26008
26085
  }
26086
+ if (!literals.full) {
26087
+ const line = node.startPosition.row + 1;
26088
+ if (STRING_NODE.test(type) && isPlainString(node)) literals.addString(node.text, line);
26089
+ else if (kids.length === 0 && NUMBER_NODE.test(type)) literals.add("number", node.text.trim(), line);
26090
+ else if (REGEX_NODE.test(type)) literals.add("regex", node.text, line);
26091
+ }
26009
26092
  if (wantCalls && !(spec.kindFrom?.[type] && spec.kindFrom[type](node)) && !spec.skipCall?.(node)) {
26010
26093
  const how = spec.calls[type];
26011
26094
  if (how === "function") {
@@ -26054,7 +26137,8 @@ function collectAll(root, spec, defNames, maxCalls, wantImports) {
26054
26137
  idents: [...identsFound].sort().slice(0, MAX_REF_IDENTS),
26055
26138
  calls: calls.slice(0, maxCalls),
26056
26139
  importedNames: [...namesFound].sort(byStr).slice(0, MAX_IMPORTED_NAMES),
26057
- terms: [...termsFound].sort(byStr)
26140
+ terms: [...termsFound].sort(byStr),
26141
+ literals: literals.result() ?? []
26058
26142
  };
26059
26143
  }
26060
26144
  function boundNames(pattern) {
@@ -26396,7 +26480,7 @@ function extractAst(rel2, ext2, content, opts = {}) {
26396
26480
  for (const s of symbols) if (!s.exported && exportedNames.has(s.name)) s.exported = true;
26397
26481
  }
26398
26482
  const wantImports = opts.imports !== false;
26399
- const { refs, idents, calls, importedNames, terms } = collectAll(
26483
+ const { refs, idents, calls, importedNames, terms, literals } = collectAll(
26400
26484
  root,
26401
26485
  spec,
26402
26486
  new Set(symbols.map((s) => s.name)),
@@ -26418,6 +26502,7 @@ function extractAst(rel2, ext2, content, opts = {}) {
26418
26502
  importedNames,
26419
26503
  relations,
26420
26504
  terms,
26505
+ literals,
26421
26506
  ...symbols.length >= maxSymbols ? { truncated: true } : {}
26422
26507
  };
26423
26508
  } catch {
@@ -26432,7 +26517,7 @@ var MAX_IMPORTED_NAMES;
26432
26517
  var MAX_SYMBOLS;
26433
26518
  var MAX_RELATIONS;
26434
26519
  var MAX_TERMS;
26435
- var MAX_LITERAL_LEN;
26520
+ var MAX_LITERAL_LEN2;
26436
26521
  var MAX_FUNC_DEPTH;
26437
26522
  var NESTED_TYPE_KINDS;
26438
26523
  var ANON_DEFAULT_FN;
@@ -26441,9 +26526,13 @@ var REF_IDENT_TYPE;
26441
26526
  var REF_IDENT_TEXT;
26442
26527
  var COMMENT_NODE;
26443
26528
  var STRING_NODE;
26529
+ var NUMBER_NODE;
26530
+ var REGEX_NODE;
26531
+ var STRING_PART;
26444
26532
  var init_extract = __esm({
26445
26533
  "src/ast/extract.ts"() {
26446
26534
  "use strict";
26535
+ init_literals();
26447
26536
  init_sort();
26448
26537
  init_loader();
26449
26538
  init_node();
@@ -26458,7 +26547,7 @@ var init_extract = __esm({
26458
26547
  MAX_SYMBOLS = 2e3;
26459
26548
  MAX_RELATIONS = 256;
26460
26549
  MAX_TERMS = 512;
26461
- MAX_LITERAL_LEN = 80;
26550
+ MAX_LITERAL_LEN2 = 80;
26462
26551
  MAX_FUNC_DEPTH = 2;
26463
26552
  NESTED_TYPE_KINDS = /* @__PURE__ */ new Set(["class", "struct", "enum", "interface", "trait", "type", "record", "union"]);
26464
26553
  ANON_DEFAULT_FN = /* @__PURE__ */ new Set([
@@ -26474,6 +26563,9 @@ var init_extract = __esm({
26474
26563
  REF_IDENT_TEXT = /^[A-Za-z_]\w{4,}$/;
26475
26564
  COMMENT_NODE = /(^|_)comment$/;
26476
26565
  STRING_NODE = /(^|_)string(_literal)?$/;
26566
+ NUMBER_NODE = /(^|_)(integer|float|number|decimal|numeric)(_literal)?$/;
26567
+ REGEX_NODE = /(^|_)(regex|regular_expression)(_pattern|_literal)?$/;
26568
+ STRING_PART = /(^|_)(fragment|content|escape_sequence|character)$/;
26477
26569
  }
26478
26570
  });
26479
26571
  function topDocComment(content) {
@@ -26721,17 +26813,55 @@ function collectTermsRegex(content) {
26721
26813
  }
26722
26814
  for (const m of line.matchAll(/(['"`])((?:\\.|(?!\1)[^\\])*)\1/g)) {
26723
26815
  const body2 = m[2];
26724
- if (body2.length && body2.length <= MAX_LITERAL_LEN2) add2(body2);
26816
+ if (body2.length && body2.length <= MAX_LITERAL_LEN3) add2(body2);
26725
26817
  }
26726
26818
  }
26727
26819
  return [...found].sort();
26728
26820
  }
26821
+ function collectLiteralsRegex(content) {
26822
+ const literals = new LiteralCollector();
26823
+ let inBlock = false;
26824
+ let lineNo = 0;
26825
+ for (const raw of content.split("\n")) {
26826
+ lineNo++;
26827
+ if (literals.full) break;
26828
+ let line = raw;
26829
+ if (inBlock) {
26830
+ const close = line.indexOf("*/");
26831
+ if (close === -1) continue;
26832
+ inBlock = false;
26833
+ line = line.slice(close + 2);
26834
+ }
26835
+ const open = line.indexOf("/*");
26836
+ if (open !== -1) {
26837
+ const close = line.indexOf("*/", open + 2);
26838
+ if (close === -1) {
26839
+ inBlock = true;
26840
+ line = line.slice(0, open);
26841
+ } else line = line.slice(0, open) + line.slice(close + 2);
26842
+ }
26843
+ const lineComment = /(^|\s)(\/\/|#|--)(.*)$/.exec(line);
26844
+ if (lineComment) line = line.slice(0, lineComment.index);
26845
+ for (const m of line.matchAll(/(['"`])((?:\\.|(?!\1)[^\\])*)\1/g)) {
26846
+ literals.add("string", m[2], lineNo);
26847
+ }
26848
+ for (const m of line.replace(/(['"`])(?:\\.|(?!\1)[^\\])*\1/g, " ").matchAll(/(?<![\w.])-?\d[\d_]*(?:\.\d+)?(?![\w.])/g)) {
26849
+ literals.add("number", m[0].replace(/_/g, ""), lineNo);
26850
+ }
26851
+ }
26852
+ return literals.result();
26853
+ }
26729
26854
  function extractCode(rel2, ext2, content, opts = {}) {
26730
26855
  const ast = extractAst(rel2, ext2, content, { maxCalls: opts.maxCallsPerFile, imports: false });
26731
26856
  const raw = ast ? ast.symbols : extractSymbols(rel2, ext2, content);
26732
26857
  const symbols = raw.slice(0, MAX_FILE_SYMBOLS);
26733
26858
  const known = new Set(symbols.map((s) => s.name));
26734
26859
  const reexports = extractReexports(rel2, content, symbols).filter((s) => !known.has(s.name));
26860
+ const refs = extractImports(ext2, content);
26861
+ const importSpecs = new Set(refs.map((r) => r.spec));
26862
+ const literals = (ast ? ast.literals.length ? ast.literals : void 0 : collectLiteralsRegex(content))?.filter(
26863
+ (l) => !(l.kind === "string" && importSpecs.has(l.value))
26864
+ );
26735
26865
  return {
26736
26866
  symbols: [...symbols, ...reexports],
26737
26867
  // A re-export list that hit its own ceiling is truncated too — a barrel that
@@ -26739,7 +26869,7 @@ function extractCode(rel2, ext2, content, opts = {}) {
26739
26869
  // exists to prevent.
26740
26870
  ...ast?.truncated || raw.length > symbols.length || reexports.length >= MAX_REEXPORTS ? { truncated: true } : {},
26741
26871
  summary: topDocComment(content),
26742
- refs: extractImports(ext2, content),
26872
+ refs,
26743
26873
  // pkg anchors namespace→source-root resolution: Java's `package`, C#'s
26744
26874
  // `namespace` (block or file-scoped). Both feed the same resolver pattern.
26745
26875
  pkg: ext2 === ".java" ? /^\s*package\s+([\w.]+)\s*;/m.exec(content)?.[1] : ext2 === ".cs" ? /^\s*(?:file-scoped\s+)?namespace\s+([\w.]+)/m.exec(content)?.[1] : void 0,
@@ -26754,7 +26884,8 @@ function extractCode(rel2, ext2, content, opts = {}) {
26754
26884
  // The AST tier reads comments and literals structurally; without a grammar
26755
26885
  // the line scanner above still supplies a vocabulary, so search quality does
26756
26886
  // not silently collapse for a language with no wasm.
26757
- terms: ast ? ast.terms : collectTermsRegex(content)
26887
+ terms: ast ? ast.terms : collectTermsRegex(content),
26888
+ literals: literals?.length ? literals : void 0
26758
26889
  };
26759
26890
  }
26760
26891
  var MAX_FILE_SYMBOLS;
@@ -26765,10 +26896,11 @@ var MAX_USE_EXPANSION;
26765
26896
  var CALL_KEYWORDS;
26766
26897
  var DEF_INTRODUCERS;
26767
26898
  var MAX_TERMS2;
26768
- var MAX_LITERAL_LEN2;
26899
+ var MAX_LITERAL_LEN3;
26769
26900
  var init_code = __esm({
26770
26901
  "src/extract/code.ts"() {
26771
26902
  "use strict";
26903
+ init_literals();
26772
26904
  init_registry();
26773
26905
  init_extract();
26774
26906
  init_common();
@@ -26817,7 +26949,52 @@ var init_code = __esm({
26817
26949
  ]);
26818
26950
  DEF_INTRODUCERS = /(?:\bfunction|\bdef|\bfunc|\bfun|\bfn|\bclass|\bsub|\bmacro|\bproc)\s*[*]?\s*$/;
26819
26951
  MAX_TERMS2 = 512;
26820
- MAX_LITERAL_LEN2 = 80;
26952
+ MAX_LITERAL_LEN3 = 80;
26953
+ }
26954
+ });
26955
+ function extractConfigLiterals(content) {
26956
+ const literals = new LiteralCollector();
26957
+ let lineNo = 0;
26958
+ for (const raw of content.split("\n")) {
26959
+ lineNo++;
26960
+ if (literals.full) break;
26961
+ const line = stripTrailingComment(raw);
26962
+ for (const m of line.matchAll(QUOTED)) literals.add("string", m[2], lineNo);
26963
+ const bare = UNQUOTED_SCALAR.exec(line);
26964
+ if (bare) {
26965
+ const v = bare[1].trim();
26966
+ if (v && !/^[[{|>&*-]/.test(v) && !HAS_QUOTE.test(v)) literals.add("string", v, lineNo);
26967
+ }
26968
+ for (const m of line.replace(QUOTED, " ").matchAll(BARE_NUMBER)) {
26969
+ literals.add("number", m[0].replace(/_/g, ""), lineNo);
26970
+ }
26971
+ }
26972
+ return literals.result();
26973
+ }
26974
+ function stripTrailingComment(line) {
26975
+ let inQuote;
26976
+ for (let i2 = 0; i2 < line.length; i2++) {
26977
+ const c2 = line[i2];
26978
+ if (inQuote) {
26979
+ if (c2 === "\\") i2++;
26980
+ else if (c2 === inQuote) inQuote = void 0;
26981
+ } else if (c2 === '"' || c2 === "'") inQuote = c2;
26982
+ else if (c2 === "#") return line.slice(0, i2);
26983
+ }
26984
+ return line;
26985
+ }
26986
+ var QUOTED;
26987
+ var HAS_QUOTE;
26988
+ var BARE_NUMBER;
26989
+ var UNQUOTED_SCALAR;
26990
+ var init_config = __esm({
26991
+ "src/extract/config.ts"() {
26992
+ "use strict";
26993
+ init_literals();
26994
+ QUOTED = /(['"])((?:\\.|(?!\1)[^\\])*)\1/g;
26995
+ HAS_QUOTE = /(['"])((?:\\.|(?!\1)[^\\])*)\1/;
26996
+ BARE_NUMBER = /(?<![\w.$-])-?\d[\d_]*(?:\.\d+)?(?![\w.$-])/g;
26997
+ UNQUOTED_SCALAR = /^\s*[\w.$-]+\s*[:=]\s*([^#\n]+?)\s*$/;
26821
26998
  }
26822
26999
  });
26823
27000
  function countLines(s) {
@@ -26964,6 +27141,10 @@ function scanRepo(root, opts = {}) {
26964
27141
  record.truncated = code2.truncated;
26965
27142
  record.relations = code2.relations;
26966
27143
  record.terms = code2.terms;
27144
+ record.literals = code2.literals;
27145
+ } else if (kind === "config") {
27146
+ record.title = basename(f.rel);
27147
+ record.literals = extractConfigLiterals(content);
26967
27148
  } else {
26968
27149
  record.title = basename(f.rel);
26969
27150
  }
@@ -27003,6 +27184,7 @@ var init_scan = __esm({
27003
27184
  init_sort();
27004
27185
  init_markdown();
27005
27186
  init_code();
27187
+ init_config();
27006
27188
  }
27007
27189
  });
27008
27190
  function toCacheMap(scan2) {
@@ -30277,6 +30459,144 @@ var init_graph_json = __esm({
30277
30459
  init_sort();
30278
30460
  }
30279
30461
  });
30462
+ function isDistinctive2(value, kind) {
30463
+ if (kind !== "string") return true;
30464
+ return value.length >= DISTINCTIVE_MIN_LEN || HAS_SEPARATOR.test(value);
30465
+ }
30466
+ function isFunctionValued(signature) {
30467
+ if (!signature) return false;
30468
+ for (let i2 = 0; i2 < signature.length; i2++) {
30469
+ if (signature[i2] !== "=") continue;
30470
+ if (signature[i2 + 1] === "=" || signature[i2 + 1] === ">") continue;
30471
+ if ("=!<>+-*/%&|^".includes(signature[i2 - 1] ?? "")) continue;
30472
+ return FUNCTION_RHS.test(signature.slice(i2 + 1));
30473
+ }
30474
+ return false;
30475
+ }
30476
+ function holderFor(symbols, line) {
30477
+ let best;
30478
+ for (const s of symbols) {
30479
+ if (!HOLDER_KINDS.has(s.kind)) continue;
30480
+ if (isFunctionValued(s.signature)) continue;
30481
+ const end = s.endLine ?? s.line;
30482
+ if (end - s.line > MAX_HOLDER_SPAN) continue;
30483
+ if (line < s.line || line > end) continue;
30484
+ if (!best || s.line > best.line) best = s;
30485
+ }
30486
+ return best;
30487
+ }
30488
+ function findLiteralDuplications(scan2, opts = {}) {
30489
+ const minFiles = opts.minFiles ?? DEFAULTS.minFiles;
30490
+ const minCount = opts.minCount ?? DEFAULTS.minCount;
30491
+ const groups = /* @__PURE__ */ new Map();
30492
+ for (const f of scan2.files) {
30493
+ if (!f.literals?.length) continue;
30494
+ if (!opts.includeTests && isTestPath(f.rel)) continue;
30495
+ for (const lit of f.literals) {
30496
+ if (opts.kinds && !opts.kinds.has(lit.kind)) continue;
30497
+ if (!isDistinctive2(lit.value, lit.kind)) continue;
30498
+ const key = `${lit.kind}\0${lit.value}`;
30499
+ let g = groups.get(key);
30500
+ if (!g) groups.set(key, g = { value: lit.value, kind: lit.kind, sites: [] });
30501
+ const holder = holderFor(f.symbols, lit.line);
30502
+ g.sites.push(
30503
+ holder ? { file: f.rel, line: lit.line, holder: holder.name, holderExported: holder.exported } : { file: f.rel, line: lit.line }
30504
+ );
30505
+ }
30506
+ }
30507
+ const duplications = [];
30508
+ for (const g of groups.values()) {
30509
+ const files = new Set(g.sites.map((s) => s.file));
30510
+ if (files.size < minFiles || g.sites.length < minCount) continue;
30511
+ const holders = g.sites.filter((s) => s.holder);
30512
+ const literals = g.sites.filter((s) => !s.holder);
30513
+ const distinctHolderNames = new Set(holders.map((h) => h.holder));
30514
+ const distinctHolders = new Set(holders.map((h) => `${h.file}\0${h.holder}`));
30515
+ let tier = distinctHolders.size >= 2 ? "competing" : holders.length > 0 ? "bypassed" : "uncentralized";
30516
+ if (g.kind === "number") {
30517
+ if (distinctHolderNames.size !== 1 || literals.length === 0) continue;
30518
+ tier = "bypassed";
30519
+ }
30520
+ if (tier === "bypassed" && literals.length === 0) continue;
30521
+ duplications.push({
30522
+ value: g.value,
30523
+ kind: g.kind,
30524
+ tier,
30525
+ holders: holders.sort(siteOrder),
30526
+ literals: literals.sort(siteOrder),
30527
+ files: files.size,
30528
+ count: g.sites.length
30529
+ });
30530
+ }
30531
+ duplications.sort(
30532
+ (a, b) => tierRank(a.tier) - tierRank(b.tier) || b.files - a.files || b.count - a.count || byStr(a.value, b.value)
30533
+ );
30534
+ return { duplications, families: groupFamilies(duplications) };
30535
+ }
30536
+ function siteOrder(a, b) {
30537
+ return byStr(a.file, b.file) || a.line - b.line;
30538
+ }
30539
+ function tierRank(t2) {
30540
+ return t2 === "competing" ? 0 : t2 === "bypassed" ? 1 : 2;
30541
+ }
30542
+ function groupFamilies(dups) {
30543
+ const byPrefix = /* @__PURE__ */ new Map();
30544
+ for (const d of dups) {
30545
+ if (d.kind !== "string" || !PATH_LIKE.test(d.value)) continue;
30546
+ const prefix2 = pathPrefix(d.value);
30547
+ if (!prefix2) continue;
30548
+ const list = byPrefix.get(prefix2);
30549
+ if (list) list.push(d);
30550
+ else byPrefix.set(prefix2, [d]);
30551
+ }
30552
+ const families = [];
30553
+ for (const [prefix2, members] of byPrefix) {
30554
+ if (members.length < FAMILY_MIN_MEMBERS) continue;
30555
+ const files = /* @__PURE__ */ new Set();
30556
+ let count = 0;
30557
+ for (const m of members) {
30558
+ for (const s of [...m.holders, ...m.literals]) files.add(s.file);
30559
+ count += m.count;
30560
+ }
30561
+ families.push({ prefix: prefix2, members, files: files.size, count });
30562
+ }
30563
+ families.sort((a, b) => b.files - a.files || b.count - a.count || byStr(a.prefix, b.prefix));
30564
+ return families;
30565
+ }
30566
+ function pathPrefix(value) {
30567
+ const body2 = value.startsWith("/") ? value.slice(1) : value;
30568
+ const cut = body2.search(/[/:]/);
30569
+ if (cut <= 0) return void 0;
30570
+ const prefix2 = (value.startsWith("/") ? "/" : "") + body2.slice(0, cut);
30571
+ return prefix2.length >= FAMILY_MIN_PREFIX ? prefix2 : void 0;
30572
+ }
30573
+ var LITERAL_DUPLICATION_CAP;
30574
+ var DEFAULTS;
30575
+ var HOLDER_KINDS;
30576
+ var MAX_HOLDER_SPAN;
30577
+ var DISTINCTIVE_MIN_LEN;
30578
+ var HAS_SEPARATOR;
30579
+ var PATH_LIKE;
30580
+ var FAMILY_MIN_MEMBERS;
30581
+ var FAMILY_MIN_PREFIX;
30582
+ var FUNCTION_RHS;
30583
+ var init_literals2 = __esm({
30584
+ "src/literals.ts"() {
30585
+ "use strict";
30586
+ init_tests_map();
30587
+ init_sort();
30588
+ LITERAL_DUPLICATION_CAP = 24;
30589
+ DEFAULTS = { minFiles: 2, minCount: 3 };
30590
+ HOLDER_KINDS = /* @__PURE__ */ new Set(["const", "constant", "variable", "enum", "enumerator", "property", "field", "static"]);
30591
+ MAX_HOLDER_SPAN = 12;
30592
+ DISTINCTIVE_MIN_LEN = 6;
30593
+ HAS_SEPARATOR = /[/:._-]/;
30594
+ PATH_LIKE = /^[/@a-z0-9][\w./:@-]*$/i;
30595
+ FAMILY_MIN_MEMBERS = 2;
30596
+ FAMILY_MIN_PREFIX = 4;
30597
+ FUNCTION_RHS = /^\s*(?:async\b|function\b|\(|[A-Za-z_$][\w$]*\s*=>)/;
30598
+ }
30599
+ });
30280
30600
  function buildIndexArtifacts(repo, opts = {}) {
30281
30601
  return buildArtifactsFromScan(scanRepo(repo, opts), opts);
30282
30602
  }
@@ -30300,6 +30620,8 @@ function buildArtifactsFromScan(scan2, opts = {}) {
30300
30620
  }
30301
30621
  const surprises = computeSurprises(graph);
30302
30622
  if (surprises.length) graph.surprises = surprises;
30623
+ const { duplications } = findLiteralDuplications(scan2);
30624
+ if (duplications.length) graph.literalDuplications = duplications.slice(0, LITERAL_DUPLICATION_CAP);
30303
30625
  const symbols = buildSymbolIndex(scan2, symbolRefsFor(scan2), opts.meta?.schemaVersion);
30304
30626
  return { scan: scan2, graph, symbols };
30305
30627
  }
@@ -30314,6 +30636,7 @@ var init_pipeline = __esm({
30314
30636
  init_centrality();
30315
30637
  init_tests_map();
30316
30638
  init_surprise();
30639
+ init_literals2();
30317
30640
  init_symbols_json();
30318
30641
  }
30319
30642
  });
@@ -30797,8 +31120,14 @@ function parseRules(input) {
30797
31120
  throw new Error(`${at} (${r.name}): \`comment\` must be a string`);
30798
31121
  if (r.builtin !== void 0) {
30799
31122
  if (!BUILTINS.has(r.builtin))
30800
- throw new Error(`${at} (${r.name}): \`builtin\` must be "cycles" or "orphans"`);
30801
- return { name: r.name, builtin: r.builtin, severity: r.severity, comment: r.comment };
31123
+ throw new Error(`${at} (${r.name}): \`builtin\` must be "cycles", "orphans" or "literals"`);
31124
+ return {
31125
+ name: r.name,
31126
+ builtin: r.builtin,
31127
+ severity: r.severity,
31128
+ comment: r.comment,
31129
+ ...r.tiers !== void 0 ? { tiers: r.tiers } : {}
31130
+ };
30802
31131
  }
30803
31132
  const glob = (field) => {
30804
31133
  const v = r[field];
@@ -30907,6 +31236,17 @@ function checkRules(graph, rules) {
30907
31236
  for (const c2 of findImportCycles(graph)) {
30908
31237
  emit2(rule, { from: c2.start, to: c2.path.join(" -> "), kind: "cycle" });
30909
31238
  }
31239
+ } else if (rule.builtin === "literals") {
31240
+ const wanted = new Set(rule.tiers?.length ? rule.tiers : GATED_TIERS);
31241
+ for (const d of graph.literalDuplications ?? []) {
31242
+ if (!wanted.has(d.tier)) continue;
31243
+ const origin = d.holders[0] ?? d.literals[0];
31244
+ emit2(rule, {
31245
+ from: `${origin.file}:${origin.line}`,
31246
+ to: `${d.tier} ${JSON.stringify(d.value)} (${d.count} sites, ${d.files} files)`,
31247
+ kind: "literal"
31248
+ });
31249
+ }
30910
31250
  } else {
30911
31251
  for (const f of graph.files) {
30912
31252
  if (f.fileKind !== "code" || f.degIn !== 0 || f.degOut !== 0) continue;
@@ -30933,6 +31273,7 @@ function checkRules(graph, rules) {
30933
31273
  var EDGE_KINDS;
30934
31274
  var SEVERITIES;
30935
31275
  var BUILTINS;
31276
+ var GATED_TIERS;
30936
31277
  var ENTRYPOINT_STEMS;
30937
31278
  var init_rules = __esm({
30938
31279
  "src/rules.ts"() {
@@ -30941,7 +31282,8 @@ var init_rules = __esm({
30941
31282
  init_sort();
30942
31283
  EDGE_KINDS = /* @__PURE__ */ new Set(["contains", "doc-link", "import", "call", "use", "mention"]);
30943
31284
  SEVERITIES = /* @__PURE__ */ new Set(["error", "warn"]);
30944
- BUILTINS = /* @__PURE__ */ new Set(["cycles", "orphans"]);
31285
+ BUILTINS = /* @__PURE__ */ new Set(["cycles", "orphans", "literals"]);
31286
+ GATED_TIERS = ["competing", "bypassed"];
30945
31287
  ENTRYPOINT_STEMS = /* @__PURE__ */ new Set([
30946
31288
  "index",
30947
31289
  "main",
@@ -31490,6 +31832,22 @@ var init_tools = __esm({
31490
31832
  required: ["repo"]
31491
31833
  }
31492
31834
  },
31835
+ {
31836
+ name: "duplicated_literals",
31837
+ 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'.",
31838
+ inputSchema: {
31839
+ type: "object",
31840
+ properties: {
31841
+ ...repoProp,
31842
+ ...scopeProps,
31843
+ minFiles: { type: "number", description: "Distinct files a value must span (default 2)" },
31844
+ minCount: { type: "number", description: "Total occurrences required (default 3)" },
31845
+ includeTests: { type: "boolean", description: "Count test files too (default false)" },
31846
+ limit: { type: "number", description: "Cap duplications (default: all)" }
31847
+ },
31848
+ required: ["repo"]
31849
+ }
31850
+ },
31493
31851
  {
31494
31852
  name: "complexity",
31495
31853
  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.",
@@ -31699,6 +32057,14 @@ var init_tools = __esm({
31699
32057
  properties: { ok: { type: "boolean" }, couplings: { type: "array", items: anyObj } },
31700
32058
  required: ["ok", "couplings"]
31701
32059
  },
32060
+ duplicated_literals: {
32061
+ type: "object",
32062
+ properties: {
32063
+ duplications: { type: "array", items: anyObj },
32064
+ families: { type: "array", items: anyObj }
32065
+ },
32066
+ required: ["duplications", "families"]
32067
+ },
31702
32068
  embed_status: {
31703
32069
  type: "object",
31704
32070
  properties: {
@@ -31754,6 +32120,7 @@ var init_tools = __esm({
31754
32120
  list_memories: { title: "List memories" },
31755
32121
  delete_memory: { title: "Delete memory", write: true, destructive: true, idempotent: true },
31756
32122
  dead_code: { title: "Dead-code candidates" },
32123
+ duplicated_literals: { title: "Values with no single source of truth" },
31757
32124
  complexity: { title: "Complexity" },
31758
32125
  mermaid: { title: "Mermaid module diagram" },
31759
32126
  grep: { title: "Grep file contents" },
@@ -32034,6 +32401,26 @@ async function callTool(name2, args2, defaultRepo) {
32034
32401
  if (limit === void 0 || all.length <= limit) return JSON.stringify(all, null, 2);
32035
32402
  return JSON.stringify({ total: all.length, shown: limit, truncated: true, candidates: all.slice(0, limit) }, null, 2);
32036
32403
  }
32404
+ if (name2 === "duplicated_literals") {
32405
+ const report = findLiteralDuplications(getScan(repo, scanOpts, walked), {
32406
+ minFiles: num(args2.minFiles),
32407
+ minCount: num(args2.minCount),
32408
+ includeTests: args2.includeTests === true
32409
+ });
32410
+ const limit = num(args2.limit);
32411
+ if (limit === void 0 || report.duplications.length <= limit) return JSON.stringify(report, null, 2);
32412
+ return JSON.stringify(
32413
+ {
32414
+ total: report.duplications.length,
32415
+ shown: limit,
32416
+ truncated: true,
32417
+ duplications: report.duplications.slice(0, limit),
32418
+ families: report.families
32419
+ },
32420
+ null,
32421
+ 2
32422
+ );
32423
+ }
32037
32424
  if (name2 === "complexity") {
32038
32425
  const scan2 = getScan(repo, scanOpts, walked);
32039
32426
  if (args2.risk === true) {
@@ -32271,6 +32658,7 @@ var init_mcp = __esm({
32271
32658
  init_coupling();
32272
32659
  init_repomap();
32273
32660
  init_deadcode();
32661
+ init_literals2();
32274
32662
  init_complexity();
32275
32663
  init_viz();
32276
32664
  init_query();
@@ -32587,7 +32975,16 @@ var CODE_EXTS = /* @__PURE__ */ new Set([
32587
32975
  ".sh",
32588
32976
  ".bash",
32589
32977
  ".zig",
32590
- ".elm"
32978
+ ".elm",
32979
+ ".hcl",
32980
+ ".tf",
32981
+ ".tfvars",
32982
+ ".sol",
32983
+ ".hh",
32984
+ ".sc",
32985
+ ".pyi",
32986
+ ".rake",
32987
+ ".cxx"
32591
32988
  ]);
32592
32989
  var STYLE_EXTS = /* @__PURE__ */ new Set([".css", ".scss", ".sass", ".less", ".styl", ".pcss"]);
32593
32990
  var DOC_EXTS = /* @__PURE__ */ new Set([".md", ".mdx", ".rst", ".adoc", ".txt"]);
@@ -33251,6 +33648,7 @@ init_rules();
33251
33648
  init_coupling();
33252
33649
  init_repomap();
33253
33650
  init_deadcode();
33651
+ init_literals2();
33254
33652
  init_complexity();
33255
33653
  init_viz();
33256
33654
  init_sort();
@@ -33654,6 +34052,7 @@ init_grep();
33654
34052
  init_coupling();
33655
34053
  init_repomap();
33656
34054
  init_deadcode();
34055
+ init_literals2();
33657
34056
  init_complexity();
33658
34057
  init_viz();
33659
34058
  init_bm25();
@@ -33706,12 +34105,21 @@ Commands:
33706
34105
  asset into the shared cache (sha256-verified,
33707
34106
  atomic). Override the source with
33708
34107
  CODEINDEX_GRAMMARS_URL
33709
- rules Architecture rules (forbidden edges, cycles, orphans) validated
33710
- against the link-graph: --config <codeindex.rules.json>; exits 1
33711
- on any error-severity violation (a CI gate)
34108
+ rules Architecture rules (forbidden edges, cycles, orphans, literals)
34109
+ validated against the link-graph: --config <codeindex.rules.json>;
34110
+ exits 1 on any error-severity violation (a CI gate)
33712
34111
  repomap Token-budgeted map of the highest-PageRank files (--budget-tokens)
33713
34112
  hotspots Churn \xD7 size ranking of the files where work concentrates (JSON)
33714
34113
  coupling Change coupling: files that change together (JSON; --since <ref>)
34114
+ literals Values with no single source of truth: one literal written out
34115
+ across many files, in three labeled tiers \u2014 'competing' (two or
34116
+ more exported constants hold it), 'bypassed' (a constant holds
34117
+ it and other files rewrite it anyway), 'uncentralized' (nothing
34118
+ holds it). Groups path-like values into namespace families so a
34119
+ whole route space reports once, not forty times. Reads code AND
34120
+ config files (JSON/YAML/TOML), because the duplications that hurt
34121
+ are the ones crossing a language boundary no compiler checks.
34122
+ (--min-files, --min-count, --include-tests)
33715
34123
  deadcode Dead-code candidates in two labeled tiers: 'unreferenced' (no
33716
34124
  call site binds AND nothing references the name) and 'uncalled'
33717
34125
  (referenced \u2014 re-export, type position \u2014 but never called)
@@ -33732,7 +34140,7 @@ Commands:
33732
34140
  and exits 0, or exits 1 when it has no opinion (run the original).
33733
34141
  Deliberately conservative \u2014 any shell metacharacter or unknown
33734
34142
  flag refuses the rewrite
33735
- mcp Run as an MCP server over stdio (26 tools: scan_summary, graph,
34143
+ mcp Run as an MCP server over stdio (30 tools: scan_summary, graph,
33736
34144
  symbols, callers, workspaces, churn, symbols_overview,
33737
34145
  find_symbol, find_references, repo_map, hotspots, coupling,
33738
34146
  dead_code, complexity, mermaid, grep, search, embed_status,
@@ -33786,6 +34194,10 @@ Flags (accepted before OR after the subcommand: '--repo X scan' and
33786
34194
  each site corroborated|unique-name
33787
34195
  --ignore-case \`grep\`: case-insensitive matching
33788
34196
  --max-hits <n> \`grep\`: cap returned hits (default 200)
34197
+ --min-files <n> \`literals\`: distinct files a value must span (default 2)
34198
+ --min-count <n> \`literals\`: total occurrences required (default 3)
34199
+ --include-tests \`literals\`: count test files too. Off by default \u2014 a test
34200
+ restating a value is usually asserting it deliberately
33789
34201
  `;
33790
34202
  function parseFlags(args2) {
33791
34203
  const flags2 = { repo: process.cwd(), include: [], exclude: [], gitignore: true, ignoreDirs: [], noAst: false, fuzzy: true, semantic: false };
@@ -33818,6 +34230,9 @@ function parseFlags(args2) {
33818
34230
  else if (a === "--ignore-case") flags2.ignoreCase = true;
33819
34231
  else if (a === "--max-hits") flags2.maxHits = num22();
33820
34232
  else if (a === "--budget-tokens") flags2.budgetTokens = num22();
34233
+ else if (a === "--min-files") flags2.minFiles = num22();
34234
+ else if (a === "--min-count") flags2.minCount = num22();
34235
+ else if (a === "--include-tests") flags2.includeTests = true;
33821
34236
  else if (a === "--no-ast") flags2.noAst = true;
33822
34237
  else if (a === "--index") flags2.indexDir = next();
33823
34238
  else if (a === "--no-index-cache") flags2.noIndexCache = true;
@@ -33911,6 +34326,8 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
33911
34326
  "--max-calls",
33912
34327
  "--max-hits",
33913
34328
  "--budget-tokens",
34329
+ "--min-files",
34330
+ "--min-count",
33914
34331
  "--since",
33915
34332
  "--config",
33916
34333
  "--limit",
@@ -34342,6 +34759,13 @@ async function runCli(rawArgv) {
34342
34759
  emit(JSON.stringify({ ok, couplings }, null, 2) + "\n", flags2.out);
34343
34760
  } else if (cmd === "deadcode") {
34344
34761
  emit(JSON.stringify(findDeadCode(readScan()), null, 2) + "\n", flags2.out);
34762
+ } else if (cmd === "literals") {
34763
+ const report = findLiteralDuplications(readScan(), {
34764
+ minFiles: flags2.minFiles,
34765
+ minCount: flags2.minCount,
34766
+ includeTests: flags2.includeTests
34767
+ });
34768
+ emit(JSON.stringify(report, null, 2) + "\n", flags2.out);
34345
34769
  } else if (cmd === "complexity") {
34346
34770
  const scan2 = readScan();
34347
34771
  emit(JSON.stringify(symbolComplexity(scan2, flags2.positional), null, 2) + "\n", flags2.out);