truecourse 0.5.6 → 0.5.7

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/cli.mjs CHANGED
@@ -10586,7 +10586,72 @@ var init_language_config = __esm({
10586
10586
 
10587
10587
  // packages/analyzer/dist/file-discovery.js
10588
10588
  import { existsSync as existsSync2, readFileSync, readdirSync as readdirSync2, statSync } from "fs";
10589
+ import { execFileSync } from "child_process";
10589
10590
  import { join, relative, resolve as resolve2 } from "path";
10591
+ function isInsideGitWorkTree(dir) {
10592
+ try {
10593
+ const out = execFileSync("git", ["-C", dir, "rev-parse", "--is-inside-work-tree"], {
10594
+ stdio: ["ignore", "pipe", "ignore"]
10595
+ });
10596
+ return out.toString("utf8").trim() === "true";
10597
+ } catch {
10598
+ return false;
10599
+ }
10600
+ }
10601
+ function buildPostGitFilter(dir) {
10602
+ const ig = (0, import_ignore.default)();
10603
+ const tcPath = join(dir, ".truecourseignore");
10604
+ if (existsSync2(tcPath))
10605
+ ig.add(readFileSync(tcPath, "utf8"));
10606
+ ig.add(".git");
10607
+ for (const p2 of getAllTestPatterns())
10608
+ ig.add(p2);
10609
+ for (const p2 of getAllIgnorePatterns())
10610
+ ig.add(p2);
10611
+ return ig;
10612
+ }
10613
+ function discoverFilesViaGit(dir) {
10614
+ if (!isInsideGitWorkTree(dir))
10615
+ return null;
10616
+ let stdout;
10617
+ try {
10618
+ stdout = execFileSync("git", ["-C", dir, "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", "."], { maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
10619
+ } catch {
10620
+ return null;
10621
+ }
10622
+ const relPaths = stdout.toString("utf8").split("\0").filter(Boolean);
10623
+ relPaths.sort(compareDepthFirst);
10624
+ const ig = buildPostGitFilter(dir);
10625
+ const out = [];
10626
+ for (const rel of relPaths) {
10627
+ if (ig.ignores(rel))
10628
+ continue;
10629
+ const abs = join(dir, rel);
10630
+ let isFile = false;
10631
+ try {
10632
+ isFile = statSync(abs).isFile();
10633
+ } catch {
10634
+ continue;
10635
+ }
10636
+ if (!isFile)
10637
+ continue;
10638
+ if (detectLanguage(abs))
10639
+ out.push(abs);
10640
+ }
10641
+ return out;
10642
+ }
10643
+ function compareDepthFirst(a, b) {
10644
+ const ap = a.split("/");
10645
+ const bp = b.split("/");
10646
+ const len = Math.min(ap.length, bp.length);
10647
+ for (let i = 0; i < len; i++) {
10648
+ const x = ap[i];
10649
+ const y = bp[i];
10650
+ if (x !== y)
10651
+ return x < y ? -1 : 1;
10652
+ }
10653
+ return ap.length - bp.length;
10654
+ }
10590
10655
  function findAllGitignores(startDir) {
10591
10656
  const gitignores = [];
10592
10657
  let currentDir = resolve2(startDir);
@@ -10596,25 +10661,46 @@ function findAllGitignores(startDir) {
10596
10661
  gitignores.unshift({ path: gitignorePath, dir: currentDir });
10597
10662
  }
10598
10663
  const parentDir = resolve2(currentDir, "..");
10599
- if (parentDir === currentDir) {
10664
+ if (parentDir === currentDir)
10600
10665
  break;
10601
- }
10602
10666
  currentDir = parentDir;
10603
10667
  }
10604
10668
  return gitignores;
10605
10669
  }
10670
+ function reanchorTruecourseignore(content, prefix) {
10671
+ if (prefix === "" || prefix === ".")
10672
+ return content;
10673
+ return content.split("\n").map((line) => {
10674
+ const raw = line;
10675
+ const trimmed2 = raw.trim();
10676
+ if (trimmed2 === "" || trimmed2.startsWith("#"))
10677
+ return raw;
10678
+ const negate = trimmed2.startsWith("!");
10679
+ const body = negate ? trimmed2.slice(1) : trimmed2;
10680
+ if (body.startsWith("**/"))
10681
+ return raw;
10682
+ const hasLeadingSlash = body.startsWith("/");
10683
+ const withoutTrailing = body.endsWith("/") ? body.slice(0, -1) : body;
10684
+ const inner = hasLeadingSlash ? withoutTrailing.slice(1) : withoutTrailing;
10685
+ const hasInternalSlash = inner.includes("/");
10686
+ if (!hasLeadingSlash && !hasInternalSlash)
10687
+ return raw;
10688
+ const stripped = hasLeadingSlash ? body.slice(1) : body;
10689
+ return `${negate ? "!" : ""}${prefix}/${stripped}`;
10690
+ }).join("\n");
10691
+ }
10606
10692
  function loadIgnorePatterns(baseDir) {
10607
10693
  const ig = (0, import_ignore.default)();
10608
10694
  const gitignores = findAllGitignores(baseDir);
10609
10695
  const rootDir = gitignores.length > 0 && gitignores[0] ? gitignores[0].dir : baseDir;
10610
10696
  for (const { path: gitignorePath } of gitignores) {
10611
- const content = readFileSync(gitignorePath, "utf8");
10612
- ig.add(content);
10697
+ ig.add(readFileSync(gitignorePath, "utf8"));
10613
10698
  }
10614
10699
  const truecourseignorePath = join(baseDir, ".truecourseignore");
10615
10700
  if (existsSync2(truecourseignorePath)) {
10616
10701
  const content = readFileSync(truecourseignorePath, "utf8");
10617
- ig.add(content);
10702
+ const prefix = relative(rootDir, baseDir).replace(/\\/g, "/");
10703
+ ig.add(reanchorTruecourseignore(content, prefix));
10618
10704
  }
10619
10705
  ig.add(".git");
10620
10706
  for (const pattern of getAllTestPatterns())
@@ -10623,7 +10709,7 @@ function loadIgnorePatterns(baseDir) {
10623
10709
  ig.add(pattern);
10624
10710
  return { ig, rootDir };
10625
10711
  }
10626
- function discoverFiles(dir) {
10712
+ function discoverFilesViaWalker(dir) {
10627
10713
  const files = [];
10628
10714
  const { ig, rootDir } = loadIgnorePatterns(dir);
10629
10715
  function traverse(currentPath) {
@@ -10634,23 +10720,27 @@ function discoverFiles(dir) {
10634
10720
  const relativePath = relative(rootDir, fullPath);
10635
10721
  const stat = statSync(fullPath);
10636
10722
  const pathToCheck = stat.isDirectory() ? relativePath + "/" : relativePath;
10637
- if (ig.ignores(pathToCheck)) {
10723
+ if (ig.ignores(pathToCheck))
10638
10724
  continue;
10639
- }
10640
10725
  if (stat.isDirectory()) {
10641
10726
  traverse(fullPath);
10642
10727
  } else if (stat.isFile()) {
10643
- if (detectLanguage(fullPath)) {
10728
+ if (detectLanguage(fullPath))
10644
10729
  files.push(fullPath);
10645
- }
10646
10730
  }
10647
10731
  }
10648
- } catch (error) {
10732
+ } catch {
10649
10733
  }
10650
10734
  }
10651
10735
  traverse(dir);
10652
10736
  return files;
10653
10737
  }
10738
+ function discoverFiles(dir) {
10739
+ const viaGit = discoverFilesViaGit(dir);
10740
+ if (viaGit !== null)
10741
+ return viaGit;
10742
+ return discoverFilesViaWalker(dir);
10743
+ }
10654
10744
  var import_ignore;
10655
10745
  var init_file_discovery = __esm({
10656
10746
  "packages/analyzer/dist/file-discovery.js"() {
@@ -93831,7 +93921,6 @@ var init_dist6 = __esm({
93831
93921
  });
93832
93922
 
93833
93923
  // packages/core/dist/services/analyzer.service.js
93834
- import path11 from "node:path";
93835
93924
  function runDeterministicModuleChecks(result, enabledDeterministic) {
93836
93925
  if (!result.modules || !result.methods)
93837
93926
  return [];
@@ -93864,150 +93953,122 @@ function runDeterministicServiceChecks(result, enabledDeterministic) {
93864
93953
  }
93865
93954
  async function runAnalysis(repoPath, _branch, onProgress, options) {
93866
93955
  let currentBranch = "unknown";
93867
- let didStash = false;
93868
- let isSubdirectory = false;
93869
- let hasChanges = false;
93870
- let git;
93871
93956
  if (!options?.skipGit) {
93872
- git = await getGit(repoPath);
93957
+ const git = await getGit(repoPath);
93873
93958
  currentBranch = (await git.branch()).current;
93874
- const statusResult = await git.status();
93875
- hasChanges = !statusResult.isClean();
93876
- const gitRoot = (await git.revparse(["--show-toplevel"])).trim();
93877
- isSubdirectory = path11.resolve(repoPath) !== path11.resolve(gitRoot);
93878
93959
  }
93879
- if (hasChanges && !options?.skipStash && !isSubdirectory && git) {
93880
- onProgress({ step: "stash", percent: 2, detail: "Stashing pending changes to analyze committed state..." });
93960
+ onProgress({ step: "discover", percent: 10, detail: "Discovering files..." });
93961
+ const analyzer = await Promise.resolve().then(() => (init_dist6(), dist_exports2));
93962
+ await analyzer.initParsers();
93963
+ const files = await analyzer.discoverFiles(repoPath);
93964
+ onProgress({
93965
+ step: "discover",
93966
+ percent: 15,
93967
+ detail: `Found ${files.length} files`
93968
+ });
93969
+ onProgress({ step: "analyze", percent: 20, detail: "Analyzing files..." });
93970
+ const fileAnalyses = [];
93971
+ const totalFiles = files.length;
93972
+ for (let i = 0; i < totalFiles; i++) {
93973
+ if (options?.signal?.aborted)
93974
+ throw new DOMException("Analysis cancelled", "AbortError");
93975
+ const file = files[i];
93881
93976
  try {
93882
- const stashResult = await git.stash(["push", "--include-untracked", "-m", "truecourse-analysis-stash"]);
93883
- didStash = !stashResult.includes("No local changes");
93977
+ const analysis = await analyzer.analyzeFile(file);
93978
+ if (analysis) {
93979
+ fileAnalyses.push(analysis);
93980
+ }
93884
93981
  } catch (error) {
93885
- log.warn(`[Analyzer] Failed to stash changes, analyzing current state: ${error instanceof Error ? error.message : String(error)}`);
93982
+ log.warn(`[Analyzer] Failed to analyze ${file}: ${error instanceof Error ? error.message : String(error)}`);
93983
+ }
93984
+ if (i % 10 === 0 || i === totalFiles - 1) {
93985
+ const analyzePercent = 20 + Math.round((i + 1) / totalFiles * 40);
93986
+ onProgress({
93987
+ step: "analyze",
93988
+ percent: analyzePercent,
93989
+ detail: `Analyzed ${i + 1}/${totalFiles} files`
93990
+ });
93886
93991
  }
93887
93992
  }
93888
- try {
93889
- onProgress({ step: "discover", percent: 10, detail: "Discovering files..." });
93890
- const analyzer = await Promise.resolve().then(() => (init_dist6(), dist_exports2));
93891
- await analyzer.initParsers();
93892
- const files = await analyzer.discoverFiles(repoPath);
93893
- onProgress({
93894
- step: "discover",
93895
- percent: 15,
93896
- detail: `Found ${files.length} files`
93897
- });
93898
- onProgress({ step: "analyze", percent: 20, detail: "Analyzing files..." });
93899
- const fileAnalyses = [];
93900
- const totalFiles = files.length;
93901
- for (let i = 0; i < totalFiles; i++) {
93902
- if (options?.signal?.aborted)
93903
- throw new DOMException("Analysis cancelled", "AbortError");
93904
- const file = files[i];
93905
- try {
93906
- const analysis = await analyzer.analyzeFile(file);
93907
- if (analysis) {
93908
- fileAnalyses.push(analysis);
93909
- }
93910
- } catch (error) {
93911
- log.warn(`[Analyzer] Failed to analyze ${file}: ${error instanceof Error ? error.message : String(error)}`);
93912
- }
93913
- if (i % 10 === 0 || i === totalFiles - 1) {
93914
- const analyzePercent = 20 + Math.round((i + 1) / totalFiles * 40);
93915
- onProgress({
93916
- step: "analyze",
93917
- percent: analyzePercent,
93918
- detail: `Analyzed ${i + 1}/${totalFiles} files`
93919
- });
93993
+ onProgress({
93994
+ step: "dependencies",
93995
+ percent: 65,
93996
+ detail: "Building dependency graph..."
93997
+ });
93998
+ const scopedOptions = buildScopedCompilerOptions(repoPath);
93999
+ if (scopedOptions.length > 0) {
94000
+ const filePaths = fileAnalyses.map((fa) => fa.filePath);
94001
+ const { exportMap } = analyzeSemantics(filePaths, scopedOptions);
94002
+ for (const fa of fileAnalyses) {
94003
+ const fileExports = exportMap.get(fa.filePath);
94004
+ if (!fileExports)
94005
+ continue;
94006
+ const defaultExportedFn = fa.exports.find((e) => e.isDefault)?.name;
94007
+ for (const fn of fa.functions) {
94008
+ fn.isExported = fileExports.has(fn.name) || fileExports.has("default") && fn.name === defaultExportedFn;
93920
94009
  }
93921
94010
  }
93922
- onProgress({
93923
- step: "dependencies",
93924
- percent: 65,
93925
- detail: "Building dependency graph..."
93926
- });
93927
- const scopedOptions = buildScopedCompilerOptions(repoPath);
93928
- if (scopedOptions.length > 0) {
93929
- const filePaths = fileAnalyses.map((fa) => fa.filePath);
93930
- const { exportMap } = analyzeSemantics(filePaths, scopedOptions);
93931
- for (const fa of fileAnalyses) {
94011
+ }
94012
+ const filesByLanguage = /* @__PURE__ */ new Map();
94013
+ for (const fa of fileAnalyses) {
94014
+ const list = filesByLanguage.get(fa.language) || [];
94015
+ list.push(fa);
94016
+ filesByLanguage.set(fa.language, list);
94017
+ }
94018
+ for (const [language, files2] of filesByLanguage) {
94019
+ const serverConfig = getLspServerConfig(language);
94020
+ if (!serverConfig)
94021
+ continue;
94022
+ try {
94023
+ const lspClient = new LspClient(serverConfig);
94024
+ await lspClient.start(repoPath);
94025
+ const filePaths = files2.map((fa) => fa.filePath);
94026
+ const { exportMap } = await lspClient.analyzeSemantics(filePaths.map((fp) => fp.startsWith(repoPath) ? fp.slice(repoPath.length + 1) : fp));
94027
+ for (const fa of files2) {
93932
94028
  const fileExports = exportMap.get(fa.filePath);
93933
94029
  if (!fileExports)
93934
94030
  continue;
93935
- const defaultExportedFn = fa.exports.find((e) => e.isDefault)?.name;
93936
94031
  for (const fn of fa.functions) {
93937
- fn.isExported = fileExports.has(fn.name) || fileExports.has("default") && fn.name === defaultExportedFn;
93938
- }
93939
- }
93940
- }
93941
- const filesByLanguage = /* @__PURE__ */ new Map();
93942
- for (const fa of fileAnalyses) {
93943
- const list = filesByLanguage.get(fa.language) || [];
93944
- list.push(fa);
93945
- filesByLanguage.set(fa.language, list);
93946
- }
93947
- for (const [language, files2] of filesByLanguage) {
93948
- const serverConfig = getLspServerConfig(language);
93949
- if (!serverConfig)
93950
- continue;
93951
- try {
93952
- const lspClient = new LspClient(serverConfig);
93953
- await lspClient.start(repoPath);
93954
- const filePaths = files2.map((fa) => fa.filePath);
93955
- const { exportMap } = await lspClient.analyzeSemantics(filePaths.map((fp) => fp.startsWith(repoPath) ? fp.slice(repoPath.length + 1) : fp));
93956
- for (const fa of files2) {
93957
- const fileExports = exportMap.get(fa.filePath);
93958
- if (!fileExports)
93959
- continue;
93960
- for (const fn of fa.functions) {
93961
- fn.isExported = fileExports.has(fn.name);
93962
- }
94032
+ fn.isExported = fileExports.has(fn.name);
93963
94033
  }
93964
- await lspClient.stop();
93965
- } catch (error) {
93966
- log.warn(`[Analyzer] ${serverConfig.name} LSP analysis failed, using tree-sitter heuristics: ${error instanceof Error ? error.message : String(error)}`);
93967
- }
93968
- }
93969
- const moduleDependencies = analyzer.buildDependencyGraph(fileAnalyses, repoPath);
93970
- onProgress({
93971
- step: "services",
93972
- percent: 75,
93973
- detail: "Detecting services..."
93974
- });
93975
- const splitResult = analyzer.performSplitAnalysis(repoPath, fileAnalyses, moduleDependencies);
93976
- onProgress({
93977
- step: "saving",
93978
- percent: 80,
93979
- detail: `Saving results: ${splitResult.services.length} services detected`
93980
- });
93981
- return {
93982
- architecture: splitResult.architecture,
93983
- services: splitResult.services,
93984
- dependencies: splitResult.dependencies,
93985
- layerDetails: splitResult.layerDetails,
93986
- databaseResult: splitResult.databaseResult,
93987
- modules: splitResult.modules,
93988
- methods: splitResult.methods,
93989
- moduleLevelDependencies: splitResult.moduleLevelDependencies,
93990
- methodLevelDependencies: splitResult.methodLevelDependencies,
93991
- fileAnalyses,
93992
- moduleDependencies,
93993
- entryPointFiles: new Set(findEntryPoints(fileAnalyses, moduleDependencies)),
93994
- metadata: {
93995
- totalFiles: files.length,
93996
- analyzedFiles: fileAnalyses.length,
93997
- branch: currentBranch || "HEAD",
93998
- analyzedAt: (/* @__PURE__ */ new Date()).toISOString()
93999
- }
94000
- };
94001
- } finally {
94002
- if (didStash && git) {
94003
- onProgress({ step: "unstash", percent: 80, detail: "Restoring pending changes..." });
94004
- try {
94005
- await git.stash(["pop"]);
94006
- } catch (error) {
94007
- log.error(`[Analyzer] Failed to restore stashed changes. Run "git stash pop" manually. ${error instanceof Error ? error.message : String(error)}`);
94008
94034
  }
94035
+ await lspClient.stop();
94036
+ } catch (error) {
94037
+ log.warn(`[Analyzer] ${serverConfig.name} LSP analysis failed, using tree-sitter heuristics: ${error instanceof Error ? error.message : String(error)}`);
94009
94038
  }
94010
94039
  }
94040
+ const moduleDependencies = analyzer.buildDependencyGraph(fileAnalyses, repoPath);
94041
+ onProgress({
94042
+ step: "services",
94043
+ percent: 75,
94044
+ detail: "Detecting services..."
94045
+ });
94046
+ const splitResult = analyzer.performSplitAnalysis(repoPath, fileAnalyses, moduleDependencies);
94047
+ onProgress({
94048
+ step: "saving",
94049
+ percent: 80,
94050
+ detail: `Saving results: ${splitResult.services.length} services detected`
94051
+ });
94052
+ return {
94053
+ architecture: splitResult.architecture,
94054
+ services: splitResult.services,
94055
+ dependencies: splitResult.dependencies,
94056
+ layerDetails: splitResult.layerDetails,
94057
+ databaseResult: splitResult.databaseResult,
94058
+ modules: splitResult.modules,
94059
+ methods: splitResult.methods,
94060
+ moduleLevelDependencies: splitResult.moduleLevelDependencies,
94061
+ methodLevelDependencies: splitResult.methodLevelDependencies,
94062
+ fileAnalyses,
94063
+ moduleDependencies,
94064
+ entryPointFiles: new Set(findEntryPoints(fileAnalyses, moduleDependencies)),
94065
+ metadata: {
94066
+ totalFiles: files.length,
94067
+ analyzedFiles: fileAnalyses.length,
94068
+ branch: currentBranch || "HEAD",
94069
+ analyzedAt: (/* @__PURE__ */ new Date()).toISOString()
94070
+ }
94071
+ };
94011
94072
  }
94012
94073
  var init_analyzer_service = __esm({
94013
94074
  "packages/core/dist/services/analyzer.service.js"() {
@@ -101346,14 +101407,14 @@ var require_main = __commonJS({
101346
101407
  });
101347
101408
 
101348
101409
  // packages/core/dist/config/env.js
101349
- import path12 from "node:path";
101410
+ import path11 from "node:path";
101350
101411
  import os3 from "node:os";
101351
101412
  var import_dotenv;
101352
101413
  var init_env = __esm({
101353
101414
  "packages/core/dist/config/env.js"() {
101354
101415
  "use strict";
101355
101416
  import_dotenv = __toESM(require_main(), 1);
101356
- import_dotenv.default.config({ path: path12.join(os3.homedir(), ".truecourse", ".env") });
101417
+ import_dotenv.default.config({ path: path11.join(os3.homedir(), ".truecourse", ".env") });
101357
101418
  import_dotenv.default.config({ path: "../../.env" });
101358
101419
  }
101359
101420
  });
@@ -103546,7 +103607,7 @@ var init_violation_lifecycle_service = __esm({
103546
103607
  // packages/core/dist/services/violation-pipeline.service.js
103547
103608
  import { randomUUID as randomUUID5 } from "node:crypto";
103548
103609
  import fs9 from "node:fs";
103549
- import path13 from "node:path";
103610
+ import path12 from "node:path";
103550
103611
  function throwIfAborted(signal) {
103551
103612
  if (signal?.aborted)
103552
103613
  throw new DOMException("Analysis cancelled", "AbortError");
@@ -103649,7 +103710,7 @@ async function runViolationPipeline(input) {
103649
103710
  const enabledLlmCodeRules = enableLlmRules !== false ? allRules.filter((r) => (r.domain ? codeDomains.has(r.domain) : r.category === "code") && r.type === "llm" && r.prompt) : [];
103650
103711
  const archLlmRules = allRules.filter((r) => r.type === "llm" && r.prompt && r.domain === "architecture").map((r) => ({ key: r.key, name: r.name, severity: r.severity, prompt: r.prompt, category: r.category }));
103651
103712
  const dbSchemaLlmRules = allRules.filter((r) => r.type === "llm" && r.prompt && r.domain === "database" && r.category === "database").map((r) => ({ key: r.key, name: r.name, severity: r.severity, prompt: r.prompt, category: r.category }));
103652
- const filesToScan = changedFileSet ? [...changedFileSet].map((relPath) => ({ filePath: relPath, resolve: true })) : (result.fileAnalyses || []).map((fa) => ({ filePath: fa.filePath, resolve: !path13.isAbsolute(fa.filePath) }));
103713
+ const filesToScan = changedFileSet ? [...changedFileSet].map((relPath) => ({ filePath: relPath, resolve: true })) : (result.fileAnalyses || []).map((fa) => ({ filePath: fa.filePath, resolve: !path12.isAbsolute(fa.filePath) }));
103653
103714
  const hasLlm = enabledLlm.length > 0;
103654
103715
  if (hasLlm)
103655
103716
  tracker?.start("scan", "Reading files...");
@@ -103661,7 +103722,7 @@ async function runViolationPipeline(input) {
103661
103722
  const lang = detectLanguage(filePath);
103662
103723
  if (!lang)
103663
103724
  continue;
103664
- const absPath = resolve8 ? path13.resolve(repoPath, filePath) : path13.isAbsolute(filePath) ? filePath : path13.join(repoPath, filePath);
103725
+ const absPath = resolve8 ? path12.resolve(repoPath, filePath) : path12.isAbsolute(filePath) ? filePath : path12.join(repoPath, filePath);
103665
103726
  if (!fs9.existsSync(absPath))
103666
103727
  continue;
103667
103728
  const content = fs9.readFileSync(absPath, "utf-8");
@@ -103677,7 +103738,7 @@ async function runViolationPipeline(input) {
103677
103738
  let typeQuery;
103678
103739
  const enabledCodeKeys = new Set(enabledCodeRules.filter((r) => r.type === "deterministic" && r.enabled).map((r) => r.key));
103679
103740
  if (hasTypeAwareVisitors(enabledCodeKeys)) {
103680
- const tsFiles = filesToScan.filter(({ filePath: fp }) => /\.(ts|tsx|js|jsx)$/.test(fp)).map(({ filePath: fp, resolve: res }) => res ? path13.resolve(repoPath, fp) : path13.isAbsolute(fp) ? fp : path13.join(repoPath, fp));
103741
+ const tsFiles = filesToScan.filter(({ filePath: fp }) => /\.(ts|tsx|js|jsx)$/.test(fp)).map(({ filePath: fp, resolve: res }) => res ? path12.resolve(repoPath, fp) : path12.isAbsolute(fp) ? fp : path12.join(repoPath, fp));
103681
103742
  if (tsFiles.length > 0) {
103682
103743
  const scoped = buildScopedCompilerOptions(repoPath);
103683
103744
  typeQuery = createTypeQueryService(tsFiles, scoped);
@@ -103756,7 +103817,7 @@ async function runViolationPipeline(input) {
103756
103817
  const lang = detectLanguage(filePath);
103757
103818
  if (!lang)
103758
103819
  continue;
103759
- const absPath = resolve8 ? path13.resolve(repoPath, filePath) : path13.isAbsolute(filePath) ? filePath : path13.join(repoPath, filePath);
103820
+ const absPath = resolve8 ? path12.resolve(repoPath, filePath) : path12.isAbsolute(filePath) ? filePath : path12.join(repoPath, filePath);
103760
103821
  const key = changedFileSet ? absPath : filePath;
103761
103822
  const fc = fileContents.get(key);
103762
103823
  if (!fc)
@@ -103770,7 +103831,7 @@ async function runViolationPipeline(input) {
103770
103831
  }
103771
103832
  log.info(`[Pipeline] Code scan: ${allCodeViolations.length} violations from ${filesToScan.length} files (${enabledCodeRules.length} det rules, ${enabledLlmCodeRules.length} LLM rules)`);
103772
103833
  if (enabledCodeRules.some((r) => r.key === "bugs/deterministic/invalid-pyproject-toml")) {
103773
- const pyprojectPath = path13.join(repoPath, "pyproject.toml");
103834
+ const pyprojectPath = path12.join(repoPath, "pyproject.toml");
103774
103835
  if (fs9.existsSync(pyprojectPath)) {
103775
103836
  try {
103776
103837
  const { checkPyprojectToml: checkPyprojectToml2 } = await Promise.resolve().then(() => (init_dist6(), dist_exports2));
@@ -104593,7 +104654,7 @@ function processLlmCodeViolations(codeResult, validFilePaths, fileContents, allC
104593
104654
  for (const v of codeResult.violations) {
104594
104655
  let filePath = v.filePath;
104595
104656
  if (!validFilePaths.has(filePath)) {
104596
- const resolved = path13.resolve(repoPath, filePath);
104657
+ const resolved = path12.resolve(repoPath, filePath);
104597
104658
  if (validFilePaths.has(resolved)) {
104598
104659
  filePath = resolved;
104599
104660
  } else {
@@ -104663,6 +104724,7 @@ var init_usage_service = __esm({
104663
104724
 
104664
104725
  // packages/core/dist/commands/analyze-core.js
104665
104726
  import { randomUUID as randomUUID6 } from "node:crypto";
104727
+ import path13 from "node:path";
104666
104728
  async function analyzeCore(project, options) {
104667
104729
  try {
104668
104730
  acquireAnalyzeLock(project.path);
@@ -104704,116 +104766,153 @@ async function analyzeCore(project, options) {
104704
104766
  const start = Date.now();
104705
104767
  const effectiveCategories = options.enabledCategoriesOverride?.length ? options.enabledCategoriesOverride : projectConfig.enabledCategories ?? void 0;
104706
104768
  const effectiveLlmRules = projectConfig.enableLlmRules ?? options.enableLlmRulesOverride ?? true;
104707
- options.tracker?.start("parse", isDiff ? "Analyzing working tree..." : "Starting analysis...");
104708
- const result = await runAnalysis(project.path, branch ?? void 0, (progress) => {
104709
- options.tracker?.detail("parse", progress.detail ?? "Analyzing...");
104710
- options.onProgress?.({ detail: progress.detail });
104711
- }, { signal, skipStash: isDiff });
104712
- if (signal?.aborted) {
104713
- throw new DOMException(isDiff ? "Diff cancelled" : "Analysis cancelled", "AbortError");
104714
- }
104715
- const { graph, serviceIdMap, moduleIdMap, methodIdMap, dbIdMap } = buildGraph(result);
104716
- let changedFiles = [];
104717
- let changedFileSet;
104718
- if (isDiff) {
104769
+ let didStash = false;
104770
+ let stashGit;
104771
+ if (!isDiff && !skipGit && !options.skipStash) {
104719
104772
  try {
104720
- const git = await getGit(project.path);
104721
- const statusResult = await git.status();
104722
- for (const f of statusResult.not_added)
104723
- changedFiles.push({ path: f, status: "new" });
104724
- for (const f of statusResult.created)
104725
- changedFiles.push({ path: f, status: "new" });
104726
- for (const f of statusResult.modified)
104727
- changedFiles.push({ path: f, status: "modified" });
104728
- for (const f of statusResult.staged) {
104729
- if (!changedFiles.some((cf) => cf.path === f)) {
104773
+ stashGit = await getGit(project.path);
104774
+ const status = await stashGit.status();
104775
+ if (!status.isClean()) {
104776
+ const gitRoot = (await stashGit.revparse(["--show-toplevel"])).trim();
104777
+ const isSubdirectory = path13.resolve(project.path) !== path13.resolve(gitRoot);
104778
+ if (!isSubdirectory) {
104779
+ options.tracker?.detail("parse", "Stashing pending changes...");
104780
+ options.onProgress?.({ detail: "Stashing pending changes to analyze committed state..." });
104781
+ const stashResult = await stashGit.stash([
104782
+ "push",
104783
+ "--include-untracked",
104784
+ "-m",
104785
+ "truecourse-analysis-stash"
104786
+ ]);
104787
+ didStash = !stashResult.includes("No local changes");
104788
+ }
104789
+ }
104790
+ } catch (error) {
104791
+ log.warn(`[Analyzer] Failed to stash changes, analyzing current state: ${error instanceof Error ? error.message : String(error)}`);
104792
+ }
104793
+ }
104794
+ try {
104795
+ options.tracker?.start("parse", isDiff ? "Analyzing working tree..." : "Starting analysis...");
104796
+ const result = await runAnalysis(project.path, branch ?? void 0, (progress) => {
104797
+ options.tracker?.detail("parse", progress.detail ?? "Analyzing...");
104798
+ options.onProgress?.({ detail: progress.detail });
104799
+ }, { signal });
104800
+ if (signal?.aborted) {
104801
+ throw new DOMException(isDiff ? "Diff cancelled" : "Analysis cancelled", "AbortError");
104802
+ }
104803
+ const { graph, serviceIdMap, moduleIdMap, methodIdMap, dbIdMap } = buildGraph(result);
104804
+ let changedFiles = [];
104805
+ let changedFileSet;
104806
+ if (isDiff) {
104807
+ try {
104808
+ const git = await getGit(project.path);
104809
+ const statusResult = await git.status();
104810
+ for (const f of statusResult.not_added)
104811
+ changedFiles.push({ path: f, status: "new" });
104812
+ for (const f of statusResult.created)
104813
+ changedFiles.push({ path: f, status: "new" });
104814
+ for (const f of statusResult.modified)
104730
104815
  changedFiles.push({ path: f, status: "modified" });
104816
+ for (const f of statusResult.staged) {
104817
+ if (!changedFiles.some((cf) => cf.path === f)) {
104818
+ changedFiles.push({ path: f, status: "modified" });
104819
+ }
104731
104820
  }
104821
+ for (const f of statusResult.deleted)
104822
+ changedFiles.push({ path: f, status: "deleted" });
104823
+ } catch (err) {
104824
+ log.warn(`[Diff] git status failed: ${err instanceof Error ? err.message : String(err)}`);
104825
+ }
104826
+ } else if (latestBaseline?.analysis.commitHash && !skipGit) {
104827
+ try {
104828
+ const git = await getGit(project.path);
104829
+ const diffOutput = await git.diff([latestBaseline.analysis.commitHash, "HEAD", "--name-only"]);
104830
+ const files = diffOutput.trim().split("\n").filter(Boolean);
104831
+ if (files.length > 0)
104832
+ changedFileSet = new Set(files);
104833
+ } catch {
104732
104834
  }
104733
- for (const f of statusResult.deleted)
104734
- changedFiles.push({ path: f, status: "deleted" });
104735
- } catch (err) {
104736
- log.warn(`[Diff] git status failed: ${err instanceof Error ? err.message : String(err)}`);
104737
104835
  }
104738
- } else if (latestBaseline?.analysis.commitHash && !skipGit) {
104739
- try {
104740
- const git = await getGit(project.path);
104741
- const diffOutput = await git.diff([latestBaseline.analysis.commitHash, "HEAD", "--name-only"]);
104742
- const files = diffOutput.trim().split("\n").filter(Boolean);
104743
- if (files.length > 0)
104744
- changedFileSet = new Set(files);
104745
- } catch {
104836
+ if (!isDiff) {
104837
+ try {
104838
+ graph.flows = detectFlows(result);
104839
+ } catch (flowError) {
104840
+ log.error(`[Flows] Detection failed: ${flowError instanceof Error ? flowError.message : String(flowError)}`);
104841
+ graph.flows = [];
104842
+ }
104843
+ touchProject(project.slug);
104844
+ }
104845
+ options.tracker?.done("parse", `${result.services.length} services, ${result.fileAnalyses?.length ?? 0} files`);
104846
+ const previousActiveViolations = latestBaseline ? latestBaseline.violations.filter((v) => branch == null || latestBaseline.analysis.branch == null || latestBaseline.analysis.branch === branch) : [];
104847
+ const previousAnalysisId = latestBaseline?.analysis.id ?? null;
104848
+ const provider = options.provider ?? (effectiveLlmRules ? createLLMProvider() : void 0);
104849
+ if (provider) {
104850
+ provider.setAnalysisId(analysisId);
104851
+ provider.setRepoPath(project.path);
104852
+ if (signal)
104853
+ provider.setAbortSignal(signal);
104854
+ }
104855
+ const pipelineResult = await runViolationPipeline({
104856
+ repoPath: project.path,
104857
+ analysisId,
104858
+ now,
104859
+ result,
104860
+ serviceIdMap,
104861
+ moduleIdMap,
104862
+ methodIdMap,
104863
+ dbIdMap,
104864
+ previousActiveViolations,
104865
+ changedFileSet,
104866
+ tracker: options.tracker,
104867
+ enabledCategories: effectiveCategories,
104868
+ enableLlmRules: effectiveLlmRules,
104869
+ provider,
104870
+ signal,
104871
+ onLlmEstimate: options.onLlmEstimate ? async (estimate) => {
104872
+ const proceed = await options.onLlmEstimate(estimate);
104873
+ options.onLlmResolved?.(proceed);
104874
+ return proceed;
104875
+ } : void 0
104876
+ });
104877
+ if (pipelineResult.serviceDescriptions.length > 0) {
104878
+ for (const desc of pipelineResult.serviceDescriptions) {
104879
+ const svc = graph.services.find((s) => s.id === desc.id);
104880
+ if (svc)
104881
+ svc.description = desc.description;
104882
+ }
104883
+ }
104884
+ const usage = provider ? toUsageRecords(provider.flushUsage()) : [];
104885
+ enforceLocationInvariant(pipelineResult.added);
104886
+ enforceLocationInvariant(pipelineResult.unchanged);
104887
+ enforceLocationInvariant(pipelineResult.resolved);
104888
+ log.info(`[${isDiff ? "Diff" : "Analysis"}] core complete in ${Date.now() - start}ms \u2014 ${pipelineResult.added.length} added, ${pipelineResult.unchanged.length} unchanged, ${pipelineResult.resolvedRefs.length} resolved`);
104889
+ return {
104890
+ mode,
104891
+ analysisId,
104892
+ now,
104893
+ branch,
104894
+ commitHash,
104895
+ architecture: result.architecture,
104896
+ metadata: result.metadata ?? null,
104897
+ graph,
104898
+ changedFiles,
104899
+ pipelineResult,
104900
+ usage,
104901
+ latestBaseline,
104902
+ previousAnalysisId,
104903
+ analysisResult: result
104904
+ };
104905
+ } finally {
104906
+ if (didStash && stashGit) {
104907
+ options.tracker?.detail("parse", "Restoring pending changes...");
104908
+ options.onProgress?.({ detail: "Restoring pending changes..." });
104909
+ try {
104910
+ await stashGit.stash(["pop"]);
104911
+ } catch (error) {
104912
+ log.error(`[Analyzer] Failed to restore stashed changes. Run "git stash pop" manually. ${error instanceof Error ? error.message : String(error)}`);
104913
+ }
104746
104914
  }
104747
104915
  }
104748
- if (!isDiff) {
104749
- try {
104750
- graph.flows = detectFlows(result);
104751
- } catch (flowError) {
104752
- log.error(`[Flows] Detection failed: ${flowError instanceof Error ? flowError.message : String(flowError)}`);
104753
- graph.flows = [];
104754
- }
104755
- touchProject(project.slug);
104756
- }
104757
- options.tracker?.done("parse", `${result.services.length} services, ${result.fileAnalyses?.length ?? 0} files`);
104758
- const previousActiveViolations = latestBaseline ? latestBaseline.violations.filter((v) => branch == null || latestBaseline.analysis.branch == null || latestBaseline.analysis.branch === branch) : [];
104759
- const previousAnalysisId = latestBaseline?.analysis.id ?? null;
104760
- const provider = options.provider ?? (effectiveLlmRules ? createLLMProvider() : void 0);
104761
- if (provider) {
104762
- provider.setAnalysisId(analysisId);
104763
- provider.setRepoPath(project.path);
104764
- if (signal)
104765
- provider.setAbortSignal(signal);
104766
- }
104767
- const pipelineResult = await runViolationPipeline({
104768
- repoPath: project.path,
104769
- analysisId,
104770
- now,
104771
- result,
104772
- serviceIdMap,
104773
- moduleIdMap,
104774
- methodIdMap,
104775
- dbIdMap,
104776
- previousActiveViolations,
104777
- changedFileSet,
104778
- tracker: options.tracker,
104779
- enabledCategories: effectiveCategories,
104780
- enableLlmRules: effectiveLlmRules,
104781
- provider,
104782
- signal,
104783
- onLlmEstimate: options.onLlmEstimate ? async (estimate) => {
104784
- const proceed = await options.onLlmEstimate(estimate);
104785
- options.onLlmResolved?.(proceed);
104786
- return proceed;
104787
- } : void 0
104788
- });
104789
- if (pipelineResult.serviceDescriptions.length > 0) {
104790
- for (const desc of pipelineResult.serviceDescriptions) {
104791
- const svc = graph.services.find((s) => s.id === desc.id);
104792
- if (svc)
104793
- svc.description = desc.description;
104794
- }
104795
- }
104796
- const usage = provider ? toUsageRecords(provider.flushUsage()) : [];
104797
- enforceLocationInvariant(pipelineResult.added);
104798
- enforceLocationInvariant(pipelineResult.unchanged);
104799
- enforceLocationInvariant(pipelineResult.resolved);
104800
- log.info(`[${isDiff ? "Diff" : "Analysis"}] core complete in ${Date.now() - start}ms \u2014 ${pipelineResult.added.length} added, ${pipelineResult.unchanged.length} unchanged, ${pipelineResult.resolvedRefs.length} resolved`);
104801
- return {
104802
- mode,
104803
- analysisId,
104804
- now,
104805
- branch,
104806
- commitHash,
104807
- architecture: result.architecture,
104808
- metadata: result.metadata ?? null,
104809
- graph,
104810
- changedFiles,
104811
- pipelineResult,
104812
- usage,
104813
- latestBaseline,
104814
- previousAnalysisId,
104815
- analysisResult: result
104816
- };
104817
104916
  } finally {
104818
104917
  releaseAnalyzeLock(project.path);
104819
104918
  }
@@ -105134,6 +105233,7 @@ init_progress();
105134
105233
  init_paths();
105135
105234
  init_registry();
105136
105235
  init_project_config();
105236
+ init_git();
105137
105237
  init_logger();
105138
105238
  init_helpers();
105139
105239
 
@@ -105311,6 +105411,50 @@ function resolveLlmDecision(options, configDefault) {
105311
105411
  }
105312
105412
  return { enabled: configDefault, autoApproveEstimate: false };
105313
105413
  }
105414
+ async function resolveStashDecision(options, repoPath) {
105415
+ if (options.stash === true) return { skipStash: false };
105416
+ if (options.stash === false) return { skipStash: true };
105417
+ let modifiedCount = 0;
105418
+ let untrackedCount = 0;
105419
+ try {
105420
+ const git = await getGit(repoPath);
105421
+ const status = await git.status();
105422
+ if (status.isClean()) return { skipStash: false };
105423
+ modifiedCount = status.modified.length + status.staged.length + status.deleted.length + status.created.length;
105424
+ untrackedCount = status.not_added.length;
105425
+ } catch {
105426
+ return { skipStash: false };
105427
+ }
105428
+ if (!isInteractive()) {
105429
+ exitMissingNonInteractiveFlag(
105430
+ "analyze needs a decision on stashing before running non-interactively.",
105431
+ "Pass --stash to stash pending changes (analyze committed state) or --no-stash to analyze the working tree as-is."
105432
+ );
105433
+ }
105434
+ O2.warn(
105435
+ `Your repository has ${modifiedCount} modified and ${untrackedCount} untracked file(s).`
105436
+ );
105437
+ const choice = await _t({
105438
+ message: "How should TrueCourse handle them?",
105439
+ options: [
105440
+ {
105441
+ value: "stash",
105442
+ label: "Stash and analyze committed state (recommended)",
105443
+ hint: "changes are temporarily stashed and restored after the run"
105444
+ },
105445
+ {
105446
+ value: "no-stash",
105447
+ label: "Don't stash \u2014 analyze the working tree as-is",
105448
+ hint: "uncommitted changes are included in the analysis"
105449
+ }
105450
+ ]
105451
+ });
105452
+ if (q(choice)) {
105453
+ pt("Cancelled \u2014 no changes made");
105454
+ process.exit(0);
105455
+ }
105456
+ return { skipStash: choice === "no-stash" };
105457
+ }
105314
105458
  async function runAnalyze(options = {}) {
105315
105459
  mt("Analyzing repository");
105316
105460
  ensureClaudeCli();
@@ -105325,6 +105469,7 @@ async function runAnalyze(options = {}) {
105325
105469
  const enabledCategories = config2.enabledCategories ?? void 0;
105326
105470
  const llmDecision = resolveLlmDecision(options, config2.enableLlmRules ?? true);
105327
105471
  const enableLlmRules = llmDecision.enabled;
105472
+ const stashDecision = await resolveStashDecision(options, project.path);
105328
105473
  renderPhase = enableLlmRules ? "pre-llm" : "all";
105329
105474
  if (wipeLegacyPostgresData()) {
105330
105475
  O2.info("Legacy Postgres data wiped. Re-analyze to repopulate.");
@@ -105340,6 +105485,7 @@ async function runAnalyze(options = {}) {
105340
105485
  const result = await analyzeInProcess(project, {
105341
105486
  tracker,
105342
105487
  signal: abortController.signal,
105488
+ skipStash: stashDecision.skipStash,
105343
105489
  enabledCategoriesOverride: enabledCategories,
105344
105490
  enableLlmRulesOverride: enableLlmRules,
105345
105491
  onLlmEstimate: async (estimate) => {
@@ -109221,7 +109367,7 @@ async function runHooksRun() {
109221
109367
 
109222
109368
  // tools/cli/src/index.ts
109223
109369
  var program2 = new Command();
109224
- program2.name("truecourse").version("0.5.6").description("TrueCourse CLI \u2014 analyze your repository and open the dashboard");
109370
+ program2.name("truecourse").version("0.5.7").description("TrueCourse CLI \u2014 analyze your repository and open the dashboard");
109225
109371
  var dashboardCmd = program2.command("dashboard").description("Start the TrueCourse dashboard and open it in your browser").option("--reconfigure", "Re-prompt for console vs background service mode").option("--service", "Run as a background service (skips mode prompt)").option("--console", "Run in this terminal (skips mode prompt)").action(async (options) => {
109226
109372
  if (options.service && options.console) {
109227
109373
  console.error("error: --service and --console are mutually exclusive");
@@ -109247,13 +109393,14 @@ function resolveInstallSkills(options) {
109247
109393
  if (options.skills === false) return false;
109248
109394
  return void 0;
109249
109395
  }
109250
- program2.command("analyze").description("Analyze the current repository").option("--diff", "Run diff check against latest analysis").option("--llm", "Run LLM-powered rules (pre-approves the cost estimate)").option("--no-llm", "Skip LLM-powered rules for this run").option("--install-skills", "Install Claude Code skills without prompting").option("--no-skills", "Skip the Claude Code skills prompt").action(async (options) => {
109396
+ program2.command("analyze").description("Analyze the current repository").option("--diff", "Run diff check against latest analysis").option("--llm", "Run LLM-powered rules (pre-approves the cost estimate)").option("--no-llm", "Skip LLM-powered rules for this run").option("--stash", "Pre-approve stashing pending changes before analysis").option("--no-stash", "Analyze the working tree as-is without stashing").option("--install-skills", "Install Claude Code skills without prompting").option("--no-skills", "Skip the Claude Code skills prompt").action(async (options) => {
109251
109397
  const llm = typeof options.llm === "boolean" ? options.llm : void 0;
109398
+ const stash = typeof options.stash === "boolean" ? options.stash : void 0;
109252
109399
  const installSkills = resolveInstallSkills(options);
109253
109400
  if (options.diff) {
109254
- await runAnalyzeDiff({ llm, installSkills });
109401
+ await runAnalyzeDiff({ llm, stash, installSkills });
109255
109402
  } else {
109256
- await runAnalyze({ llm, installSkills });
109403
+ await runAnalyze({ llm, stash, installSkills });
109257
109404
  }
109258
109405
  });
109259
109406
  program2.command("add").description("Register the current directory with TrueCourse").option("--install-skills", "Install Claude Code skills without prompting").option("--no-skills", "Skip the Claude Code skills prompt").action(async (options) => {