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/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 bundled copy and the anchor
11
- * re-stamped, while the strength stack and global binary are reported, never rewritten the
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 materialized = await materializeSkills(root);
23
- await initConfig(root, version); // re-stamp the anchor; the repo facts stay kept
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: materialized.dir,
35
- skills: materialized.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.