speccle 0.12.0 → 0.16.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 +321 -9
- package/dist/calibration.js +156 -0
- package/dist/changeset.js +136 -0
- package/dist/claims.js +24 -12
- package/dist/cli.js +497 -10
- package/dist/config.js +13 -10
- package/dist/doctor.js +40 -9
- package/dist/git.js +23 -0
- package/dist/init.js +12 -0
- package/dist/lenses.js +68 -0
- package/dist/remedy.js +133 -0
- package/dist/render.js +277 -16
- package/dist/reviewinit.js +139 -0
- package/dist/reviewrun.js +516 -0
- package/dist/risk.js +243 -0
- package/dist/update.js +23 -6
- package/dist/verify.js +92 -0
- package/lenses/accessibility.md +39 -0
- package/lenses/architecture.md +38 -0
- package/lenses/correctness.md +36 -0
- package/lenses/house-conventions.md +37 -0
- package/lenses/performance.md +40 -0
- package/lenses/risk.md +45 -0
- package/lenses/security.md +42 -0
- package/lenses/test-quality.md +39 -0
- package/package.json +5 -4
- package/skills/review/SKILL.md +190 -0
package/dist/risk.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { assertPredicate, contentReader, gitChangeSet, gitRangeChangeSet, isDirectory, matching, messageOf, } from "./changeset.js";
|
|
4
|
+
import { claims } from "./claims.js";
|
|
5
|
+
import { readConfig, resolveFacts } from "./config.js";
|
|
6
|
+
import { DEFAULT_DIALECT, resolveDialect } from "./dialects.js";
|
|
7
|
+
import { discoverSpecs } from "./discover.js";
|
|
8
|
+
import { gitStdout } from "./git.js";
|
|
9
|
+
import { parseSpec } from "./spec.js";
|
|
10
|
+
/** Where a repo declares its risk policy (ADR-0041) — judgement, so a single hand-edited file. */
|
|
11
|
+
export const RISK_POLICY_FILE = ".speccle/risk.json";
|
|
12
|
+
/** The shipped weight of each baseline signal; a repo may reweight or mute (0) in its policy. */
|
|
13
|
+
const BASELINE_WEIGHTS = {
|
|
14
|
+
"spec-silent-change": 3,
|
|
15
|
+
"criterion-retired": 4,
|
|
16
|
+
"criterion-reworded": 2,
|
|
17
|
+
"unclaimed-change": 3,
|
|
18
|
+
};
|
|
19
|
+
const BASELINE_IDS = Object.keys(BASELINE_WEIGHTS);
|
|
20
|
+
/** The review threshold a repo has not overridden. Low by design — most changes supervised (ADR-0041). */
|
|
21
|
+
export const DEFAULT_THRESHOLD = 3;
|
|
22
|
+
export async function risk(target, options = {}) {
|
|
23
|
+
const root = resolve(target);
|
|
24
|
+
if (!(await isDirectory(root)))
|
|
25
|
+
throw new Error(`path not found: ${target}`);
|
|
26
|
+
const policy = await loadPolicy(root);
|
|
27
|
+
const threshold = policy.threshold ?? DEFAULT_THRESHOLD;
|
|
28
|
+
const weightOf = (id) => policy.weights?.[id] ?? BASELINE_WEIGHTS[id];
|
|
29
|
+
// Read the range once: it carries both the change set and the ref a criterion's baseline
|
|
30
|
+
// reads from, and the two must agree or the diff is measured against the wrong commit.
|
|
31
|
+
const range = options.base === undefined ? undefined : gitRangeChangeSet(root, options.base);
|
|
32
|
+
const changed = (options.changed ?? range?.changed ?? gitChangeSet(root)).slice().sort();
|
|
33
|
+
// Resolved here rather than where it is used, so a bogus `--dialect` fails loudly even when
|
|
34
|
+
// every signal that would have read it is muted.
|
|
35
|
+
const forced = options.dialect === undefined ? undefined : resolveDialect(options.dialect);
|
|
36
|
+
const baselineRef = range?.baseline ?? "HEAD";
|
|
37
|
+
const baseline = options.baseline ?? ((file) => gitBaseline(root, file, baselineRef));
|
|
38
|
+
const read = contentReader(root);
|
|
39
|
+
const signals = [];
|
|
40
|
+
const fireBaseline = (id, reason, evidence) => {
|
|
41
|
+
const weight = weightOf(id);
|
|
42
|
+
if (weight > 0 && evidence.length > 0) {
|
|
43
|
+
signals.push({ id, weight, source: "baseline", reason, evidence: unique(evidence) });
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
// Baseline signals — the spec-aware facts only Speccle can see. A muted (0-weight) signal is
|
|
47
|
+
// not computed at all, keeping the git and claims work off the path when a repo has opted out.
|
|
48
|
+
if (weightOf("spec-silent-change") > 0) {
|
|
49
|
+
fireBaseline("spec-silent-change", "production source changed in a governed slice whose SPEC.md did not", await specSilentChanges(root, changed, forced));
|
|
50
|
+
}
|
|
51
|
+
if (weightOf("criterion-retired") > 0 || weightOf("criterion-reworded") > 0) {
|
|
52
|
+
const { retired, reworded } = await criterionDiffs(changed, read, baseline);
|
|
53
|
+
fireBaseline("criterion-retired", "a criterion was retired from a changed SPEC.md", retired);
|
|
54
|
+
fireBaseline("criterion-reworded", "a criterion's statement was reworded", reworded);
|
|
55
|
+
}
|
|
56
|
+
if (weightOf("unclaimed-change") > 0) {
|
|
57
|
+
fireBaseline("unclaimed-change", "changed code lives in a slice with a criterion no test claims", await unclaimedChanges(root, changed, options.dialect));
|
|
58
|
+
}
|
|
59
|
+
// Policy signals — repo-defined predicates over the change set.
|
|
60
|
+
for (const signal of policy.signals ?? []) {
|
|
61
|
+
if (signal.weight === 0)
|
|
62
|
+
continue;
|
|
63
|
+
const hits = await matching(signal.when, changed, read); // when: present, enforced by loadPolicy.
|
|
64
|
+
if (hits.length === 0)
|
|
65
|
+
continue;
|
|
66
|
+
signals.push({
|
|
67
|
+
id: signal.id,
|
|
68
|
+
weight: signal.weight,
|
|
69
|
+
source: "policy",
|
|
70
|
+
reason: signal.message,
|
|
71
|
+
evidence: hits,
|
|
72
|
+
...(signal.because !== undefined && { because: signal.because }),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const score = signals.reduce((sum, signal) => sum + signal.weight, 0);
|
|
76
|
+
return {
|
|
77
|
+
root,
|
|
78
|
+
changed,
|
|
79
|
+
...(options.base !== undefined && { base: options.base }),
|
|
80
|
+
signals,
|
|
81
|
+
score,
|
|
82
|
+
threshold,
|
|
83
|
+
humanRequired: score >= threshold,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Governed-slice production source that changed while the slice's SPEC.md stayed silent.
|
|
88
|
+
*
|
|
89
|
+
* Whether a changed file is a test is a per-path question, so with no forced dialect each file
|
|
90
|
+
* is judged under the one the config resolves at its own path (ADR-0040) — a swift slice's
|
|
91
|
+
* `PlayerTests.swift` is a test even in a repo that defaults to ts-vitest. Resolved at the file
|
|
92
|
+
* rather than its spec folder: that also honours an override deeper than the folder, and keeps
|
|
93
|
+
* an outer slice from misreading a nested slice's tests as production source.
|
|
94
|
+
*/
|
|
95
|
+
async function specSilentChanges(root, changed, forced) {
|
|
96
|
+
const specs = await discoverSpecs(root);
|
|
97
|
+
const config = forced === undefined ? await readConfig(root) : undefined;
|
|
98
|
+
const dialectAt = (file) => forced ??
|
|
99
|
+
resolveDialect(config === undefined ? DEFAULT_DIALECT : resolveFacts(config, file).dialect);
|
|
100
|
+
const silent = [];
|
|
101
|
+
for (const spec of specs) {
|
|
102
|
+
const folder = dirname(spec);
|
|
103
|
+
if (changed.includes(spec))
|
|
104
|
+
continue; // the spec moved with its code — not silent.
|
|
105
|
+
for (const file of changed) {
|
|
106
|
+
if (!underFolder(file, folder))
|
|
107
|
+
continue;
|
|
108
|
+
if (isContractFile(file, folder) || dialectAt(file).isTestFile(file))
|
|
109
|
+
continue;
|
|
110
|
+
silent.push(file);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return silent;
|
|
114
|
+
}
|
|
115
|
+
/** Criterion ids retired or reworded between each changed SPEC.md's baseline and its current text. */
|
|
116
|
+
async function criterionDiffs(changed, read, baseline) {
|
|
117
|
+
const retired = [];
|
|
118
|
+
const reworded = [];
|
|
119
|
+
for (const spec of changed.filter(isSpecPath)) {
|
|
120
|
+
const before = await baseline(spec);
|
|
121
|
+
if (before === undefined)
|
|
122
|
+
continue; // a newly added spec retires and rewords nothing.
|
|
123
|
+
const past = criteriaOf(before, spec);
|
|
124
|
+
const now = criteriaOf((await read(spec)) ?? "", spec); // deleted spec → every criterion retired.
|
|
125
|
+
for (const [id, statement] of past) {
|
|
126
|
+
if (!now.has(id))
|
|
127
|
+
retired.push(id);
|
|
128
|
+
else if (now.get(id) !== statement)
|
|
129
|
+
reworded.push(id);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { retired, reworded };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Unclaimed criterion ids in the governed slices this change touched. Only an explicit
|
|
136
|
+
* `--dialect` is passed down: without one, `claims` resolves each slice's own dialect from the
|
|
137
|
+
* config, so a mixed-language tree is not joined under whichever dialect the repo defaults to.
|
|
138
|
+
*/
|
|
139
|
+
async function unclaimedChanges(root, changed, dialect) {
|
|
140
|
+
const report = await claims(root, { ...(dialect !== undefined && { dialect }) });
|
|
141
|
+
const unclaimed = [];
|
|
142
|
+
for (const feature of report.features) {
|
|
143
|
+
const folder = dirname(feature.spec);
|
|
144
|
+
if (!changed.some((file) => underFolder(file, folder)))
|
|
145
|
+
continue;
|
|
146
|
+
for (const criterion of feature.criteria) {
|
|
147
|
+
if (!criterion.claimed)
|
|
148
|
+
unclaimed.push(criterion.id);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return unclaimed;
|
|
152
|
+
}
|
|
153
|
+
/** The well-formed criteria of a SPEC.md, id → statement. */
|
|
154
|
+
function criteriaOf(content, file) {
|
|
155
|
+
const criteria = new Map();
|
|
156
|
+
for (const criterion of parseSpec(content, file).criteria) {
|
|
157
|
+
if (criterion.wellFormed)
|
|
158
|
+
criteria.set(criterion.id, criterion.statement);
|
|
159
|
+
}
|
|
160
|
+
return criteria;
|
|
161
|
+
}
|
|
162
|
+
/** The review threshold in force at a repo — its policy override, or the shipped default. */
|
|
163
|
+
export async function reviewThreshold(root) {
|
|
164
|
+
return (await loadPolicy(root)).threshold ?? DEFAULT_THRESHOLD;
|
|
165
|
+
}
|
|
166
|
+
async function loadPolicy(root) {
|
|
167
|
+
let raw;
|
|
168
|
+
try {
|
|
169
|
+
raw = await readFile(resolve(root, RISK_POLICY_FILE), "utf8");
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return {}; // no policy: the conservative baseline defaults apply.
|
|
173
|
+
}
|
|
174
|
+
let policy;
|
|
175
|
+
try {
|
|
176
|
+
policy = JSON.parse(raw);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
throw new Error(`${RISK_POLICY_FILE} is not valid JSON: ${messageOf(err)}`);
|
|
180
|
+
}
|
|
181
|
+
assertPolicy(policy);
|
|
182
|
+
return policy;
|
|
183
|
+
}
|
|
184
|
+
// A malformed policy must fail loudly: a silently-ignored weight or signal is exactly the
|
|
185
|
+
// quiet reduction of supervision ADR-0041 is designed to prevent.
|
|
186
|
+
function assertPolicy(policy) {
|
|
187
|
+
const file = RISK_POLICY_FILE;
|
|
188
|
+
if (policy.threshold !== undefined && !isNonNegativeNumber(policy.threshold)) {
|
|
189
|
+
throw new Error(`${file}: "threshold" must be a non-negative number`);
|
|
190
|
+
}
|
|
191
|
+
for (const [id, weight] of Object.entries(policy.weights ?? {})) {
|
|
192
|
+
if (!BASELINE_IDS.includes(id)) {
|
|
193
|
+
throw new Error(`${file}: unknown baseline signal in "weights": ${id} — known: ${BASELINE_IDS.join(", ")}`);
|
|
194
|
+
}
|
|
195
|
+
if (!isNonNegativeNumber(weight)) {
|
|
196
|
+
throw new Error(`${file}: weight for "${id}" must be a non-negative number`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const seen = new Set();
|
|
200
|
+
for (const signal of policy.signals ?? []) {
|
|
201
|
+
if (typeof signal.id !== "string" || signal.id === "") {
|
|
202
|
+
throw new Error(`${file}: a signal needs a non-empty "id"`);
|
|
203
|
+
}
|
|
204
|
+
if (BASELINE_IDS.includes(signal.id)) {
|
|
205
|
+
throw new Error(`${file}: signal id "${signal.id}" collides with a baseline signal — reweight it in "weights" instead`);
|
|
206
|
+
}
|
|
207
|
+
if (seen.has(signal.id))
|
|
208
|
+
throw new Error(`${file}: duplicate signal id "${signal.id}"`);
|
|
209
|
+
seen.add(signal.id);
|
|
210
|
+
if (!isNonNegativeNumber(signal.weight)) {
|
|
211
|
+
throw new Error(`${file}: signal "${signal.id}" needs a non-negative "weight"`);
|
|
212
|
+
}
|
|
213
|
+
if (typeof signal.message !== "string" || signal.message === "") {
|
|
214
|
+
throw new Error(`${file}: signal "${signal.id}" needs a non-empty "message"`);
|
|
215
|
+
}
|
|
216
|
+
if (signal.when === undefined) {
|
|
217
|
+
throw new Error(`${file}: signal "${signal.id}" needs a "when" predicate`);
|
|
218
|
+
}
|
|
219
|
+
assertPredicate(signal.when, "when", file);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** Content of a path at `ref`, or undefined when git has no baseline for it (new or no commits). */
|
|
223
|
+
function gitBaseline(root, file, ref) {
|
|
224
|
+
return gitStdout(root, ["show", `${ref}:./${file}`]);
|
|
225
|
+
}
|
|
226
|
+
function isSpecPath(file) {
|
|
227
|
+
return file === "SPEC.md" || file.endsWith("/SPEC.md");
|
|
228
|
+
}
|
|
229
|
+
function isContractFile(file, folder) {
|
|
230
|
+
const base = file.slice(file.lastIndexOf("/") + 1);
|
|
231
|
+
if (base === "SPEC.md" || base === "CONTEXT.md" || base === "AGENTS.md")
|
|
232
|
+
return true;
|
|
233
|
+
return file.startsWith(folder === "." ? "decisions/" : `${folder}/decisions/`);
|
|
234
|
+
}
|
|
235
|
+
function underFolder(file, folder) {
|
|
236
|
+
return folder === "." || file === folder || file.startsWith(`${folder}/`);
|
|
237
|
+
}
|
|
238
|
+
function isNonNegativeNumber(value) {
|
|
239
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
240
|
+
}
|
|
241
|
+
function unique(values) {
|
|
242
|
+
return [...new Set(values)].sort();
|
|
243
|
+
}
|
package/dist/update.js
CHANGED
|
@@ -2,15 +2,21 @@ import { resolve } from "node:path";
|
|
|
2
2
|
import { initConfig, readConfig } from "./config.js";
|
|
3
3
|
import { doctor } from "./doctor.js";
|
|
4
4
|
import { detectPackageManager, installCommandFor } from "./init.js";
|
|
5
|
+
import { materializeLenses } from "./lenses.js";
|
|
6
|
+
import { scaffoldReviewWorkflow } from "./reviewinit.js";
|
|
5
7
|
import { materializeSkills } from "./skills.js";
|
|
6
8
|
/** The global-install one-liner. npm is the portable choice: it ships with Node. */
|
|
7
9
|
const BINARY_UPDATE = "npm install -g speccle@latest";
|
|
8
10
|
/**
|
|
9
11
|
* Brings a Speccle consumer current (#182). Only the per-repo halves are touched, and only
|
|
10
|
-
* as a reviewable diff: the skills are re-materialized from the
|
|
11
|
-
*
|
|
12
|
+
* as a reviewable diff: the skills and the baseline lenses are re-materialized from the
|
|
13
|
+
* bundled copy and both anchors re-stamped — the house-conventions lens the repo authored is
|
|
14
|
+
* left alone — while the strength stack and global binary are reported, never rewritten: the
|
|
12
15
|
* ticket's principle that only the binary may update silently, and it does so through the
|
|
13
16
|
* printed command, not through this deterministic tool.
|
|
17
|
+
*
|
|
18
|
+
* The CI driver moves only if it is already there (#187): the workflow spends a metered API
|
|
19
|
+
* key per run, so scaffolding one into a repo that never asked would be opting it in silently.
|
|
14
20
|
*/
|
|
15
21
|
export async function update(target) {
|
|
16
22
|
const root = resolve(target);
|
|
@@ -19,8 +25,12 @@ export async function update(target) {
|
|
|
19
25
|
throw new Error("not initialized — run `speccle init` first");
|
|
20
26
|
}
|
|
21
27
|
const version = diagnosis.cli;
|
|
22
|
-
const
|
|
23
|
-
await
|
|
28
|
+
const materializedSkills = await materializeSkills(root);
|
|
29
|
+
const materializedLenses = await materializeLenses(root);
|
|
30
|
+
await initConfig(root, version); // re-stamp both anchors; the repo facts stay kept
|
|
31
|
+
const hasDriver = diagnosis.driver.status !== "absent";
|
|
32
|
+
if (hasDriver)
|
|
33
|
+
await scaffoldReviewWorkflow(root, version);
|
|
24
34
|
const outstanding = diagnosis.stack.deps.filter((dep) => dep.status !== "ok");
|
|
25
35
|
const fixCommand = diagnosis.stack.status === "drift"
|
|
26
36
|
? installCommandFor(await detectPackageManager(root), outstanding.map((dep) => `${dep.name}@^${dep.wantedMajor}`))
|
|
@@ -31,9 +41,16 @@ export async function update(target) {
|
|
|
31
41
|
skills: {
|
|
32
42
|
from: diagnosis.skills.recorded,
|
|
33
43
|
to: version,
|
|
34
|
-
dir:
|
|
35
|
-
skills:
|
|
44
|
+
dir: materializedSkills.dir,
|
|
45
|
+
skills: materializedSkills.skills,
|
|
36
46
|
},
|
|
47
|
+
lenses: {
|
|
48
|
+
from: diagnosis.lenses.recorded,
|
|
49
|
+
to: version,
|
|
50
|
+
dir: materializedLenses.dir,
|
|
51
|
+
lenses: materializedLenses.lenses,
|
|
52
|
+
},
|
|
53
|
+
driver: { from: diagnosis.driver.recorded, to: hasDriver ? version : null },
|
|
37
54
|
stack: { status: diagnosis.stack.status, deps: diagnosis.stack.deps, fixCommand },
|
|
38
55
|
};
|
|
39
56
|
}
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { anyMatches, assertPredicate, contentReader, gitChangeSet, gitRangeChangeSet, isDirectory, matching, messageOf, } from "./changeset.js";
|
|
4
|
+
/** Where a repo keeps its hand- and meta-loop-authored checks (ADR-0043). */
|
|
5
|
+
export const CHECKS_DIR = ".speccle/checks";
|
|
6
|
+
export async function verify(target, options = {}) {
|
|
7
|
+
const root = resolve(target);
|
|
8
|
+
if (!(await isDirectory(root)))
|
|
9
|
+
throw new Error(`path not found: ${target}`);
|
|
10
|
+
const checks = await loadChecks(root);
|
|
11
|
+
const changed = (options.changed ?? changeSetOf(root, options.base)).slice().sort();
|
|
12
|
+
const read = contentReader(root);
|
|
13
|
+
const results = [];
|
|
14
|
+
for (const { check, file } of checks) {
|
|
15
|
+
results.push(await evaluate(check, file, changed, read));
|
|
16
|
+
}
|
|
17
|
+
const breaches = results.filter((result) => result.status === "breach").length;
|
|
18
|
+
return {
|
|
19
|
+
root,
|
|
20
|
+
changed,
|
|
21
|
+
...(options.base !== undefined && { base: options.base }),
|
|
22
|
+
checks: results,
|
|
23
|
+
breaches,
|
|
24
|
+
clean: breaches === 0,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** The working tree's pending change, or the commits a base ref and HEAD differ by. */
|
|
28
|
+
function changeSetOf(root, base) {
|
|
29
|
+
return base === undefined ? gitChangeSet(root) : gitRangeChangeSet(root, base).changed;
|
|
30
|
+
}
|
|
31
|
+
async function evaluate(check, file, changed, read) {
|
|
32
|
+
const base = {
|
|
33
|
+
id: check.id ?? basename(file),
|
|
34
|
+
file,
|
|
35
|
+
message: check.message,
|
|
36
|
+
...(check.because !== undefined && { because: check.because }),
|
|
37
|
+
};
|
|
38
|
+
if (check.when !== undefined && !(await anyMatches(check.when, changed, read))) {
|
|
39
|
+
return { ...base, status: "inactive" };
|
|
40
|
+
}
|
|
41
|
+
if (check.forbid !== undefined) {
|
|
42
|
+
const offenders = await matching(check.forbid, changed, read);
|
|
43
|
+
return offenders.length === 0
|
|
44
|
+
? { ...base, status: "pass" }
|
|
45
|
+
: { ...base, status: "breach", offenders };
|
|
46
|
+
}
|
|
47
|
+
// require: enforced above by loadChecks, so it is defined here.
|
|
48
|
+
const satisfied = await anyMatches(check.require, changed, read);
|
|
49
|
+
return { ...base, status: satisfied ? "pass" : "breach" };
|
|
50
|
+
}
|
|
51
|
+
async function loadChecks(root) {
|
|
52
|
+
let entries;
|
|
53
|
+
try {
|
|
54
|
+
entries = await readdir(join(root, CHECKS_DIR));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return []; // no checks dir: a repo that has authored none passes trivially.
|
|
58
|
+
}
|
|
59
|
+
const loaded = [];
|
|
60
|
+
for (const name of entries.filter((name) => name.endsWith(".json")).sort()) {
|
|
61
|
+
const file = `${CHECKS_DIR}/${name}`;
|
|
62
|
+
const raw = await readFile(join(root, file), "utf8");
|
|
63
|
+
let check;
|
|
64
|
+
try {
|
|
65
|
+
check = JSON.parse(raw);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
throw new Error(`${file} is not valid JSON: ${messageOf(err)}`);
|
|
69
|
+
}
|
|
70
|
+
assertCheck(check, file);
|
|
71
|
+
loaded.push({ check, file });
|
|
72
|
+
}
|
|
73
|
+
return loaded;
|
|
74
|
+
}
|
|
75
|
+
// A malformed check must fail loudly, naming the file — a check that silently does nothing is
|
|
76
|
+
// worse than no check, because the meta loop trusts it to hold the invariant.
|
|
77
|
+
function assertCheck(check, file) {
|
|
78
|
+
if (typeof check.message !== "string" || check.message === "") {
|
|
79
|
+
throw new Error(`${file}: a check needs a non-empty "message"`);
|
|
80
|
+
}
|
|
81
|
+
const requirements = [check.require, check.forbid].filter((r) => r !== undefined);
|
|
82
|
+
if (requirements.length !== 1) {
|
|
83
|
+
throw new Error(`${file}: a check needs exactly one of "require" or "forbid"`);
|
|
84
|
+
}
|
|
85
|
+
assertPredicate(check.when, "when", file);
|
|
86
|
+
assertPredicate(check.require, "require", file);
|
|
87
|
+
assertPredicate(check.forbid, "forbid", file);
|
|
88
|
+
}
|
|
89
|
+
function basename(file) {
|
|
90
|
+
const name = file.slice(file.lastIndexOf("/") + 1);
|
|
91
|
+
return name.endsWith(".json") ? name.slice(0, -".json".length) : name;
|
|
92
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# accessibility lens
|
|
2
|
+
|
|
3
|
+
**Stance:** someone reaches this change with a keyboard, a screen reader, and a magnified
|
|
4
|
+
low-contrast display. Read the diff as the interface they actually meet.
|
|
5
|
+
|
|
6
|
+
## What to look for
|
|
7
|
+
|
|
8
|
+
- **Semantics** — a real `<button>` / `<a>` / `<label>` / heading where a styled `<div>`
|
|
9
|
+
was used; a control with no accessible name; a landmark or list expressed only visually.
|
|
10
|
+
- **Keyboard** — an interactive element that is not focusable, a focus trap, a custom widget
|
|
11
|
+
with no key handlers, an action reachable only by hover or click, focus lost after a
|
|
12
|
+
route or modal change.
|
|
13
|
+
- **Screen reader** — an image, icon button, or input with no text alternative; state
|
|
14
|
+
(expanded, selected, busy, invalid) conveyed only by colour or position; a live region
|
|
15
|
+
missing where content updates silently.
|
|
16
|
+
- **ARIA** — a role or `aria-*` that contradicts the element, a required attribute for the
|
|
17
|
+
role omitted, ARIA used to paper over a non-semantic element that should just be the right
|
|
18
|
+
element.
|
|
19
|
+
- **Contrast & zoom** — text or an essential icon below the contrast threshold; a tap target
|
|
20
|
+
too small; a fixed pixel layout that breaks at 200% zoom or reflow.
|
|
21
|
+
- **Motion & media** — animation with no reduced-motion path; media with no captions or
|
|
22
|
+
transcript.
|
|
23
|
+
|
|
24
|
+
## How to report
|
|
25
|
+
|
|
26
|
+
Report only findings anchored to a **changed line** in this change set. For each finding
|
|
27
|
+
give:
|
|
28
|
+
|
|
29
|
+
- `path:line` — the changed line it anchors to
|
|
30
|
+
- **severity** — blocker (blocks a task for an assistive-tech user) · major · minor · nit
|
|
31
|
+
- **what** — the barrier in one line
|
|
32
|
+
- **why** — who is blocked and from what: "a screen-reader user cannot tell this toggle is
|
|
33
|
+
on"; cite the WCAG success criterion when you know it
|
|
34
|
+
- **fix** — the semantic element, name, or attribute that removes the barrier
|
|
35
|
+
- **route** — `criterion` · `check` (e.g. "an `<img>` under `components/` must carry `alt`")
|
|
36
|
+
· `lens` · `none`
|
|
37
|
+
|
|
38
|
+
Prefer a real semantic element over an ARIA patch, and say so in the fix. An empty report is
|
|
39
|
+
a valid result.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# architecture lens
|
|
2
|
+
|
|
3
|
+
**Stance:** read the change for the shape it leaves behind. A change that works today but
|
|
4
|
+
puts a seam in the wrong place, or leaks a detail across one, costs every change after it.
|
|
5
|
+
|
|
6
|
+
## What to look for
|
|
7
|
+
|
|
8
|
+
- **Misplaced seams** — logic that belongs behind an interface written in the caller; a
|
|
9
|
+
boundary that exposes its internals; a module that now knows a neighbour's private shape.
|
|
10
|
+
- **Coupling the change adds** — a new import that points the wrong way (a domain reaching
|
|
11
|
+
into the UI, a core reaching into a plugin); a circular dependency; a change here that
|
|
12
|
+
forces an unrelated change there.
|
|
13
|
+
- **Shallow modules** — a wrapper that adds a layer without hiding complexity; an interface
|
|
14
|
+
as wide as its implementation; a "manager"/"util"/"helper" that names a grab-bag, not a
|
|
15
|
+
responsibility.
|
|
16
|
+
- **Leaked abstraction** — callers that must know the order to call things in, pass a flag
|
|
17
|
+
that toggles two behaviours, or handle an error type from three layers down.
|
|
18
|
+
- **Duplicated decision** — the same rule, constant, or shape now expressed in two places
|
|
19
|
+
that will drift; a copy where a call belonged.
|
|
20
|
+
- **Change amplification** — a single conceptual change the diff had to make in many files:
|
|
21
|
+
a sign the knowledge is not held in one place.
|
|
22
|
+
|
|
23
|
+
## How to report
|
|
24
|
+
|
|
25
|
+
Report only findings anchored to a **changed line** in this change set — the structure this
|
|
26
|
+
change introduced or worsened, not the whole codebase's debt. For each finding give:
|
|
27
|
+
|
|
28
|
+
- `path:line` — the changed line it anchors to
|
|
29
|
+
- **severity** — major (a seam that will be expensive to move later) · minor · nit
|
|
30
|
+
- **what** — the structural problem in one line
|
|
31
|
+
- **why** — the future change it makes harder, concretely
|
|
32
|
+
- **fix** — where the seam or knowledge should sit instead
|
|
33
|
+
- **route** — `criterion` · `check` (e.g. "nothing under `domain/` may import from `ui/`") ·
|
|
34
|
+
`lens` · `none`
|
|
35
|
+
|
|
36
|
+
Judge the change, not the pre-existing design around it — unless the change deepens an
|
|
37
|
+
existing problem, in which case say which. Prefer the smallest move that puts the seam right.
|
|
38
|
+
An empty report is a valid result.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# correctness lens
|
|
2
|
+
|
|
3
|
+
**Stance:** assume the change is subtly wrong and go looking for the input that proves it.
|
|
4
|
+
Read the diff as an adversary would — the value the author did not picture.
|
|
5
|
+
|
|
6
|
+
## What to look for
|
|
7
|
+
|
|
8
|
+
- **Boundaries** — off-by-one, empty collection, single element, the maximum, zero,
|
|
9
|
+
negative, the exact threshold a comparison turns on. `<` where `<=` was meant.
|
|
10
|
+
- **Absent values** — null / undefined / None threaded into code that assumes presence; a
|
|
11
|
+
default that silently swallows a real value; an optional unwrapped without a branch.
|
|
12
|
+
- **Error paths** — a thrown error caught and dropped; a rejected promise nobody awaits; a
|
|
13
|
+
failure that leaves state half-written; a `catch` that hides the cause.
|
|
14
|
+
- **Async** — an unawaited promise, a race between two writers, a shared mutable read
|
|
15
|
+
mid-update, iteration that mutates what it iterates.
|
|
16
|
+
- **Logic the change moved** — an inverted condition, a De Morgan slip, a `&&` that should
|
|
17
|
+
be `||`, a guard that no longer guards because the code around it moved.
|
|
18
|
+
- **Contract drift** — a caller and callee that now disagree about units, order, nullability,
|
|
19
|
+
or what an empty result means; a return type widened without every caller noticing.
|
|
20
|
+
|
|
21
|
+
## How to report
|
|
22
|
+
|
|
23
|
+
Report only findings anchored to a **changed line** — not a pre-existing bug the change did
|
|
24
|
+
not touch. For each finding give:
|
|
25
|
+
|
|
26
|
+
- `path:line` — the changed line it anchors to
|
|
27
|
+
- **severity** — blocker · major · minor · nit
|
|
28
|
+
- **what** — the defect in one line
|
|
29
|
+
- **why** — the concrete input or interleaving that triggers it, and what goes wrong
|
|
30
|
+
- **fix** — the change that closes it
|
|
31
|
+
- **route** — the artefact that stops the class recurring: `criterion` (the behaviour a
|
|
32
|
+
test should have claimed) · `check` (a cross-file invariant) · `lens` · `none`
|
|
33
|
+
|
|
34
|
+
State the triggering case concretely — "a basket with exactly one line item", not "edge
|
|
35
|
+
cases". A finding you cannot reduce to an input is a hunch; hold it or say so. An empty
|
|
36
|
+
report is a valid and common result.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<!-- speccle:lens-template — this lens is unauthored. Replace the body with your repo's own
|
|
2
|
+
conventions and delete this comment. Until you do, `review` runs it as inert and it
|
|
3
|
+
reports nothing. A refresh (`speccle update`) never overwrites this file. -->
|
|
4
|
+
|
|
5
|
+
# house-conventions lens
|
|
6
|
+
|
|
7
|
+
**Stance:** _this is your lens to write._ The shipped lenses cover what is true of good code
|
|
8
|
+
everywhere; this one covers what is true of code **in this repo** — the taste, the patterns,
|
|
9
|
+
and the mistakes only your team keeps making. It is the most valuable lens you have, and
|
|
10
|
+
Speccle cannot write it for you.
|
|
11
|
+
|
|
12
|
+
## What to look for
|
|
13
|
+
|
|
14
|
+
_Replace the prompts below with your own. Good sources: your CONTRIBUTING guide, past review
|
|
15
|
+
comments you have made more than once, the ADRs your code must honour, the bug that shipped
|
|
16
|
+
last quarter._
|
|
17
|
+
|
|
18
|
+
- The naming, layering, or error-handling pattern this codebase follows that a newcomer
|
|
19
|
+
would break.
|
|
20
|
+
- The library or idiom you have standardized on — and the one you have banned.
|
|
21
|
+
- The invariant that is not a lint rule yet but should never be violated.
|
|
22
|
+
- The class of mistake that has bitten this team before and will again.
|
|
23
|
+
|
|
24
|
+
## How to report
|
|
25
|
+
|
|
26
|
+
Report only findings anchored to a **changed line** in this change set. For each finding
|
|
27
|
+
give:
|
|
28
|
+
|
|
29
|
+
- `path:line` — the changed line it anchors to
|
|
30
|
+
- **severity** — major · minor · nit
|
|
31
|
+
- **what** — the convention broken, in one line
|
|
32
|
+
- **why** — the house rule, and why it holds here
|
|
33
|
+
- **fix** — the change that follows the convention
|
|
34
|
+
- **route** — `criterion` · `check` (many house conventions become an `oracle verify` check
|
|
35
|
+
once they are stable) · `lens` (sharpen this file) · `none`
|
|
36
|
+
|
|
37
|
+
> While this file still reads as the shipped template, report nothing.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# performance lens
|
|
2
|
+
|
|
3
|
+
**Stance:** find the cost the change adds that grows with the data. A slowdown that only
|
|
4
|
+
shows at production scale will not show in the diff — picture the input ten thousand times
|
|
5
|
+
larger.
|
|
6
|
+
|
|
7
|
+
## What to look for
|
|
8
|
+
|
|
9
|
+
- **N+1 and hidden loops** — a query, fetch, or read inside a loop that a batch or join
|
|
10
|
+
would collapse; an `await` in a loop that serializes independent work; a nested scan that
|
|
11
|
+
turns linear into quadratic.
|
|
12
|
+
- **Work that grows** — an algorithm a size class worse than it needs (a linear scan for a
|
|
13
|
+
set membership, a sort where a heap suffices); a data structure that forces it (an array
|
|
14
|
+
where a map's keys were the point).
|
|
15
|
+
- **Repeated work** — a pure computation redone every render or every call that memoization
|
|
16
|
+
or hoisting removes; a value recomputed inside a loop that is constant across it.
|
|
17
|
+
- **Unbounded growth** — a cache, list, or map with no eviction; a subscription or listener
|
|
18
|
+
added without a matching teardown; a closure that retains a large object.
|
|
19
|
+
- **Payload & I/O** — over-fetching columns or rows the caller drops; a whole file read to
|
|
20
|
+
use one line; a round-trip per item where one call would do; a missing index behind a hot
|
|
21
|
+
query.
|
|
22
|
+
- **Frontend cost** — a re-render triggered by a new object identity each pass; a large
|
|
23
|
+
synchronous computation on the main thread; a bundle-heavy import for a small need.
|
|
24
|
+
|
|
25
|
+
## How to report
|
|
26
|
+
|
|
27
|
+
Report only findings anchored to a **changed line** in this change set. For each finding
|
|
28
|
+
give:
|
|
29
|
+
|
|
30
|
+
- `path:line` — the changed line it anchors to
|
|
31
|
+
- **severity** — major (cost grows with production-scale input) · minor (fixed overhead) ·
|
|
32
|
+
nit
|
|
33
|
+
- **what** — the cost in one line
|
|
34
|
+
- **why** — how it scales: "one query per line item, so a 500-item order is 500 round-trips"
|
|
35
|
+
- **fix** — the batch, index, memo, structure, or bound that flattens it
|
|
36
|
+
- **route** — `criterion` (a performance budget worth asserting) · `check` · `lens` · `none`
|
|
37
|
+
|
|
38
|
+
Reach for scaling arguments, not micro-optimizations — a quadratic loop matters, a saved
|
|
39
|
+
allocation rarely does. Do not trade clarity for speed on a cold path; say when a cost is
|
|
40
|
+
fine. An empty report is a valid result.
|
package/lenses/risk.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# risk lens
|
|
2
|
+
|
|
3
|
+
**Stance:** the deterministic risk score is a floor, not a ceiling. Your one job is to catch
|
|
4
|
+
the consequential change the signals could not see — and, when you find it, to **raise**
|
|
5
|
+
supervision. You may escalate. You may never lower.
|
|
6
|
+
|
|
7
|
+
This lens does not produce findings. It produces one escalation decision over the whole
|
|
8
|
+
change set, and it runs before the panel, because it decides whether `review` may fix at all.
|
|
9
|
+
|
|
10
|
+
## What to look for — subtlety no deterministic signal catches
|
|
11
|
+
|
|
12
|
+
- **Blast radius** — a small diff on a hot path: an auth check, a payment or money
|
|
13
|
+
calculation, a permissions boundary, a data-deletion or migration, a public API contract
|
|
14
|
+
many callers depend on.
|
|
15
|
+
- **Irreversibility** — a change that is hard or impossible to undo once it ships: a
|
|
16
|
+
destructive migration, a data backfill, a webhook or email that fires, a format written to
|
|
17
|
+
storage that must be read back forever.
|
|
18
|
+
- **Silent semantic shift** — behaviour that changed while its shape did not: a default
|
|
19
|
+
flipped, a rounding or timezone rule altered, an ordering guarantee dropped, a currency or
|
|
20
|
+
unit reinterpreted.
|
|
21
|
+
- **Concurrency & state** — a new shared write, a lock removed, an assumption of
|
|
22
|
+
single-threaded execution the change quietly breaks.
|
|
23
|
+
- **Security surface** — a boundary newly reachable, a trust assumption relaxed, a secret's
|
|
24
|
+
handling changed — even where no single line is itself the bug.
|
|
25
|
+
|
|
26
|
+
## How to report
|
|
27
|
+
|
|
28
|
+
Emit one decision:
|
|
29
|
+
|
|
30
|
+
- **escalate** — `true` or `false`
|
|
31
|
+
- **requireHuman** — `true` forces human review regardless of the floor; `false` leaves the
|
|
32
|
+
deterministic verdict standing
|
|
33
|
+
- **raiseFloorBy** — a non-negative number added to the score, or `0`. Never negative.
|
|
34
|
+
- **reasons** — for every escalation, the specific change and the consequence that justifies
|
|
35
|
+
it, anchored to `path:line`. An escalation with no legible reason is not auditable and does
|
|
36
|
+
not count.
|
|
37
|
+
|
|
38
|
+
Rules that bind you:
|
|
39
|
+
|
|
40
|
+
- You may only **add** supervision. If you believe the floor is too high, say so in reasons
|
|
41
|
+
for a human to weigh — do not lower it. Lowering supervision is never automatic.
|
|
42
|
+
- Silence is not safety. If nothing here is consequential, return `escalate: false` plainly;
|
|
43
|
+
do not invent risk to look thorough.
|
|
44
|
+
- When in doubt on a genuinely consequential change, escalate. A needless human review costs
|
|
45
|
+
minutes; an unsupervised bad change on a hot path costs more.
|