@yawlabs/ctxlint 0.22.0 → 0.23.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/.pre-commit-hooks.yaml +1 -1
- package/dist/index.js +431 -384
- package/package.json +2 -1
package/.pre-commit-hooks.yaml
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
# Version-pinned so a checkout at `rev: vX.Y.Z` runs exactly that release
|
|
5
5
|
# of ctxlint — matches the pinning done by `ctxlint init`. release.sh keeps
|
|
6
6
|
# this in sync with package.json on each bump.
|
|
7
|
-
entry: npx @yawlabs/ctxlint@0.
|
|
7
|
+
entry: npx @yawlabs/ctxlint@0.23.0 --strict
|
|
8
8
|
language: node
|
|
9
9
|
always_run: true
|
|
10
10
|
pass_filenames: false
|
package/dist/index.js
CHANGED
|
@@ -24050,84 +24050,443 @@ var init_cli_subcommands = __esm({
|
|
|
24050
24050
|
}
|
|
24051
24051
|
});
|
|
24052
24052
|
|
|
24053
|
-
// src/core/checks/
|
|
24053
|
+
// src/core/checks/tokens.ts
|
|
24054
|
+
function resolveTokenThresholds(overrides) {
|
|
24055
|
+
if (!overrides) return DEFAULT_TOKEN_THRESHOLDS;
|
|
24056
|
+
const merged = { ...DEFAULT_TOKEN_THRESHOLDS, ...overrides };
|
|
24057
|
+
if (merged.info >= merged.warning || merged.warning >= merged.error) {
|
|
24058
|
+
console.error(
|
|
24059
|
+
`Warning: token thresholds should satisfy info < warning < error (got ${merged.info}, ${merged.warning}, ${merged.error}) \u2014 using defaults`
|
|
24060
|
+
);
|
|
24061
|
+
return DEFAULT_TOKEN_THRESHOLDS;
|
|
24062
|
+
}
|
|
24063
|
+
return merged;
|
|
24064
|
+
}
|
|
24065
|
+
async function checkTokens(file2, _projectRoot, thresholds = DEFAULT_TOKEN_THRESHOLDS) {
|
|
24066
|
+
const issues = [];
|
|
24067
|
+
const tokens = file2.totalTokens;
|
|
24068
|
+
if (tokens >= thresholds.error) {
|
|
24069
|
+
issues.push({
|
|
24070
|
+
severity: "error",
|
|
24071
|
+
check: "tokens",
|
|
24072
|
+
ruleId: "tokens/excessive",
|
|
24073
|
+
line: 1,
|
|
24074
|
+
message: `${tokens.toLocaleString()} tokens \u2014 consumes significant context window space`,
|
|
24075
|
+
suggestion: "Consider splitting into focused sections or removing redundant content."
|
|
24076
|
+
});
|
|
24077
|
+
} else if (tokens >= thresholds.warning) {
|
|
24078
|
+
issues.push({
|
|
24079
|
+
severity: "warning",
|
|
24080
|
+
check: "tokens",
|
|
24081
|
+
ruleId: "tokens/large",
|
|
24082
|
+
line: 1,
|
|
24083
|
+
message: `${tokens.toLocaleString()} tokens \u2014 large context file`,
|
|
24084
|
+
suggestion: "Consider trimming \u2014 research shows diminishing returns past ~300 lines."
|
|
24085
|
+
});
|
|
24086
|
+
} else if (tokens >= thresholds.info) {
|
|
24087
|
+
issues.push({
|
|
24088
|
+
severity: "info",
|
|
24089
|
+
check: "tokens",
|
|
24090
|
+
ruleId: "tokens/info",
|
|
24091
|
+
line: 1,
|
|
24092
|
+
message: `Uses ~${tokens.toLocaleString()} tokens per session`
|
|
24093
|
+
});
|
|
24094
|
+
}
|
|
24095
|
+
return issues;
|
|
24096
|
+
}
|
|
24097
|
+
function checkAggregateTokens(files, thresholds = DEFAULT_TOKEN_THRESHOLDS) {
|
|
24098
|
+
const total = files.reduce((sum, f) => sum + f.tokens, 0);
|
|
24099
|
+
if (total >= thresholds.aggregate && files.length > 1) {
|
|
24100
|
+
return {
|
|
24101
|
+
severity: "warning",
|
|
24102
|
+
check: "tokens",
|
|
24103
|
+
ruleId: "tokens/aggregate",
|
|
24104
|
+
line: 0,
|
|
24105
|
+
message: `${files.length} context files consume ${total.toLocaleString()} tokens combined`,
|
|
24106
|
+
suggestion: "Consider consolidating or trimming to reduce per-session context cost."
|
|
24107
|
+
};
|
|
24108
|
+
}
|
|
24109
|
+
return null;
|
|
24110
|
+
}
|
|
24111
|
+
var DEFAULT_TOKEN_THRESHOLDS;
|
|
24112
|
+
var init_tokens2 = __esm({
|
|
24113
|
+
"src/core/checks/tokens.ts"() {
|
|
24114
|
+
"use strict";
|
|
24115
|
+
init_define_WEB_FIRST_SEGMENTS();
|
|
24116
|
+
DEFAULT_TOKEN_THRESHOLDS = {
|
|
24117
|
+
info: 1e3,
|
|
24118
|
+
warning: 3e3,
|
|
24119
|
+
error: 8e3,
|
|
24120
|
+
aggregate: 5e3,
|
|
24121
|
+
tierBreakdown: 1e3,
|
|
24122
|
+
tierAggregate: 4e3
|
|
24123
|
+
};
|
|
24124
|
+
}
|
|
24125
|
+
});
|
|
24126
|
+
|
|
24127
|
+
// src/core/checks/tier-tokens.ts
|
|
24054
24128
|
import * as fs6 from "node:fs";
|
|
24129
|
+
import * as os2 from "node:os";
|
|
24055
24130
|
import * as path7 from "node:path";
|
|
24056
|
-
function
|
|
24057
|
-
|
|
24131
|
+
function hasFrontmatterList(content, field) {
|
|
24132
|
+
const lines = content.split("\n");
|
|
24133
|
+
if (lines[0]?.trim() !== "---") return false;
|
|
24134
|
+
const fieldPattern = new RegExp(`^${field}\\s*:\\s*(.*)$`);
|
|
24135
|
+
for (let i2 = 1; i2 < lines.length; i2++) {
|
|
24136
|
+
const line = lines[i2];
|
|
24137
|
+
if (line.trim() === "---") return false;
|
|
24138
|
+
const match = line.match(fieldPattern);
|
|
24139
|
+
if (match) {
|
|
24140
|
+
const val = match[1].trim();
|
|
24141
|
+
if (val && val !== "[]") return true;
|
|
24142
|
+
for (let j3 = i2 + 1; j3 < lines.length; j3++) {
|
|
24143
|
+
const next = lines[j3];
|
|
24144
|
+
if (next.trim() === "---") return false;
|
|
24145
|
+
if (next.trim() === "") continue;
|
|
24146
|
+
if (next.trim().startsWith("- ")) return true;
|
|
24147
|
+
break;
|
|
24148
|
+
}
|
|
24149
|
+
}
|
|
24150
|
+
}
|
|
24151
|
+
return false;
|
|
24058
24152
|
}
|
|
24059
|
-
function
|
|
24060
|
-
const
|
|
24061
|
-
if (
|
|
24062
|
-
|
|
24063
|
-
|
|
24064
|
-
|
|
24153
|
+
function frontmatterScalar(content, field) {
|
|
24154
|
+
const lines = content.split("\n");
|
|
24155
|
+
if (lines[0]?.trim() !== "---") return null;
|
|
24156
|
+
const fieldPattern = new RegExp(`^${field}\\s*:\\s*(.*)$`);
|
|
24157
|
+
for (let i2 = 1; i2 < lines.length; i2++) {
|
|
24158
|
+
if (lines[i2].trim() === "---") return null;
|
|
24159
|
+
const match = lines[i2].match(fieldPattern);
|
|
24160
|
+
if (match) return match[1].trim().replace(/^['"]|['"]$/g, "") || null;
|
|
24161
|
+
}
|
|
24162
|
+
return null;
|
|
24065
24163
|
}
|
|
24066
|
-
function
|
|
24067
|
-
|
|
24068
|
-
|
|
24069
|
-
|
|
24070
|
-
|
|
24071
|
-
if (
|
|
24072
|
-
|
|
24073
|
-
|
|
24074
|
-
|
|
24164
|
+
function isAlwaysLoaded(file2) {
|
|
24165
|
+
const rel = file2.relativePath.replace(/\\/g, "/");
|
|
24166
|
+
if (rel.endsWith(".mdc")) return false;
|
|
24167
|
+
if (rel.startsWith(".github/instructions/")) return false;
|
|
24168
|
+
if (rel.includes("/rules/")) {
|
|
24169
|
+
if (hasFrontmatterList(file2.content, "paths")) return false;
|
|
24170
|
+
if (hasFrontmatterList(file2.content, "globs")) return false;
|
|
24171
|
+
if (rel.includes(".windsurf/rules/")) {
|
|
24172
|
+
const trigger = frontmatterScalar(file2.content, "trigger");
|
|
24173
|
+
if (trigger && trigger.toLowerCase() !== "always_on") return false;
|
|
24075
24174
|
}
|
|
24076
|
-
|
|
24077
|
-
|
|
24175
|
+
return true;
|
|
24176
|
+
}
|
|
24177
|
+
const basename4 = rel.split("/").pop() ?? "";
|
|
24178
|
+
for (const name of ALWAYS_LOADED_NAMES) {
|
|
24179
|
+
if (name.includes("/")) {
|
|
24180
|
+
if (rel === name || rel.endsWith("/" + name)) return true;
|
|
24181
|
+
} else if (basename4 === name) {
|
|
24182
|
+
return true;
|
|
24078
24183
|
}
|
|
24079
|
-
|
|
24080
|
-
|
|
24184
|
+
}
|
|
24185
|
+
return false;
|
|
24186
|
+
}
|
|
24187
|
+
function computeSectionCosts(file2) {
|
|
24188
|
+
if (file2.sections.length === 0) return [];
|
|
24189
|
+
const hasH2 = file2.sections.some((s) => s.level === 2);
|
|
24190
|
+
const topLevel = hasH2 ? 2 : 1;
|
|
24191
|
+
const lines = file2.content.split("\n");
|
|
24192
|
+
return file2.sections.filter((s) => s.level === topLevel).map((s) => {
|
|
24193
|
+
const body = lines.slice(s.startLine - 1, s.endLine).join("\n");
|
|
24194
|
+
return { title: s.title, line: s.startLine, tokens: countTokens(body) };
|
|
24195
|
+
}).sort((a, b2) => b2.tokens - a.tokens);
|
|
24196
|
+
}
|
|
24197
|
+
function inlineCodeSpans(line) {
|
|
24198
|
+
const spans = [];
|
|
24199
|
+
const re2 = /`([^`]+)`/g;
|
|
24200
|
+
let m;
|
|
24201
|
+
while ((m = re2.exec(line)) !== null) {
|
|
24202
|
+
spans.push({ start: m.index, end: m.index + m[0].length, content: m[1] });
|
|
24203
|
+
}
|
|
24204
|
+
return spans;
|
|
24205
|
+
}
|
|
24206
|
+
function maskCodeSpans(line, spans) {
|
|
24207
|
+
let out = line;
|
|
24208
|
+
for (const s of spans) {
|
|
24209
|
+
out = out.slice(0, s.start) + "#".repeat(s.end - s.start) + out.slice(s.end);
|
|
24210
|
+
}
|
|
24211
|
+
return out;
|
|
24212
|
+
}
|
|
24213
|
+
function findInviolableCommand(line) {
|
|
24214
|
+
const spans = inlineCodeSpans(line);
|
|
24215
|
+
if (spans.length === 0) return null;
|
|
24216
|
+
const masked = maskCodeSpans(line, spans);
|
|
24217
|
+
FRAMING_TOKEN.lastIndex = 0;
|
|
24218
|
+
let m;
|
|
24219
|
+
while ((m = FRAMING_TOKEN.exec(masked)) !== null) {
|
|
24220
|
+
const token = m[1];
|
|
24221
|
+
if (token === token.toLowerCase()) continue;
|
|
24222
|
+
const afterIdx = m.index + m[0].length;
|
|
24223
|
+
const span = spans.find((s) => s.start >= afterIdx);
|
|
24224
|
+
if (!span) continue;
|
|
24225
|
+
const gap = masked.slice(afterIdx, span.start);
|
|
24226
|
+
if (gap.length > FRAMING_COMMAND_GAP || /[.!?]/.test(gap)) continue;
|
|
24227
|
+
return { framing: token, command: span.content };
|
|
24081
24228
|
}
|
|
24082
24229
|
return null;
|
|
24083
24230
|
}
|
|
24084
|
-
function
|
|
24085
|
-
|
|
24086
|
-
|
|
24087
|
-
|
|
24088
|
-
|
|
24089
|
-
|
|
24231
|
+
function fingerprintFiles(paths) {
|
|
24232
|
+
return paths.map((p2) => {
|
|
24233
|
+
try {
|
|
24234
|
+
const st2 = fs6.statSync(p2);
|
|
24235
|
+
return `${st2.mtimeMs}:${st2.size}`;
|
|
24236
|
+
} catch {
|
|
24237
|
+
return "absent";
|
|
24238
|
+
}
|
|
24239
|
+
}).join("|");
|
|
24090
24240
|
}
|
|
24091
|
-
function
|
|
24092
|
-
const
|
|
24093
|
-
|
|
24241
|
+
function loadSettingsSources(projectRoot, includeGlobal) {
|
|
24242
|
+
const candidates = [
|
|
24243
|
+
path7.join(projectRoot, ".claude", "settings.json"),
|
|
24244
|
+
path7.join(projectRoot, ".claude", "settings.local.json")
|
|
24245
|
+
];
|
|
24246
|
+
if (includeGlobal) {
|
|
24247
|
+
candidates.push(path7.join(os2.homedir(), ".claude", "settings.json"));
|
|
24248
|
+
}
|
|
24249
|
+
const fingerprint = fingerprintFiles(candidates);
|
|
24250
|
+
if (settingsCache?.root === projectRoot && settingsCache.includeGlobal === includeGlobal && settingsCache.fingerprint === fingerprint) {
|
|
24251
|
+
return settingsCache.data;
|
|
24252
|
+
}
|
|
24253
|
+
const sources = [];
|
|
24254
|
+
for (const p2 of candidates) {
|
|
24094
24255
|
let content;
|
|
24095
24256
|
try {
|
|
24096
|
-
content = stripBom(fs6.readFileSync(
|
|
24257
|
+
content = stripBom(fs6.readFileSync(p2, "utf-8"));
|
|
24097
24258
|
} catch {
|
|
24098
24259
|
continue;
|
|
24099
24260
|
}
|
|
24100
|
-
const
|
|
24101
|
-
const
|
|
24102
|
-
if (
|
|
24103
|
-
|
|
24104
|
-
|
|
24105
|
-
|
|
24106
|
-
|
|
24107
|
-
if (prefix) prefixes.push(prefix);
|
|
24261
|
+
const errors = [];
|
|
24262
|
+
const data = parse2(content, errors, { allowTrailingComma: true });
|
|
24263
|
+
if (errors.length > 0) {
|
|
24264
|
+
console.warn(
|
|
24265
|
+
`ctxlint: could not parse ${p2}: ${printParseErrorCode(errors[0].error)} at offset ${errors[0].offset}`
|
|
24266
|
+
);
|
|
24267
|
+
continue;
|
|
24108
24268
|
}
|
|
24269
|
+
if (!data || typeof data !== "object") continue;
|
|
24270
|
+
sources.push(data);
|
|
24109
24271
|
}
|
|
24110
|
-
|
|
24272
|
+
settingsCache = { root: projectRoot, includeGlobal, fingerprint, data: sources };
|
|
24273
|
+
return sources;
|
|
24111
24274
|
}
|
|
24112
|
-
function
|
|
24113
|
-
|
|
24275
|
+
function canonicalizeCommand(backticked) {
|
|
24276
|
+
const beforeFlags = backticked.trim().split(/\s+--?/, 1)[0];
|
|
24277
|
+
return beforeFlags.replace(/\s+/g, " ");
|
|
24114
24278
|
}
|
|
24115
|
-
|
|
24116
|
-
const
|
|
24117
|
-
const
|
|
24118
|
-
|
|
24119
|
-
|
|
24120
|
-
|
|
24121
|
-
|
|
24122
|
-
|
|
24123
|
-
|
|
24124
|
-
|
|
24125
|
-
|
|
24126
|
-
|
|
24127
|
-
|
|
24128
|
-
|
|
24129
|
-
|
|
24130
|
-
|
|
24279
|
+
function buildCommandPattern(cmd) {
|
|
24280
|
+
const tokens = cmd.split(/\s+/).filter(Boolean);
|
|
24281
|
+
const escaped = tokens.map((t2) => t2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
24282
|
+
if (tokens.length === 1) {
|
|
24283
|
+
return new RegExp(`(?<![A-Za-z0-9_\\-])${escaped[0]}(?![A-Za-z0-9_\\-])`, "i");
|
|
24284
|
+
}
|
|
24285
|
+
const body = escaped.join("[\\s\\-_]+");
|
|
24286
|
+
return new RegExp(`(?<![A-Za-z0-9])${body}(?![A-Za-z0-9])`, "i");
|
|
24287
|
+
}
|
|
24288
|
+
function commandIsEnforced(cmd, settings) {
|
|
24289
|
+
const pattern = buildCommandPattern(cmd);
|
|
24290
|
+
for (const s of settings) {
|
|
24291
|
+
for (const entry of [...s.permissions?.deny ?? [], ...s.permissions?.ask ?? []]) {
|
|
24292
|
+
if (pattern.test(entry)) return true;
|
|
24293
|
+
}
|
|
24294
|
+
for (const h2 of [...s.hooks?.PreToolUse ?? [], ...s.hooks?.Stop ?? []]) {
|
|
24295
|
+
if (pattern.test(h2.matcher || "")) return true;
|
|
24296
|
+
for (const sub of h2.hooks ?? []) {
|
|
24297
|
+
if (pattern.test(sub.command || "")) return true;
|
|
24298
|
+
}
|
|
24299
|
+
}
|
|
24300
|
+
}
|
|
24301
|
+
return false;
|
|
24302
|
+
}
|
|
24303
|
+
function checkHardEnforcement(file2, settings) {
|
|
24304
|
+
const issues = [];
|
|
24305
|
+
const lines = file2.content.split("\n");
|
|
24306
|
+
for (let i2 = 0; i2 < lines.length; i2++) {
|
|
24307
|
+
const line = lines[i2];
|
|
24308
|
+
const match = findInviolableCommand(line);
|
|
24309
|
+
if (!match) continue;
|
|
24310
|
+
const cmd = canonicalizeCommand(match.command);
|
|
24311
|
+
if (!cmd) continue;
|
|
24312
|
+
if (commandIsEnforced(cmd, settings)) continue;
|
|
24313
|
+
const suggestion = match.framing.toUpperCase() === "ALWAYS" ? `Rules in always-loaded files are advisory. For \`${cmd}\`, add a hook in .claude/settings.json (e.g. a PreToolUse or Stop hook that runs or verifies \`${cmd}\`) so the requirement doesn't depend on the agent remembering.` : `Rules in always-loaded files are advisory. For \`${cmd}\`, add a PreToolUse hook (or permissions.deny entry) in .claude/settings.json so the command is physically blocked.`;
|
|
24314
|
+
issues.push({
|
|
24315
|
+
severity: "info",
|
|
24316
|
+
check: "tier-tokens",
|
|
24317
|
+
ruleId: "tier-tokens/hard-enforcement-missing",
|
|
24318
|
+
line: i2 + 1,
|
|
24319
|
+
message: `Inviolable framing ("${line.trim().slice(0, 80)}") without a hook to back it up`,
|
|
24320
|
+
suggestion
|
|
24321
|
+
});
|
|
24322
|
+
}
|
|
24323
|
+
return issues;
|
|
24324
|
+
}
|
|
24325
|
+
async function checkTierTokens(file2, projectRoot, thresholds = DEFAULT_TOKEN_THRESHOLDS, includeGlobal = false) {
|
|
24326
|
+
if (!isAlwaysLoaded(file2)) return [];
|
|
24327
|
+
const issues = [];
|
|
24328
|
+
const threshold = thresholds.tierBreakdown;
|
|
24329
|
+
if (file2.totalTokens >= threshold) {
|
|
24330
|
+
const sectionCosts = computeSectionCosts(file2);
|
|
24331
|
+
if (sectionCosts.length > 0) {
|
|
24332
|
+
const top = sectionCosts.slice(0, TOP_SECTIONS_TO_REPORT);
|
|
24333
|
+
const heaviest = top[0];
|
|
24334
|
+
const pct = Math.round(heaviest.tokens / file2.totalTokens * 100);
|
|
24335
|
+
const detail = top.map((s) => ` - "${s.title}" (L${s.line}): ~${s.tokens.toLocaleString()} tokens`).join("\n");
|
|
24336
|
+
issues.push({
|
|
24337
|
+
severity: "info",
|
|
24338
|
+
check: "tier-tokens",
|
|
24339
|
+
ruleId: "tier-tokens/section-breakdown",
|
|
24340
|
+
line: heaviest.line,
|
|
24341
|
+
message: `${file2.totalTokens.toLocaleString()} tokens loaded every session \u2014 heaviest top-level section${top.length === 1 ? "" : "s"}:`,
|
|
24342
|
+
detail,
|
|
24343
|
+
suggestion: `"${heaviest.title}" is ~${heaviest.tokens.toLocaleString()} tokens (${pct}% of file). Consider demoting to an on-demand tier (skill, subagent, or memory) so it loads only when relevant.`
|
|
24344
|
+
});
|
|
24345
|
+
}
|
|
24346
|
+
}
|
|
24347
|
+
const settings = loadSettingsSources(projectRoot, includeGlobal);
|
|
24348
|
+
issues.push(...checkHardEnforcement(file2, settings));
|
|
24349
|
+
return issues;
|
|
24350
|
+
}
|
|
24351
|
+
function checkAggregateTierTokens(files, thresholds = DEFAULT_TOKEN_THRESHOLDS) {
|
|
24352
|
+
const alwaysLoaded = files.filter(isAlwaysLoaded);
|
|
24353
|
+
if (alwaysLoaded.length < 2) return null;
|
|
24354
|
+
const total = alwaysLoaded.reduce((sum, f) => sum + f.totalTokens, 0);
|
|
24355
|
+
const threshold = thresholds.tierAggregate;
|
|
24356
|
+
if (total < threshold) return null;
|
|
24357
|
+
const breakdown = alwaysLoaded.slice().sort((a, b2) => b2.totalTokens - a.totalTokens).slice(0, 5).map((f) => ` - ${f.relativePath}: ~${f.totalTokens.toLocaleString()} tokens`).join("\n");
|
|
24358
|
+
return {
|
|
24359
|
+
severity: "warning",
|
|
24360
|
+
check: "tier-tokens",
|
|
24361
|
+
ruleId: "tier-tokens/aggregate",
|
|
24362
|
+
line: 0,
|
|
24363
|
+
message: `${alwaysLoaded.length} always-loaded files total ${total.toLocaleString()} tokens \u2014 loaded every session`,
|
|
24364
|
+
detail: breakdown,
|
|
24365
|
+
suggestion: "Consider moving the largest files or their heaviest sections to on-demand tiers (skills, subagents, memory)."
|
|
24366
|
+
};
|
|
24367
|
+
}
|
|
24368
|
+
var ALWAYS_LOADED_NAMES, TOP_SECTIONS_TO_REPORT, FRAMING_TOKEN, FRAMING_COMMAND_GAP, settingsCache;
|
|
24369
|
+
var init_tier_tokens = __esm({
|
|
24370
|
+
"src/core/checks/tier-tokens.ts"() {
|
|
24371
|
+
"use strict";
|
|
24372
|
+
init_define_WEB_FIRST_SEGMENTS();
|
|
24373
|
+
init_main3();
|
|
24374
|
+
init_tokens();
|
|
24375
|
+
init_fs();
|
|
24376
|
+
init_tokens2();
|
|
24377
|
+
ALWAYS_LOADED_NAMES = [
|
|
24378
|
+
"CLAUDE.md",
|
|
24379
|
+
"CLAUDE.local.md",
|
|
24380
|
+
"AGENTS.md",
|
|
24381
|
+
"AGENTS.override.md",
|
|
24382
|
+
"AGENT.md",
|
|
24383
|
+
"GEMINI.md",
|
|
24384
|
+
".cursorrules",
|
|
24385
|
+
".windsurfrules",
|
|
24386
|
+
".clinerules",
|
|
24387
|
+
".aiderules",
|
|
24388
|
+
".continuerules",
|
|
24389
|
+
".rules",
|
|
24390
|
+
".goosehints",
|
|
24391
|
+
"replit.md",
|
|
24392
|
+
".github/copilot-instructions.md",
|
|
24393
|
+
".junie/guidelines.md",
|
|
24394
|
+
".junie/AGENTS.md",
|
|
24395
|
+
".goose/instructions.md"
|
|
24396
|
+
];
|
|
24397
|
+
TOP_SECTIONS_TO_REPORT = 3;
|
|
24398
|
+
FRAMING_TOKEN = /(?<![\w'.-])(never|always|don'?t|do\s+not|must\s+not)(?![\w'.-])/gi;
|
|
24399
|
+
FRAMING_COMMAND_GAP = 80;
|
|
24400
|
+
settingsCache = null;
|
|
24401
|
+
}
|
|
24402
|
+
});
|
|
24403
|
+
|
|
24404
|
+
// src/core/checks/commands.ts
|
|
24405
|
+
import * as fs7 from "node:fs";
|
|
24406
|
+
import * as path8 from "node:path";
|
|
24407
|
+
function isManagerBuiltin(manager, sub) {
|
|
24408
|
+
return PM_MANAGER_BUILTINS[manager]?.has(sub) ?? false;
|
|
24409
|
+
}
|
|
24410
|
+
function scriptNameFromMatch(match) {
|
|
24411
|
+
const [, manager, explicitRun, name] = match;
|
|
24412
|
+
if (name.startsWith("-")) return null;
|
|
24413
|
+
if (manager && !explicitRun && PM_BUILTIN_SUBCOMMANDS.has(name)) return null;
|
|
24414
|
+
if (manager && !explicitRun && isManagerBuiltin(manager, name)) return null;
|
|
24415
|
+
return name;
|
|
24416
|
+
}
|
|
24417
|
+
function extractNpxPackage(cmd) {
|
|
24418
|
+
if (!/^npx\b/.test(cmd)) return null;
|
|
24419
|
+
const tokens = cmd.split(/\s+/).slice(1);
|
|
24420
|
+
for (let i2 = 0; i2 < tokens.length; i2++) {
|
|
24421
|
+
const t2 = tokens[i2];
|
|
24422
|
+
if (t2 === "-p" || t2 === "--package") {
|
|
24423
|
+
const v2 = tokens[i2 + 1];
|
|
24424
|
+
if (v2 && !v2.startsWith("-")) return v2;
|
|
24425
|
+
continue;
|
|
24426
|
+
}
|
|
24427
|
+
if (t2.startsWith("-p=") || t2.startsWith("--package=")) {
|
|
24428
|
+
return t2.slice(t2.indexOf("=") + 1) || null;
|
|
24429
|
+
}
|
|
24430
|
+
if (t2.startsWith("-")) continue;
|
|
24431
|
+
return t2;
|
|
24432
|
+
}
|
|
24433
|
+
return null;
|
|
24434
|
+
}
|
|
24435
|
+
function wouldNeedPackageJson(cmd) {
|
|
24436
|
+
const scriptMatch = cmd.match(NPM_SCRIPT_PATTERN);
|
|
24437
|
+
if (scriptMatch && scriptNameFromMatch(scriptMatch) !== null) return true;
|
|
24438
|
+
const shorthandMatch = cmd.match(PKG_SHORTHAND_PATTERN);
|
|
24439
|
+
if (shorthandMatch && !isManagerBuiltin(shorthandMatch[1], shorthandMatch[2])) return true;
|
|
24440
|
+
return /^npx\b/.test(cmd) || PKG_DEPENDENT_TOOL_PATTERN.test(cmd);
|
|
24441
|
+
}
|
|
24442
|
+
function loadDeniedCommandPrefixes(projectRoot) {
|
|
24443
|
+
const prefixes = [];
|
|
24444
|
+
for (const rel of ["settings.json", "settings.local.json"]) {
|
|
24445
|
+
let content;
|
|
24446
|
+
try {
|
|
24447
|
+
content = stripBom(fs7.readFileSync(path8.join(projectRoot, ".claude", rel), "utf-8"));
|
|
24448
|
+
} catch {
|
|
24449
|
+
continue;
|
|
24450
|
+
}
|
|
24451
|
+
const data = parse2(content, [], { allowTrailingComma: true });
|
|
24452
|
+
const deny = data?.permissions?.deny;
|
|
24453
|
+
if (!Array.isArray(deny)) continue;
|
|
24454
|
+
for (const entry of deny) {
|
|
24455
|
+
if (typeof entry !== "string") continue;
|
|
24456
|
+
const inner = entry.replace(/^[A-Za-z]+\((.*)\)$/, "$1");
|
|
24457
|
+
const prefix = inner.replace(/:?\*+$/, "").trim();
|
|
24458
|
+
if (prefix) prefixes.push(prefix);
|
|
24459
|
+
}
|
|
24460
|
+
}
|
|
24461
|
+
return prefixes;
|
|
24462
|
+
}
|
|
24463
|
+
function isDeniedCommand(cmd, deniedPrefixes) {
|
|
24464
|
+
return deniedPrefixes.some((p2) => cmd === p2 || cmd.startsWith(`${p2} `));
|
|
24465
|
+
}
|
|
24466
|
+
function isProhibitedMention(lines, ref) {
|
|
24467
|
+
const line = lines[ref.line - 1] ?? "";
|
|
24468
|
+
const masked = maskCodeSpans(line, inlineCodeSpans(line));
|
|
24469
|
+
const prefix = masked.slice(0, Math.max(0, ref.column - 1));
|
|
24470
|
+
const clause = prefix.split(CLAUSE_TERMINATOR).pop() ?? "";
|
|
24471
|
+
return PROHIBITION_TOKEN.test(clause);
|
|
24472
|
+
}
|
|
24473
|
+
async function checkCommands(file2, projectRoot) {
|
|
24474
|
+
const issues = [];
|
|
24475
|
+
const pkgJson = loadPackageJson(projectRoot);
|
|
24476
|
+
const makefile = loadMakefile(projectRoot);
|
|
24477
|
+
const deniedPrefixes = loadDeniedCommandPrefixes(projectRoot);
|
|
24478
|
+
const contentLines = file2.content.split("\n");
|
|
24479
|
+
if (!pkgJson) {
|
|
24480
|
+
const skipped = file2.references.commands.find((ref) => wouldNeedPackageJson(ref.value));
|
|
24481
|
+
if (skipped) {
|
|
24482
|
+
issues.push({
|
|
24483
|
+
severity: "info",
|
|
24484
|
+
check: "commands",
|
|
24485
|
+
ruleId: "commands/package-json-missing",
|
|
24486
|
+
line: skipped.line,
|
|
24487
|
+
message: "package.json missing or unparseable \u2014 command checks skipped",
|
|
24488
|
+
suggestion: "Add a parseable package.json at the project root so script, npx, and tool references can be validated."
|
|
24489
|
+
});
|
|
24131
24490
|
}
|
|
24132
24491
|
}
|
|
24133
24492
|
issues.push(...checkUnknownSubcommand(file2, projectRoot, pkgJson));
|
|
@@ -24188,9 +24547,10 @@ async function checkCommands(file2, projectRoot) {
|
|
|
24188
24547
|
};
|
|
24189
24548
|
if (!(pkgName in allDeps)) {
|
|
24190
24549
|
if (isDeniedCommand(cmd, deniedPrefixes)) continue;
|
|
24191
|
-
|
|
24550
|
+
if (isProhibitedMention(contentLines, ref)) continue;
|
|
24551
|
+
const binPath = path8.join(projectRoot, "node_modules", ".bin", pkgName);
|
|
24192
24552
|
try {
|
|
24193
|
-
|
|
24553
|
+
fs7.accessSync(binPath);
|
|
24194
24554
|
} catch {
|
|
24195
24555
|
issues.push({
|
|
24196
24556
|
severity: "warning",
|
|
@@ -24238,9 +24598,9 @@ async function checkCommands(file2, projectRoot) {
|
|
|
24238
24598
|
...pkgJson.optionalDependencies
|
|
24239
24599
|
};
|
|
24240
24600
|
if (!(pkgName in allDeps)) {
|
|
24241
|
-
const binPath =
|
|
24601
|
+
const binPath = path8.join(projectRoot, "node_modules", ".bin", tool);
|
|
24242
24602
|
try {
|
|
24243
|
-
|
|
24603
|
+
fs7.accessSync(binPath);
|
|
24244
24604
|
} catch {
|
|
24245
24605
|
issues.push({
|
|
24246
24606
|
severity: "warning",
|
|
@@ -24284,7 +24644,7 @@ function checkUnknownSubcommand(file2, projectRoot, pkgJson) {
|
|
|
24284
24644
|
}
|
|
24285
24645
|
function loadMakefile(projectRoot) {
|
|
24286
24646
|
try {
|
|
24287
|
-
return stripBom(
|
|
24647
|
+
return stripBom(fs7.readFileSync(path8.join(projectRoot, "Makefile"), "utf-8"));
|
|
24288
24648
|
} catch {
|
|
24289
24649
|
return null;
|
|
24290
24650
|
}
|
|
@@ -24302,7 +24662,7 @@ function hasMakeTarget(makefile, target) {
|
|
|
24302
24662
|
const pattern = new RegExp(`^${escaped}\\s*:(?!:?=)`, "m");
|
|
24303
24663
|
return pattern.test(makefile);
|
|
24304
24664
|
}
|
|
24305
|
-
var NPM_SCRIPT_PATTERN, MAKE_PATTERN, PM_BUILTIN_SUBCOMMANDS, PM_MANAGER_BUILTINS, PKG_DEPENDENT_TOOL_PATTERN, BIN_TO_PACKAGE, PKG_SHORTHAND_PATTERN;
|
|
24665
|
+
var NPM_SCRIPT_PATTERN, MAKE_PATTERN, PM_BUILTIN_SUBCOMMANDS, PM_MANAGER_BUILTINS, PKG_DEPENDENT_TOOL_PATTERN, BIN_TO_PACKAGE, PKG_SHORTHAND_PATTERN, PROHIBITION_TOKEN, CLAUSE_TERMINATOR;
|
|
24306
24666
|
var init_commands = __esm({
|
|
24307
24667
|
"src/core/checks/commands.ts"() {
|
|
24308
24668
|
"use strict";
|
|
@@ -24311,6 +24671,7 @@ var init_commands = __esm({
|
|
|
24311
24671
|
init_fs();
|
|
24312
24672
|
init_exit_status();
|
|
24313
24673
|
init_cli_subcommands();
|
|
24674
|
+
init_tier_tokens();
|
|
24314
24675
|
NPM_SCRIPT_PATTERN = /^(?:npm\s+run|(pnpm|yarn|bun)(?:\s+(run))?)\s+(\S+)/;
|
|
24315
24676
|
MAKE_PATTERN = /^make\s+\S/;
|
|
24316
24677
|
PM_BUILTIN_SUBCOMMANDS = /* @__PURE__ */ new Set([
|
|
@@ -24380,17 +24741,19 @@ var init_commands = __esm({
|
|
|
24380
24741
|
tsc: "typescript"
|
|
24381
24742
|
};
|
|
24382
24743
|
PKG_SHORTHAND_PATTERN = /^(npm|pnpm|yarn|bun)\s+(test|start|build|dev|lint|format|check|typecheck|clean|serve|preview|e2e)\b/;
|
|
24744
|
+
PROHIBITION_TOKEN = /\b(?:never|don['’]?t|do\s+not|must\s+not|avoid)\b/i;
|
|
24745
|
+
CLAUSE_TERMINATOR = /[.!?;—]|\s--(?=\s|$)/;
|
|
24383
24746
|
}
|
|
24384
24747
|
});
|
|
24385
24748
|
|
|
24386
24749
|
// src/core/checks/staleness.ts
|
|
24387
|
-
import * as
|
|
24750
|
+
import * as path9 from "node:path";
|
|
24388
24751
|
async function checkStaleness(file2, projectRoot) {
|
|
24389
24752
|
const issues = [];
|
|
24390
24753
|
if (!await isGitRepo(projectRoot)) {
|
|
24391
24754
|
return issues;
|
|
24392
24755
|
}
|
|
24393
|
-
const relativePath =
|
|
24756
|
+
const relativePath = path9.relative(projectRoot, file2.filePath).replace(/\\/g, "/");
|
|
24394
24757
|
const lastModified = await getFileLastModified(projectRoot, relativePath);
|
|
24395
24758
|
if (!lastModified || isNaN(lastModified.getTime())) {
|
|
24396
24759
|
return issues;
|
|
@@ -24506,322 +24869,6 @@ var init_suppressions = __esm({
|
|
|
24506
24869
|
}
|
|
24507
24870
|
});
|
|
24508
24871
|
|
|
24509
|
-
// src/core/checks/tokens.ts
|
|
24510
|
-
function resolveTokenThresholds(overrides) {
|
|
24511
|
-
if (!overrides) return DEFAULT_TOKEN_THRESHOLDS;
|
|
24512
|
-
const merged = { ...DEFAULT_TOKEN_THRESHOLDS, ...overrides };
|
|
24513
|
-
if (merged.info >= merged.warning || merged.warning >= merged.error) {
|
|
24514
|
-
console.error(
|
|
24515
|
-
`Warning: token thresholds should satisfy info < warning < error (got ${merged.info}, ${merged.warning}, ${merged.error}) \u2014 using defaults`
|
|
24516
|
-
);
|
|
24517
|
-
return DEFAULT_TOKEN_THRESHOLDS;
|
|
24518
|
-
}
|
|
24519
|
-
return merged;
|
|
24520
|
-
}
|
|
24521
|
-
async function checkTokens(file2, _projectRoot, thresholds = DEFAULT_TOKEN_THRESHOLDS) {
|
|
24522
|
-
const issues = [];
|
|
24523
|
-
const tokens = file2.totalTokens;
|
|
24524
|
-
if (tokens >= thresholds.error) {
|
|
24525
|
-
issues.push({
|
|
24526
|
-
severity: "error",
|
|
24527
|
-
check: "tokens",
|
|
24528
|
-
ruleId: "tokens/excessive",
|
|
24529
|
-
line: 1,
|
|
24530
|
-
message: `${tokens.toLocaleString()} tokens \u2014 consumes significant context window space`,
|
|
24531
|
-
suggestion: "Consider splitting into focused sections or removing redundant content."
|
|
24532
|
-
});
|
|
24533
|
-
} else if (tokens >= thresholds.warning) {
|
|
24534
|
-
issues.push({
|
|
24535
|
-
severity: "warning",
|
|
24536
|
-
check: "tokens",
|
|
24537
|
-
ruleId: "tokens/large",
|
|
24538
|
-
line: 1,
|
|
24539
|
-
message: `${tokens.toLocaleString()} tokens \u2014 large context file`,
|
|
24540
|
-
suggestion: "Consider trimming \u2014 research shows diminishing returns past ~300 lines."
|
|
24541
|
-
});
|
|
24542
|
-
} else if (tokens >= thresholds.info) {
|
|
24543
|
-
issues.push({
|
|
24544
|
-
severity: "info",
|
|
24545
|
-
check: "tokens",
|
|
24546
|
-
ruleId: "tokens/info",
|
|
24547
|
-
line: 1,
|
|
24548
|
-
message: `Uses ~${tokens.toLocaleString()} tokens per session`
|
|
24549
|
-
});
|
|
24550
|
-
}
|
|
24551
|
-
return issues;
|
|
24552
|
-
}
|
|
24553
|
-
function checkAggregateTokens(files, thresholds = DEFAULT_TOKEN_THRESHOLDS) {
|
|
24554
|
-
const total = files.reduce((sum, f) => sum + f.tokens, 0);
|
|
24555
|
-
if (total >= thresholds.aggregate && files.length > 1) {
|
|
24556
|
-
return {
|
|
24557
|
-
severity: "warning",
|
|
24558
|
-
check: "tokens",
|
|
24559
|
-
ruleId: "tokens/aggregate",
|
|
24560
|
-
line: 0,
|
|
24561
|
-
message: `${files.length} context files consume ${total.toLocaleString()} tokens combined`,
|
|
24562
|
-
suggestion: "Consider consolidating or trimming to reduce per-session context cost."
|
|
24563
|
-
};
|
|
24564
|
-
}
|
|
24565
|
-
return null;
|
|
24566
|
-
}
|
|
24567
|
-
var DEFAULT_TOKEN_THRESHOLDS;
|
|
24568
|
-
var init_tokens2 = __esm({
|
|
24569
|
-
"src/core/checks/tokens.ts"() {
|
|
24570
|
-
"use strict";
|
|
24571
|
-
init_define_WEB_FIRST_SEGMENTS();
|
|
24572
|
-
DEFAULT_TOKEN_THRESHOLDS = {
|
|
24573
|
-
info: 1e3,
|
|
24574
|
-
warning: 3e3,
|
|
24575
|
-
error: 8e3,
|
|
24576
|
-
aggregate: 5e3,
|
|
24577
|
-
tierBreakdown: 1e3,
|
|
24578
|
-
tierAggregate: 4e3
|
|
24579
|
-
};
|
|
24580
|
-
}
|
|
24581
|
-
});
|
|
24582
|
-
|
|
24583
|
-
// src/core/checks/tier-tokens.ts
|
|
24584
|
-
import * as fs7 from "node:fs";
|
|
24585
|
-
import * as os2 from "node:os";
|
|
24586
|
-
import * as path9 from "node:path";
|
|
24587
|
-
function hasFrontmatterList(content, field) {
|
|
24588
|
-
const lines = content.split("\n");
|
|
24589
|
-
if (lines[0]?.trim() !== "---") return false;
|
|
24590
|
-
const fieldPattern = new RegExp(`^${field}\\s*:\\s*(.*)$`);
|
|
24591
|
-
for (let i2 = 1; i2 < lines.length; i2++) {
|
|
24592
|
-
const line = lines[i2];
|
|
24593
|
-
if (line.trim() === "---") return false;
|
|
24594
|
-
const match = line.match(fieldPattern);
|
|
24595
|
-
if (match) {
|
|
24596
|
-
const val = match[1].trim();
|
|
24597
|
-
if (val && val !== "[]") return true;
|
|
24598
|
-
for (let j3 = i2 + 1; j3 < lines.length; j3++) {
|
|
24599
|
-
const next = lines[j3];
|
|
24600
|
-
if (next.trim() === "---") return false;
|
|
24601
|
-
if (next.trim() === "") continue;
|
|
24602
|
-
if (next.trim().startsWith("- ")) return true;
|
|
24603
|
-
break;
|
|
24604
|
-
}
|
|
24605
|
-
}
|
|
24606
|
-
}
|
|
24607
|
-
return false;
|
|
24608
|
-
}
|
|
24609
|
-
function frontmatterScalar(content, field) {
|
|
24610
|
-
const lines = content.split("\n");
|
|
24611
|
-
if (lines[0]?.trim() !== "---") return null;
|
|
24612
|
-
const fieldPattern = new RegExp(`^${field}\\s*:\\s*(.*)$`);
|
|
24613
|
-
for (let i2 = 1; i2 < lines.length; i2++) {
|
|
24614
|
-
if (lines[i2].trim() === "---") return null;
|
|
24615
|
-
const match = lines[i2].match(fieldPattern);
|
|
24616
|
-
if (match) return match[1].trim().replace(/^['"]|['"]$/g, "") || null;
|
|
24617
|
-
}
|
|
24618
|
-
return null;
|
|
24619
|
-
}
|
|
24620
|
-
function isAlwaysLoaded(file2) {
|
|
24621
|
-
const rel = file2.relativePath.replace(/\\/g, "/");
|
|
24622
|
-
if (rel.endsWith(".mdc")) return false;
|
|
24623
|
-
if (rel.startsWith(".github/instructions/")) return false;
|
|
24624
|
-
if (rel.includes("/rules/")) {
|
|
24625
|
-
if (hasFrontmatterList(file2.content, "paths")) return false;
|
|
24626
|
-
if (hasFrontmatterList(file2.content, "globs")) return false;
|
|
24627
|
-
if (rel.includes(".windsurf/rules/")) {
|
|
24628
|
-
const trigger = frontmatterScalar(file2.content, "trigger");
|
|
24629
|
-
if (trigger && trigger.toLowerCase() !== "always_on") return false;
|
|
24630
|
-
}
|
|
24631
|
-
return true;
|
|
24632
|
-
}
|
|
24633
|
-
const basename4 = rel.split("/").pop() ?? "";
|
|
24634
|
-
for (const name of ALWAYS_LOADED_NAMES) {
|
|
24635
|
-
if (name.includes("/")) {
|
|
24636
|
-
if (rel === name || rel.endsWith("/" + name)) return true;
|
|
24637
|
-
} else if (basename4 === name) {
|
|
24638
|
-
return true;
|
|
24639
|
-
}
|
|
24640
|
-
}
|
|
24641
|
-
return false;
|
|
24642
|
-
}
|
|
24643
|
-
function computeSectionCosts(file2) {
|
|
24644
|
-
if (file2.sections.length === 0) return [];
|
|
24645
|
-
const hasH2 = file2.sections.some((s) => s.level === 2);
|
|
24646
|
-
const topLevel = hasH2 ? 2 : 1;
|
|
24647
|
-
const lines = file2.content.split("\n");
|
|
24648
|
-
return file2.sections.filter((s) => s.level === topLevel).map((s) => {
|
|
24649
|
-
const body = lines.slice(s.startLine - 1, s.endLine).join("\n");
|
|
24650
|
-
return { title: s.title, line: s.startLine, tokens: countTokens(body) };
|
|
24651
|
-
}).sort((a, b2) => b2.tokens - a.tokens);
|
|
24652
|
-
}
|
|
24653
|
-
function fingerprintFiles(paths) {
|
|
24654
|
-
return paths.map((p2) => {
|
|
24655
|
-
try {
|
|
24656
|
-
const st2 = fs7.statSync(p2);
|
|
24657
|
-
return `${st2.mtimeMs}:${st2.size}`;
|
|
24658
|
-
} catch {
|
|
24659
|
-
return "absent";
|
|
24660
|
-
}
|
|
24661
|
-
}).join("|");
|
|
24662
|
-
}
|
|
24663
|
-
function loadSettingsSources(projectRoot, includeGlobal) {
|
|
24664
|
-
const candidates = [
|
|
24665
|
-
path9.join(projectRoot, ".claude", "settings.json"),
|
|
24666
|
-
path9.join(projectRoot, ".claude", "settings.local.json")
|
|
24667
|
-
];
|
|
24668
|
-
if (includeGlobal) {
|
|
24669
|
-
candidates.push(path9.join(os2.homedir(), ".claude", "settings.json"));
|
|
24670
|
-
}
|
|
24671
|
-
const fingerprint = fingerprintFiles(candidates);
|
|
24672
|
-
if (settingsCache?.root === projectRoot && settingsCache.includeGlobal === includeGlobal && settingsCache.fingerprint === fingerprint) {
|
|
24673
|
-
return settingsCache.data;
|
|
24674
|
-
}
|
|
24675
|
-
const sources = [];
|
|
24676
|
-
for (const p2 of candidates) {
|
|
24677
|
-
let content;
|
|
24678
|
-
try {
|
|
24679
|
-
content = stripBom(fs7.readFileSync(p2, "utf-8"));
|
|
24680
|
-
} catch {
|
|
24681
|
-
continue;
|
|
24682
|
-
}
|
|
24683
|
-
const errors = [];
|
|
24684
|
-
const data = parse2(content, errors, { allowTrailingComma: true });
|
|
24685
|
-
if (errors.length > 0) {
|
|
24686
|
-
console.warn(
|
|
24687
|
-
`ctxlint: could not parse ${p2}: ${printParseErrorCode(errors[0].error)} at offset ${errors[0].offset}`
|
|
24688
|
-
);
|
|
24689
|
-
continue;
|
|
24690
|
-
}
|
|
24691
|
-
if (!data || typeof data !== "object") continue;
|
|
24692
|
-
sources.push(data);
|
|
24693
|
-
}
|
|
24694
|
-
settingsCache = { root: projectRoot, includeGlobal, fingerprint, data: sources };
|
|
24695
|
-
return sources;
|
|
24696
|
-
}
|
|
24697
|
-
function canonicalizeCommand(backticked) {
|
|
24698
|
-
const beforeFlags = backticked.trim().split(/\s+--?/, 1)[0];
|
|
24699
|
-
return beforeFlags.replace(/\s+/g, " ");
|
|
24700
|
-
}
|
|
24701
|
-
function buildCommandPattern(cmd) {
|
|
24702
|
-
const tokens = cmd.split(/\s+/).filter(Boolean);
|
|
24703
|
-
const escaped = tokens.map((t2) => t2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
24704
|
-
if (tokens.length === 1) {
|
|
24705
|
-
return new RegExp(`(?<![A-Za-z0-9_\\-])${escaped[0]}(?![A-Za-z0-9_\\-])`, "i");
|
|
24706
|
-
}
|
|
24707
|
-
const body = escaped.join("[\\s\\-_]+");
|
|
24708
|
-
return new RegExp(`(?<![A-Za-z0-9])${body}(?![A-Za-z0-9])`, "i");
|
|
24709
|
-
}
|
|
24710
|
-
function commandIsEnforced(cmd, settings) {
|
|
24711
|
-
const pattern = buildCommandPattern(cmd);
|
|
24712
|
-
for (const s of settings) {
|
|
24713
|
-
for (const entry of [...s.permissions?.deny ?? [], ...s.permissions?.ask ?? []]) {
|
|
24714
|
-
if (pattern.test(entry)) return true;
|
|
24715
|
-
}
|
|
24716
|
-
for (const h2 of [...s.hooks?.PreToolUse ?? [], ...s.hooks?.Stop ?? []]) {
|
|
24717
|
-
if (pattern.test(h2.matcher || "")) return true;
|
|
24718
|
-
for (const sub of h2.hooks ?? []) {
|
|
24719
|
-
if (pattern.test(sub.command || "")) return true;
|
|
24720
|
-
}
|
|
24721
|
-
}
|
|
24722
|
-
}
|
|
24723
|
-
return false;
|
|
24724
|
-
}
|
|
24725
|
-
function checkHardEnforcement(file2, settings) {
|
|
24726
|
-
const issues = [];
|
|
24727
|
-
const lines = file2.content.split("\n");
|
|
24728
|
-
for (let i2 = 0; i2 < lines.length; i2++) {
|
|
24729
|
-
const line = lines[i2];
|
|
24730
|
-
const match = line.match(INVIOLABLE_WITH_COMMAND);
|
|
24731
|
-
if (!match) continue;
|
|
24732
|
-
const cmd = canonicalizeCommand(match[2]);
|
|
24733
|
-
if (!cmd) continue;
|
|
24734
|
-
if (commandIsEnforced(cmd, settings)) continue;
|
|
24735
|
-
const suggestion = match[1].toUpperCase() === "ALWAYS" ? `Rules in always-loaded files are advisory. For \`${cmd}\`, add a hook in .claude/settings.json (e.g. a PreToolUse or Stop hook that runs or verifies \`${cmd}\`) so the requirement doesn't depend on the agent remembering.` : `Rules in always-loaded files are advisory. For \`${cmd}\`, add a PreToolUse hook (or permissions.deny entry) in .claude/settings.json so the command is physically blocked.`;
|
|
24736
|
-
issues.push({
|
|
24737
|
-
severity: "info",
|
|
24738
|
-
check: "tier-tokens",
|
|
24739
|
-
ruleId: "tier-tokens/hard-enforcement-missing",
|
|
24740
|
-
line: i2 + 1,
|
|
24741
|
-
message: `Inviolable framing ("${line.trim().slice(0, 80)}") without a hook to back it up`,
|
|
24742
|
-
suggestion
|
|
24743
|
-
});
|
|
24744
|
-
}
|
|
24745
|
-
return issues;
|
|
24746
|
-
}
|
|
24747
|
-
async function checkTierTokens(file2, projectRoot, thresholds = DEFAULT_TOKEN_THRESHOLDS, includeGlobal = false) {
|
|
24748
|
-
if (!isAlwaysLoaded(file2)) return [];
|
|
24749
|
-
const issues = [];
|
|
24750
|
-
const threshold = thresholds.tierBreakdown;
|
|
24751
|
-
if (file2.totalTokens >= threshold) {
|
|
24752
|
-
const sectionCosts = computeSectionCosts(file2);
|
|
24753
|
-
if (sectionCosts.length > 0) {
|
|
24754
|
-
const top = sectionCosts.slice(0, TOP_SECTIONS_TO_REPORT);
|
|
24755
|
-
const heaviest = top[0];
|
|
24756
|
-
const pct = Math.round(heaviest.tokens / file2.totalTokens * 100);
|
|
24757
|
-
const detail = top.map((s) => ` - "${s.title}" (L${s.line}): ~${s.tokens.toLocaleString()} tokens`).join("\n");
|
|
24758
|
-
issues.push({
|
|
24759
|
-
severity: "info",
|
|
24760
|
-
check: "tier-tokens",
|
|
24761
|
-
ruleId: "tier-tokens/section-breakdown",
|
|
24762
|
-
line: heaviest.line,
|
|
24763
|
-
message: `${file2.totalTokens.toLocaleString()} tokens loaded every session \u2014 heaviest top-level section${top.length === 1 ? "" : "s"}:`,
|
|
24764
|
-
detail,
|
|
24765
|
-
suggestion: `"${heaviest.title}" is ~${heaviest.tokens.toLocaleString()} tokens (${pct}% of file). Consider demoting to an on-demand tier (skill, subagent, or memory) so it loads only when relevant.`
|
|
24766
|
-
});
|
|
24767
|
-
}
|
|
24768
|
-
}
|
|
24769
|
-
const settings = loadSettingsSources(projectRoot, includeGlobal);
|
|
24770
|
-
issues.push(...checkHardEnforcement(file2, settings));
|
|
24771
|
-
return issues;
|
|
24772
|
-
}
|
|
24773
|
-
function checkAggregateTierTokens(files, thresholds = DEFAULT_TOKEN_THRESHOLDS) {
|
|
24774
|
-
const alwaysLoaded = files.filter(isAlwaysLoaded);
|
|
24775
|
-
if (alwaysLoaded.length < 2) return null;
|
|
24776
|
-
const total = alwaysLoaded.reduce((sum, f) => sum + f.totalTokens, 0);
|
|
24777
|
-
const threshold = thresholds.tierAggregate;
|
|
24778
|
-
if (total < threshold) return null;
|
|
24779
|
-
const breakdown = alwaysLoaded.slice().sort((a, b2) => b2.totalTokens - a.totalTokens).slice(0, 5).map((f) => ` - ${f.relativePath}: ~${f.totalTokens.toLocaleString()} tokens`).join("\n");
|
|
24780
|
-
return {
|
|
24781
|
-
severity: "warning",
|
|
24782
|
-
check: "tier-tokens",
|
|
24783
|
-
ruleId: "tier-tokens/aggregate",
|
|
24784
|
-
line: 0,
|
|
24785
|
-
message: `${alwaysLoaded.length} always-loaded files total ${total.toLocaleString()} tokens \u2014 loaded every session`,
|
|
24786
|
-
detail: breakdown,
|
|
24787
|
-
suggestion: "Consider moving the largest files or their heaviest sections to on-demand tiers (skills, subagents, memory)."
|
|
24788
|
-
};
|
|
24789
|
-
}
|
|
24790
|
-
var ALWAYS_LOADED_NAMES, TOP_SECTIONS_TO_REPORT, INVIOLABLE_WITH_COMMAND, settingsCache;
|
|
24791
|
-
var init_tier_tokens = __esm({
|
|
24792
|
-
"src/core/checks/tier-tokens.ts"() {
|
|
24793
|
-
"use strict";
|
|
24794
|
-
init_define_WEB_FIRST_SEGMENTS();
|
|
24795
|
-
init_main3();
|
|
24796
|
-
init_tokens();
|
|
24797
|
-
init_fs();
|
|
24798
|
-
init_tokens2();
|
|
24799
|
-
ALWAYS_LOADED_NAMES = [
|
|
24800
|
-
"CLAUDE.md",
|
|
24801
|
-
"CLAUDE.local.md",
|
|
24802
|
-
"AGENTS.md",
|
|
24803
|
-
"AGENTS.override.md",
|
|
24804
|
-
"AGENT.md",
|
|
24805
|
-
"GEMINI.md",
|
|
24806
|
-
".cursorrules",
|
|
24807
|
-
".windsurfrules",
|
|
24808
|
-
".clinerules",
|
|
24809
|
-
".aiderules",
|
|
24810
|
-
".continuerules",
|
|
24811
|
-
".rules",
|
|
24812
|
-
".goosehints",
|
|
24813
|
-
"replit.md",
|
|
24814
|
-
".github/copilot-instructions.md",
|
|
24815
|
-
".junie/guidelines.md",
|
|
24816
|
-
".junie/AGENTS.md",
|
|
24817
|
-
".goose/instructions.md"
|
|
24818
|
-
];
|
|
24819
|
-
TOP_SECTIONS_TO_REPORT = 3;
|
|
24820
|
-
INVIOLABLE_WITH_COMMAND = /\b(NEVER|ALWAYS|DON'?T|DO NOT|MUST NOT)\b[^.!?`]{0,80}`([^`]+)`/i;
|
|
24821
|
-
settingsCache = null;
|
|
24822
|
-
}
|
|
24823
|
-
});
|
|
24824
|
-
|
|
24825
24872
|
// src/utils/similarity.ts
|
|
24826
24873
|
function jaccardSimilarityFromSets(a, b2, opts = {}) {
|
|
24827
24874
|
const bothEmptyIsIdentical = opts.bothEmptyIsIdentical ?? false;
|
|
@@ -28968,7 +29015,7 @@ import { readFileSync as readFileSync8 } from "node:fs";
|
|
|
28968
29015
|
import { resolve as resolve15, dirname as dirname7 } from "node:path";
|
|
28969
29016
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
28970
29017
|
function loadVersion() {
|
|
28971
|
-
if (true) return "0.
|
|
29018
|
+
if (true) return "0.23.0";
|
|
28972
29019
|
try {
|
|
28973
29020
|
const __dir = dirname7(fileURLToPath2(import.meta.url));
|
|
28974
29021
|
const pkgPath = resolve15(__dir, "../package.json");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/ctxlint",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"mcpName": "io.github.YawLabs/ctxlint",
|
|
5
5
|
"description": "Lint your AI agent context files, MCP server configs, and session data against your actual codebase",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"scripts": {
|
|
14
14
|
"build": "node build.mjs",
|
|
15
15
|
"dev": "node build.mjs",
|
|
16
|
+
"prepublishOnly": "node build.mjs",
|
|
16
17
|
"generate": "node scripts/generate-catalog-prose.mjs",
|
|
17
18
|
"generate:check": "node scripts/generate-catalog-prose.mjs --check",
|
|
18
19
|
"pretest": "node build.mjs",
|