@yawlabs/ctxlint 0.23.0 → 0.24.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 +456 -340
- package/package.json +1 -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.24.0 --strict
|
|
8
8
|
language: node
|
|
9
9
|
always_run: true
|
|
10
10
|
pass_filenames: false
|
package/dist/index.js
CHANGED
|
@@ -24050,150 +24050,7 @@ var init_cli_subcommands = __esm({
|
|
|
24050
24050
|
}
|
|
24051
24051
|
});
|
|
24052
24052
|
|
|
24053
|
-
// src/
|
|
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
|
|
24128
|
-
import * as fs6 from "node:fs";
|
|
24129
|
-
import * as os2 from "node:os";
|
|
24130
|
-
import * as path7 from "node:path";
|
|
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;
|
|
24152
|
-
}
|
|
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;
|
|
24163
|
-
}
|
|
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;
|
|
24174
|
-
}
|
|
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;
|
|
24183
|
-
}
|
|
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
|
-
}
|
|
24053
|
+
// src/utils/markdown.ts
|
|
24197
24054
|
function inlineCodeSpans(line) {
|
|
24198
24055
|
const spans = [];
|
|
24199
24056
|
const re2 = /`([^`]+)`/g;
|
|
@@ -24210,200 +24067,66 @@ function maskCodeSpans(line, spans) {
|
|
|
24210
24067
|
}
|
|
24211
24068
|
return out;
|
|
24212
24069
|
}
|
|
24213
|
-
function
|
|
24214
|
-
|
|
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 };
|
|
24228
|
-
}
|
|
24229
|
-
return null;
|
|
24070
|
+
function nonProseLineMask(lines) {
|
|
24071
|
+
return classifyLines(lines).map((c3) => c3.fence || c3.comment);
|
|
24230
24072
|
}
|
|
24231
|
-
function
|
|
24232
|
-
return
|
|
24233
|
-
try {
|
|
24234
|
-
const st2 = fs6.statSync(p2);
|
|
24235
|
-
return `${st2.mtimeMs}:${st2.size}`;
|
|
24236
|
-
} catch {
|
|
24237
|
-
return "absent";
|
|
24238
|
-
}
|
|
24239
|
-
}).join("|");
|
|
24073
|
+
function htmlCommentLineMask(lines) {
|
|
24074
|
+
return classifyLines(lines).map((c3) => c3.comment);
|
|
24240
24075
|
}
|
|
24241
|
-
function
|
|
24242
|
-
const
|
|
24243
|
-
|
|
24244
|
-
|
|
24245
|
-
|
|
24246
|
-
|
|
24247
|
-
|
|
24248
|
-
|
|
24249
|
-
|
|
24250
|
-
|
|
24251
|
-
return settingsCache.data;
|
|
24252
|
-
}
|
|
24253
|
-
const sources = [];
|
|
24254
|
-
for (const p2 of candidates) {
|
|
24255
|
-
let content;
|
|
24256
|
-
try {
|
|
24257
|
-
content = stripBom(fs6.readFileSync(p2, "utf-8"));
|
|
24258
|
-
} catch {
|
|
24076
|
+
function classifyLines(lines) {
|
|
24077
|
+
const out = lines.map(() => ({ fence: false, comment: false }));
|
|
24078
|
+
let inFence = false;
|
|
24079
|
+
let inHtmlComment = false;
|
|
24080
|
+
for (let i2 = 0; i2 < lines.length; i2++) {
|
|
24081
|
+
const raw = lines[i2];
|
|
24082
|
+
const trimmed2 = raw.trimStart();
|
|
24083
|
+
if (trimmed2.startsWith("```")) {
|
|
24084
|
+
out[i2].fence = true;
|
|
24085
|
+
inFence = !inFence;
|
|
24259
24086
|
continue;
|
|
24260
24087
|
}
|
|
24261
|
-
|
|
24262
|
-
|
|
24263
|
-
if (errors.length > 0) {
|
|
24264
|
-
console.warn(
|
|
24265
|
-
`ctxlint: could not parse ${p2}: ${printParseErrorCode(errors[0].error)} at offset ${errors[0].offset}`
|
|
24266
|
-
);
|
|
24088
|
+
if (inFence) {
|
|
24089
|
+
out[i2].fence = true;
|
|
24267
24090
|
continue;
|
|
24268
24091
|
}
|
|
24269
|
-
|
|
24270
|
-
|
|
24271
|
-
|
|
24272
|
-
|
|
24273
|
-
|
|
24274
|
-
}
|
|
24275
|
-
function canonicalizeCommand(backticked) {
|
|
24276
|
-
const beforeFlags = backticked.trim().split(/\s+--?/, 1)[0];
|
|
24277
|
-
return beforeFlags.replace(/\s+/g, " ");
|
|
24278
|
-
}
|
|
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;
|
|
24092
|
+
const probe = maskCodeSpans(trimmed2, inlineCodeSpans(trimmed2));
|
|
24093
|
+
if (inHtmlComment) {
|
|
24094
|
+
out[i2].comment = true;
|
|
24095
|
+
if (probe.includes("-->")) inHtmlComment = false;
|
|
24096
|
+
continue;
|
|
24293
24097
|
}
|
|
24294
|
-
|
|
24295
|
-
|
|
24296
|
-
|
|
24297
|
-
if (pattern.test(sub.command || "")) return true;
|
|
24298
|
-
}
|
|
24098
|
+
if (HTML_COMMENT.test(probe.replace(HTML_COMMENT_PAIR, ""))) {
|
|
24099
|
+
out[i2].comment = true;
|
|
24100
|
+
inHtmlComment = true;
|
|
24299
24101
|
}
|
|
24300
24102
|
}
|
|
24301
|
-
return
|
|
24103
|
+
return out;
|
|
24302
24104
|
}
|
|
24303
|
-
function
|
|
24304
|
-
|
|
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;
|
|
24105
|
+
function stripInlineHtmlComments(line) {
|
|
24106
|
+
return line.replace(HTML_COMMENT_PAIR, (m) => "#".repeat(m.length));
|
|
24324
24107
|
}
|
|
24325
|
-
|
|
24326
|
-
|
|
24327
|
-
const
|
|
24328
|
-
|
|
24329
|
-
|
|
24330
|
-
|
|
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
|
-
}
|
|
24108
|
+
function htmlCommentSpans(line) {
|
|
24109
|
+
const re2 = /<!--[\s\S]*?-->/g;
|
|
24110
|
+
const spans = [];
|
|
24111
|
+
let m;
|
|
24112
|
+
while ((m = re2.exec(line)) !== null) {
|
|
24113
|
+
spans.push({ start: m.index, end: m.index + m[0].length });
|
|
24346
24114
|
}
|
|
24347
|
-
|
|
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
|
-
};
|
|
24115
|
+
return spans;
|
|
24367
24116
|
}
|
|
24368
|
-
var
|
|
24369
|
-
var
|
|
24370
|
-
"src/
|
|
24117
|
+
var HTML_COMMENT_PAIR, HTML_COMMENT;
|
|
24118
|
+
var init_markdown = __esm({
|
|
24119
|
+
"src/utils/markdown.ts"() {
|
|
24371
24120
|
"use strict";
|
|
24372
24121
|
init_define_WEB_FIRST_SEGMENTS();
|
|
24373
|
-
|
|
24374
|
-
|
|
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;
|
|
24122
|
+
HTML_COMMENT_PAIR = /<!--[\s\S]*?-->/g;
|
|
24123
|
+
HTML_COMMENT = /<!--/;
|
|
24401
24124
|
}
|
|
24402
24125
|
});
|
|
24403
24126
|
|
|
24404
24127
|
// src/core/checks/commands.ts
|
|
24405
|
-
import * as
|
|
24406
|
-
import * as
|
|
24128
|
+
import * as fs6 from "node:fs";
|
|
24129
|
+
import * as path7 from "node:path";
|
|
24407
24130
|
function isManagerBuiltin(manager, sub) {
|
|
24408
24131
|
return PM_MANAGER_BUILTINS[manager]?.has(sub) ?? false;
|
|
24409
24132
|
}
|
|
@@ -24444,7 +24167,7 @@ function loadDeniedCommandPrefixes(projectRoot) {
|
|
|
24444
24167
|
for (const rel of ["settings.json", "settings.local.json"]) {
|
|
24445
24168
|
let content;
|
|
24446
24169
|
try {
|
|
24447
|
-
content = stripBom(
|
|
24170
|
+
content = stripBom(fs6.readFileSync(path7.join(projectRoot, ".claude", rel), "utf-8"));
|
|
24448
24171
|
} catch {
|
|
24449
24172
|
continue;
|
|
24450
24173
|
}
|
|
@@ -24463,21 +24186,48 @@ function loadDeniedCommandPrefixes(projectRoot) {
|
|
|
24463
24186
|
function isDeniedCommand(cmd, deniedPrefixes) {
|
|
24464
24187
|
return deniedPrefixes.some((p2) => cmd === p2 || cmd.startsWith(`${p2} `));
|
|
24465
24188
|
}
|
|
24466
|
-
function
|
|
24467
|
-
const
|
|
24189
|
+
function narrowAtContrastComma(clause) {
|
|
24190
|
+
const idx = clause.lastIndexOf(",");
|
|
24191
|
+
if (idx === -1) return clause;
|
|
24192
|
+
const tail = clause.slice(idx + 1);
|
|
24193
|
+
return RECOMMENDATION_VERB.test(tail) ? tail : clause;
|
|
24194
|
+
}
|
|
24195
|
+
function isProhibitedAt(rawLine, offset) {
|
|
24196
|
+
const line = stripInlineHtmlComments(rawLine);
|
|
24468
24197
|
const masked = maskCodeSpans(line, inlineCodeSpans(line));
|
|
24469
|
-
const prefix = masked.slice(0, Math.max(0,
|
|
24470
|
-
const clause = prefix.split(CLAUSE_TERMINATOR).pop() ?? "";
|
|
24198
|
+
const prefix = masked.slice(0, Math.max(0, offset));
|
|
24199
|
+
const clause = narrowAtContrastComma(prefix.split(CLAUSE_TERMINATOR).pop() ?? "");
|
|
24471
24200
|
return PROHIBITION_TOKEN.test(clause);
|
|
24472
24201
|
}
|
|
24202
|
+
function isProhibitedRef(lines, ref) {
|
|
24203
|
+
return isProhibitedAt(lines[ref.line - 1] ?? "", ref.column - 1);
|
|
24204
|
+
}
|
|
24205
|
+
function isProhibitedInvocation(lines, inv) {
|
|
24206
|
+
const line = lines[inv.line - 1] ?? "";
|
|
24207
|
+
for (const span of inlineCodeSpans(line)) {
|
|
24208
|
+
if (span.content.trim() === inv.cmd) return isProhibitedAt(line, span.start);
|
|
24209
|
+
}
|
|
24210
|
+
const idx = line.indexOf(inv.cmd);
|
|
24211
|
+
return idx < 0 ? false : isProhibitedAt(line, idx);
|
|
24212
|
+
}
|
|
24473
24213
|
async function checkCommands(file2, projectRoot) {
|
|
24474
24214
|
const issues = [];
|
|
24475
24215
|
const pkgJson = loadPackageJson(projectRoot);
|
|
24476
24216
|
const makefile = loadMakefile(projectRoot);
|
|
24477
24217
|
const deniedPrefixes = loadDeniedCommandPrefixes(projectRoot);
|
|
24478
24218
|
const contentLines = file2.content.split("\n");
|
|
24219
|
+
const commentLines = htmlCommentLineMask(contentLines);
|
|
24220
|
+
const inHtmlComment = (line, column) => {
|
|
24221
|
+
if (commentLines[line - 1] === true) return true;
|
|
24222
|
+
const offset = column - 1;
|
|
24223
|
+
return htmlCommentSpans(contentLines[line - 1] ?? "").some(
|
|
24224
|
+
(s) => offset >= s.start && offset < s.end
|
|
24225
|
+
);
|
|
24226
|
+
};
|
|
24479
24227
|
if (!pkgJson) {
|
|
24480
|
-
const skipped = file2.references.commands.find(
|
|
24228
|
+
const skipped = file2.references.commands.find(
|
|
24229
|
+
(ref) => wouldNeedPackageJson(ref.value) && !inHtmlComment(ref.line, ref.column) && !isDeniedCommand(ref.value, deniedPrefixes) && !isProhibitedRef(contentLines, ref)
|
|
24230
|
+
);
|
|
24481
24231
|
if (skipped) {
|
|
24482
24232
|
issues.push({
|
|
24483
24233
|
severity: "info",
|
|
@@ -24489,8 +24239,11 @@ async function checkCommands(file2, projectRoot) {
|
|
|
24489
24239
|
});
|
|
24490
24240
|
}
|
|
24491
24241
|
}
|
|
24492
|
-
issues.push(
|
|
24242
|
+
issues.push(
|
|
24243
|
+
...checkUnknownSubcommand(file2, projectRoot, pkgJson, deniedPrefixes, contentLines)
|
|
24244
|
+
);
|
|
24493
24245
|
for (const ref of file2.references.commands) {
|
|
24246
|
+
if (inHtmlComment(ref.line, ref.column)) continue;
|
|
24494
24247
|
const cmd = ref.value;
|
|
24495
24248
|
const masked = analyzeMaskedExitStatus(cmd, pkgJson?.scripts);
|
|
24496
24249
|
if (masked && !pipefailInScope(file2.content, ref.line)) {
|
|
@@ -24504,6 +24257,7 @@ async function checkCommands(file2, projectRoot) {
|
|
|
24504
24257
|
suggestion: "Add `set -o pipefail` before the pipeline, drop the filter, or read `${PIPESTATUS[0]}` instead of `$?`."
|
|
24505
24258
|
});
|
|
24506
24259
|
}
|
|
24260
|
+
if (isDeniedCommand(cmd, deniedPrefixes) || isProhibitedRef(contentLines, ref)) continue;
|
|
24507
24261
|
const scriptMatch = cmd.match(NPM_SCRIPT_PATTERN);
|
|
24508
24262
|
if (scriptMatch && pkgJson) {
|
|
24509
24263
|
const scriptName = scriptNameFromMatch(scriptMatch);
|
|
@@ -24546,11 +24300,9 @@ async function checkCommands(file2, projectRoot) {
|
|
|
24546
24300
|
...pkgJson.optionalDependencies
|
|
24547
24301
|
};
|
|
24548
24302
|
if (!(pkgName in allDeps)) {
|
|
24549
|
-
|
|
24550
|
-
if (isProhibitedMention(contentLines, ref)) continue;
|
|
24551
|
-
const binPath = path8.join(projectRoot, "node_modules", ".bin", pkgName);
|
|
24303
|
+
const binPath = path7.join(projectRoot, "node_modules", ".bin", pkgName);
|
|
24552
24304
|
try {
|
|
24553
|
-
|
|
24305
|
+
fs6.accessSync(binPath);
|
|
24554
24306
|
} catch {
|
|
24555
24307
|
issues.push({
|
|
24556
24308
|
severity: "warning",
|
|
@@ -24598,9 +24350,9 @@ async function checkCommands(file2, projectRoot) {
|
|
|
24598
24350
|
...pkgJson.optionalDependencies
|
|
24599
24351
|
};
|
|
24600
24352
|
if (!(pkgName in allDeps)) {
|
|
24601
|
-
const binPath =
|
|
24353
|
+
const binPath = path7.join(projectRoot, "node_modules", ".bin", tool);
|
|
24602
24354
|
try {
|
|
24603
|
-
|
|
24355
|
+
fs6.accessSync(binPath);
|
|
24604
24356
|
} catch {
|
|
24605
24357
|
issues.push({
|
|
24606
24358
|
severity: "warning",
|
|
@@ -24615,7 +24367,7 @@ async function checkCommands(file2, projectRoot) {
|
|
|
24615
24367
|
}
|
|
24616
24368
|
return issues;
|
|
24617
24369
|
}
|
|
24618
|
-
function checkUnknownSubcommand(file2, projectRoot, pkgJson) {
|
|
24370
|
+
function checkUnknownSubcommand(file2, projectRoot, pkgJson, deniedPrefixes, contentLines) {
|
|
24619
24371
|
const bins = ownedBins(pkgJson);
|
|
24620
24372
|
if (bins.length === 0) return [];
|
|
24621
24373
|
const invocations = findBinInvocations(file2.content, new Set(bins.map((b2) => b2.name)));
|
|
@@ -24623,6 +24375,8 @@ function checkUnknownSubcommand(file2, projectRoot, pkgJson) {
|
|
|
24623
24375
|
const resolved = /* @__PURE__ */ new Map();
|
|
24624
24376
|
const issues = [];
|
|
24625
24377
|
for (const inv of invocations) {
|
|
24378
|
+
if (isDeniedCommand(inv.cmd, deniedPrefixes)) continue;
|
|
24379
|
+
if (isProhibitedInvocation(contentLines, inv)) continue;
|
|
24626
24380
|
if (!resolved.has(inv.bin)) {
|
|
24627
24381
|
const bin = bins.find((b2) => b2.name === inv.bin);
|
|
24628
24382
|
resolved.set(inv.bin, bin ? knownSubcommands(projectRoot, bin.entry) : null);
|
|
@@ -24644,7 +24398,7 @@ function checkUnknownSubcommand(file2, projectRoot, pkgJson) {
|
|
|
24644
24398
|
}
|
|
24645
24399
|
function loadMakefile(projectRoot) {
|
|
24646
24400
|
try {
|
|
24647
|
-
return stripBom(
|
|
24401
|
+
return stripBom(fs6.readFileSync(path7.join(projectRoot, "Makefile"), "utf-8"));
|
|
24648
24402
|
} catch {
|
|
24649
24403
|
return null;
|
|
24650
24404
|
}
|
|
@@ -24662,7 +24416,7 @@ function hasMakeTarget(makefile, target) {
|
|
|
24662
24416
|
const pattern = new RegExp(`^${escaped}\\s*:(?!:?=)`, "m");
|
|
24663
24417
|
return pattern.test(makefile);
|
|
24664
24418
|
}
|
|
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;
|
|
24419
|
+
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, RECOMMENDATION_VERB;
|
|
24666
24420
|
var init_commands = __esm({
|
|
24667
24421
|
"src/core/checks/commands.ts"() {
|
|
24668
24422
|
"use strict";
|
|
@@ -24671,7 +24425,7 @@ var init_commands = __esm({
|
|
|
24671
24425
|
init_fs();
|
|
24672
24426
|
init_exit_status();
|
|
24673
24427
|
init_cli_subcommands();
|
|
24674
|
-
|
|
24428
|
+
init_markdown();
|
|
24675
24429
|
NPM_SCRIPT_PATTERN = /^(?:npm\s+run|(pnpm|yarn|bun)(?:\s+(run))?)\s+(\S+)/;
|
|
24676
24430
|
MAKE_PATTERN = /^make\s+\S/;
|
|
24677
24431
|
PM_BUILTIN_SUBCOMMANDS = /* @__PURE__ */ new Set([
|
|
@@ -24743,17 +24497,18 @@ var init_commands = __esm({
|
|
|
24743
24497
|
PKG_SHORTHAND_PATTERN = /^(npm|pnpm|yarn|bun)\s+(test|start|build|dev|lint|format|check|typecheck|clean|serve|preview|e2e)\b/;
|
|
24744
24498
|
PROHIBITION_TOKEN = /\b(?:never|don['’]?t|do\s+not|must\s+not|avoid)\b/i;
|
|
24745
24499
|
CLAUSE_TERMINATOR = /[.!?;—]|\s--(?=\s|$)/;
|
|
24500
|
+
RECOMMENDATION_VERB = /\b(?:run|use|call|invoke|execute|prefer|deploy|install)\b/i;
|
|
24746
24501
|
}
|
|
24747
24502
|
});
|
|
24748
24503
|
|
|
24749
24504
|
// src/core/checks/staleness.ts
|
|
24750
|
-
import * as
|
|
24505
|
+
import * as path8 from "node:path";
|
|
24751
24506
|
async function checkStaleness(file2, projectRoot) {
|
|
24752
24507
|
const issues = [];
|
|
24753
24508
|
if (!await isGitRepo(projectRoot)) {
|
|
24754
24509
|
return issues;
|
|
24755
24510
|
}
|
|
24756
|
-
const relativePath =
|
|
24511
|
+
const relativePath = path8.relative(projectRoot, file2.filePath).replace(/\\/g, "/");
|
|
24757
24512
|
const lastModified = await getFileLastModified(projectRoot, relativePath);
|
|
24758
24513
|
if (!lastModified || isNaN(lastModified.getTime())) {
|
|
24759
24514
|
return issues;
|
|
@@ -24869,6 +24624,344 @@ var init_suppressions = __esm({
|
|
|
24869
24624
|
}
|
|
24870
24625
|
});
|
|
24871
24626
|
|
|
24627
|
+
// src/core/checks/tokens.ts
|
|
24628
|
+
function resolveTokenThresholds(overrides) {
|
|
24629
|
+
if (!overrides) return DEFAULT_TOKEN_THRESHOLDS;
|
|
24630
|
+
const merged = { ...DEFAULT_TOKEN_THRESHOLDS, ...overrides };
|
|
24631
|
+
if (merged.info >= merged.warning || merged.warning >= merged.error) {
|
|
24632
|
+
console.error(
|
|
24633
|
+
`Warning: token thresholds should satisfy info < warning < error (got ${merged.info}, ${merged.warning}, ${merged.error}) \u2014 using defaults`
|
|
24634
|
+
);
|
|
24635
|
+
return DEFAULT_TOKEN_THRESHOLDS;
|
|
24636
|
+
}
|
|
24637
|
+
return merged;
|
|
24638
|
+
}
|
|
24639
|
+
async function checkTokens(file2, _projectRoot, thresholds = DEFAULT_TOKEN_THRESHOLDS) {
|
|
24640
|
+
const issues = [];
|
|
24641
|
+
const tokens = file2.totalTokens;
|
|
24642
|
+
if (tokens >= thresholds.error) {
|
|
24643
|
+
issues.push({
|
|
24644
|
+
severity: "error",
|
|
24645
|
+
check: "tokens",
|
|
24646
|
+
ruleId: "tokens/excessive",
|
|
24647
|
+
line: 1,
|
|
24648
|
+
message: `${tokens.toLocaleString()} tokens \u2014 consumes significant context window space`,
|
|
24649
|
+
suggestion: "Consider splitting into focused sections or removing redundant content."
|
|
24650
|
+
});
|
|
24651
|
+
} else if (tokens >= thresholds.warning) {
|
|
24652
|
+
issues.push({
|
|
24653
|
+
severity: "warning",
|
|
24654
|
+
check: "tokens",
|
|
24655
|
+
ruleId: "tokens/large",
|
|
24656
|
+
line: 1,
|
|
24657
|
+
message: `${tokens.toLocaleString()} tokens \u2014 large context file`,
|
|
24658
|
+
suggestion: "Consider trimming \u2014 research shows diminishing returns past ~300 lines."
|
|
24659
|
+
});
|
|
24660
|
+
} else if (tokens >= thresholds.info) {
|
|
24661
|
+
issues.push({
|
|
24662
|
+
severity: "info",
|
|
24663
|
+
check: "tokens",
|
|
24664
|
+
ruleId: "tokens/info",
|
|
24665
|
+
line: 1,
|
|
24666
|
+
message: `Uses ~${tokens.toLocaleString()} tokens per session`
|
|
24667
|
+
});
|
|
24668
|
+
}
|
|
24669
|
+
return issues;
|
|
24670
|
+
}
|
|
24671
|
+
function checkAggregateTokens(files, thresholds = DEFAULT_TOKEN_THRESHOLDS) {
|
|
24672
|
+
const total = files.reduce((sum, f) => sum + f.tokens, 0);
|
|
24673
|
+
if (total >= thresholds.aggregate && files.length > 1) {
|
|
24674
|
+
return {
|
|
24675
|
+
severity: "warning",
|
|
24676
|
+
check: "tokens",
|
|
24677
|
+
ruleId: "tokens/aggregate",
|
|
24678
|
+
line: 0,
|
|
24679
|
+
message: `${files.length} context files consume ${total.toLocaleString()} tokens combined`,
|
|
24680
|
+
suggestion: "Consider consolidating or trimming to reduce per-session context cost."
|
|
24681
|
+
};
|
|
24682
|
+
}
|
|
24683
|
+
return null;
|
|
24684
|
+
}
|
|
24685
|
+
var DEFAULT_TOKEN_THRESHOLDS;
|
|
24686
|
+
var init_tokens2 = __esm({
|
|
24687
|
+
"src/core/checks/tokens.ts"() {
|
|
24688
|
+
"use strict";
|
|
24689
|
+
init_define_WEB_FIRST_SEGMENTS();
|
|
24690
|
+
DEFAULT_TOKEN_THRESHOLDS = {
|
|
24691
|
+
info: 1e3,
|
|
24692
|
+
warning: 3e3,
|
|
24693
|
+
error: 8e3,
|
|
24694
|
+
aggregate: 5e3,
|
|
24695
|
+
tierBreakdown: 1e3,
|
|
24696
|
+
tierAggregate: 4e3
|
|
24697
|
+
};
|
|
24698
|
+
}
|
|
24699
|
+
});
|
|
24700
|
+
|
|
24701
|
+
// src/core/checks/tier-tokens.ts
|
|
24702
|
+
import * as fs7 from "node:fs";
|
|
24703
|
+
import * as os2 from "node:os";
|
|
24704
|
+
import * as path9 from "node:path";
|
|
24705
|
+
function hasFrontmatterList(content, field) {
|
|
24706
|
+
const lines = content.split("\n");
|
|
24707
|
+
if (lines[0]?.trim() !== "---") return false;
|
|
24708
|
+
const fieldPattern = new RegExp(`^${field}\\s*:\\s*(.*)$`);
|
|
24709
|
+
for (let i2 = 1; i2 < lines.length; i2++) {
|
|
24710
|
+
const line = lines[i2];
|
|
24711
|
+
if (line.trim() === "---") return false;
|
|
24712
|
+
const match = line.match(fieldPattern);
|
|
24713
|
+
if (match) {
|
|
24714
|
+
const val = match[1].trim();
|
|
24715
|
+
if (val && val !== "[]") return true;
|
|
24716
|
+
for (let j3 = i2 + 1; j3 < lines.length; j3++) {
|
|
24717
|
+
const next = lines[j3];
|
|
24718
|
+
if (next.trim() === "---") return false;
|
|
24719
|
+
if (next.trim() === "") continue;
|
|
24720
|
+
if (next.trim().startsWith("- ")) return true;
|
|
24721
|
+
break;
|
|
24722
|
+
}
|
|
24723
|
+
}
|
|
24724
|
+
}
|
|
24725
|
+
return false;
|
|
24726
|
+
}
|
|
24727
|
+
function frontmatterScalar(content, field) {
|
|
24728
|
+
const lines = content.split("\n");
|
|
24729
|
+
if (lines[0]?.trim() !== "---") return null;
|
|
24730
|
+
const fieldPattern = new RegExp(`^${field}\\s*:\\s*(.*)$`);
|
|
24731
|
+
for (let i2 = 1; i2 < lines.length; i2++) {
|
|
24732
|
+
if (lines[i2].trim() === "---") return null;
|
|
24733
|
+
const match = lines[i2].match(fieldPattern);
|
|
24734
|
+
if (match) return match[1].trim().replace(/^['"]|['"]$/g, "") || null;
|
|
24735
|
+
}
|
|
24736
|
+
return null;
|
|
24737
|
+
}
|
|
24738
|
+
function isAlwaysLoaded(file2) {
|
|
24739
|
+
const rel = file2.relativePath.replace(/\\/g, "/");
|
|
24740
|
+
if (rel.endsWith(".mdc")) return false;
|
|
24741
|
+
if (rel.startsWith(".github/instructions/")) return false;
|
|
24742
|
+
if (rel.includes("/rules/")) {
|
|
24743
|
+
if (hasFrontmatterList(file2.content, "paths")) return false;
|
|
24744
|
+
if (hasFrontmatterList(file2.content, "globs")) return false;
|
|
24745
|
+
if (rel.includes(".windsurf/rules/")) {
|
|
24746
|
+
const trigger = frontmatterScalar(file2.content, "trigger");
|
|
24747
|
+
if (trigger && trigger.toLowerCase() !== "always_on") return false;
|
|
24748
|
+
}
|
|
24749
|
+
return true;
|
|
24750
|
+
}
|
|
24751
|
+
const basename4 = rel.split("/").pop() ?? "";
|
|
24752
|
+
for (const name of ALWAYS_LOADED_NAMES) {
|
|
24753
|
+
if (name.includes("/")) {
|
|
24754
|
+
if (rel === name || rel.endsWith("/" + name)) return true;
|
|
24755
|
+
} else if (basename4 === name) {
|
|
24756
|
+
return true;
|
|
24757
|
+
}
|
|
24758
|
+
}
|
|
24759
|
+
return false;
|
|
24760
|
+
}
|
|
24761
|
+
function computeSectionCosts(file2) {
|
|
24762
|
+
if (file2.sections.length === 0) return [];
|
|
24763
|
+
const hasH2 = file2.sections.some((s) => s.level === 2);
|
|
24764
|
+
const topLevel = hasH2 ? 2 : 1;
|
|
24765
|
+
const lines = file2.content.split("\n");
|
|
24766
|
+
return file2.sections.filter((s) => s.level === topLevel).map((s) => {
|
|
24767
|
+
const body = lines.slice(s.startLine - 1, s.endLine).join("\n");
|
|
24768
|
+
return { title: s.title, line: s.startLine, tokens: countTokens(body) };
|
|
24769
|
+
}).sort((a, b2) => b2.tokens - a.tokens);
|
|
24770
|
+
}
|
|
24771
|
+
function findInviolableCommand(line) {
|
|
24772
|
+
const spans = inlineCodeSpans(line);
|
|
24773
|
+
if (spans.length === 0) return null;
|
|
24774
|
+
const masked = maskCodeSpans(line, spans);
|
|
24775
|
+
FRAMING_TOKEN.lastIndex = 0;
|
|
24776
|
+
let m;
|
|
24777
|
+
while ((m = FRAMING_TOKEN.exec(masked)) !== null) {
|
|
24778
|
+
const token = m[1];
|
|
24779
|
+
if (token === token.toLowerCase()) continue;
|
|
24780
|
+
const afterIdx = m.index + m[0].length;
|
|
24781
|
+
const span = spans.find((s) => s.start >= afterIdx);
|
|
24782
|
+
if (!span) continue;
|
|
24783
|
+
const gap = masked.slice(afterIdx, span.start);
|
|
24784
|
+
if (gap.length > FRAMING_COMMAND_GAP || /[.!?]/.test(gap)) continue;
|
|
24785
|
+
return { framing: token, command: span.content };
|
|
24786
|
+
}
|
|
24787
|
+
return null;
|
|
24788
|
+
}
|
|
24789
|
+
function fingerprintFiles(paths) {
|
|
24790
|
+
return paths.map((p2) => {
|
|
24791
|
+
try {
|
|
24792
|
+
const st2 = fs7.statSync(p2);
|
|
24793
|
+
return `${st2.mtimeMs}:${st2.size}`;
|
|
24794
|
+
} catch {
|
|
24795
|
+
return "absent";
|
|
24796
|
+
}
|
|
24797
|
+
}).join("|");
|
|
24798
|
+
}
|
|
24799
|
+
function loadSettingsSources(projectRoot, includeGlobal) {
|
|
24800
|
+
const candidates = [
|
|
24801
|
+
path9.join(projectRoot, ".claude", "settings.json"),
|
|
24802
|
+
path9.join(projectRoot, ".claude", "settings.local.json")
|
|
24803
|
+
];
|
|
24804
|
+
if (includeGlobal) {
|
|
24805
|
+
candidates.push(path9.join(os2.homedir(), ".claude", "settings.json"));
|
|
24806
|
+
}
|
|
24807
|
+
const fingerprint = fingerprintFiles(candidates);
|
|
24808
|
+
if (settingsCache?.root === projectRoot && settingsCache.includeGlobal === includeGlobal && settingsCache.fingerprint === fingerprint) {
|
|
24809
|
+
return settingsCache.data;
|
|
24810
|
+
}
|
|
24811
|
+
const sources = [];
|
|
24812
|
+
for (const p2 of candidates) {
|
|
24813
|
+
let content;
|
|
24814
|
+
try {
|
|
24815
|
+
content = stripBom(fs7.readFileSync(p2, "utf-8"));
|
|
24816
|
+
} catch {
|
|
24817
|
+
continue;
|
|
24818
|
+
}
|
|
24819
|
+
const errors = [];
|
|
24820
|
+
const data = parse2(content, errors, { allowTrailingComma: true });
|
|
24821
|
+
if (errors.length > 0) {
|
|
24822
|
+
console.warn(
|
|
24823
|
+
`ctxlint: could not parse ${p2}: ${printParseErrorCode(errors[0].error)} at offset ${errors[0].offset}`
|
|
24824
|
+
);
|
|
24825
|
+
continue;
|
|
24826
|
+
}
|
|
24827
|
+
if (!data || typeof data !== "object") continue;
|
|
24828
|
+
sources.push(data);
|
|
24829
|
+
}
|
|
24830
|
+
settingsCache = { root: projectRoot, includeGlobal, fingerprint, data: sources };
|
|
24831
|
+
return sources;
|
|
24832
|
+
}
|
|
24833
|
+
function canonicalizeCommand(backticked) {
|
|
24834
|
+
const beforeFlags = backticked.trim().split(/\s+--?/, 1)[0];
|
|
24835
|
+
return beforeFlags.replace(/\s+/g, " ");
|
|
24836
|
+
}
|
|
24837
|
+
function buildCommandPattern(cmd) {
|
|
24838
|
+
const tokens = cmd.split(/\s+/).filter(Boolean);
|
|
24839
|
+
const escaped = tokens.map((t2) => t2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
24840
|
+
if (tokens.length === 1) {
|
|
24841
|
+
return new RegExp(`(?<![A-Za-z0-9_\\-])${escaped[0]}(?![A-Za-z0-9_\\-])`, "i");
|
|
24842
|
+
}
|
|
24843
|
+
const body = escaped.join("[\\s\\-_]+");
|
|
24844
|
+
return new RegExp(`(?<![A-Za-z0-9])${body}(?![A-Za-z0-9])`, "i");
|
|
24845
|
+
}
|
|
24846
|
+
function commandIsEnforced(cmd, settings) {
|
|
24847
|
+
const pattern = buildCommandPattern(cmd);
|
|
24848
|
+
for (const s of settings) {
|
|
24849
|
+
for (const entry of [...s.permissions?.deny ?? [], ...s.permissions?.ask ?? []]) {
|
|
24850
|
+
if (pattern.test(entry)) return true;
|
|
24851
|
+
}
|
|
24852
|
+
for (const h2 of [...s.hooks?.PreToolUse ?? [], ...s.hooks?.Stop ?? []]) {
|
|
24853
|
+
if (pattern.test(h2.matcher || "")) return true;
|
|
24854
|
+
for (const sub of h2.hooks ?? []) {
|
|
24855
|
+
if (pattern.test(sub.command || "")) return true;
|
|
24856
|
+
}
|
|
24857
|
+
}
|
|
24858
|
+
}
|
|
24859
|
+
return false;
|
|
24860
|
+
}
|
|
24861
|
+
function checkHardEnforcement(file2, settings) {
|
|
24862
|
+
const issues = [];
|
|
24863
|
+
const lines = file2.content.split("\n");
|
|
24864
|
+
const nonProse = nonProseLineMask(lines);
|
|
24865
|
+
for (let i2 = 0; i2 < lines.length; i2++) {
|
|
24866
|
+
if (nonProse[i2]) continue;
|
|
24867
|
+
const line = stripInlineHtmlComments(lines[i2]);
|
|
24868
|
+
const match = findInviolableCommand(line);
|
|
24869
|
+
if (!match) continue;
|
|
24870
|
+
const cmd = canonicalizeCommand(match.command);
|
|
24871
|
+
if (!cmd) continue;
|
|
24872
|
+
if (commandIsEnforced(cmd, settings)) continue;
|
|
24873
|
+
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.`;
|
|
24874
|
+
issues.push({
|
|
24875
|
+
severity: "info",
|
|
24876
|
+
check: "tier-tokens",
|
|
24877
|
+
ruleId: "tier-tokens/hard-enforcement-missing",
|
|
24878
|
+
line: i2 + 1,
|
|
24879
|
+
message: `Inviolable framing ("${line.trim().slice(0, 80)}") without a hook to back it up`,
|
|
24880
|
+
suggestion
|
|
24881
|
+
});
|
|
24882
|
+
}
|
|
24883
|
+
return issues;
|
|
24884
|
+
}
|
|
24885
|
+
async function checkTierTokens(file2, projectRoot, thresholds = DEFAULT_TOKEN_THRESHOLDS, includeGlobal = false) {
|
|
24886
|
+
if (!isAlwaysLoaded(file2)) return [];
|
|
24887
|
+
const issues = [];
|
|
24888
|
+
const threshold = thresholds.tierBreakdown;
|
|
24889
|
+
if (file2.totalTokens >= threshold) {
|
|
24890
|
+
const sectionCosts = computeSectionCosts(file2);
|
|
24891
|
+
if (sectionCosts.length > 0) {
|
|
24892
|
+
const top = sectionCosts.slice(0, TOP_SECTIONS_TO_REPORT);
|
|
24893
|
+
const heaviest = top[0];
|
|
24894
|
+
const pct = Math.round(heaviest.tokens / file2.totalTokens * 100);
|
|
24895
|
+
const detail = top.map((s) => ` - "${s.title}" (L${s.line}): ~${s.tokens.toLocaleString()} tokens`).join("\n");
|
|
24896
|
+
issues.push({
|
|
24897
|
+
severity: "info",
|
|
24898
|
+
check: "tier-tokens",
|
|
24899
|
+
ruleId: "tier-tokens/section-breakdown",
|
|
24900
|
+
line: heaviest.line,
|
|
24901
|
+
message: `${file2.totalTokens.toLocaleString()} tokens loaded every session \u2014 heaviest top-level section${top.length === 1 ? "" : "s"}:`,
|
|
24902
|
+
detail,
|
|
24903
|
+
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.`
|
|
24904
|
+
});
|
|
24905
|
+
}
|
|
24906
|
+
}
|
|
24907
|
+
const settings = loadSettingsSources(projectRoot, includeGlobal);
|
|
24908
|
+
issues.push(...checkHardEnforcement(file2, settings));
|
|
24909
|
+
return issues;
|
|
24910
|
+
}
|
|
24911
|
+
function checkAggregateTierTokens(files, thresholds = DEFAULT_TOKEN_THRESHOLDS) {
|
|
24912
|
+
const alwaysLoaded = files.filter(isAlwaysLoaded);
|
|
24913
|
+
if (alwaysLoaded.length < 2) return null;
|
|
24914
|
+
const total = alwaysLoaded.reduce((sum, f) => sum + f.totalTokens, 0);
|
|
24915
|
+
const threshold = thresholds.tierAggregate;
|
|
24916
|
+
if (total < threshold) return null;
|
|
24917
|
+
const breakdown = alwaysLoaded.slice().sort((a, b2) => b2.totalTokens - a.totalTokens).slice(0, 5).map((f) => ` - ${f.relativePath}: ~${f.totalTokens.toLocaleString()} tokens`).join("\n");
|
|
24918
|
+
return {
|
|
24919
|
+
severity: "warning",
|
|
24920
|
+
check: "tier-tokens",
|
|
24921
|
+
ruleId: "tier-tokens/aggregate",
|
|
24922
|
+
line: 0,
|
|
24923
|
+
message: `${alwaysLoaded.length} always-loaded files total ${total.toLocaleString()} tokens \u2014 loaded every session`,
|
|
24924
|
+
detail: breakdown,
|
|
24925
|
+
suggestion: "Consider moving the largest files or their heaviest sections to on-demand tiers (skills, subagents, memory)."
|
|
24926
|
+
};
|
|
24927
|
+
}
|
|
24928
|
+
var ALWAYS_LOADED_NAMES, TOP_SECTIONS_TO_REPORT, FRAMING_TOKEN, FRAMING_COMMAND_GAP, settingsCache;
|
|
24929
|
+
var init_tier_tokens = __esm({
|
|
24930
|
+
"src/core/checks/tier-tokens.ts"() {
|
|
24931
|
+
"use strict";
|
|
24932
|
+
init_define_WEB_FIRST_SEGMENTS();
|
|
24933
|
+
init_main3();
|
|
24934
|
+
init_tokens();
|
|
24935
|
+
init_fs();
|
|
24936
|
+
init_markdown();
|
|
24937
|
+
init_tokens2();
|
|
24938
|
+
ALWAYS_LOADED_NAMES = [
|
|
24939
|
+
"CLAUDE.md",
|
|
24940
|
+
"CLAUDE.local.md",
|
|
24941
|
+
"AGENTS.md",
|
|
24942
|
+
"AGENTS.override.md",
|
|
24943
|
+
"AGENT.md",
|
|
24944
|
+
"GEMINI.md",
|
|
24945
|
+
".cursorrules",
|
|
24946
|
+
".windsurfrules",
|
|
24947
|
+
".clinerules",
|
|
24948
|
+
".aiderules",
|
|
24949
|
+
".continuerules",
|
|
24950
|
+
".rules",
|
|
24951
|
+
".goosehints",
|
|
24952
|
+
"replit.md",
|
|
24953
|
+
".github/copilot-instructions.md",
|
|
24954
|
+
".junie/guidelines.md",
|
|
24955
|
+
".junie/AGENTS.md",
|
|
24956
|
+
".goose/instructions.md"
|
|
24957
|
+
];
|
|
24958
|
+
TOP_SECTIONS_TO_REPORT = 3;
|
|
24959
|
+
FRAMING_TOKEN = /(?<![\w'.-])(never|always|don'?t|do\s+not|must\s+not)(?![\w'.-])/gi;
|
|
24960
|
+
FRAMING_COMMAND_GAP = 80;
|
|
24961
|
+
settingsCache = null;
|
|
24962
|
+
}
|
|
24963
|
+
});
|
|
24964
|
+
|
|
24872
24965
|
// src/utils/similarity.ts
|
|
24873
24966
|
function jaccardSimilarityFromSets(a, b2, opts = {}) {
|
|
24874
24967
|
const bothEmptyIsIdentical = opts.bothEmptyIsIdentical ?? false;
|
|
@@ -29015,7 +29108,7 @@ import { readFileSync as readFileSync8 } from "node:fs";
|
|
|
29015
29108
|
import { resolve as resolve15, dirname as dirname7 } from "node:path";
|
|
29016
29109
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
29017
29110
|
function loadVersion() {
|
|
29018
|
-
if (true) return "0.
|
|
29111
|
+
if (true) return "0.24.0";
|
|
29019
29112
|
try {
|
|
29020
29113
|
const __dir = dirname7(fileURLToPath2(import.meta.url));
|
|
29021
29114
|
const pkgPath = resolve15(__dir, "../package.json");
|
|
@@ -70107,6 +70200,13 @@ __export(cli_exports, {
|
|
|
70107
70200
|
});
|
|
70108
70201
|
import * as fs15 from "node:fs";
|
|
70109
70202
|
import * as path20 from "node:path";
|
|
70203
|
+
function existsAsDirectory(p2) {
|
|
70204
|
+
try {
|
|
70205
|
+
return fs15.statSync(p2).isDirectory();
|
|
70206
|
+
} catch {
|
|
70207
|
+
return false;
|
|
70208
|
+
}
|
|
70209
|
+
}
|
|
70110
70210
|
function validateCheckNames(names, source) {
|
|
70111
70211
|
const invalid = names.filter((n7) => n7 && !VALID_CHECKS.has(n7));
|
|
70112
70212
|
if (invalid.length > 0) {
|
|
@@ -70130,6 +70230,12 @@ async function runCli() {
|
|
|
70130
70230
|
false
|
|
70131
70231
|
).option("--no-ignore-file", "Disable .ctxlintignore suppression (see all findings)").option("--watch", "Re-lint on context file changes", false).action(async (projectPath, opts) => {
|
|
70132
70232
|
const resolvedPath = path20.resolve(projectPath);
|
|
70233
|
+
if (!existsAsDirectory(resolvedPath)) {
|
|
70234
|
+
console.error(
|
|
70235
|
+
`Error: ${resolvedPath} is not an existing directory. Check the path argument.`
|
|
70236
|
+
);
|
|
70237
|
+
process.exit(2);
|
|
70238
|
+
}
|
|
70133
70239
|
const { config: config2, options, activeChecks } = resolveSession(resolvedPath, opts);
|
|
70134
70240
|
const spinner = options.format === "text" && !options.quiet ? ora("Scanning for context files...").start() : void 0;
|
|
70135
70241
|
try {
|
|
@@ -70448,7 +70554,7 @@ function resolveDepth(raw) {
|
|
|
70448
70554
|
}
|
|
70449
70555
|
function resolveSession(resolvedPath, opts, throwOnConfigError = false) {
|
|
70450
70556
|
const configPath = opts.config ? path20.resolve(opts.config) : void 0;
|
|
70451
|
-
const config2 = configPath ? loadConfigFromPath(configPath, throwOnConfigError) :
|
|
70557
|
+
const config2 = configPath ? loadConfigFromPath(configPath, throwOnConfigError) : loadDiscoveredConfig(resolvedPath, throwOnConfigError);
|
|
70452
70558
|
const mcpGlobal = opts.mcpGlobal || config2?.mcpGlobal || false;
|
|
70453
70559
|
const mcpOnly = opts.mcpOnly || config2?.mcpOnly || false;
|
|
70454
70560
|
const mcpFlag = opts.mcp || mcpGlobal || mcpOnly || config2?.mcp || false;
|
|
@@ -70516,6 +70622,16 @@ function resolveSession(resolvedPath, opts, throwOnConfigError = false) {
|
|
|
70516
70622
|
const activeChecks = options.checks.filter((c3) => !options.ignore.includes(c3));
|
|
70517
70623
|
return { config: config2, options, activeChecks };
|
|
70518
70624
|
}
|
|
70625
|
+
function loadDiscoveredConfig(resolvedPath, throwOnError = false) {
|
|
70626
|
+
try {
|
|
70627
|
+
return loadConfig(resolvedPath);
|
|
70628
|
+
} catch (err) {
|
|
70629
|
+
if (throwOnError) throw err;
|
|
70630
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
70631
|
+
console.error(`Error: ${detail}`);
|
|
70632
|
+
process.exit(2);
|
|
70633
|
+
}
|
|
70634
|
+
}
|
|
70519
70635
|
function loadConfigFromPath(configPath, throwOnError = false) {
|
|
70520
70636
|
try {
|
|
70521
70637
|
return loadConfigFromExplicitPath(configPath);
|
package/package.json
CHANGED