truecourse 0.5.1 → 0.5.3

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/README.md CHANGED
@@ -90,12 +90,26 @@ truecourse rules llm --disable # Disable LLM rules
90
90
 
91
91
  ### Git Hooks
92
92
 
93
- TrueCourse can install a pre-commit hook that blocks commits with critical violations:
93
+ TrueCourse can install a pre-commit hook that blocks commits introducing new violations at or above a configured severity:
94
94
 
95
95
  ```bash
96
96
  truecourse hooks install # Install pre-commit hook
97
97
  truecourse hooks uninstall # Remove pre-commit hook
98
- truecourse hooks status # Show hook installation status
98
+ truecourse hooks status # Show hook status + config
99
+ ```
100
+
101
+ On every commit the hook runs `truecourse analyze --diff` against the repo's last full analysis and blocks if any newly-introduced violation matches the configured block severities. **Commits will take as long as a full diff analysis** — on large repos that can be tens of seconds per commit. `truecourse hooks install` warns you and requires confirmation before writing the hook.
102
+
103
+ **First-time setup:** run `truecourse analyze` once to establish a baseline. Without it the hook can't diff.
104
+
105
+ **Bypass:** `git commit --no-verify` (standard git).
106
+
107
+ **Configuration** — `hooks install` seeds `<repo>/.truecourse/hooks.yaml` with starter defaults; commit the file so your team shares one policy. The hook reads only from this file — if you delete it, the hook warns and passes every commit (no hidden code-level defaults). Current shape:
108
+
109
+ ```yaml
110
+ pre-commit:
111
+ block-on: [critical, high] # severities. Valid: info|low|medium|high|critical
112
+ llm: false # run LLM rules on every commit (tokens per commit)
99
113
  ```
100
114
 
101
115
  ### Telemetry
@@ -131,13 +145,14 @@ TrueCourse ships with **1,200+ deterministic rules** and **100 LLM rules** acros
131
145
 
