@danielsimonjr/repo-tools 2.2.4 → 2.2.6

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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,51 @@ All notable changes to this project are recorded in this file. The format follow
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.2.6] - 2026-10-22
10
+
11
+ ### Fixed
12
+
13
+ - `repo-tools map` finds the entry roots of an app whose build output is not `dist/` and whose sources are not
14
+ only under `src/`. On an Electron app (main process built to `dist-electron/`, CLI built to `dist-cli/`, renderer
15
+ loaded from `src/index.html`) it reported 74 dormant files, among them 11 orphans, and now reports 5 dormant files,
16
+ among them 3 orphans. Four sources of roots were missing or broken:
17
+ - A `tsc -p <tsconfig>` script of the root package in a repository without workspaces seeds the files of that
18
+ tsconfig. Only a workspace package did this before.
19
+ - A tsconfig that holds comments or trailing commas is read. `JSON.parse` rejected it and the whole tsconfig was
20
+ ignored without a message.
21
+ - A root build or test config names entry files with an `entry`, `input`, `setupFiles`, `globalSetup` or
22
+ `globalTeardown` key (for example `path.resolve(__dirname, 'electron/main.ts')`). Each such file is a root.
23
+ Only `new URL(...)` was read before.
24
+ - The module script of `index.html` or `src/index.html` is a root.
25
+ The ambient declaration files (`vite-env.d.ts`) stay in the list of files without an importer, as before.
26
+
27
+ ## [2.2.5] - 2026-10-22
28
+
29
+ ### Changed
30
+
31
+ - The C# test-coverage report states its basis. A C# `using` usually names a namespace, so a test covers every file
32
+ that declares the namespace, and a covered file is not proof that a test uses the file. A test in the same namespace,
33
+ or in a nested namespace, needs no `using` and does not count, so the percentage can be too high or too low.
34
+ `TEST_COVERAGE.md` now has a note under the summary table that says "counted by namespace import".
35
+ `test-coverage.json` now has `metadata.coverageBasis` (`namespace-import`) and `metadata.coverageCaveat`. The
36
+ percentage does not change. The reports of TypeScript, Python and Rust have no note and no new key, and the golden
37
+ files do not change.
38
+ `importsNameSymbols` in `src/map/artifacts.ts` is now the one rule for "does an import name a symbol"; the
39
+ unused-analysis caveat and its export classification use it as well.
40
+ - `docs/design.md` passes `repo-tools ste` with 0 problems. The check reported 4 sentences of 31 to 43 words in
41
+ section 16.3. Each of the 4 sentences is now a group of shorter sentences. No fact changes.
42
+
43
+ ### Fixed
44
+
45
+ - `repo-tools docs` fails on a repository that has more files of a language it cannot read than TypeScript,
46
+ JavaScript and Python together. It printed `PASS -- 0/0 exported symbols documented, 0 MUST issues` and exited 0
47
+ for IronClaw, a Rust repository. `docs check src` measured the 1 JavaScript file below `src` and none of the 247
48
+ Rust files. `scan`, `check` and `stub` now exit 1 and print
49
+ `this repo is rust: 247 rust file(s) against 1 TypeScript, JavaScript or Python file(s)`. `map` already refused
50
+ such a repository. `docs` uses the file walk of `map`, so both commands give a repository the same language. A tie
51
+ is measured. A TypeScript repository with a few Rust files is measured as before. A repository of Go files only
52
+ now names Go. It said before that it found no source file.
53
+
9
54
  ## [2.2.4] - 2026-10-19
10
55
 
11
56
  ### Fixed
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  | `query` | Answers structural questions from the graph of `map`, and writes two derived reports. |
14
14
  | `ste` | Checks Markdown or docstring prose against Simplified Technical English. |
15
15
 
16
- Status: version 2.2.4.
16
+ Status: version 2.2.6.
17
17
 
18
18
  - `map`, `check` and `query` read their settings from `repo-tools.config.json` in the root. The
19
19
  section of `map` is `map` (its old name, `depgraph`, is also read). `--config=<file>` makes any
