cachelint 0.1.0
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/LICENSE +27 -0
- package/LICENSE-PRO.md +67 -0
- package/README.md +298 -0
- package/dist/check-2ONJCYKP.js +23 -0
- package/dist/chunk-2JSY5BQA.js +340 -0
- package/dist/chunk-4GHSUBAY.js +28 -0
- package/dist/chunk-4LB5LF2P.js +51 -0
- package/dist/chunk-BUZCVKMZ.js +246 -0
- package/dist/chunk-DYQPXD3G.js +92 -0
- package/dist/chunk-GXASKZ4Z.js +182 -0
- package/dist/chunk-JZU4PCTS.js +80 -0
- package/dist/chunk-MW7BXKPR.js +593 -0
- package/dist/chunk-O7ZE6CFI.js +32 -0
- package/dist/chunk-SQ4IAMTX.js +65 -0
- package/dist/chunk-UUVSBJ62.js +77 -0
- package/dist/chunk-VWE5TDL5.js +91 -0
- package/dist/chunk-WKFGRNYK.js +494 -0
- package/dist/chunk-WPJYUQMU.js +26 -0
- package/dist/chunk-XKRPGMZT.js +197 -0
- package/dist/chunk-ZSEZQ422.js +286 -0
- package/dist/cli.d.ts +46 -0
- package/dist/cli.js +199 -0
- package/dist/gate-63NKQRXL.js +16 -0
- package/dist/hash-XUTGK6SB.js +15 -0
- package/dist/index.d.ts +1200 -0
- package/dist/index.js +184 -0
- package/dist/license-GFDBTQHG.js +19 -0
- package/dist/lint-PQHIDWOY.js +19 -0
- package/dist/sarif-EQ6VJNGN.js +63 -0
- package/dist/step-summary-NKD4N7AY.js +160 -0
- package/dist/store-KZIQANIT.js +14 -0
- package/package.json +85 -0
- package/schema/cachelint.config.schema.json +114 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ExactCountUnavailableError
|
|
3
|
+
} from "./chunk-XKRPGMZT.js";
|
|
4
|
+
|
|
5
|
+
// src/exact/anthropic.ts
|
|
6
|
+
var DEFAULT_EXACT_LIMIT = 100;
|
|
7
|
+
var DEFAULT_EXACT_MODEL = "claude-opus-4-7";
|
|
8
|
+
var memo = /* @__PURE__ */ new Map();
|
|
9
|
+
async function countTokensExact(requests, limit = DEFAULT_EXACT_LIMIT) {
|
|
10
|
+
const apiKey = process.env["ANTHROPIC_API_KEY"];
|
|
11
|
+
if (apiKey === void 0 || apiKey === "") {
|
|
12
|
+
throw new ExactCountUnavailableError(
|
|
13
|
+
"`--exact` needs ANTHROPIC_API_KEY to call the count-tokens API",
|
|
14
|
+
"Export ANTHROPIC_API_KEY (your own key \u2014 cachelint never sees it), or drop --exact for the estimate."
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
let SDK;
|
|
18
|
+
try {
|
|
19
|
+
SDK = (await import("@anthropic-ai/sdk")).default;
|
|
20
|
+
} catch {
|
|
21
|
+
throw new ExactCountUnavailableError(
|
|
22
|
+
"`--exact` needs the optional dependency @anthropic-ai/sdk",
|
|
23
|
+
"Install it: `npm i @anthropic-ai/sdk`. (It's an optional peer dep so it isn't installed by default.)"
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
const AnthropicCtor = SDK;
|
|
27
|
+
let client;
|
|
28
|
+
try {
|
|
29
|
+
client = new AnthropicCtor({ apiKey, timeout: 3e4, maxRetries: 2 });
|
|
30
|
+
} catch (err) {
|
|
31
|
+
throw new ExactCountUnavailableError(
|
|
32
|
+
`could not initialize the @anthropic-ai/sdk client: ${err instanceof Error ? err.message : String(err)}`,
|
|
33
|
+
"Check the ANTHROPIC_API_KEY format, or drop --exact for the estimate."
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
const out = [];
|
|
37
|
+
let calls = 0;
|
|
38
|
+
let skippedDueToCap = 0;
|
|
39
|
+
for (const req of requests) {
|
|
40
|
+
const key = `${req.model}\0${req.sha256}`;
|
|
41
|
+
const cached = memo.get(key);
|
|
42
|
+
if (cached !== void 0) {
|
|
43
|
+
out.push({ sha256: req.sha256, model: req.model, tokens: cached });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (calls >= limit) {
|
|
47
|
+
skippedDueToCap++;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
calls++;
|
|
51
|
+
let resp;
|
|
52
|
+
try {
|
|
53
|
+
resp = await client.messages.countTokens({ model: req.model, messages: [{ role: "user", content: req.text }] });
|
|
54
|
+
} catch (err) {
|
|
55
|
+
throw new ExactCountUnavailableError(
|
|
56
|
+
`the Anthropic count-tokens API call failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
57
|
+
"Retry later, or drop --exact for the estimate. (cachelint never logs your API key.)"
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
const tokens = typeof resp.input_tokens === "number" ? resp.input_tokens : 0;
|
|
61
|
+
memo.set(key, tokens);
|
|
62
|
+
out.push({ sha256: req.sha256, model: req.model, tokens });
|
|
63
|
+
}
|
|
64
|
+
if (skippedDueToCap > 0) {
|
|
65
|
+
process.stderr.write(
|
|
66
|
+
`cachelint: --exact reached the per-run cap (${limit}); ${skippedDueToCap} file(s) were not counted. Raise --exact-limit to count more.
|
|
67
|
+
`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export {
|
|
74
|
+
DEFAULT_EXACT_LIMIT,
|
|
75
|
+
DEFAULT_EXACT_MODEL,
|
|
76
|
+
countTokensExact
|
|
77
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// src/cost/format.ts
|
|
2
|
+
var MIN_RENDERABLE_USD_PER_1K = 0.01;
|
|
3
|
+
function formatUsdPer1k(usd) {
|
|
4
|
+
if (!Number.isFinite(usd) || usd <= 0 || usd < MIN_RENDERABLE_USD_PER_1K) return null;
|
|
5
|
+
return `$${Number(usd.toPrecision(2))}`;
|
|
6
|
+
}
|
|
7
|
+
function costLabel(pricing, asOf) {
|
|
8
|
+
return `est. if caching is enabled, ${pricing.model} pricing, ${asOf}`;
|
|
9
|
+
}
|
|
10
|
+
function formatTokens(n) {
|
|
11
|
+
return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
12
|
+
}
|
|
13
|
+
function costLineInvalidates(fromHereTokens) {
|
|
14
|
+
return `\u2192 invalidates ~${formatTokens(fromHereTokens)} stable prefix tokens from here`;
|
|
15
|
+
}
|
|
16
|
+
function costLineWasted(usd, label) {
|
|
17
|
+
return `\u2248 ${usd} wasted per 1,000 calls (${label})`;
|
|
18
|
+
}
|
|
19
|
+
var COST_LINE_OVERLAPPED = "(overlapped by the finding above \u2014 no additional waste)";
|
|
20
|
+
function costLineAtRisk(fromHereTokens) {
|
|
21
|
+
return `~${formatTokens(fromHereTokens)} prefix tokens at risk (byte-stable \u2014 changes on edit/regeneration, not per call)`;
|
|
22
|
+
}
|
|
23
|
+
function costSummary(totals, label) {
|
|
24
|
+
if (totals.wastedTokens <= 0) return null;
|
|
25
|
+
const usd = formatUsdPer1k(totals.wastedUsdPer1kCalls);
|
|
26
|
+
const atRisk = totals.atRiskTokens > 0 ? ` \xB7 ~${formatTokens(totals.atRiskTokens)} prefix tokens at risk` : "";
|
|
27
|
+
if (usd === null) {
|
|
28
|
+
return `est. waste ~${formatTokens(totals.wastedTokens)} stable prefix tokens per call (under $0.01 per 1,000 calls)${atRisk}`;
|
|
29
|
+
}
|
|
30
|
+
return `est. waste \u2248 ${usd} per 1,000 calls${atRisk} (${label})`;
|
|
31
|
+
}
|
|
32
|
+
function receiptProtectedLine(tokens, engagedBundles, usd, label) {
|
|
33
|
+
const noun = engagedBundles === 1 ? "bundle" : "bundles";
|
|
34
|
+
const worth = usd === null ? "worth under $0.01 per 1,000 calls" : `worth at most ${usd} per 1,000 calls`;
|
|
35
|
+
return `protected \u2014 ~${formatTokens(tokens)} stable prefix tokens across ${engagedBundles} ${noun}, ${worth} (${label})`;
|
|
36
|
+
}
|
|
37
|
+
function receiptWontEngageLine(name, tokens, minimumTokens) {
|
|
38
|
+
return `bundle "${name}": caching won't engage yet (~${formatTokens(tokens)} tokens < ${formatTokens(minimumTokens)}-token minimum)`;
|
|
39
|
+
}
|
|
40
|
+
function receiptAllBelowMinimumLine(minimumTokens, minimumSource) {
|
|
41
|
+
return `all bundles are below the cacheable minimum (${formatTokens(minimumTokens)} tokens, ${minimumSource}) \u2014 caching won't engage yet`;
|
|
42
|
+
}
|
|
43
|
+
function receiptWarnQualifier(totals) {
|
|
44
|
+
const parts = [];
|
|
45
|
+
if (totals.wastedTokens > 0) {
|
|
46
|
+
const usd = formatUsdPer1k(totals.wastedUsdPer1kCalls);
|
|
47
|
+
parts.push(
|
|
48
|
+
usd === null ? `est. waste ~${formatTokens(totals.wastedTokens)} stable prefix tokens per call (under $0.01 per 1,000 calls)` : `est. waste \u2248 ${usd} per 1,000 calls`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (totals.atRiskTokens > 0) parts.push(`~${formatTokens(totals.atRiskTokens)} prefix tokens at risk`);
|
|
52
|
+
if (parts.length === 0) return null;
|
|
53
|
+
return `of that, ${parts.join(" \xB7 ")} \u2014 see the warnings above`;
|
|
54
|
+
}
|
|
55
|
+
function buildReceiptLines(cost, label) {
|
|
56
|
+
const receipt = cost.receipt;
|
|
57
|
+
const engaged = receipt.bundles.filter((b) => b.engaged).length;
|
|
58
|
+
const qualifier = receiptWarnQualifier(cost.totals);
|
|
59
|
+
const out = [];
|
|
60
|
+
if (engaged > 0) {
|
|
61
|
+
out.push(receiptProtectedLine(receipt.protectedTokens, engaged, formatUsdPer1k(receipt.protectedUsdPer1kCalls), label));
|
|
62
|
+
if (qualifier !== null) out.push(qualifier);
|
|
63
|
+
}
|
|
64
|
+
for (const b of receipt.bundles) {
|
|
65
|
+
if (!b.engaged) out.push(receiptWontEngageLine(b.name, b.protectedTokens, receipt.engagementMinimumTokens));
|
|
66
|
+
}
|
|
67
|
+
if (engaged === 0) {
|
|
68
|
+
out.push(receiptAllBelowMinimumLine(receipt.engagementMinimumTokens, receipt.engagementMinimumSource));
|
|
69
|
+
if (qualifier !== null) out.push(qualifier);
|
|
70
|
+
}
|
|
71
|
+
if (receipt.exactFallbackReason !== void 0) out.push(receipt.exactFallbackReason);
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
var RECEIPT_EXACT_FALLBACK_LINE = "--exact did not cover every bundle file (call cap reached, or content changed during the run) \u2014 affected bundles use the chars/4 estimate";
|
|
75
|
+
|
|
76
|
+
export {
|
|
77
|
+
formatUsdPer1k,
|
|
78
|
+
costLabel,
|
|
79
|
+
formatTokens,
|
|
80
|
+
costLineInvalidates,
|
|
81
|
+
costLineWasted,
|
|
82
|
+
COST_LINE_OVERLAPPED,
|
|
83
|
+
costLineAtRisk,
|
|
84
|
+
costSummary,
|
|
85
|
+
receiptProtectedLine,
|
|
86
|
+
receiptWontEngageLine,
|
|
87
|
+
receiptAllBelowMinimumLine,
|
|
88
|
+
receiptWarnQualifier,
|
|
89
|
+
buildReceiptLines,
|
|
90
|
+
RECEIPT_EXACT_FALLBACK_LINE
|
|
91
|
+
};
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_RULE_SEVERITY,
|
|
3
|
+
DEFAULT_RULE_VOLATILITY,
|
|
4
|
+
RULE_IDS
|
|
5
|
+
} from "./chunk-4GHSUBAY.js";
|
|
6
|
+
import {
|
|
7
|
+
RuleError
|
|
8
|
+
} from "./chunk-XKRPGMZT.js";
|
|
9
|
+
|
|
10
|
+
// src/rules/text-utils.ts
|
|
11
|
+
function splitLines(text) {
|
|
12
|
+
if (text === "") return [""];
|
|
13
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
14
|
+
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
|
15
|
+
return lines;
|
|
16
|
+
}
|
|
17
|
+
function lineColAt(text, index) {
|
|
18
|
+
let line = 1;
|
|
19
|
+
let lastBreak = -1;
|
|
20
|
+
for (let i = 0; i < index && i < text.length; i++) {
|
|
21
|
+
if (text.charCodeAt(i) === 10) {
|
|
22
|
+
line++;
|
|
23
|
+
lastBreak = i;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { line, column: index - lastBreak };
|
|
27
|
+
}
|
|
28
|
+
function snippetOf(line, max = 120) {
|
|
29
|
+
const t = line.trim();
|
|
30
|
+
return t.length <= max ? t : t.slice(0, max - 1) + "\u2026";
|
|
31
|
+
}
|
|
32
|
+
function* matchesWithLocation(text, re) {
|
|
33
|
+
if (!re.global) throw new Error("matchesWithLocation requires a global regex");
|
|
34
|
+
const lines = splitLines(text);
|
|
35
|
+
re.lastIndex = 0;
|
|
36
|
+
let m;
|
|
37
|
+
while ((m = re.exec(text)) !== null) {
|
|
38
|
+
const { line, column } = lineColAt(text, m.index);
|
|
39
|
+
yield { match: m, line, column, sourceLine: lines[line - 1] ?? "" };
|
|
40
|
+
if (m.index === re.lastIndex) re.lastIndex++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/rules/pragmas.ts
|
|
45
|
+
var MARKER = "cachelint-disable-next-line";
|
|
46
|
+
var ID_TOKEN_RE = /R\d{3}|\*/g;
|
|
47
|
+
function computePragmaExemptions(raw) {
|
|
48
|
+
const out = /* @__PURE__ */ new Map();
|
|
49
|
+
const lines = splitLines(raw);
|
|
50
|
+
for (let i = 0; i < lines.length; i++) {
|
|
51
|
+
const line = lines[i] ?? "";
|
|
52
|
+
if (!line.includes(MARKER)) continue;
|
|
53
|
+
const segments = line.split(MARKER);
|
|
54
|
+
const exemptLine = i + 2;
|
|
55
|
+
const set = out.get(exemptLine) ?? /* @__PURE__ */ new Set();
|
|
56
|
+
for (let s = 1; s < segments.length; s++) {
|
|
57
|
+
const rest = stripCommentClose(segments[s] ?? "");
|
|
58
|
+
const tokens = rest.match(ID_TOKEN_RE) ?? [];
|
|
59
|
+
const ids = tokens.length > 0 ? tokens : ["*"];
|
|
60
|
+
for (const id of ids) set.add(id);
|
|
61
|
+
}
|
|
62
|
+
out.set(exemptLine, set);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
function stripCommentClose(s) {
|
|
67
|
+
const close = Math.min(indexOrInfinity(s, "-->"), indexOrInfinity(s, "*/"));
|
|
68
|
+
return close === Infinity ? s : s.slice(0, close);
|
|
69
|
+
}
|
|
70
|
+
function indexOrInfinity(s, needle) {
|
|
71
|
+
const i = s.indexOf(needle);
|
|
72
|
+
return i === -1 ? Infinity : i;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/rules/R001-interpolated-timestamp.ts
|
|
76
|
+
var PATTERNS = [
|
|
77
|
+
{
|
|
78
|
+
re: /\{\{[~#/]?\s*(?:now|today|date|datetime|timestamp|moment|current[_-]?date(?:[_-]?time)?|current[_-]?time|iso[_-]?now|now[_-]?iso)\b[^}]*\}\}/gi,
|
|
79
|
+
message: "template helper injects the current date/time into the prompt",
|
|
80
|
+
fixHint: "Remove the time helper, or move it after the last cache breakpoint.",
|
|
81
|
+
volatility: "per-call"
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
re: /\$\{[^}]*\b(?:new\s+Date\b|Date\.now\s*\(\s*\)|performance\.now\s*\(\s*\))[^}]*\}/g,
|
|
85
|
+
message: "template literal interpolates a JavaScript timestamp",
|
|
86
|
+
fixHint: "Compute the timestamp outside the cached prefix, or drop it.",
|
|
87
|
+
volatility: "per-call"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
re: /\{[^{}\n]*\b(?:datetime|date|time)\s*\.\s*(?:now|today|utcnow)\s*\([^)]*\)[^{}\n]*\}/g,
|
|
91
|
+
message: "f-string/format placeholder injects a Python timestamp",
|
|
92
|
+
fixHint: "Build the timestamp before the cached prefix, or remove it.",
|
|
93
|
+
volatility: "per-call"
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
re: /\bnew\s+Date\s*\([^)]*\)/g,
|
|
97
|
+
message: "`new Date(...)` in prompt content \u2014 likely injects the current time",
|
|
98
|
+
fixHint: "If this must vary, keep it after the last cache_control breakpoint.",
|
|
99
|
+
volatility: "per-call"
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
re: /\bDate\.now\s*\(\s*\)/g,
|
|
103
|
+
message: "`Date.now()` in prompt content \u2014 injects the current epoch millis",
|
|
104
|
+
fixHint: "Move it out of the stable prefix, or hardcode a fixed value.",
|
|
105
|
+
volatility: "per-call"
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
re: /\b(?:datetime\.datetime\.(?:now|utcnow)|datetime\.now|datetime\.utcnow|time\.time)\s*\([^)]*\)/g,
|
|
109
|
+
message: "Python `datetime.now()` / `time.time()` in prompt content",
|
|
110
|
+
fixHint: "Compute it before the cached prefix, or remove it.",
|
|
111
|
+
volatility: "per-call"
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
re: /\b(?:Current\s+Date(?:\s*&\s*Time)?|Current\s+date(?:\s*\/\s*time)?|Today'?s?\s+date|As\s+of(?:\s+date)?|Last\s+updated|Generated\s+(?:on|at))\s*[:=]\s*\S/gi,
|
|
115
|
+
message: "a \u201Ccurrent date/time\u201D label in the prompt body \u2014 usually filled at edit time, breaking the cache",
|
|
116
|
+
fixHint: "Drop the line, or move it below the last cache breakpoint.",
|
|
117
|
+
volatility: "per-call"
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
re: /\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\b/g,
|
|
121
|
+
message: "hardcoded ISO-8601 timestamp \u2014 if this is \u201Cnow\u201D it changes every time you regenerate the file",
|
|
122
|
+
fixHint: "If it's meant to be dynamic, don't bake it into the cached prefix; if it's truly fixed, ignore this warning.",
|
|
123
|
+
volatility: "regeneration"
|
|
124
|
+
}
|
|
125
|
+
];
|
|
126
|
+
var ruleR001 = {
|
|
127
|
+
id: "R001",
|
|
128
|
+
defaultSeverity: "warn",
|
|
129
|
+
volatility: "per-call",
|
|
130
|
+
title: "Interpolated timestamp in a cached prompt",
|
|
131
|
+
match(raw, _ctx) {
|
|
132
|
+
const seen = /* @__PURE__ */ new Set();
|
|
133
|
+
const out = [];
|
|
134
|
+
for (const { re, message, fixHint, volatility } of PATTERNS) {
|
|
135
|
+
for (const { line, column, sourceLine } of matchesWithLocation(raw, re)) {
|
|
136
|
+
const key = `${line}:${column}`;
|
|
137
|
+
if (seen.has(key)) continue;
|
|
138
|
+
seen.add(key);
|
|
139
|
+
out.push({ line, column, message, fixHint, snippet: snippetOf(sourceLine), volatility });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// src/rules/R002-interpolated-uuid-or-random.ts
|
|
147
|
+
var PATTERNS2 = [
|
|
148
|
+
{
|
|
149
|
+
re: /\bcrypto\.randomUUID\s*\(\s*\)/g,
|
|
150
|
+
message: "`crypto.randomUUID()` in prompt content \u2014 a fresh UUID every call",
|
|
151
|
+
fixHint: "Generate the id outside the cached prefix (or after the last breakpoint)."
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
re: /\bMath\.random\s*\(\s*\)/g,
|
|
155
|
+
message: "`Math.random()` in prompt content \u2014 non-deterministic",
|
|
156
|
+
fixHint: "Move it out of the stable prefix, or remove it."
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
re: /\buuid(?:v[1345]|V[1345])\s*\(/g,
|
|
160
|
+
message: "`uuidv4()` (or similar) in prompt content \u2014 a fresh UUID every call",
|
|
161
|
+
fixHint: "Compute the id before the cached prefix."
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
re: /\buuid\s*\.\s*(?:v[1345]|uuid[1345])\s*\(/g,
|
|
165
|
+
message: "`uuid.v4()` / `uuid.uuid4()` in prompt content \u2014 non-deterministic",
|
|
166
|
+
fixHint: "Generate it outside the cached prefix."
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
re: /\bnanoid\s*\([^)]*\)/g,
|
|
170
|
+
message: "`nanoid()` in prompt content \u2014 random id every call",
|
|
171
|
+
fixHint: "Move it out of the stable prefix."
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
re: /\bsecrets\.token_(?:hex|urlsafe|bytes)\s*\([^)]*\)/g,
|
|
175
|
+
message: "Python `secrets.token_*()` in prompt content \u2014 random every call",
|
|
176
|
+
fixHint: "Compute it before the cached prefix."
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
re: /\bos\.urandom\s*\([^)]*\)/g,
|
|
180
|
+
message: "Python `os.urandom()` in prompt content \u2014 random bytes every call",
|
|
181
|
+
fixHint: "Move it out of the stable prefix."
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
re: /\$\{[^}]*\b(?:randomUUID|Math\.random|nanoid|uuidv?[1345])\b[^}]*\}/g,
|
|
185
|
+
message: "template literal interpolates a random value",
|
|
186
|
+
fixHint: "Compute the value outside the cached prefix."
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
re: /\{\{[~#/]?\s*(?:uuid|guid|random|nanoid|request[_-]?id|requestId|correlation[_-]?id|correlationId|trace[_-]?id|traceId|nonce|session[_-]?id|sessionId)\b[^}]*\}\}/gi,
|
|
190
|
+
message: "template helper injects a per-request id/random value into the prompt",
|
|
191
|
+
fixHint: "Drop the helper, or move it after the last cache breakpoint."
|
|
192
|
+
}
|
|
193
|
+
];
|
|
194
|
+
var ruleR002 = {
|
|
195
|
+
id: "R002",
|
|
196
|
+
defaultSeverity: "warn",
|
|
197
|
+
volatility: "per-call",
|
|
198
|
+
title: "Interpolated UUID / random value in a cached prompt",
|
|
199
|
+
match(raw, _ctx) {
|
|
200
|
+
const seen = /* @__PURE__ */ new Set();
|
|
201
|
+
const out = [];
|
|
202
|
+
for (const { re, message, fixHint } of PATTERNS2) {
|
|
203
|
+
for (const { line, column, sourceLine } of matchesWithLocation(raw, re)) {
|
|
204
|
+
const key = `${line}:${column}`;
|
|
205
|
+
if (seen.has(key)) continue;
|
|
206
|
+
seen.add(key);
|
|
207
|
+
out.push({ line, column, message, fixHint, snippet: snippetOf(sourceLine) });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// src/rules/R003-unsorted-json-stringify.ts
|
|
215
|
+
import { parse as acornParse } from "acorn";
|
|
216
|
+
import { simple as walkSimple } from "acorn-walk";
|
|
217
|
+
var JS_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".cjs", ".mjs", ".jsx"]);
|
|
218
|
+
var MESSAGE = "`JSON.stringify(...)` without a key-sorting replacer \u2014 output key order isn't guaranteed stable";
|
|
219
|
+
var FIX_HINT = "Pass a replacer that sorts keys, e.g. JSON.stringify(obj, Object.keys(obj).sort()), or a recursive stable-stringify.";
|
|
220
|
+
var ruleR003 = {
|
|
221
|
+
id: "R003",
|
|
222
|
+
defaultSeverity: "warn",
|
|
223
|
+
// "regeneration", deliberately conservative: unsorted stringify is a
|
|
224
|
+
// fragility/risk — output is often byte-stable run-to-run, so pricing it as
|
|
225
|
+
// per-call waste would overstate.
|
|
226
|
+
volatility: "regeneration",
|
|
227
|
+
title: "Unsorted JSON.stringify in a cached prompt",
|
|
228
|
+
match(raw, ctx) {
|
|
229
|
+
if (!ctx.promptScoped) return [];
|
|
230
|
+
if (JS_EXTENSIONS.has(ctx.ext)) {
|
|
231
|
+
const viaAst = tryAst(raw);
|
|
232
|
+
if (viaAst !== null) return viaAst;
|
|
233
|
+
}
|
|
234
|
+
return viaRegex(raw);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
function tryAst(raw) {
|
|
238
|
+
let ast;
|
|
239
|
+
try {
|
|
240
|
+
ast = acornParse(raw, {
|
|
241
|
+
ecmaVersion: "latest",
|
|
242
|
+
sourceType: "module",
|
|
243
|
+
locations: true,
|
|
244
|
+
allowHashBang: true,
|
|
245
|
+
allowReturnOutsideFunction: true,
|
|
246
|
+
allowAwaitOutsideFunction: true
|
|
247
|
+
});
|
|
248
|
+
} catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
const lines = splitLines(raw);
|
|
252
|
+
const out = [];
|
|
253
|
+
walkSimple(ast, {
|
|
254
|
+
CallExpression(rawNode) {
|
|
255
|
+
const node = rawNode;
|
|
256
|
+
const callee = node["callee"];
|
|
257
|
+
if (callee && callee.type === "MemberExpression" && callee["computed"] === false && callee["object"]?.type === "Identifier" && callee["object"].name === "JSON" && callee["property"]?.type === "Identifier" && callee["property"].name === "stringify") {
|
|
258
|
+
const args = node["arguments"];
|
|
259
|
+
const argCount = Array.isArray(args) ? args.length : 0;
|
|
260
|
+
if (argCount < 2) {
|
|
261
|
+
const loc = node.loc?.start ?? { line: 1, column: 0 };
|
|
262
|
+
out.push({
|
|
263
|
+
line: loc.line,
|
|
264
|
+
column: loc.column + 1,
|
|
265
|
+
// acorn columns are 0-based
|
|
266
|
+
message: MESSAGE,
|
|
267
|
+
fixHint: FIX_HINT,
|
|
268
|
+
snippet: snippetOf(lines[loc.line - 1] ?? "")
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
var CALL_RE = /\bJSON\.stringify\s*\(([^()]*)\)/g;
|
|
277
|
+
function viaRegex(raw) {
|
|
278
|
+
const out = [];
|
|
279
|
+
for (const { match, line, column, sourceLine } of matchesWithLocation(raw, CALL_RE)) {
|
|
280
|
+
const args = match[1] ?? "";
|
|
281
|
+
if (hasTopLevelComma(args)) continue;
|
|
282
|
+
out.push({ line, column, message: MESSAGE, fixHint: FIX_HINT, snippet: snippetOf(sourceLine) });
|
|
283
|
+
}
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
function hasTopLevelComma(s) {
|
|
287
|
+
let depth = 0;
|
|
288
|
+
let quote = null;
|
|
289
|
+
for (let i = 0; i < s.length; i++) {
|
|
290
|
+
const c = s[i];
|
|
291
|
+
if (quote !== null) {
|
|
292
|
+
if (c === "\\") {
|
|
293
|
+
i++;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (c === quote) quote = null;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
300
|
+
quote = c;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (c === "[" || c === "{") depth++;
|
|
304
|
+
else if (c === "]" || c === "}") depth = Math.max(0, depth - 1);
|
|
305
|
+
else if (c === "," && depth === 0) return true;
|
|
306
|
+
}
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// src/rules/R004-bom-byte.ts
|
|
311
|
+
var ruleR004 = {
|
|
312
|
+
id: "R004",
|
|
313
|
+
defaultSeverity: "error",
|
|
314
|
+
volatility: "regeneration",
|
|
315
|
+
title: "Leading UTF-8 BOM in a prompt file",
|
|
316
|
+
match(raw, _ctx) {
|
|
317
|
+
if (raw.length === 0 || raw.charCodeAt(0) !== 65279) return [];
|
|
318
|
+
return [
|
|
319
|
+
{
|
|
320
|
+
line: 1,
|
|
321
|
+
column: 1,
|
|
322
|
+
message: "file begins with a UTF-8 BOM (U+FEFF) \u2014 an invisible character at the head of the prompt",
|
|
323
|
+
fixHint: "Re-save the file as UTF-8 *without* BOM (most editors have a 'Save with Encoding' option)."
|
|
324
|
+
}
|
|
325
|
+
];
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// src/rules/R005-mixed-line-endings.ts
|
|
330
|
+
var ruleR005 = {
|
|
331
|
+
id: "R005",
|
|
332
|
+
defaultSeverity: "error",
|
|
333
|
+
volatility: "regeneration",
|
|
334
|
+
title: "Mixed line endings in a prompt file",
|
|
335
|
+
match(raw, _ctx) {
|
|
336
|
+
let line = 1;
|
|
337
|
+
let firstKind = null;
|
|
338
|
+
for (let i = 0; i < raw.length; i++) {
|
|
339
|
+
const c = raw.charCodeAt(i);
|
|
340
|
+
let kind = null;
|
|
341
|
+
if (c === 13) {
|
|
342
|
+
if (raw.charCodeAt(i + 1) === 10) {
|
|
343
|
+
kind = "crlf";
|
|
344
|
+
i++;
|
|
345
|
+
} else {
|
|
346
|
+
kind = "cr";
|
|
347
|
+
}
|
|
348
|
+
} else if (c === 10) {
|
|
349
|
+
kind = "lf";
|
|
350
|
+
}
|
|
351
|
+
if (kind !== null) {
|
|
352
|
+
if (firstKind === null) {
|
|
353
|
+
firstKind = kind;
|
|
354
|
+
} else if (kind !== firstKind) {
|
|
355
|
+
return [
|
|
356
|
+
{
|
|
357
|
+
line,
|
|
358
|
+
column: 1,
|
|
359
|
+
message: `mixed line endings \u2014 file uses ${labelOf(firstKind)} elsewhere but this line ends with ${labelOf(kind)}`,
|
|
360
|
+
fixHint: "Normalize the file to LF (e.g. `.gitattributes`: `* text=auto eol=lf`, then re-checkout)."
|
|
361
|
+
}
|
|
362
|
+
];
|
|
363
|
+
}
|
|
364
|
+
line++;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return [];
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
function labelOf(e) {
|
|
371
|
+
return e === "crlf" ? "CRLF" : e === "lf" ? "LF" : "bare CR";
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// src/rules/index.ts
|
|
375
|
+
var RULE_DOCS_BASE = "https://github.com/davioe/cachelint/blob/main/docs/rules/";
|
|
376
|
+
var REGISTRY = Object.freeze([
|
|
377
|
+
ruleR001,
|
|
378
|
+
ruleR002,
|
|
379
|
+
ruleR003,
|
|
380
|
+
ruleR004,
|
|
381
|
+
ruleR005
|
|
382
|
+
]);
|
|
383
|
+
(() => {
|
|
384
|
+
const got = REGISTRY.map((r) => r.id);
|
|
385
|
+
const want = [...RULE_IDS];
|
|
386
|
+
const sameOrder = got.length === want.length && got.every((id, i) => id === want[i]);
|
|
387
|
+
if (!sameOrder) {
|
|
388
|
+
throw new RuleError({
|
|
389
|
+
code: "RULE_REGISTRY_MISMATCH",
|
|
390
|
+
message: `rule registry [${got.join(", ")}] does not match the id allow-list [${want.join(", ")}]`,
|
|
391
|
+
hint: "Keep src/rules/ids.ts (RULE_IDS) and src/rules/index.ts (REGISTRY) in sync."
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
for (const r of REGISTRY) {
|
|
395
|
+
if (r.defaultSeverity !== DEFAULT_RULE_SEVERITY[r.id]) {
|
|
396
|
+
throw new RuleError({
|
|
397
|
+
code: "RULE_SEVERITY_MISMATCH",
|
|
398
|
+
message: `rule ${r.id} default severity ${r.defaultSeverity} != ids.ts ${DEFAULT_RULE_SEVERITY[r.id]}`,
|
|
399
|
+
ruleId: r.id
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
if (r.volatility !== DEFAULT_RULE_VOLATILITY[r.id]) {
|
|
403
|
+
throw new RuleError({
|
|
404
|
+
code: "RULE_VOLATILITY_MISMATCH",
|
|
405
|
+
message: `rule ${r.id} volatility ${r.volatility} != ids.ts ${DEFAULT_RULE_VOLATILITY[r.id]}`,
|
|
406
|
+
ruleId: r.id
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
})();
|
|
411
|
+
function allRules() {
|
|
412
|
+
return REGISTRY;
|
|
413
|
+
}
|
|
414
|
+
function getRule(id) {
|
|
415
|
+
return REGISTRY.find((r) => r.id === id);
|
|
416
|
+
}
|
|
417
|
+
function ruleDocsUrl(id) {
|
|
418
|
+
return `${RULE_DOCS_BASE}${id}.md`;
|
|
419
|
+
}
|
|
420
|
+
function resolveSeverities(settings = {}) {
|
|
421
|
+
const disabled = new Set(settings.disable ?? []);
|
|
422
|
+
const out = {};
|
|
423
|
+
for (const id of RULE_IDS) {
|
|
424
|
+
if (disabled.has(id)) {
|
|
425
|
+
out[id] = null;
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const override = settings.rules?.[id];
|
|
429
|
+
if (override === "off") out[id] = null;
|
|
430
|
+
else if (override === "warn" || override === "error") out[id] = override;
|
|
431
|
+
else out[id] = DEFAULT_RULE_SEVERITY[id];
|
|
432
|
+
}
|
|
433
|
+
return out;
|
|
434
|
+
}
|
|
435
|
+
function runRules(sources, opts = {}) {
|
|
436
|
+
const severities = resolveSeverities(opts.settings);
|
|
437
|
+
const diagnostics = [];
|
|
438
|
+
for (const src of sources) {
|
|
439
|
+
const exemptions = computePragmaExemptions(src.raw);
|
|
440
|
+
const ctx = {
|
|
441
|
+
file: src.file,
|
|
442
|
+
ext: src.ext,
|
|
443
|
+
bomByte: src.bomByte,
|
|
444
|
+
mixedLineEndings: src.mixedLineEndings,
|
|
445
|
+
promptScoped: src.promptScoped
|
|
446
|
+
};
|
|
447
|
+
for (const rule of REGISTRY) {
|
|
448
|
+
const severity = severities[rule.id];
|
|
449
|
+
if (severity === null) continue;
|
|
450
|
+
let raws;
|
|
451
|
+
try {
|
|
452
|
+
raws = rule.match(src.raw, ctx);
|
|
453
|
+
} catch (err) {
|
|
454
|
+
throw new RuleError({
|
|
455
|
+
code: "RULE_THREW",
|
|
456
|
+
message: `rule ${rule.id} threw while scanning ${src.file}: ${err instanceof Error ? err.message : String(err)}`,
|
|
457
|
+
ruleId: rule.id,
|
|
458
|
+
cause: err
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
for (const d of raws) {
|
|
462
|
+
if (isExempt(exemptions, d.line, rule.id)) continue;
|
|
463
|
+
diagnostics.push({
|
|
464
|
+
rule: rule.id,
|
|
465
|
+
severity,
|
|
466
|
+
file: src.file,
|
|
467
|
+
line: d.line,
|
|
468
|
+
column: d.column,
|
|
469
|
+
message: d.message,
|
|
470
|
+
...d.fixHint !== void 0 ? { fixHint: d.fixHint } : {},
|
|
471
|
+
whyUrl: ruleDocsUrl(rule.id),
|
|
472
|
+
...d.snippet !== void 0 ? { snippet: d.snippet } : {},
|
|
473
|
+
volatility: d.volatility ?? rule.volatility
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
diagnostics.sort(
|
|
479
|
+
(a, b) => a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line || a.column - b.column || (a.rule < b.rule ? -1 : a.rule > b.rule ? 1 : 0)
|
|
480
|
+
);
|
|
481
|
+
return diagnostics;
|
|
482
|
+
}
|
|
483
|
+
function isExempt(exemptions, line, ruleId) {
|
|
484
|
+
const set = exemptions.get(line);
|
|
485
|
+
if (set === void 0) return false;
|
|
486
|
+
return set.has("*") || set.has(ruleId);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export {
|
|
490
|
+
allRules,
|
|
491
|
+
getRule,
|
|
492
|
+
ruleDocsUrl,
|
|
493
|
+
runRules
|
|
494
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// src/version.ts
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { dirname, resolve } from "path";
|
|
5
|
+
function readPackageJsonVersion() {
|
|
6
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const pkgPath = resolve(here, "..", "package.json");
|
|
8
|
+
const raw = readFileSync(pkgPath, "utf8");
|
|
9
|
+
const parsed = JSON.parse(raw);
|
|
10
|
+
if (typeof parsed === "object" && parsed !== null && "version" in parsed) {
|
|
11
|
+
const v = parsed.version;
|
|
12
|
+
if (typeof v === "string") return v;
|
|
13
|
+
}
|
|
14
|
+
throw new Error("cachelint: could not read version from package.json");
|
|
15
|
+
}
|
|
16
|
+
var CACHELINT_VERSION = readPackageJsonVersion();
|
|
17
|
+
var NORMALIZATION_POLICY_VERSION = 1;
|
|
18
|
+
var RULE_TABLE_VERSION = 1;
|
|
19
|
+
var COST_MODEL_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
CACHELINT_VERSION,
|
|
23
|
+
NORMALIZATION_POLICY_VERSION,
|
|
24
|
+
RULE_TABLE_VERSION,
|
|
25
|
+
COST_MODEL_VERSION
|
|
26
|
+
};
|