132
146
  TrueCourse includes [Claude Code skills](https://docs.anthropic.com/en/docs/claude-code/skills) for conversational analysis from within Claude Code.
133
147
 
134
- Run `truecourse add` to install skills to `.claude/skills/truecourse/` in your project.
148
+ The first `truecourse analyze` (or `truecourse add`) in a fresh repo asks whether to install skills into `.claude/skills/truecourse/`. Re-runs skip the prompt if skills are already present. Pass `--install-skills` / `--no-skills` to bypass the prompt explicitly.
135
149
 
136
150
  | Skill | What it does |
137
151
  |---|---|
138
152
  | `/truecourse-analyze` | Runs analysis or diff check, summarizes results |
139
153
  | `/truecourse-list` | Shows full violation details |
140
154
  | `/truecourse-fix` | Lists fixable violations, applies changes |
155
+ | `/truecourse-hooks` | Installs, configures, or removes the pre-commit hook |
141
156
 
142
157
  ## Language Support
143
158
 
@@ -172,6 +187,10 @@ pnpm build # Build all packages
172
187
 
173
188
  TrueCourse collects anonymous usage data (event type, language, file count range, OS). No source code, file paths, or violation details are collected. Opt out with `truecourse telemetry disable` or `TRUECOURSE_TELEMETRY=0`.
174
189
 
190
+ ## Contact
191
+
192
+ Questions, feedback, or security reports: **Mushegh Gevorgyan** — [mushegh@truecourse.dev](mailto:mushegh@truecourse.dev).
193
+
175
194
  ## License
176
195
 
177
196
  MIT
package/cli.mjs CHANGED
@@ -4080,7 +4080,7 @@ __export(helpers_exports, {
4080
4080
  writeConfig: () => writeConfig
4081
4081
  });
4082
4082
  import { exec as exec2 } from "node:child_process";
4083
- import { cpSync, existsSync } from "node:fs";
4083
+ import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
4084
4084
  import fs3 from "node:fs";
4085
4085
  import os2 from "node:os";
4086
4086
  import path3 from "node:path";
@@ -4319,12 +4319,6 @@ function openInBrowser(url) {
4319
4319
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
4320
4320
  exec2(`${cmd} ${url}`);
4321
4321
  }
4322
- function skillDestPath(repoPath) {
4323
- return resolve(repoPath, ".claude", "skills", "truecourse");
4324
- }
4325
- function hasInstalledSkills(repoPath) {
4326
- return existsSync(skillDestPath(repoPath));
4327
- }
4328
4322
  function isInteractive() {
4329
4323
  return !!process.stdin.isTTY;
4330
4324
  }
@@ -4336,35 +4330,61 @@ Running non-interactively with no answer. ${flagGuidance}`
4336
4330
  );
4337
4331
  process.exit(1);
4338
4332
  }
4339
- function copySkillsInto(repoPath) {
4333
+ function resolveSkillsSrcDir() {
4340
4334
  const __dirname4 = dirname(fileURLToPath(import.meta.url));
4341
- const srcPath = resolve(__dirname4, "..", "..", "skills", "truecourse");
4342
- const distPath = resolve(__dirname4, "skills", "truecourse");
4343
- const skillsSrc = existsSync(srcPath) ? srcPath : distPath;
4344
- if (!existsSync(skillsSrc)) {
4335
+ const candidate = resolve(__dirname4, "skills", "truecourse");
4336
+ return existsSync(candidate) ? candidate : null;
4337
+ }
4338
+ function skillDestDir(repoPath) {
4339
+ return resolve(repoPath, ".claude", "skills", "truecourse");
4340
+ }
4341
+ function listSkillDirs(root) {
4342
+ if (!existsSync(root)) return [];
4343
+ return readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort();
4344
+ }
4345
+ function computeMissingSkills(repoPath) {
4346
+ const src = resolveSkillsSrcDir();
4347
+ if (!src) return [];
4348
+ const shipped = new Set(listSkillDirs(src));
4349
+ const installed = new Set(listSkillDirs(skillDestDir(repoPath)));
4350
+ return [...shipped].filter((name) => !installed.has(name));
4351
+ }
4352
+ function hasInstalledSkills(repoPath) {
4353
+ return computeMissingSkills(repoPath).length === 0;
4354
+ }
4355
+ function copySkills(repoPath, skillNames) {
4356
+ const src = resolveSkillsSrcDir();
4357
+ if (!src) {
4345
4358
  O2.warn("Skills directory not found in package \u2014 skipping.");
4346
4359
  return;
4347
4360
  }
4348
- const skillsDest = resolve(repoPath, ".claude", "skills");
4349
- cpSync(skillsSrc, skillsDest, { recursive: true });
4350
- O2.success("Installed Claude Code skills:");
4351
- O2.message(" - truecourse-analyze (run analysis)");
4352
- O2.message(" - truecourse-list (list violations)");
4353
- O2.message(" - truecourse-fix (apply fixes)");
4361
+ const destParent = skillDestDir(repoPath);
4362
+ mkdirSync(destParent, { recursive: true });
4363
+ for (const name of skillNames) {
4364
+ const skillSrc = resolve(src, name);
4365
+ const skillDest = resolve(destParent, name);
4366
+ if (existsSync(skillDest)) continue;
4367
+ cpSync(skillSrc, skillDest, { recursive: true });
4368
+ }
4369
+ O2.success(
4370
+ `Installed ${skillNames.length} Claude Code skill${skillNames.length === 1 ? "" : "s"}:`
4371
+ );
4372
+ for (const name of skillNames) O2.message(` - ${name}`);
4354
4373
  }
4355
4374
  async function promptInstallSkills(repoPath, { install } = {}) {
4356
- if (hasInstalledSkills(repoPath)) return;
4375
+ const missing = computeMissingSkills(repoPath);
4376
+ if (missing.length === 0) return;
4357
4377
  if (install === true) {
4358
- copySkillsInto(repoPath);
4378
+ copySkills(repoPath, missing);
4359
4379
  return;
4360
4380
  }
4361
4381
  if (install === false) return;
4362
4382
  if (!isInteractive()) return;
4363
- const answer = await ot2({
4364
- message: "Would you like to install Claude Code skills?"
4365
- });
4383
+ const isUpgrade = existsSync(skillDestDir(repoPath));
4384
+ const message = isUpgrade ? `New Claude Code skill${missing.length === 1 ? "" : "s"} available: ${missing.join(", ")}. Install?` : "Would you like to install Claude Code skills?";
4385
+ const answer = await ot2({ message });
4366
4386
  if (q(answer) || !answer) return;
4367
- copySkillsInto(repoPath);
4387
+ copySkills(repoPath, missing);
4368
4388
  }
4369
4389
  var DEFAULT_PORT, DEFAULT_CONFIG;
4370
4390
  var init_helpers = __esm({
@@ -10563,7 +10583,7 @@ var init_language_config = __esm({
10563
10583
  });
10564
10584
 
10565
10585
  // packages/analyzer/dist/file-discovery.js
10566
- import { existsSync as existsSync2, readFileSync, readdirSync, statSync } from "fs";
10586
+ import { existsSync as existsSync2, readFileSync, readdirSync as readdirSync2, statSync } from "fs";
10567
10587
  import { join, relative, resolve as resolve2 } from "path";
10568
10588
  function findAllGitignores(startDir) {
10569
10589
  const gitignores = [];
@@ -10606,7 +10626,7 @@ function discoverFiles(dir) {
10606
10626
  const { ig, rootDir } = loadIgnorePatterns(dir);
10607
10627
  function traverse(currentPath) {
10608
10628
  try {
10609
- const entries = readdirSync(currentPath).sort();
10629
+ const entries = readdirSync2(currentPath).sort();
10610
10630
  for (const entry of entries) {
10611
10631
  const fullPath = join(currentPath, entry);
10612
10632
  const relativePath = relative(rootDir, fullPath);
@@ -10894,7 +10914,7 @@ var init_service_patterns = __esm({
10894
10914
  // packages/analyzer/dist/ts-compiler.js
10895
10915
  import * as ts from "typescript";
10896
10916
  import { dirname as dirname2, join as join2 } from "path";
10897
- import { existsSync as existsSync3, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
10917
+ import { existsSync as existsSync3, readdirSync as readdirSync3, statSync as statSync2 } from "fs";
10898
10918
  function buildScopedCompilerOptions(rootPath) {
10899
10919
  const result = [];
10900
10920
  const candidates = [join2(rootPath, "tsconfig.json")];
@@ -10903,7 +10923,7 @@ function buildScopedCompilerOptions(rootPath) {
10903
10923
  if (!existsSync3(dirPath) || !statSync2(dirPath).isDirectory())
10904
10924
  continue;
10905
10925
  try {
10906
- for (const entry of readdirSync2(dirPath).sort()) {
10926
+ for (const entry of readdirSync3(dirPath).sort()) {
10907
10927
  candidates.push(join2(dirPath, entry, "tsconfig.json"));
10908
10928
  }
10909
10929
  } catch {
@@ -13533,7 +13553,7 @@ var init_registry2 = __esm({
13533
13553
 
13534
13554
  // packages/analyzer/dist/dependency-graph.js
13535
13555
  import { resolve as resolve4, dirname as dirname4, join as join3 } from "path";
13536
- import { existsSync as existsSync4, readFileSync as readFileSync2, readdirSync as readdirSync3, realpathSync, statSync as statSync3 } from "fs";
13556
+ import { existsSync as existsSync4, readFileSync as readFileSync2, readdirSync as readdirSync4, realpathSync, statSync as statSync3 } from "fs";
13537
13557
  function resolveRelativeFallback(importSource, containingFile, analyzedFiles, extensions, indexFiles) {
13538
13558
  const fromDir = dirname4(containingFile);
13539
13559
  const basePath = resolve4(fromDir, importSource);
@@ -13561,7 +13581,7 @@ function buildWorkspacePackageMap(rootPath) {
13561
13581
  if (!existsSync4(dirPath) || !statSync3(dirPath).isDirectory())
13562
13582
  continue;
13563
13583
  try {
13564
- for (const entry of readdirSync3(dirPath).sort()) {
13584
+ for (const entry of readdirSync4(dirPath).sort()) {
13565
13585
  const pkgDir = join3(dirPath, entry);
13566
13586
  const pkgJsonPath = join3(pkgDir, "package.json");
13567
13587
  if (!existsSync4(pkgJsonPath))
@@ -16249,7 +16269,7 @@ var init_registry3 = __esm({
16249
16269
  });
16250
16270
 
16251
16271
  // packages/analyzer/dist/service-detector.js
16252
- import { existsSync as existsSync7, readFileSync as readFileSync5, readdirSync as readdirSync4, statSync as statSync4 } from "fs";
16272
+ import { existsSync as existsSync7, readFileSync as readFileSync5, readdirSync as readdirSync5, statSync as statSync4 } from "fs";
16253
16273
  import { join as join6, basename, dirname as dirname5 } from "path";
16254
16274
  function detectServices(rootPath, allFiles) {
16255
16275
  const monorepoServices = detectMonorepoServices(rootPath, allFiles);
@@ -16294,7 +16314,7 @@ function detectMonorepoServices(rootPath, allFiles) {
16294
16314
  if (!existsSync7(dirPath))
16295
16315
  continue;
16296
16316
  try {
16297
- const entries = readdirSync4(dirPath).sort();
16317
+ const entries = readdirSync5(dirPath).sort();
16298
16318
  for (const entry of entries) {
16299
16319
  const servicePath = join6(dirPath, entry);
16300
16320
  const stats = statSync4(servicePath);
@@ -17050,7 +17070,7 @@ var init_registry4 = __esm({
17050
17070
  });
17051
17071
 
17052
17072
  // packages/analyzer/dist/database-detector.js
17053
- import { existsSync as existsSync8, readFileSync as readFileSync6, readdirSync as readdirSync5 } from "fs";
17073
+ import { existsSync as existsSync8, readFileSync as readFileSync6, readdirSync as readdirSync6 } from "fs";
17054
17074
  import { join as join7, resolve as resolve5 } from "path";
17055
17075
  function detectDatabases(rootPath, analyses, services) {
17056
17076
  const detections = [];
@@ -17219,7 +17239,7 @@ function parseDockerCompose(rootPath) {
17219
17239
  function findFiles(dir, fileName, ignoreDirs) {
17220
17240
  const results = [];
17221
17241
  try {
17222
- const entries = readdirSync5(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
17242
+ const entries = readdirSync6(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
17223
17243
  for (const entry of entries) {
17224
17244
  if (ignoreDirs.includes(entry.name))
17225
17245
  continue;
@@ -123658,7 +123678,7 @@ var init_schemas2 = __esm({
123658
123678
 
123659
123679
  // apps/server/dist/services/llm/cli-provider.js
123660
123680
  import { spawn as spawn3 } from "node:child_process";
123661
- import { mkdirSync, writeFileSync } from "node:fs";
123681
+ import { mkdirSync as mkdirSync2, writeFileSync } from "node:fs";
123662
123682
  import { join as join8 } from "node:path";
123663
123683
  import { tmpdir } from "node:os";
123664
123684
  import { randomUUID as randomUUID3 } from "node:crypto";
@@ -123721,7 +123741,7 @@ var init_cli_provider = __esm({
123721
123741
  constructor() {
123722
123742
  if (process.env.TRUECOURSE_CLI_DEBUG) {
123723
123743
  this.debugDir = join8(tmpdir(), "truecourse-cli-debug");
123724
- mkdirSync(this.debugDir, { recursive: true });
123744
+ mkdirSync2(this.debugDir, { recursive: true });
123725
123745
  log.info(`[CLI] Debug output: ${this.debugDir}`);
123726
123746
  }
123727
123747
  }
@@ -130434,6 +130454,11 @@ var jsYaml = {
130434
130454
  };
130435
130455
 
130436
130456
  // tools/cli/src/commands/hooks.ts
130457
+ init_dist4();
130458
+ init_paths();
130459
+ init_registry();
130460
+ init_analysis_store();
130461
+ init_diff_in_process();
130437
130462
  init_helpers();
130438
130463
  var HOOK_IDENTIFIER = "# TrueCourse pre-commit hook";
130439
130464
  var HOOK_SCRIPT = `#!/bin/sh
@@ -130441,29 +130466,35 @@ ${HOOK_IDENTIFIER}
130441
130466
  # Installed by: truecourse hooks install
130442
130467
  # Bypass with: git commit --no-verify
130443
130468
 
130444
- exec truecourse hooks run
130469
+ exec npx -y truecourse hooks run
130470
+ `;
130471
+ var SEVERITIES2 = ["info", "low", "medium", "high", "critical"];
130472
+ var HOOKS_YAML_TEMPLATE = `# TrueCourse pre-commit hook config.
130473
+ # Commit this file \u2014 it's the team-shared policy for what blocks a commit.
130474
+ # Check the live values with \`truecourse hooks status\`.
130475
+ pre-commit:
130476
+ # Severities that block a commit when the diff surfaces NEW violations
130477
+ # at that level. Valid: info, low, medium, high, critical.
130478
+ block-on:
130479
+ - critical
130480
+ - high
130481
+
130482
+ # Run LLM-powered rules on every commit? Off by default (no tokens per
130483
+ # commit). Set to true for deeper semantic checks at the commit gate \u2014
130484
+ # each commit will then cost tokens.
130485
+ llm: false
130445
130486
  `;
130446
- var DEFAULT_BLOCK_ON = [
130447
- "security/deterministic/hardcoded-secret",
130448
- { severity: "critical" }
130449
- ];
130450
- var DEFAULT_TIMEOUT_MS = 3e4;
130451
130487
  function findGitDir(from) {
130452
130488
  let dir = from;
130453
130489
  while (true) {
130454
130490
  const gitPath = path21.join(dir, ".git");
130455
130491
  if (fs16.existsSync(gitPath)) {
130456
130492
  const stat = fs16.statSync(gitPath);
130457
- if (stat.isDirectory()) {
130458
- return gitPath;
130459
- }
130493
+ if (stat.isDirectory()) return gitPath;
130460
130494
  if (stat.isFile()) {
130461
130495
  const content = fs16.readFileSync(gitPath, "utf-8").trim();
130462
130496
  const match2 = content.match(/^gitdir:\s*(.+)$/);
130463
- if (match2) {
130464
- const resolved = path21.resolve(dir, match2[1]);
130465
- return resolved;
130466
- }
130497
+ if (match2) return path21.resolve(dir, match2[1]);
130467
130498
  }
130468
130499
  }
130469
130500
  const parent = path21.dirname(dir);
@@ -130474,46 +130505,68 @@ function findGitDir(from) {
130474
130505
  function findProjectRoot(from) {
130475
130506
  let dir = from;
130476
130507
  while (true) {
130477
- if (fs16.existsSync(path21.join(dir, ".git"))) {
130478
- return dir;
130479
- }
130508
+ if (fs16.existsSync(path21.join(dir, ".git"))) return dir;
130480
130509
  const parent = path21.dirname(dir);
130481
130510
  if (parent === dir) return null;
130482
130511
  dir = parent;
130483
130512
  }
130484
130513
  }
130485
- function parseTimeout(value) {
130486
- const match2 = value.match(/^(\d+)\s*(s|ms|m)?$/);
130487
- if (!match2) return DEFAULT_TIMEOUT_MS;
130488
- const num = parseInt(match2[1], 10);
130489
- const unit = match2[2] || "s";
130490
- if (unit === "ms") return num;
130491
- if (unit === "m") return num * 6e4;
130492
- return num * 1e3;
130493
- }
130494
130514
  function loadConfig(projectRoot) {
130495
130515
  const configPath = path21.join(projectRoot, ".truecourse", "hooks.yaml");
130496
- if (!fs16.existsSync(configPath)) return {};
130516
+ if (!fs16.existsSync(configPath)) return null;
130517
+ let parsed;
130497
130518
  try {
130498
130519
  const raw = fs16.readFileSync(configPath, "utf-8");
130499
- return jsYaml.load(raw) || {};
130500
- } catch {
130501
- return {};
130520
+ parsed = jsYaml.load(raw) || {};
130521
+ } catch (err) {
130522
+ console.error(`Error parsing ${configPath}: ${err.message}`);
130523
+ process.exit(1);
130502
130524
  }
130525
+ const preCommit = parsed["pre-commit"] ?? {};
130526
+ const rawBlockOn = preCommit["block-on"];
130527
+ if (!Array.isArray(rawBlockOn)) {
130528
+ console.error(
130529
+ `Invalid ${configPath}: \`pre-commit.block-on\` must be an array of severity names.`
130530
+ );
130531
+ console.error(` Valid severities: ${SEVERITIES2.join(", ")}`);
130532
+ process.exit(1);
130533
+ }
130534
+ const invalid = rawBlockOn.filter(
130535
+ (s) => typeof s !== "string" || !SEVERITIES2.includes(s)
130536
+ );
130537
+ if (invalid.length > 0) {
130538
+ console.error(
130539
+ `Invalid ${configPath}: unknown value(s) in \`pre-commit.block-on\`: ${invalid.map((v) => JSON.stringify(v)).join(", ")}`
130540
+ );
130541
+ console.error(` Valid severities: ${SEVERITIES2.join(", ")}`);
130542
+ process.exit(1);
130543
+ }
130544
+ return {
130545
+ blockOn: rawBlockOn,
130546
+ llm: preCommit.llm === true,
130547
+ configPath
130548
+ };
130503
130549
  }
