slopbrick 0.15.0 → 0.17.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 @@ var __export = (target, all) => {
6
6
 
7
7
  // src/engine/worker.ts
8
8
  import { isMainThread, parentPort, workerData } from "worker_threads";
9
- import { extname as extname3 } from "path";
9
+ import { extname as extname6 } from "path";
10
10
 
11
11
  // ../engine/dist/index.js
12
12
  import { readFile, writeFile, mkdir, access } from "fs/promises";
@@ -33752,6 +33752,339 @@ var importPathMismatchRule = createRule({
33752
33752
  }
33753
33753
  });
33754
33754
 
33755
+ // src/rules/db/duplicate-index.ts
33756
+ import { parseSync as parseSql, loadModule as loadSqlModule } from "pgsql-parser";
33757
+ var moduleReady = loadSqlModule();
33758
+ function parseStatements(raw) {
33759
+ let result;
33760
+ try {
33761
+ result = parseSql(raw);
33762
+ } catch {
33763
+ return null;
33764
+ }
33765
+ const stmts = [];
33766
+ for (const wrapper of result?.stmts ?? []) {
33767
+ const inner = wrapper.stmt ?? {};
33768
+ const type = Object.keys(inner)[0] ?? "Other";
33769
+ stmts.push({ type, ast: inner[type] });
33770
+ }
33771
+ return stmts;
33772
+ }
33773
+ var duplicateIndexRule = createRule({
33774
+ id: "db/duplicate-index",
33775
+ category: "db",
33776
+ severity: "high",
33777
+ aiSpecific: false,
33778
+ description: "Two CREATE INDEX statements cover the same column list on the same table \u2014 drop one.",
33779
+ create(context) {
33780
+ return context;
33781
+ },
33782
+ analyze(_context, facts) {
33783
+ const issues = [];
33784
+ const source = facts.v2?._source;
33785
+ if (!source) return issues;
33786
+ const stmts = parseStatements(source);
33787
+ if (!stmts) return issues;
33788
+ const seen = /* @__PURE__ */ new Map();
33789
+ for (const stmt of stmts) {
33790
+ if (stmt.type !== "IndexStmt") continue;
33791
+ const idx = stmt.ast;
33792
+ const idxName = idx.idxname ?? "?";
33793
+ const cols = (idx.indexParams ?? []).map((p) => p.IndexElem?.name ?? "?").sort();
33794
+ const key = cols.join(",");
33795
+ if (key === "") continue;
33796
+ const table = idx.relation?.relname ?? "";
33797
+ if (seen.has(key)) {
33798
+ const prev = seen.get(key);
33799
+ if (prev.indexName === idxName && prev.table === table) continue;
33800
+ issues.push({
33801
+ ruleId: "db/duplicate-index",
33802
+ category: "db",
33803
+ severity: "high",
33804
+ aiSpecific: false,
33805
+ message: `Index \`${idxName}\` duplicates \`${prev.indexName}\` on the same column list (${cols.join(", ")}).`,
33806
+ line: 1,
33807
+ column: 1,
33808
+ advice: `Drop one of the two indexes \u2014 extra indexes slow writes without benefit.`
33809
+ });
33810
+ } else {
33811
+ seen.set(key, { indexName: idxName, table });
33812
+ }
33813
+ }
33814
+ return issues;
33815
+ }
33816
+ });
33817
+
33818
+ // src/rules/db/enum-sprawl.ts
33819
+ import { parseSync as parseSql2, loadModule as loadSqlModule2 } from "pgsql-parser";
33820
+ var moduleReady2 = loadSqlModule2();
33821
+ function parseStatements2(raw) {
33822
+ let result;
33823
+ try {
33824
+ result = parseSql2(raw);
33825
+ } catch {
33826
+ return null;
33827
+ }
33828
+ const stmts = [];
33829
+ for (const wrapper of result?.stmts ?? []) {
33830
+ const inner = wrapper.stmt ?? {};
33831
+ const type = Object.keys(inner)[0] ?? "Other";
33832
+ stmts.push({ type, ast: inner[type] });
33833
+ }
33834
+ return stmts;
33835
+ }
33836
+ var ENUM_VALUES_MAX = 12;
33837
+ var enumSprawlRule = createRule({
33838
+ id: "db/enum-sprawl",
33839
+ category: "db",
33840
+ severity: "low",
33841
+ aiSpecific: false,
33842
+ description: `CREATE TYPE \u2026 AS ENUM with >${ENUM_VALUES_MAX} values \u2014 brittle to extend; prefer a lookup table.`,
33843
+ create(context) {
33844
+ return context;
33845
+ },
33846
+ analyze(_context, facts) {
33847
+ const issues = [];
33848
+ const source = facts.v2?._source;
33849
+ if (!source) return issues;
33850
+ const stmts = parseStatements2(source);
33851
+ if (!stmts) return issues;
33852
+ for (const stmt of stmts) {
33853
+ if (stmt.type !== "CreateEnumStmt") continue;
33854
+ const en = stmt.ast;
33855
+ const vals = (en.vals ?? []).map(
33856
+ (v) => v.String?.sval ?? "?"
33857
+ );
33858
+ if (vals.length <= ENUM_VALUES_MAX) continue;
33859
+ const typeName = (en.typeName?.names ?? []).map((n) => n.String?.sval).filter(Boolean).join(".");
33860
+ issues.push({
33861
+ ruleId: "db/enum-sprawl",
33862
+ category: "db",
33863
+ severity: "low",
33864
+ aiSpecific: false,
33865
+ message: `Enum \`${typeName}\` has ${vals.length} values (recommended max: ${ENUM_VALUES_MAX}).`,
33866
+ line: 1,
33867
+ column: 1,
33868
+ advice: `Consider a lookup table. Enums with many values are brittle to extend and hard to localize.`
33869
+ });
33870
+ }
33871
+ return issues;
33872
+ }
33873
+ });
33874
+
33875
+ // src/rules/db/missing-fk-index.ts
33876
+ import { parseSync as parseSql3, loadModule as loadSqlModule3 } from "pgsql-parser";
33877
+ var moduleReady3 = loadSqlModule3();
33878
+ function parseStatements3(raw) {
33879
+ let result;
33880
+ try {
33881
+ result = parseSql3(raw);
33882
+ } catch {
33883
+ return null;
33884
+ }
33885
+ const stmts = [];
33886
+ for (const wrapper of result?.stmts ?? []) {
33887
+ const inner = wrapper.stmt ?? {};
33888
+ const type = Object.keys(inner)[0] ?? "Other";
33889
+ stmts.push({ type, ast: inner[type] });
33890
+ }
33891
+ return stmts;
33892
+ }
33893
+ var missingFkIndexRule = createRule({
33894
+ id: "db/missing-fk-index",
33895
+ category: "db",
33896
+ severity: "high",
33897
+ aiSpecific: false,
33898
+ description: "Foreign key column without a matching CREATE INDEX in the same file \u2014 sequential scan on parent deletes.",
33899
+ create(context) {
33900
+ return context;
33901
+ },
33902
+ analyze(_context, facts) {
33903
+ const issues = [];
33904
+ const source = facts.v2?._source;
33905
+ if (!source) return issues;
33906
+ const stmts = parseStatements3(source);
33907
+ if (!stmts) return issues;
33908
+ const fkColsByTable = /* @__PURE__ */ new Map();
33909
+ const idxColsByTable = /* @__PURE__ */ new Map();
33910
+ for (const stmt of stmts) {
33911
+ if (stmt.type === "CreateStmt") {
33912
+ const cs = stmt.ast;
33913
+ const tableName = cs?.relation?.relname;
33914
+ if (!tableName || !Array.isArray(cs.tableElts)) continue;
33915
+ for (const elt of cs.tableElts) {
33916
+ const cd = elt.ColumnDef;
33917
+ if (!cd || !Array.isArray(cd.constraints)) continue;
33918
+ for (const con of cd.constraints) {
33919
+ const c = con.Constraint;
33920
+ if (!c) continue;
33921
+ const isFk = c.contype === "CONSTR_FOREIGN" || Array.isArray(c.fk_attrs) && c.fk_attrs.length > 0 || c.pktable?.relation?.relname;
33922
+ if (!isFk) continue;
33923
+ if (!fkColsByTable.has(tableName)) fkColsByTable.set(tableName, /* @__PURE__ */ new Set());
33924
+ fkColsByTable.get(tableName).add(cd.colname);
33925
+ }
33926
+ }
33927
+ } else if (stmt.type === "IndexStmt") {
33928
+ const idx = stmt.ast;
33929
+ const tableName = idx.relation?.relname;
33930
+ if (!tableName) continue;
33931
+ if (!idxColsByTable.has(tableName)) idxColsByTable.set(tableName, /* @__PURE__ */ new Set());
33932
+ for (const p of idx.indexParams ?? []) {
33933
+ if (p.IndexElem?.name) idxColsByTable.get(tableName).add(p.IndexElem.name);
33934
+ }
33935
+ }
33936
+ }
33937
+ for (const [table, fkCols] of fkColsByTable) {
33938
+ const idxCols = idxColsByTable.get(table) ?? /* @__PURE__ */ new Set();
33939
+ for (const fk of fkCols) {
33940
+ if (idxCols.has(fk)) continue;
33941
+ issues.push({
33942
+ ruleId: "db/missing-fk-index",
33943
+ category: "db",
33944
+ severity: "high",
33945
+ aiSpecific: false,
33946
+ message: `Foreign key column \`${table}.${fk}\` has no matching index.`,
33947
+ line: 1,
33948
+ column: 1,
33949
+ advice: `Add \`CREATE INDEX ON ${table} (${fk});\`. Without an index, deletes on the parent table perform a sequential scan. Use \`CREATE INDEX CONCURRENTLY\` in production.`,
33950
+ extras: { table, columnName: fk }
33951
+ });
33952
+ }
33953
+ }
33954
+ return issues;
33955
+ }
33956
+ });
33957
+
33958
+ // src/rules/db/missing-not-null.ts
33959
+ import { parseSync as parseSql4, loadModule as loadSqlModule4 } from "pgsql-parser";
33960
+ var moduleReady4 = loadSqlModule4();
33961
+ function parseStatements4(raw) {
33962
+ let result;
33963
+ try {
33964
+ result = parseSql4(raw);
33965
+ } catch {
33966
+ return null;
33967
+ }
33968
+ const stmts = [];
33969
+ for (const wrapper of result?.stmts ?? []) {
33970
+ const inner = wrapper.stmt ?? {};
33971
+ const type = Object.keys(inner)[0] ?? "Other";
33972
+ stmts.push({ type, ast: inner[type] });
33973
+ }
33974
+ return stmts;
33975
+ }
33976
+ var REQUIRED_COLUMN_HEURISTIC = /^(id|user_?id|order_?id|product_?id|tenant_?id|customer_?id|email|created_?at|updated_?at|deleted_?at|expires_?at|status|uuid|slug|handle|key|name)$/i;
33977
+ var missingNotNullRule = createRule({
33978
+ id: "db/missing-not-null",
33979
+ category: "db",
33980
+ severity: "high",
33981
+ aiSpecific: false,
33982
+ description: "Required-identifier column (id, email, created_at, \u2026) without NOT NULL \u2014 silent NULL inserts in production.",
33983
+ create(context) {
33984
+ return context;
33985
+ },
33986
+ analyze(_context, facts) {
33987
+ const issues = [];
33988
+ const source = facts.v2?._source;
33989
+ if (!source) return issues;
33990
+ const stmts = parseStatements4(source);
33991
+ if (!stmts) return issues;
33992
+ for (const stmt of stmts) {
33993
+ if (stmt.type !== "CreateStmt") continue;
33994
+ const cs = stmt.ast;
33995
+ const tableName = cs?.relation?.relname;
33996
+ if (!tableName || !Array.isArray(cs.tableElts)) continue;
33997
+ for (const elt of cs.tableElts) {
33998
+ const cd = elt.ColumnDef;
33999
+ if (!cd) continue;
34000
+ if (!REQUIRED_COLUMN_HEURISTIC.test(cd.colname ?? "")) continue;
34001
+ const hasNotNull = (cd.constraints ?? []).some((c) => {
34002
+ const con = c.Constraint;
34003
+ return con?.contype === "CONSTR_NOTNULL" || con?.contype === "CONSTR_PRIMARY";
34004
+ });
34005
+ if (hasNotNull) continue;
34006
+ issues.push({
34007
+ ruleId: "db/missing-not-null",
34008
+ category: "db",
34009
+ severity: "high",
34010
+ aiSpecific: false,
34011
+ message: `Required column \`${tableName}.${cd.colname}\` is missing \`NOT NULL\`.`,
34012
+ line: 1,
34013
+ column: 1,
34014
+ advice: `Add \`NOT NULL\` (or \`PRIMARY KEY\`). Optional identifiers are a common AI-generated SQL smell that leads to silent NULL inserts in production.`,
34015
+ extras: { table: tableName, columnName: cd.colname }
34016
+ });
34017
+ }
34018
+ }
34019
+ return issues;
34020
+ }
34021
+ });
34022
+
34023
+ // src/rules/db/naming-inconsistency.ts
34024
+ import { parseSync as parseSql5, loadModule as loadSqlModule5 } from "pgsql-parser";
34025
+ var moduleReady5 = loadSqlModule5();
34026
+ function parseStatements5(raw) {
34027
+ let result;
34028
+ try {
34029
+ result = parseSql5(raw);
34030
+ } catch {
34031
+ return null;
34032
+ }
34033
+ const stmts = [];
34034
+ for (const wrapper of result?.stmts ?? []) {
34035
+ const inner = wrapper.stmt ?? {};
34036
+ const type = Object.keys(inner)[0] ?? "Other";
34037
+ stmts.push({ type, ast: inner[type] });
34038
+ }
34039
+ return stmts;
34040
+ }
34041
+ var namingInconsistencyRule = createRule({
34042
+ id: "db/naming-inconsistency",
34043
+ category: "db",
34044
+ severity: "low",
34045
+ aiSpecific: false,
34046
+ description: "snake_case and camelCase identifiers mix within one SQL file \u2014 standardize.",
34047
+ create(context) {
34048
+ return context;
34049
+ },
34050
+ analyze(_context, facts) {
34051
+ const issues = [];
34052
+ const source = facts.v2?._source;
34053
+ if (!source) return issues;
34054
+ const stmts = parseStatements5(source);
34055
+ if (!stmts) return issues;
34056
+ let snakeCount = 0;
34057
+ let camelCount = 0;
34058
+ function walk3(node) {
34059
+ if (!node || typeof node !== "object") return;
34060
+ if (Array.isArray(node)) {
34061
+ for (const item of node) walk3(item);
34062
+ return;
34063
+ }
34064
+ if (node.String?.sval && typeof node.String.sval === "string") {
34065
+ const s = node.String.sval;
34066
+ if (/^[a-z_][a-z0-9_]*$/.test(s)) snakeCount++;
34067
+ else if (/^[a-z][A-Za-z0-9]*$/.test(s)) camelCount++;
34068
+ }
34069
+ for (const k of Object.keys(node)) walk3(node[k]);
34070
+ }
34071
+ walk3(stmts);
34072
+ if (snakeCount >= 2 && camelCount >= 2) {
34073
+ issues.push({
34074
+ ruleId: "db/naming-inconsistency",
34075
+ category: "db",
34076
+ severity: "low",
34077
+ aiSpecific: false,
34078
+ message: `Mixed identifier styles: ${snakeCount} snake_case vs ${camelCount} camelCase.`,
34079
+ line: 1,
34080
+ column: 1,
34081
+ advice: `Standardize on snake_case (Postgres convention) or document the deviation.`
34082
+ });
34083
+ }
34084
+ return issues;
34085
+ }
34086
+ });
34087
+
33755
34088
  // src/rules/utils.ts