@@ -41,7 +41,7 @@ repo-tools --version
41
41
 
42
42
  The executable holds the tool and the Bun runtime. It needs no Bun, Node or `node_modules`.
43
43
  Download the file for your platform and `SHA256SUMS` from the
44
- [GitHub release](https://github.com/danielsimonjr/repo-tools/releases/tag/v2.2.4):
44
+ [GitHub release](https://github.com/danielsimonjr/repo-tools/releases/tag/v2.2.6):
45
45
 
46
46
  | Platform | File |
47
47
  |---|---|
@@ -238,7 +238,9 @@ The rules are:
238
238
  | S6 | SHOULD | The prose follows Simplified Technical English (the rules of `repo-tools ste`). |
239
239
 
240
240
  Only a MUST issue changes the exit code of `check`. A file that does not parse is also a failure,
241
- because an unknown file is not a clean file. A repository with no source file fails too.
241
+ because an unknown file is not a clean file. A repository with no source file fails too. So does a
242
+ repository with more files of another language (Rust or Go, for example) than TypeScript, JavaScript
243
+ and Python together. The command names the language and exits 1.
242
244
 
243
245
  `stub` is a dry run until you pass `--apply`. Each stub holds the marker `TODO:`, so `check` fails
244
246
  until you replace the stub with real prose. The command parses a file again after the change.
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // package.json
3
3
  var package_default = {
4
4
  name: "@danielsimonjr/repo-tools",
5
- version: "2.2.4",
5
+ version: "2.2.6",
6
6
  description: "One CLI for repository analysis: dependency graphs creation and query with per-export API facts, file chunking, and context compression. TypeScript on Bun, compiled to a single executable.",
7
7
  type: "module",
8
8
  license: "MIT",
@@ -2066,13 +2066,55 @@ function tsupConfigEntries(root, pkgDir) {
2066
2066
  }
2067
2067
  return out2;
2068
2068
  }
2069
+ function stringEnd(text, start2) {
2070
+ let j = start2 + 1;
2071
+ while (j < text.length && text.charAt(j) !== '"')
2072
+ j += text.charAt(j) === "\\" ? 2 : 1;
2073
+ return Math.min(j + 1, text.length);
2074
+ }
2075
+ function stripJsonc(text) {
2076
+ let plain = "";
2077
+ for (let i2 = 0;i2 < text.length; ) {
2078
+ const c = text.charAt(i2);
2079
+ if (c === '"') {
2080
+ const end = stringEnd(text, i2);
2081
+ plain += text.slice(i2, end);
2082
+ i2 = end;
2083
+ } else if (c === "/" && text.charAt(i2 + 1) === "/") {
2084
+ while (i2 < text.length && text.charAt(i2) !== `
2085
+ `)
2086
+ i2++;
2087
+ } else if (c === "/" && text.charAt(i2 + 1) === "*") {
2088
+ const end = text.indexOf("*/", i2 + 2);
2089
+ i2 = end === -1 ? text.length : end + 2;
2090
+ } else {
2091
+ plain += c;
2092
+ i2++;
2093
+ }
2094
+ }
2095
+ let out2 = "";
2096
+ for (let i2 = 0;i2 < plain.length; ) {
2097
+ const c = plain.charAt(i2);
2098
+ if (c === '"') {
2099
+ const end = stringEnd(plain, i2);
2100
+ out2 += plain.slice(i2, end);
2101
+ i2 = end;
2102
+ } else if (c === "," && /^\s*[}\]]/.test(plain.slice(i2 + 1))) {
2103
+ i2++;
2104
+ } else {
2105
+ out2 += c;
2106
+ i2++;
2107
+ }
2108
+ }
2109
+ return out2;
2110
+ }
2069
2111
  function seedTsconfigEntries(root, pkgDir, cfgRel, add) {
2070
2112
  const cfgPath = join7(root, pkgDir, cfgRel);
2071
2113
  if (!existsSync4(cfgPath))
2072
2114
  return;
2073
2115
  let cfg;
2074
2116
  try {
2075
- cfg = JSON.parse(readFileSync3(cfgPath, "utf-8"));
2117
+ cfg = JSON.parse(stripJsonc(readFileSync3(cfgPath, "utf-8")));
2076
2118
  } catch {
2077
2119
  return;
2078
2120
  }
@@ -2226,6 +2268,8 @@ function selfPackage(root) {
2226
2268
  entryFiles: packageEntryFiles(root, pkg)
2227
2269
  };
2228
2270
  }