130504
- function getBlockRules(config2) {
130505
- return config2["pre-commit"]?.["block-on"] ?? DEFAULT_BLOCK_ON;
130506
- }
130507
- function getTimeoutMs(config2) {
130508
- const raw = config2["pre-commit"]?.timeout;
130509
- return raw ? parseTimeout(raw) : DEFAULT_TIMEOUT_MS;
130510
- }
130511
- function runHooksInstall() {
130550
+ var INSTALL_WARNING = "The pre-commit hook runs `truecourse analyze --diff` on every commit.\nCommits will take as long as a full diff analysis of this repo \u2014\non large repos that can be tens of seconds per commit.";
130551
+ async function runHooksInstall() {
130512
130552
  const gitDir = findGitDir(process.cwd());
130513
130553
  if (!gitDir) {
130514
130554
  console.error("Error: Not a git repository.");
130515
130555
  process.exit(1);
130516
130556
  }
130557
+ if (isInteractive()) {
130558
+ O2.warn(INSTALL_WARNING);
130559
+ const proceed = await ot2({
130560
+ message: "Install the pre-commit hook?",
130561
+ initialValue: false
130562
+ });
130563
+ if (q(proceed) || !proceed) {
130564
+ pt("Install cancelled.");
130565
+ process.exit(0);
130566
+ }
130567
+ } else {
130568
+ console.log(INSTALL_WARNING);
130569
+ }
130517
130570
  const hooksDir = path21.join(gitDir, "hooks");
130518
130571
  fs16.mkdirSync(hooksDir, { recursive: true });
130519
130572
  const hookPath = path21.join(hooksDir, "pre-commit");
@@ -130531,6 +130584,16 @@ function runHooksInstall() {
130531
130584
  fs16.writeFileSync(hookPath, HOOK_SCRIPT, { mode: 493 });
130532
130585
  console.log("TrueCourse pre-commit hook installed.");
130533
130586
  console.log(` ${hookPath}`);
130587
+ const projectRoot = findProjectRoot(process.cwd());
130588
+ if (projectRoot) {
130589
+ const cfgDir = path21.join(projectRoot, ".truecourse");
130590
+ const cfgPath = path21.join(cfgDir, "hooks.yaml");
130591
+ if (!fs16.existsSync(cfgPath)) {
130592
+ fs16.mkdirSync(cfgDir, { recursive: true });
130593
+ fs16.writeFileSync(cfgPath, HOOKS_YAML_TEMPLATE);
130594
+ console.log(` ${cfgPath} (starter config \u2014 edit to customize, commit to share with the team)`);
130595
+ }
130596
+ }
130534
130597
  }
130535
130598
  function runHooksUninstall() {
130536
130599
  const gitDir = findGitDir(process.cwd());
@@ -130568,125 +130631,97 @@ function runHooksStatus() {
130568
130631
  }
130569
130632
  const projectRoot = findProjectRoot(process.cwd());
130570
130633
  if (projectRoot) {
130571
- const configPath = path21.join(projectRoot, ".truecourse", "hooks.yaml");
130572
- if (fs16.existsSync(configPath)) {
130573
- console.log(`
130574
- Config: ${configPath}`);
130575
- const config2 = loadConfig(projectRoot);
130576
- const blockRules = getBlockRules(config2);
130577
- console.log("Block on:");
130578
- for (const rule of blockRules) {
130579
- if (typeof rule === "string") {
130580
- console.log(` - ${rule}`);
130581
- } else {
130582
- console.log(` - severity: ${rule.severity}`);
130583
- }
130584
- }
130585
- const timeoutMs = getTimeoutMs(config2);
130586
- console.log(`Timeout: ${timeoutMs / 1e3}s`);
130634
+ const cfg = loadConfig(projectRoot);
130635
+ if (!cfg) {
130636
+ console.log(
130637
+ "\nNo `.truecourse/hooks.yaml` \u2014 hook has no policy. Run `truecourse hooks install` to generate one."
130638
+ );
130587
130639
  } else {
130588
- console.log("\nNo config file. Using defaults (block on: security/deterministic/hardcoded-secret, severity: critical).");
130589
- }
130590
- }
130591
- }
130592
- function shouldBlock(violation, blockRules) {
130593
- for (const rule of blockRules) {
130594
- if (typeof rule === "string") {
130595
- if (violation.ruleKey === rule || violation.ruleKey.endsWith(`/${rule}`)) {
130596
- return true;
130597
- }
130598
- } else if (rule.severity) {
130599
- if (violation.severity.toLowerCase() === rule.severity.toLowerCase()) {
130600
- return true;
130601
- }
130640
+ console.log(`
130641
+ Config: ${cfg.configPath}`);
130642
+ console.log(`Block on severities: ${cfg.blockOn.join(", ")}`);
130643
+ console.log(`LLM rules on commit: ${cfg.llm ? "enabled (tokens per commit)" : "disabled"}`);
130602
130644
  }
130603
130645
  }
130604
- return false;
130605
130646
  }
130606
130647
  async function runHooksRun() {
130607
- const startTime = Date.now();
130608
130648
  process.stdout.write("TrueCourse pre-commit check...");
130609
130649
  const projectRoot = findProjectRoot(process.cwd());
130610
130650
  if (!projectRoot) {
130611
130651
  console.log(" skipped (not a git repository)");
130612
130652
  process.exit(0);
130613
130653
  }
130614
- const config2 = loadConfig(projectRoot);
130615
- const blockRules = getBlockRules(config2);
130616
- const timeoutMs = getTimeoutMs(config2);
130617
- let stagedFiles;
130654
+ let hasStaged = false;
130618
130655
  try {
130619
130656
  const output = execSync5("git diff --cached --name-only --diff-filter=ACM", {
130620
130657
  encoding: "utf-8",
130621
130658
  cwd: projectRoot
130622
130659
  }).trim();
130623
- stagedFiles = output ? output.split("\n") : [];
130660
+ hasStaged = output.length > 0;
130624
130661
  } catch {
130625
130662
  console.log(" skipped (git error)");
130626
130663
  process.exit(0);
130627
130664
  }
130628
- if (stagedFiles.length === 0) {
130665
+ if (!hasStaged) {
130629
130666
  console.log(" \u2714 passed (no staged files)");
130630
130667
  process.exit(0);
130631
130668
  }
130632
- let parseCode2;
130633
- let detectLanguage2;
130634
- let checkCodeRules2;
130635
- let CODE_RULES2;
130636
- try {
130637
- const analyzer = await Promise.resolve().then(() => (init_dist6(), dist_exports2));
130638
- parseCode2 = analyzer.parseCode;
130639
- detectLanguage2 = analyzer.detectLanguage;
130640
- checkCodeRules2 = analyzer.checkCodeRules;
130641
- CODE_RULES2 = analyzer.CODE_RULES;
130642
- await analyzer.initParsers();
130643
- } catch {
130644
- console.log(" skipped (analyzer not available)");
130669
+ const cfg = loadConfig(projectRoot);
130670
+ if (!cfg) {
130671
+ console.log(" skipped");
130672
+ console.error(
130673
+ "No `.truecourse/hooks.yaml` found. The pre-commit hook has no policy to\nenforce \u2014 run `truecourse hooks install` to generate one."
130674
+ );
130645
130675
  process.exit(0);
130646
130676
  }
130647
- const supportedFiles = stagedFiles.filter((f) => detectLanguage2(f) !== null);
130648
- if (supportedFiles.length === 0) {
130649
- console.log(" \u2714 passed");
130650
- process.exit(0);
130677
+ const repoDir = resolveRepoDir(process.cwd());
130678
+ const project = repoDir ? getProjectByPath(repoDir) ?? registerProject(repoDir) : null;
130679
+ if (!project || !readLatest(project.path)) {
130680
+ console.log("");
130681
+ console.error(
130682
+ "No baseline analysis yet. Run `truecourse analyze` once in this repo before\nthe pre-commit hook can block new violations. Or bypass this commit with\n`git commit --no-verify`."
130683
+ );
130684
+ process.exit(1);
130651
130685
  }
130652
- const allViolations = [];
130653
- for (const filePath of supportedFiles) {
130654
- if (Date.now() - startTime > timeoutMs) {
130655
- console.log("\n Warning: timeout reached, skipping remaining files.");
130656
- break;
130657
- }
130658
- const language = detectLanguage2(filePath);
130659
- if (!language) continue;
130660
- let content;
130661
- try {
130662
- content = execSync5(`git show ":${filePath}"`, {
130663
- encoding: "utf-8",
130664
- cwd: projectRoot,
130665
- maxBuffer: 5 * 1024 * 1024
130666
- // 5MB
130667
- });
130668
- } catch {
130669
- continue;
130670
- }
130671
- try {
130672
- const tree = parseCode2(content, language);
130673
- const violations = checkCodeRules2(tree, filePath, content, CODE_RULES2, language);
130674
- allViolations.push(...violations);
130675
- } catch {
130676
- continue;
130686
+ const abortController = new AbortController();
130687
+ const onSigint = () => abortController.abort();
130688
+ process.on("SIGINT", onSigint);
130689
+ process.stdout.write(" running analysis...");
130690
+ let newViolations;
130691
+ try {
130692
+ const { diff } = await diffInProcess(project, {
130693
+ signal: abortController.signal,
130694
+ enableLlmRulesOverride: cfg.llm,
130695
+ // Pre-approved: the user opted into LLM-in-hook by setting llm: true
130696
+ // in hooks.yaml, so we don't re-prompt for the token cost estimate.
130697
+ onLlmEstimate: async () => true
130698
+ });
130699
+ newViolations = diff.newViolations;
130700
+ } catch (err) {
130701
+ console.log("");
130702
+ if (err instanceof DOMException && err.name === "AbortError") {
130703
+ console.error("Pre-commit check cancelled.");
130704
+ process.exit(130);
130677
130705
  }
130706
+ console.error(`Pre-commit check failed: ${err instanceof Error ? err.message : String(err)}`);
130707
+ process.exit(1);
130708
+ } finally {
130709
+ process.removeListener("SIGINT", onSigint);
130678
130710
  }
130679
- const blocking = allViolations.filter((v) => shouldBlock(v, blockRules));
130711
+ const blockSet = new Set(cfg.blockOn);
130712
+ const blocking = newViolations.filter((v) => blockSet.has(v.severity.toLowerCase()));
130680
130713
  if (blocking.length === 0) {
130681
- console.log(" \u2714 passed");
130714
+ console.log(` \u2714 passed (${newViolations.length} new violations, none at or above ${cfg.blockOn.join("/")})`);
130682
130715
  process.exit(0);
130683
130716
  }
130684
130717
  console.log("\n");
130685
130718
  for (const v of blocking) {
130686
130719
  const icon = severityIcon(v.severity);
130687
130720
  const color = severityColor(v.severity);
130721
+ const location = v.filePath ? `${v.filePath}${v.lineStart ? `:${v.lineStart}` : ""}` : "(no file)";
130688
130722
  console.log(` ${color(`${icon} BLOCKED`)}: ${v.title}`);
130689
- console.log(` ${v.filePath}:${v.lineStart} \u2014 ${v.content}`);
130723
+ console.log(` ${location} \u2014 ${v.content}`);
130724
+ if (v.fixPrompt) console.log(` Fix: ${v.fixPrompt}`);
130690
130725
  console.log("");
130691
130726
  }
130692
130727
  console.log("Commit blocked. Fix the issue or bypass with --no-verify.");
@@ -130695,7 +130730,7 @@ async function runHooksRun() {
130695
130730
 
130696
130731
  // tools/cli/src/index.ts
130697
130732
  var program2 = new Command();
130698
- program2.name("truecourse").version("0.5.1").description("TrueCourse CLI \u2014 analyze your repository and open the dashboard");
130733
+ program2.name("truecourse").version("0.5.3").description("TrueCourse CLI \u2014 analyze your repository and open the dashboard");
130699
130734
  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) => {
130700
130735
  if (options.service && options.console) {
130701
130736
  console.error("error: --service and --console are mutually exclusive");
@@ -130774,8 +130809,8 @@ telemetryCmd.command("status").description("Show current telemetry status").acti
130774
130809
  }
130775
130810
  });
130776
130811
  var hooksCmd = program2.command("hooks").description("Manage git hooks");
130777
- hooksCmd.command("install").description("Install pre-commit hook").action(() => {
130778
- runHooksInstall();
130812
+ hooksCmd.command("install").description("Install pre-commit hook").action(async () => {
130813
+ await runHooksInstall();
130779
130814
  });
130780
130815
  hooksCmd.command("uninstall").description("Remove pre-commit hook").action(() => {
130781
130816
  runHooksUninstall();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "truecourse",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Visualize your codebase architecture as an interactive graph",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,10 @@
21
21
  "node-windows": "^1.0.0-beta.8"
22
22
  },
23
23
  "license": "MIT",
24
+ "author": {
25
+ "name": "Mushegh Gevorgyan",
26
+ "email": "mushegh@truecourse.dev"
27
+ },
24
28
  "repository": {
25
29
  "type": "git",
26
30
  "url": "https://github.com/truecourse-ai/truecourse"
@@ -18,7 +18,7 @@ Run architecture analysis on the current repository using TrueCourse.
18
18
  - **Full analysis** stashes any uncommitted changes, analyzes the clean working tree, then unstashes. The user's uncommitted work is preserved.
19
19
  - **Diff check** analyzes the full working tree (including uncommitted changes — it does NOT stash) and compares the result against the last full analysis baseline. The report lists violations newly introduced and violations resolved since that baseline. Prefer diff for in-progress work where the user is iterating on changes.
20
20
  - **Always invoke via `npx -y`** — without `-y`, npx will hang on the "Ok to proceed?" prompt whenever the user hasn't cached the latest `truecourse` version (which happens every time we publish a new release).
21
- - **LLM rules cost real money.** Never pass `--llm` without first relaying the cost estimate to the user and getting approval. See the LLM flow below.
21
+ - **LLM rules cost tokens.** Never pass `--llm` without first relaying the token estimate to the user and getting approval. See the LLM flow below.
22
22
 
23
23
  ## Instructions
24
24
 
@@ -30,7 +30,7 @@ Ask the user whether they want a **full analysis** or a **diff check**. If they
30
30
 
31
31
  ### 2. Decide on LLM rules
32
32
 
33
- LLM rules add higher-value insights but cost money per run. Ask the user one question: **"Run LLM-powered rules this time?"** If the user is unsure, offer to run deterministic-only first (free, fast) and add LLM later.
33
+ LLM rules add higher-value insights but cost tokens per run. Ask the user one question: **"Run LLM-powered rules this time?"** If the user is unsure, offer to run deterministic-only first (no tokens, fast) and add LLM later.
34
34
 
35
35
  - If **user approves LLM**: append `--llm` to the command.
36
36
  - If **user declines LLM or wants a free run**: append `--no-llm`.
@@ -0,0 +1,86 @@
1
+ ---
2
+ name: truecourse-hooks
3
+ description: Install, configure, or remove the TrueCourse pre-commit hook
4
+ user_invocable: true
5
+ triggers:
6
+ - install the pre-commit hook
7
+ - set up truecourse hooks
8
+ - enable truecourse hook
9
+ - check hook status
10
+ - remove pre-commit hook
11
+ - change what the hook blocks
12
+ - edit hook config
13
+ ---
14
+
15
+ # TrueCourse Hooks
16
+
17
+ Install, configure, and manage the pre-commit hook that blocks new violations before they land in git.
18
+
19
+ ## Important
20
+
21
+ - **Always invoke via `npx -y`** — without `-y`, npx will hang on the "Ok to proceed?" prompt whenever the user hasn't cached the latest `truecourse` version.
22
+ - **The hook makes commits slower.** Every commit runs `truecourse analyze --diff`. On large repos that can be tens of seconds per commit. Make sure the user knows before you install.
23
+ - **Baseline required.** The hook needs a `truecourse analyze` to have run at least once in the repo — otherwise every commit is blocked with "run analyze first". If the user hasn't, suggest running `/truecourse-analyze` first (or `npx -y truecourse analyze`).
24
+ - **`hooks.yaml` is the single source of truth.** Installation creates `<repo>/.truecourse/hooks.yaml` with defaults; edit it to change policy. The file is meant to be committed so the whole team shares one hook config.
25
+
26
+ ## Instructions
27
+
28
+ ### 1. Figure out what the user wants
29
+
30
+ - "install", "set up", "enable" → **Install flow**
31
+ - "status", "is the hook active", "what does it block" → **Status flow**
32
+ - "uninstall", "remove", "disable" → **Uninstall flow**
33
+ - "change what blocks", "make it stricter/looser", "add/remove severities", "enable LLM" → **Configure flow**
34
+
35
+ ### 2. Install flow
36
+
37
+ 1. Tell the user the tradeoff upfront: commits will be slower; this repo needs a `truecourse analyze` baseline; policy lives in `.truecourse/hooks.yaml` which they should commit.
38
+ 2. Run:
39
+ ```
40
+ npx -y truecourse hooks install
41
+ ```
42
+ 3. Relay the output. Two files get created:
43
+ - `.git/hooks/pre-commit` (the script git invokes)
44
+ - `.truecourse/hooks.yaml` (starter policy, blocks `critical` and `high` by default, LLM off)
45
+ 4. If the user hasn't run a full analysis in this repo, suggest `/truecourse-analyze` — without it, every commit will be blocked with "no baseline" until they do.
46
+
47
+ ### 3. Status flow
48
+
49
+ Run:
50
+ ```
51
+ npx -y truecourse hooks status
52
+ ```
53
+ Relay the output. It reports whether the hook is installed, the config path, the block severities, and whether LLM is on.
54
+
55
+ ### 4. Uninstall flow
56
+
57
+ Run:
58
+ ```
59
+ npx -y truecourse hooks uninstall
60
+ ```
61
+ Only removes the git hook script. `hooks.yaml` is preserved (it's team policy, not install state).
62
+
63
+ ### 5. Configure flow
64
+
65
+ The config lives at `<repo>/.truecourse/hooks.yaml`. Use the Read and Edit tools — do not shell out through `truecourse` for edits.
66
+
67
+ Schema:
68
+ ```yaml
69
+ pre-commit:
70
+ block-on: [critical, high] # valid: info, low, medium, high, critical
71
+ llm: false # true = LLM rules on every commit (tokens per commit)
72
+ ```
73
+
74
+ Common edits the user might ask for:
75
+ - **Stricter** ("block medium too"): `block-on: [critical, high, medium]`
76
+ - **Permissive** ("only block criticals"): `block-on: [critical]`
77
+ - **Enable LLM** ("run full checks on commit"): set `llm: true`. Warn the user this spends tokens on every commit — confirm before flipping it.
78
+
79
+ After editing, run `npx -y truecourse hooks status` so they can verify the parsed values match their intent.
80
+
81
+ ### 6. When the user hits a blocked commit
82
+
83
+ If a user comes to you saying "my commit got blocked" or similar:
84
+ - The hook's stdout already listed the blocking violations (file, line, title, severity).
85
+ - Offer to run `/truecourse-fix` to apply fix suggestions to those violations.
86
+ - If they want to ship anyway, remind them of `git commit --no-verify` (standard git bypass).