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 +22 -3
- package/cli.mjs +194 -159
- package/package.json +5 -1
- package/skills/truecourse/truecourse-analyze/SKILL.md +2 -2
- package/skills/truecourse/truecourse-hooks/SKILL.md +86 -0
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
|
|
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
|
|
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
|
-
|
|
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
|
|
4333
|
+
function resolveSkillsSrcDir() {
|
|
4340
4334
|
const __dirname4 = dirname(fileURLToPath(import.meta.url));
|
|
4341
|
-
const
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
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
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
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
|
-
|
|
4375
|
+
const missing = computeMissingSkills(repoPath);
|
|
4376
|
+
if (missing.length === 0) return;
|
|
4357
4377
|
if (install === true) {
|
|
4358
|
-
|
|
4378
|
+
copySkills(repoPath, missing);
|
|
4359
4379
|
return;
|
|
4360
4380
|
}
|
|
4361
4381
|
if (install === false) return;
|
|
4362
4382
|
if (!isInteractive()) return;
|
|
4363
|
-
const
|
|
4364
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
130500
|
-
} catch {
|
|
130501
|
-
|
|
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
|
-
|
|
130505
|
-
|
|
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
|
|
130572
|
-
if (
|
|
130573
|
-
console.log(
|
|
130574
|
-
|
|
130575
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
130660
|
+
hasStaged = output.length > 0;
|
|
130624
130661
|
} catch {
|
|
130625
130662
|
console.log(" skipped (git error)");
|
|
130626
130663
|
process.exit(0);
|
|
130627
130664
|
}
|
|
130628
|
-
if (
|
|
130665
|
+
if (!hasStaged) {
|
|
130629
130666
|
console.log(" \u2714 passed (no staged files)");
|
|
130630
130667
|
process.exit(0);
|
|
130631
130668
|
}
|
|
130632
|
-
|
|
130633
|
-
|
|
130634
|
-
|
|
130635
|
-
|
|
130636
|
-
|
|
130637
|
-
|
|
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
|
|
130648
|
-
|
|
130649
|
-
|
|
130650
|
-
|
|
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
|
|
130653
|
-
|
|
130654
|
-
|
|
130655
|
-
|
|
130656
|
-
|
|
130657
|
-
|
|
130658
|
-
const
|
|
130659
|
-
|
|
130660
|
-
|
|
130661
|
-
|
|
130662
|
-
|
|
130663
|
-
|
|
130664
|
-
|
|
130665
|
-
|
|
130666
|
-
|
|
130667
|
-
|
|
130668
|
-
|
|
130669
|
-
|
|
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
|
|
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(
|
|
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(` ${
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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).
|