33756
34089
  var LAYOUT_ARBITRARY_RE = /^(?:w|h|p|m|gap|space-x|space-y|px|py|mx|my|min-w|min-h|max-w|max-h|inset)-\[.*\]$/;
33757
34090
  var RADIUS_ARBITRARY_RE = /^(?:rounded|rounded-t|rounded-r|rounded-b|rounded-l|rounded-tl|rounded-tr|rounded-br|rounded-bl|rounded-ss|rounded-se|rounded-es|rounded-ee)-\[.*\]$/;
@@ -33910,6 +34243,577 @@ function parseStyleObject(source) {
33910
34243
  return entries;
33911
34244
  }
33912
34245
 
34246
+ // src/rules/db/sql-concat.ts
34247
+ var TEMPLATE_SQL_RE = /`((?:SELECT|INSERT\s+INTO|UPDATE|DELETE\s+FROM|WITH)\b[^`]*\$\{[^}]+\}[^`]*)`/gi;
34248
+ var sqlConcatRule = createRule({
34249
+ id: "db/sql-concat",
34250
+ category: "db",
34251
+ severity: "high",
34252
+ aiSpecific: true,
34253
+ description: "Template-literal SQL query with ${...} interpolation \u2014 use parameterized queries.",
34254
+ create(context) {
34255
+ return context;
34256
+ },
34257
+ analyze(_context, facts) {
34258
+ const issues = [];
34259
+ const source = facts.v2?._source;
34260
+ if (!source) return issues;
34261
+ TEMPLATE_SQL_RE.lastIndex = 0;
34262
+ let m;
34263
+ while ((m = TEMPLATE_SQL_RE.exec(source)) !== null) {
34264
+ issues.push({
34265
+ ruleId: "db/sql-concat",
34266
+ category: "db",
34267
+ severity: "high",
34268
+ aiSpecific: true,
34269
+ message: "Template-literal SQL query with `${...}` interpolation \u2014 string concatenation is a SQL injection vector and a common AI-generated smell.",
34270
+ line: lineOfSource(source, m.index),
34271
+ column: 1,
34272
+ advice: "Use parameterized queries (`db.query('SELECT ... WHERE id = $1', [id])`) or an ORM."
34273
+ });
34274
+ }
34275
+ return issues;
34276
+ }
34277
+ });
34278
+
34279
+ // src/rules/docs/broken-link.ts
34280
+ import { existsSync as existsSync4 } from "fs";
34281
+ import { dirname as dirname4, join as join7, resolve as resolve2 } from "path";
34282
+
34283
+ // src/engine/doc-freshness.ts
34284
+ import { readFileSync as readFileSync5, existsSync as existsSync3 } from "fs";
34285
+ import { join as join6, dirname as dirname3, relative as relative2 } from "path";
34286
+ import { globby as globby2 } from "globby";
34287
+
34288
+ // src/mcp/patterns.ts
34289
+ import { readFileSync as readFileSync3 } from "fs";
34290
+ import { basename as basename2, extname as extname2 } from "path";
34291
+
34292
+ // src/engine/discover.ts
34293
+ import { globby } from "globby";
34294
+ import { minimatch } from "minimatch";
34295
+ import { resolve, extname, relative, sep, basename, dirname as dirname2, join as join3 } from "path";
34296
+ import { readFileSync } from "fs";
34297
+ var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".vue", ".svelte", ".astro", ".html"]);
34298
+ var BACKEND_EXTENSIONS = /* @__PURE__ */ new Set([
34299
+ ".py",
34300
+ ".go",
34301
+ // v0.14.0
34302
+ ".swift",
34303
+ ".kt",
34304
+ ".kts",
34305
+ ".dart",
34306
+ ".rs",
34307
+ ".cpp",
34308
+ ".cc",
34309
+ ".cxx",
34310
+ ".c",
34311
+ ".h",
34312
+ ".hpp",
34313
+ ".hxx",
34314
+ ".java",
34315
+ ".rb",
34316
+ ".php"
34317
+ ]);
34318
+ var ALL_SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([
34319
+ ...SOURCE_EXTENSIONS,
34320
+ ...BACKEND_EXTENSIONS
34321
+ ]);
34322
+
34323
+ // src/config/conventions.ts
34324
+ import { existsSync, readFileSync as readFileSync2 } from "fs";
34325
+ import { join as join4 } from "path";
34326
+
34327
+ // src/mcp/patterns.ts
34328
+ var ESM_IMPORT_RE = /(?:^|\n)\s*(?:import\s+(?:type\s+)?(?:[\w*${},\s]+\s+from\s+)?|import\s+|export\s+(?:type\s+)?[\w*${},\s]+\s+from\s+)(['"])([^'"]+)\1/g;
34329
+ var DYNAMIC_IMPORT_RE = /import\s*\(\s*(['"])([^'"]+)\1\s*\)/g;
34330
+ var COMMONJS_REQUIRE_RE = /require\s*\(\s*(['"])([^'"]+)\1\s*\)/g;
34331
+ function extractImports(source) {
34332
+ const seen = /* @__PURE__ */ new Set();
34333
+ const out = [];
34334
+ const push = (spec) => {
34335
+ if (spec.startsWith(".") || spec.startsWith("/")) return;
34336
+ if (seen.has(spec)) return;
34337
+ seen.add(spec);
34338
+ out.push(spec);
34339
+ };
34340
+ for (const re of [ESM_IMPORT_RE, DYNAMIC_IMPORT_RE, COMMONJS_REQUIRE_RE]) {
34341
+ re.lastIndex = 0;
34342
+ let m;
34343
+ while ((m = re.exec(source)) !== null) {
34344
+ push(m[2]);
34345
+ }
34346
+ }
34347
+ return out;
34348
+ }
34349
+
34350
+ // src/rules/docs/expired-code-example.ts
34351
+ var CODE_LANGS = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "javascript", "typescript"]);
34352
+ function stripSubpath(spec) {
34353
+ if (spec.startsWith("@")) return spec.split("/").slice(0, 2).join("/");
34354
+ return spec.split("/")[0] ?? spec;
34355
+ }
34356
+ var expiredCodeExampleRule = createRule({
34357
+ id: "docs/expired-code-example",
34358
+ category: "docs",
34359
+ severity: "medium",
34360
+ aiSpecific: false,
34361
+ description: "A fenced code example imports a package that is not declared in package.json.",
34362
+ create(context) {
34363
+ return { ...context, packages: declaredPackages(context.cwd) };
34364
+ },
34365
+ analyze(context, facts) {
34366
+ const issues = [];
34367
+ const source = facts.v2?._source;
34368
+ if (!source) return issues;
34369
+ const blocks = extractFencedCodeBlocks(source);
34370
+ for (const block of blocks) {
34371
+ if (!CODE_LANGS.has(block.lang)) continue;
34372
+ if (block.body.split("\n").length < 2) continue;
34373
+ const imports = extractImports(block.body);
34374
+ for (const imp of imports) {
34375
+ const pkgName = stripSubpath(imp);
34376
+ if (context.packages.has(pkgName)) continue;
34377
+ issues.push({
34378
+ ruleId: "docs/expired-code-example",
34379
+ category: "docs",
34380
+ severity: "medium",
34381
+ aiSpecific: false,
34382
+ message: `Code example imports \`${imp}\` but \`${pkgName}\` is not in package.json.`,
34383
+ line: block.line,
34384
+ column: block.column,
34385
+ advice: `Add \`${pkgName}\` to package.json or update the example.`
34386
+ });
34387
+ }
34388
+ }
34389
+ return issues;
34390
+ }
34391
+ });
34392
+
34393
+ // src/rules/docs/stale-function-reference.ts
34394
+ import { readFileSync as readFileSync4, readdirSync, existsSync as existsSync2 } from "fs";
34395
+ import { join as join5, extname as extname3 } from "path";
34396
+ var RESERVED = /* @__PURE__ */ new Set([
34397
+ "true",
34398
+ "false",
34399
+ "null",
34400
+ "undefined",
34401
+ "this",
34402
+ "self",
34403
+ "get",
34404
+ "set",
34405
+ "init",
34406
+ "destroy",
34407
+ "value",
34408
+ "key",
34409
+ "id",
34410
+ "name",
34411
+ "data",
34412
+ "error",
34413
+ "info",
34414
+ "debug",
34415
+ "log",
34416
+ "warn",
34417
+ "type",
34418
+ "class",
34419
+ "function",
34420
+ "const",
34421
+ "let",
34422
+ "var",
34423
+ "return",
34424
+ "if",
34425
+ "else",
34426
+ "for",
34427
+ "while",
34428
+ "do",
34429
+ "switch",
34430
+ "case",
34431
+ "default",
34432
+ "break",
34433
+ "continue",
34434
+ "new",
34435
+ "delete",
34436
+ "try",
34437
+ "catch",
34438
+ "finally",
34439
+ "async",
34440
+ "await",
34441
+ "import",
34442
+ "export",
34443
+ "from",
34444
+ "as",
34445
+ "then",
34446
+ "resolve",
34447
+ "reject",
34448
+ "next",
34449
+ "prev",
34450
+ "current",
34451
+ "index",
34452
+ "count",
34453
+ "length",
34454
+ "size",
34455
+ "width",
34456
+ "height",
34457
+ "top",
34458
+ "left",
34459
+ "right",
34460
+ "bottom",
34461
+ "result",
34462
+ "response",
34463
+ "request",
34464
+ "user",
34465
+ "message",
34466
+ "code",
34467
+ "status",
34468
+ "state",
34469
+ "props",
34470
+ "ctx",
34471
+ "context",
34472
+ "config",
34473
+ "options",
34474
+ "params",
34475
+ "args",
34476
+ "event",
34477
+ "target",
34478
+ "input",
34479
+ "output",
34480
+ "src",
34481
+ "dest",
34482
+ "path",
34483
+ "file",
34484
+ "dir",
34485
+ "url",
34486
+ "header",
34487
+ "body",
34488
+ "token",
34489
+ "auth",
34490
+ "session",
34491
+ "react",
34492
+ "node",
34493
+ "next",
34494
+ "vue",
34495
+ "angular",
34496
+ "svelte"
34497
+ ]);
34498
+ var SOURCE_EXTS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
34499
+ var SOURCE_ROOTS = ["src", "lib", "app", "components"];
34500
+ var CAP = 200;
34501
+ function walk(dir, out, cap) {
34502
+ if (!existsSync2(dir) || out.length >= cap) return;
34503
+ let entries;
34504
+ try {
34505
+ entries = readdirSync(dir, { withFileTypes: true });
34506
+ } catch {
34507
+ return;
34508
+ }
34509
+ for (const entry of entries) {
34510
+ if (out.length >= cap) return;
34511
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
34512
+ const full = join5(dir, entry.name);
34513
+ if (entry.isDirectory()) walk(full, out, cap);
34514
+ else if (entry.isFile() && SOURCE_EXTS.has(extname3(entry.name))) out.push(full);
34515
+ }
34516
+ }
34517
+ function collectExports(cwd) {
34518
+ const out = /* @__PURE__ */ new Set();
34519
+ const files = [];
34520
+ for (const root of SOURCE_ROOTS) walk(join5(cwd, root), files, CAP);
34521
+ for (const file of files) {
34522
+ let source;
34523
+ try {
34524
+ source = readFileSync4(file, "utf-8");
34525
+ } catch {
34526
+ continue;
34527
+ }
34528
+ for (const re of [
34529
+ /\bexport\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g,
34530
+ /\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g,
34531
+ /\bexport\s+class\s+([A-Za-z_$][\w$]*)/g,
34532
+ /\bexport\s+interface\s+([A-Za-z_$][\w$]*)/g,
34533
+ /\bexport\s+type\s+([A-Za-z_$][\w$]*)/g,
34534
+ /\bexport\s+default\s+(?:function\s+|class\s+)?([A-Za-z_$][\w$]*)/g
34535
+ ]) {
34536
+ let m;
34537
+ while ((m = re.exec(source)) !== null) {
34538
+ const name = m[1];
34539
+ if (name) out.add(name);
34540
+ }
34541
+ }
34542
+ }
34543
+ return out;
34544
+ }
34545
+ var staleFunctionReferenceRule = createRule({
34546
+ id: "docs/stale-function-reference",
34547
+ category: "docs",
34548
+ severity: "medium",
34549
+ aiSpecific: false,
34550
+ description: "Markdown references an identifier in a calling context (foo()) that is not exported by the project.",
34551
+ create(context) {
34552
+ return { ...context, exports: collectExports(context.cwd) };
34553
+ },
34554
+ analyze(context, facts) {
34555
+ const issues = [];
34556
+ const source = facts.v2?._source;
34557
+ if (!source) return issues;
34558
+ for (const span of extractInlineCodeSpans(source)) {
34559
+ const text = span.text;
34560
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(text)) continue;
34561
+ if (text.length < 3) continue;
34562
+ if (RESERVED.has(text.toLowerCase())) continue;
34563
+ if (context.exports.has(text)) continue;
34564
+ const end = Math.min(source.length, span.index + text.length + 50);
34565
+ if (!/\(/.test(source.slice(span.index, end))) continue;
34566
+ issues.push({
34567
+ ruleId: "docs/stale-function-reference",
34568
+ category: "docs",
34569
+ severity: "medium",
34570
+ aiSpecific: false,
34571
+ message: `Documents \`${text}()\` but no such export exists.`,
34572
+ line: span.line,
34573
+ column: span.column,
34574
+ advice: `Rename the doc reference, or add a \`${text}\` wrapper export.`,
34575
+ extras: { identifier: text }
34576
+ });
34577
+ }
34578
+ return issues;
34579
+ }
34580
+ });
34581
+
34582
+ // src/rules/docs/stale-package-reference.ts
34583
+ var ENGLISH_WORD_DENYLIST = /* @__PURE__ */ new Set([
34584
+ "the",
34585
+ "and",
34586
+ "for",
34587
+ "with",
34588
+ "from",
34589
+ "this",
34590
+ "that",
34591
+ "npm",
34592
+ "npx",
34593
+ "pnpm",
34594
+ "yarn",
34595
+ "node",
34596
+ "git",
34597
+ "cli",
34598
+ "api",
34599
+ "sdk",
34600
+ "src",
34601
+ "dist",
34602
+ "lib",
34603
+ "bin",
34604
+ "doc",
34605
+ "docs",
34606
+ "test",
34607
+ "spec",
34608
+ "todo",
34609
+ "fix",
34610
+ "bug",
34611
+ "feat",
34612
+ "refactor",
34613
+ "chore",
34614
+ "http",
34615
+ "https",
34616
+ "url",
34617
+ "json",
34618
+ "xml",
34619
+ "yaml",
34620
+ "sql",
34621
+ "orm",
34622
+ "css",
34623
+ "html",
34624
+ "svg",
34625
+ "png",
34626
+ "jpg",
34627
+ "pdf",
34628
+ "csv",
34629
+ "md",
34630
+ "mdx",
34631
+ "ts",
34632
+ "tsx",
34633
+ "js",
34634
+ "jsx",
34635
+ "ok",
34636
+ "no",
34637
+ "yes"
34638
+ ]);
34639
+ var stalePackageReferenceRule = createRule({
34640
+ id: "docs/stale-package-reference",
34641
+ category: "docs",
34642
+ severity: "medium",
34643
+ aiSpecific: false,
34644
+ description: "Markdown references a package (npm install / from / require) that is not in package.json.",
34645
+ create(context) {
34646
+ return { ...context, packages: declaredPackages(context.cwd) };
34647
+ },
34648
+ analyze(context, facts) {
34649
+ const issues = [];
34650
+ const source = facts.v2?._source;
34651
+ if (!source) return issues;
34652
+ const spans = extractInlineCodeSpans(source);
34653
+ for (const span of spans) {
34654
+ const lineStart = source.lastIndexOf("\n", span.index) + 1;
34655
+ const lineEnd = source.indexOf("\n", span.index);
34656
+ const line = source.slice(lineStart, lineEnd === -1 ? source.length : lineEnd);
34657
+ let candidate;
34658
+ const installMatch = /(npm\s+install|pnpm\s+add|yarn\s+add)\s+([A-Za-z0-9_./@-]+)/i.exec(line);
34659
+ if (installMatch) candidate = installMatch[2];
34660
+ if (!candidate) {
34661
+ const fromMatch = /from\s+['"]([^'"]+)['"]/i.exec(line);
34662
+ if (fromMatch) candidate = fromMatch[1];
34663
+ }
34664
+ if (!candidate) {
34665
+ const requireMatch = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/i.exec(line);
34666
+ if (requireMatch) candidate = requireMatch[1];
34667
+ }
34668
+ if (!candidate) continue;
34669
+ let pkgName = candidate;
34670
+ if (pkgName.startsWith("@")) {
34671
+ pkgName = pkgName.split("/").slice(0, 2).join("/");
34672
+ } else {
34673
+ pkgName = pkgName.split("/")[0] ?? pkgName;
34674
+ }
34675
+ if (!/^@?[a-z][a-z0-9._/-]*$/.test(pkgName)) continue;
34676
+ if (pkgName.length < 2) continue;
34677
+ if (ENGLISH_WORD_DENYLIST.has(pkgName)) continue;
34678
+ if (context.packages.has(pkgName)) continue;
34679
+ issues.push({
34680
+ ruleId: "docs/stale-package-reference",
34681
+ category: "docs",
34682
+ severity: "medium",
34683
+ aiSpecific: false,
34684
+ message: `Documents \`${pkgName}\` but it is not in package.json.`,
34685
+ line: span.line,
34686
+ column: span.column,
34687
+ advice: `Add \`${pkgName}\` to package.json or update the doc to reference an installed package.`,
34688
+ extras: { package: pkgName }
34689
+ });
34690
+ }
34691
+ return issues;
34692
+ }
34693
+ });
34694
+
34695
+ // src/engine/doc-freshness.ts
34696
+ function extractInlineCodeSpans(source) {
34697
+ const hits = [];
34698
+ const re = /`([^`\n]+?)`/g;
34699
+ let m;
34700
+ while ((m = re.exec(source)) !== null) {
34701
+ const text = m[1] ?? "";
34702
+ const upTo = source.slice(0, m.index);
34703
+ const line = upTo.split("\n").length;
34704
+ const lastNl = upTo.lastIndexOf("\n");
34705
+ const column = lastNl === -1 ? m.index + 1 : m.index - lastNl;
34706
+ hits.push({ text, line, column, index: m.index });
34707
+ }
34708
+ return hits;
34709
+ }
34710
+ function extractFencedCodeBlocks(source) {
34711
+ const blocks = [];
34712
+ const lines = source.split("\n");
34713
+ let i = 0;
34714
+ while (i < lines.length) {
34715
+ const line = lines[i] ?? "";
34716
+ const fenceMatch = /^```(\w*)\s*$/.exec(line);
34717
+ if (!fenceMatch) {
34718
+ i++;
34719
+ continue;
34720
+ }
34721
+ const lang = fenceMatch[1] ?? "";
34722
+ const startLine = i + 1;
34723
+ const bodyLines = [];
34724
+ i++;
34725
+ while (i < lines.length) {
34726
+ if (/^```\s*$/.test(lines[i] ?? "")) {
34727
+ i++;
34728
+ break;
34729
+ }
34730
+ bodyLines.push(lines[i] ?? "");
34731
+ i++;
34732
+ }
34733
+ blocks.push({
34734
+ lang,
34735
+ body: bodyLines.join("\n"),
34736
+ line: startLine,
34737
+ column: 1
34738
+ });
34739
+ }
34740
+ return blocks;
34741
+ }
34742
+ function extractMarkdownLinks(source) {
34743
+ const hits = [];
34744
+ const re = /(?<!\!)\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
34745
+ let m;
34746
+ while ((m = re.exec(source)) !== null) {
34747
+ const target = m[2] ?? "";
34748
+ const upTo = source.slice(0, m.index);
34749
+ const line = upTo.split("\n").length;
34750
+ const lastNl = upTo.lastIndexOf("\n");
34751
+ const column = lastNl === -1 ? m.index + 1 : m.index - lastNl;
34752
+ hits.push({ target, line, column });
34753
+ }
34754
+ return hits;
34755
+ }
34756
+ function declaredPackages(cwd) {
34757
+ const out = /* @__PURE__ */ new Set();
34758
+ const pkgPath = join6(cwd, "package.json");
34759
+ if (!existsSync3(pkgPath)) return out;
34760
+ try {
34761
+ const raw = readFileSync5(pkgPath, "utf-8");
34762
+ const pkg = JSON.parse(raw);
34763
+ for (const k of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
34764
+ const v = pkg[k];
34765
+ if (v && typeof v === "object") {
34766
+ for (const name of Object.keys(v)) {
34767
+ out.add(name);
34768
+ }
34769
+ }
34770
+ }
34771
+ } catch {
34772
+ }
34773
+ return out;
34774
+ }
34775
+
34776
+ // src/rules/docs/broken-link.ts
34777
+ var brokenLinkRule = createRule({
34778
+ id: "docs/broken-link",
34779
+ category: "docs",
34780
+ severity: "low",
34781
+ aiSpecific: false,
34782
+ description: "Markdown link target is relative and does not resolve to a file on disk.",
34783
+ create(context) {
34784
+ return context;
34785
+ },
34786
+ analyze(context, facts) {
34787
+ const issues = [];
34788
+ const source = facts.v2?._source;
34789
+ if (!source) return issues;
34790
+ const links = extractMarkdownLinks(source);
34791
+ const docDir = dirname4(resolve2(context.cwd, context.filePath));
34792
+ for (const link of links) {
34793
+ const target = link.target;
34794
+ if (target.startsWith("http://") || target.startsWith("https://")) continue;
34795
+ if (target.startsWith("mailto:") || target.startsWith("tel:")) continue;
34796
+ if (target.startsWith("#")) continue;
34797
+ if (target.startsWith("//")) continue;
34798
+ if (target.startsWith("/")) continue;
34799
+ const resolved = join7(docDir, target);
34800
+ if (existsSync4(resolved)) continue;
34801
+ issues.push({
34802
+ ruleId: "docs/broken-link",
34803
+ category: "docs",
34804
+ severity: "low",
34805
+ aiSpecific: false,
34806
+ message: `Relative link \`${target}\` does not exist.`,
34807
+ line: link.line,
34808
+ column: link.column,
34809
+ advice: `Create the file or fix the link target.`,
34810
+ extras: { link: target }
34811
+ });
34812
+ }
34813
+ return issues;
34814
+ }
34815
+ });
34816
+
33913
34817
  // src/rules/layout/gap-monopoly.ts
33914
34818
  var GAP_RE = /\bgap(?:-x|-y)?-(\d+)\b/g;
33915
34819
  var gapMonopolyRule = createRule({
@@ -34190,6 +35094,7 @@ var DEFAULT_CONFIG = {
34190
35094
  "wcag/dragging-movements": "medium",
34191
35095
  "wcag/focus-appearance": "high",
34192
35096
  "wcag/focus-obscured": "low",
35097
+ "wcag/missing-alt": "medium",
34193
35098
  "wcag/target-size": "high",
34194
35099
  "test/weak-assertion": "medium",
34195
35100
  "test/duplicate-setup": "medium",
@@ -34261,16 +35166,16 @@ var DEFAULT_CONFIG = {
34261
35166
  };
34262
35167
 
34263
35168
  // src/config/detect/monorepo.ts
34264
- import { existsSync, readFileSync, readdirSync, statSync } from "fs";
34265
- import { dirname as dirname2, join as join3, resolve } from "path";
35169
+ import { existsSync as existsSync5, readFileSync as readFileSync6, readdirSync as readdirSync2, statSync } from "fs";
35170
+ import { dirname as dirname5, join as join8, resolve as resolve3 } from "path";
34266
35171
 
34267
35172
  // src/config/detect/styling.ts
34268
- import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
34269
- import { join as join4, resolve as resolve2 } from "path";
35173
+ import { existsSync as existsSync6, readFileSync as readFileSync7, readdirSync as readdirSync3, statSync as statSync2 } from "fs";
35174
+ import { join as join9, resolve as resolve4 } from "path";
34270
35175
 
34271
35176
  // src/config/detect/stack.ts
34272
- import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
34273
- import { extname, join as join5, resolve as resolve3 } from "path";
35177
+ import { existsSync as existsSync7, readFileSync as readFileSync8, readdirSync as readdirSync4, statSync as statSync3 } from "fs";
35178
+ import { extname as extname4, join as join10, resolve as resolve5 } from "path";
34274
35179
 
34275
35180
  // src/config/presets.ts
34276
35181
  var REACT_ONLY_RULES = {
@@ -34319,8 +35224,8 @@ var FRAMEWORK_PRESETS = {
34319
35224
  };
34320
35225
 
34321
35226
  // src/config/load.ts
34322
- import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
34323
- import { dirname as dirname3, extname as extname2, join as join7, resolve as resolve4 } from "path";
35227
+ import { existsSync as existsSync8, readFileSync as readFileSync9 } from "fs";
35228
+ import { dirname as dirname6, extname as extname5, join as join11, resolve as resolve6 } from "path";
34324
35229
  import { createRequire } from "module";
34325
35230
 
34326
35231
  // src/engine/logger.ts
@@ -34342,10 +35247,6 @@ function setLoggerQuiet(quiet) {
34342
35247
  logger = createLogger(quiet);
34343
35248
  }
34344
35249
 
34345
- // src/config/conventions.ts
34346
- import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
34347
- import { join as join6 } from "path";
34348
-
34349
35250
  // src/config/init.ts
34350
35251
  var STRICTNESS_PRESETS = {
34351
35252
  strict: {
@@ -34561,6 +35462,7 @@ var boundaryViolationRule = createRule({
34561
35462
  category: "logic",
34562
35463
  severity: "high",
34563
35464
  aiSpecific: false,
35465
+ description: "Server-side data-layer / DB import leaked into a client component, or a client React hook used inside a server file (RSC boundary violation)",
34564
35466
  create(ruleContext) {
34565
35467
  return { clientHooks: CLIENT_HOOKS, supportsRsc: ruleContext.config.supportsRsc ?? true };
34566
35468
  },
@@ -34790,7 +35692,11 @@ var mathAnyDensityRule = createRule({
34790
35692
  category: "logic",
34791
35693
  severity: "high",
34792
35694
  aiSpecific: true,
34793
- description: RULE_DESCRIPTION,
35695
+ // Self-match avoidance: the source uses string concatenation to keep the
35696
+ // literal ": any" sequence out of the regex match — the description
35697
+ // metadata is for humans, not the rule engine, so we keep it as a regular
35698
+ // string literal here.
35699
+ description: "`: any` density \u2265 3 per 100 lines \u2014 AI sprinkling of `any` types",
34794
35700
  create(context) {
34795
35701
  return context;
34796
35702
  },
@@ -35744,6 +36650,94 @@ var dangerousCorsRule = createRule({
35744
36650
  }
35745
36651
  });
35746
36652
 
36653
+ // src/rules/security/eval.ts
36654
+ var EVAL_CALL_RE = /\beval\s*\(/g;
36655
+ var NEW_FUNCTION_RE = /\bnew\s+Function\s*\(/g;
36656
+ var QUALIFIED_EVAL_RE = /\b(?:window|globalThis|self|global)\s*\.\s*eval\s*\(/g;
36657
+ var BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g;
36658
+ function scanForEvalCalls(source) {
36659
+ const stripped = source.replace(
36660
+ BLOCK_COMMENT_RE,
36661
+ (match) => match.replace(/[^\n]/g, " ")
36662
+ );
36663
+ const hits = [];
36664
+ let m;
36665
+ EVAL_CALL_RE.lastIndex = 0;
36666
+ while ((m = EVAL_CALL_RE.exec(stripped)) !== null) {
36667
+ const before = source.slice(0, m.index);
36668
+ const lastNewline = before.lastIndexOf("\n");
36669
+ const lineStart = lastNewline + 1;
36670
+ const lineBeforeMatch = source.slice(lineStart, m.index);
36671
+ if (lineBeforeMatch.includes("//")) continue;
36672
+ const quoteCount = (lineBeforeMatch.match(/['"`]/g) || []).length;
36673
+ if (quoteCount % 2 === 1) continue;
36674
+ hits.push({
36675
+ message: "Avoid eval(): it is an RCE vector whenever the argument is or becomes attacker-controlled. Use JSON.parse, a sandboxed expression parser, or a precomputed dispatch table.",
36676
+ line: lineOfSource(stripped, m.index),
36677
+ column: m.index - lineStart + 1
36678
+ });
36679
+ }
36680
+ NEW_FUNCTION_RE.lastIndex = 0;
36681
+ while ((m = NEW_FUNCTION_RE.exec(stripped)) !== null) {
36682
+ const before = source.slice(0, m.index);
36683
+ const lastNewline = before.lastIndexOf("\n");
36684
+ const lineStart = lastNewline + 1;
36685
+ const lineBeforeMatch = source.slice(lineStart, m.index);
36686
+ if (lineBeforeMatch.includes("//")) continue;
36687
+ const quoteCount = (lineBeforeMatch.match(/['"`]/g) || []).length;
36688
+ if (quoteCount % 2 === 1) continue;
36689
+ hits.push({
36690
+ message: "Avoid new Function(): the function-constructor evaluates a string at runtime \u2014 same RCE risk as eval().",
36691
+ line: lineOfSource(stripped, m.index),
36692
+ column: m.index - lineStart + 1
36693
+ });
36694
+ }
36695
+ QUALIFIED_EVAL_RE.lastIndex = 0;
36696
+ while ((m = QUALIFIED_EVAL_RE.exec(stripped)) !== null) {
36697
+ const before = source.slice(0, m.index);
36698
+ const lastNewline = before.lastIndexOf("\n");
36699
+ const lineStart = lastNewline + 1;
36700
+ const lineBeforeMatch = source.slice(lineStart, m.index);
36701
+ if (lineBeforeMatch.includes("//")) continue;
36702
+ const quoteCount = (lineBeforeMatch.match(/['"`]/g) || []).length;
36703
+ if (quoteCount % 2 === 1) continue;
36704
+ hits.push({
36705
+ message: "Avoid window.eval() / globalThis.eval() \u2014 same RCE risk as bare eval().",
36706
+ line: lineOfSource(stripped, m.index),
36707
+ column: m.index - lineStart + 1
36708
+ });
36709
+ }
36710
+ return hits;
36711
+ }
36712
+ var evalRule = createRule({
36713
+ id: "security/eval",
36714
+ category: "security",
36715
+ severity: "high",
36716
+ aiSpecific: false,
36717
+ description: "eval() / new Function() / window.eval() \u2014 RCE vector when the argument is attacker-controlled (OWASP A03:2021)",
36718
+ create(context) {
36719
+ return context;
36720
+ },
36721
+ analyze(_context, facts) {
36722
+ const issues = [];
36723
+ const source = facts.v2?._source;
36724
+ if (!source) return issues;
36725
+ for (const hit of scanForEvalCalls(source)) {
36726
+ issues.push({
36727
+ ruleId: "security/eval",
36728
+ category: "security",
36729
+ severity: "high",
36730
+ aiSpecific: false,
36731
+ message: hit.message,
36732
+ line: hit.line,
36733
+ column: hit.column,
36734
+ advice: "Replace eval() with JSON.parse, a sandboxed expression parser, or a precomputed dispatch table. If you must compile dynamic code, use a Web Worker with a strict CSP and no network access."
36735
+ });
36736
+ }
36737
+ return issues;
36738
+ }
36739
+ });
36740
+
35747
36741
  // src/rules/security/exposed-env-var.ts
35748
36742
  var CLIENT_PREFIXES = [
35749
36743
  "NEXT_PUBLIC_",
@@ -35929,6 +36923,86 @@ var hardcodedSecretRule = createRule({
35929
36923
  }
35930
36924
  });
35931
36925
 
36926
+ // src/rules/security/localstorage-token.ts
36927
+ var LS_SETITEM_RE = /(?:localStorage|sessionStorage)\s*\.\s*setItem\s*\(\s*(['"`])([^'"`]+)\1/g;
36928
+ var LS_SETITEM_VAR_RE = /(?:localStorage|sessionStorage)\s*\.\s*setItem\s*\(\s*(?!['"`])([A-Za-z_$][\w$]*)/g;
36929
+ var TOKEN_KEY_RE = /(token|jwt|bearer|access|refresh|auth|session[_-]?id|session[_-]?token|id[_-]?token|sso|api[_-]?key)/i;
36930
+ var NON_TOKEN_KEY_ALLOWLIST = /* @__PURE__ */ new Set([
36931
+ "theme",
36932
+ "lang",
36933
+ "locale",
36934
+ "i18n",
36935
+ "user_pref",
36936
+ "userprefs",
36937
+ "prefs",
36938
+ "preferences",
36939
+ "settings",
36940
+ "ui_theme",
36941
+ "sidebar",
36942
+ "view_mode",
36943
+ "cart",
36944
+ "last_seen",
36945
+ "onboarding",
36946
+ "dismissed"
36947
+ ]);
36948
+ function isTokenY(rawKey) {
36949
+ const k = rawKey.toLowerCase();
36950
+ return !NON_TOKEN_KEY_ALLOWLIST.has(k) && TOKEN_KEY_RE.test(k);
36951
+ }
36952
+ function pushIssue(out, source, offset, message, advice) {
36953
+ out.push({
36954
+ ruleId: "security/localstorage-token",
36955
+ category: "security",
36956
+ severity: "high",
36957
+ aiSpecific: false,
36958
+ message,
36959
+ advice,
36960
+ line: lineOfSource(source, offset),
36961
+ column: 1
36962
+ });
36963
+ }
36964
+ var localstorageTokenRule = createRule({
36965
+ id: "security/localstorage-token",
36966
+ category: "security",
36967
+ severity: "high",
36968
+ aiSpecific: false,
36969
+ description: "Auth token stored in localStorage \u2014 vulnerable to XSS exfiltration (OWASP A03:2021).",
36970
+ create(context) {
36971
+ return context;
36972
+ },
36973
+ analyze(_context, facts) {
36974
+ const issues = [];
36975
+ const source = facts.v2?._source ?? "";
36976
+ if (!source) return issues;
36977
+ let m;
36978
+ LS_SETITEM_RE.lastIndex = 0;
36979
+ while ((m = LS_SETITEM_RE.exec(source)) !== null) {
36980
+ const key = m[2];
36981
+ if (!isTokenY(key)) continue;
36982
+ pushIssue(
36983
+ issues,
36984
+ source,
36985
+ m.index,
36986
+ `Auth key '${key}' written to localStorage / sessionStorage. Any page script can read it (XSS, malicious dep, browser extension).`,
36987
+ "Issue the token as an httpOnly Secure SameSite cookie so JS cannot access it. Keep credentials out of client JS entirely."
36988
+ );
36989
+ }
36990
+ LS_SETITEM_VAR_RE.lastIndex = 0;
36991
+ while ((m = LS_SETITEM_VAR_RE.exec(source)) !== null) {
36992
+ const ident = m[1];
36993
+ if (!TOKEN_KEY_RE.test(ident)) continue;
36994
+ pushIssue(
36995
+ issues,
36996
+ source,
36997
+ m.index,
36998
+ `localStorage.setItem('${ident}', ...) \u2014 identifier '${ident}' looks token-y. Verify the value is not a credential.`,
36999
+ "Trace the value being persisted. Tokens must never reach localStorage; issue as httpOnly cookies and call them server-side only."
37000
+ );
37001
+ }
37002
+ return issues;
37003
+ }
37004
+ });
37005
+
35932
37006
  // src/rules/security/missing-auth-check.ts
35933
37007
  var AUTH_PRIMITIVES = [
35934
37008
  "getServerSession",
@@ -36100,6 +37174,53 @@ var sqlConstructionRule = createRule({
36100
37174
  }
36101
37175
  });
36102
37176
 
37177
+ // src/rules/security/target-blank-no-noopener.ts
37178
+ var ANCHOR_TAG_RE = /<a\s+([^>]*?)>/gi;
37179
+ var TARGET_BLANK_RE = /\btarget\s*=\s*(["'])_blank\1/i;
37180
+ var REL_ATTR_RE = /\brel\s*=\s*["']([^"']*)["']/i;
37181
+ function hasSafeRel(attrs) {
37182
+ const m = attrs.match(REL_ATTR_RE);
37183
+ if (!m) return false;
37184
+ const tokens = m[1].trim().toLowerCase().split(/\s+/).filter(Boolean);
37185
+ return tokens.includes("noopener") || tokens.includes("noreferrer");
37186
+ }
37187
+ function pushIssue2(out, source, offset) {
37188
+ out.push({
37189
+ ruleId: "security/target-blank-no-noopener",
37190
+ category: "security",
37191
+ severity: "medium",
37192
+ aiSpecific: false,
37193
+ message: '<a target="_blank"> without rel="noopener" (or rel="noreferrer"). window.opener can navigate the originating tab \u2014 reverse tabnabbing.',
37194
+ advice: 'Add rel="noopener" to the <a>. rel="noreferrer" implies noopener and also strips the Referer header.',
37195
+ line: lineOfSource(source, offset),
37196
+ column: 1
37197
+ });
37198
+ }
37199
+ var targetBlankNoNoopenerRule = createRule({
37200
+ id: "security/target-blank-no-noopener",
37201
+ category: "security",
37202
+ severity: "medium",
37203
+ aiSpecific: false,
37204
+ description: 'Link with target="_blank" missing rel="noopener" \u2014 window.opener can navigate the opener tab (reverse tabnabbing, MDN).',
37205
+ create(context) {
37206
+ return context;
37207
+ },
37208
+ analyze(_context, facts) {
37209
+ const issues = [];
37210
+ const source = facts.v2?._source ?? "";
37211
+ if (!source) return issues;
37212
+ let m;
37213
+ ANCHOR_TAG_RE.lastIndex = 0;
37214
+ while ((m = ANCHOR_TAG_RE.exec(source)) !== null) {
37215
+ const attrs = m[1];
37216
+ if (!TARGET_BLANK_RE.test(attrs)) continue;
37217
+ if (hasSafeRel(attrs)) continue;
37218
+ pushIssue2(issues, source, m.index);
37219
+ }
37220
+ return issues;
37221
+ }
37222
+ });
37223
+
36103
37224
  // src/rules/security/unsafe-html-render.ts
36104
37225
  var DANGEROUS_RE = /dangerouslySetInnerHTML\s*=\s*\{\s*\{\s*__html\s*:\s*([\s\S]+?)\}\s*\}\s*[\s/]/g;
36105
37226
  function isStaticLiteral(value) {
@@ -36520,7 +37641,7 @@ function looksRealistic(value) {
36520
37641
 
36521
37642
  // src/rules/test/missing-edge-case.ts
36522
37643
  import { parseSync as parseSync2 } from "@swc/core";
36523
- import { existsSync as existsSync6, readFileSync as readFileSync6, readdirSync as readdirSync4, statSync as statSync4 } from "fs";
37644
+ import { existsSync as existsSync9, readFileSync as readFileSync10, readdirSync as readdirSync5, statSync as statSync4 } from "fs";
36524
37645
  var MAX_PER_FILE = 20;
36525
37646
  var missingEdgeCaseRule = createRule({
36526
37647
  id: "test/missing-edge-case",
@@ -36542,7 +37663,7 @@ var missingEdgeCaseRule = createRule({
36542
37663
  const testFileSources = /* @__PURE__ */ new Map();
36543
37664
  for (const testFile of discoverTestFiles(cwd)) {
36544
37665
  try {
36545
- testFileSources.set(testFile, readFileSync6(testFile, "utf-8"));
37666
+ testFileSources.set(testFile, readFileSync10(testFile, "utf-8"));
36546
37667
  } catch {
36547
37668
  }
36548
37669
  }
@@ -36796,16 +37917,16 @@ function discoverTestFiles(cwd) {
36796
37917
  const found = [];
36797
37918
  for (const root of roots) {
36798
37919
  const abs = `${cwd}/${root}`;
36799
- if (!existsSync6(abs)) continue;
36800
- walk(abs, found, readdirSync4, statSync4);
37920
+ if (!existsSync9(abs)) continue;
37921
+ walk2(abs, found, readdirSync5, statSync4);
36801
37922
  if (found.length > 200) break;
36802
37923
  }
36803
37924
  return found;
36804
37925
  }
36805
- function walk(dir, out, readdirSync5, statSync5) {
37926
+ function walk2(dir, out, readdirSync6, statSync5) {
36806
37927
  let entries;
36807
37928
  try {
36808
- entries = readdirSync5(dir);
37929
+ entries = readdirSync6(dir);
36809
37930
  } catch {
36810
37931
  return;
36811
37932
  }
@@ -36819,7 +37940,7 @@ function walk(dir, out, readdirSync5, statSync5) {
36819
37940
  continue;
36820
37941
  }
36821
37942
  if (stat.isDirectory()) {
36822
- walk(full, out, readdirSync5, statSync5);
37943
+ walk2(full, out, readdirSync6, statSync5);
36823
37944
  } else if (stat.isFile()) {
36824
37945
  if (/\.(test|spec)\.[jt]sx?$/.test(entry) || /\.stories\.[jt]sx?$/.test(entry)) {
36825
37946
  out.push(full);
@@ -37193,12 +38314,81 @@ var mathCtaVocabularyRule = createRule({
37193
38314
  }
37194
38315
  });
37195
38316
 
38317
+ // src/rules/typo/placeholder-text.ts
38318
+ var BAD_PATTERNS = [
38319
+ /\blorem\s+ipsum\b/i,
38320
+ /\bplaceholder\b/i,
38321
+ /\b(todo|fixme|xxx|aaa)\b/i,
38322
+ /\benter\s+text\b/i,
38323
+ /\btype\s+here\b/i,
38324
+ /\byour\s+text\s+here\b/i,
38325
+ /\bclick\s+here\b/i,
38326
+ /\b(asdf|qwerty)\b/i,
38327
+ /^(test|foo|bar|baz|sample)$/i
38328
+ ];
38329
+ function isBadPlaceholder(value, config) {
38330
+ const trimmed = value.trim();
38331
+ if (config.allowlist && config.allowlist.includes(trimmed)) return false;
38332
+ const minLength = config.minLength ?? 3;
38333
+ if (trimmed.length < minLength) return false;
38334
+ return BAD_PATTERNS.some((re) => re.test(trimmed));
38335
+ }
38336
+ function scanForBadPlaceholders(source, config) {
38337
+ const hits = [];
38338
+ const seen = /* @__PURE__ */ new Set();
38339
+ const re = /placeholder\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*"([^"]*)"\s*\}|\{\s*'([^']*)'\s*\})/gi;
38340
+ let m;
38341
+ while ((m = re.exec(source)) !== null) {
38342
+ if (seen.has(m.index)) continue;
38343
+ seen.add(m.index);
38344
+ const value = m[1] ?? m[2] ?? m[3] ?? m[4] ?? "";
38345
+ if (!isBadPlaceholder(value, config)) continue;
38346
+ hits.push({
38347
+ message: `Placeholder text "${value}" is a dev/AI default. Replace with real copy describing the expected input (e.g. "Search products", "Email address").`,
38348
+ line: lineOfSource(source, m.index),
38349
+ column: m.index - source.lastIndexOf("\n", m.index - 1),
38350
+ value
38351
+ });
38352
+ }
38353
+ return hits;
38354
+ }
38355
+ var placeholderTextRule = createRule({
38356
+ id: "typo/placeholder-text",
38357
+ category: "typo",
38358
+ severity: "low",
38359
+ aiSpecific: false,
38360
+ description: "Placeholder text contains dev/AI defaults (Lorem ipsum, Enter text here, TODO, etc.) \u2014 unfinished UI.",
38361
+ create(context) {
38362
+ return context;
38363
+ },
38364
+ analyze(context, facts) {
38365
+ const issues = [];
38366
+ const source = facts.v2?._source;
38367
+ if (!source) return issues;
38368
+ const config = context.config.ruleConfig["typo/placeholder-text"] ?? {};
38369
+ for (const hit of scanForBadPlaceholders(source, config)) {
38370
+ issues.push({
38371
+ ruleId: "typo/placeholder-text",
38372
+ category: "typo",
38373
+ severity: "low",
38374
+ aiSpecific: false,
38375
+ message: hit.message,
38376
+ line: hit.line,
38377
+ column: hit.column,
38378
+ advice: "Replace with specific, user-facing copy."
38379
+ });
38380
+ }
38381
+ return issues;
38382
+ }
38383
+ });
38384
+
37196
38385
  // src/rules/visual/arbitrary-escape.ts
37197
38386
  var arbitraryEscapeRule = createRule({
37198
38387
  id: "visual/arbitrary-escape",
37199
38388
  category: "visual",
37200
38389
  severity: "medium",
37201
38390
  aiSpecific: true,
38391
+ description: "Bracket-notation Tailwind values (e.g. `p-[13px]`, `bg-[#7c3aed]`) \u2014 AI agents reach for arbitrary escapes instead of design tokens (Refactoring UI; M\xE4ntyl\xE4 2003)",
37202
38392
  create(context) {
37203
38393
  return {
37204
38394
  allowlist: context.config.arbitraryValueAllowlist
@@ -38065,6 +39255,7 @@ var focusAppearanceRule = createRule({
38065
39255
  category: "wcag",
38066
39256
  severity: "high",
38067
39257
  aiSpecific: false,
39258
+ description: "Inject a global focus-ring CSS block (`*:focus-visible { outline: ... }`) \u2014 WCAG 2.4.7 (Focus Visible)",
38068
39259
  create(context) {
38069
39260
  return { globalCssTarget: context.config.globalCssTarget };
38070
39261
  },
@@ -38141,6 +39332,55 @@ var focusObscuredRule = createRule({
38141
39332
  }
38142
39333
  });
38143
39334
 
39335
+ // src/rules/wcag/missing-alt.ts
39336
+ var IMG_OPEN_RE = /<img\b[^>]*>/gi;
39337
+ var HAS_ALT_RE = /\balt\s*=\s*("[^"]*"|'[^']*')/i;
39338
+ var HAS_PRESENTATION_ROLE_RE = /\brole\s*=\s*["'](?:presentation|none)["']/i;
39339
+ function scanForMissingAlt(source) {
39340
+ const hits = [];
39341
+ IMG_OPEN_RE.lastIndex = 0;
39342
+ let m;
39343
+ while ((m = IMG_OPEN_RE.exec(source)) !== null) {
39344
+ const tagText = m[0];
39345
+ if (HAS_ALT_RE.test(tagText)) continue;
39346
+ if (HAS_PRESENTATION_ROLE_RE.test(tagText)) continue;
39347
+ hits.push({
39348
+ message: '<img> element is missing an `alt` attribute. WCAG 2.1 SC 1.1.1 requires alt text for non-text content. Use alt="" for purely decorative images, or role="presentation" to remove it from the accessibility tree.',
39349
+ line: lineOfSource(source, m.index),
39350
+ column: m.index - source.lastIndexOf("\n", m.index - 1)
39351
+ });
39352
+ }
39353
+ return hits;
39354
+ }
39355
+ var missingAltRule = createRule({
39356
+ id: "wcag/missing-alt",
39357
+ category: "wcag",
39358
+ severity: "medium",
39359
+ aiSpecific: false,
39360
+ description: "<img> element is missing an `alt` attribute (WCAG 2.1 SC 1.1.1, Level A).",
39361
+ create(context) {
39362
+ return context;
39363
+ },
39364
+ analyze(_context, facts) {
39365
+ const issues = [];
39366
+ const source = facts.v2?._source;
39367
+ if (!source) return issues;
39368
+ for (const hit of scanForMissingAlt(source)) {
39369
+ issues.push({
39370
+ ruleId: "wcag/missing-alt",
39371
+ category: "wcag",
39372
+ severity: "medium",
39373
+ aiSpecific: false,
39374
+ message: hit.message,
39375
+ line: hit.line,
39376
+ column: hit.column,
39377
+ advice: 'Add `alt="..."` describing the image, or `alt=""` (or role="presentation") for purely decorative images.'
39378
+ });
39379
+ }
39380
+ return issues;
39381
+ }
39382
+ });
39383
+
38144
39384
  // src/rules/wcag/target-size.ts
38145
39385
  var SIZE_PREFIX_RE = /^(min-w|min-h|max-w|max-h|h|w|size)-(.+)$/;
38146
39386
  var PAD_PREFIX_RE = /^(p|px|py)-(.+)$/;
@@ -38322,6 +39562,16 @@ var builtinRules = [
38322
39562
  multipleComponentsPerFileRule,
38323
39563
  shadcnPropMismatchRule,
38324
39564
  importPathMismatchRule,
39565
+ duplicateIndexRule,
39566
+ enumSprawlRule,
39567
+ missingFkIndexRule,
39568
+ missingNotNullRule,
39569
+ namingInconsistencyRule,
39570
+ sqlConcatRule,
39571
+ brokenLinkRule,
39572
+ expiredCodeExampleRule,
39573
+ staleFunctionReferenceRule,
39574
+ stalePackageReferenceRule,
38325
39575
  gapMonopolyRule,
38326
39576
  mathElementUniformityRule,
38327
39577
  mathGridUniformityRule,
@@ -38347,12 +39597,15 @@ var builtinRules = [
38347
39597
  terminologyDriftRule,
38348
39598
  uxPatternFragmentationRule,
38349
39599
  dangerousCorsRule,
39600
+ evalRule,
38350
39601
  exposedEnvVarRule,
38351
39602
  failOpenAuthRule,
38352
39603
  hardcodedSecretRule,
39604
+ localstorageTokenRule,
38353
39605
  missingAuthCheckRule,
38354
39606
  publicAdminRouteRule,
38355
39607
  sqlConstructionRule,
39608
+ targetBlankNoNoopenerRule,
38356
39609
  unsafeHtmlRenderRule,
38357
39610
  duplicateSetupRule,
38358
39611
  fakePlaceholderRule,
@@ -38363,6 +39616,7 @@ var builtinRules = [
38363
39616
  clampOffscaleRule,
38364
39617
  mathButtonLabelUniformityRule,
38365
39618
  mathCtaVocabularyRule,
39619
+ placeholderTextRule,
38366
39620
  arbitraryEscapeRule,
38367
39621
  clampSoupRule,
38368
39622
  forcedLayoutRule,
@@ -38380,6 +39634,7 @@ var builtinRules = [
38380
39634
  draggingMovementsRule,
38381
39635
  focusAppearanceRule,
38382
39636
  focusObscuredRule,
39637
+ missingAltRule,
38383
39638
  targetSizeRule
38384
39639
  ];
38385
39640
 
@@ -39173,6 +40428,156 @@ var signal_strength_default = {
39173
40428
  lastCalibratedAt: "2026-06-27T12:00:00Z",
39174
40429
  verdict: "USEFUL",
39175
40430
  _calibrationNote: "v7 corpus re-calibration (2026-06-27, min-date=2025-01-01): 184488 neg + 239054 pos. USEFUL \u2014 TP=1912, FP=175, P=91.6%, FPR=0.09%, lift=8.4. aiSpecific=True."
40431
+ },
40432
+ "db/missing-fk-index": {
40433
+ recall: 0,
40434
+ fpRate: 0,
40435
+ ratio: 0,
40436
+ precision: 0,
40437
+ lastCalibratedAt: "2026-06-30T00:00:00Z",
40438
+ verdict: "DORMANT",
40439
+ defaultOff: true,
40440
+ _calibrationNote: "v0.17.0 ship \u2014 not in v7 per-rule table. Default-off until calibration data lands. Backed by: PostgreSQL Global Development Group (2024), *PostgreSQL 16 Documentation \xA75.4 (Constraints)*, https://www.postgresql.org/docs/16/ddl-constraints.html; Squawk (2023), *Postgres linter rules*, https://github.com/sqllabs/squawk, rule `require-index-for-fk`. (Foreign key columns need a matching index \u2014 sequential scan on parent delete is a canonical Postgres anti-pattern.)"
40441
+ },
40442
+ "db/duplicate-index": {
40443
+ recall: 0,
40444
+ fpRate: 0,
40445
+ ratio: 0,
40446
+ precision: 0,
40447
+ lastCalibratedAt: "2026-06-30T00:00:00Z",
40448
+ verdict: "DORMANT",
40449
+ defaultOff: true,
40450
+ _calibrationNote: "v0.17.0 ship \u2014 not in v7 per-rule table. Default-off until calibration data lands. Backed by: PostgreSQL Global Development Group (2024), *PostgreSQL 16 Documentation \xA711.2 (Index Types)*; Heroku Postgres Team (2018), *Efficient Use of PostgreSQL Indexes*. (Duplicate indexes silently double write cost without read benefit \u2014 Postgres does not warn.)"
40451
+ },
40452
+ "db/missing-not-null": {
40453
+ recall: 0,
40454
+ fpRate: 0,
40455
+ ratio: 0,
40456
+ precision: 0,
40457
+ lastCalibratedAt: "2026-06-30T00:00:00Z",
40458
+ verdict: "DORMANT",
40459
+ defaultOff: true,
40460
+ _calibrationNote: "v0.17.0 ship \u2014 not in v7 per-rule table. Default-off until calibration data lands. Backed by: PostgreSQL Global Development Group (2024), *PostgreSQL 16 Documentation \xA75.4.1 (Check Constraints)*; Kleppmann, M. (2017), *Designing Data-Intensive Applications*, O'Reilly, ch.4 (NULL semantics in RDBMS). (Required-identifier columns without NOT NULL produce silent NULL inserts \u2014 canonical AI SQL smell.)"
40461
+ },
40462
+ "db/enum-sprawl": {
40463
+ recall: 0,
40464
+ fpRate: 0,
40465
+ ratio: 0,
40466
+ precision: 0,
40467
+ lastCalibratedAt: "2026-06-30T00:00:00Z",
40468
+ verdict: "DORMANT",
40469
+ defaultOff: true,
40470
+ _calibrationNote: "v0.17.0 ship \u2014 not in v7 per-rule table. Default-off until calibration data lands. Backed by: PostgreSQL Global Development Group (2024), *PostgreSQL 16 Documentation \xA78.7 (Enumerated Types)*; Borkowski, A. (2022), *PostgreSQL enum types: when to use them and when not to*, pgconfig.org. (Enums with >12 values are brittle to extend and hard to localize \u2014 lookup table is the standard replacement.)"
40471
+ },
40472
+ "db/naming-inconsistency": {
40473
+ recall: 0,
40474
+ fpRate: 0,
40475
+ ratio: 0,
40476
+ precision: 0,
40477
+ lastCalibratedAt: "2026-06-30T00:00:00Z",
40478
+ verdict: "DORMANT",
40479
+ defaultOff: true,
40480
+ _calibrationNote: "v0.17.0 ship \u2014 not in v7 per-rule table. Default-off until calibration data lands. Backed by: PostgreSQL Global Development Group (2024), *PostgreSQL 16 Documentation \xA74.1.1 (Identifiers and Key Words)*; pgsql-hackers (2018), *Re: snake_case vs camelCase in Postgres identifiers*. (snake_case is the Postgres convention; mixing styles in the same schema breaks ORM generators.)"
40481
+ },
40482
+ "db/sql-concat": {
40483
+ recall: 0,
40484
+ fpRate: 0,
40485
+ ratio: 0,
40486
+ precision: 0,
40487
+ lastCalibratedAt: "2026-06-30T00:00:00Z",
40488
+ verdict: "DORMANT",
40489
+ defaultOff: true,
40490
+ _calibrationNote: "v0.17.0 ship \u2014 not in v7 per-rule table. Default-off until calibration data lands. Backed by: OWASP Foundation (2021), *OWASP Top 10 \u2014 A03:2021 Injection*, https://owasp.org/Top10/A03_2021-Injection/; OWASP Foundation (2017), *SQL Injection Prevention Cheat Sheet*. (Template-literal SQL with ${...} interpolation is the #1 SQL injection vector in AI-generated TypeScript code.)"
40491
+ },
40492
+ "docs/stale-package-reference": {
40493
+ recall: 0,
40494
+ fpRate: 0,
40495
+ ratio: 0,
40496
+ precision: 0,
40497
+ lastCalibratedAt: "2026-06-30T00:00:00Z",
40498
+ verdict: "DORMANT",
40499
+ defaultOff: true,
40500
+ _calibrationNote: "v0.17.0 ship \u2014 not in v7 per-rule table. Default-off until calibration data lands. Backed by: Meng, M. et al. (2020), *The Cost of Fixing Code Documentation: An Empirical Study on Open-Source Software*, ICSE 2020; Aghajani, E. et al. (2019), *Software documentation: a practitioners' perspective*, ICSE 2019. (Doc references to undeclared packages are copy-paste rot from previous projects \u2014 high-signal doc drift.)"
40501
+ },
40502
+ "docs/stale-function-reference": {
40503
+ recall: 0,
40504
+ fpRate: 0,
40505
+ ratio: 0,
40506
+ precision: 0,
40507
+ lastCalibratedAt: "2026-06-30T00:00:00Z",
40508
+ verdict: "DORMANT",
40509
+ defaultOff: true,
40510
+ _calibrationNote: "v0.17.0 ship \u2014 not in v7 per-rule table. Default-off until calibration data lands. Backed by: Meng, M. et al. (2020), *The Cost of Fixing Code Documentation*; Chen, Z. et al. (2022), *An Empirical Study on Code Documentation Adequacy*, MSR 2022. (Doc callouts to non-existent exports \u2014 readers copy-paste and hit a runtime error.)"
40511
+ },
40512
+ "docs/expired-code-example": {
40513
+ recall: 0,
40514
+ fpRate: 0,
40515
+ ratio: 0,
40516
+ precision: 0,
40517
+ lastCalibratedAt: "2026-06-30T00:00:00Z",
40518
+ verdict: "DORMANT",
40519
+ defaultOff: true,
40520
+ _calibrationNote: "v0.17.0 ship \u2014 not in v7 per-rule table. Default-off until calibration data lands. Backed by: Parnas, D.L. (2010), *Precise Documentation: The Key to Better Software*, Springer (canonical doc-correctness argument); Chen, Z. et al. (2022). (Fenced code examples with broken imports erode trust in the entire docs site.)"
40521
+ },
40522
+ "docs/broken-link": {
40523
+ recall: 0,
40524
+ fpRate: 0,
40525
+ ratio: 0,
40526
+ precision: 0,
40527
+ lastCalibratedAt: "2026-06-30T00:00:00Z",
40528
+ verdict: "DORMANT",
40529
+ defaultOff: true,
40530
+ _calibrationNote: "v0.17.0 ship \u2014 not in v7 per-rule table. Default-off until calibration data lands. Backed by: Nielsen, J. (2000), *Designing Web Usability*, New Riders (broken links as a top-5 trust erosion signal); Google Search Central (2024), *Crawl errors documentation*, https://developers.google.com/search/docs/crawling-indexing. (Broken internal links cost SEO crawl budget and reader trust.)"
40531
+ },
40532
+ "security/eval": {
40533
+ recall: 0,
40534
+ fpRate: 0,
40535
+ ratio: 0,
40536
+ precision: 0,
40537
+ lastCalibratedAt: "2026-06-30T00:00:00Z",
40538
+ verdict: "DORMANT",
40539
+ defaultOff: true,
40540
+ _calibrationNote: "v0.16.0 ship \u2014 not in v7 per-rule table. Default-off until calibration data lands. Backed by: OWASP Foundation (2021), *OWASP Top 10 \u2014 A03:2021 Injection*; Mozilla Developer Network (2024), *eval() \u2014 MDN Web Docs*, https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval (Security Considerations). (eval() and new Function() are RCE vectors if the input is ever attacker-controlled.)"
40541
+ },
40542
+ "security/localstorage-token": {
40543
+ recall: 0,
40544
+ fpRate: 0,
40545
+ ratio: 0,
40546
+ precision: 0,
40547
+ lastCalibratedAt: "2026-06-30T00:00:00Z",
40548
+ verdict: "DORMANT",
40549
+ defaultOff: true,
40550
+ _calibrationNote: "v0.16.0 ship \u2014 not in v7 per-rule table. Default-off until calibration data lands. Backed by: OWASP Foundation (2021), *OWASP Top 10 \u2014 A07:2021 Identification and Authentication Failures*; OWASP Foundation (2022), *HTML5 Security Cheat Sheet \xA72.1 (Local Storage)*. (JWT/access/refresh tokens in localStorage are readable by any XSS payload \u2014 issue as httpOnly cookies instead.)"
40551
+ },
40552
+ "security/target-blank-no-noopener": {
40553
+ recall: 0,
40554
+ fpRate: 0,
40555
+ ratio: 0,
40556
+ precision: 0,
40557
+ lastCalibratedAt: "2026-06-30T00:00:00Z",
40558
+ verdict: "DORMANT",
40559
+ defaultOff: true,
40560
+ _calibrationNote: 'v0.16.0 ship \u2014 not in v7 per-rule table. Default-off until calibration data lands. Backed by: OWASP Foundation (2022), *HTML5 Security Cheat Sheet \xA72.3 (Tabnabbing)*; WHATWG (2024), *HTML Living Standard \u2014 Link types: noopener*. (target="_blank" without rel="noopener" lets the linked page control window.opener \u2014 reverse tabnabbing.)'
40561
+ },
40562
+ "wcag/missing-alt": {
40563
+ recall: 0,
40564
+ fpRate: 0,
40565
+ ratio: 0,
40566
+ precision: 0,
40567
+ lastCalibratedAt: "2026-06-30T00:00:00Z",
40568
+ verdict: "DORMANT",
40569
+ defaultOff: true,
40570
+ _calibrationNote: 'v0.16.0 ship \u2014 not in v7 per-rule table. Default-off until calibration data lands. Backed by: W3C (2023), *Web Content Accessibility Guidelines (WCAG) 2.2 \u2014 1.1.1 Non-text Content*, https://www.w3.org/TR/WCAG22/. (Every <img> needs alt text. Decorative: alt="". Informative: describe the image.)'
40571
+ },
40572
+ "typo/placeholder-text": {
40573
+ recall: 0,
40574
+ fpRate: 0,
40575
+ ratio: 0,
40576
+ precision: 0,
40577
+ lastCalibratedAt: "2026-06-30T00:00:00Z",
40578
+ verdict: "DORMANT",
40579
+ defaultOff: true,
40580
+ _calibrationNote: "v0.16.0 ship \u2014 not in v7 per-rule table. Default-off until calibration data lands. Backed by: Nielsen, J. (2000), *Designing Web Usability* (placeholder copy as a top-10 trust erosion signal); Krug, S. (2000), *Don't Make Me Think*, 2nd ed. (TODO/placeholder text in shipped UI signals incomplete work.)"
39176
40581
  }
39177
40582
  };
39178
40583
 
@@ -39198,7 +40603,7 @@ function applyRuleOverrides(issues, rules) {
39198
40603
  return result;
39199
40604
  }
39200
40605
  async function scanFile(filePath, config, registry, cwd = process.cwd()) {
39201
- const ext = extname3(filePath).toLowerCase();
40606
+ const ext = extname6(filePath).toLowerCase();
39202
40607
  const UNSUPPORTED_LANGS = /* @__PURE__ */ new Set([
39203
40608
  ".swift",
39204
40609
  ".kt",