2271
+ var CONFIG_ENTRY_KEY = /\b(?:entry|input|setupFiles|globalSetup|globalTeardown)["']?\s*:\s*(\[[^\]]*\]|\{[^}]*\}|[\w$.]+\([^)]*\)|['"`][^'"`]*['"`])/g;
2272
+ var SOURCE_LITERAL = /['"`]([^'"`]+\.[cm]?[jt]sx?)['"`]/g;
2229
2273
  function configReferencedEntries(root) {
2230
2274
  const out2 = new Set;
2231
2275
  let names;
@@ -2247,9 +2291,36 @@ function configReferencedEntries(root) {
2247
2291
  for (const m of code.matchAll(/new\s+URL\(\s*['"`]([^'"`]*?src\/[\w./-]+\.ts)['"`]/g)) {
2248
2292
  out2.add((m[1] ?? "").replace(/^\.\//, ""));
2249
2293
  }
2294
+ for (const key of code.matchAll(CONFIG_ENTRY_KEY)) {
2295
+ for (const lit of (key[1] ?? "").matchAll(SOURCE_LITERAL)) {
2296
+ out2.add(toPosix(lit[1] ?? "").replace(/^\.\//, ""));
2297
+ }
2298
+ }
2250
2299
  }
2251
2300
  return [...out2];
2252
2301
  }
2302
+ function htmlReferencedEntries(root) {
2303
+ const out2 = [];
2304
+ for (const page of ["index.html", "src/index.html"]) {
2305
+ let html;
2306
+ try {
2307
+ html = readFileSync3(join7(root, page), "utf-8");
2308
+ } catch {
2309
+ continue;
2310
+ }
2311
+ for (const m of html.matchAll(/<script\b[^>]*?\bsrc\s*=\s*["']([^"']+)["']/gi)) {
2312
+ const src = m[1] ?? "";
2313
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(src) || !/\.[cm]?[jt]sx?$/.test(src))
2314
+ continue;
2315
+ const fromPage = toPosix(join7(dirname4(page), src)).replace(/^\.\//, "");
2316
+ const candidates = src.startsWith("/") ? [src.slice(1), fromPage] : [fromPage];
2317
+ const found = candidates.find((c) => existsSync4(join7(root, c)));
2318
+ if (found !== undefined && !out2.includes(found))
2319
+ out2.push(found);
2320
+ }
2321
+ }
2322
+ return out2;
2323
+ }
2253
2324
  function runtimeLaunchedEntries(root, parsed) {
2254
2325
  const known = new Set(parsed.map((f) => f.path));
2255
2326
  const out2 = new Set;
@@ -12513,7 +12584,8 @@ function findRoots(root, known, language) {
12513
12584
  return rustRoots(known);
12514
12585
  const [rootRoots, rootWarnings] = packageJsonRoots(root, known);
12515
12586
  const [wsRoots, wsWarnings] = workspaceRoots(root, known);
12516
- const roots = [...new Set([...rootRoots, ...wsRoots])];
12587
+ const scriptRoots = detectWorkspaces(root).size === 0 ? rootPackageEntries(root).filter((e) => known.has(e)) : [];
12588
+ const roots = [...new Set([...rootRoots, ...wsRoots, ...scriptRoots])];
12517
12589
  const warnings = [...rootWarnings, ...wsWarnings];
12518
12590
  if (roots.length > 0)
12519
12591
  return [roots, warnings];
@@ -12689,7 +12761,7 @@ async function buildGraph(root) {
12689
12761
  const [declaredRoots, rootWarnings] = findRoots(root, known, language);
12690
12762
  const roots = [...declaredRoots];
12691
12763
  if (language === "typescript") {
12692
- for (const entry of configReferencedEntries(root)) {
12764
+ for (const entry of [...configReferencedEntries(root), ...htmlReferencedEntries(root)]) {
12693
12765
  if (known.has(entry) && !roots.includes(entry))
12694
12766
  roots.push(entry);
12695
12767
  }
@@ -13114,6 +13186,9 @@ var IMPORTS_NAME_SYMBOLS = {
13114
13186
  csharp: false,
13115
13187
  rust: true
13116
13188
  };
13189
+ function importsNameSymbols(language) {
13190
+ return IMPORTS_NAME_SYMBOLS[language] ?? true;
13191
+ }
13117
13192
  var PUBLIC_API_LANGUAGES = new Set(["rust"]);
13118
13193
  var PUBLIC_API_CAVEAT = "A `pub` item with no user inside this crate is normally PUBLIC API, not dead code. `unused-analysis` sees only the repository. A library crate's public surface exists for DOWNSTREAM consumers. These consumers are not in the graph. Therefore, 'no importer here' is the expected state for most public items. The state is not a finding. Measured on one real crate: 2,635 of 2,739 exports (96%) had no in-crate user, and essentially all of them were live API. Treat this list as 'candidates to check against the crate's intended surface', never as a deletion list.";
13119
13194
  var NAMESPACE_IMPORT_CAVEAT = "In this language, an import names a namespace, not the symbols that it brings in (C#'s `using App.Hosting;`). The import edges therefore cannot show the use of one symbol. Usage here is a whole-identifier text search across every scanned file instead. The search is the same class of TEXT-LEVEL heuristic as the `referencedInModule` split above. The limit is the same: a name in the comment or string literal of another file counts as a use. The search is conservative. It UNDER-reports dead code and never invents it. That direction is safe for a list whose entries read as deletion candidates. Neither the import edges nor the text search can see a type that only reflection reaches (`Activator.CreateInstance`, DI-by-convention) or that only a .csproj/XAML file names.";
@@ -13144,7 +13219,7 @@ function identifierUsers(graph) {
13144
13219
  }
13145
13220
  function caveatsFor(graph) {
13146
13221
  const caveats = [...UNUSED_CAVEATS];
13147
- if (!(IMPORTS_NAME_SYMBOLS[graph.language] ?? true))
13222
+ if (!importsNameSymbols(graph.language))
13148
13223
  caveats.push(NAMESPACE_IMPORT_CAVEAT);
13149
13224
  if (PUBLIC_API_LANGUAGES.has(graph.language))
13150
13225
  caveats.push(PUBLIC_API_CAVEAT);
@@ -13185,7 +13260,7 @@ function classifyUnusedExports(graph) {
13185
13260
  const unreferenced = {};
13186
13261
  const referencedInModule = {};
13187
13262
  const unclassified = {};
13188
- const namesSymbols = IMPORTS_NAME_SYMBOLS[graph.language] ?? true;
13263
+ const namesSymbols = importsNameSymbols(graph.language);
13189
13264
  const users = namesSymbols ? new Map : identifierUsers(graph);
13190
13265
  const push = (bucket, path, name2) => {
13191
13266
  const list = bucket[path] ?? [];
@@ -13530,6 +13605,8 @@ import { basename as basename5 } from "node:path";
13530
13605
  function percent(tested, total) {
13531
13606
  return total > 0 ? (tested / total * 100).toFixed(1) : "0";
13532
13607
  }
13608
+ var NAMESPACE_IMPORT_BASIS = "namespace-import";
13609
+ var NAMESPACE_IMPORT_COVERAGE_CAVEAT = "Coverage is counted by namespace import. A C# `using` directive usually names a namespace, not a type, so a test covers every file that declares the namespace. A namespace import does not show that a test uses the file. A test in the same namespace, or in a nested namespace, needs no `using` and does not count. The percentage can be too high or too low.";
13533
13610
  function generateTestCoverageMarkdown(coverage, options = {}) {
13534
13611
  const hint = options.expectedTestHint ?? true;
13535
13612
  const lines = [];
@@ -13548,9 +13625,11 @@ function generateTestCoverageMarkdown(coverage, options = {}) {
13548
13625
  lines.push(`| Source Files without Tests | ${totalUntested} |`);
13549
13626
  lines.push(`| Coverage (raw, direct-import) | **${percent(totalTested, totalSource)}%** |`);
13550
13627
  const b = coverage.policyBreakdown;
13628
+ const basisNote = options.namespaceImportBasis ? [`> ${NAMESPACE_IMPORT_COVERAGE_CAVEAT}`, ""] : [];
13551
13629
  if (coverage.policy) {
13552
13630
  lines.push(`| Coverage (effective, active code only) | **${b.effectivePercent}%** (${b.testedActive} / ${b.activeFiles}) |`);
13553
13631
  lines.push("");
13632
+ lines.push(...basisNote);
13554
13633
  const policyPath = options.policyPath ?? "docs/architecture/coverage-policy.json";
13555
13634
  lines.push("> The raw figure counts every source file the CDG tool finds, including code that is " + "intentionally not direct-imported by a vitest `*.test.ts` (synced mathjs categories, " + "AssemblyScript sources, type-only barrels, …). The **effective** figure excludes those " + `per \`${policyPath}\` so the number reflects the genuinely-active hand-written code only. ` + "See [`COVERAGE_POLICY.md`](./COVERAGE_POLICY.md) for the policy.");
13556
13635
  lines.push("");
@@ -13566,6 +13645,8 @@ function generateTestCoverageMarkdown(coverage, options = {}) {
13566
13645
  }
13567
13646
  lines.push(`| **Active (real gap — needs a test)** | ${b.byCategory.active_untested ?? 0} | These are the files that should grow a direct-import test. |`);
13568
13647
  lines.push("");
13648
+ } else if (basisNote.length > 0) {
13649
+ lines.push("", ...basisNote);
13569
13650
  }
13570
13651
  lines.push("---");
13571
13652
  lines.push("");
@@ -13621,7 +13702,7 @@ function generateTestCoverageMarkdown(coverage, options = {}) {
13621
13702
  return lines.join(`
13622
13703
  `);
13623
13704
  }
13624
- function generateTestCoverageJson(coverage) {
13705
+ function generateTestCoverageJson(coverage, options = {}) {
13625
13706
  const coverageMapObj = {};
13626
13707
  for (const [source, tests] of coverage.coverageMap)
13627
13708
  coverageMapObj[source] = tests;
@@ -13636,6 +13717,10 @@ function generateTestCoverageJson(coverage) {
13636
13717
  testedCount: coverage.testedFiles.length,
13637
13718
  untestedCount: coverage.untestedFiles.length,
13638
13719
  coveragePercent: percent(coverage.testedFiles.length, coverage.sourceFiles.length),
13720
+ ...options.namespaceImportBasis ? {
13721
+ coverageBasis: NAMESPACE_IMPORT_BASIS,
13722
+ coverageCaveat: NAMESPACE_IMPORT_COVERAGE_CAVEAT
13723
+ } : {},
13639
13724
  effectiveCoverage: {
13640
13725
  policyLoaded: coverage.policy !== null,
13641
13726
  activeFiles: b.activeFiles,
@@ -14140,12 +14225,14 @@ function emitTestCoverage(graph, root, outDir, options = {}) {
14140
14225
  const coverage = analyzeTestCoverage(sources, tests, root, options.policyPath ?? join16(root, "docs", "architecture", "coverage-policy.json"));
14141
14226
  const mdPath = join16(outDir, "TEST_COVERAGE.md");
14142
14227
  const jsonPath = join16(outDir, "test-coverage.json");
14228
+ const namespaceImportBasis = !importsNameSymbols(graph.language);
14143
14229
  const markdown = generateTestCoverageMarkdown(coverage, {
14144
14230
  expectedTestHint: graph.language === "typescript",
14145
- policyPath: options.policyNote ?? "docs/architecture/coverage-policy.json"
14231
+ policyPath: options.policyNote ?? "docs/architecture/coverage-policy.json",
14232
+ namespaceImportBasis
14146
14233
  });
14147
14234
  writeReport(mdPath, withBanner(markdown, { command: MAP_REGENERATE_COMMAND, ...options.banner }));
14148
- writeReport(jsonPath, JSON.stringify(generateTestCoverageJson(coverage), null, 2));
14235
+ writeReport(jsonPath, JSON.stringify(generateTestCoverageJson(coverage, { namespaceImportBasis }), null, 2));
14149
14236
  return [mdPath, jsonPath];
14150
14237
  }
14151
14238
 
@@ -17517,6 +17604,22 @@ class ConfigError extends Error {
17517
17604
  this.name = "ConfigError";
17518
17605
  }
17519
17606
  }
17607
+
17608
+ class UnreadableRepoLanguage extends Error {
17609
+ constructor(message) {
17610
+ super(message);
17611
+ this.name = "UnreadableRepoLanguage";
17612
+ }
17613
+ }
17614
+ function assertReadableLanguage(root) {
17615
+ const counts = countLanguages(root);
17616
+ const readable = (counts.typescript ?? 0) + (counts.python ?? 0);
17617
+ const unreadable = Object.entries(counts).filter(([language]) => language !== "typescript" && language !== "python").sort(([a, x], [b, y]) => y - x || (a < b ? -1 : a > b ? 1 : 0));
17618
+ const [language, n] = unreadable[0] ?? ["", 0];
17619
+ if (n > readable) {
17620
+ throw new UnreadableRepoLanguage(`this repo is ${language}: ${n} ${language} file(s) against ${readable} TypeScript, ` + "JavaScript or Python file(s). repo-tools docs reads TypeScript, JavaScript and Python " + "only, so a PASS here would describe the wrong files. It fails rather than print a gate " + "that measured nothing.");
17621
+ }
17622
+ }
17520
17623
  function languageOf(path) {
17521
17624
  const suffix = suffixOf(path.slice(path.lastIndexOf("/") + 1));
17522
17625
  if (PYTHON_SUFFIXES2.has(suffix))
@@ -18711,7 +18814,9 @@ var DOCS_HELP = `Usage: repo-tools docs scan [root] [--out=<dir>]
18711
18814
  Measure, stub and gate the documentation comments of the TypeScript, JavaScript
18712
18815
  and Python files of a repository. The files are the files that git tracks. A
18713
18816
  file in a skipped folder (node_modules, dist, build, bundle, out, vendor) or
18714
- with a generated name (.d.ts, .min.js, _pb2.py) is not read.
18817
+ with a generated name (.d.ts, .min.js, _pb2.py) is not read. A repository with
18818
+ more files of another language (for example Rust or Go) than TypeScript,
18819
+ JavaScript and Python together is refused: the run names the language and exits 1.
18715
18820
 
18716
18821
  Actions:
18717
18822
  scan Measure the coverage. Write COVERAGE.md and coverage.json.
@@ -19085,6 +19190,7 @@ async function run5(argv, io) {
19085
19190
  root = resolve8(options.root);
19086
19191
  if (!isDirectory(root))
19087
19192
  throw new Error("the root <root> is not an existing directory");
19193
+ assertReadableLanguage(root);
19088
19194
  if (options.action === "scan")
19089
19195
  return await runScan(root, options, io);
19090
19196
  if (options.action === "stub")
@@ -19094,7 +19200,7 @@ async function run5(argv, io) {
19094
19200
  const message = error instanceof Error ? error.message : String(error);
19095
19201
  stderr(`repo-tools docs: ${message}
19096
19202
  `);
19097
- return error instanceof NothingToMeasure ? 1 : 2;
19203
+ return error instanceof NothingToMeasure || error instanceof UnreadableRepoLanguage ? 1 : 2;
19098
19204
  }
19099
19205
  }
19100
19206
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danielsimonjr/repo-tools",
3
- "version": "2.2.4",
3
+ "version": "2.2.6",
4
4
  "description": "One CLI for repository analysis: dependency graphs creation and query with per-export API facts, file chunking, and context compression. TypeScript on Bun, compiled to a single executable.",
5
5
  "type": "module",
6
6
  "license": "MIT",