speccle 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/dist/check.js +74 -0
- package/dist/claims.js +80 -0
- package/dist/cli.js +218 -0
- package/dist/coverage.js +48 -0
- package/dist/dialects.js +43 -0
- package/dist/discover.js +29 -0
- package/dist/init.js +164 -0
- package/dist/lint.js +20 -0
- package/dist/mutation.js +103 -0
- package/dist/render.js +209 -0
- package/dist/rules/src/index.js +10 -0
- package/dist/rules/src/quality.js +259 -0
- package/dist/rules/src/structural.js +119 -0
- package/dist/spec.js +88 -0
- package/dist/strength.js +187 -0
- package/dist/violation.js +13 -0
- package/package.json +50 -0
package/dist/discover.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
const SKIP_DIRS = new Set(["node_modules", "dist", "fixtures", "__fixtures__"]);
|
|
4
|
+
export function discoverSpecs(root) {
|
|
5
|
+
return discoverFiles(root, (file) => basename(file) === "SPEC.md");
|
|
6
|
+
}
|
|
7
|
+
export function discoverTests(root, dialect) {
|
|
8
|
+
return discoverFiles(root, (file) => dialect.isTestFile(file));
|
|
9
|
+
}
|
|
10
|
+
/** `keep` receives each file's posix path relative to `root`. */
|
|
11
|
+
export async function discoverFiles(root, keep) {
|
|
12
|
+
const found = [];
|
|
13
|
+
async function walk(abs, rel) {
|
|
14
|
+
for (const entry of await readdir(abs, { withFileTypes: true })) {
|
|
15
|
+
if (entry.isDirectory()) {
|
|
16
|
+
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith("."))
|
|
17
|
+
continue;
|
|
18
|
+
await walk(join(abs, entry.name), rel === "" ? entry.name : `${rel}/${entry.name}`);
|
|
19
|
+
}
|
|
20
|
+
else if (entry.isFile()) {
|
|
21
|
+
const file = rel === "" ? entry.name : `${rel}/${entry.name}`;
|
|
22
|
+
if (keep(file))
|
|
23
|
+
found.push(file);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
await walk(root, "");
|
|
28
|
+
return found.sort();
|
|
29
|
+
}
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { access, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { discoverSpecs } from "./discover.js";
|
|
6
|
+
// The one stack the oracle join is proven on (ADR-0008), pinned to those majors.
|
|
7
|
+
export const STRENGTH_DEPS = [
|
|
8
|
+
"vitest@^4",
|
|
9
|
+
"@vitest/coverage-istanbul@^4",
|
|
10
|
+
"@stryker-mutator/core@^9",
|
|
11
|
+
"@stryker-mutator/vitest-runner@^9",
|
|
12
|
+
];
|
|
13
|
+
const LOCKFILES = [
|
|
14
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
15
|
+
["package-lock.json", "npm"],
|
|
16
|
+
["yarn.lock", "yarn"],
|
|
17
|
+
["bun.lock", "bun"],
|
|
18
|
+
["bun.lockb", "bun"],
|
|
19
|
+
];
|
|
20
|
+
const STRYKER_CONFIG_NAMES = ["stryker.config", "stryker.conf"].flatMap((base) => ["json", "js", "mjs", "cjs"].map((ext) => `${base}.${ext}`));
|
|
21
|
+
const VITEST_CONFIG_NAMES = ["vitest.config", "vite.config"].flatMap((base) => ["ts", "mts", "cts", "js", "mjs", "cjs"].map((ext) => `${base}.${ext}`));
|
|
22
|
+
export function strykerConfig(packageManager, mutate) {
|
|
23
|
+
return {
|
|
24
|
+
$schema: "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
|
|
25
|
+
packageManager,
|
|
26
|
+
testRunner: "vitest",
|
|
27
|
+
plugins: ["@stryker-mutator/vitest-runner"],
|
|
28
|
+
coverageAnalysis: "perTest",
|
|
29
|
+
reporters: ["json", "progress"],
|
|
30
|
+
jsonReporter: { fileName: "reports/mutation/mutation.json" },
|
|
31
|
+
mutate,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function vitestConfig(mutate) {
|
|
35
|
+
const include = mutate.filter((glob) => !glob.startsWith("!"));
|
|
36
|
+
const exclude = mutate.filter((glob) => glob.startsWith("!")).map((glob) => glob.slice(1));
|
|
37
|
+
const list = (globs) => globs.map((g) => JSON.stringify(g)).join(", ");
|
|
38
|
+
return `import { defineConfig } from "vitest/config";
|
|
39
|
+
|
|
40
|
+
export default defineConfig({
|
|
41
|
+
test: {
|
|
42
|
+
coverage: {
|
|
43
|
+
provider: "istanbul",
|
|
44
|
+
reporter: ["json-summary", "text"],
|
|
45
|
+
include: [${list(include)}],
|
|
46
|
+
exclude: [${list(exclude)}],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
`;
|
|
51
|
+
}
|
|
52
|
+
export async function mutateGlobs(root) {
|
|
53
|
+
const specs = await discoverSpecs(root);
|
|
54
|
+
const dirs = [...new Set(specs.map((spec) => dirname(spec)))].sort();
|
|
55
|
+
if (dirs.length === 0 || dirs.includes("."))
|
|
56
|
+
return ["features/**/*.ts", "!features/**/*.test.ts"];
|
|
57
|
+
return [...dirs.map((dir) => `${dir}/**/*.ts`), ...dirs.map((dir) => `!${dir}/**/*.test.ts`)];
|
|
58
|
+
}
|
|
59
|
+
export async function init(root, options = {}) {
|
|
60
|
+
const packageJson = await readPackageJson(root);
|
|
61
|
+
const packageManager = await detectPackageManager(root);
|
|
62
|
+
const mutate = options.mutate?.length ? options.mutate : await mutateGlobs(root);
|
|
63
|
+
const declared = new Set([
|
|
64
|
+
...Object.keys(packageJson.devDependencies ?? {}),
|
|
65
|
+
...Object.keys(packageJson.dependencies ?? {}),
|
|
66
|
+
]);
|
|
67
|
+
const wantedDeps = [`speccle@^${await ownVersion()}`, ...STRENGTH_DEPS];
|
|
68
|
+
const missingDeps = wantedDeps.filter((spec) => !declared.has(withoutVersion(spec)));
|
|
69
|
+
const installCommand = missingDeps.length > 0 ? installCommandFor(packageManager, missingDeps) : null;
|
|
70
|
+
const files = [
|
|
71
|
+
await provision(root, STRYKER_CONFIG_NAMES, "stryker.config.json", () => JSON.stringify(strykerConfig(packageManager, mutate), null, 2) + "\n"),
|
|
72
|
+
await provision(root, VITEST_CONFIG_NAMES, "vitest.config.ts", () => vitestConfig(mutate)),
|
|
73
|
+
];
|
|
74
|
+
let installRan = false;
|
|
75
|
+
if (installCommand !== null && !options.skipInstall) {
|
|
76
|
+
runInstall(root, installCommand);
|
|
77
|
+
installRan = true;
|
|
78
|
+
}
|
|
79
|
+
const doubleLoad = await detectDoubleLoad(root);
|
|
80
|
+
return {
|
|
81
|
+
root,
|
|
82
|
+
packageManager,
|
|
83
|
+
missingDeps,
|
|
84
|
+
installCommand,
|
|
85
|
+
installRan,
|
|
86
|
+
mutate,
|
|
87
|
+
files,
|
|
88
|
+
doubleLoad,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// The materialized copies must be the one source of truth: a target that vendors the
|
|
92
|
+
// skills project-level should not also load the user-level plugin. Best-effort — the
|
|
93
|
+
// settings shape is Claude Code's, not ours; absence of the file means no warning.
|
|
94
|
+
export async function detectDoubleLoad(root, settingsFile = join(homedir(), ".claude", "settings.json")) {
|
|
95
|
+
if (!(await exists(join(root, ".claude", "skills", "feature", "SKILL.md"))))
|
|
96
|
+
return false;
|
|
97
|
+
let raw;
|
|
98
|
+
try {
|
|
99
|
+
raw = await readFile(settingsFile, "utf8");
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const settings = JSON.parse(raw);
|
|
106
|
+
return Object.entries(settings.enabledPlugins ?? {}).some(([plugin, enabled]) => enabled && plugin.startsWith("speccle@"));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function ownVersion() {
|
|
113
|
+
const raw = await readFile(new URL("../package.json", import.meta.url), "utf8");
|
|
114
|
+
return JSON.parse(raw).version;
|
|
115
|
+
}
|
|
116
|
+
async function readPackageJson(root) {
|
|
117
|
+
let raw;
|
|
118
|
+
try {
|
|
119
|
+
raw = await readFile(join(root, "package.json"), "utf8");
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
throw new Error(`no package.json found in ${root} — run init at the target's root`);
|
|
123
|
+
}
|
|
124
|
+
return JSON.parse(raw);
|
|
125
|
+
}
|
|
126
|
+
async function detectPackageManager(root) {
|
|
127
|
+
for (const [lockfile, manager] of LOCKFILES) {
|
|
128
|
+
if (await exists(join(root, lockfile)))
|
|
129
|
+
return manager;
|
|
130
|
+
}
|
|
131
|
+
return "npm";
|
|
132
|
+
}
|
|
133
|
+
async function provision(root, existingNames, writeName, content) {
|
|
134
|
+
for (const name of existingNames) {
|
|
135
|
+
if (await exists(join(root, name)))
|
|
136
|
+
return { file: name, action: "kept" };
|
|
137
|
+
}
|
|
138
|
+
await writeFile(join(root, writeName), content());
|
|
139
|
+
return { file: writeName, action: "written" };
|
|
140
|
+
}
|
|
141
|
+
function installCommandFor(manager, deps) {
|
|
142
|
+
const subcommand = manager === "npm" ? "install -D" : manager === "bun" ? "add -d" : "add -D";
|
|
143
|
+
return `${manager} ${subcommand} ${deps.join(" ")}`;
|
|
144
|
+
}
|
|
145
|
+
function runInstall(root, command) {
|
|
146
|
+
const [manager, ...args] = command.split(" ");
|
|
147
|
+
const result = spawnSync(manager, args, { cwd: root, encoding: "utf8" });
|
|
148
|
+
if (result.error)
|
|
149
|
+
throw new Error(`${command} failed: ${result.error.message}`);
|
|
150
|
+
if (result.status !== 0)
|
|
151
|
+
throw new Error(`${command} failed (exit ${result.status}):\n${result.stderr}`);
|
|
152
|
+
}
|
|
153
|
+
function withoutVersion(spec) {
|
|
154
|
+
return spec.slice(0, spec.lastIndexOf("@"));
|
|
155
|
+
}
|
|
156
|
+
async function exists(path) {
|
|
157
|
+
try {
|
|
158
|
+
await access(path);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
package/dist/lint.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { discoverSpecs } from "./discover.js";
|
|
4
|
+
import { parseSpec } from "./spec.js";
|
|
5
|
+
import { runRules } from "./rules/src/index.js";
|
|
6
|
+
export async function lint(target) {
|
|
7
|
+
const abs = resolve(target);
|
|
8
|
+
let stats;
|
|
9
|
+
try {
|
|
10
|
+
stats = await stat(abs);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new Error(`path not found: ${target}`);
|
|
14
|
+
}
|
|
15
|
+
const root = stats.isFile() ? dirname(abs) : abs;
|
|
16
|
+
const files = stats.isFile() ? [basename(abs)] : await discoverSpecs(root);
|
|
17
|
+
const specs = await Promise.all(files.map(async (file) => parseSpec(await readFile(join(root, file), "utf8"), file)));
|
|
18
|
+
const violations = runRules(specs);
|
|
19
|
+
return { root, files, violations, clean: violations.length === 0 };
|
|
20
|
+
}
|
package/dist/mutation.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slice of the Stryker JSON report (mutation-testing-elements schema) the join needs.
|
|
3
|
+
* `killedBy` is deliberately absent: a kill counts for every covering criterion, so only
|
|
4
|
+
* the status matters (ADR-0011).
|
|
5
|
+
*/
|
|
6
|
+
/** Every status the schema defines. Killed and Timeout are kills; the rest are not. */
|
|
7
|
+
const STATUSES = [
|
|
8
|
+
"Killed",
|
|
9
|
+
"Survived",
|
|
10
|
+
"NoCoverage",
|
|
11
|
+
"CompileError",
|
|
12
|
+
"RuntimeError",
|
|
13
|
+
"Timeout",
|
|
14
|
+
"Ignored",
|
|
15
|
+
"Pending",
|
|
16
|
+
];
|
|
17
|
+
const KILLED = new Set(["Killed", "Timeout"]);
|
|
18
|
+
/** A mutant Stryker actually ran: it either died or survived. The others cannot be scored. */
|
|
19
|
+
const SCORED = new Set(["Killed", "Survived", "Timeout"]);
|
|
20
|
+
export function isKill(status) {
|
|
21
|
+
return KILLED.has(status);
|
|
22
|
+
}
|
|
23
|
+
export function isScored(status) {
|
|
24
|
+
return SCORED.has(status);
|
|
25
|
+
}
|
|
26
|
+
export function parseMutationReport(json, source) {
|
|
27
|
+
const fail = (detail) => {
|
|
28
|
+
throw new Error(`${source} is not a Stryker mutation report: ${detail}`);
|
|
29
|
+
};
|
|
30
|
+
if (!isRecord(json))
|
|
31
|
+
return fail("expected a JSON object");
|
|
32
|
+
const files = json.files;
|
|
33
|
+
if (!isRecord(files))
|
|
34
|
+
return fail("missing `files`");
|
|
35
|
+
const mutants = [];
|
|
36
|
+
for (const [file, entry] of Object.entries(files)) {
|
|
37
|
+
if (!isRecord(entry))
|
|
38
|
+
return fail(`\`files["${file}"]\` is not an object`);
|
|
39
|
+
const list = entry.mutants;
|
|
40
|
+
if (!Array.isArray(list))
|
|
41
|
+
return fail(`\`files["${file}"].mutants\` is not an array`);
|
|
42
|
+
for (const raw of list) {
|
|
43
|
+
if (!isRecord(raw))
|
|
44
|
+
return fail(`a mutant in "${file}" is not an object`);
|
|
45
|
+
const id = raw.id;
|
|
46
|
+
if (typeof id !== "string")
|
|
47
|
+
return fail(`a mutant in "${file}" has no string \`id\``);
|
|
48
|
+
const status = raw.status;
|
|
49
|
+
if (!isStatus(status))
|
|
50
|
+
return fail(`mutant ${id} has unknown status ${String(status)}`);
|
|
51
|
+
const start = isRecord(raw.location) ? raw.location.start : undefined;
|
|
52
|
+
if (!isRecord(start) || typeof start.line !== "number") {
|
|
53
|
+
return fail(`mutant ${id} has no \`location.start.line\``);
|
|
54
|
+
}
|
|
55
|
+
const replacement = raw.replacement;
|
|
56
|
+
mutants.push({
|
|
57
|
+
id,
|
|
58
|
+
file,
|
|
59
|
+
line: start.line,
|
|
60
|
+
column: typeof start.column === "number" ? start.column : 0,
|
|
61
|
+
mutator: typeof raw.mutatorName === "string" ? raw.mutatorName : "unknown",
|
|
62
|
+
replacement: typeof replacement === "string" ? replacement : undefined,
|
|
63
|
+
status,
|
|
64
|
+
static: raw.static === true,
|
|
65
|
+
coveredBy: stringArray(raw.coveredBy),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const testNames = new Map();
|
|
70
|
+
const testFiles = json.testFiles;
|
|
71
|
+
if (isRecord(testFiles)) {
|
|
72
|
+
for (const entry of Object.values(testFiles)) {
|
|
73
|
+
if (!isRecord(entry))
|
|
74
|
+
continue;
|
|
75
|
+
const tests = entry.tests;
|
|
76
|
+
if (!Array.isArray(tests))
|
|
77
|
+
continue;
|
|
78
|
+
for (const test of tests) {
|
|
79
|
+
if (!isRecord(test))
|
|
80
|
+
continue;
|
|
81
|
+
const { id, name } = test;
|
|
82
|
+
if (typeof id === "string" && typeof name === "string")
|
|
83
|
+
testNames.set(id, name);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const config = json.config;
|
|
88
|
+
const coverageAnalysis = isRecord(config) ? config.coverageAnalysis : undefined;
|
|
89
|
+
return {
|
|
90
|
+
mutants,
|
|
91
|
+
testNames,
|
|
92
|
+
coverageAnalysis: typeof coverageAnalysis === "string" ? coverageAnalysis : undefined,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function isStatus(value) {
|
|
96
|
+
return STATUSES.includes(value);
|
|
97
|
+
}
|
|
98
|
+
function stringArray(value) {
|
|
99
|
+
return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
|
|
100
|
+
}
|
|
101
|
+
function isRecord(value) {
|
|
102
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
103
|
+
}
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
export function renderCheck(report) {
|
|
2
|
+
const lines = [
|
|
3
|
+
`mutation ${describeReport(report.mutation)}`,
|
|
4
|
+
`coverage ${describeReport(report.coverage)}`,
|
|
5
|
+
];
|
|
6
|
+
const ready = report.mutation.status === "fresh" && report.coverage.status === "fresh";
|
|
7
|
+
if (!ready) {
|
|
8
|
+
lines.push("reports must be regenerated before the heatmap is worth reading");
|
|
9
|
+
}
|
|
10
|
+
else if (report.evaluated) {
|
|
11
|
+
lines.push("already evaluated — nothing new to read");
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
lines.push(`fresh and unread — touch ${report.marker} after evaluating the heatmap`);
|
|
15
|
+
}
|
|
16
|
+
return lines.join("\n");
|
|
17
|
+
}
|
|
18
|
+
function describeReport(check) {
|
|
19
|
+
if (check.status === "missing")
|
|
20
|
+
return `${check.path} — missing`;
|
|
21
|
+
if (check.status === "stale")
|
|
22
|
+
return `${check.path} — stale (${check.staleAgainst} is newer)`;
|
|
23
|
+
return `${check.path} — fresh`;
|
|
24
|
+
}
|
|
25
|
+
export function renderClaims(report) {
|
|
26
|
+
if (report.features.length === 0)
|
|
27
|
+
return "No SPEC.md files found.";
|
|
28
|
+
const idWidth = Math.max(...report.features.flatMap((f) => f.criteria.map((c) => c.id.length)));
|
|
29
|
+
const lines = [];
|
|
30
|
+
for (const feature of report.features) {
|
|
31
|
+
lines.push(feature.spec);
|
|
32
|
+
for (const c of feature.criteria) {
|
|
33
|
+
const status = c.claimed ? plural(c.tests.length, "test name") : "unclaimed";
|
|
34
|
+
lines.push(` ${c.id.padEnd(idWidth)} ${status.padEnd(13)} ${c.statement}`);
|
|
35
|
+
}
|
|
36
|
+
lines.push("");
|
|
37
|
+
}
|
|
38
|
+
if (report.testFiles.length === 0) {
|
|
39
|
+
lines.push(`no test files matched the ${report.dialect} dialect`);
|
|
40
|
+
lines.push("");
|
|
41
|
+
}
|
|
42
|
+
if (report.unclaimed.length > 0) {
|
|
43
|
+
lines.push("unclaimed — no test name carries these tokens");
|
|
44
|
+
for (const id of report.unclaimed)
|
|
45
|
+
lines.push(` ${id}`);
|
|
46
|
+
lines.push("");
|
|
47
|
+
}
|
|
48
|
+
if (report.unknownClaims.length > 0) {
|
|
49
|
+
lines.push("unknown claims — test names claim criteria that no spec declares");
|
|
50
|
+
for (const claim of report.unknownClaims) {
|
|
51
|
+
lines.push(` ${claim.id} ${[...new Set(claim.tests.map((t) => t.file))].join(", ")}`);
|
|
52
|
+
}
|
|
53
|
+
lines.push("");
|
|
54
|
+
}
|
|
55
|
+
const total = report.features.reduce((n, f) => n + f.criteria.length, 0);
|
|
56
|
+
const claimed = report.features.reduce((n, f) => n + f.criteria.filter((c) => c.claimed).length, 0);
|
|
57
|
+
const criteria = `${total} ${total === 1 ? "criterion" : "criteria"}`;
|
|
58
|
+
const specs = plural(report.features.length, "spec file");
|
|
59
|
+
const counts = `${report.dialect} — ${specs}, ${criteria}, ${claimed} claimed`;
|
|
60
|
+
lines.push(report.clean ? `${counts}, clean` : counts);
|
|
61
|
+
return lines.join("\n");
|
|
62
|
+
}
|
|
63
|
+
export function renderInit(report) {
|
|
64
|
+
const lines = [];
|
|
65
|
+
for (const { file, action } of report.files) {
|
|
66
|
+
lines.push(action === "written" ? `wrote ${file}` : `kept ${file} — already present, left untouched`);
|
|
67
|
+
}
|
|
68
|
+
if (report.missingDeps.length === 0) {
|
|
69
|
+
lines.push("devDependencies already present");
|
|
70
|
+
}
|
|
71
|
+
else if (report.installRan) {
|
|
72
|
+
lines.push(`installed ${report.missingDeps.join(", ")}`);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
lines.push(`missing devDependencies — run: ${report.installCommand}`);
|
|
76
|
+
}
|
|
77
|
+
if (report.files.some((f) => f.action === "kept")) {
|
|
78
|
+
lines.push("");
|
|
79
|
+
lines.push("kept files must carry the strength stack themselves:");
|
|
80
|
+
lines.push(' stryker config: coverageAnalysis "perTest", the json reporter');
|
|
81
|
+
lines.push(" vitest config: istanbul provider, json-summary reporter");
|
|
82
|
+
}
|
|
83
|
+
if (report.doubleLoad) {
|
|
84
|
+
lines.push("");
|
|
85
|
+
lines.push("warning: this repo vendors the speccle skills project-level AND the");
|
|
86
|
+
lines.push("speccle plugin is enabled user-level — two copies of every skill will");
|
|
87
|
+
lines.push("load. Disable one: /plugin (user-level) or remove .claude/skills/ here.");
|
|
88
|
+
}
|
|
89
|
+
return lines.join("\n");
|
|
90
|
+
}
|
|
91
|
+
export function renderHuman(report) {
|
|
92
|
+
if (report.files.length === 0)
|
|
93
|
+
return "No SPEC.md files found.";
|
|
94
|
+
const lines = [];
|
|
95
|
+
if (report.violations.length > 0) {
|
|
96
|
+
const lineWidth = Math.max(...report.violations.map((v) => String(v.line).length));
|
|
97
|
+
const ruleWidth = Math.max(...report.violations.map((v) => v.rule.length));
|
|
98
|
+
let currentFile;
|
|
99
|
+
for (const v of report.violations) {
|
|
100
|
+
if (v.file !== currentFile) {
|
|
101
|
+
if (currentFile !== undefined)
|
|
102
|
+
lines.push("");
|
|
103
|
+
lines.push(v.file);
|
|
104
|
+
currentFile = v.file;
|
|
105
|
+
}
|
|
106
|
+
lines.push(` ${String(v.line).padStart(lineWidth)} ${v.rule.padEnd(ruleWidth)} ${v.message}`);
|
|
107
|
+
}
|
|
108
|
+
lines.push("");
|
|
109
|
+
}
|
|
110
|
+
const files = plural(report.files.length, "spec file");
|
|
111
|
+
lines.push(report.clean ? `${files}, clean` : `${files}, ${plural(report.violations.length, "violation")}`);
|
|
112
|
+
return lines.join("\n");
|
|
113
|
+
}
|
|
114
|
+
const BAR_WIDTH = 20;
|
|
115
|
+
const STRONG = 0.9;
|
|
116
|
+
const WEAK = 0.7;
|
|
117
|
+
export function renderStrength(report, color = false) {
|
|
118
|
+
if (report.features.length === 0)
|
|
119
|
+
return "No SPEC.md files found.";
|
|
120
|
+
const paint = color ? ansi : (text, _code) => text;
|
|
121
|
+
const dim = (text) => paint(text, "2");
|
|
122
|
+
const tint = (value, text) => value === null ? dim(text) : paint(text, value >= STRONG ? "32" : value >= WEAK ? "33" : "31");
|
|
123
|
+
const idWidth = Math.max(...report.features.flatMap((f) => f.criteria.map((c) => c.id.length)), 0);
|
|
124
|
+
const out = [];
|
|
125
|
+
for (const feature of report.features) {
|
|
126
|
+
out.push(bold(feature.spec, color));
|
|
127
|
+
for (const criterion of feature.criteria) {
|
|
128
|
+
out.push(renderCriterion(criterion, idWidth, tint, dim));
|
|
129
|
+
for (const survivor of criterion.survivors)
|
|
130
|
+
out.push(` ${renderSite(survivor, dim)}`);
|
|
131
|
+
}
|
|
132
|
+
out.push(` ${dim(`line coverage ${percent(feature.lineCoverage)}`)}`);
|
|
133
|
+
out.push("");
|
|
134
|
+
}
|
|
135
|
+
if (report.unclaimed.length > 0) {
|
|
136
|
+
out.push(`${tint(0, "unclaimed")} ${dim("— no test carries these tokens")}`);
|
|
137
|
+
for (const id of report.unclaimed)
|
|
138
|
+
out.push(` ${id}`);
|
|
139
|
+
out.push("");
|
|
140
|
+
}
|
|
141
|
+
if (report.unknownClaims.length > 0) {
|
|
142
|
+
out.push(`${tint(0, "unknown claims")} ${dim("— tests claim criteria that no spec declares")}`);
|
|
143
|
+
for (const id of report.unknownClaims)
|
|
144
|
+
out.push(` ${id}`);
|
|
145
|
+
out.push("");
|
|
146
|
+
}
|
|
147
|
+
if (report.unclaimedMutants.length > 0) {
|
|
148
|
+
out.push(`${tint(0, "unclaimed mutants")} ${dim("— scored mutants no criterion's tests cover")}`);
|
|
149
|
+
for (const site of report.unclaimedMutants)
|
|
150
|
+
out.push(` ${renderSite(site, dim)}`);
|
|
151
|
+
out.push("");
|
|
152
|
+
}
|
|
153
|
+
const staticSurvived = report.staticMutants.survivors.length;
|
|
154
|
+
const staticTotal = report.staticMutants.killed + staticSurvived;
|
|
155
|
+
if (staticTotal > 0) {
|
|
156
|
+
out.push(`${tint(staticSurvived === 0 ? 1 : 0, "static mutants")} ` +
|
|
157
|
+
dim("— run at module load, attributable to no criterion"));
|
|
158
|
+
out.push(` ${report.staticMutants.killed} killed, ${staticSurvived} survived`);
|
|
159
|
+
for (const site of report.staticMutants.survivors)
|
|
160
|
+
out.push(` ${renderSite(site, dim)}`);
|
|
161
|
+
out.push("");
|
|
162
|
+
}
|
|
163
|
+
const survivors = report.covered - report.killed + staticSurvived;
|
|
164
|
+
out.push(`oracle strength ${tint(report.strength, percent(report.strength))} ` +
|
|
165
|
+
`${dim(`(${report.killed}/${report.covered})`)} ` +
|
|
166
|
+
`line coverage ${dim(percent(report.lineCoverage))}`);
|
|
167
|
+
out.push(survivors === 0
|
|
168
|
+
? dim("no surviving mutants")
|
|
169
|
+
: `${plural(survivors, "surviving mutant")}${dim(" — each one a change no test noticed")}`);
|
|
170
|
+
return out.join("\n");
|
|
171
|
+
}
|
|
172
|
+
function renderCriterion(criterion, idWidth, tint, dim) {
|
|
173
|
+
const id = criterion.id.padEnd(idWidth);
|
|
174
|
+
const counts = `${criterion.killed}/${criterion.covered}`.padStart(7);
|
|
175
|
+
if (!criterion.claimed) {
|
|
176
|
+
return ` ${id} ${dim("░".repeat(BAR_WIDTH))} ${tint(0, "unclaimed".padStart(6))} ${dim(counts)} ${criterion.statement}`;
|
|
177
|
+
}
|
|
178
|
+
return (` ${id} ${tint(criterion.strength, bar(criterion.strength))} ` +
|
|
179
|
+
`${tint(criterion.strength, percent(criterion.strength).padStart(6))} ` +
|
|
180
|
+
`${dim(counts)} ${criterion.statement}`);
|
|
181
|
+
}
|
|
182
|
+
function renderSite(site, dim) {
|
|
183
|
+
const at = `${site.file}:${site.line}:${site.column}`;
|
|
184
|
+
const change = site.replacement === undefined ? "" : ` → ${collapse(site.replacement)}`;
|
|
185
|
+
return dim(`${at} ${site.mutator}${change}`);
|
|
186
|
+
}
|
|
187
|
+
/** Mutant replacements can be whole statements; the heatmap shows one line of them. */
|
|
188
|
+
function collapse(replacement) {
|
|
189
|
+
const single = replacement.replace(/\s+/g, " ").trim();
|
|
190
|
+
return single.length > 48 ? `${single.slice(0, 47)}…` : single;
|
|
191
|
+
}
|
|
192
|
+
function bar(value) {
|
|
193
|
+
if (value === null)
|
|
194
|
+
return "░".repeat(BAR_WIDTH);
|
|
195
|
+
const filled = Math.round(value * BAR_WIDTH);
|
|
196
|
+
return "█".repeat(filled) + "░".repeat(BAR_WIDTH - filled);
|
|
197
|
+
}
|
|
198
|
+
function percent(value) {
|
|
199
|
+
return value === null ? "—" : `${(value * 100).toFixed(1)}%`;
|
|
200
|
+
}
|
|
201
|
+
function ansi(text, code) {
|
|
202
|
+
return `\x1b[${code}m${text}\x1b[0m`;
|
|
203
|
+
}
|
|
204
|
+
function bold(text, color) {
|
|
205
|
+
return color ? ansi(text, "1") : text;
|
|
206
|
+
}
|
|
207
|
+
function plural(n, noun) {
|
|
208
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
209
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { RULE_IDS } from "../../violation.js";
|
|
2
|
+
import { structuralRules } from "./structural.js";
|
|
3
|
+
import { qualityRules } from "./quality.js";
|
|
4
|
+
export function runRules(specs) {
|
|
5
|
+
const violations = [...structuralRules(specs), ...qualityRules(specs)];
|
|
6
|
+
return violations.sort((a, b) => a.file.localeCompare(b.file) ||
|
|
7
|
+
a.line - b.line ||
|
|
8
|
+
RULE_IDS.indexOf(a.rule) - RULE_IDS.indexOf(b.rule) ||
|
|
9
|
+
a.message.localeCompare(b.message));
|
|
10
|
+
}
|