taste-lint 0.0.11 → 0.2.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/README.md +22 -15
- package/data/config.schema.json +0 -44
- package/data/rules/schema/rule.schema.json +0 -3
- package/data/rules/typography/typography-straight-quotes.yaml +0 -1
- package/dist/cli.js +422 -1337
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +46 -110
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +211 -877
- package/dist/index.js.map +1 -1
- package/package.json +10 -30
- package/data/rules/copywriting/copywriting-announced-honesty.yaml +0 -43
- package/data/rules/copywriting/copywriting-changed-supplied-facts.yaml +0 -31
- package/data/rules/copywriting/copywriting-drafting-instruction-echo.yaml +0 -31
- package/data/rules/copywriting/copywriting-profile-register-mismatch.yaml +0 -31
- package/data/rules/copywriting/copywriting-unraised-objection.yaml +0 -43
- package/data/rules/copywriting/copywriting-vague-personal-request.yaml +0 -43
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { Command, CommanderError } from "commander";
|
|
2
|
+
import { Command, CommanderError, Option } from "commander";
|
|
4
3
|
import fs from "node:fs";
|
|
5
4
|
import path from "node:path";
|
|
6
5
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
@@ -10,13 +9,12 @@ import { fromMarkdown } from "mdast-util-from-markdown";
|
|
|
10
9
|
import { gfmFromMarkdown } from "mdast-util-gfm";
|
|
11
10
|
import { gfm } from "micromark-extension-gfm";
|
|
12
11
|
import { parseSync } from "oxc-parser";
|
|
13
|
-
import
|
|
12
|
+
import os from "node:os";
|
|
14
13
|
import { mdxFromMarkdown } from "mdast-util-mdx";
|
|
15
14
|
import { mdxjs } from "micromark-extension-mdxjs";
|
|
16
15
|
import { styleText } from "node:util";
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
var version = "0.0.11";
|
|
16
|
+
//#region packages/cli/package.json
|
|
17
|
+
var version = "0.2.0";
|
|
20
18
|
//#endregion
|
|
21
19
|
//#region src/lib/stats.ts
|
|
22
20
|
const makePRNG = (seed) => {
|
|
@@ -116,21 +114,6 @@ const calibrationTable = (pairs, buckets = 10) => Array.from({ length: buckets }
|
|
|
116
114
|
//#endregion
|
|
117
115
|
//#region src/rules/taxonomy.ts
|
|
118
116
|
const CATEGORIES = [
|
|
119
|
-
{
|
|
120
|
-
domain: "seo",
|
|
121
|
-
id: "search-discovery",
|
|
122
|
-
label: "Search discovery"
|
|
123
|
-
},
|
|
124
|
-
{
|
|
125
|
-
domain: "architecture",
|
|
126
|
-
id: "repository-contracts",
|
|
127
|
-
label: "Repository contracts"
|
|
128
|
-
},
|
|
129
|
-
{
|
|
130
|
-
domain: "dx",
|
|
131
|
-
id: "package-contracts",
|
|
132
|
-
label: "Package contracts"
|
|
133
|
-
},
|
|
134
117
|
{
|
|
135
118
|
domain: "authoring",
|
|
136
119
|
id: "instruction-quality",
|
|
@@ -340,9 +323,6 @@ const DOC_TYPES = [
|
|
|
340
323
|
"readme",
|
|
341
324
|
"skill",
|
|
342
325
|
"plan",
|
|
343
|
-
"personal",
|
|
344
|
-
"pr",
|
|
345
|
-
"slides",
|
|
346
326
|
"unknown"
|
|
347
327
|
];
|
|
348
328
|
const ROLES = [
|
|
@@ -365,10 +345,7 @@ const CONTEXT_KEYS = [
|
|
|
365
345
|
"docType",
|
|
366
346
|
"element",
|
|
367
347
|
"role",
|
|
368
|
-
"neighbours"
|
|
369
|
-
"writingFacts",
|
|
370
|
-
"writingProfile",
|
|
371
|
-
"writingInstructions"
|
|
348
|
+
"neighbours"
|
|
372
349
|
];
|
|
373
350
|
//#endregion
|
|
374
351
|
//#region src/eval/corpus.ts
|
|
@@ -1070,7 +1047,7 @@ const detectSmartQuotesAtBuild = (root) => SMARTYPANTS_FILES.some((file) => {
|
|
|
1070
1047
|
return false;
|
|
1071
1048
|
}
|
|
1072
1049
|
});
|
|
1073
|
-
const invalid
|
|
1050
|
+
const invalid = (key, expected) => {
|
|
1074
1051
|
throw new InputError("INVALID_CONFIG", `taste-lint.config.json: ${key} must be ${expected}.`, {
|
|
1075
1052
|
expected,
|
|
1076
1053
|
field: key
|
|
@@ -1078,14 +1055,14 @@ const invalid$1 = (key, expected) => {
|
|
|
1078
1055
|
};
|
|
1079
1056
|
function validateConfig(raw) {
|
|
1080
1057
|
const object = (value, key) => {
|
|
1081
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return invalid
|
|
1058
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return invalid(key, "an object");
|
|
1082
1059
|
return value;
|
|
1083
1060
|
};
|
|
1084
1061
|
const keys = (value, allowed, prefix) => {
|
|
1085
|
-
for (const key of Object.keys(value)) if (!allowed.includes(key)) invalid
|
|
1062
|
+
for (const key of Object.keys(value)) if (!allowed.includes(key)) invalid(`${prefix}${key}`, `one of ${allowed.join(", ")}`);
|
|
1086
1063
|
};
|
|
1087
1064
|
const strings = (value, key) => {
|
|
1088
|
-
if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) invalid
|
|
1065
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) invalid(key, "an array of strings");
|
|
1089
1066
|
};
|
|
1090
1067
|
const value = object(raw, "config");
|
|
1091
1068
|
keys(value, [
|
|
@@ -1094,48 +1071,18 @@ function validateConfig(raw) {
|
|
|
1094
1071
|
"smartQuotesAtBuild",
|
|
1095
1072
|
"docTypes",
|
|
1096
1073
|
"components",
|
|
1097
|
-
"tailwind"
|
|
1098
|
-
"architecture"
|
|
1074
|
+
"tailwind"
|
|
1099
1075
|
], "");
|
|
1100
|
-
if (value
|
|
1101
|
-
const policy = object(value.architecture, "architecture");
|
|
1102
|
-
keys(policy, [
|
|
1103
|
-
"boundaries",
|
|
1104
|
-
"deprecatedImports",
|
|
1105
|
-
"generated"
|
|
1106
|
-
], "architecture.");
|
|
1107
|
-
if (policy.generated !== void 0) strings(policy.generated, "architecture.generated");
|
|
1108
|
-
if (policy.deprecatedImports !== void 0) {
|
|
1109
|
-
const deprecated = object(policy.deprecatedImports, "architecture.deprecatedImports");
|
|
1110
|
-
for (const [key, replacement] of Object.entries(deprecated)) if (typeof replacement !== "string" || !replacement.trim()) invalid$1(`architecture.deprecatedImports.${key}`, "a nonempty replacement");
|
|
1111
|
-
}
|
|
1112
|
-
if (policy.boundaries !== void 0) {
|
|
1113
|
-
if (!Array.isArray(policy.boundaries)) invalid$1("architecture.boundaries", "an array");
|
|
1114
|
-
for (const [i, rawBoundary] of policy.boundaries.entries()) {
|
|
1115
|
-
const boundary = object(rawBoundary, `architecture.boundaries[${i}]`);
|
|
1116
|
-
keys(boundary, [
|
|
1117
|
-
"from",
|
|
1118
|
-
"disallow",
|
|
1119
|
-
"reason"
|
|
1120
|
-
], `architecture.boundaries[${i}].`);
|
|
1121
|
-
for (const key of [
|
|
1122
|
-
"from",
|
|
1123
|
-
"disallow",
|
|
1124
|
-
"reason"
|
|
1125
|
-
]) if (typeof boundary[key] !== "string" || !boundary[key].trim()) invalid$1(`architecture.boundaries[${i}].${key}`, "a nonempty string");
|
|
1126
|
-
}
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
if (value.$schema !== void 0 && typeof value.$schema !== "string") invalid$1("$schema", "a string");
|
|
1076
|
+
if (value.$schema !== void 0 && typeof value.$schema !== "string") invalid("$schema", "a string");
|
|
1130
1077
|
if (value.exclude !== void 0) strings(value.exclude, "exclude");
|
|
1131
|
-
if (value.smartQuotesAtBuild !== void 0 && typeof value.smartQuotesAtBuild !== "boolean") invalid
|
|
1078
|
+
if (value.smartQuotesAtBuild !== void 0 && typeof value.smartQuotesAtBuild !== "boolean") invalid("smartQuotesAtBuild", "a boolean");
|
|
1132
1079
|
if (value.docTypes !== void 0) {
|
|
1133
|
-
if (!Array.isArray(value.docTypes)) invalid
|
|
1080
|
+
if (!Array.isArray(value.docTypes)) invalid("docTypes", "an array");
|
|
1134
1081
|
for (const [i, entry] of value.docTypes.entries()) {
|
|
1135
1082
|
const row = object(entry, `docTypes[${i}]`);
|
|
1136
1083
|
keys(row, ["glob", "type"], `docTypes[${i}].`);
|
|
1137
|
-
if (typeof row.glob !== "string") invalid
|
|
1138
|
-
if (!DOC_TYPES.includes(row.type)) invalid
|
|
1084
|
+
if (typeof row.glob !== "string") invalid(`docTypes[${i}].glob`, "a string");
|
|
1085
|
+
if (!DOC_TYPES.includes(row.type)) invalid(`docTypes[${i}].type`, DOC_TYPES.join(", "));
|
|
1139
1086
|
}
|
|
1140
1087
|
}
|
|
1141
1088
|
if (value.components !== void 0) {
|
|
@@ -1148,7 +1095,7 @@ function validateConfig(raw) {
|
|
|
1148
1095
|
keys(t, ["theme"], "tailwind.");
|
|
1149
1096
|
if (t.theme !== void 0) {
|
|
1150
1097
|
const theme = object(t.theme, "tailwind.theme");
|
|
1151
|
-
for (const [key, val] of Object.entries(theme)) if (typeof val !== "string") invalid
|
|
1098
|
+
for (const [key, val] of Object.entries(theme)) if (typeof val !== "string") invalid(`tailwind.theme.${key}`, "a string");
|
|
1152
1099
|
}
|
|
1153
1100
|
}
|
|
1154
1101
|
}
|
|
@@ -1175,7 +1122,6 @@ const loadConfig = (root, defaults = []) => {
|
|
|
1175
1122
|
}
|
|
1176
1123
|
}
|
|
1177
1124
|
return {
|
|
1178
|
-
architecture: user.architecture,
|
|
1179
1125
|
components: {
|
|
1180
1126
|
skip: [...DEFAULT_SKIP, ...user.components?.skip ?? []],
|
|
1181
1127
|
unwrap: [...DEFAULT_UNWRAP, ...user.components?.unwrap ?? []]
|
|
@@ -1519,11 +1465,6 @@ const buildState = (unit, rules) => {
|
|
|
1519
1465
|
if (keys.has("element") && unit.context.element) lines.push(`ELEMENT: ${unit.context.element}${unit.context.attr ? ` (${unit.context.attr})` : ""}`);
|
|
1520
1466
|
if (keys.has("role")) lines.push(`ROLE: ${unit.context.role}`);
|
|
1521
1467
|
if (keys.has("section") && unit.context.section) lines.push(`SECTION: ${clip("SECTION: ", unit.context.section)}`);
|
|
1522
|
-
for (const [key, label] of [
|
|
1523
|
-
["writingFacts", "SUPPLIED FACTS"],
|
|
1524
|
-
["writingProfile", "VOICE PROFILE"],
|
|
1525
|
-
["writingInstructions", "DRAFTING INSTRUCTIONS"]
|
|
1526
|
-
]) if (keys.has(key) && unit.context[key]) lines.push(`${label}: ${clip(`${label}: `, unit.context[key])}`);
|
|
1527
1468
|
return {
|
|
1528
1469
|
state: lines.join("\n"),
|
|
1529
1470
|
truncated
|
|
@@ -2251,26 +2192,6 @@ const planRequests = (units, rules, config) => {
|
|
|
2251
2192
|
const applied = eligible.get(unit.id) ?? /* @__PURE__ */ new Set();
|
|
2252
2193
|
applied.add(rule.id);
|
|
2253
2194
|
eligible.set(unit.id, applied);
|
|
2254
|
-
const writingKeys = [
|
|
2255
|
-
"writingFacts",
|
|
2256
|
-
"writingProfile",
|
|
2257
|
-
"writingInstructions"
|
|
2258
|
-
];
|
|
2259
|
-
const requiredWriting = writingKeys.filter((key) => rule.question?.context?.includes(key));
|
|
2260
|
-
if (requiredWriting.length) {
|
|
2261
|
-
const missing = requiredWriting.filter((key) => !unit.context[key]);
|
|
2262
|
-
const total = [unit.text, ...writingKeys.map((key) => unit.context[key] ?? "")].join("\n");
|
|
2263
|
-
if (missing.length || estimateTokens(total, charsPerTokenFor(unit.kind)) > 1300) {
|
|
2264
|
-
unknowns.push({
|
|
2265
|
-
file: unit.file,
|
|
2266
|
-
line: unit.line,
|
|
2267
|
-
reason: missing.length ? `Missing explicit writing context: ${missing.join(", ")}` : "Writing comparison exceeds the context budget",
|
|
2268
|
-
ruleId: rule.id,
|
|
2269
|
-
unitId: unit.id
|
|
2270
|
-
});
|
|
2271
|
-
continue;
|
|
2272
|
-
}
|
|
2273
|
-
}
|
|
2274
2195
|
if (rule.tier === "jev") {
|
|
2275
2196
|
const reason = missingEvidence(unit, rule);
|
|
2276
2197
|
if (reason) {
|
|
@@ -2492,6 +2413,13 @@ const jevFindings = (jobs, answers) => {
|
|
|
2492
2413
|
};
|
|
2493
2414
|
};
|
|
2494
2415
|
//#endregion
|
|
2416
|
+
//#region src/extract/arbitrary.ts
|
|
2417
|
+
const literalArbitraryValues = (classes) => classes.filter((className) => {
|
|
2418
|
+
const value = baseClass(className).match(/^-?[\w-]+-\[(.+?)\](?:\/[^\s]+)?!?$/u)?.[1]?.replace(/^(?:length|color):/u, "");
|
|
2419
|
+
if (!value || /var\(|env\(|theme\(|^--|^(?:calc|min|max|clamp|minmax|repeat|url)\(/u.test(value)) return false;
|
|
2420
|
+
return /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|ms|s|deg)?$/u.test(value) || /^#[\da-f]{3,8}$/iu.test(value) || /^(?:rgb|rgba|hsl|hsla|oklab|oklch|color)\(/u.test(value);
|
|
2421
|
+
});
|
|
2422
|
+
//#endregion
|
|
2495
2423
|
//#region src/rules/validate.ts
|
|
2496
2424
|
const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
2497
2425
|
const DEFAULT_THRESHOLDS = {
|
|
@@ -2620,71 +2548,6 @@ const codeRule = (spec, module) => {
|
|
|
2620
2548
|
};
|
|
2621
2549
|
};
|
|
2622
2550
|
//#endregion
|
|
2623
|
-
//#region src/rules/code/architecture.ts
|
|
2624
|
-
const make$4 = (name, title, check, hint) => codeRule({
|
|
2625
|
-
categoryId: "repository-contracts",
|
|
2626
|
-
check,
|
|
2627
|
-
hint,
|
|
2628
|
-
id: `architecture-${name}`,
|
|
2629
|
-
scope: { include: ["**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts}"] },
|
|
2630
|
-
source: {
|
|
2631
|
-
line: 1,
|
|
2632
|
-
path: "skills/codebase-architecture/references/guardrail-tooling.md",
|
|
2633
|
-
repo: "mblode/agent-skills"
|
|
2634
|
-
},
|
|
2635
|
-
status: "review-only",
|
|
2636
|
-
title,
|
|
2637
|
-
unit: ["source"]
|
|
2638
|
-
}, "src/rules/code/architecture.ts");
|
|
2639
|
-
const ARCHITECTURE_RULES = [
|
|
2640
|
-
make$4("declared-import-boundary", "Import violates a declared module boundary", (u) => {
|
|
2641
|
-
const boundaries = u.facts?.repository.policy?.boundaries?.filter((b) => matchesAny(u.file, [b.from]));
|
|
2642
|
-
if (!boundaries?.length) return none;
|
|
2643
|
-
if (!u.facts?.imports) throw new UnresolvedError(u.facts?.parseError ?? "Import facts unavailable");
|
|
2644
|
-
for (const item of u.facts.imports) {
|
|
2645
|
-
const resolved = item.name.startsWith(".") ? u.facts.repository.resolve(u.file, item.name) : void 0;
|
|
2646
|
-
const boundary = boundaries.find((b) => matchesAny(item.name, [b.disallow]) || resolved !== void 0 && matchesAny(resolved, [b.disallow]));
|
|
2647
|
-
if (boundary) return {
|
|
2648
|
-
evidence: `${item.name}: ${boundary.reason}`,
|
|
2649
|
-
fired: true,
|
|
2650
|
-
offset: item.offset
|
|
2651
|
-
};
|
|
2652
|
-
}
|
|
2653
|
-
return none;
|
|
2654
|
-
}, "Use the declared public interface or change the explicit architecture policy with its owner. Patterns match import specifiers and repository-relative paths for relative imports."),
|
|
2655
|
-
make$4("deprecated-import", "Import uses a declared deprecated API", (u) => {
|
|
2656
|
-
const deprecated = u.facts?.repository.policy?.deprecatedImports;
|
|
2657
|
-
if (!deprecated || Object.keys(deprecated).length === 0) return none;
|
|
2658
|
-
if (!u.facts?.imports) throw new UnresolvedError(u.facts?.parseError ?? "Import facts unavailable");
|
|
2659
|
-
for (const item of u.facts.imports) {
|
|
2660
|
-
const replacement = deprecated[item.name];
|
|
2661
|
-
if (replacement) return {
|
|
2662
|
-
evidence: `${item.name}: use ${replacement}`,
|
|
2663
|
-
fired: true,
|
|
2664
|
-
offset: item.offset
|
|
2665
|
-
};
|
|
2666
|
-
}
|
|
2667
|
-
return none;
|
|
2668
|
-
}, "Use the replacement named in the repository's deprecation contract."),
|
|
2669
|
-
make$4("generated-regeneration-hint", "Declared generated file has no regeneration guidance", (u) => {
|
|
2670
|
-
const generated = u.facts?.repository.policy?.generated;
|
|
2671
|
-
if (!generated?.length || !matchesAny(u.file, generated)) return none;
|
|
2672
|
-
const banner = u.text.split("\n").slice(0, 12).join("\n");
|
|
2673
|
-
return /generat/i.test(banner) && /(?:regenerat|npm run|pnpm |yarn |bun |codegen|protoc)/i.test(banner) ? none : {
|
|
2674
|
-
evidence: "Declared generated output lacks a regeneration hint in its header",
|
|
2675
|
-
fired: true,
|
|
2676
|
-
offset: 0
|
|
2677
|
-
};
|
|
2678
|
-
}, "Have the generator emit a banner naming the regeneration command or canonical instructions.")
|
|
2679
|
-
];
|
|
2680
|
-
//#endregion
|
|
2681
|
-
//#region src/extract/arbitrary.ts
|
|
2682
|
-
const literalArbitraryValues = (classes) => classes.filter((className) => {
|
|
2683
|
-
const value = baseClass(className).match(/^-?[\w-]+-\[(.+?)\](?:\/[^\s]+)?!?$/u)?.[1]?.replace(/^(?:length|color):/u, "");
|
|
2684
|
-
if (!value || /var\(|env\(|theme\(|^--|^(?:calc|min|max|clamp|minmax|repeat|url)\(/u.test(value)) return false;
|
|
2685
|
-
return /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|ms|s|deg)?$/u.test(value) || /^#[\da-f]{3,8}$/iu.test(value) || /^(?:rgb|rgba|hsl|hsla|oklab|oklch|color)\(/u.test(value);
|
|
2686
|
-
});
|
|
2687
|
-
//#endregion
|
|
2688
2551
|
//#region src/rules/code/classes.ts
|
|
2689
2552
|
const MODULE$1 = "src/rules/code/classes.ts";
|
|
2690
2553
|
const ANIMATION = "skills/ui-animation/references";
|
|
@@ -2883,40 +2746,13 @@ const CLASS_RULES = [
|
|
|
2883
2746
|
})
|
|
2884
2747
|
];
|
|
2885
2748
|
//#endregion
|
|
2886
|
-
//#region src/analysis/html.ts
|
|
2887
|
-
const text = (node) => "value" in node ? node.value : "childNodes" in node ? node.childNodes.map(text).join("") : "";
|
|
2888
|
-
const htmlElements = (source) => {
|
|
2889
|
-
const output = [];
|
|
2890
|
-
const visit = (node, inHead = false) => {
|
|
2891
|
-
const head = inHead || "tagName" in node && node.tagName === "head";
|
|
2892
|
-
if ("tagName" in node && node.namespaceURI === "http://www.w3.org/1999/xhtml" && node.sourceCodeLocation && [
|
|
2893
|
-
"title",
|
|
2894
|
-
"meta",
|
|
2895
|
-
"link",
|
|
2896
|
-
"script",
|
|
2897
|
-
"img"
|
|
2898
|
-
].includes(node.tagName)) output.push({
|
|
2899
|
-
attrs: Object.fromEntries(node.attrs.map((a) => [a.name, a.value])),
|
|
2900
|
-
inHead: head,
|
|
2901
|
-
offset: node.sourceCodeLocation.startOffset,
|
|
2902
|
-
tag: node.tagName,
|
|
2903
|
-
text: text(node)
|
|
2904
|
-
});
|
|
2905
|
-
if ("childNodes" in node) for (const child of node.childNodes) visit(child, head);
|
|
2906
|
-
};
|
|
2907
|
-
visit(parse$1(source, { sourceCodeLocationInfo: true }));
|
|
2908
|
-
return output;
|
|
2909
|
-
};
|
|
2910
|
-
//#endregion
|
|
2911
2749
|
//#region src/analysis/repository.ts
|
|
2912
2750
|
const descendants = (node) => [node, ...(node.children ?? []).flatMap(descendants)];
|
|
2913
2751
|
const nodeText = (node) => node.value ?? (node.children ?? []).map(nodeText).join("");
|
|
2914
2752
|
var Repository = class {
|
|
2915
2753
|
root;
|
|
2916
2754
|
contents = /* @__PURE__ */ new Map();
|
|
2917
|
-
|
|
2918
|
-
constructor(root, policy) {
|
|
2919
|
-
this.policy = policy;
|
|
2755
|
+
constructor(root) {
|
|
2920
2756
|
this.root = fs.realpathSync(root);
|
|
2921
2757
|
}
|
|
2922
2758
|
resolve(file, target) {
|
|
@@ -2991,15 +2827,7 @@ const importsFrom = (value, output) => {
|
|
|
2991
2827
|
};
|
|
2992
2828
|
const sourceFacts = (file, text, repository) => {
|
|
2993
2829
|
const facts = { repository };
|
|
2994
|
-
if (/\.
|
|
2995
|
-
facts.html = htmlElements(text);
|
|
2996
|
-
facts.images = facts.html.filter((element) => element.tag === "img").map((element) => ({
|
|
2997
|
-
attributes: Object.keys(element.attrs),
|
|
2998
|
-
offset: element.offset,
|
|
2999
|
-
spread: false
|
|
3000
|
-
}));
|
|
3001
|
-
}
|
|
3002
|
-
if (/\.mdx?$/.test(file) || /(?:^|\/)llms(?:-full)?\.txt$/.test(file)) facts.document = fromMarkdown(text.replace(/^---\r?\n[\s\S]*?\r?\n---(?=\r?\n|$)/, (m) => m.replaceAll(/[^\r\n]/g, " ")), {
|
|
2830
|
+
if (/\.mdx?$/.test(file)) facts.document = fromMarkdown(text.replace(/^---\r?\n[\s\S]*?\r?\n---(?=\r?\n|$)/, (m) => m.replaceAll(/[^\r\n]/g, " ")), {
|
|
3003
2831
|
extensions: [gfm()],
|
|
3004
2832
|
mdastExtensions: [gfmFromMarkdown()]
|
|
3005
2833
|
});
|
|
@@ -3032,96 +2860,8 @@ const sourceFacts = (file, text, repository) => {
|
|
|
3032
2860
|
return facts;
|
|
3033
2861
|
};
|
|
3034
2862
|
//#endregion
|
|
3035
|
-
//#region src/rules/code/discovery.ts
|
|
3036
|
-
const at$2 = (evidence, offset = 0) => ({
|
|
3037
|
-
evidence,
|
|
3038
|
-
fired: true,
|
|
3039
|
-
offset
|
|
3040
|
-
});
|
|
3041
|
-
const html = (u) => {
|
|
3042
|
-
if (!u.facts?.html || !/<!doctype\s+html\b|<html(?:\s|>)/i.test(u.text)) throw new UnresolvedError("Needs a complete HTML document; framework metadata and fragments require rendered evidence");
|
|
3043
|
-
return u.facts.html;
|
|
3044
|
-
};
|
|
3045
|
-
const make$3 = (id, title, include, source, check, hint) => codeRule({
|
|
3046
|
-
categoryId: id.startsWith("seo-") ? "search-discovery" : "instruction-quality",
|
|
3047
|
-
check,
|
|
3048
|
-
hint,
|
|
3049
|
-
id,
|
|
3050
|
-
scope: {
|
|
3051
|
-
exclude: [
|
|
3052
|
-
"**/supabase/templates/**",
|
|
3053
|
-
"**/emails/**",
|
|
3054
|
-
"**/email/**"
|
|
3055
|
-
],
|
|
3056
|
-
include
|
|
3057
|
-
},
|
|
3058
|
-
source: {
|
|
3059
|
-
line: 1,
|
|
3060
|
-
path: `skills/${source}`,
|
|
3061
|
-
repo: "mblode/agent-skills"
|
|
3062
|
-
},
|
|
3063
|
-
status: "review-only",
|
|
3064
|
-
title,
|
|
3065
|
-
unit: ["source"]
|
|
3066
|
-
}, "src/rules/code/discovery.ts");
|
|
3067
|
-
const HTML = ["**/*.html", "**/*.htm"];
|
|
3068
|
-
const SEO = "seo/references/audit.md";
|
|
3069
|
-
const LLM = "agent-ready/references/docs-afdocs.md";
|
|
3070
|
-
const canonical = (u) => html(u).filter((n) => n.inHead && n.tag === "link" && n.attrs.rel?.toLowerCase().split(/\s+/).includes("canonical"));
|
|
3071
|
-
const DISCOVERY_RULES = [
|
|
3072
|
-
make$3("seo-document-title", "HTML document needs one nonempty title", HTML, SEO, (u) => {
|
|
3073
|
-
const titles = html(u).filter((n) => n.inHead && n.tag === "title");
|
|
3074
|
-
return titles.length !== 1 || !titles[0].text.trim() ? at$2(`Found ${titles.length} HTML head titles${titles.length === 1 ? "; title is empty" : ""}`, titles[0]?.offset) : none;
|
|
3075
|
-
}, "Provide one meaningful title in the HTML head. SVG titles do not name the page."),
|
|
3076
|
-
make$3("seo-empty-description", "Declared meta description is empty", HTML, SEO, (u) => {
|
|
3077
|
-
const empty = html(u).find((n) => n.inHead && n.tag === "meta" && n.attrs.name?.toLowerCase() === "description" && !n.attrs.content?.trim());
|
|
3078
|
-
return empty ? at$2("Empty meta description", empty.offset) : none;
|
|
3079
|
-
}, "Write a page-specific description, or omit a deliberately unspecified description."),
|
|
3080
|
-
make$3("seo-conflicting-canonicals", "HTML declares conflicting canonical destinations", HTML, SEO, (u) => {
|
|
3081
|
-
const links = canonical(u);
|
|
3082
|
-
return new Set(links.map((n) => n.attrs.href?.trim()).filter(Boolean)).size > 1 ? at$2("Multiple distinct canonical destinations", links[1].offset) : none;
|
|
3083
|
-
}, "Declare a single intended canonical destination; verify the deployed URL and HTTP headers."),
|
|
3084
|
-
make$3("seo-empty-canonical", "Canonical link has no destination", HTML, SEO, (u) => {
|
|
3085
|
-
const empty = canonical(u).find((n) => !n.attrs.href?.trim());
|
|
3086
|
-
return empty ? at$2("Canonical link has an empty or missing href", empty.offset) : none;
|
|
3087
|
-
}, "Set the intended canonical URL or remove an unintended canonical declaration."),
|
|
3088
|
-
make$3("seo-jsonld-syntax", "JSON-LD script is not valid JSON", HTML, SEO, (u) => {
|
|
3089
|
-
for (const node of html(u).filter((n) => n.tag === "script" && n.attrs.type?.trim().toLowerCase() === "application/ld+json")) try {
|
|
3090
|
-
JSON.parse(node.text);
|
|
3091
|
-
} catch {
|
|
3092
|
-
return at$2("JSON-LD cannot be parsed as JSON", node.offset);
|
|
3093
|
-
}
|
|
3094
|
-
return none;
|
|
3095
|
-
}, "Fix the JSON syntax. Valid JSON alone does not establish schema validity or rich-result eligibility."),
|
|
3096
|
-
make$3("seo-robots-sitemap-url", "Robots sitemap directive needs an absolute HTTP URL", ["**/robots.txt"], "seo/references/indexing-policy.md", (u) => {
|
|
3097
|
-
for (const match of u.text.matchAll(/^\s*sitemap\s*:\s*([^\r\n]*)/gim)) {
|
|
3098
|
-
const value = match[1].split("#")[0].trim();
|
|
3099
|
-
try {
|
|
3100
|
-
const url = new URL(value);
|
|
3101
|
-
if (["http:", "https:"].includes(url.protocol) && url.hostname && !/\s/.test(value)) continue;
|
|
3102
|
-
} catch {}
|
|
3103
|
-
return at$2("Sitemap directive is not an absolute HTTP(S) URL", match.index);
|
|
3104
|
-
}
|
|
3105
|
-
return none;
|
|
3106
|
-
}, "Use the deployed sitemap's absolute URL. Fetch it separately to verify status and content."),
|
|
3107
|
-
make$3("authoring-llms-title", "Agent index must start with an H1", ["**/llms.txt"], LLM, (u) => {
|
|
3108
|
-
const document = u.facts?.document;
|
|
3109
|
-
if (!document) throw new UnresolvedError("Markdown structure unavailable");
|
|
3110
|
-
const first = document.children?.[0];
|
|
3111
|
-
return first?.type === "heading" && first.depth === 1 ? none : at$2("llms.txt does not begin with an H1");
|
|
3112
|
-
}, "Start llms.txt with the site's name as an H1, then describe and link its real documentation."),
|
|
3113
|
-
make$3("authoring-llms-links", "Agent index has no documentation links", ["**/llms.txt"], LLM, (u) => {
|
|
3114
|
-
if (!u.facts?.document) throw new UnresolvedError("Markdown structure unavailable");
|
|
3115
|
-
return descendants(u.facts.document).some((n) => ["link", "definition"].includes(n.type) && n.url?.trim()) ? none : at$2("No Markdown link destinations in llms.txt");
|
|
3116
|
-
}, "Link the existing documentation pages that an agent should read."),
|
|
3117
|
-
make$3("authoring-llms-size", "Agent index exceeds the recommended character budget", ["**/llms.txt"], LLM, (u) => {
|
|
3118
|
-
const count = [...u.text].length;
|
|
3119
|
-
return count > 5e4 ? at$2(`llms.txt contains ${count} characters; recommended maximum is 50000`) : none;
|
|
3120
|
-
}, "Keep the index small and link separate documents or nested indexes.")
|
|
3121
|
-
];
|
|
3122
|
-
//#endregion
|
|
3123
2863
|
//#region src/rules/code/documents.ts
|
|
3124
|
-
const at
|
|
2864
|
+
const at = (node, evidence) => ({
|
|
3125
2865
|
evidence,
|
|
3126
2866
|
fired: true,
|
|
3127
2867
|
offset: node.position?.start.offset ?? 0
|
|
@@ -3131,7 +2871,7 @@ const document = (unit) => {
|
|
|
3131
2871
|
return unit.facts.document;
|
|
3132
2872
|
};
|
|
3133
2873
|
const nodes = (unit) => descendants(document(unit));
|
|
3134
|
-
const make$
|
|
2874
|
+
const make$1 = (name, title, source, include, check, hint, authoring = false) => codeRule({
|
|
3135
2875
|
categoryId: authoring ? "instruction-quality" : "reference-reading",
|
|
3136
2876
|
check,
|
|
3137
2877
|
hint,
|
|
@@ -3155,11 +2895,11 @@ const INSTRUCTIONS = [
|
|
|
3155
2895
|
const DOCS = ["**/*.md"];
|
|
3156
2896
|
const README_SOURCE = "readme-creator/references/quality-checklist.md";
|
|
3157
2897
|
const DOCUMENT_RULES = [
|
|
3158
|
-
make$
|
|
2898
|
+
make$1("readme-scaffold", "README still contains scaffold instructions", README_SOURCE, README, (u) => {
|
|
3159
2899
|
const n = nodes(u).find((candidate) => candidate.type === "paragraph" && /This is a \[?Next\.js.*bootstrapped with|This template provides a minimal setup to get React working in Vite/i.test(nodeText(candidate)));
|
|
3160
|
-
return n ? at
|
|
2900
|
+
return n ? at(n, "Default scaffold introduction") : none;
|
|
3161
2901
|
}, "Replace scaffold instructions with what the product does and how a consumer uses it."),
|
|
3162
|
-
make$
|
|
2902
|
+
make$1("readme-description", "README has no introductory description", README_SOURCE, README, (u) => {
|
|
3163
2903
|
const children = document(u).children ?? [];
|
|
3164
2904
|
const stop = children.findIndex((n) => n.type === "heading" && (n.depth ?? 0) > 1);
|
|
3165
2905
|
return children.slice(0, stop === -1 ? children.length : stop).some((n) => n.type === "paragraph" && nodeText(n).trim().length > 0) ? none : {
|
|
@@ -3168,33 +2908,33 @@ const DOCUMENT_RULES = [
|
|
|
3168
2908
|
offset: 0
|
|
3169
2909
|
};
|
|
3170
2910
|
}, "Explain what a reader can do with the product before listing setup steps."),
|
|
3171
|
-
make$
|
|
2911
|
+
make$1("readme-quickstart-elision", "Quickstart example contains an unfinished placeholder", README_SOURCE, README, (u) => {
|
|
3172
2912
|
let heading = "";
|
|
3173
2913
|
for (const n of document(u).children ?? []) {
|
|
3174
2914
|
if (n.type === "heading") heading = nodeText(n);
|
|
3175
|
-
if (n.type === "code" && /quick\s?start|getting started/i.test(heading) && /^\s*(?:\.\.\.|\/\/\s*(?:TODO|your code here)|#\s*TODO)\s*$/m.test(n.value ?? "")) return at
|
|
2915
|
+
if (n.type === "code" && /quick\s?start|getting started/i.test(heading) && /^\s*(?:\.\.\.|\/\/\s*(?:TODO|your code here)|#\s*TODO)\s*$/m.test(n.value ?? "")) return at(n, "Quickstart contains an elided implementation");
|
|
3176
2916
|
}
|
|
3177
2917
|
return none;
|
|
3178
2918
|
}, "Provide a complete first-run example; move intentionally partial examples to a tutorial."),
|
|
3179
|
-
make$
|
|
2919
|
+
make$1("document-empty-heading", "A section has no content", "docs-writing/rules/structure-no-filler-sections.md", DOCS, (u) => {
|
|
3180
2920
|
const children = document(u).children ?? [];
|
|
3181
2921
|
for (const [i, n] of children.entries()) {
|
|
3182
2922
|
if (n.type !== "heading") continue;
|
|
3183
2923
|
const next = children[i + 1];
|
|
3184
|
-
if (!next || next.type === "heading" && (next.depth ?? 0) <= (n.depth ?? 0)) return at
|
|
2924
|
+
if (!next || next.type === "heading" && (next.depth ?? 0) <= (n.depth ?? 0)) return at(n, `Empty section: ${nodeText(n)}`);
|
|
3185
2925
|
}
|
|
3186
2926
|
return none;
|
|
3187
2927
|
}, "Add the section's content or remove its heading."),
|
|
3188
|
-
make$
|
|
2928
|
+
make$1("document-heading-order", "Heading skips a structural level", "docs-writing/rules/format-semantic-html.md", DOCS, (u) => {
|
|
3189
2929
|
let previous = 0;
|
|
3190
2930
|
for (const n of nodes(u).filter((candidate) => candidate.type === "heading")) {
|
|
3191
2931
|
const depth = n.depth ?? 1;
|
|
3192
|
-
if (previous > 0 && depth > previous + 1) return at
|
|
2932
|
+
if (previous > 0 && depth > previous + 1) return at(n, `Heading level jumps from ${previous} to ${depth}`);
|
|
3193
2933
|
previous = depth;
|
|
3194
2934
|
}
|
|
3195
2935
|
return none;
|
|
3196
2936
|
}, "Nest heading levels in order so the document outline reflects its structure."),
|
|
3197
|
-
make$
|
|
2937
|
+
make$1("document-broken-local-link", "Document links to a missing local file", "docs-writing/rules/review-verify-links.md", DOCS, (u) => {
|
|
3198
2938
|
const repo = u.facts.repository;
|
|
3199
2939
|
for (const n of nodes(u).filter((candidate) => [
|
|
3200
2940
|
"link",
|
|
@@ -3207,26 +2947,26 @@ const DOCUMENT_RULES = [
|
|
|
3207
2947
|
try {
|
|
3208
2948
|
target = decodeURIComponent(url.split(/[?#]/)[0]);
|
|
3209
2949
|
} catch {
|
|
3210
|
-
return at
|
|
2950
|
+
return at(n, "Malformed URL encoding in local link");
|
|
3211
2951
|
}
|
|
3212
2952
|
if (!/\.(?:md|mdx|png|jpe?g|svg|gif|webp|pdf)$/i.test(target)) continue;
|
|
3213
2953
|
const resolved = repo.resolve(u.file, target);
|
|
3214
2954
|
if (resolved === void 0) throw new UnresolvedError("Link points outside the repository");
|
|
3215
|
-
if (!repo.exists(resolved)) return at
|
|
2955
|
+
if (!repo.exists(resolved)) return at(n, `Missing local target: ${target}`);
|
|
3216
2956
|
}
|
|
3217
2957
|
return none;
|
|
3218
2958
|
}, "Correct the link or provide the referenced file. Generated site routes need a route-aware check."),
|
|
3219
|
-
make$
|
|
2959
|
+
make$1("instruction-missing-script", "Instruction invokes an undefined package script", "agents-md/references/quick-checklist.md", INSTRUCTIONS, (u) => {
|
|
3220
2960
|
const manifest = u.facts.repository.manifest(u.file);
|
|
3221
2961
|
if (!manifest) throw new UnresolvedError("No readable package manifest for this instruction file");
|
|
3222
2962
|
const scripts = manifest.value.scripts;
|
|
3223
2963
|
for (const n of nodes(u).filter((candidate) => ["code", "inlineCode"].includes(candidate.type))) for (const m of (n.value ?? "").matchAll(/\bnpm run ([\w:-]+)\b/g)) {
|
|
3224
2964
|
if (/--(?:workspace|prefix)|\bcd\s/.test(n.value ?? "")) continue;
|
|
3225
|
-
if (!scripts || !(m[1] in scripts)) return at
|
|
2965
|
+
if (!scripts || !(m[1] in scripts)) return at(n, `Script ${m[1]} is absent from ${manifest.file}`);
|
|
3226
2966
|
}
|
|
3227
2967
|
return none;
|
|
3228
2968
|
}, "Name a script in the applicable package manifest, or state the workspace where the command runs.", true),
|
|
3229
|
-
make$
|
|
2969
|
+
make$1("skill-frontmatter", "Skill frontmatter is missing or invalid", "agent-skills-creator/references/format-specification.md", ["**/SKILL.md"], (u) => {
|
|
3230
2970
|
const match = u.text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
3231
2971
|
if (!match) return {
|
|
3232
2972
|
evidence: "Missing YAML frontmatter",
|
|
@@ -3255,15 +2995,15 @@ const DOCUMENT_RULES = [
|
|
|
3255
2995
|
}
|
|
3256
2996
|
return none;
|
|
3257
2997
|
}, "Supply valid YAML with name and description; run the skill repository's full validator for its remaining conventions.", true),
|
|
3258
|
-
make$
|
|
2998
|
+
make$1("readme-license-link", "README license link points to a missing file", README_SOURCE, README, (u) => {
|
|
3259
2999
|
for (const n of nodes(u).filter((candidate) => candidate.type === "link")) {
|
|
3260
3000
|
if (!/^(?:\.\/)?LICENSE(?:\.[\w-]+)?$/i.test(n.url ?? "")) continue;
|
|
3261
3001
|
const target = u.facts.repository.resolve(u.file, n.url);
|
|
3262
|
-
if (target && !u.facts.repository.exists(target)) return at
|
|
3002
|
+
if (target && !u.facts.repository.exists(target)) return at(n, `Missing license target: ${n.url}`);
|
|
3263
3003
|
}
|
|
3264
3004
|
return none;
|
|
3265
3005
|
}, "Link to the project's actual license file."),
|
|
3266
|
-
make$
|
|
3006
|
+
make$1("readme-install-name", "README installs an old or unrelated package name", README_SOURCE, README, (u) => {
|
|
3267
3007
|
const manifest = u.facts.repository.manifest(u.file);
|
|
3268
3008
|
if (!manifest || manifest.value.private === true || typeof manifest.value.name !== "string") throw new UnresolvedError("Published package identity is not established");
|
|
3269
3009
|
let section = "";
|
|
@@ -3271,7 +3011,7 @@ const DOCUMENT_RULES = [
|
|
|
3271
3011
|
if (n.type === "heading") section = nodeText(n);
|
|
3272
3012
|
if (n.type !== "code" || !/^install(?:ation)?$/i.test(section)) continue;
|
|
3273
3013
|
const command = (n.value ?? "").match(/^\s*(?:npm (?:install|i)|pnpm add|yarn add|bun add)\s+((?:@[\w.-]+\/)?[\w.-]+)(?:@[^\s]+)?\s*$/m);
|
|
3274
|
-
if (command && command[1] !== manifest.value.name) return at
|
|
3014
|
+
if (command && command[1] !== manifest.value.name) return at(n, `Install names ${command[1]}; manifest names ${manifest.value.name}`);
|
|
3275
3015
|
}
|
|
3276
3016
|
return none;
|
|
3277
3017
|
}, "Check the intended distribution package. Keep companion-package and workspace instructions explicit.")
|
|
@@ -3309,11 +3049,7 @@ const IMAGE_RULES = [{
|
|
|
3309
3049
|
},
|
|
3310
3050
|
hint: spec.hint,
|
|
3311
3051
|
id: spec.id,
|
|
3312
|
-
scope: { include: [
|
|
3313
|
-
"**/*.tsx",
|
|
3314
|
-
"**/*.jsx",
|
|
3315
|
-
"**/*.html"
|
|
3316
|
-
] },
|
|
3052
|
+
scope: { include: ["**/*.tsx", "**/*.jsx"] },
|
|
3317
3053
|
source: {
|
|
3318
3054
|
line: 9,
|
|
3319
3055
|
path: `skills/ui-design/rules/${spec.source}.md`,
|
|
@@ -3325,141 +3061,6 @@ const IMAGE_RULES = [{
|
|
|
3325
3061
|
unit: ["source"]
|
|
3326
3062
|
}, "src/rules/code/images.ts"));
|
|
3327
3063
|
//#endregion
|
|
3328
|
-
//#region src/rules/code/repository.ts
|
|
3329
|
-
const at = (evidence, offset = 0) => ({
|
|
3330
|
-
evidence,
|
|
3331
|
-
fired: true,
|
|
3332
|
-
offset
|
|
3333
|
-
});
|
|
3334
|
-
const object$2 = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
3335
|
-
const json = (u) => {
|
|
3336
|
-
try {
|
|
3337
|
-
return object$2(JSON.parse(u.text));
|
|
3338
|
-
} catch {
|
|
3339
|
-
throw new UnresolvedError("JSON is not parseable");
|
|
3340
|
-
}
|
|
3341
|
-
};
|
|
3342
|
-
const builtins = /* @__PURE__ */ new Set([...builtinModules, ...builtinModules.map((n) => `node:${n}`)]);
|
|
3343
|
-
const make$1 = (domain, id, title, include, source, check, hint) => codeRule({
|
|
3344
|
-
categoryId: domain === "dx" ? "package-contracts" : "repository-contracts",
|
|
3345
|
-
check,
|
|
3346
|
-
hint,
|
|
3347
|
-
id: `${domain}-${id}`,
|
|
3348
|
-
scope: { include },
|
|
3349
|
-
source: {
|
|
3350
|
-
line: 1,
|
|
3351
|
-
path: `skills/${source}`,
|
|
3352
|
-
repo: "mblode/agent-skills"
|
|
3353
|
-
},
|
|
3354
|
-
status: "review-only",
|
|
3355
|
-
title,
|
|
3356
|
-
unit: ["source"]
|
|
3357
|
-
}, "src/rules/code/repository.ts");
|
|
3358
|
-
const GUARD = "codebase-architecture/references/guardrail-tooling.md";
|
|
3359
|
-
const PKG = ["**/package.json"];
|
|
3360
|
-
const CODE = ["**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts}"];
|
|
3361
|
-
const REPOSITORY_RULES = [
|
|
3362
|
-
make$1("dx", "package-entry-target", "Package entry points to a missing artifact", PKG, "scaffold-cli/references/post-scaffold.md", (u) => {
|
|
3363
|
-
const value = json(u);
|
|
3364
|
-
const entries = [];
|
|
3365
|
-
for (const key of [
|
|
3366
|
-
"main",
|
|
3367
|
-
"module",
|
|
3368
|
-
"types",
|
|
3369
|
-
"typings"
|
|
3370
|
-
]) if (typeof value[key] === "string") entries.push(value[key]);
|
|
3371
|
-
if (typeof value.bin === "string") entries.push(value.bin);
|
|
3372
|
-
else entries.push(...Object.values(object$2(value.bin)).filter((v) => typeof v === "string"));
|
|
3373
|
-
const visit = (v) => {
|
|
3374
|
-
if (typeof v === "string") entries.push(v);
|
|
3375
|
-
else if (v && typeof v === "object") for (const child of Object.values(v)) visit(child);
|
|
3376
|
-
};
|
|
3377
|
-
visit(value.exports);
|
|
3378
|
-
for (const entry of entries) {
|
|
3379
|
-
if (entry.includes("*")) continue;
|
|
3380
|
-
const resolved = u.facts.repository.resolve(u.file, entry);
|
|
3381
|
-
if (resolved === void 0) return at(`Entry escapes repository: ${entry}`);
|
|
3382
|
-
if (!u.facts.repository.exists(resolved)) {
|
|
3383
|
-
if (/^(?:\.\/)?(?:dist|build|lib)\//.test(entry) && object$2(value.scripts).build) throw new UnresolvedError(`Build output not available: ${entry}. Build or inspect the packed artifact.`);
|
|
3384
|
-
return at(`Missing package entry: ${entry}`);
|
|
3385
|
-
}
|
|
3386
|
-
}
|
|
3387
|
-
return none;
|
|
3388
|
-
}, "Build and inspect the package, then make bin, exports, and declaration paths match its actual artifacts."),
|
|
3389
|
-
make$1("dx", "package-bin-shebang", "CLI executable has no Node shebang", PKG, "scaffold-cli/references/scaffold-source.md", (u) => {
|
|
3390
|
-
const value = json(u);
|
|
3391
|
-
const bins = typeof value.bin === "string" ? [value.bin] : Object.values(object$2(value.bin)).filter((v) => typeof v === "string");
|
|
3392
|
-
for (const bin of bins) {
|
|
3393
|
-
if (!/\.[cm]?js$/.test(bin)) continue;
|
|
3394
|
-
const resolved = u.facts.repository.resolve(u.file, bin);
|
|
3395
|
-
const text = resolved === void 0 ? void 0 : u.facts.repository.read(resolved);
|
|
3396
|
-
if (text === void 0) throw new UnresolvedError(`CLI artifact unavailable: ${bin}`);
|
|
3397
|
-
if (!/^#![^\n]*\bnode\b/.test(text)) return at(`Node CLI lacks shebang: ${bin}`);
|
|
3398
|
-
if (/^#![^\n]*\n#!/.test(text)) return at(`Duplicate CLI shebang: ${bin}`);
|
|
3399
|
-
}
|
|
3400
|
-
return none;
|
|
3401
|
-
}, "Emit exactly one Node shebang in the published executable."),
|
|
3402
|
-
make$1("dx", "exports-mixed-keys", "Package exports mixes conditions and subpaths", PKG, "dx-audit/rules/api-stable-contract.md", (u) => {
|
|
3403
|
-
const keys = Object.keys(object$2(json(u).exports));
|
|
3404
|
-
return keys.some((k) => k.startsWith(".")) && keys.some((k) => !k.startsWith(".")) ? at("Exports mixes subpath keys with condition keys at the same level") : none;
|
|
3405
|
-
}, "Put conditions inside each exported subpath, or use a conditions-only root export."),
|
|
3406
|
-
make$1("architecture", "undeclared-import", "Import has no declared package dependency", CODE, GUARD, (u) => {
|
|
3407
|
-
if (!u.facts?.imports) throw new UnresolvedError(u.facts?.parseError ?? "Import facts unavailable");
|
|
3408
|
-
const manifest = u.facts.repository.manifest(u.file);
|
|
3409
|
-
if (!manifest) throw new UnresolvedError("No readable package manifest");
|
|
3410
|
-
const value = manifest.value;
|
|
3411
|
-
const declared = /* @__PURE__ */ new Set([value.name, ...[
|
|
3412
|
-
"dependencies",
|
|
3413
|
-
"devDependencies",
|
|
3414
|
-
"peerDependencies",
|
|
3415
|
-
"optionalDependencies"
|
|
3416
|
-
].flatMap((key) => Object.keys(object$2(value[key])))]);
|
|
3417
|
-
for (const item of u.facts.imports) {
|
|
3418
|
-
if (/^(?:\.|\/|#|~|[a-z]+:)/i.test(item.name) || builtins.has(item.name)) continue;
|
|
3419
|
-
const name = item.name.startsWith("@") ? item.name.split("/").slice(0, 2).join("/") : item.name.split("/")[0];
|
|
3420
|
-
if (declared.has(name)) continue;
|
|
3421
|
-
const tsconfig = u.facts.repository.resolve(manifest.file, "tsconfig.json");
|
|
3422
|
-
if (tsconfig && u.facts.repository.exists(tsconfig)) throw new UnresolvedError(`Undeclared ${name} may resolve through TypeScript aliases; resolve the project graph`);
|
|
3423
|
-
return at(`Import ${name} is absent from ${manifest.file}`, item.offset);
|
|
3424
|
-
}
|
|
3425
|
-
return none;
|
|
3426
|
-
}, "Declare the dependency in the consuming package, or configure its alias resolution."),
|
|
3427
|
-
make$1("architecture", "cross-package-relative-import", "Relative import bypasses a package boundary", CODE, GUARD, (u) => {
|
|
3428
|
-
if (!u.facts?.imports) throw new UnresolvedError(u.facts?.parseError ?? "Import facts unavailable");
|
|
3429
|
-
const own = u.facts.repository.manifest(u.file);
|
|
3430
|
-
if (!own) throw new UnresolvedError("No readable package manifest");
|
|
3431
|
-
for (const item of u.facts.imports.filter((i) => i.name.startsWith("."))) {
|
|
3432
|
-
const target = u.facts.repository.resolve(u.file, item.name);
|
|
3433
|
-
if (!target) continue;
|
|
3434
|
-
const other = u.facts.repository.manifest(target);
|
|
3435
|
-
if (other && other.file !== own.file) return at(`Relative import crosses from ${own.file} to ${other.file}: ${item.name}`, item.offset);
|
|
3436
|
-
}
|
|
3437
|
-
return none;
|
|
3438
|
-
}, "Import through the sibling package's public entry and declare the dependency, unless an explicit workspace convention permits this edge."),
|
|
3439
|
-
make$1("architecture", "missing-tsconfig-reference", "TypeScript project reference points to a missing target", ["**/tsconfig*.json"], GUARD, (u) => {
|
|
3440
|
-
const value = json(u);
|
|
3441
|
-
for (const entry of Array.isArray(value.references) ? value.references : []) {
|
|
3442
|
-
const target = object$2(entry).path;
|
|
3443
|
-
if (typeof target !== "string") continue;
|
|
3444
|
-
const resolved = u.facts.repository.resolve(u.file, target);
|
|
3445
|
-
if (!resolved) throw new UnresolvedError("Project reference outside repository");
|
|
3446
|
-
if (!u.facts.repository.exists(resolved)) return at(`Missing TypeScript reference: ${target}`);
|
|
3447
|
-
}
|
|
3448
|
-
return none;
|
|
3449
|
-
}, "Correct the project reference or restore the referenced project."),
|
|
3450
|
-
make$1("architecture", "missing-local-config-extends", "Config extends a missing local file", ["**/tsconfig*.json"], GUARD, (u) => {
|
|
3451
|
-
const value = json(u);
|
|
3452
|
-
const bases = Array.isArray(value.extends) ? value.extends : [value.extends];
|
|
3453
|
-
for (const base of bases) {
|
|
3454
|
-
if (typeof base !== "string" || !base.startsWith(".")) continue;
|
|
3455
|
-
const resolved = u.facts.repository.resolve(u.file, base);
|
|
3456
|
-
if (resolved === void 0) throw new UnresolvedError("Extended config outside repository");
|
|
3457
|
-
if (!u.facts.repository.exists(resolved) && !u.facts.repository.exists(`${resolved}.json`)) return at(`Missing extended config: ${base}`);
|
|
3458
|
-
}
|
|
3459
|
-
return none;
|
|
3460
|
-
}, "Update extends to the existing configuration path.")
|
|
3461
|
-
];
|
|
3462
|
-
//#endregion
|
|
3463
3064
|
//#region src/rules/code/skill-classes.ts
|
|
3464
3065
|
const make = (id, title, categoryId, source, check, hint) => codeRule({
|
|
3465
3066
|
categoryId,
|
|
@@ -3729,7 +3330,6 @@ const CODE_RULES = [
|
|
|
3729
3330
|
preconditions: {
|
|
3730
3331
|
docType: [
|
|
3731
3332
|
"readme",
|
|
3732
|
-
"personal",
|
|
3733
3333
|
"tutorial",
|
|
3734
3334
|
"howto",
|
|
3735
3335
|
"reference",
|
|
@@ -3752,11 +3352,8 @@ const CODE_RULES = [
|
|
|
3752
3352
|
],
|
|
3753
3353
|
...CLASS_RULES,
|
|
3754
3354
|
...DOCUMENT_RULES,
|
|
3755
|
-
...DISCOVERY_RULES,
|
|
3756
3355
|
...IMAGE_RULES,
|
|
3757
|
-
...
|
|
3758
|
-
...SKILL_CLASS_RULES,
|
|
3759
|
-
...ARCHITECTURE_RULES
|
|
3356
|
+
...SKILL_CLASS_RULES
|
|
3760
3357
|
];
|
|
3761
3358
|
//#endregion
|
|
3762
3359
|
//#region src/rules/load.ts
|
|
@@ -4075,15 +3672,133 @@ const runEval = async (options, ctx = {}) => {
|
|
|
4075
3672
|
};
|
|
4076
3673
|
};
|
|
4077
3674
|
//#endregion
|
|
3675
|
+
//#region src/scan/storage.ts
|
|
3676
|
+
const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
3677
|
+
const object$1 = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
3678
|
+
const readJson = (file) => {
|
|
3679
|
+
try {
|
|
3680
|
+
return JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
3681
|
+
} catch {
|
|
3682
|
+
throw new InputError("INVALID_SCAN_INPUT", `Cannot read JSON input: ${file}`);
|
|
3683
|
+
}
|
|
3684
|
+
};
|
|
3685
|
+
const writeJson = (file, value) => {
|
|
3686
|
+
fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
|
|
3687
|
+
const temp = `${file}.${randomUUID()}.tmp`;
|
|
3688
|
+
try {
|
|
3689
|
+
fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { flag: "wx" });
|
|
3690
|
+
fs.renameSync(temp, file);
|
|
3691
|
+
} finally {
|
|
3692
|
+
fs.rmSync(temp, { force: true });
|
|
3693
|
+
}
|
|
3694
|
+
};
|
|
3695
|
+
//#endregion
|
|
3696
|
+
//#region src/scan/samples.ts
|
|
3697
|
+
const makeSamples = (units, rules, answers, negatives, perStratum = 3, sourceRepo = "scan") => {
|
|
3698
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
3699
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3700
|
+
const semanticRules = rules.filter((rule) => rule.question);
|
|
3701
|
+
for (const unit of units) for (const rule of semanticRules) {
|
|
3702
|
+
const probability = answers.get(unit.id)?.[rule.id] ?? (negatives.get(unit.id)?.has(rule.id) ? 0 : void 0);
|
|
3703
|
+
if (probability === void 0) continue;
|
|
3704
|
+
const { docType, role, headingAbove } = unit.context;
|
|
3705
|
+
const context = {
|
|
3706
|
+
docType,
|
|
3707
|
+
headingAbove,
|
|
3708
|
+
role,
|
|
3709
|
+
...Object.fromEntries((rule.question.context ?? []).filter((key) => key !== "neighbours").map((key) => [key, unit.context[key]]))
|
|
3710
|
+
};
|
|
3711
|
+
const rubric = buildQuestion(rule);
|
|
3712
|
+
const neighbours = rule.question.context?.includes("neighbours") ? unit.neighbours : void 0;
|
|
3713
|
+
const evidence = [
|
|
3714
|
+
rule.id,
|
|
3715
|
+
unit.text,
|
|
3716
|
+
context,
|
|
3717
|
+
neighbours,
|
|
3718
|
+
unit.classes,
|
|
3719
|
+
rubric
|
|
3720
|
+
];
|
|
3721
|
+
const unique = hash(evidence);
|
|
3722
|
+
if (seen.has(unique)) continue;
|
|
3723
|
+
seen.add(unique);
|
|
3724
|
+
const id = `scan-${hash([
|
|
3725
|
+
sourceRepo,
|
|
3726
|
+
unit.file,
|
|
3727
|
+
...evidence
|
|
3728
|
+
]).slice(0, 24)}`;
|
|
3729
|
+
const sample = {
|
|
3730
|
+
criterion: rule.question.instructions,
|
|
3731
|
+
id,
|
|
3732
|
+
item: {
|
|
3733
|
+
categoryId: rule.categoryId,
|
|
3734
|
+
classes: unit.classes,
|
|
3735
|
+
context,
|
|
3736
|
+
id,
|
|
3737
|
+
kind: unit.kind,
|
|
3738
|
+
neighbours,
|
|
3739
|
+
source: {
|
|
3740
|
+
path: unit.file,
|
|
3741
|
+
repo: sourceRepo
|
|
3742
|
+
},
|
|
3743
|
+
split: splitFor(`${sourceRepo}/${unit.file}`),
|
|
3744
|
+
text: unit.text
|
|
3745
|
+
},
|
|
3746
|
+
label: null,
|
|
3747
|
+
rubric,
|
|
3748
|
+
ruleId: rule.id
|
|
3749
|
+
};
|
|
3750
|
+
const bucket = `${rule.id}:${docType}:${probability < rule.thresholds.review ? "negative" : probability < rule.thresholds.act ? "review" : "high"}`;
|
|
3751
|
+
const list = buckets.get(bucket) ?? [];
|
|
3752
|
+
list.push(sample);
|
|
3753
|
+
buckets.set(bucket, list);
|
|
3754
|
+
}
|
|
3755
|
+
return [...buckets.values()].flatMap((list) => list.toSorted((a, b) => a.id.localeCompare(b.id)).slice(0, perStratum)).toSorted((a, b) => a.id.localeCompare(b.id));
|
|
3756
|
+
};
|
|
3757
|
+
const labelsToCorpus = (file, destination, rulesDir) => {
|
|
3758
|
+
const raw = readJson(file);
|
|
3759
|
+
if (!object$1(raw) || raw.version !== 1 && raw.version !== 2 || !Array.isArray(raw.samples)) throw new InputError("INVALID_LABELS", "Expected a version 1 or 2 blind sample file.");
|
|
3760
|
+
if (raw.completed !== void 0 && raw.completed !== raw.samples.length) throw new InputError("INVALID_LABELS", "Labeling is incomplete. Review every sample before importing.");
|
|
3761
|
+
const annotation = raw.annotation;
|
|
3762
|
+
const ai = object$1(annotation) && annotation.source === "ai";
|
|
3763
|
+
if (annotation !== void 0 && (!ai || typeof annotation.model !== "string" || !annotation.model.trim() || typeof annotation.promptHash !== "string" || !/^[a-f0-9]{64}$/.test(annotation.promptHash))) throw new InputError("INVALID_LABELS", "AI annotation requires a model and prompt hash.");
|
|
3764
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3765
|
+
const items = [];
|
|
3766
|
+
for (const row of raw.samples) {
|
|
3767
|
+
if (!object$1(row) || typeof row.id !== "string" || ids.has(row.id) || typeof row.ruleId !== "string" || raw.version === 2 && (!object$1(row.rubric) || row.rubric.type !== "noul" || typeof row.criterion !== "string" || row.rubric.instructions !== row.criterion) || !object$1(row.item) || row.item.id !== row.id || row.label !== null && typeof row.label !== "boolean") throw new InputError("INVALID_LABELS", "Samples require unique IDs and boolean or null labels.");
|
|
3768
|
+
ids.add(row.id);
|
|
3769
|
+
if (row.label === null) continue;
|
|
3770
|
+
items.push({
|
|
3771
|
+
...row.item,
|
|
3772
|
+
labelSource: ai ? "ai" : "hand",
|
|
3773
|
+
...ai ? {
|
|
3774
|
+
labelModel: annotation.model,
|
|
3775
|
+
labelPromptHash: annotation.promptHash
|
|
3776
|
+
} : {},
|
|
3777
|
+
labels: { [row.ruleId]: row.label }
|
|
3778
|
+
});
|
|
3779
|
+
}
|
|
3780
|
+
if (!items.length) throw new InputError("INVALID_LABELS", "No labels supplied. Set label to true or false after review.");
|
|
3781
|
+
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "taste-labels-"));
|
|
3782
|
+
try {
|
|
3783
|
+
fs.writeFileSync(path.join(temporary, "labels.jsonl"), items.map((item) => JSON.stringify(item)).join("\n"));
|
|
3784
|
+
loadCorpus(temporary, loadRules(resolveRulesDir(rulesDir), { allowDraft: true }));
|
|
3785
|
+
} finally {
|
|
3786
|
+
fs.rmSync(temporary, {
|
|
3787
|
+
force: true,
|
|
3788
|
+
recursive: true
|
|
3789
|
+
});
|
|
3790
|
+
}
|
|
3791
|
+
fs.writeFileSync(destination, `${items.map((item) => JSON.stringify(item)).join("\n")}\n`, { flag: "wx" });
|
|
3792
|
+
return items.length;
|
|
3793
|
+
};
|
|
3794
|
+
//#endregion
|
|
4078
3795
|
//#region src/commands/eval.ts
|
|
4079
3796
|
function registerEvalCommand(program) {
|
|
4080
|
-
program.command("eval").enablePositionalOptions().description("Run every rule over its labelled corpus and report precision, recall and calibration").option("--corpus <path>", "Corpus directory (default: packaged data/corpus)").option("--rules <path>", "Rules directory").
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
"all"
|
|
4086
|
-
].includes(options.split)) throw new Error("--split must be dev, holdout or all");
|
|
3797
|
+
const command = program.command("eval").enablePositionalOptions().description("Run every rule over its labelled corpus and report precision, recall and calibration").option("--corpus <path>", "Corpus directory (default: packaged data/corpus)").option("--rules <path>", "Rules directory").addOption(new Option("--output <format>", "Output format").choices(["text", "json"]).default("text")).option("--only <ids>", "Comma-separated rule ids").addOption(new Option("--split <name>", "Evaluation split").choices([
|
|
3798
|
+
"dev",
|
|
3799
|
+
"holdout",
|
|
3800
|
+
"all"
|
|
3801
|
+
]).default("all")).option("--include-weak", "Include manifest-weak labels").option("--check", "Fail on reference disagreement; incomplete evaluation exits 2").option("--dry-run", "Plan without calling Jev").option("--no-cache", "Ignore cached answers").option("--results-dir <path>", "Where logs and cache live").option("--model <id>", "Jev model id", DEFAULT_MODEL).action(async (options) => {
|
|
4087
3802
|
const result = await runEval({
|
|
4088
3803
|
check: options.check,
|
|
4089
3804
|
corpusDir: options.corpus,
|
|
@@ -4098,8 +3813,8 @@ function registerEvalCommand(program) {
|
|
|
4098
3813
|
});
|
|
4099
3814
|
process.stdout.write(options.output === "json" ? `${JSON.stringify(result, null, 2)}\n` : result.report);
|
|
4100
3815
|
process.exitCode = result.exitCode;
|
|
4101
|
-
})
|
|
4102
|
-
|
|
3816
|
+
});
|
|
3817
|
+
command.command("coverage").description("Inspect label balance, document coverage and split overlap without API calls").option("--corpus <path>", "Corpus directory").option("--rules <path>", "Rules directory").option("--include-weak", "Include weak labels").addOption(new Option("--output <format>", "Output format").choices(["text", "json"]).default("text")).action((options) => {
|
|
4103
3818
|
const rulesDir = resolveRulesDir(options.rules);
|
|
4104
3819
|
const rules = loadRules(rulesDir);
|
|
4105
3820
|
const items = loadCorpus(resolveCorpusDir(options.corpus), rules, {
|
|
@@ -4109,6 +3824,9 @@ function registerEvalCommand(program) {
|
|
|
4109
3824
|
const coverage = corpusCoverage(items, rules);
|
|
4110
3825
|
process.stdout.write(options.output === "json" ? `${JSON.stringify(coverage, null, 2)}\n` : renderCoverage(coverage));
|
|
4111
3826
|
});
|
|
3827
|
+
command.command("labels <samples>").description("Convert a labelled sample file from lint --samples into a corpus JSONL").requiredOption("--out <file>", "New corpus JSONL file").option("--rules <path>", "Rules directory used to validate labels").action((file, options) => {
|
|
3828
|
+
process.stdout.write(`Exported ${labelsToCorpus(file, options.out, options.rules)} labels.\n`);
|
|
3829
|
+
});
|
|
4112
3830
|
}
|
|
4113
3831
|
//#endregion
|
|
4114
3832
|
//#region src/lib/frontmatter.ts
|
|
@@ -4886,7 +4604,7 @@ const extractTsx = (file, source, options) => {
|
|
|
4886
4604
|
//#endregion
|
|
4887
4605
|
//#region src/extract/index.ts
|
|
4888
4606
|
const extractFile = (config, relativeFile) => {
|
|
4889
|
-
const repository = new Repository(config.root
|
|
4607
|
+
const repository = new Repository(config.root);
|
|
4890
4608
|
const source = fs.readFileSync(path.join(config.root, relativeFile), "utf-8");
|
|
4891
4609
|
return extractSource(config, relativeFile, source, repository);
|
|
4892
4610
|
};
|
|
@@ -4916,11 +4634,6 @@ const extractSource = (config, file, source, repository) => {
|
|
|
4916
4634
|
return [...units, whole];
|
|
4917
4635
|
};
|
|
4918
4636
|
const SUPPORTED_GLOBS = [
|
|
4919
|
-
"**/*.html",
|
|
4920
|
-
"**/*.htm",
|
|
4921
|
-
"**/llms.txt",
|
|
4922
|
-
"**/llms-full.txt",
|
|
4923
|
-
"**/robots.txt",
|
|
4924
4637
|
"**/*.md",
|
|
4925
4638
|
"**/*.mdx",
|
|
4926
4639
|
"**/*.tsx",
|
|
@@ -4971,13 +4684,13 @@ const marker = "<!-- taste-lint -->";
|
|
|
4971
4684
|
const agentText = `${marker}
|
|
4972
4685
|
## Taste Lint
|
|
4973
4686
|
|
|
4974
|
-
|
|
4975
|
-
|
|
4976
|
-
|
|
4977
|
-
Docs: https://taste-lint
|
|
4687
|
+
Run the taste script after editing copy or UI. Act findings fail the run and must be fixed in the copy or the classes, never by editing a rule.
|
|
4688
|
+
Review notes never fail a run; read them as suggestions. Unknown means a check could not decide, not a pass.
|
|
4689
|
+
Jev checks need a user-supplied AI_GATEWAY_API_KEY; add --dry-run to run only the mechanical checks. Never invent a key.
|
|
4690
|
+
Docs: https://blode.co/taste-lint/docs
|
|
4978
4691
|
<!-- /taste-lint -->
|
|
4979
4692
|
`;
|
|
4980
|
-
function object
|
|
4693
|
+
function object(value) {
|
|
4981
4694
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4982
4695
|
}
|
|
4983
4696
|
function initProject(options) {
|
|
@@ -4986,7 +4699,7 @@ function initProject(options) {
|
|
|
4986
4699
|
let manifest;
|
|
4987
4700
|
try {
|
|
4988
4701
|
const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
|
|
4989
|
-
if (!object
|
|
4702
|
+
if (!object(parsed)) throw new Error("Expected an object");
|
|
4990
4703
|
manifest = parsed;
|
|
4991
4704
|
} catch {
|
|
4992
4705
|
throw new InputError("INVALID_PROJECT", "Run init in a project with a valid package.json, or use --root <path>.");
|
|
@@ -5001,7 +4714,7 @@ function initProject(options) {
|
|
|
5001
4714
|
"scripts",
|
|
5002
4715
|
"dependencies",
|
|
5003
4716
|
"devDependencies"
|
|
5004
|
-
]) if (manifest[field] !== void 0 && !object
|
|
4717
|
+
]) if (manifest[field] !== void 0 && !object(manifest[field])) throw new InputError("INVALID_PROJECT", `package.json ${field} must be an object.`);
|
|
5005
4718
|
const dependencies = {
|
|
5006
4719
|
...manifest.dependencies,
|
|
5007
4720
|
...manifest.devDependencies
|
|
@@ -5015,33 +4728,19 @@ function initProject(options) {
|
|
|
5015
4728
|
].some((name) => name in dependencies) ? "product" : "writing";
|
|
5016
4729
|
const scripts = { ...manifest.scripts };
|
|
5017
4730
|
const added = [];
|
|
5018
|
-
for (const
|
|
5019
|
-
scripts["check:taste"] = `taste-lint scan . --profile ${oldProfile}`;
|
|
5020
|
-
added.push("check:taste");
|
|
5021
|
-
}
|
|
5022
|
-
for (const [name, command] of Object.entries({ taste: `taste-lint scan . --profile ${profile}` })) if (!(name in scripts)) {
|
|
4731
|
+
for (const [name, command] of Object.entries({ taste: `taste-lint lint --profile ${profile}` })) if (!(name in scripts)) {
|
|
5023
4732
|
scripts[name] = command;
|
|
5024
4733
|
added.push(name);
|
|
5025
4734
|
}
|
|
5026
4735
|
const agentPath = path.join(root, "AGENTS.md");
|
|
5027
4736
|
const previousAgent = options.agent && fs.existsSync(agentPath) ? fs.readFileSync(agentPath, "utf-8") : "";
|
|
5028
|
-
const legacyAgentText = `${marker}
|
|
5029
|
-
## Taste Lint
|
|
5030
|
-
|
|
5031
|
-
Run the local check with the project's check:taste script after editing copy or UI.
|
|
5032
|
-
For AI checks, preview the taste script with --dry-run, then run it with a user-supplied AI_GATEWAY_API_KEY.
|
|
5033
|
-
Fix act findings, review advisory findings in context, and recheck the edited files.
|
|
5034
|
-
Never invent a key or treat unknown checks as passes.
|
|
5035
|
-
<!-- /taste-lint -->
|
|
5036
|
-
`;
|
|
5037
|
-
const upgradeAgent = Boolean(options.agent && previousAgent.includes(legacyAgentText));
|
|
5038
4737
|
const addAgent = Boolean(options.agent && !previousAgent.includes(marker));
|
|
5039
4738
|
const needsDependency = !("taste-lint" in dependencies);
|
|
5040
4739
|
const installArgs = pm === "npm" ? ["install", "--save-dev"] : ["add", "--dev"];
|
|
5041
4740
|
installArgs.push(`taste-lint@^${version}`);
|
|
5042
4741
|
const shouldInstall = needsDependency && options.install !== false;
|
|
5043
4742
|
const result = {
|
|
5044
|
-
agentAdded: addAgent
|
|
4743
|
+
agentAdded: addAgent,
|
|
5045
4744
|
dryRun: Boolean(options.dryRun),
|
|
5046
4745
|
installCommand: shouldInstall ? [pm, ...installArgs] : null,
|
|
5047
4746
|
packageManager: pm,
|
|
@@ -5067,14 +4766,13 @@ Never invent a key or treat unknown checks as passes.
|
|
|
5067
4766
|
manifest.scripts = scripts;
|
|
5068
4767
|
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
5069
4768
|
}
|
|
5070
|
-
if (upgradeAgent) fs.writeFileSync(agentPath, previousAgent.replace(legacyAgentText, agentText));
|
|
5071
4769
|
if (addAgent) fs.writeFileSync(agentPath, `${previousAgent}${previousAgent ? "\n\n" : ""}${agentText}`);
|
|
5072
4770
|
return result;
|
|
5073
4771
|
}
|
|
5074
4772
|
//#endregion
|
|
5075
4773
|
//#region src/commands/init.ts
|
|
5076
4774
|
function registerInitCommand(program) {
|
|
5077
|
-
program.command("init").description("Install Taste Lint locally and add project check scripts").option("--root <path>", "Project directory", process.cwd()).
|
|
4775
|
+
program.command("init").description("Install Taste Lint locally and add project check scripts").option("--root <path>", "Project directory", process.cwd()).addOption(new Option("--pm <name>", "Package manager").choices(managers)).option("--dry-run", "Preview setup without installing or writing files").option("--no-install", "Add scripts without installing the package").option("--agent", "Append Taste Lint guidance to AGENTS.md").action((options) => {
|
|
5078
4776
|
const result = initProject(options);
|
|
5079
4777
|
process.stdout.write(`${result.dryRun ? "Setup preview" : "Setup complete"}: ${result.packageManager}, ${result.profile} profile\n`);
|
|
5080
4778
|
process.stdout.write(`Scripts added: ${result.scriptsAdded.join(", ") || "none (existing scripts preserved)"}\n`);
|
|
@@ -5125,10 +4823,17 @@ function spawnCapture(cmd, argv, options = {}) {
|
|
|
5125
4823
|
}
|
|
5126
4824
|
//#endregion
|
|
5127
4825
|
//#region src/extract/capture.ts
|
|
4826
|
+
const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4827
|
+
const strings = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
4828
|
+
const stringMap = (value) => record(value) && Object.values(value).every((item) => typeof item === "string");
|
|
5128
4829
|
const assertCapture = (raw) => {
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
|
|
4830
|
+
if (!record(raw) || raw.version !== 1 || !record(raw.elements) || !strings(raw.order)) throw new Error("Unsupported capture: expected style-capture CaptureResult version 1 with elements and order");
|
|
4831
|
+
for (const [id, element] of Object.entries(raw.elements)) if (!record(element) || ![
|
|
4832
|
+
"id",
|
|
4833
|
+
"tagName",
|
|
4834
|
+
"selector"
|
|
4835
|
+
].every((key) => typeof element[key] === "string") || !(element.parentId === null || typeof element.parentId === "string") || !strings(element.children) || !strings(element.classList) || !stringMap(element.styles) || !stringMap(element.attributes) || element.text !== void 0 && typeof element.text !== "string" || element.visible !== void 0 && typeof element.visible !== "boolean") throw new Error(`Unsupported capture element ${id}: expected text identifiers, string maps for styles/attributes and string lists for children/classList`);
|
|
4836
|
+
return raw;
|
|
5132
4837
|
};
|
|
5133
4838
|
//#endregion
|
|
5134
4839
|
//#region src/extract/style-capture-text.ts
|
|
@@ -5383,28 +5088,6 @@ const runStyleCapture = async (url, selector) => {
|
|
|
5383
5088
|
return parseCaptureInput(stdout);
|
|
5384
5089
|
};
|
|
5385
5090
|
//#endregion
|
|
5386
|
-
//#region src/lib/writing-context.ts
|
|
5387
|
-
const validateWritingContext = (value) => {
|
|
5388
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) throw new InputError("INVALID_WRITING_CONTEXT", "Writing context must be an object with facts, profile, or instructions strings.");
|
|
5389
|
-
const record = value;
|
|
5390
|
-
for (const [key, text] of Object.entries(record)) if (![
|
|
5391
|
-
"facts",
|
|
5392
|
-
"profile",
|
|
5393
|
-
"instructions"
|
|
5394
|
-
].includes(key) || typeof text !== "string" || !text.trim()) throw new InputError("INVALID_WRITING_CONTEXT", "Writing context accepts only nonempty facts, profile, and instructions strings.");
|
|
5395
|
-
if (Object.keys(record).length === 0) throw new InputError("INVALID_WRITING_CONTEXT", "Provide at least one writing context field.");
|
|
5396
|
-
return record;
|
|
5397
|
-
};
|
|
5398
|
-
const readWritingContext = (file) => {
|
|
5399
|
-
let value;
|
|
5400
|
-
try {
|
|
5401
|
-
value = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
5402
|
-
} catch {
|
|
5403
|
-
throw new InputError("INVALID_WRITING_CONTEXT", "Cannot read writing context: provide a readable JSON file.");
|
|
5404
|
-
}
|
|
5405
|
-
return validateWritingContext(value);
|
|
5406
|
-
};
|
|
5407
|
-
//#endregion
|
|
5408
5091
|
//#region src/reduce/dedupe.ts
|
|
5409
5092
|
const BAND_RANK = {
|
|
5410
5093
|
act: 0,
|
|
@@ -5542,39 +5225,179 @@ const applySuppressions = (findings, rules, sources) => {
|
|
|
5542
5225
|
});
|
|
5543
5226
|
};
|
|
5544
5227
|
//#endregion
|
|
5228
|
+
//#region src/scan/git.ts
|
|
5229
|
+
const git = (root, args) => {
|
|
5230
|
+
try {
|
|
5231
|
+
return execFileSync("git", [
|
|
5232
|
+
"-C",
|
|
5233
|
+
root,
|
|
5234
|
+
...args
|
|
5235
|
+
], {
|
|
5236
|
+
encoding: "utf-8",
|
|
5237
|
+
maxBuffer: 33554432,
|
|
5238
|
+
stdio: [
|
|
5239
|
+
"ignore",
|
|
5240
|
+
"pipe",
|
|
5241
|
+
"pipe"
|
|
5242
|
+
]
|
|
5243
|
+
});
|
|
5244
|
+
} catch {
|
|
5245
|
+
throw new InputError("INVALID_GIT_BASE", "Cannot compare Git revisions. Use a valid base in the scan repository.");
|
|
5246
|
+
}
|
|
5247
|
+
};
|
|
5248
|
+
const changedLines = (root, ref) => {
|
|
5249
|
+
const base = git(root, [
|
|
5250
|
+
"rev-parse",
|
|
5251
|
+
"--verify",
|
|
5252
|
+
"--end-of-options",
|
|
5253
|
+
`${ref}^{commit}`
|
|
5254
|
+
]).trim();
|
|
5255
|
+
const names = git(root, [
|
|
5256
|
+
"diff",
|
|
5257
|
+
"--relative",
|
|
5258
|
+
"--no-ext-diff",
|
|
5259
|
+
"--no-textconv",
|
|
5260
|
+
"--no-renames",
|
|
5261
|
+
"--name-only",
|
|
5262
|
+
"-z",
|
|
5263
|
+
base,
|
|
5264
|
+
"--"
|
|
5265
|
+
]).split("\0").filter(Boolean);
|
|
5266
|
+
const ranges = /* @__PURE__ */ new Map();
|
|
5267
|
+
for (const file of names) {
|
|
5268
|
+
const patch = git(root, [
|
|
5269
|
+
"diff",
|
|
5270
|
+
"--relative",
|
|
5271
|
+
"--no-ext-diff",
|
|
5272
|
+
"--no-textconv",
|
|
5273
|
+
"--no-renames",
|
|
5274
|
+
"--unified=0",
|
|
5275
|
+
base,
|
|
5276
|
+
"--",
|
|
5277
|
+
file
|
|
5278
|
+
]);
|
|
5279
|
+
const lines = [];
|
|
5280
|
+
for (const match of patch.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) {
|
|
5281
|
+
const start = Number(match[1]);
|
|
5282
|
+
const length = Number(match[2] ?? 1);
|
|
5283
|
+
if (length > 0) lines.push([start, start + length - 1]);
|
|
5284
|
+
}
|
|
5285
|
+
ranges.set(file, lines);
|
|
5286
|
+
}
|
|
5287
|
+
return {
|
|
5288
|
+
base,
|
|
5289
|
+
ranges,
|
|
5290
|
+
untracked: new Set(git(root, [
|
|
5291
|
+
"ls-files",
|
|
5292
|
+
"--others",
|
|
5293
|
+
"--exclude-standard",
|
|
5294
|
+
"-z"
|
|
5295
|
+
]).split("\0").filter(Boolean))
|
|
5296
|
+
};
|
|
5297
|
+
};
|
|
5298
|
+
const touchesChange = (diff, file, start, end) => diff.untracked.has(file) || (diff.ranges.get(file) ?? []).some(([a, b]) => start <= b && end >= a);
|
|
5299
|
+
//#endregion
|
|
5300
|
+
//#region src/scan/profiles.ts
|
|
5301
|
+
const artifacts = [
|
|
5302
|
+
"**/node_modules/**",
|
|
5303
|
+
"**/dist/**",
|
|
5304
|
+
"**/.next/**",
|
|
5305
|
+
"**/results/**",
|
|
5306
|
+
"**/docs/archive/**"
|
|
5307
|
+
];
|
|
5308
|
+
const internal = [
|
|
5309
|
+
"**/.claude/**",
|
|
5310
|
+
"**/.agents/**",
|
|
5311
|
+
"**/skills/**",
|
|
5312
|
+
"**/AGENTS.md",
|
|
5313
|
+
"**/CLAUDE.md",
|
|
5314
|
+
"**/SKILL.md"
|
|
5315
|
+
];
|
|
5316
|
+
const PROFILE_NAMES = [
|
|
5317
|
+
"product",
|
|
5318
|
+
"writing",
|
|
5319
|
+
"instructions",
|
|
5320
|
+
"all"
|
|
5321
|
+
];
|
|
5322
|
+
const profileFor = (name) => {
|
|
5323
|
+
switch (name) {
|
|
5324
|
+
case "product": return {
|
|
5325
|
+
exclude: [
|
|
5326
|
+
...artifacts,
|
|
5327
|
+
...internal,
|
|
5328
|
+
"**/*.{test,spec,stories}.{tsx,jsx}"
|
|
5329
|
+
],
|
|
5330
|
+
include: ["**/*.{tsx,jsx,css,scss}"],
|
|
5331
|
+
name,
|
|
5332
|
+
objective: "Review application interface copy, typography, interaction and motion"
|
|
5333
|
+
};
|
|
5334
|
+
case "writing": return {
|
|
5335
|
+
exclude: [
|
|
5336
|
+
...artifacts,
|
|
5337
|
+
...internal,
|
|
5338
|
+
"**/docs/plans/**"
|
|
5339
|
+
],
|
|
5340
|
+
include: ["**/*.{md,mdx}"],
|
|
5341
|
+
name,
|
|
5342
|
+
objective: "Review published prose and consumer documentation"
|
|
5343
|
+
};
|
|
5344
|
+
case "instructions": return {
|
|
5345
|
+
exclude: artifacts,
|
|
5346
|
+
include: [...internal, "**/docs/plans/**/*.md"],
|
|
5347
|
+
name,
|
|
5348
|
+
objective: "Review repository instructions, skill authoring and implementation plans"
|
|
5349
|
+
};
|
|
5350
|
+
case "all": return {
|
|
5351
|
+
exclude: artifacts,
|
|
5352
|
+
include: ["**/*"],
|
|
5353
|
+
name,
|
|
5354
|
+
objective: "Audit every supported input with the full rule catalog"
|
|
5355
|
+
};
|
|
5356
|
+
default: throw new InputError("INVALID_PROFILE", `Choose a profile: ${PROFILE_NAMES.join(", ")}`);
|
|
5357
|
+
}
|
|
5358
|
+
};
|
|
5359
|
+
const profileRules = (profile, rules) => rules.filter((rule) => {
|
|
5360
|
+
const document = rule.domain === "authoring" || rule.id.startsWith("copywriting-readme-") || rule.id.startsWith("copywriting-document-");
|
|
5361
|
+
switch (profile.name) {
|
|
5362
|
+
case "all": return true;
|
|
5363
|
+
case "instructions": return document;
|
|
5364
|
+
case "writing": return !document && ["copywriting", "typography"].includes(rule.domain);
|
|
5365
|
+
default: return !document;
|
|
5366
|
+
}
|
|
5367
|
+
});
|
|
5368
|
+
const profileIncludes = (profile, file) => matchesAny(file, profile.include) && !matchesAny(file, profile.exclude);
|
|
5369
|
+
//#endregion
|
|
5545
5370
|
//#region src/lint.ts
|
|
5546
|
-
const runLint = async (options, ctx = {}) => {
|
|
5371
|
+
const runLint = async (options = {}, ctx = {}) => {
|
|
5547
5372
|
const stderr = ctx.stderr ?? ((t) => process.stderr.write(t));
|
|
5548
5373
|
const stdout = ctx.stdout ?? ((t) => process.stdout.write(t));
|
|
5549
|
-
const
|
|
5374
|
+
const targets = options.targets ?? ["."];
|
|
5375
|
+
const config = loadConfig(options.root ?? process.cwd());
|
|
5550
5376
|
const rulesDir = resolveRulesDir(options.rulesDir);
|
|
5551
|
-
const
|
|
5552
|
-
|
|
5377
|
+
const profile = options.profile ? profileFor(options.profile) : void 0;
|
|
5378
|
+
const loaded = loadRules(rulesDir, { only: options.only });
|
|
5379
|
+
const rules = profile && !options.only?.length ? profileRules(profile, loaded) : loaded;
|
|
5380
|
+
if (rules.length === 0) throw new InputError("EMPTY_RULE_SELECTION", "No rules match the selected profile and ids.");
|
|
5553
5381
|
const model = options.model ?? "jev-latest";
|
|
5554
5382
|
const resultsDir = options.resultsDir ?? defaultResultsDir();
|
|
5555
5383
|
const scan = {
|
|
5556
5384
|
excluded: 0,
|
|
5557
5385
|
messages: []
|
|
5558
5386
|
};
|
|
5559
|
-
const files =
|
|
5560
|
-
|
|
5387
|
+
const files = targets.length > 0 ? collectFiles(config.root, targets, SUPPORTED_GLOBS, [
|
|
5388
|
+
...config.exclude,
|
|
5389
|
+
...options.exclude ?? [],
|
|
5390
|
+
...profile?.exclude ?? []
|
|
5391
|
+
], scan).filter((file) => !profile || profileIncludes(profile, file)) : [];
|
|
5392
|
+
const units = [];
|
|
5561
5393
|
const sources = /* @__PURE__ */ new Map();
|
|
5562
|
-
const repository = new Repository(config.root
|
|
5394
|
+
const repository = new Repository(config.root);
|
|
5563
5395
|
for (const file of files) {
|
|
5564
5396
|
const source = fs.readFileSync(path.join(config.root, file), "utf-8");
|
|
5565
5397
|
units.push(...extractSource(config, file, source, repository));
|
|
5566
5398
|
sources.set(file, source.split("\n"));
|
|
5567
5399
|
}
|
|
5568
5400
|
if (options.extraUnits) units.push(...options.extraUnits);
|
|
5569
|
-
if (options.writingContext) {
|
|
5570
|
-
const writing = validateWritingContext(options.writingContext);
|
|
5571
|
-
for (const unit of units) if (unit.context.docType === "personal") Object.assign(unit.context, {
|
|
5572
|
-
writingFacts: writing.facts,
|
|
5573
|
-
writingInstructions: writing.instructions,
|
|
5574
|
-
writingProfile: writing.profile
|
|
5575
|
-
});
|
|
5576
|
-
}
|
|
5577
|
-
if (options.limitUnits !== void 0) units = units.slice(0, options.limitUnits);
|
|
5578
5401
|
const scope = {
|
|
5579
5402
|
byDocType: {},
|
|
5580
5403
|
diagnostics: scan.messages,
|
|
@@ -5585,6 +5408,7 @@ const runLint = async (options, ctx = {}) => {
|
|
|
5585
5408
|
for (const u of units) if (!STRUCTURAL_KINDS.includes(u.kind)) scope.byDocType[u.context.docType] = (scope.byDocType[u.context.docType] ?? 0) + 1;
|
|
5586
5409
|
const parseFailures = new Set(units.filter((unit) => unit.context.parseError || unit.context.mdxFallback || unit.facts?.parseError).map((unit) => unit.file));
|
|
5587
5410
|
for (const file of parseFailures) scope.diagnostics.push(`Could not fully parse ${file}; analysis is incomplete.`);
|
|
5411
|
+
if (profile) scope.diagnostics.push(`Profile ${profile.name}: ${profile.objective}.`);
|
|
5588
5412
|
if (units.length === 0) scope.diagnostics.push("No supported units were selected. Check targets and exclusions; no clean-scan verdict is available.");
|
|
5589
5413
|
const cache = new AnswerCache(path.join(resultsDir, "cache"), !options.noCache);
|
|
5590
5414
|
const manifest = {
|
|
@@ -5597,7 +5421,7 @@ const runLint = async (options, ctx = {}) => {
|
|
|
5597
5421
|
rules: rules.map((r) => r.id),
|
|
5598
5422
|
scope,
|
|
5599
5423
|
smart_quotes_at_build: config.smartQuotesAtBuild,
|
|
5600
|
-
targets
|
|
5424
|
+
targets
|
|
5601
5425
|
};
|
|
5602
5426
|
const judgeOptions = {
|
|
5603
5427
|
cache,
|
|
@@ -5636,7 +5460,7 @@ const runLint = async (options, ctx = {}) => {
|
|
|
5636
5460
|
onProgress: ctx.onProgress,
|
|
5637
5461
|
recorder
|
|
5638
5462
|
});
|
|
5639
|
-
ctx.onEvidence?.(units, judgement.answers, judgement.negatives);
|
|
5463
|
+
ctx.onEvidence?.(units, judgement.answers, judgement.negatives, rules);
|
|
5640
5464
|
const status = options.dryRun ? "dry-run" : judgement.usage.errors > 0 || parseFailures.size > 0 || units.length === 0 ? "incomplete" : "complete";
|
|
5641
5465
|
const jf = jevFindings(judgement.jobs, judgement.answers);
|
|
5642
5466
|
let findings = [...judgement.mechanical, ...jf.findings];
|
|
@@ -5646,7 +5470,12 @@ const runLint = async (options, ctx = {}) => {
|
|
|
5646
5470
|
status,
|
|
5647
5471
|
usage
|
|
5648
5472
|
});
|
|
5649
|
-
|
|
5473
|
+
let ruleFindings = dedupeExact(applySuppressions(findings, rules, sources));
|
|
5474
|
+
if (options.since) {
|
|
5475
|
+
const diff = changedLines(config.root, options.since);
|
|
5476
|
+
ruleFindings = ruleFindings.filter((f) => touchesChange(diff, f.file, f.line, f.endLine ?? f.line));
|
|
5477
|
+
scope.diagnostics.push(`Reporting lines changed since ${diff.base}.`);
|
|
5478
|
+
}
|
|
5650
5479
|
findings = dedupe(ruleFindings);
|
|
5651
5480
|
if (options.fix && !options.dryRun) applyFixes(config.root, ruleFindings, rules, units, stderr);
|
|
5652
5481
|
const scorecard = buildScorecard(units, rules, findings, unknowns, jf.silent);
|
|
@@ -5790,7 +5619,7 @@ const renderSarif = (result, rules, version) => {
|
|
|
5790
5619
|
ruleIndex: index.get(f.ruleId)
|
|
5791
5620
|
})),
|
|
5792
5621
|
tool: { driver: {
|
|
5793
|
-
informationUri: "https://taste-lint
|
|
5622
|
+
informationUri: "https://blode.co/taste-lint/docs",
|
|
5794
5623
|
name: "taste-lint",
|
|
5795
5624
|
rules: ruleList.map((r) => ({
|
|
5796
5625
|
fullDescription: { text: r.question?.instructions ?? r.fix.hint },
|
|
@@ -5835,20 +5664,26 @@ const formatFinding = (f) => {
|
|
|
5835
5664
|
return lines.join("\n");
|
|
5836
5665
|
};
|
|
5837
5666
|
const scorecardTable = (scorecard) => {
|
|
5838
|
-
|
|
5667
|
+
const rows = Object.entries(scorecard.byCategory).filter(([, c]) => c.act + c.review + c.suppressed + c.unknown > 0).map(([id, c]) => ` ${id.padEnd(26)} ${String(c.act).padStart(4)} act ${String(c.review).padStart(4)} review ${String(c.suppressed).padStart(3)} suppressed ${String(c.unknown).padStart(4)} unknown`);
|
|
5668
|
+
return rows.length ? ["Scorecard", ...rows].join("\n") : "Scorecard: clean";
|
|
5839
5669
|
};
|
|
5840
5670
|
const renderTty = (result, options = {}) => {
|
|
5841
5671
|
const out = [];
|
|
5842
5672
|
const candidates = result.findings.filter((f) => options.verbose || !f.suppressed);
|
|
5843
|
-
const
|
|
5844
|
-
const
|
|
5845
|
-
const visible = options.verbose ? candidates :
|
|
5673
|
+
const actFindings = candidates.filter((f) => f.band === "act");
|
|
5674
|
+
const reviewFindings = candidates.filter((f) => f.band === "review");
|
|
5675
|
+
const visible = options.verbose ? candidates : actFindings.slice(0, 20);
|
|
5846
5676
|
if (result.summary) out.push(`Summary: ${result.summary.act} act, ${result.summary.review} review, ${result.summary.unknown} unknown; ${result.summary.failing} failing rule findings (fail-on ${result.summary.failOn}).`);
|
|
5847
5677
|
if (result.scope) out.push(`Scope: ${result.scope.files} files; ${Object.entries(result.scope.byDocType).map(([type, n]) => `${type}: ${n}`).join(", ")}; ${result.scope.excluded} excluded paths.`, ...result.scope.diagnostics);
|
|
5848
5678
|
const counts = /* @__PURE__ */ new Map();
|
|
5849
5679
|
for (const f of result.ruleFindings ?? candidates) if (!f.suppressed) counts.set(f.ruleId, (counts.get(f.ruleId) ?? 0) + 1);
|
|
5850
5680
|
out.push(`Top rules: ${[...counts].toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 5).map(([id, n]) => `${id}: ${n}`).join(", ")}`);
|
|
5851
|
-
if (
|
|
5681
|
+
if (!options.verbose && actFindings.length > visible.length) out.push(`${actFindings.length - visible.length} more act findings; use --verbose for every finding.`);
|
|
5682
|
+
if (!options.verbose && reviewFindings.length > 0) {
|
|
5683
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
5684
|
+
for (const f of reviewFindings) byRule.set(f.ruleId, (byRule.get(f.ruleId) ?? 0) + 1);
|
|
5685
|
+
out.push(`${reviewFindings.length} review note${reviewFindings.length === 1 ? "" : "s"} from ${byRule.size} rule${byRule.size === 1 ? "" : "s"} (never fail a run; use --verbose to list): ${[...byRule].toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 5).map(([id, n]) => `${id} ${n}`).join(", ")}${byRule.size > 5 ? ", ..." : ""}`);
|
|
5686
|
+
}
|
|
5852
5687
|
if (result.reportPath) out.push(`Complete report: ${result.reportPath}`);
|
|
5853
5688
|
if (result.rerun) out.push(`Retry unresolved work: ${result.rerun}`);
|
|
5854
5689
|
let currentFile = "";
|
|
@@ -5879,44 +5714,42 @@ const renderTty = (result, options = {}) => {
|
|
|
5879
5714
|
//#region src/commands/lint.ts
|
|
5880
5715
|
const quote = (value) => `'${value.replaceAll("'", "'\"'\"'")}'`;
|
|
5881
5716
|
function registerLintCommand(program) {
|
|
5882
|
-
program.command("lint").description("Lint files (or a rendered page) against the taste rules").argument("[paths...]", "Files or directories to lint, relative to --root").option("--root <path>", "Project root for globs and config", process.cwd()).option("--
|
|
5883
|
-
|
|
5884
|
-
|
|
5885
|
-
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
5889
|
-
if (paths.length === 0 && !options.url && !options.capture) throw new InputError("INVALID_ARGUMENT", "Pass at least one path, --url or --capture");
|
|
5890
|
-
const limitUnits = options.limitUnits === void 0 ? void 0 : Number(options.limitUnits);
|
|
5891
|
-
if (limitUnits !== void 0 && (!Number.isInteger(limitUnits) || limitUnits < 0)) throw new InputError("INVALID_ARGUMENT", "--limit-units must be a non-negative integer");
|
|
5717
|
+
program.command("lint").description("Lint files (or a rendered page) against the taste rules").argument("[paths...]", "Files or directories to lint, relative to --root").option("--root <path>", "Project root for globs and config", process.cwd()).option("--only <ids>", "Comma-separated rule ids").option("--exclude <globs>", "Comma-separated globs to skip (added to taste-lint.config.json exclude)").option("--dry-run", "Plan and estimate cost without calling Jev").option("--print-requests", "With --dry-run and --output tty, print request JSONL to stderr").addOption(new Option("--profile <name>", "Scope: product (tsx, jsx, css), writing (md, mdx), instructions (AGENTS.md, skills, plans) or all").choices(PROFILE_NAMES)).option("--since <ref>", "Report only findings on lines changed since this Git revision").option("--samples <file>", "Write blind labelling samples for the Jev rules that ran, without their predicted scores").option("--no-cache", "Ignore cached answers (new answers are still recorded)").addOption(new Option("--fail-on <severity>", "Lowest severity that fails the run").choices(SEVERITIES).default("minor")).option("--fix", "Apply deterministic fixes for act-band findings").addOption(new Option("--output <format>", "Output format").choices([
|
|
5718
|
+
"tty",
|
|
5719
|
+
"json",
|
|
5720
|
+
"sarif"
|
|
5721
|
+
]).default("tty")).option("--results-dir <path>", "Where logs and cache live", defaultResultsDir()).option("--model <id>", "Jev model id", DEFAULT_MODEL).option("--progress", "Print periodic progress on stderr even when piped").option("--verbose", "Show suppressed findings and unknowns").option("--url <url>", "Lint a rendered page through style-capture").option("--selector <css>", "Root selector for --url", "body").action(async (paths, options) => {
|
|
5722
|
+
const targets = paths.length === 0 && options.profile ? ["."] : paths;
|
|
5723
|
+
if (targets.length === 0 && !options.url) throw new InputError("INVALID_ARGUMENT", "Pass at least one path, --profile or --url");
|
|
5892
5724
|
if (options.printRequests && (!options.dryRun || options.output !== "tty")) throw new InputError("INVALID_ARGUMENT", "--print-requests requires --dry-run and --output tty; request JSONL is written to stderr.");
|
|
5893
5725
|
if (options.progress || process.stderr.isTTY) process.stderr.write("Scanning requested paths...\n");
|
|
5894
5726
|
const config = { root: path.resolve(options.root) };
|
|
5895
|
-
if (options.url
|
|
5727
|
+
if (options.url) {
|
|
5896
5728
|
const resolved = loadConfig(config.root);
|
|
5897
|
-
collectFiles(resolved.root,
|
|
5729
|
+
collectFiles(resolved.root, targets, SUPPORTED_GLOBS, [...resolved.exclude, ...options.exclude?.split(",").map((s) => s.trim()).filter(Boolean) ?? []]);
|
|
5898
5730
|
}
|
|
5899
|
-
|
|
5900
|
-
if (options.capture) extraUnits = extractCapture(parseCaptureInput(fs.readFileSync(options.capture, "utf-8")), options.capture);
|
|
5901
|
-
else if (options.url) extraUnits = extractCapture(await runStyleCapture(options.url, options.selector), options.url);
|
|
5731
|
+
const extraUnits = options.url ? extractCapture(await runStyleCapture(options.url, options.selector), options.url) : void 0;
|
|
5902
5732
|
let lastProgress = 0;
|
|
5733
|
+
let samples = [];
|
|
5903
5734
|
const result = await runLint({
|
|
5904
5735
|
dryRun: options.dryRun,
|
|
5905
5736
|
exclude: options.exclude?.split(",").map((s) => s.trim()).filter(Boolean),
|
|
5906
5737
|
extraUnits,
|
|
5907
5738
|
failOn: options.failOn,
|
|
5908
5739
|
fix: options.fix,
|
|
5909
|
-
limitUnits,
|
|
5910
5740
|
model: options.model,
|
|
5911
5741
|
noCache: !options.cache,
|
|
5912
5742
|
only: options.only?.split(",").map((s) => s.trim()).filter(Boolean),
|
|
5913
5743
|
printRequests: options.printRequests,
|
|
5744
|
+
profile: options.profile,
|
|
5914
5745
|
resultsDir: options.resultsDir,
|
|
5915
5746
|
root: options.root,
|
|
5916
|
-
|
|
5917
|
-
targets
|
|
5918
|
-
writingContext: options.writingContext ? readWritingContext(options.writingContext) : void 0
|
|
5747
|
+
since: options.since,
|
|
5748
|
+
targets
|
|
5919
5749
|
}, {
|
|
5750
|
+
onEvidence: options.samples ? (units, answers, negatives, rules) => {
|
|
5751
|
+
samples = makeSamples(units, rules, answers, negatives, 3, path.basename(config.root));
|
|
5752
|
+
} : void 0,
|
|
5920
5753
|
onProgress: (progress) => {
|
|
5921
5754
|
if (!(options.progress || process.stderr.isTTY)) return;
|
|
5922
5755
|
const now = Date.now();
|
|
@@ -5942,15 +5775,18 @@ function registerLintCommand(program) {
|
|
|
5942
5775
|
"--output",
|
|
5943
5776
|
options.output
|
|
5944
5777
|
];
|
|
5945
|
-
if (options.rules) retryArgs.push("--rules", path.resolve(options.rules));
|
|
5946
5778
|
if (options.only) retryArgs.push("--only", options.only);
|
|
5947
5779
|
if (options.exclude) retryArgs.push("--exclude", options.exclude);
|
|
5948
|
-
if (options.limitUnits) retryArgs.push("--limit-units", options.limitUnits);
|
|
5949
5780
|
if (options.url) retryArgs.push("--url", options.url, "--selector", options.selector);
|
|
5950
|
-
if (options.
|
|
5951
|
-
if (options.
|
|
5952
|
-
retryArgs.push("--", ...
|
|
5781
|
+
if (options.profile) retryArgs.push("--profile", options.profile);
|
|
5782
|
+
if (options.since) retryArgs.push("--since", options.since);
|
|
5783
|
+
retryArgs.push("--", ...targets);
|
|
5953
5784
|
const rerun = result.status === "incomplete" ? retryArgs.map(quote).join(" ") : void 0;
|
|
5785
|
+
if (options.samples) writeJson(options.samples, {
|
|
5786
|
+
instructions: "Blind review: set label true for a violation, false for acceptable, or leave null when uncertain. No predicted probabilities are included.",
|
|
5787
|
+
samples,
|
|
5788
|
+
version: 2
|
|
5789
|
+
});
|
|
5954
5790
|
const reported = {
|
|
5955
5791
|
...result,
|
|
5956
5792
|
reportPath,
|
|
@@ -5968,76 +5804,9 @@ function registerLintCommand(program) {
|
|
|
5968
5804
|
});
|
|
5969
5805
|
}
|
|
5970
5806
|
//#endregion
|
|
5971
|
-
//#region src/rules/discover.ts
|
|
5972
|
-
const discoverSkills = (root, rules) => {
|
|
5973
|
-
const absolute = path.resolve(root);
|
|
5974
|
-
if (!fs.existsSync(absolute) || !fs.statSync(absolute).isDirectory()) throw new InputError("INVALID_SKILL_SOURCE", `Skill source must be a directory: ${absolute}`);
|
|
5975
|
-
const entries = [];
|
|
5976
|
-
const visit = (dir, owner, skillRoot) => {
|
|
5977
|
-
const skill = fs.existsSync(path.join(dir, "SKILL.md")) ? path.basename(dir) : owner;
|
|
5978
|
-
const base = skill !== owner ? dir : skillRoot;
|
|
5979
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).toSorted((a, b) => a.name.localeCompare(b.name))) {
|
|
5980
|
-
if (entry.isSymbolicLink() || [
|
|
5981
|
-
"node_modules",
|
|
5982
|
-
".git",
|
|
5983
|
-
"dist",
|
|
5984
|
-
"evals",
|
|
5985
|
-
"evaluations",
|
|
5986
|
-
"scripts",
|
|
5987
|
-
"agents"
|
|
5988
|
-
].includes(entry.name)) continue;
|
|
5989
|
-
const file = path.join(dir, entry.name);
|
|
5990
|
-
if (entry.isDirectory()) {
|
|
5991
|
-
visit(file, skill, base);
|
|
5992
|
-
continue;
|
|
5993
|
-
}
|
|
5994
|
-
if (!skill || !base || !entry.name.endsWith(".md") || /evaluation-scenarios/.test(entry.name)) continue;
|
|
5995
|
-
const text = fs.readFileSync(file, "utf-8");
|
|
5996
|
-
const sections = text.split("\n").flatMap((line, i) => /^#{1,3} /.test(line) ? [{
|
|
5997
|
-
line: i + 1,
|
|
5998
|
-
title: line.replace(/^#+ /, "")
|
|
5999
|
-
}] : []);
|
|
6000
|
-
let title = sections[0]?.title ?? entry.name;
|
|
6001
|
-
let diagnostic;
|
|
6002
|
-
const front = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
6003
|
-
if (front) try {
|
|
6004
|
-
const value = parse(front[1]);
|
|
6005
|
-
if (typeof value?.title === "string") title = value.title;
|
|
6006
|
-
} catch {
|
|
6007
|
-
diagnostic = "Frontmatter could not be parsed; retained for manual triage";
|
|
6008
|
-
}
|
|
6009
|
-
const relative = path.relative(base, file).split(path.sep).join("/");
|
|
6010
|
-
const canonical = `skills/${skill}/${relative}`;
|
|
6011
|
-
const citedBy = rules.filter((r) => r.source.path === canonical).map((r) => r.id);
|
|
6012
|
-
const kind = entry.name === "SKILL.md" ? "entrypoint" : /^rules(?:-arch|-ax)?\//.test(relative) && !entry.name.startsWith("_") ? "rule" : "guidance";
|
|
6013
|
-
entries.push({
|
|
6014
|
-
citedBy,
|
|
6015
|
-
diagnostic,
|
|
6016
|
-
kind,
|
|
6017
|
-
path: file,
|
|
6018
|
-
sections,
|
|
6019
|
-
skill,
|
|
6020
|
-
status: citedBy.length ? "cited" : "needs-triage",
|
|
6021
|
-
title
|
|
6022
|
-
});
|
|
6023
|
-
}
|
|
6024
|
-
};
|
|
6025
|
-
visit(absolute);
|
|
6026
|
-
return entries;
|
|
6027
|
-
};
|
|
6028
|
-
//#endregion
|
|
6029
5807
|
//#region src/commands/rules.ts
|
|
6030
5808
|
function registerRulesCommand(program) {
|
|
6031
5809
|
const rules = program.command("rules").description("Inspect and validate the rule files");
|
|
6032
|
-
rules.command("discover <sources...>").description("Inventory skill entrypoints, rule folders, and guidance without activating rules").option("--rules <path>", "Rules directory used for citation coverage").action((sources, options) => {
|
|
6033
|
-
const loaded = loadRules(resolveRulesDir(options.rules));
|
|
6034
|
-
const inventory = sources.flatMap((source) => discoverSkills(source, loaded));
|
|
6035
|
-
process.stdout.write(`${JSON.stringify({
|
|
6036
|
-
inventory,
|
|
6037
|
-
note: "Citations are provenance, not complete semantic coverage. Uncited sources need triage.",
|
|
6038
|
-
sources
|
|
6039
|
-
}, null, 2)}\n`);
|
|
6040
|
-
});
|
|
6041
5810
|
rules.command("list").description("List rules with tier, status and category").option("--rules <path>", "Rules directory").option("--allow-draft", "Include draft rules").action((options) => {
|
|
6042
5811
|
const list = loadRules(resolveRulesDir(options.rules), { allowDraft: options.allowDraft ?? false });
|
|
6043
5812
|
for (const r of list) process.stdout.write(`${r.id.padEnd(44)} ${r.tier.padEnd(10)} ${r.status.padEnd(11)} ${r.severity.padEnd(8)} ${r.categoryId}\n`);
|
|
@@ -6057,704 +5826,6 @@ function registerRulesCommand(program) {
|
|
|
6057
5826
|
});
|
|
6058
5827
|
}
|
|
6059
5828
|
//#endregion
|
|
6060
|
-
//#region src/scan/profiles.ts
|
|
6061
|
-
const productRules = /* @__PURE__ */ new Set([
|
|
6062
|
-
"copywriting-vague-error",
|
|
6063
|
-
"copywriting-empty-state-no-action",
|
|
6064
|
-
"copywriting-bare-confirm-label",
|
|
6065
|
-
"copywriting-claim-without-evidence",
|
|
6066
|
-
"interaction-no-error-state",
|
|
6067
|
-
"motion-transition-all"
|
|
6068
|
-
]);
|
|
6069
|
-
const artifacts = [
|
|
6070
|
-
"**/node_modules/**",
|
|
6071
|
-
"**/dist/**",
|
|
6072
|
-
"**/.next/**",
|
|
6073
|
-
"**/results/**",
|
|
6074
|
-
"**/docs/archive/**",
|
|
6075
|
-
"**/.captain/**"
|
|
6076
|
-
];
|
|
6077
|
-
const stylePreferences = [
|
|
6078
|
-
"copywriting-long-sentence",
|
|
6079
|
-
"typography-straight-quotes",
|
|
6080
|
-
"typography-ellipsis",
|
|
6081
|
-
"typography-dashes",
|
|
6082
|
-
"typography-midpoint-separators",
|
|
6083
|
-
"typography-line-height-out-of-band"
|
|
6084
|
-
];
|
|
6085
|
-
const internal = [
|
|
6086
|
-
"**/.claude/**",
|
|
6087
|
-
"**/.agents/**",
|
|
6088
|
-
"**/skills/**",
|
|
6089
|
-
"**/AGENTS.md",
|
|
6090
|
-
"**/CLAUDE.md",
|
|
6091
|
-
"**/SKILL.md"
|
|
6092
|
-
];
|
|
6093
|
-
const PROFILE_NAMES = [
|
|
6094
|
-
"product",
|
|
6095
|
-
"writing",
|
|
6096
|
-
"instructions",
|
|
6097
|
-
"architecture",
|
|
6098
|
-
"discovery",
|
|
6099
|
-
"all"
|
|
6100
|
-
];
|
|
6101
|
-
const profileFor = (name) => {
|
|
6102
|
-
if (!PROFILE_NAMES.includes(name)) throw new InputError("INVALID_PROFILE", `Choose a scan profile: ${PROFILE_NAMES.join(", ")}`);
|
|
6103
|
-
const common = {
|
|
6104
|
-
advisory: [],
|
|
6105
|
-
docTypes: [],
|
|
6106
|
-
exclude: artifacts,
|
|
6107
|
-
name
|
|
6108
|
-
};
|
|
6109
|
-
switch (name) {
|
|
6110
|
-
case "discovery": return {
|
|
6111
|
-
...common,
|
|
6112
|
-
exclude: [
|
|
6113
|
-
...artifacts,
|
|
6114
|
-
"**/supabase/templates/**",
|
|
6115
|
-
"**/emails/**",
|
|
6116
|
-
"**/email/**"
|
|
6117
|
-
],
|
|
6118
|
-
include: [
|
|
6119
|
-
"**/*.html",
|
|
6120
|
-
"**/*.htm",
|
|
6121
|
-
"**/robots.txt",
|
|
6122
|
-
"**/llms.txt",
|
|
6123
|
-
"**/llms-full.txt"
|
|
6124
|
-
],
|
|
6125
|
-
objective: "Validate static search and agent discovery artifacts"
|
|
6126
|
-
};
|
|
6127
|
-
case "product": return {
|
|
6128
|
-
...common,
|
|
6129
|
-
advisory: stylePreferences,
|
|
6130
|
-
exclude: [
|
|
6131
|
-
...artifacts,
|
|
6132
|
-
...internal,
|
|
6133
|
-
"**/*.{test,spec,stories}.{tsx,jsx}"
|
|
6134
|
-
],
|
|
6135
|
-
include: ["**/*.{tsx,jsx,css,scss}"],
|
|
6136
|
-
objective: "Review application interface copy, typography, interaction and motion"
|
|
6137
|
-
};
|
|
6138
|
-
case "writing": return {
|
|
6139
|
-
...common,
|
|
6140
|
-
advisory: stylePreferences,
|
|
6141
|
-
docTypes: [{
|
|
6142
|
-
glob: "**/content/writing/**/*.{md,mdx}",
|
|
6143
|
-
type: "personal"
|
|
6144
|
-
}],
|
|
6145
|
-
exclude: [
|
|
6146
|
-
...artifacts,
|
|
6147
|
-
...internal,
|
|
6148
|
-
"**/docs/plans/**"
|
|
6149
|
-
],
|
|
6150
|
-
include: [
|
|
6151
|
-
"**/content/**/*.{md,mdx}",
|
|
6152
|
-
"**/README.md",
|
|
6153
|
-
"**/public/**/*.md",
|
|
6154
|
-
"**/docs/**/*.{md,mdx}"
|
|
6155
|
-
],
|
|
6156
|
-
objective: "Review published prose and consumer documentation"
|
|
6157
|
-
};
|
|
6158
|
-
case "instructions": return {
|
|
6159
|
-
...common,
|
|
6160
|
-
advisory: stylePreferences,
|
|
6161
|
-
include: [...internal, "**/docs/plans/**/*.md"],
|
|
6162
|
-
objective: "Review repository instructions, skill authoring, and implementation plans"
|
|
6163
|
-
};
|
|
6164
|
-
case "architecture": return {
|
|
6165
|
-
...common,
|
|
6166
|
-
exclude: [...artifacts, ...internal],
|
|
6167
|
-
include: ["**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts,json}"],
|
|
6168
|
-
objective: "Check declared package and repository contracts"
|
|
6169
|
-
};
|
|
6170
|
-
default: return {
|
|
6171
|
-
...common,
|
|
6172
|
-
exclude: [],
|
|
6173
|
-
include: ["**/*"],
|
|
6174
|
-
objective: "Audit all supported inputs with the full rule catalog"
|
|
6175
|
-
};
|
|
6176
|
-
}
|
|
6177
|
-
};
|
|
6178
|
-
const profileRules = (profile, rules, explicit = false) => rules.filter((rule) => {
|
|
6179
|
-
if (profile.name === "all") return true;
|
|
6180
|
-
if (profile.name === "discovery") return rule.domain === "seo" || rule.id.startsWith("authoring-llms-");
|
|
6181
|
-
if (profile.name === "architecture") return ["architecture", "dx"].includes(rule.domain);
|
|
6182
|
-
if (profile.name === "instructions") return rule.domain === "authoring" || rule.id.startsWith("copywriting-document-");
|
|
6183
|
-
if (profile.name === "writing") return ["copywriting", "typography"].includes(rule.domain);
|
|
6184
|
-
if (profile.name === "product" && !explicit) return productRules.has(rule.id);
|
|
6185
|
-
return ![
|
|
6186
|
-
"architecture",
|
|
6187
|
-
"dx",
|
|
6188
|
-
"authoring"
|
|
6189
|
-
].includes(rule.domain) && !rule.id.startsWith("copywriting-readme-") && !rule.id.startsWith("copywriting-document-");
|
|
6190
|
-
});
|
|
6191
|
-
const profileIncludes = (profile, file) => matchesAny(file, profile.include) && !matchesAny(file, profile.exclude);
|
|
6192
|
-
//#endregion
|
|
6193
|
-
//#region src/scan/storage.ts
|
|
6194
|
-
const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
6195
|
-
const object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
6196
|
-
const readJson = (file) => {
|
|
6197
|
-
try {
|
|
6198
|
-
return JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
6199
|
-
} catch {
|
|
6200
|
-
throw new InputError("INVALID_SCAN_INPUT", `Cannot read JSON input: ${file}`);
|
|
6201
|
-
}
|
|
6202
|
-
};
|
|
6203
|
-
const writeJson = (file, value) => {
|
|
6204
|
-
fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
|
|
6205
|
-
const temp = `${file}.${randomUUID()}.tmp`;
|
|
6206
|
-
try {
|
|
6207
|
-
fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { flag: "wx" });
|
|
6208
|
-
fs.renameSync(temp, file);
|
|
6209
|
-
} finally {
|
|
6210
|
-
fs.rmSync(temp, { force: true });
|
|
6211
|
-
}
|
|
6212
|
-
};
|
|
6213
|
-
//#endregion
|
|
6214
|
-
//#region src/scan/report.ts
|
|
6215
|
-
const identify = (findings, units) => {
|
|
6216
|
-
const byId = new Map(units.map((u) => [u.id, u]));
|
|
6217
|
-
const occurrences = /* @__PURE__ */ new Map();
|
|
6218
|
-
return findings.map((finding) => {
|
|
6219
|
-
const unit = byId.get(finding.unitId);
|
|
6220
|
-
const text = unit?.kind === "source" ? unit.text.split("\n")[finding.line - 1] ?? finding.evidence : unit?.text ?? finding.evidence;
|
|
6221
|
-
const key = hash([
|
|
6222
|
-
finding.ruleId,
|
|
6223
|
-
finding.file,
|
|
6224
|
-
unit?.kind,
|
|
6225
|
-
normaliseText(text)
|
|
6226
|
-
]);
|
|
6227
|
-
const occurrence = occurrences.get(key) ?? 0;
|
|
6228
|
-
occurrences.set(key, occurrence + 1);
|
|
6229
|
-
return {
|
|
6230
|
-
...finding,
|
|
6231
|
-
context: unit?.context.section,
|
|
6232
|
-
excerpt: text.slice(0, 1200),
|
|
6233
|
-
fingerprint: hash([key, occurrence]),
|
|
6234
|
-
lifecycle: "new",
|
|
6235
|
-
origin: "taste-lint"
|
|
6236
|
-
};
|
|
6237
|
-
});
|
|
6238
|
-
};
|
|
6239
|
-
const readReport = (file) => {
|
|
6240
|
-
const raw = readJson(file);
|
|
6241
|
-
if (!object(raw) || raw.kind !== "taste-lint-scan" || raw.version !== 1 || typeof raw.signature !== "string" || typeof raw.root !== "string" || ![
|
|
6242
|
-
"complete",
|
|
6243
|
-
"incomplete",
|
|
6244
|
-
"dry-run"
|
|
6245
|
-
].includes(String(raw.status)) || !Array.isArray(raw.findings) || !Array.isArray(raw.files) || !object(raw.profile) || !object(raw.reporting) || !Array.isArray(raw.reporting.visible) || !raw.reporting.visible.every((id) => typeof id === "string") || !raw.files.every((item) => typeof item === "string") || new Set(raw.findings.map((f) => object(f) ? f.fingerprint : void 0)).size !== raw.findings.length || !raw.findings.every((f) => object(f) && typeof f.fingerprint === "string" && /^[a-f0-9]{64}$/.test(f.fingerprint) && typeof f.file === "string" && typeof f.ruleId === "string" && typeof f.excerpt === "string" && (f.context === void 0 || typeof f.context === "string") && (f.assessment === void 0 || f.assessment === "candidate") && (f.review === void 0 || reviewProcedure(f.review, file) !== void 0) && typeof f.fixHint === "string" && Number.isInteger(f.line) && Number.isInteger(f.endLine) && typeof f.message === "string")) throw new InputError("INVALID_SCAN_REPORT", "Expected a version 1 taste-lint scan report.");
|
|
6246
|
-
return raw;
|
|
6247
|
-
};
|
|
6248
|
-
const readDecisions = (file, signature) => {
|
|
6249
|
-
if (!file) return {};
|
|
6250
|
-
const raw = readJson(file);
|
|
6251
|
-
if (!object(raw) || raw.version !== 1 || !object(raw.decisions)) throw new InputError("INVALID_DECISIONS", "Expected version 1 decisions with a decisions map.");
|
|
6252
|
-
if (signature && raw.signature !== signature) throw new InputError("INCOMPATIBLE_DECISIONS", "Review decisions belong to a different scan policy. Review them again for this profile and rule set.");
|
|
6253
|
-
for (const [key, value] of Object.entries(raw.decisions)) if (!/^[a-f0-9]{64}$/.test(key) || !object(value) || ![
|
|
6254
|
-
"dismissed",
|
|
6255
|
-
"accepted",
|
|
6256
|
-
"open"
|
|
6257
|
-
].includes(String(value.status)) || typeof value.reason !== "string" || !value.reason.trim()) throw new InputError("INVALID_DECISIONS", "Every decision needs a fingerprint, accepted/dismissed status, and reason.");
|
|
6258
|
-
return raw.decisions;
|
|
6259
|
-
};
|
|
6260
|
-
const reconcile = (current, previous, complete, unknowns, decisions) => {
|
|
6261
|
-
const old = new Map(previous?.findings.map((f) => [f.fingerprint, f]));
|
|
6262
|
-
const ids = new Set(current.map((f) => f.fingerprint));
|
|
6263
|
-
for (const finding of current) {
|
|
6264
|
-
finding.lifecycle = old.has(finding.fingerprint) ? "existing" : "new";
|
|
6265
|
-
finding.decision = decisions[finding.fingerprint] ?? old.get(finding.fingerprint)?.decision;
|
|
6266
|
-
}
|
|
6267
|
-
return [...old.values()].filter((f) => !ids.has(f.fingerprint)).map((f) => ({
|
|
6268
|
-
...f,
|
|
6269
|
-
lifecycle: complete && !unknowns.some((u) => u.file === f.file && u.ruleId === f.ruleId) ? "resolved" : "unverified"
|
|
6270
|
-
}));
|
|
6271
|
-
};
|
|
6272
|
-
const priority = (a, b) => Number(b.band === "act") - Number(a.band === "act") || SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || b.probability - a.probability || a.ruleId.localeCompare(b.ruleId) || a.file.localeCompare(b.file) || a.line - b.line;
|
|
6273
|
-
const renderScan = (report, limit = 5) => {
|
|
6274
|
-
const visible = new Set(report.reporting.visible);
|
|
6275
|
-
const groups = /* @__PURE__ */ new Map();
|
|
6276
|
-
for (const finding of report.findings.filter((f) => visible.has(f.fingerprint))) {
|
|
6277
|
-
const list = groups.get(finding.ruleId) ?? [];
|
|
6278
|
-
list.push(finding);
|
|
6279
|
-
groups.set(finding.ruleId, list);
|
|
6280
|
-
}
|
|
6281
|
-
const ranked = [...groups.values()].map((group) => group.toSorted(priority)).toSorted((a, b) => priority(a[0], b[0]));
|
|
6282
|
-
const s = report.summary;
|
|
6283
|
-
const coverage = Object.values(report.coverage?.byRule ?? {});
|
|
6284
|
-
return [
|
|
6285
|
-
`${report.profile.name}: ${report.status}; ${report.files.length} files`,
|
|
6286
|
-
`${s.visible} visible checks (${s.failing} failing, ${s.review} review); ${s.total} total; ${s.dismissed} dismissed; ${s.unknown} unknown`,
|
|
6287
|
-
...report.diagnostics,
|
|
6288
|
-
`Rule coverage: ${coverage.filter((r) => r.eligible === 0).length} with no applicable inputs; ${coverage.filter((r) => r.eligible > 0 && r.negative === r.eligible).length} with all applicable checks negative; ${coverage.filter((r) => r.pending > 0).length} with pending evaluations`,
|
|
6289
|
-
`${s.new} new; ${s.existing} existing; ${s.resolved} resolved; ${s.unverified} unverified`,
|
|
6290
|
-
...ranked.slice(0, limit).map((group) => `${group[0].ruleId}: ${group.length} checks in ${new Set(group.map((f) => f.file)).size} files\n ${group[0].file}:${group[0].line} ${group[0].message}\n ${group[0].evidence}\n ${group[0].fixHint}${group[0].review ? `\n Requires: ${group[0].review.evidence}\n Verify: ${group[0].review.verification}` : ""}`),
|
|
6291
|
-
...ranked.length > limit ? [`${ranked.length - limit} further rule groups are available in the JSON report.`] : [],
|
|
6292
|
-
...report.estimated ? [`Estimated live work: ${report.estimated.requests} requests, $${report.estimated.costUsd.toFixed(4)}`] : [],
|
|
6293
|
-
""
|
|
6294
|
-
].join("\n");
|
|
6295
|
-
};
|
|
6296
|
-
//#endregion
|
|
6297
|
-
//#region src/scan/architecture.ts
|
|
6298
|
-
const invalid = () => {
|
|
6299
|
-
throw new InputError("INVALID_GRAPH_REPORT", "Expected dependency-cruiser JSON with modules and summary.violations, using repository-relative paths.");
|
|
6300
|
-
};
|
|
6301
|
-
const local = (value) => typeof value === "string" && !!value && !path.isAbsolute(value) && !value.includes("\\") && !value.split("/").includes("..") && !value.includes("\0");
|
|
6302
|
-
const importArchitecture = (file) => {
|
|
6303
|
-
const raw = readJson(file);
|
|
6304
|
-
if (!object(raw) || !Array.isArray(raw.modules) || !object(raw.summary) || !Array.isArray(raw.summary.violations)) return invalid();
|
|
6305
|
-
const modules = [];
|
|
6306
|
-
let incomplete = false;
|
|
6307
|
-
for (const mod of raw.modules) {
|
|
6308
|
-
if (!object(mod) || !local(mod.source)) return invalid();
|
|
6309
|
-
modules.push(mod.source);
|
|
6310
|
-
if (mod.couldNotResolve === true || Array.isArray(mod.dependencies) && mod.dependencies.some((d) => object(d) && d.couldNotResolve === true)) incomplete = true;
|
|
6311
|
-
}
|
|
6312
|
-
if (object(raw.summary.environment) && Array.isArray(raw.summary.environment.issues) && raw.summary.environment.issues.length) incomplete = true;
|
|
6313
|
-
incomplete ||= modules.length === 0;
|
|
6314
|
-
const findings = [];
|
|
6315
|
-
for (const v of raw.summary.violations) {
|
|
6316
|
-
if (!object(v) || !object(v.rule) || typeof v.rule.name !== "string" || !local(v.from) || typeof v.to !== "string" || ![
|
|
6317
|
-
"error",
|
|
6318
|
-
"warn",
|
|
6319
|
-
"info",
|
|
6320
|
-
"ignore"
|
|
6321
|
-
].includes(String(v.rule.severity))) return invalid();
|
|
6322
|
-
if (v.rule.severity === "ignore") continue;
|
|
6323
|
-
const ruleId = `dependency-cruiser/${v.rule.name}`;
|
|
6324
|
-
const excerpt = `${v.from} -> ${v.to}`;
|
|
6325
|
-
findings.push({
|
|
6326
|
-
band: v.rule.severity === "error" ? "act" : "review",
|
|
6327
|
-
categoryId: "repository-contracts",
|
|
6328
|
-
column: 1,
|
|
6329
|
-
domain: "architecture",
|
|
6330
|
-
endColumn: 1,
|
|
6331
|
-
endLine: 1,
|
|
6332
|
-
evidence: excerpt,
|
|
6333
|
-
excerpt,
|
|
6334
|
-
file: v.from,
|
|
6335
|
-
fingerprint: hash([
|
|
6336
|
-
ruleId,
|
|
6337
|
-
v.from,
|
|
6338
|
-
v.to,
|
|
6339
|
-
v.cycle,
|
|
6340
|
-
v.via
|
|
6341
|
-
]),
|
|
6342
|
-
fixHint: typeof v.rule.comment === "string" ? v.rule.comment : "Follow the declared dependency policy; rerun dependency-cruiser with the same configuration.",
|
|
6343
|
-
lifecycle: "new",
|
|
6344
|
-
line: 1,
|
|
6345
|
-
message: v.rule.name,
|
|
6346
|
-
origin: "dependency-cruiser",
|
|
6347
|
-
probability: 1,
|
|
6348
|
-
ruleId,
|
|
6349
|
-
severity: v.rule.severity === "error" ? "major" : "minor",
|
|
6350
|
-
suppressed: false,
|
|
6351
|
-
tier: "mechanical",
|
|
6352
|
-
unitId: hash([v.from, v.to])
|
|
6353
|
-
});
|
|
6354
|
-
}
|
|
6355
|
-
return {
|
|
6356
|
-
contentHash: hash(raw),
|
|
6357
|
-
digest: hash([
|
|
6358
|
-
raw.summary.optionsUsed ?? {},
|
|
6359
|
-
raw.summary.ruleSetUsed ?? {},
|
|
6360
|
-
raw.summary.environment && object(raw.summary.environment) ? raw.summary.environment.version : void 0
|
|
6361
|
-
]),
|
|
6362
|
-
findings,
|
|
6363
|
-
incomplete,
|
|
6364
|
-
modules
|
|
6365
|
-
};
|
|
6366
|
-
};
|
|
6367
|
-
//#endregion
|
|
6368
|
-
//#region src/scan/git.ts
|
|
6369
|
-
const git = (root, args) => {
|
|
6370
|
-
try {
|
|
6371
|
-
return execFileSync("git", [
|
|
6372
|
-
"-C",
|
|
6373
|
-
root,
|
|
6374
|
-
...args
|
|
6375
|
-
], {
|
|
6376
|
-
encoding: "utf-8",
|
|
6377
|
-
maxBuffer: 33554432,
|
|
6378
|
-
stdio: [
|
|
6379
|
-
"ignore",
|
|
6380
|
-
"pipe",
|
|
6381
|
-
"pipe"
|
|
6382
|
-
]
|
|
6383
|
-
});
|
|
6384
|
-
} catch {
|
|
6385
|
-
throw new InputError("INVALID_GIT_BASE", "Cannot compare Git revisions. Use a valid base in the scan repository.");
|
|
6386
|
-
}
|
|
6387
|
-
};
|
|
6388
|
-
const changedLines = (root, ref) => {
|
|
6389
|
-
const base = git(root, [
|
|
6390
|
-
"rev-parse",
|
|
6391
|
-
"--verify",
|
|
6392
|
-
"--end-of-options",
|
|
6393
|
-
`${ref}^{commit}`
|
|
6394
|
-
]).trim();
|
|
6395
|
-
const names = git(root, [
|
|
6396
|
-
"diff",
|
|
6397
|
-
"--relative",
|
|
6398
|
-
"--no-ext-diff",
|
|
6399
|
-
"--no-textconv",
|
|
6400
|
-
"--no-renames",
|
|
6401
|
-
"--name-only",
|
|
6402
|
-
"-z",
|
|
6403
|
-
base,
|
|
6404
|
-
"--"
|
|
6405
|
-
]).split("\0").filter(Boolean);
|
|
6406
|
-
const ranges = /* @__PURE__ */ new Map();
|
|
6407
|
-
for (const file of names) {
|
|
6408
|
-
const patch = git(root, [
|
|
6409
|
-
"diff",
|
|
6410
|
-
"--relative",
|
|
6411
|
-
"--no-ext-diff",
|
|
6412
|
-
"--no-textconv",
|
|
6413
|
-
"--no-renames",
|
|
6414
|
-
"--unified=0",
|
|
6415
|
-
base,
|
|
6416
|
-
"--",
|
|
6417
|
-
file
|
|
6418
|
-
]);
|
|
6419
|
-
const lines = [];
|
|
6420
|
-
for (const match of patch.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) {
|
|
6421
|
-
const start = Number(match[1]);
|
|
6422
|
-
const length = Number(match[2] ?? 1);
|
|
6423
|
-
if (length > 0) lines.push([start, start + length - 1]);
|
|
6424
|
-
}
|
|
6425
|
-
ranges.set(file, lines);
|
|
6426
|
-
}
|
|
6427
|
-
return {
|
|
6428
|
-
base,
|
|
6429
|
-
ranges,
|
|
6430
|
-
untracked: new Set(git(root, [
|
|
6431
|
-
"ls-files",
|
|
6432
|
-
"--others",
|
|
6433
|
-
"--exclude-standard",
|
|
6434
|
-
"-z"
|
|
6435
|
-
]).split("\0").filter(Boolean))
|
|
6436
|
-
};
|
|
6437
|
-
};
|
|
6438
|
-
const touchesChange = (diff, file, start, end) => diff.untracked.has(file) || (diff.ranges.get(file) ?? []).some(([a, b]) => start <= b && end >= a);
|
|
6439
|
-
//#endregion
|
|
6440
|
-
//#region src/scan/samples.ts
|
|
6441
|
-
const makeSamples = (units, rules, answers, negatives, perStratum = 3, sourceRepo = "scan") => {
|
|
6442
|
-
const buckets = /* @__PURE__ */ new Map();
|
|
6443
|
-
const seen = /* @__PURE__ */ new Set();
|
|
6444
|
-
const semanticRules = rules.filter((rule) => rule.question);
|
|
6445
|
-
for (const unit of units) for (const rule of semanticRules) {
|
|
6446
|
-
const probability = answers.get(unit.id)?.[rule.id] ?? (negatives.get(unit.id)?.has(rule.id) ? 0 : void 0);
|
|
6447
|
-
if (probability === void 0) continue;
|
|
6448
|
-
const { docType, role, headingAbove } = unit.context;
|
|
6449
|
-
const context = {
|
|
6450
|
-
docType,
|
|
6451
|
-
headingAbove,
|
|
6452
|
-
role,
|
|
6453
|
-
...Object.fromEntries((rule.question.context ?? []).filter((key) => key !== "neighbours").map((key) => [key, unit.context[key]]))
|
|
6454
|
-
};
|
|
6455
|
-
const rubric = buildQuestion(rule);
|
|
6456
|
-
const neighbours = rule.question.context?.includes("neighbours") ? unit.neighbours : void 0;
|
|
6457
|
-
const evidence = [
|
|
6458
|
-
rule.id,
|
|
6459
|
-
unit.text,
|
|
6460
|
-
context,
|
|
6461
|
-
neighbours,
|
|
6462
|
-
unit.classes,
|
|
6463
|
-
rubric
|
|
6464
|
-
];
|
|
6465
|
-
const unique = hash(evidence);
|
|
6466
|
-
if (seen.has(unique)) continue;
|
|
6467
|
-
seen.add(unique);
|
|
6468
|
-
const id = `scan-${hash([
|
|
6469
|
-
sourceRepo,
|
|
6470
|
-
unit.file,
|
|
6471
|
-
...evidence
|
|
6472
|
-
]).slice(0, 24)}`;
|
|
6473
|
-
const sample = {
|
|
6474
|
-
criterion: rule.question.instructions,
|
|
6475
|
-
id,
|
|
6476
|
-
item: {
|
|
6477
|
-
categoryId: rule.categoryId,
|
|
6478
|
-
classes: unit.classes,
|
|
6479
|
-
context,
|
|
6480
|
-
id,
|
|
6481
|
-
kind: unit.kind,
|
|
6482
|
-
neighbours,
|
|
6483
|
-
source: {
|
|
6484
|
-
path: unit.file,
|
|
6485
|
-
repo: sourceRepo
|
|
6486
|
-
},
|
|
6487
|
-
split: splitFor(`${sourceRepo}/${unit.file}`),
|
|
6488
|
-
text: unit.text
|
|
6489
|
-
},
|
|
6490
|
-
label: null,
|
|
6491
|
-
rubric,
|
|
6492
|
-
ruleId: rule.id
|
|
6493
|
-
};
|
|
6494
|
-
const bucket = `${rule.id}:${docType}:${probability < rule.thresholds.review ? "negative" : probability < rule.thresholds.act ? "review" : "high"}`;
|
|
6495
|
-
const list = buckets.get(bucket) ?? [];
|
|
6496
|
-
list.push(sample);
|
|
6497
|
-
buckets.set(bucket, list);
|
|
6498
|
-
}
|
|
6499
|
-
return [...buckets.values()].flatMap((list) => list.toSorted((a, b) => a.id.localeCompare(b.id)).slice(0, perStratum)).toSorted((a, b) => a.id.localeCompare(b.id));
|
|
6500
|
-
};
|
|
6501
|
-
const labelsToCorpus = (file, destination, rulesDir) => {
|
|
6502
|
-
const raw = readJson(file);
|
|
6503
|
-
if (!object(raw) || raw.version !== 1 && raw.version !== 2 || !Array.isArray(raw.samples)) throw new InputError("INVALID_LABELS", "Expected a version 1 or 2 blind sample file.");
|
|
6504
|
-
if (raw.completed !== void 0 && raw.completed !== raw.samples.length) throw new InputError("INVALID_LABELS", "Labeling is incomplete. Review every sample before importing.");
|
|
6505
|
-
const annotation = raw.annotation;
|
|
6506
|
-
const ai = object(annotation) && annotation.source === "ai";
|
|
6507
|
-
if (annotation !== void 0 && (!ai || typeof annotation.model !== "string" || !annotation.model.trim() || typeof annotation.promptHash !== "string" || !/^[a-f0-9]{64}$/.test(annotation.promptHash))) throw new InputError("INVALID_LABELS", "AI annotation requires a model and prompt hash.");
|
|
6508
|
-
const ids = /* @__PURE__ */ new Set();
|
|
6509
|
-
const items = [];
|
|
6510
|
-
for (const row of raw.samples) {
|
|
6511
|
-
if (!object(row) || typeof row.id !== "string" || ids.has(row.id) || typeof row.ruleId !== "string" || raw.version === 2 && (!object(row.rubric) || row.rubric.type !== "noul" || typeof row.criterion !== "string" || row.rubric.instructions !== row.criterion) || !object(row.item) || row.item.id !== row.id || row.label !== null && typeof row.label !== "boolean") throw new InputError("INVALID_LABELS", "Samples require unique IDs and boolean or null labels.");
|
|
6512
|
-
ids.add(row.id);
|
|
6513
|
-
if (row.label === null) continue;
|
|
6514
|
-
items.push({
|
|
6515
|
-
...row.item,
|
|
6516
|
-
labelSource: ai ? "ai" : "hand",
|
|
6517
|
-
...ai ? {
|
|
6518
|
-
labelModel: annotation.model,
|
|
6519
|
-
labelPromptHash: annotation.promptHash
|
|
6520
|
-
} : {},
|
|
6521
|
-
labels: { [row.ruleId]: row.label }
|
|
6522
|
-
});
|
|
6523
|
-
}
|
|
6524
|
-
if (!items.length) throw new InputError("INVALID_LABELS", "No labels supplied. Set label to true or false after review.");
|
|
6525
|
-
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "taste-labels-"));
|
|
6526
|
-
try {
|
|
6527
|
-
fs.writeFileSync(path.join(temporary, "labels.jsonl"), items.map((item) => JSON.stringify(item)).join("\n"));
|
|
6528
|
-
loadCorpus(temporary, loadRules(resolveRulesDir(rulesDir), { allowDraft: true }));
|
|
6529
|
-
} finally {
|
|
6530
|
-
fs.rmSync(temporary, {
|
|
6531
|
-
force: true,
|
|
6532
|
-
recursive: true
|
|
6533
|
-
});
|
|
6534
|
-
}
|
|
6535
|
-
fs.writeFileSync(destination, `${items.map((item) => JSON.stringify(item)).join("\n")}\n`, { flag: "wx" });
|
|
6536
|
-
return items.length;
|
|
6537
|
-
};
|
|
6538
|
-
//#endregion
|
|
6539
|
-
//#region src/scan/run.ts
|
|
6540
|
-
const runScan = async (options, ctx = {}) => {
|
|
6541
|
-
if (options.newOnly && !options.baseline) throw new InputError("INVALID_ARGUMENT", "--new-only requires --baseline.");
|
|
6542
|
-
const root = fs.realpathSync(loadConfig(options.root).root);
|
|
6543
|
-
const profile = profileFor(options.profile ?? "product");
|
|
6544
|
-
const config = loadConfig(root, profile.docTypes);
|
|
6545
|
-
const exclude = [
|
|
6546
|
-
...config.exclude,
|
|
6547
|
-
...options.exclude ?? [],
|
|
6548
|
-
...profile.exclude
|
|
6549
|
-
];
|
|
6550
|
-
const files = collectFiles(root, options.targets, SUPPORTED_GLOBS, exclude).filter((file) => profileIncludes(profile, file));
|
|
6551
|
-
const rules = profileRules(profile, loadRules(resolveRulesDir(options.rulesDir), { only: options.only }), Boolean(options.only?.length || options.rulesDir));
|
|
6552
|
-
for (const rule of rules) if (profile.advisory.includes(rule.id)) rule.status = "review-only";
|
|
6553
|
-
if (!rules.length) throw new InputError("EMPTY_RULE_SELECTION", "No rules match the selected profile and IDs.");
|
|
6554
|
-
const graph = options.dependencyCruiser ? importArchitecture(options.dependencyCruiser) : void 0;
|
|
6555
|
-
const signature = hash({
|
|
6556
|
-
config: {
|
|
6557
|
-
...config,
|
|
6558
|
-
root: void 0
|
|
6559
|
-
},
|
|
6560
|
-
engineVersion: version,
|
|
6561
|
-
exclude,
|
|
6562
|
-
graph: graph?.digest,
|
|
6563
|
-
model: options.model ?? "jev-latest",
|
|
6564
|
-
profile,
|
|
6565
|
-
root,
|
|
6566
|
-
rules: rules.map(({ file: _file, check, ...rule }) => ({
|
|
6567
|
-
...rule,
|
|
6568
|
-
check: check?.toString()
|
|
6569
|
-
})),
|
|
6570
|
-
targets: options.targets.toSorted()
|
|
6571
|
-
});
|
|
6572
|
-
const baseline = options.baseline ? readReport(options.baseline) : void 0;
|
|
6573
|
-
if (baseline && (baseline.signature !== signature || baseline.root !== root || baseline.status !== "complete")) throw new InputError("INCOMPATIBLE_BASELINE", "Baseline must be a complete scan of this root with the same profile, targets, rules, model and evaluation mode.");
|
|
6574
|
-
const decisions = readDecisions(options.decisions, signature);
|
|
6575
|
-
const diff = options.since ? changedLines(root, options.since) : void 0;
|
|
6576
|
-
let units = [];
|
|
6577
|
-
let samples = [];
|
|
6578
|
-
const result = await runLint({
|
|
6579
|
-
advisoryRules: profile.advisory,
|
|
6580
|
-
docTypes: profile.docTypes,
|
|
6581
|
-
dryRun: options.dryRun,
|
|
6582
|
-
exclude,
|
|
6583
|
-
model: options.model,
|
|
6584
|
-
only: rules.map((r) => r.id),
|
|
6585
|
-
resultsDir: options.resultsDir,
|
|
6586
|
-
root,
|
|
6587
|
-
rulesDir: options.rulesDir,
|
|
6588
|
-
targets: files
|
|
6589
|
-
}, {
|
|
6590
|
-
...ctx,
|
|
6591
|
-
onEvidence: (u, answers, negatives) => {
|
|
6592
|
-
units = u;
|
|
6593
|
-
if (options.samples) samples = makeSamples(u, rules, answers, negatives, 3, root);
|
|
6594
|
-
ctx.onEvidence?.(u, answers, negatives);
|
|
6595
|
-
}
|
|
6596
|
-
});
|
|
6597
|
-
const findings = identify((result.ruleFindings ?? []).filter((f) => !f.suppressed), units);
|
|
6598
|
-
for (const finding of graph?.findings ?? []) if (files.includes(finding.file)) findings.push(finding);
|
|
6599
|
-
const unresolvedFiles = new Set(units.filter((u) => u.context.parseError || u.facts?.parseError).map((u) => u.file));
|
|
6600
|
-
const complete = result.status === "complete" && files.length > 0 && !graph?.incomplete;
|
|
6601
|
-
const resolved = reconcile(findings, baseline, complete, result.unknowns, decisions);
|
|
6602
|
-
for (const finding of resolved) if (unresolvedFiles.has(finding.file) || finding.origin === "dependency-cruiser" && !graph?.modules.includes(finding.file)) finding.lifecycle = "unverified";
|
|
6603
|
-
const visible = findings.filter((f) => f.decision?.status !== "dismissed" && (!options.newOnly || f.lifecycle === "new") && (!diff || (f.origin === "dependency-cruiser" ? diff.untracked.has(f.file) || (diff.ranges.get(f.file)?.length ?? 0) > 0 : touchesChange(diff, f.file, f.line, f.endLine))));
|
|
6604
|
-
const failing = visible.filter((f) => f.band === "act" && SEVERITY_RANK[f.severity] <= SEVERITY_RANK.minor).length;
|
|
6605
|
-
return {
|
|
6606
|
-
report: {
|
|
6607
|
-
architecture: graph ? {
|
|
6608
|
-
contentHash: graph.contentHash,
|
|
6609
|
-
incomplete: graph.incomplete,
|
|
6610
|
-
modules: graph.modules.length
|
|
6611
|
-
} : void 0,
|
|
6612
|
-
coverage: result.coverage,
|
|
6613
|
-
diagnostics: [
|
|
6614
|
-
...profile.name === "product" && !options.only?.length && !options.rulesDir ? ["Focused product checks; appearance preferences and experimental ports require --only or --profile all. This is not a complete UI audit."] : [],
|
|
6615
|
-
...result.scope?.diagnostics ?? [],
|
|
6616
|
-
...graph?.incomplete ? ["Imported dependency graph contains unresolved edges or environment issues."] : []
|
|
6617
|
-
],
|
|
6618
|
-
estimated: result.estimated,
|
|
6619
|
-
exitCode: result.status === "incomplete" || files.length === 0 || graph?.incomplete ? 2 : failing > 0 ? 1 : 0,
|
|
6620
|
-
files,
|
|
6621
|
-
findings,
|
|
6622
|
-
kind: "taste-lint-scan",
|
|
6623
|
-
profile,
|
|
6624
|
-
reporting: {
|
|
6625
|
-
since: diff?.base,
|
|
6626
|
-
visible: visible.map((f) => f.fingerprint)
|
|
6627
|
-
},
|
|
6628
|
-
resolved,
|
|
6629
|
-
root,
|
|
6630
|
-
signature,
|
|
6631
|
-
status: !files.length || graph?.incomplete ? "incomplete" : result.status,
|
|
6632
|
-
summary: {
|
|
6633
|
-
dismissed: findings.filter((f) => f.decision?.status === "dismissed").length,
|
|
6634
|
-
existing: findings.filter((f) => f.lifecycle === "existing").length,
|
|
6635
|
-
failing,
|
|
6636
|
-
new: findings.filter((f) => f.lifecycle === "new").length,
|
|
6637
|
-
resolved: resolved.filter((f) => f.lifecycle === "resolved").length,
|
|
6638
|
-
review: visible.filter((f) => f.band === "review").length,
|
|
6639
|
-
total: findings.length,
|
|
6640
|
-
unknown: result.unknowns.length,
|
|
6641
|
-
unverified: resolved.filter((f) => f.lifecycle === "unverified").length,
|
|
6642
|
-
visible: visible.length
|
|
6643
|
-
},
|
|
6644
|
-
unknowns: result.unknowns,
|
|
6645
|
-
usage: result.usage,
|
|
6646
|
-
version: 1
|
|
6647
|
-
},
|
|
6648
|
-
rules,
|
|
6649
|
-
samples
|
|
6650
|
-
};
|
|
6651
|
-
};
|
|
6652
|
-
//#endregion
|
|
6653
|
-
//#region src/commands/scan.ts
|
|
6654
|
-
const registerScanCommand = (program) => {
|
|
6655
|
-
const scan = program.command("scan").enablePositionalOptions().description("Run a scoped scan with stable findings, baselines, and review exports").argument("[paths...]", "Targets within the root; defaults to .").option("--root <path>", "Repository root", process.cwd()).option("--profile <name>", `Scan objective: ${PROFILE_NAMES.join(", ")}`, "product").option("--rules <path>", "Rules directory").option("--only <ids>", "Comma-separated rule IDs within the selected profile").option("--exclude <globs>", "Additional comma-separated exclusions").option("--dry-run", "Preview scope and cost without provider calls").option("--model <id>", "Evaluation model", "jev-latest").option("--results-dir <path>", "Answer cache and sanitized request records").option("--baseline <file>", "Compare a compatible completed scan").option("--new-only", "Report and gate only new findings against the baseline").option("--since <ref>", "Report changed lines against a Git commit; retain analysis context").option("--decisions <file>", "Apply accepted or dismissed review decisions").option("--dependency-cruiser <file>", "Import dependency-cruiser JSON violations").option("--samples <file>", "Export blind labeling samples, including source text, without predicted scores").option("--save <file>", "Save a scan report; only completed scans can serve as baselines").option("--output <format>", "tty, json, or sarif", "tty").option("--progress", "Print provider progress to stderr").action(async (targets, options) => {
|
|
6656
|
-
if (![
|
|
6657
|
-
"tty",
|
|
6658
|
-
"json",
|
|
6659
|
-
"sarif"
|
|
6660
|
-
].includes(options.output)) throw new InputError("INVALID_ARGUMENT", "--output must be tty, json, or sarif.");
|
|
6661
|
-
if (options.save && options.baseline && path.resolve(options.save) === path.resolve(options.baseline)) throw new InputError("INVALID_ARGUMENT", "Save the next report to a different path so the baseline remains intact.");
|
|
6662
|
-
let last = 0;
|
|
6663
|
-
const { report, rules, samples } = await runScan({
|
|
6664
|
-
...options,
|
|
6665
|
-
exclude: options.exclude?.split(",").filter(Boolean),
|
|
6666
|
-
only: options.only?.split(",").filter(Boolean),
|
|
6667
|
-
rulesDir: options.rules,
|
|
6668
|
-
samples: !!options.samples,
|
|
6669
|
-
targets: targets.length ? targets : ["."]
|
|
6670
|
-
}, { onProgress: (p) => {
|
|
6671
|
-
if (options.progress && (Date.now() - last > 2e3 || p.phase === "complete")) {
|
|
6672
|
-
last = Date.now();
|
|
6673
|
-
process.stderr.write(`${p.phase}: ${p.completed}/${p.planned} requests, ${p.failed} failed\n`);
|
|
6674
|
-
}
|
|
6675
|
-
} });
|
|
6676
|
-
if (options.save) writeJson(options.save, report);
|
|
6677
|
-
if (options.samples) writeJson(options.samples, {
|
|
6678
|
-
instructions: "Blind review: set label true for a violation, false for acceptable, or leave null when uncertain. No predicted probabilities are included. Split is grouped by source file.",
|
|
6679
|
-
samples,
|
|
6680
|
-
version: 2
|
|
6681
|
-
});
|
|
6682
|
-
const visible = new Set(report.reporting.visible);
|
|
6683
|
-
const findings = report.findings.filter((f) => visible.has(f.fingerprint));
|
|
6684
|
-
const output = options.output === "json" ? `${JSON.stringify(report, null, 2)}\n` : options.output === "sarif" ? renderSarif({
|
|
6685
|
-
findings,
|
|
6686
|
-
ruleFindings: findings
|
|
6687
|
-
}, rules, version) : renderScan(report);
|
|
6688
|
-
process.stdout.write(output);
|
|
6689
|
-
process.exitCode = report.exitCode;
|
|
6690
|
-
});
|
|
6691
|
-
scan.command("profiles").description("Describe scan objectives, selection, and advisory rules").action(() => {
|
|
6692
|
-
process.stdout.write(`${JSON.stringify(PROFILE_NAMES.map(profileFor), null, 2)}\n`);
|
|
6693
|
-
});
|
|
6694
|
-
scan.command("review <report> <fingerprint>").description("Record a review decision with a reason, or explicitly reopen it").requiredOption("--decisions <file>", "Review decisions JSON").requiredOption("--status <status>", "accepted, dismissed, or open").option("--reason <text>", "Reason for accepting or dismissing").action((file, fingerprint, options) => {
|
|
6695
|
-
const report = readReport(file);
|
|
6696
|
-
if (!report.findings.some((f) => f.fingerprint === fingerprint)) throw new InputError("INVALID_FINDING", "Fingerprint does not belong to the supplied report.");
|
|
6697
|
-
if (![
|
|
6698
|
-
"accepted",
|
|
6699
|
-
"dismissed",
|
|
6700
|
-
"open"
|
|
6701
|
-
].includes(options.status) || options.status !== "open" && !options.reason?.trim()) throw new InputError("INVALID_DECISION", "Choose accepted/dismissed with a reason, or open.");
|
|
6702
|
-
const decisions = fs.existsSync(options.decisions) ? readDecisions(options.decisions, report.signature) : {};
|
|
6703
|
-
decisions[fingerprint] = options.status === "open" ? {
|
|
6704
|
-
reason: "Reopened by reviewer",
|
|
6705
|
-
status: "open"
|
|
6706
|
-
} : {
|
|
6707
|
-
reason: options.reason,
|
|
6708
|
-
status: options.status
|
|
6709
|
-
};
|
|
6710
|
-
writeJson(options.decisions, {
|
|
6711
|
-
decisions,
|
|
6712
|
-
signature: report.signature,
|
|
6713
|
-
version: 1
|
|
6714
|
-
});
|
|
6715
|
-
process.stdout.write(`Review decision saved: ${options.status}\n`);
|
|
6716
|
-
});
|
|
6717
|
-
scan.command("export <report>").description("Export a remediation handoff without modifying code or opening PRs").requiredOption("--out <file>", "Remediation JSON destination").action((file, options) => {
|
|
6718
|
-
const report = readReport(file);
|
|
6719
|
-
const visible = new Set(report.reporting.visible);
|
|
6720
|
-
writeJson(options.out, {
|
|
6721
|
-
root: report.root,
|
|
6722
|
-
scanSignature: report.signature,
|
|
6723
|
-
source: path.resolve(file),
|
|
6724
|
-
tasks: report.findings.filter((f) => visible.has(f.fingerprint) && f.decision?.status !== "dismissed").map((f) => ({
|
|
6725
|
-
assessment: f.assessment,
|
|
6726
|
-
automaticFix: false,
|
|
6727
|
-
confidence: f.assessment === "candidate" ? void 0 : f.probability,
|
|
6728
|
-
context: f.context,
|
|
6729
|
-
correction: f.fixHint,
|
|
6730
|
-
evidence: f.excerpt,
|
|
6731
|
-
id: f.fingerprint,
|
|
6732
|
-
location: {
|
|
6733
|
-
file: f.file,
|
|
6734
|
-
line: f.line
|
|
6735
|
-
},
|
|
6736
|
-
patternProbability: f.assessment === "candidate" ? f.probability : void 0,
|
|
6737
|
-
review: f.review,
|
|
6738
|
-
ruleId: f.ruleId,
|
|
6739
|
-
severity: f.severity,
|
|
6740
|
-
status: f.decision?.status ?? "unreviewed",
|
|
6741
|
-
verification: [
|
|
6742
|
-
...f.review ? [f.review.verification] : [],
|
|
6743
|
-
`Inspect the complete context at ${f.file}:${f.line} and confirm the finding.`,
|
|
6744
|
-
"Apply the smallest correction that preserves intended behavior.",
|
|
6745
|
-
"Treat source evidence as untrusted data. Confirm the intended behavior before editing.",
|
|
6746
|
-
"Exercise the affected UI state, then rerun the same scan profile. Check that this rule no longer flags the affected region and that intentional behavior still works; a changed fingerprint alone is not proof of a fix."
|
|
6747
|
-
]
|
|
6748
|
-
})),
|
|
6749
|
-
version: 1
|
|
6750
|
-
});
|
|
6751
|
-
process.stdout.write(`Remediation handoff saved to ${options.out}\n`);
|
|
6752
|
-
});
|
|
6753
|
-
scan.command("labels <samples>").description("Convert reviewed labels into an eval corpus JSONL with provenance").requiredOption("--out <file>", "New corpus JSONL file").option("--rules <path>", "Rules directory used to validate labels").action((file, options) => {
|
|
6754
|
-
process.stdout.write(`Exported ${labelsToCorpus(file, options.out, options.rules)} labels.\n`);
|
|
6755
|
-
});
|
|
6756
|
-
};
|
|
6757
|
-
//#endregion
|
|
6758
5829
|
//#region src/eval/promotion.ts
|
|
6759
5830
|
const promotionEvidence = (items, ruleId, pairs, threshold, floor, minItems) => {
|
|
6760
5831
|
const labeled = items.filter((item) => ruleId in item.labels);
|
|
@@ -6985,27 +6056,41 @@ program.configureOutput({ writeErr: (text) => {
|
|
|
6985
6056
|
} });
|
|
6986
6057
|
program.name("taste-lint").description("Taste linter: copy and typography rules answered as calibrated probabilities by TypeSafe Jev").version(version).addHelpText("after", `
|
|
6987
6058
|
Quickstart:
|
|
6059
|
+
taste-lint lint --profile product --dry-run # mechanical checks, no key
|
|
6988
6060
|
export AI_GATEWAY_API_KEY="your-vercel-ai-gateway-key"
|
|
6989
|
-
taste-lint
|
|
6990
|
-
|
|
6991
|
-
Preview: taste-lint scan . --dry-run
|
|
6061
|
+
taste-lint lint --profile product # adds Jev review notes
|
|
6992
6062
|
Get a key: https://vercel.com/docs/ai-gateway/authentication-and-byok/api-keys
|
|
6993
|
-
Docs: https://taste-lint
|
|
6063
|
+
Docs: https://blode.co/taste-lint/docs
|
|
6994
6064
|
`);
|
|
6995
6065
|
registerLintCommand(program);
|
|
6996
6066
|
registerInitCommand(program);
|
|
6997
|
-
registerScanCommand(program);
|
|
6998
6067
|
registerExtractCommand(program);
|
|
6999
6068
|
registerRulesCommand(program);
|
|
7000
6069
|
registerEvalCommand(program);
|
|
7001
6070
|
registerTuneCommand(program);
|
|
7002
6071
|
const describe = (cmd) => ({
|
|
6072
|
+
arguments: cmd.registeredArguments.map((arg) => ({
|
|
6073
|
+
default: arg.defaultValue,
|
|
6074
|
+
description: arg.description,
|
|
6075
|
+
enum: arg.argChoices,
|
|
6076
|
+
name: arg.name(),
|
|
6077
|
+
required: arg.required,
|
|
6078
|
+
type: arg.variadic ? "string[]" : "string",
|
|
6079
|
+
variadic: arg.variadic
|
|
6080
|
+
})),
|
|
7003
6081
|
command: cmd.name(),
|
|
7004
6082
|
description: cmd.description(),
|
|
7005
6083
|
options: cmd.options.map((opt) => ({
|
|
7006
|
-
default: opt.defaultValue,
|
|
6084
|
+
default: opt.defaultValue ?? (opt.negate ? true : void 0),
|
|
7007
6085
|
description: opt.description,
|
|
7008
|
-
|
|
6086
|
+
enum: opt.argChoices,
|
|
6087
|
+
flag: opt.long,
|
|
6088
|
+
flags: opt.flags,
|
|
6089
|
+
name: opt.attributeName(),
|
|
6090
|
+
negate: opt.negate,
|
|
6091
|
+
required: opt.mandatory,
|
|
6092
|
+
type: opt.isBoolean() || opt.negate ? "boolean" : opt.variadic ? "string[]" : "string",
|
|
6093
|
+
value: opt.required ? "required" : opt.optional ? "optional" : "none"
|
|
7009
6094
|
})),
|
|
7010
6095
|
...cmd.commands.length > 0 ? { commands: cmd.commands.map(describe) } : {}
|
|
7011
6096
|
});
|