tokenclinic 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +180 -0
- package/package.json +67 -0
- package/skill/token-clinic/SKILL.md +76 -0
- package/src/amortize/cluster.ts +20 -0
- package/src/amortize/promote.ts +27 -0
- package/src/amortize/sg.ts +67 -0
- package/src/amortize/synthesize.ts +68 -0
- package/src/amortize/validate.ts +51 -0
- package/src/audit/audit.ts +76 -0
- package/src/audit/classify.ts +56 -0
- package/src/bill/eob.ts +77 -0
- package/src/cli.ts +221 -0
- package/src/detect/deps.ts +37 -0
- package/src/diagnose/context.ts +27 -0
- package/src/diagnose/partition.ts +37 -0
- package/src/pricing/llmIntel.ts +41 -0
- package/src/pricing/normalize.ts +35 -0
- package/src/pricing/table.ts +50 -0
- package/src/record/health.ts +44 -0
- package/src/scan.ts +95 -0
- package/src/treat/anthropic.ts +85 -0
- package/src/treat/apply.ts +74 -0
- package/src/treat/fixer.ts +38 -0
- package/src/treat/route.ts +39 -0
- package/src/triage/analyzers/astgrep.ts +74 -0
- package/src/triage/analyzers/tsc.ts +49 -0
- package/src/triage/index.ts +20 -0
- package/src/types.ts +114 -0
- package/src/util.ts +0 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, appendFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { Finding, EOB } from "../types";
|
|
4
|
+
import type { DepProfile } from "../detect/deps";
|
|
5
|
+
|
|
6
|
+
// The Codebase Health Record: a .tokenclinic/ directory written into the scanned
|
|
7
|
+
// repo. v1 persists the dep profile and an append-only run history. v2 adds
|
|
8
|
+
// rules/ (promoted generated checks + fixtures), quarantine/, and routing.json
|
|
9
|
+
// (learned per-class model routing). Every run reads it back, so every run gets
|
|
10
|
+
// cheaper and smarter — this is the compounding asset, not the router.
|
|
11
|
+
|
|
12
|
+
export function writeHealthRecord(
|
|
13
|
+
root: string,
|
|
14
|
+
deps: DepProfile,
|
|
15
|
+
findings: Finding[],
|
|
16
|
+
eob: EOB,
|
|
17
|
+
now: string,
|
|
18
|
+
): string {
|
|
19
|
+
const dir = join(root, ".tokenclinic");
|
|
20
|
+
mkdirSync(dir, { recursive: true });
|
|
21
|
+
|
|
22
|
+
writeFileSync(
|
|
23
|
+
join(dir, "profile.json"),
|
|
24
|
+
JSON.stringify(
|
|
25
|
+
{ updated: now, manager: deps.manager, deps: deps.deps, analyzers: ["tsc"] },
|
|
26
|
+
null,
|
|
27
|
+
2,
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const entry = {
|
|
32
|
+
at: now,
|
|
33
|
+
findings: findings.length,
|
|
34
|
+
fixedLocally: eob.fixedLocally,
|
|
35
|
+
escalated: eob.escalated,
|
|
36
|
+
spend: eob.spend,
|
|
37
|
+
naiveCost: eob.naiveCost,
|
|
38
|
+
saved: eob.saved,
|
|
39
|
+
estimated: eob.estimated,
|
|
40
|
+
};
|
|
41
|
+
appendFileSync(join(dir, "history.jsonl"), `${JSON.stringify(entry)}\n`);
|
|
42
|
+
|
|
43
|
+
return dir;
|
|
44
|
+
}
|
package/src/scan.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Finding, EOB } from "./types";
|
|
2
|
+
import { detectDeps, type DepProfile } from "./detect/deps";
|
|
3
|
+
import { triage } from "./triage";
|
|
4
|
+
import { partition } from "./diagnose/partition";
|
|
5
|
+
import { buildContext } from "./diagnose/context";
|
|
6
|
+
import { DryRunFixer } from "./treat/fixer";
|
|
7
|
+
import { buildEOB } from "./bill/eob";
|
|
8
|
+
|
|
9
|
+
// The read-only scan, assembled once and rendered two ways: a human EOB (CLI) or
|
|
10
|
+
// a machine report (--json, for a host agent). No model is called here — this is
|
|
11
|
+
// the advisory core that runs identically standalone or inside a harness.
|
|
12
|
+
|
|
13
|
+
export interface ScanData {
|
|
14
|
+
deps: DepProfile;
|
|
15
|
+
findings: Finding[];
|
|
16
|
+
eob: EOB;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function assembleScan(root: string): Promise<ScanData> {
|
|
20
|
+
const deps = detectDeps(root);
|
|
21
|
+
const findings = partition(triage(root));
|
|
22
|
+
|
|
23
|
+
const fixer = new DryRunFixer();
|
|
24
|
+
for (const f of findings) {
|
|
25
|
+
if (f.fixability !== "needs-llm") continue;
|
|
26
|
+
f.context = buildContext(root, f); // the tight packet the host agent fixes from
|
|
27
|
+
f.resolution = (await fixer.fix(f)).resolution; // routed model = the recommendation
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { deps, findings, eob: buildEOB(root, findings, true) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// --- machine report (the in-harness integration contract) ---
|
|
34
|
+
|
|
35
|
+
type Lane = "local" | "model" | "ignore";
|
|
36
|
+
|
|
37
|
+
export interface ScanReport {
|
|
38
|
+
version: string;
|
|
39
|
+
root: string;
|
|
40
|
+
prices: string;
|
|
41
|
+
deps: { manager: string; count: number };
|
|
42
|
+
eob: EOB;
|
|
43
|
+
findings: Array<{
|
|
44
|
+
id: string;
|
|
45
|
+
source: string;
|
|
46
|
+
rule: string;
|
|
47
|
+
severity: string;
|
|
48
|
+
message: string;
|
|
49
|
+
file: string;
|
|
50
|
+
line: number;
|
|
51
|
+
col: number;
|
|
52
|
+
lane: Lane;
|
|
53
|
+
difficulty?: string;
|
|
54
|
+
recommendedModel?: string;
|
|
55
|
+
context?: { snippet: string; startLine: number };
|
|
56
|
+
}>;
|
|
57
|
+
// What a host agent should do: apply the local lane cheaply; fix the escalate
|
|
58
|
+
// list with its OWN model, using each finding's context — don't crawl the repo.
|
|
59
|
+
advice: {
|
|
60
|
+
autoApply: string[];
|
|
61
|
+
escalate: Array<{ id: string; file: string; line: number; recommendedModel?: string }>;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const laneOf = (f: Finding): Lane => (f.fixability === "autofix" ? "local" : f.fixability === "ignore" ? "ignore" : "model");
|
|
66
|
+
|
|
67
|
+
export function toReport(root: string, prices: string, { deps, findings, eob }: ScanData): ScanReport {
|
|
68
|
+
return {
|
|
69
|
+
version: "0.1",
|
|
70
|
+
root,
|
|
71
|
+
prices,
|
|
72
|
+
deps: { manager: deps.manager, count: Object.keys(deps.deps).length },
|
|
73
|
+
eob,
|
|
74
|
+
findings: findings.map((f) => ({
|
|
75
|
+
id: f.id,
|
|
76
|
+
source: f.source,
|
|
77
|
+
rule: f.rule,
|
|
78
|
+
severity: f.severity,
|
|
79
|
+
message: f.message,
|
|
80
|
+
file: f.file,
|
|
81
|
+
line: f.line,
|
|
82
|
+
col: f.col,
|
|
83
|
+
lane: laneOf(f),
|
|
84
|
+
difficulty: f.difficulty,
|
|
85
|
+
recommendedModel: f.resolution?.model,
|
|
86
|
+
context: f.context ? { snippet: f.context.snippet, startLine: f.context.startLine } : undefined,
|
|
87
|
+
})),
|
|
88
|
+
advice: {
|
|
89
|
+
autoApply: findings.filter((f) => f.fixability === "autofix").map((f) => f.id),
|
|
90
|
+
escalate: findings
|
|
91
|
+
.filter((f) => f.fixability === "needs-llm")
|
|
92
|
+
.map((f) => ({ id: f.id, file: f.file, line: f.line, recommendedModel: f.resolution?.model })),
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import type { Finding } from "../types";
|
|
3
|
+
import { cost } from "../pricing/table";
|
|
4
|
+
import { routeModel } from "./route";
|
|
5
|
+
import type { Fixer, FixResult } from "./fixer";
|
|
6
|
+
|
|
7
|
+
// Live fixer: sends the tight context packet to the routed model, gets back a
|
|
8
|
+
// corrected snippet via structured output, and reports the EXACT token cost from
|
|
9
|
+
// the API's usage. Used by `scan --apply`. The apply loop (in cli.ts) writes the
|
|
10
|
+
// patch and verifies by re-running the source analyzer — a fix is not done until
|
|
11
|
+
// the finding is gone.
|
|
12
|
+
//
|
|
13
|
+
// Reads ANTHROPIC_API_KEY from the environment (standard SDK credential resolution).
|
|
14
|
+
|
|
15
|
+
const SYSTEM =
|
|
16
|
+
"You are a precise code-fixing assistant. You are given a single compiler/linter " +
|
|
17
|
+
"error and the source lines around it. Return a corrected version of EXACTLY those " +
|
|
18
|
+
"lines that resolves the error, changing as little as possible. Preserve indentation " +
|
|
19
|
+
"and surrounding lines verbatim; only change what the error requires.";
|
|
20
|
+
|
|
21
|
+
// Structured-output schema — constrains the model to return the replacement
|
|
22
|
+
// snippet as data, so the apply step never parses prose.
|
|
23
|
+
const SCHEMA = {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
fixedSnippet: { type: "string", description: "the corrected replacement for the provided lines" },
|
|
27
|
+
explanation: { type: "string", description: "one sentence on what changed" },
|
|
28
|
+
},
|
|
29
|
+
required: ["fixedSnippet", "explanation"],
|
|
30
|
+
additionalProperties: false,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export class AnthropicFixer implements Fixer {
|
|
34
|
+
private client: Anthropic;
|
|
35
|
+
|
|
36
|
+
constructor() {
|
|
37
|
+
this.client = new Anthropic();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async fix(finding: Finding): Promise<FixResult> {
|
|
41
|
+
const model = routeModel(finding.difficulty);
|
|
42
|
+
const snippet = finding.context?.snippet ?? "";
|
|
43
|
+
const startLine = finding.context?.startLine ?? finding.line;
|
|
44
|
+
|
|
45
|
+
const user =
|
|
46
|
+
`Error ${finding.rule} at ${finding.file}:${finding.line} — ${finding.message}\n\n` +
|
|
47
|
+
`The lines below start at line ${startLine}. Return a corrected replacement for them.\n` +
|
|
48
|
+
"```\n" +
|
|
49
|
+
snippet +
|
|
50
|
+
"\n```";
|
|
51
|
+
|
|
52
|
+
const res = await this.client.messages.create({
|
|
53
|
+
model,
|
|
54
|
+
max_tokens: 2000,
|
|
55
|
+
system: SYSTEM,
|
|
56
|
+
messages: [{ role: "user", content: user }],
|
|
57
|
+
output_config: { format: { type: "json_schema", schema: SCHEMA } },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const text = res.content.find((b) => b.type === "text")?.text ?? "{}";
|
|
61
|
+
let fixedSnippet = snippet;
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(text) as { fixedSnippet?: string };
|
|
64
|
+
if (typeof parsed.fixedSnippet === "string") fixedSnippet = parsed.fixedSnippet;
|
|
65
|
+
} catch {
|
|
66
|
+
// model returned non-JSON despite the schema — leave the snippet unchanged
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const tokensIn = res.usage.input_tokens;
|
|
70
|
+
const tokensOut = res.usage.output_tokens;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
resolution: {
|
|
74
|
+
model,
|
|
75
|
+
tokensIn,
|
|
76
|
+
tokensOut,
|
|
77
|
+
cost: cost(model, tokensIn, tokensOut), // exact, from real usage
|
|
78
|
+
patched: false,
|
|
79
|
+
verified: false,
|
|
80
|
+
patch: fixedSnippet,
|
|
81
|
+
},
|
|
82
|
+
newSnippet: fixedSnippet,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { Finding } from "../types";
|
|
4
|
+
import type { Fixer } from "./fixer";
|
|
5
|
+
import { triage } from "../triage";
|
|
6
|
+
import { partition } from "../diagnose/partition";
|
|
7
|
+
import { buildContext } from "../diagnose/context";
|
|
8
|
+
|
|
9
|
+
// The live apply loop, decoupled from the CLI and the concrete fixer so it can be
|
|
10
|
+
// driven by a fake fixer in tests (no API key required).
|
|
11
|
+
//
|
|
12
|
+
// Each pass re-triages from scratch, so line shifts introduced by earlier edits
|
|
13
|
+
// are handled automatically. Hardening:
|
|
14
|
+
// - a finding is attempted at most once (by id) → bounded by maxPasses
|
|
15
|
+
// - a no-op patch (unchanged snippet) is skipped, never written
|
|
16
|
+
// - a patch that increases the total finding count is reverted (don't let a
|
|
17
|
+
// "fix" leave the repo worse than it found it)
|
|
18
|
+
// - verified = the finding is gone on the next triage
|
|
19
|
+
|
|
20
|
+
export interface ApplyResult {
|
|
21
|
+
before: Finding[]; // initial snapshot, for the EOB's autofix/ignore buckets
|
|
22
|
+
fixed: Finding[]; // findings the loop escalated, each carrying its resolution
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function runApplyLoop(root: string, fixer: Fixer, maxPasses = 25): Promise<ApplyResult> {
|
|
26
|
+
const before = partition(triage(root));
|
|
27
|
+
const attempted = new Set<string>();
|
|
28
|
+
const fixed: Finding[] = [];
|
|
29
|
+
|
|
30
|
+
for (let pass = 0; pass < maxPasses; pass++) {
|
|
31
|
+
const current = partition(triage(root));
|
|
32
|
+
const totalBefore = current.length;
|
|
33
|
+
const target = current.find((f) => f.fixability === "needs-llm" && !attempted.has(f.id));
|
|
34
|
+
if (!target) break;
|
|
35
|
+
attempted.add(target.id);
|
|
36
|
+
|
|
37
|
+
target.context = buildContext(root, target);
|
|
38
|
+
const { resolution, newSnippet } = await fixer.fix(target);
|
|
39
|
+
target.resolution = resolution;
|
|
40
|
+
fixed.push(target);
|
|
41
|
+
|
|
42
|
+
// No-op patch — nothing to write.
|
|
43
|
+
if (newSnippet === undefined || newSnippet === target.context?.snippet) {
|
|
44
|
+
resolution.patched = false;
|
|
45
|
+
resolution.verified = false;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const path = join(root, target.file);
|
|
50
|
+
const original = readFileSync(path, "utf8");
|
|
51
|
+
writeSnippet(path, target, newSnippet);
|
|
52
|
+
|
|
53
|
+
const after = partition(triage(root));
|
|
54
|
+
if (after.length > totalBefore) {
|
|
55
|
+
// The patch introduced more problems than it solved — revert.
|
|
56
|
+
writeFileSync(path, original);
|
|
57
|
+
resolution.patched = false;
|
|
58
|
+
resolution.verified = false;
|
|
59
|
+
} else {
|
|
60
|
+
resolution.patched = true;
|
|
61
|
+
resolution.verified = !after.some((f) => f.id === target.id);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { before, fixed };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function writeSnippet(path: string, f: Finding, newSnippet: string) {
|
|
69
|
+
const lines = readFileSync(path, "utf8").split("\n");
|
|
70
|
+
const start = (f.context?.startLine ?? f.line) - 1;
|
|
71
|
+
const oldCount = (f.context?.snippet ?? "").split("\n").length;
|
|
72
|
+
lines.splice(start, oldCount, ...newSnippet.split("\n"));
|
|
73
|
+
writeFileSync(path, lines.join("\n"));
|
|
74
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Finding, Resolution } from "../types";
|
|
2
|
+
import { cost, estimateTokens } from "../pricing/table";
|
|
3
|
+
import { routeModel } from "./route";
|
|
4
|
+
|
|
5
|
+
// The seam where a fix is actually performed. Treat routes each escalated
|
|
6
|
+
// finding through a Fixer.
|
|
7
|
+
export interface FixResult {
|
|
8
|
+
resolution: Resolution;
|
|
9
|
+
newSnippet?: string; // present only when the fix can be applied to the file
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Fixer {
|
|
13
|
+
fix(finding: Finding): Promise<FixResult>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Estimating fixer: computes the cost of an escalation from real measured token
|
|
17
|
+
// counts of the real context packet, but does NOT call a model or mutate files.
|
|
18
|
+
// Used by `scan` (the default, read-only path). Every number it produces is
|
|
19
|
+
// honest about being an estimate; the EOB is flagged `estimated`.
|
|
20
|
+
const ASSUMED_OUTPUT_TOKENS = 200; // a typical small patch
|
|
21
|
+
|
|
22
|
+
export class DryRunFixer implements Fixer {
|
|
23
|
+
async fix(finding: Finding): Promise<FixResult> {
|
|
24
|
+
const model = routeModel(finding.difficulty);
|
|
25
|
+
const tokensIn = (finding.context?.tokensEstimate ?? 0) + estimateTokens(finding.message);
|
|
26
|
+
const tokensOut = ASSUMED_OUTPUT_TOKENS;
|
|
27
|
+
return {
|
|
28
|
+
resolution: {
|
|
29
|
+
model,
|
|
30
|
+
tokensIn,
|
|
31
|
+
tokensOut,
|
|
32
|
+
cost: cost(model, tokensIn, tokensOut),
|
|
33
|
+
patched: false,
|
|
34
|
+
verified: false,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { DifficultyClass } from "../types";
|
|
4
|
+
|
|
5
|
+
// Model routing: map a fix's difficulty class to the cheapest model that clears
|
|
6
|
+
// the bar. Declarative and provider-agnostic — the defaults are Anthropic, but a
|
|
7
|
+
// repo can override per class with ANY model id (including OpenRouter-style
|
|
8
|
+
// `openai/…` / `google/…`) via .tokenclinic/routing.json. Pricing resolves
|
|
9
|
+
// whatever id is configured through llm-intel, so other providers work for the
|
|
10
|
+
// cost/EOB/audit layers without touching this file.
|
|
11
|
+
|
|
12
|
+
const DEFAULT: Record<DifficultyClass, string> = {
|
|
13
|
+
mechanical: "claude-haiku-4-5",
|
|
14
|
+
semantic: "claude-sonnet-4-6",
|
|
15
|
+
architectural: "claude-opus-4-8",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
let routing: Record<DifficultyClass, string> = { ...DEFAULT };
|
|
19
|
+
|
|
20
|
+
export function loadRouting(root: string): void {
|
|
21
|
+
routing = { ...DEFAULT };
|
|
22
|
+
const path = join(root, ".tokenclinic", "routing.json");
|
|
23
|
+
if (!existsSync(path)) return;
|
|
24
|
+
try {
|
|
25
|
+
const cfg = JSON.parse(readFileSync(path, "utf8")) as Partial<Record<DifficultyClass, string>>;
|
|
26
|
+
routing = { ...DEFAULT, ...cfg };
|
|
27
|
+
} catch {
|
|
28
|
+
/* malformed config — keep defaults */
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function routeModel(difficulty: DifficultyClass = "semantic"): string {
|
|
33
|
+
return routing[difficulty] ?? DEFAULT[difficulty];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The distinct model ids routing can target — used to warm the price book.
|
|
37
|
+
export function routedModels(): string[] {
|
|
38
|
+
return [...new Set(Object.values(routing))];
|
|
39
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import type { Finding, GeneratedRule } from "../../types";
|
|
4
|
+
import { hash } from "../../util";
|
|
5
|
+
import { runRule } from "../../amortize/sg";
|
|
6
|
+
|
|
7
|
+
// The payoff side of amortization: load the repo's promoted rules and run them
|
|
8
|
+
// on-device. Every match is a $0 finding the model never has to see again.
|
|
9
|
+
|
|
10
|
+
const EXT_BY_LANG: Record<string, string[]> = {
|
|
11
|
+
TypeScript: [".ts"],
|
|
12
|
+
Tsx: [".tsx"],
|
|
13
|
+
JavaScript: [".js", ".mjs", ".cjs"],
|
|
14
|
+
};
|
|
15
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".tokenclinic", "dist", "build"]);
|
|
16
|
+
|
|
17
|
+
export function runAstGrep(root: string): Finding[] {
|
|
18
|
+
const rulesDir = join(root, ".tokenclinic", "rules");
|
|
19
|
+
if (!existsSync(rulesDir)) return [];
|
|
20
|
+
|
|
21
|
+
const rules: GeneratedRule[] = [];
|
|
22
|
+
for (const f of readdirSync(rulesDir)) {
|
|
23
|
+
if (!f.endsWith(".json")) continue;
|
|
24
|
+
try {
|
|
25
|
+
rules.push(JSON.parse(readFileSync(join(rulesDir, f), "utf8")) as GeneratedRule);
|
|
26
|
+
} catch {
|
|
27
|
+
/* skip a corrupt rule file */
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (rules.length === 0) return [];
|
|
31
|
+
|
|
32
|
+
const wantedExts = new Set(rules.flatMap((r) => EXT_BY_LANG[r.language] ?? []));
|
|
33
|
+
const findings: Finding[] = [];
|
|
34
|
+
|
|
35
|
+
for (const file of walk(root)) {
|
|
36
|
+
if (![...wantedExts].some((e) => file.endsWith(e))) continue;
|
|
37
|
+
const code = readFileSync(file, "utf8");
|
|
38
|
+
const rel = relative(root, file);
|
|
39
|
+
|
|
40
|
+
for (const rule of rules) {
|
|
41
|
+
if (!(EXT_BY_LANG[rule.language] ?? []).some((e) => file.endsWith(e))) continue;
|
|
42
|
+
let matches;
|
|
43
|
+
try {
|
|
44
|
+
matches = runRule(rule.language, code, rule.rule);
|
|
45
|
+
} catch {
|
|
46
|
+
continue; // a promoted rule that somehow no longer parses — skip, don't crash a scan
|
|
47
|
+
}
|
|
48
|
+
for (const m of matches) {
|
|
49
|
+
findings.push({
|
|
50
|
+
id: hash("ast-grep", rule.id, rel, m.line, m.col),
|
|
51
|
+
source: `ast-grep:${rule.id}`,
|
|
52
|
+
rule: rule.id,
|
|
53
|
+
severity: rule.severity,
|
|
54
|
+
message: rule.message,
|
|
55
|
+
file: rel,
|
|
56
|
+
line: m.line,
|
|
57
|
+
col: m.col,
|
|
58
|
+
fixability: "autofix", // a promoted rule is, by definition, a $0 on-device check
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return findings;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function* walk(dir: string): Generator<string> {
|
|
68
|
+
for (const entry of readdirSync(dir)) {
|
|
69
|
+
if (SKIP_DIRS.has(entry)) continue;
|
|
70
|
+
const p = join(dir, entry);
|
|
71
|
+
if (statSync(p).isDirectory()) yield* walk(p);
|
|
72
|
+
else yield p;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import type { Finding } from "../../types";
|
|
4
|
+
import { hash } from "../../util";
|
|
5
|
+
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
|
|
8
|
+
// tsc --pretty false emits one diagnostic per line:
|
|
9
|
+
// src/index.ts(3,7): error TS2322: Type 'number' is not assignable to type 'string'.
|
|
10
|
+
const LINE_RE = /^(.+?)\((\d+),(\d+)\): (error|warning) (TS\d+): (.+)$/;
|
|
11
|
+
|
|
12
|
+
// Run the project's type checker and normalize its output into Findings.
|
|
13
|
+
// We resolve tsc from tokenclinic's own node_modules and point it at the target
|
|
14
|
+
// via cwd, so the scanned repo doesn't need typescript installed itself.
|
|
15
|
+
export function runTsc(root: string): Finding[] {
|
|
16
|
+
let tscBin: string;
|
|
17
|
+
try {
|
|
18
|
+
tscBin = require.resolve("typescript/bin/tsc");
|
|
19
|
+
} catch {
|
|
20
|
+
return []; // typescript not installed in tokenclinic — skip this analyzer
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const res = spawnSync(process.execPath, [tscBin, "--noEmit", "--pretty", "false"], {
|
|
24
|
+
cwd: root,
|
|
25
|
+
encoding: "utf8",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const out = `${res.stdout ?? ""}${res.stderr ?? ""}`;
|
|
29
|
+
const findings: Finding[] = [];
|
|
30
|
+
|
|
31
|
+
for (const raw of out.split("\n")) {
|
|
32
|
+
const m = LINE_RE.exec(raw.trim());
|
|
33
|
+
if (!m) continue; // continuation/elaboration lines are skipped in v1
|
|
34
|
+
const [, file, line, col, sev, rule, message] = m;
|
|
35
|
+
findings.push({
|
|
36
|
+
id: hash("tsc", rule, file, line, col),
|
|
37
|
+
source: "tsc",
|
|
38
|
+
rule,
|
|
39
|
+
severity: sev === "error" ? "error" : "warning",
|
|
40
|
+
message,
|
|
41
|
+
file,
|
|
42
|
+
line: Number(line),
|
|
43
|
+
col: Number(col),
|
|
44
|
+
fixability: "needs-llm", // refined in Diagnose/partition
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return findings;
|
|
49
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Finding } from "../types";
|
|
2
|
+
import { runTsc } from "./analyzers/tsc";
|
|
3
|
+
import { runAstGrep } from "./analyzers/astgrep";
|
|
4
|
+
|
|
5
|
+
// Triage: run every applicable on-device analyzer, normalize their output into a
|
|
6
|
+
// single Finding[], and rank by signal (errors before warnings).
|
|
7
|
+
//
|
|
8
|
+
// Each analyzer is a (root) => Finding[] function. runTsc is the native type
|
|
9
|
+
// checker; runAstGrep runs the repo's promoted (amortized) rules on-device — the
|
|
10
|
+
// $0 lane that grows as recurring classes get synthesized into local checks.
|
|
11
|
+
const ANALYZERS: Array<(root: string) => Finding[]> = [runTsc, runAstGrep];
|
|
12
|
+
|
|
13
|
+
const SEVERITY_RANK: Record<string, number> = { error: 0, warning: 1, info: 2 };
|
|
14
|
+
|
|
15
|
+
export function triage(root: string): Finding[] {
|
|
16
|
+
const findings = ANALYZERS.flatMap((run) => run(root));
|
|
17
|
+
return findings.sort(
|
|
18
|
+
(a, b) => (SEVERITY_RANK[a.severity] ?? 9) - (SEVERITY_RANK[b.severity] ?? 9),
|
|
19
|
+
);
|
|
20
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// The single record that flows through every stage of the clinic loop.
|
|
2
|
+
// Triage creates Findings; Diagnose enriches them; Treat resolves them; Bill sums them.
|
|
3
|
+
|
|
4
|
+
export type Severity = "error" | "warning" | "info";
|
|
5
|
+
|
|
6
|
+
// How a finding can be resolved:
|
|
7
|
+
// autofix - deterministically fixable on-device, $0, no model
|
|
8
|
+
// needs-llm - requires a model; escalated with a tight context packet
|
|
9
|
+
// ignore - informational, not worth a fix
|
|
10
|
+
export type Fixability = "autofix" | "needs-llm" | "ignore";
|
|
11
|
+
|
|
12
|
+
// Difficulty class -> drives model routing in Treat.
|
|
13
|
+
export type DifficultyClass = "mechanical" | "semantic" | "architectural";
|
|
14
|
+
|
|
15
|
+
export interface Finding {
|
|
16
|
+
id: string; // stable hash of (source, rule, file, line) so it dedupes across runs
|
|
17
|
+
source: string; // "tsc" | "eslint" | "ast-grep:<rule>" | ...
|
|
18
|
+
rule: string; // e.g. "TS2322"
|
|
19
|
+
severity: Severity;
|
|
20
|
+
message: string;
|
|
21
|
+
file: string; // relative to scan root
|
|
22
|
+
line: number;
|
|
23
|
+
col: number;
|
|
24
|
+
fixability: Fixability;
|
|
25
|
+
difficulty?: DifficultyClass;
|
|
26
|
+
context?: ContextPacket; // assembled only when escalated
|
|
27
|
+
resolution?: Resolution; // filled by Treat
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The tight, pre-assembled packet handed to the model instead of the whole repo.
|
|
31
|
+
export interface ContextPacket {
|
|
32
|
+
snippet: string;
|
|
33
|
+
startLine: number;
|
|
34
|
+
tokensEstimate: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Resolution {
|
|
38
|
+
model: string; // "local" | model id
|
|
39
|
+
tokensIn: number;
|
|
40
|
+
tokensOut: number;
|
|
41
|
+
cost: number; // USD
|
|
42
|
+
patched: boolean;
|
|
43
|
+
verified: boolean; // a fix is not "done" until its source check passes again
|
|
44
|
+
patch?: string; // the corrected snippet that replaces the finding's context lines
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- Audit (Approach A: the retroactive audit over existing call logs) ---
|
|
48
|
+
|
|
49
|
+
// Two-bucket economics (Premise 5):
|
|
50
|
+
// eliminable - reducible to a deterministic rule; could have cost $0 on-device
|
|
51
|
+
// routable - real work, but resolvable on a cheaper model (not eliminable)
|
|
52
|
+
// essential - genuine reasoning; left untouched
|
|
53
|
+
export type Bucket = "eliminable" | "routable" | "essential";
|
|
54
|
+
|
|
55
|
+
// One past LLM call, read from a team's logs / agent traces. `category` is the
|
|
56
|
+
// instrumented/concierge signal — when present it's authoritative; otherwise the
|
|
57
|
+
// call is bucketed heuristically from `task` text (and the result flagged estimated).
|
|
58
|
+
export interface CallRecord {
|
|
59
|
+
model: string;
|
|
60
|
+
inputTokens: number;
|
|
61
|
+
outputTokens: number;
|
|
62
|
+
task?: string;
|
|
63
|
+
category?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface AuditResult {
|
|
67
|
+
calls: number;
|
|
68
|
+
spend: number; // real USD, from tokens × price
|
|
69
|
+
byBucket: Record<Bucket, { count: number; spend: number }>;
|
|
70
|
+
eliminableFraction: number; // share of spend in the eliminable bucket — the bet
|
|
71
|
+
projectedSpend: number; // eliminable→$0, routable→re-priced to cheapest tier, essential→unchanged
|
|
72
|
+
projectedSaved: number;
|
|
73
|
+
estimated: boolean; // true if any call was bucketed heuristically (no category)
|
|
74
|
+
unpriced: number; // calls whose model had no known price (excluded from cost)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- Amortization (v2): synthesize a deterministic check from a recurring class ---
|
|
78
|
+
|
|
79
|
+
// A generated check, stored as DATA not code: an ast-grep rule object plus the
|
|
80
|
+
// fixtures that gate its promotion. Lives in .tokenclinic/rules/<id>.json once it
|
|
81
|
+
// passes validation, or .tokenclinic/quarantine/ if it doesn't.
|
|
82
|
+
export interface GeneratedRule {
|
|
83
|
+
id: string; // kebab-case; also the filename
|
|
84
|
+
language: string; // an ast-grep Lang key, e.g. "TypeScript"
|
|
85
|
+
message: string;
|
|
86
|
+
severity: Severity;
|
|
87
|
+
rule: Record<string, unknown>; // the ast-grep rule object, e.g. { pattern: "..." }
|
|
88
|
+
fix?: string; // optional ast-grep fix template (informational in v2)
|
|
89
|
+
origin?: string; // the analyzer rule this was amortized from, e.g. "TS2304"
|
|
90
|
+
fixtures: {
|
|
91
|
+
positive: string[]; // code the rule MUST flag
|
|
92
|
+
negative: string[]; // similar code the rule must NOT flag
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A group of recurring needs-llm findings of the same shape — the trigger for synthesis.
|
|
97
|
+
export interface Cluster {
|
|
98
|
+
rule: string; // the source rule code shared by the group
|
|
99
|
+
message: string;
|
|
100
|
+
findings: Finding[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Explanation Of Benefits — the screenshot-able receipt.
|
|
104
|
+
export interface EOB {
|
|
105
|
+
total: number;
|
|
106
|
+
fixedLocally: number;
|
|
107
|
+
escalated: number;
|
|
108
|
+
ignored: number;
|
|
109
|
+
spend: number; // USD actually (or, in v1, would-be) spent by the clinic loop
|
|
110
|
+
naiveCost: number; // USD estimate for the naive "dump the file at a top model" approach
|
|
111
|
+
saved: number; // naiveCost - spend
|
|
112
|
+
byModel: Record<string, { count: number; cost: number }>;
|
|
113
|
+
estimated: boolean; // true while the LLM step is simulated (no key wired)
|
|
114
|
+
}
|
package/src/util.ts
ADDED
|
Binary file
|